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
+175 -11
View File
@@ -5,8 +5,8 @@
# game_scene.set_player_model(pv)
# pv.set_anim_state("run") # wait/walk/run/attack/dead
#
# 转发 weapon_gr2 / gr2_path / hair_gr2 / hair_skin 的 set 给内部 Metin2Model
# 所以 equip_model.gd 可以直接 `player.set("weapon_gr2", path)`
# 转发 weapon_gr2 / gr2_path / hair_gr2 / hair_skin 属性及材质换肤调用给内部
# Metin2Model所以 equip_model.gd 可以直接操作外层 PlayerView
extends Node3D
# race -> (class, 是否女). playersettingmodule: 0=WAR_M 1=ASN_W 2=SURA_M 3=SHA_W
@@ -54,6 +54,7 @@ var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin"
var _left_bone := ""
var _race := 0
var _hit := HitReaction.new()
var _motion_mode := 1 # CRaceMotionData::MODE_GENERAL
# pump 可空:每个重活(gr2 解析 / LOD 扫描 / hair 折叠 / .msa 解析)之间调一下
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
@@ -132,12 +133,19 @@ func set_anim_state(s: String) -> void:
if _hit.active and s in HitReaction.LOOP_STATES:
_hit.resume = s
return
# 单次动作(技能、攻击、表情)播放中:普通循环动作(wait/walk/run)不得强行打断
if _state in ["__skill", "attack", "combo", "__motion"] and s in HitReaction.LOOP_STATES:
if anim.has_method("get_duration") and anim.has_method("get_time"):
var d: float = float(anim.call("get_duration"))
var t: float = float(anim.call("get_time"))
if d > 0.0 and t < d and bool(anim.get("playing")):
return
else:
return
_hit.cancel()
_state = s
var msa := motion_dir.path_join(s + ".msa")
if not FileAccess.file_exists(msa):
msa = motion_dir.path_join(s + ".gr2")
if FileAccess.file_exists(msa):
var msa := _motion_file(s)
if msa != "":
_bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s))
# CActorInstance::SetMoveSpeedm_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放
@@ -155,10 +163,29 @@ func _loop_speed(state: String) -> float:
## 走 / 跑动作的根运动速度(cm/smovSpd 100):x = walky = run。0 = 取不到。
## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。
func get_move_motion_speeds() -> Vector2:
if motion_dir == "":
var dir := _mode_motion_dir() if motion_dir != "" else ""
if dir == "":
return Vector2.ZERO
return Vector2(MsaMotion.move_speed(motion_dir.path_join("walk.msa")),
MsaMotion.move_speed(motion_dir.path_join("run.msa")))
return Vector2(MsaMotion.move_speed(dir.path_join("walk.msa")),
MsaMotion.move_speed(dir.path_join("run.msa")))
# CInstanceBase::RefreshState selects every motion through m_wcurMotionMode,
# not only attack/combo. A two-handed weapon therefore uses twohand_sword for
# wait/walk/run/damage too; the left-hand grip is authored in those animations
# while the weapon model itself remains attached to PART_WEAPON (right hand).
func set_motion_mode(mode: int) -> void:
var next := mode if MOTION_MODE_DIRS.has(mode) else 1
if next == _motion_mode:
return
_motion_mode = next
var resume := _state
if resume == "" or resume == "__motion":
resume = "wait"
_state = "" # force rebind even though the semantic state did not change
set_anim_state(resume)
func get_motion_mode() -> int:
return _motion_mode
# Play one of the 40250 CRaceMotionData one-shot motions. The protocol sends
# the numeric motion id; paired emotions use the other entity's race to select
@@ -246,6 +273,98 @@ func play_attack_motion(mode: int, index: int, speed_ratio: float) -> void:
return
_bind("attack", msa, false, speed_ratio if speed_ratio > 0.0 else 1.0)
# 40250 CInstanceBase::NEW_UseSkill (InstanceBaseBattle.cpp:310)
# 播放指定技能动作(.msa),依据 grade (0=普, 1=M, 2=G, 3=P) 读取对应阶位动作文件。
func play_skill_motion(motion_name: String, grade: int = 0) -> bool:
if anim == null:
return false
var cls: String = CLASS_OF[_race & 3]
var pc_dir := "pc2/ymir work/pc2/%s/skill" % cls if _race >= 4 else "PC/ymir work/pc/%s/skill" % cls
var sdir := _resolve_dir(_assets_root, pc_dir)
if sdir == "":
return false
var suffix := ""
if grade >= 1:
suffix = "_%d" % (grade + 1)
var msa := sdir.path_join(motion_name + suffix + ".msa")
if not FileAccess.file_exists(msa):
msa = sdir.path_join(motion_name + ".msa")
if not FileAccess.file_exists(msa):
return false
_bind("__skill", msa, false, 1.0)
return true
const AFFECT_EFFECT_MAP := {
15: {"file": "d:/ymir work/pc/warrior/effect/geom_sword_loop.mse", "bone": "equip_right_hand"}, # AFFECT_GEOMGYEONG
16: {"file": "d:/ymir work/pc/warrior/effect/gyeokgongjang_loop.mse", "bone": ""}, # AFFECT_CHEONGEUN
17: {"file": "d:/ymir work/pc/assassin/effect/gyeonggong_loop.mse", "bone": ""}, # AFFECT_GYEONGGONG
19: {"file": "d:/ymir work/pc/sura/effect/gwigeom_loop.mse", "bone": "Bip01 R Finger2"}, # AFFECT_GWIGEOM
20: {"file": "d:/ymir work/pc/sura/effect/fear_loop.mse", "bone": ""}, # AFFECT_FEAR
21: {"file": "d:/ymir work/pc/sura/effect/jumagap_loop.mse", "bone": ""}, # AFFECT_JUMAGAP
22: {"file": "d:/ymir work/pc/shaman/effect/3hosin_loop.mse", "bone": ""}, # AFFECT_HOSIN
23: {"file": "d:/ymir work/pc/shaman/effect/boho_loop.mse", "bone": ""}, # AFFECT_BOHO
24: {"file": "d:/ymir work/pc/shaman/effect/10kwaesok_loop.mse", "bone": ""}, # AFFECT_KWAESOK
25: {"file": "d:/ymir work/pc/sura/effect/heuksin_loop.mse", "bone": ""}, # AFFECT_HEUKSIN
26: {"file": "d:/ymir work/pc/sura/effect/muyeong_loop.mse", "bone": ""}, # AFFECT_MUYEONG
29: {"file": "d:/ymir work/pc/shaman/effect/6gicheon_hand.mse", "bone": "Bip01 R Hand"}, # AFFECT_GICHEON
30: {"file": "d:/ymir work/pc/shaman/effect/jeungryeok_hand.mse", "bone": "Bip01 L Hand"}, # AFFECT_JEUNGRYEOK
32: {"file": "d:/ymir work/pc/sura/effect/pabeop_loop.mse", "bone": "Bip01 Head"}, # AFFECT_PABEOP
3: {"file": "d:/ymir work/effect/hit/blow_poison/poison_loop.mse", "bone": "Bip01"}, # AFFECT_POISON
4: {"file": "d:/ymir work/effect/affect/slow.mse", "bone": ""}, # AFFECT_SLOW
5: {"file": "d:/ymir work/effect/etc/stun/stun_loop.mse", "bone": "Bip01 Head"}, # AFFECT_STUN
}
var _fx: RefCounted
var _affect_nodes: Dictionary = {}
func set_fx(fx_registry: RefCounted) -> void:
_fx = fx_registry
func set_affect(affect_type: int, visible: bool, fx_registry: RefCounted = null) -> void:
var reg: RefCounted = fx_registry if fx_registry != null else _fx
if not visible:
if _affect_nodes.has(affect_type):
var n: Node = _affect_nodes[affect_type]
_affect_nodes.erase(affect_type)
if is_instance_valid(n):
n.queue_free()
return
if _affect_nodes.has(affect_type) or reg == null:
return
if not AFFECT_EFFECT_MAP.has(affect_type):
return
var spec: Dictionary = AFFECT_EFFECT_MAP[affect_type]
var fx_name: String = String(spec.get("file", ""))
if affect_type == 15 and _motion_mode == 3:
fx_name = "d:/ymir work/pc/warrior/effect/geom_spear_loop.mse"
if fx_name == "":
return
var effect: Node = reg.call("spawn", fx_name, self, false)
if effect == null:
return
var bone: String = String(spec.get("bone", ""))
if bone != "":
var anchor := preload("res://fx/motion_effect_anchor.gd").new()
effect.add_child(anchor)
var ev := {"attaching": true, "following": true, "independent": false, "bone": bone, "pos": Vector3.ZERO}
if not anchor.configure(self, ev):
effect.queue_free()
return
_affect_nodes[affect_type] = effect
func set_affect_flags(flags: int, fx_registry: RefCounted = null) -> void:
for t in AFFECT_EFFECT_MAP.keys():
var active: bool = (flags & (1 << t)) != 0
set_affect(t, active, fx_registry)
func clear_affects() -> void:
for t in _affect_nodes.keys():
var n: Node = _affect_nodes[t]
if is_instance_valid(n):
n.queue_free()
_affect_nodes.clear()
# CGraphicThingInstance::InsertDelay(fStiffenTime):动作冻结,结束后恢复 fSpeedRatio。
func insert_delay(d: float) -> void:
_hit.insert_delay(d, anim)
@@ -261,6 +380,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()
# 受击方 m_DefendingPointInstanceList 的来源:root/msm/<class>_<m|w>.msm 的 DEFENDING 球。
func get_defending_spheres() -> Array:
var sex := "w" if _race in FEMALE_RACES else "m"
@@ -276,9 +399,18 @@ func _on_playback_finished() -> void:
_state = ""
set_anim_state(_hit.resume)
return
if _state == "__motion":
if _state in ["__motion", "__skill", "attack", "combo"]:
_state = ""
set_anim_state("wait")
motion_bound.emit("wait")
_ground_model()
func reset_skill_motion() -> void:
if _state in ["__skill", "__motion"]:
_state = ""
set_anim_state("wait")
motion_bound.emit("wait")
_ground_model()
# True while the one-shot hit/knockback chain is still playing.
func is_in_hit_reaction() -> bool:
@@ -287,7 +419,7 @@ func is_in_hit_reaction() -> bool:
func _bind_reaction(step: String) -> bool:
if anim == null or motion_dir == "":
return false
var msa := _pick_msa(motion_dir, REACTION_FILES.get(step, []))
var msa := _pick_mode_msa(REACTION_FILES.get(step, []))
if msa == "":
return false
_bind(step, msa, false, 1.0)
@@ -303,6 +435,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)
@@ -315,6 +448,26 @@ func _pick_msa(dir: String, names: Array) -> String:
found.append(p)
return "" if found.is_empty() else String(found[randi() % found.size()])
func _mode_motion_dir() -> String:
if motion_dir == "":
return ""
var candidate := motion_dir.get_base_dir().path_join(String(MOTION_MODE_DIRS.get(_motion_mode, "general")))
return candidate if DirAccess.dir_exists_absolute(candidate) else motion_dir
func _motion_file(state: String) -> String:
for dir in [_mode_motion_dir(), motion_dir]:
if dir == "":
continue
for ext in [".msa", ".gr2"]:
var path: String = String(dir).path_join(state + String(ext))
if FileAccess.file_exists(path):
return path
return ""
func _pick_mode_msa(names: Array) -> String:
var picked := _pick_msa(_mode_motion_dir(), names)
return picked if picked != "" else _pick_msa(motion_dir, names)
func _process(delta: float) -> void:
_hit.tick(delta, anim, model as Node3D) # InsertDelay + ShakeProcess
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \
@@ -347,6 +500,17 @@ func _set(prop: StringName, val: Variant) -> bool:
return true
return false
# EquipModel operates on the outer PlayerView, while the material palette lives
# on the native Metin2Model child. Keep these as explicit methods so MSM
# SourceSkin -> TargetSkin remaps reach both character select and the game.
func set_skin_texture(source: String, target: String) -> void:
if model and model.has_method("set_skin_texture"):
model.call("set_skin_texture", source, target)
func clear_skin_textures() -> void:
if model and model.has_method("clear_skin_textures"):
model.call("clear_skin_textures")
func _ground_model() -> void:
if model and model.has_method("get_ground_offset"):
model.position.y = float(model.call("get_ground_offset"))