feat(client): 完成40250客户端核心功能1:1对齐与桥梁高度采样修复

- 桥梁与静态物体高度采样修复:
  - 严格对齐 40250 CMapOutdoor::GetHeight 与 CAttributeInstance::GetHeight
  - 解析 .mdatr 中的 AttributeHeight 网格,使用 is_in_triangle_2d 准确计算桥面多边形平面方程
  - sample_height 查询邻近区块并返回 fMAX(fObjectHeight, fTerrainHeight),彻底解决走上桥面穿透掉入水底/河床的问题
  - 新增 test_bridge_height_parity.gd 自动化对拍测试
- 40250 怪物击杀经验动效:
  - 1:1 实现 FLY_EXP(0) / FLY_HP / FLY_SP 粒子轨迹与爆炸吸附
- 40250 客户端全系统功能对齐(Batches 1-31):
  - 包含公会、交易、骑乘、变身、钓鱼、采矿、商城、信件、结婚、地牢等 134 套对拍系统与自动化回归测试
- 文档沉淀:
  - 新增 docs/CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
shen
2026-09-19 08:51:25 -07:00
parent 1db9e9a129
commit 66d217b313
650 changed files with 53046 additions and 1586 deletions
+199 -57
View File
@@ -53,8 +53,16 @@ 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 _race_src_name: Dictionary = {}
static var _npclist_loaded := false
# race -> { "dir": String, "mesh_stem": String, "gr2": String, "motions": Dictionary }
# 同一种怪/NPC 只解析一次目录、主网格与 motlist.txt,后续实例直接复用,避免数万次同步磁盘 IO。
static var _race_spec_cache: Dictionary = {}
static func clear_cache() -> void:
_race_spec_cache.clear()
static func _load_npclist(assets_root: String) -> void:
if _npclist_loaded:
return
@@ -77,43 +85,37 @@ static func _load_npclist(assets_root: String) -> void:
var code := String(parts[1]).strip_edges()
if vnum > 0 and code != "":
_npclist[vnum] = code
elif vnum == 0 and parts.size() >= 3:
var src := String(parts[2]).strip_edges()
if code != "" and src != "":
_race_src_name[code] = src
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
_mesh_stem = mesh_stem
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
var gr2 := ""
var cached: Variant = _race_spec_cache.get(race, null)
if cached is Dictionary:
_dir = String(cached.get("dir", ""))
_mesh_stem = String(cached.get("mesh_stem", ""))
gr2 = String(cached.get("gr2", ""))
_motions = (cached.get("motions", {}) as Dictionary).duplicate()
else:
_load_npclist(assets_root)
var spec := _find_model_spec(assets_root, proto, race)
if spec.is_empty():
return false
_dir = String(spec.get("dir", ""))
_mesh_stem = String(spec.get("mesh_stem", ""))
gr2 = String(spec.get("gr2", ""))
_load_motlist()
_race_spec_cache[race] = {
"dir": _dir,
"mesh_stem": _mesh_stem,
"gr2": gr2,
"motions": _motions.duplicate()
}
if pump.is_valid(): pump.call()
model = ClassDB.instantiate("Metin2Model")
model.name = "Metin2Model"
@@ -126,7 +128,6 @@ func build(assets_root: String, proto: Node, race: int, pump := Callable()) -> b
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"))
@@ -176,8 +177,13 @@ func _loop_speed(state: String) -> float:
## 走 / 跑动作的根运动速度(cm/smovSpd 100):x = walky = run。0 = 取不到。
## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。
func get_move_motion_speeds() -> Vector2:
return Vector2(MsaMotion.move_speed(String(resolve_motion("walk").path)),
MsaMotion.move_speed(String(resolve_motion("run").path)))
var w := MsaMotion.move_speed(String(resolve_motion("walk").path))
var r := MsaMotion.move_speed(String(resolve_motion("run").path))
if w <= 0.0 and r > 0.0:
w = r
elif r <= 0.0 and w > 0.0:
r = w
return Vector2(w, r)
# CGraphicThingInstance::InsertDelay(fStiffenTime)
func insert_delay(d: float) -> void:
@@ -193,6 +199,10 @@ func hit_greate(scalar: float, stunned: bool) -> void:
func hit_stone(stunned: bool) -> void:
_hit.stone(stunned)
# 40250 CActorInstance::__Shake(100)
func shake() -> void:
_hit.shake()
func is_in_hit_reaction() -> bool:
return _hit.active
@@ -241,6 +251,7 @@ func _bind(state: String, path: String, loop: bool, speed_ratio: float) -> void:
if not loop and String(anim.get("anim_path")) == path:
anim.set("anim_path", "") # 同一段一次性动作再触发:先清,否则原生播放器视为无变化
anim.set("anim_path", path)
anim.set("playing", true)
anim.set("time_scale", _hit.scale_for(speed_ratio))
_refresh_sound_script(path)
motion_bound.emit(state)
@@ -351,7 +362,10 @@ func _load_motlist() -> void:
_motions[motion] = abs
func _pick_base_gr2() -> String:
var da := DirAccess.open(_dir)
return _pick_base_gr2_in(_dir)
func _pick_base_gr2_in(d: String) -> String:
var da := DirAccess.open(d)
if da == null:
return ""
for fn in da.get_files():
@@ -363,30 +377,158 @@ func _pick_base_gr2() -> String:
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 d.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
# MSM BaseModelFileName 解析:如 campfire.msm 指向 zone/b/obj/general_obj_campfire.gr2
func _resolve_msm_base_model(assets_root: String, msm_path: String) -> String:
if not FileAccess.file_exists(msm_path):
return ""
var f := FileAccess.open(msm_path, FileAccess.READ)
if f == null:
return ""
var content := f.get_as_text()
var regex := RegEx.create_from_string("(?i)BaseModelFileName\\s+\"([^\"]+)\"")
if regex == null:
return ""
var match := regex.search(content)
if match == null:
return ""
var raw_path := match.get_string(1).replace("\\", "/").strip_edges()
var lower := raw_path.to_lower()
var rel := ""
for prefix in ["d:/ymir work/", "c:/ymir work/", "ymir work/", "/ymir work/"]:
if lower.begins_with(prefix):
rel = raw_path.substr(prefix.length())
break
if rel == "":
rel = raw_path
for candidate in [
assets_root.path_join("ymir work").path_join(rel),
assets_root.path_join(rel),
assets_root.path_join("Zone/ymir work").path_join(rel),
assets_root.path_join("NPC/ymir work").path_join(rel),
assets_root.path_join("Monster/ymir work").path_join(rel),
]:
if FileAccess.file_exists(candidate):
return candidate
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
var c := assets_root.path_join(sub).path_join("ymir work").path_join(rel)
if FileAccess.file_exists(c):
return c
c = assets_root.path_join(sub).path_join(rel)
if FileAccess.file_exists(c):
return c
return ""
func _find_model_spec(assets_root: String, proto: Node, race: int) -> Dictionary:
var codes: Array[String] = []
var primary: String = _npclist.get(race, "")
if primary != "":
codes.append(primary)
for c: String in _folder_candidates(proto, race):
if not codes.has(c):
codes.append(c)
for code in codes:
var spec := _resolve_code_spec(assets_root, code)
if not spec.is_empty():
return spec
return {}
func _resolve_code_spec(assets_root: String, code: String) -> Dictionary:
if code.begins_with("#"):
# 40250 LOAD_LOCAL_RESOURCE:直接相对路径(如 #season1/npc/chagirap/
var rel := code.trim_prefix("#").strip_edges().trim_prefix("/").trim_suffix("/")
var stem := rel.get_file()
var candidate_dirs: Array[String] = [
assets_root.path_join(rel),
assets_root.path_join(rel.replace("chenjo", "cheonjo")),
]
var sub := rel.split("/")[0]
candidate_dirs.append(assets_root.path_join(sub).path_join(rel))
candidate_dirs.append(assets_root.path_join(sub).path_join(rel.replace("chenjo", "cheonjo")))
var da := DirAccess.open(assets_root)
if da:
for d in da.get_directories():
candidate_dirs.append(assets_root.path_join(d).path_join("ymir work").path_join(rel.get_slice("/", 1)).path_join(stem))
candidate_dirs.append(assets_root.path_join(d).path_join(rel))
for d in candidate_dirs:
var spec := _check_dir_for_model(assets_root, d, stem)
if not spec.is_empty():
return spec
return {}
var mesh_stem := code
var folders: Array[String] = []
# npclist vnum=0 别名(如 red_wild_boar -> wild_boar
if _race_src_name.has(code):
folders.append(String(_race_src_name[code]))
folders.append(code)
var segs := code.split("_")
for cut in range(segs.size(), 0, -1):
var sub_folder := "_".join(segs.slice(0, cut))
if not folders.has(sub_folder):
folders.append(sub_folder)
for folder in folders:
var candidate_dirs: Array[String] = [
assets_root.path_join("ymir work/monster/%s" % folder),
assets_root.path_join("ymir work/monster2/%s" % folder),
assets_root.path_join("ymir work/npc/%s" % folder),
assets_root.path_join("ymir work/npc2/%s" % folder),
assets_root.path_join("ymir work/guild/%s" % folder),
assets_root.path_join("Monster/ymir work/monster/%s" % folder),
assets_root.path_join("Monster/ymir work/monster2/%s" % folder),
assets_root.path_join("monster2/ymir work/monster2/%s" % folder),
assets_root.path_join("NPC/ymir work/npc/%s" % folder),
assets_root.path_join("NPC/ymir work/npc2/%s" % folder),
assets_root.path_join("npc2/ymir work/npc2/%s" % folder),
assets_root.path_join("guild/ymir work/guild/%s" % folder),
]
var da := DirAccess.open(assets_root)
if da:
for sub_dir in da.get_directories():
for rel in [
"ymir work/monster/%s" % folder,
"ymir work/monster2/%s" % folder,
"ymir work/npc/%s" % folder,
"ymir work/npc2/%s" % folder,
"ymir work/guild/%s" % folder,
]:
candidate_dirs.append(assets_root.path_join(sub_dir).path_join(rel))
for d in candidate_dirs:
var spec := _check_dir_for_model(assets_root, d, mesh_stem)
if not spec.is_empty():
return spec
return {}
func _check_dir_for_model(assets_root: String, d: String, mesh_stem: String) -> Dictionary:
if not DirAccess.dir_exists_absolute(d):
return {}
# 1. 对应代号的网格
var gr2 := d.path_join(mesh_stem + ".gr2")
if FileAccess.file_exists(gr2):
return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2}
# 2. 目录同名主网格
gr2 = d.path_join(d.get_file() + ".gr2")
if FileAccess.file_exists(gr2):
return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2}
# 3. 目录里第一个非动作 .gr2
gr2 = _pick_base_gr2_in(d)
if gr2 != "":
return {"dir": d, "mesh_stem": mesh_stem, "gr2": gr2}
# 4. 检查 .msm 的 BaseModelFileName(如 campfire.msm, flag_red.msm
for msm_name in [mesh_stem + ".msm", d.get_file() + ".msm", "shape.msm"]:
var msm_p := d.path_join(msm_name)
if FileAccess.file_exists(msm_p):
var base_gr2 := _resolve_msm_base_model(assets_root, msm_p)
if base_gr2 != "":
return {"dir": d, "mesh_stem": mesh_stem, "gr2": base_gr2}
return {}
func _find_dir(assets_root: String, folder: String) -> String:
var spec := _resolve_code_spec(assets_root, folder)
return String(spec.get("dir", ""))