# mob_view_test —— 怪 / NPC 真模型(MobView)headless 自检。 # godot --headless --path project --script mob_view_test.gd # 需要 Metin2Model 扩展 + Metin2 资产(AssetRoot.path());缺就跳过对应检查。 extends SceneTree const MobView = preload("res://ui/mob_view.gd") const Audio = preload("res://audio.gd") const MsaMotion = preload("res://ui/msa_motion.gd") var _fail := 0 var _resolution_done := false var _move_speed_done := false func _ck(c: bool, m: String) -> void: if not c: _fail += 1 printerr("FAIL: " + m) func _init() -> void: _run() if _fail == 0: print("PASS: mob_view_test") quit(0) else: printerr("%d check(s) failed" % _fail) quit(1) class FakeAnim: extends Node var anim_path := "" var loop := false var time_scale := 1.0 # 纯解析:不需要扩展和资产。旧客户端 CRaceData::GetMotionKey 找不到动作时 # SetLoopMotion/InterceptMotion 直接返回、保留当前动作;不能静默换成 WAIT。 func _check_motion_resolution() -> void: var mv: Node3D = MobView.new() mv._dir = "res://__no_such_mob_dir__" mv._motions = {"WAIT": "wait.msa", "NORMAL_ATTACK": "attack.msa", "FRONT_DEAD": "dead.msa"} var dead: Dictionary = mv.resolve_motion("dead") _ck(dead.get("requested_state") == "dead" and dead.get("motion") == "FRONT_DEAD" \ and dead.get("path") == "dead.msa" and dead.get("fallback_reason") == "", "dead -> FRONT_DEAD 无回退") var run: Dictionary = mv.resolve_motion("run") _ck(run.get("path") == "" and run.get("motion") == "" \ and run.get("fallback_reason") == "reference_keep_current_motion", "缺 RUN/WALK -> 保留当前动作(%s)" % run) _ck(mv.get_motion_path("run") == "", "缺 RUN/WALK 时 get_motion_path 不返回 WAIT") mv._motions = {"WAIT": "wait.msa"} _ck(mv.get_motion_path("dead") == "", "缺死亡动作时不能解析成 WAIT") mv._motions = {"WAIT": "wait.msa", "WALK": "walk.msa"} var alias: Dictionary = mv.resolve_motion("run") _ck(alias.get("path") == "walk.msa" and alias.get("fallback_reason") == "client_alias:WALK", "run -> WALK 标注别名回退") mv._motions = {"WAIT": "wait.msa"} var anim := FakeAnim.new() mv.anim = anim mv.set_anim_state("wait") mv.set_anim_state("run") _ck(anim.anim_path == "wait.msa" and anim.loop, "缺 run 时保留当前 WAIT 播放") _ck(mv.last_motion_resolution().get("fallback_reason") == "reference_keep_current_motion", "记录最后一次解析的回退原因") mv.anim = null anim.free() mv.free() _resolution_done = true # CActorInstance::Move:走 / 跑按 m_fMovSpd = movSpd/100 播放;根运动速度 # = |.msa Accumulation| / MotionDuration(wolf 03.msa:255.45 / 0.6 = 425.75 cm/s)。 func _write_msa(path: String, duration: float, ay: float) -> void: var f := FileAccess.open(path, FileAccess.WRITE) f.store_string("ScriptType MotionData\n\nMotionFileName \"x.gr2\"\n" + "MotionDuration %f\nAccumulation 0.00\t%.2f\t0.00\n" % [duration, ay]) f.close() func _check_move_speed() -> void: var walk_p := "user://__mob_view_test_walk.msa" var run_p := "user://__mob_view_test_run.msa" _write_msa(walk_p, 0.866667, -75.81) _write_msa(run_p, 0.6, -255.45) _ck(absf(MsaMotion.move_speed(run_p) - 425.75) < 0.01, "run msa 速度 425.75(%f)" % MsaMotion.move_speed(run_p)) _ck(absf(MsaMotion.move_speed(walk_p) - 87.47) < 0.01, "walk msa 速度 87.47(%f)" % MsaMotion.move_speed(walk_p)) _ck(MsaMotion.move_speed("user://__no_such.msa") == 0.0, "缺文件 -> 0") var mv: Node3D = MobView.new() mv._dir = "res://__no_such_mob_dir__" mv._motions = {"WAIT": "wait.msa", "WALK": walk_p, "RUN": run_p} var sp: Vector2 = mv.get_move_motion_speeds() _ck(absf(sp.x - 87.47) < 0.01 and absf(sp.y - 425.75) < 0.01, "get_move_motion_speeds(%s)" % sp) var anim := FakeAnim.new() mv.anim = anim mv.set_move_speed(200) mv.set_anim_state("wait") _ck(anim.time_scale == 1.0, "wait 不受移动速度影响") mv.set_anim_state("run") _ck(anim.anim_path == run_p and anim.time_scale == 2.0, "run 按 movSpd/100 播放(%f)" % anim.time_scale) mv.set_move_speed(150) _ck(anim.time_scale == 1.5, "跑动中改速立即生效(%f)" % anim.time_scale) mv.set_move_speed(1200) _ck(anim.time_scale == 1.0, ">1100 保持原速(%f)" % anim.time_scale) mv.anim = null anim.free() mv.free() DirAccess.remove_absolute(ProjectSettings.globalize_path(walk_p)) DirAccess.remove_absolute(ProjectSettings.globalize_path(run_p)) _move_speed_done = true func _run() -> void: _check_motion_resolution() _check_move_speed() _ck(_move_speed_done, "移动速度断言全部执行") # 脚本运行时错误只会中止函数、不计失败;用哨兵保证整段断言都执行过。 _ck(_resolution_done, "动作解析断言全部执行") if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2Proto"): print(" (skip: 扩展未注册)") return var assets := AssetRoot.path() if not DirAccess.dir_exists_absolute(assets.path_join("Monster")): print(" (skip: 无 Monster 资产)") return var proto: Object = ClassDB.instantiate("Metin2Proto") get_root().add_child(proto) var audio: Node = Audio.new() get_root().add_child(audio) audio.setup(assets) var mp := assets.path_join("locale/locale/en/mob_proto") if not FileAccess.file_exists(mp) or not proto.call("load_mob_proto", mp): print(" (skip: 无 mob_proto)") return # race 102 = Wolf -> assets/*/ymir work/monster/wolf/ var mv: Node3D = MobView.new() get_root().add_child(mv) var ok: bool = mv.build(assets, proto, 102) _ck(ok, "MobView.build(102 = Wolf) 成功") if not ok: return mv.set_audio(audio) _ck(mv._dir.get_file() == "wolf", "解到 wolf 目录(%s)" % mv._dir) _ck(mv.model != null and mv.model.get_class() == "Metin2Model", "Metin2Model 建好") _ck(mv._motions.size() >= 3, "motlist.txt 解出 %d 个动作" % mv._motions.size()) _ck(mv._motions.has("WAIT"), "有 WAIT 动作") mv.set_display_name("Wolf") _ck(mv.get_node_or_null("Label3D") != null and (mv.get_node("Label3D") as Label3D).text == "Wolf", "set_display_name -> nameplate") # 状态切换:run -> RUN / attack -> NORMAL_ATTACK(存在才断言路径变化) mv.set_anim_state("run") var s1: String = mv._state mv.set_anim_state("attack") _ck(mv._state == "attack" and s1 == "run", "set_anim_state 切换记录") _ck(mv._sound_instances.size() > 0, "MobView attack motion loads paired .mss instances") # 未知 race -> build 失败(返回 null 用占位) var mv2: Node3D = MobView.new() get_root().add_child(mv2) _ck(not mv2.build(assets, proto, 999999), "未知 race -> build 失败") # race 20009 = Old Man(NPC)——若有目录就该建成 var mv3: Node3D = MobView.new() get_root().add_child(mv3) var npc_ok: bool = mv3.build(assets, proto, 20009) print(" NPC 20009 (Old Man) build = %s (%s)" % [npc_ok, mv3._dir])