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
+167 -9
View File
@@ -25,6 +25,8 @@ extends Node
const RaceSpec = preload("res://ui/race_spec.gd")
const PartHiding = preload("res://part_hiding.gd")
const EffectRegistry = preload("res://fx/effect_registry.gd")
const MotionEffectAnchor = preload("res://fx/motion_effect_anchor.gd")
const WEAR_BODY := 0
const WEAR_HEAD := 1
@@ -39,7 +41,13 @@ const CHR_EQUIPPART_HAIR := 3 # 兼容旧引用
# item_length.h EWeaponSubTypesCItemData::GetWeaponType = bSubType
const WEAPON_SUB_DAGGER := 1
const WEAPON_SUB_BOW := 2
const WEAPON_SUB_TWO_HANDED := 3
const WEAPON_SUB_BELL := 4
const WEAPON_SUB_FAN := 5
const WEAPON_SUB_ARROW := 6
const ITEM_TYPE_WEAPON := 1
const ITEM_TYPE_ARMOR := 2
const ARMOR_BODY := 0
const HAND_RIGHT := 1
const HAND_LEFT := 2
const CLASS_OF := ["warrior", "assassin", "sura", "shaman"]
@@ -65,6 +73,9 @@ var _remote_parts: Array = [] # 远端 PC 的 awPart[ARMOR..HAIR]
var armor_shape_of: Callable = func(vnum: int) -> int: return _armor_shape_default(vnum)
# 应用方可注入:vnum -> 身体 gr2 路径(优先于 race_spec
var armor_model_map := {}
var effect_registry: RefCounted
var _weapon_refine_fx: Array[Node] = []
var _armor_refine_fx: Array[Node] = []
func setup(m2client: Node, il: RefCounted, model_getter: Callable, assets := "",
race_index := -1) -> void:
@@ -153,15 +164,154 @@ func refresh() -> void:
var wpn_vnum := _eff(parts, PART_WEAPON, eq, WEAR_WEAPON)
var wpn_eff := PartHiding.effective_weapon(wpn_vnum, raw_shape, is_poly)
var hands := _weapon_hands(wpn_eff)
if wpn_eff != _last_weapon_vnum or hands != _last_weapon_hands:
var weapon_changed := wpn_eff != _last_weapon_vnum or hands != _last_weapon_hands
if weapon_changed:
_last_weapon_vnum = wpn_eff
_last_weapon_hands = hands
# 左手用 GetSubModelThing()item .msm 加载在参考端被注释掉,恒为同一个 gr2
var wpath := _resolve_weapon(wpn_eff)
model.set("weapon_gr2", wpath if hands & HAND_RIGHT else "")
model.set("shield_gr2", wpath if hands & HAND_LEFT else "")
# SetArmor / SetWeapon both call __GetRefinedEffect.
_refresh_weapon_refine_effect(model, _last_weapon_vnum)
_refresh_armor_refine_effect(model, _last_body_vnum)
# ActorInstanceAttach.cpp __IsRightHandWeapon / __IsLeftHandWeapon -> HAND_* 位掩码
static func refine_effect_level(vnum: int, item: Dictionary) -> int:
if vnum <= 0 or item.is_empty():
return 0
var sockets := int(item.get("gain_socket_pct", 0))
if sockets != 3:
return 0
return maxi(vnum % 10, 0)
static func refine_effect_specs(vnum: int, item: Dictionary) -> Array:
var out: Array = []
if vnum <= 0 or item.is_empty():
return out
var item_type := int(item.get("type", -1))
var subtype := int(item.get("sub_type", -1))
var level := refine_effect_level(vnum, item)
if item_type == ITEM_TYPE_WEAPON:
if level < 7 or level > 9:
return out
var stem := "sword_%d" % level
match subtype:
WEAPON_SUB_DAGGER:
out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem})
out.append({"slot": "left", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem})
WEAPON_SUB_FAN:
out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_f.mse" % stem})
WEAPON_SUB_BOW:
out.append({"slot": "left", "effect": "d:/ymir work/pc/common/effect/sword/%s_b.mse" % stem})
WEAPON_SUB_BELL, WEAPON_SUB_ARROW:
out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s_s.mse" % stem})
_:
# Sword and two-handed sword share the normal sword effect.
out.append({"slot": "right", "effect": "d:/ymir work/pc/common/effect/sword/%s.mse" % stem})
elif item_type == ITEM_TYPE_ARMOR and subtype == ARMOR_BODY:
# 40250 always layers these two Blue Steel armour effects, in addition
# to the regular +7/+8/+9 body effect.
if vnum >= 12010 and vnum <= 12049:
out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor-4-2-1.mse"})
out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor-4-2-2.mse"})
if level >= 7 and level <= 9:
out.append({"slot": "body", "effect": "d:/ymir work/pc/common/effect/armor/armor_%d.mse" % level})
return out
func _item(vnum: int) -> Dictionary:
if vnum > 0 and proto and proto.has_method("item"):
return proto.item(vnum)
return {}
func _ensure_effect_registry() -> RefCounted:
if effect_registry == null and assets_root != "":
effect_registry = EffectRegistry.new()
effect_registry.call("setup", assets_root)
return effect_registry
func _clear_refine_effects(nodes: Array[Node]) -> void:
for node in nodes:
if is_instance_valid(node) and not node.is_queued_for_deletion():
node.queue_free()
nodes.clear()
func _effect_bone(model: Node, slot: String) -> String:
match slot:
"right":
if model and "_right_bone" in model and String(model.get("_right_bone")) != "":
return String(model.get("_right_bone"))
return "equip_right_hand"
"left":
if model and "_left_bone" in model and String(model.get("_left_bone")) != "":
return String(model.get("_left_bone"))
return "equip_left"
"body": return "Bip01"
return ""
func _effect_part(model: Node, slot: String) -> Node3D:
if model == null:
return null
match slot:
"right": return model.find_child("Weapon", true, false) as Node3D
"left": return model.find_child("Shield", true, false) as Node3D
"body": return model as Node3D
return model as Node3D
func _spawn_bone_refine(registry: RefCounted, model: Node, spec: Dictionary) -> Node:
# Weapons with a non-identity Granny grip pre-transform (for
# example 03150): it moves the particles away even though the mesh is held.
if not model is Node3D or not ("anim" in model):
return null
var animator: Variant = model.get("anim")
if not animator is Node or not animator.has_method("get_effect_bone_pose"):
return null
var bone := _effect_bone(model, String(spec.get("slot", "")))
if bone == "":
return null
var effect: Node = registry.call("spawn", String(spec.get("effect", "")),
model as Node3D, false)
if effect == null:
return null
var anchor := MotionEffectAnchor.new()
effect.add_child(anchor)
var event := {"attaching": true, "following": true, "independent": false,
"bone": bone, "pos": Vector3.ZERO}
if anchor.configure(model as Node3D, event):
return effect
effect.free()
return null
func _spawn_refine_specs(model: Node, specs: Array, output: Array[Node]) -> void:
var registry := _ensure_effect_registry()
if registry == null or not registry.has_method("spawn"):
return
for spec in specs:
var parent := _effect_part(model, String(spec.get("slot", "")))
var effect: Node = _spawn_bone_refine(registry, model, spec)
if effect == null and parent != null:
effect = registry.call("spawn", String(spec.get("effect", "")), parent, false)
if effect:
output.append(effect)
func _refresh_weapon_refine_effect(model: Node, vnum: int) -> void:
_clear_refine_effects(_weapon_refine_fx)
_spawn_refine_specs(model, refine_effect_specs(vnum, _item(vnum)), _weapon_refine_fx)
func _refresh_armor_refine_effect(model: Node, vnum: int) -> void:
_clear_refine_effects(_armor_refine_fx)
_spawn_refine_specs(model, refine_effect_specs(vnum, _item(vnum)), _armor_refine_fx)
# A RemotePlayerView is assembled before NetWorld adds it to the SceneTree.
# Re-run only the effect attachment after _ready so skeleton-following anchors
# can resolve global transforms; do not reload body/weapon geometry.
func refresh_refine_effects() -> void:
var model: Node = _model_getter.call() if _model_getter.is_valid() else null
if model == null:
return
_refresh_armor_refine_effect(model, _last_body_vnum)
_refresh_weapon_refine_effect(model, _last_weapon_vnum)
func _weapon_hands(vnum: int) -> int:
if vnum == 0:
return 0
@@ -302,7 +452,7 @@ func _armor_shape_default(vnum: int) -> int:
if vnum > 1 and proto and proto.has_method("item"):
var it: Dictionary = proto.item(vnum)
var v = it.get("values", [])
if v is Array and v.size() > 3 and int(v[3]) != 0:
if not it.is_empty() and v is Array and v.size() > 3:
return int(v[3])
return vnum
@@ -312,10 +462,13 @@ func _armor_specular(vnum: int) -> int:
return 0
func _apply_body(model: Node, vnum: int) -> void:
# 强化等级越高越亮(PARITY §2.7fSpecular = bSpecular / 100
if model == null:
return
if model.has_method("clear_skin_textures"):
model.call("clear_skin_textures")
# 强化等级越高越亮(PARITY §2.7fSpecular = bSpecular / 100,无高光时重置为 0)
var sp := _armor_specular(vnum)
if sp > 0:
model.set("specular_power", sp / 100.0)
model.set("specular_power", sp / 100.0 if sp > 0 else 0.0)
if armor_model_map.has(vnum):
model.set("gr2_path", String(armor_model_map[vnum]))
return
@@ -326,13 +479,18 @@ func _apply_body(model: Node, vnum: int) -> void:
var body := _resolve_spec_asset(e, String(e.get("model", "")))
if body != "":
model.set("gr2_path", body)
# 换肤(同模型不同 TargetSkin):覆盖 surface 0
# 换肤(同模型不同 TargetSkin):按 40250 SetMaterialImagePointer 材质名映射
var src: String = e.get("source_skin", "")
var tgt: String = e.get("target_skin", "")
if tgt != "" and tgt != src and model.has_method("set_surface_texture"):
if tgt != "" and tgt != src:
var t := _resolve_spec_asset(e, tgt)
if t != "":
model.call("set_surface_texture", 0, t)
var s := _resolve_spec_asset(e, src)
if model.has_method("set_skin_texture"):
if t != "":
model.call("set_skin_texture", s if s != "" else src, t)
if model.has_method("set_surface_texture"):
if t != "":
model.call("set_surface_texture", 0, t)
func _spec_shape(idx: int) -> Dictionary:
_ensure_spec()