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:
+199
-57
@@ -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/s,movSpd 100):x = walk,y = 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", ""))
|
||||
|
||||
Reference in New Issue
Block a user