Files
mtgodot-poc/project/ui/mob_view.gd
T
shenleiandClaude Opus 5 f39a55fdd5 feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档
- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-11 22:06:48 +09:00

282 lines
9.7 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MobView (真机字节校准收尾) —— 怪 / NPC 的真模型:Metin2Model + Metin2AnimPlayer。
#
# var mv := preload("res://ui/mob_view.gd").new()
# if mv.build(assets_root, proto, race): # race = mob_proto vnumGC_CHARACTER_ADD.race
# ...
# mv.set_anim_state("run") # wait/run/attack/damage/dead/skill/emotion
#
# 目录:<assets>/*/ymir work/{monster,npc}/<mob_proto.name>/
# <name>.gr2 = 基础网格,<name>.dds = 贴图,motlist.txt = 动作索引表:
# GENERAL WAIT 00.msa 65 (组 动作名 文件 权重)
extends Node3D
# net_world 的状态名 -> motlist.txt 的动作名(按优先级取第一个存在的)
const STATE_MOTIONS := {
"wait": ["WAIT", "WAIT1"],
"walk": ["WALK", "RUN"],
"run": ["RUN", "WALK"],
"attack": ["NORMAL_ATTACK", "NORMAL_ATTACK1", "SPECIAL_1"],
"combo": ["NORMAL_ATTACK1", "NORMAL_ATTACK"],
"skill": ["SPECIAL_1", "SKILL", "NORMAL_ATTACK"],
"damage": ["FRONT_DAMAGE", "DAMAGE", "BACK_DAMAGE"],
"dead": ["FRONT_DEAD", "DEAD", "BACK_DEAD"],
"emotion": ["WAIT"],
}
var model: Node # Metin2Model
var anim: Node # Metin2AnimPlayer
var _dir := ""
var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径
var _state := ""
var _last_resolution := {}
var _audio: Node
var _sound_instances: Array = []
var _sound_frame := -1
# root/npclist.txt: vnum -> 模型代号(bear_brown / tiger_big / stray_dog…)。
# 翻译版 mob_proto 把 szName 也本地化了("Brown Bear"),解不出目录,必须靠这张表。
static var _npclist: Dictionary = {}
static var _npclist_loaded := false
static func _load_npclist(assets_root: String) -> void:
if _npclist_loaded:
return
_npclist_loaded = true
for rel in ["root/npclist.txt", "npclist.txt", "locale/npclist.txt"]:
var p := assets_root.path_join(rel)
if not FileAccess.file_exists(p):
continue
var f := FileAccess.open(p, FileAccess.READ)
while f and not f.eof_reached():
var line := f.get_line().strip_edges()
if line == "":
continue
var parts := line.split("\t", false)
if parts.size() < 2:
parts = line.split(" ", false)
if parts.size() < 2:
continue
var vnum := int(parts[0])
var code := String(parts[1]).strip_edges()
if vnum > 0 and code != "":
_npclist[vnum] = code
break
func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> bool:
if not ClassDB.class_exists("Metin2Model") or race < 1:
return false
_load_npclist(assets_root)
# 候选代号:npclist 优先,proto 名兜底
var stems: Array[String] = []
var code: String = _npclist.get(race, "")
if code != "":
stems.append(code)
for c: String in _folder_candidates(proto, race):
if not stems.has(c):
stems.append(c)
# 逐个代号:目录名从完整代号逐段回退(bear_brown -> bear),网格名保留完整代号
var mesh_stem := ""
for stem in stems:
var segs := stem.split("_")
for cut in range(segs.size(), 0, -1):
var folder := "_".join(segs.slice(0, cut))
var d := _find_dir(assets_root, folder)
if d != "":
_dir = d
mesh_stem = stem
break
if _dir != "":
break
if _dir == "":
return false
var gr2 := _dir.path_join(mesh_stem + ".gr2")
if not FileAccess.file_exists(gr2):
gr2 = _dir.path_join(_dir.get_file() + ".gr2") # 目录同名主网格
if not FileAccess.file_exists(gr2):
gr2 = _pick_base_gr2() # 目录里第一个非动作 .gr2
if gr2 == "":
return false
if pump.is_valid(): pump.call()
model = ClassDB.instantiate("Metin2Model")
model.name = "Metin2Model"
# NPC/monster GR2 uses the same source winding as PCs. Without conversion,
# back-face culling exposes interiors and hides the outward-facing surfaces.
model.set("flip_winding", true)
model.set("texture_dir", _dir)
model.set("gr2_path", gr2) # 重:解 gr2 + 建网格
if pump.is_valid(): pump.call()
add_child(model)
model.ready.connect(_ground_model)
_ground_model()
_load_motlist()
if ClassDB.class_exists("Metin2AnimPlayer"):
anim = ClassDB.instantiate("Metin2AnimPlayer")
anim.set("model_path", NodePath("../Metin2Model"))
anim.set("blend_time", 0.15)
add_child(anim)
set_anim_state("wait")
CharShadow.attach(self, 1.6) # 怪脚印大一点
return true
func set_audio(audio_node: Node) -> void:
_audio = audio_node
_refresh_sound_script(String(anim.get("anim_path")) if anim else "")
func set_anim_state(s: String) -> void:
if s == _state or anim == null:
return
_state = s
_last_resolution = resolve_motion(s)
var msa := String(_last_resolution.path)
if msa == "":
# 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。
return
anim.set("loop", s in ["wait", "walk", "run"])
anim.set("anim_path", msa)
_refresh_sound_script(msa)
# Read-only motion resolver used by the forest acceptance harness. Returning
# the selected .msa before playback lets the test distinguish a real action
# from a silent "keep the previous animation" fallback.
func get_motion_path(state: String) -> String:
return String(resolve_motion(state).path)
## requested_state / motion / path / fallback_reason。fallback_reason:
## "" 首选动作名命中
## "client_alias:<NAME>" 本客户端的同类动作别名(如 run -> WALK
## "motlist_missing:<file>" motlist 未列出,按目录里的 <name>.msa 兜底
## "reference_keep_current_motion" 资源没有任何候选动作;与旧客户端一致保留当前动作
func resolve_motion(state: String) -> Dictionary:
var names: Array = STATE_MOTIONS.get(state, [])
var out := {"requested_state": state, "motion": "", "path": "", "fallback_reason": ""}
for mo: String in names:
if _motions.has(mo):
out.motion = mo
out.path = String(_motions[mo])
if mo != String(names[0]):
out.fallback_reason = "client_alias:" + mo
return out
for mo: String in names:
var p := _dir.path_join(mo.to_lower() + ".msa")
if FileAccess.file_exists(p):
out.motion = mo
out.path = p
out.fallback_reason = "motlist_missing:" + p.get_file()
return out
out.fallback_reason = "reference_keep_current_motion"
return out
func last_motion_resolution() -> Dictionary:
return _last_resolution.duplicate()
func _process(_delta: float) -> void:
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
or not bool(anim.get("playing")):
return
var current_time: float = anim.call("get_time")
var frame := int(floor(current_time * 60.0))
if frame == _sound_frame:
return
_sound_frame = frame
_audio.update_sound_instances_3d(global_position.x, global_position.y, global_position.z,
frame, _sound_instances, true)
func _refresh_sound_script(motion_path: String) -> void:
_sound_instances = []
_sound_frame = -1
if _audio == null or motion_path == "" or not motion_path.to_lower().ends_with(".msa"):
return
if _audio.has_method("load_mss_for_motion"):
_sound_instances = _audio.load_mss_for_motion(motion_path)
# 供 net_world._on_info 用:真模型没有统一的名字节点时补一个。
func set_display_name(n: String) -> void:
var tag := get_node_or_null("Label3D") as Label3D
if tag == null:
tag = Label3D.new()
tag.name = "Label3D"
tag.position.y = 2.1
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true
tag.pixel_size = 0.006
add_child(tag)
tag.text = n
func _ground_model() -> void:
if model and model.has_method("get_ground_offset"):
model.position.y = float(model.call("get_ground_offset"))
# --- 内部 -------------------------------------------------------------------
func _folder_candidates(proto: Node, race: int) -> Array:
if proto == null or not proto.has_method("mob"):
return []
var m: Dictionary = proto.mob(race)
if m.is_empty():
return []
var out: Array = []
var raws: Array[String] = [String(m.get("name", "")), String(m.get("locale_name", ""))]
for raw in raws:
var s: String = raw.strip_edges().to_lower()
if s == "":
continue
for c in [s.replace(" ", "_"), s.replace(" ", ""), s.replace(" ", "-")]:
if c != "" and not out.has(c):
out.append(c)
return out
func _load_motlist() -> void:
var ml := _dir.path_join("motlist.txt")
if not FileAccess.file_exists(ml):
return
var f := FileAccess.open(ml, FileAccess.READ)
while f and not f.eof_reached():
var parts := f.get_line().strip_edges().split(" ", false)
if parts.size() >= 3:
var motion := String(parts[1])
var file := String(parts[2])
var abs := _dir.path_join(file)
if FileAccess.file_exists(abs):
_motions[motion] = abs
func _pick_base_gr2() -> String:
var da := DirAccess.open(_dir)
if da == null:
return ""
for fn in da.get_files():
if not fn.ends_with(".gr2"):
continue
if fn.contains("_lod_"):
continue
# 动作文件通常是纯数字命名(00.gr2 / 20_1.gr2
var stem := fn.get_basename()
if stem.is_valid_int() or (stem.contains("_") and stem.split("_")[0].is_valid_int()):
continue
return _dir.path_join(fn)
return ""
func _find_dir(assets_root: String, folder: String) -> String:
# <assets>/*/ymir work/{monster,npc}/<folder>
var rels := [
"ymir work/monster/%s" % folder,
"ymir work/monster2/%s" % folder,
"ymir work/npc/%s" % folder,
"ymir work/npc2/%s" % folder,
"Monster/ymir work/monster/%s" % folder,
"Monster/ymir work/monster2/%s" % folder,
"NPC/ymir work/npc/%s" % folder,
"NPC/ymir work/npc2/%s" % folder,
]
for rel: String in rels:
var d := assets_root.path_join(rel)
if DirAccess.dir_exists_absolute(d):
return d
var da := DirAccess.open(assets_root)
if da:
for sub in da.get_directories():
for rel: String in rels:
var c := assets_root.path_join(sub).path_join(rel)
if DirAccess.dir_exists_absolute(c):
return c
return ""