Files
mtgodot-poc/project/mob_view_test.gd
T
shenleiandClaude Opus 5 232d0461be port 2D step 2: 40250 item_proto/mob_proto through CLZO and pack://locale/<lang>/
proto.cpp reads the 40250 MIPX/MMPT tables with the ported CLZO/TEA (156-byte
TItemTable, 255-byte SMobTable), replacing the m2dev XChaCha20 236/335 reader.
Names follow CItemData::GetName / GetMonsterName (szLocaleName, cp1252).
GDScript callers load AssetRoot.locale_file() = pack://locale/en/<name>, which
makes the EterPack chain runtime-reachable; port-map entries for the functions
a coverage run hit move to PORTED/ADAPTED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:34:08 +09:00

216 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.
# mob_view_test —— 怪 / NPC 真模型(MobViewheadless 自检。
# 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") == "" and alias.get("fallback_reason") == "reference_keep_current_motion",
"缺 RUN 时不能把 WALK 当作 RUN")
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| / MotionDurationwolf 03.msa255.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 _check_motlist_registry() -> void:
var root := ProjectSettings.globalize_path("user://mob_motion_registry_test")
DirAccess.make_dir_recursive_absolute(root)
var body := "GENERAL WAIT wait.msa 60\n" + \
"GENERAL WAIT1 wait1.msa 20\n" + \
"GENERAL WAIT3 wait3.msa 20\n" + \
"GENERAL WALK walk.msa 100\n" + \
"GENERAL COMBO_ATTACK combo1.msa 100\n" + \
"GENERAL COMBO_ATTACK1 combo2.msa 100\n" + \
"GENERAL NORMAL_ATTACK normal.msa 100\n" + \
"GENERAL FRONT_DAMAGE front.msa 40\n" + \
"GENERAL DAMAGE unknown.msa 100\n" + \
"GENERAL FRONT_DAMAGE2 front2.msa 37\n"
for file in ["wait.msa", "wait1.msa", "wait3.msa", "walk.msa", "combo1.msa",
"combo2.msa", "normal.msa", "front.msa", "unknown.msa", "front2.msa"]:
var f := FileAccess.open(root.path_join(file), FileAccess.WRITE)
f.store_string("")
f.close()
var motlist := root.path_join("motlist_range.txt")
var mf := FileAccess.open(motlist, FileAccess.WRITE)
mf.store_string(body)
mf.close()
var msm := FileAccess.open(root.path_join("range.msm"), FileAccess.WRITE)
msm.store_string("MotionListFileName \"motlist_range.txt\"\n")
msm.close()
var mv: Node3D = MobView.new()
mv._dir = root
var selected_list: String = mv._motion_list_for_dir(root, "range")
_ck(selected_list == motlist, "MSM MotionListFileName 优先于固定 motlist.txt")
mv._load_motlist(selected_list)
_ck(mv._motlist_loaded and mv._motion_variants.has(1), "加载 NPC motlist 注册表")
var waits: Array = mv._motion_variants[1]
_ck(waits.size() == 3 and int(waits[0].weight) == 60 and int(waits[1].weight) == 20 and
int(waits[2].weight) == 20, "WAIT/WAIT1/WAIT3 归并到同一索引并保留权重")
_ck(not mv._motion_variants.has(33), "未注册 SPECIAL 不凭空生成技能动作")
_ck(not mv._motion_variants.has(0) and not mv._motions.has("DAMAGE"),
"未知 DAMAGE 不注册到动作表")
_ck(mv._motion_variants.has(14) and mv._motion_variants[14].size() == 1 and
mv._motion_variants.has(15) and mv._motion_variants[15].size() == 1,
"COMBO_ATTACK 与 COMBO_ATTACK1 使用不同动作索引")
var wait: Dictionary = mv.resolve_motion("wait")
_ck(String(wait.path) in [root.path_join("wait.msa"), root.path_join("wait1.msa"), root.path_join("wait3.msa")],
"WAIT 从注册 vector 中按权重选择")
var run: Dictionary = mv.resolve_motion("run")
_ck(run.path == "" and run.fallback_reason == "reference_keep_current_motion",
"缺 RUN 注册时保持当前动作")
_ck(mv._motion_variants.has(25) and mv._motion_variants[25][0].path == root.path_join("front2.msa") and
int(mv._motion_variants[25][0].weight) == 37,
"缺 SPAWN 时复用最后一条 DAMAGE 资源及解析出的权重")
mv.free()
for file in ["range.msm", "motlist_range.txt", "wait.msa", "wait1.msa", "wait3.msa", "walk.msa",
"combo1.msa", "combo2.msa", "normal.msa", "front.msa", "unknown.msa", "front2.msa"]:
DirAccess.remove_absolute(root.path_join(file))
DirAccess.remove_absolute(root)
func _run() -> void:
_check_motion_resolution()
_check_move_speed()
_check_motlist_registry()
_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 := AssetRoot.locale_file("mob_proto")
if 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 ManNPC)——若有目录就该建成
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])