Files
mtgodot-poc/project/equip_model_test.gd
T
shenandshen 66d217b313 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 客户端对拍缺陷发现与修复工程指南
2026-09-19 08:51:25 -07:00

383 lines
17 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.
# equip_model_test —— P2 装备→模型 headless 自检(假 client + 真 item_list.txt + 桩 model)。
# godot --headless --path project --script equip_model_test.gd
extends SceneTree
const ItemListDB = preload("res://ui/item_list.gd")
const EquipModel = preload("res://ui/equip_model.gd")
const PlayerView = preload("res://ui/player_view.gd")
const EffectRegistry = preload("res://fx/effect_registry.gd")
const MotionEffectAnchor = preload("res://fx/motion_effect_anchor.gd")
class FakeClient extends Node:
signal inventory_changed(window: int, cell: int)
signal entity_main_set(vid: int)
signal entity_info(vid: int, entity: Dictionary)
var equip := []
var main_parts := [0, 0, 0, 5] # parts[3] = hair 5
func _init():
for i in 11: equip.append({"vnum": 0, "count": 0, "wear": i})
func get_equipment() -> Array: return equip
func get_main_vid() -> int: return 1000
var mount_vnum := 0
func get_entity(_v) -> Dictionary: return {"race": 0, "parts": main_parts, "mount_vnum": mount_vnum}
func set_wear(idx, vnum):
equip[idx] = {"vnum": vnum, "count": 1, "wear": idx}
inventory_changed.emit(2, 90 + idx) # window EQUIPMENT
# Node3D 桩:记录 weapon_gr2 / shield_gr2 / gr2_path / hair_gr2 被设成什么
class StubModel extends Node3D:
var weapon_gr2 := ""
var shield_gr2 := ""
var gr2_path := ""
var hair_gr2 := ""
var hair_skin := "-"
var weapon_bone := "equip_right_hand"
var shield_bone := "equip_left"
var skin_source := ""
var skin_target := ""
var specular_power := -1.0
func clear_skin_textures():
skin_source = ""
skin_target = ""
func set_skin_texture(source: String, target: String):
skin_source = source
skin_target = target
func _set(prop, val):
match String(prop):
"weapon_gr2": weapon_gr2 = val; return true
"shield_gr2": shield_gr2 = val; return true
"gr2_path": gr2_path = val; return true
"hair_gr2": hair_gr2 = val; return true
"hair_skin": hair_skin = val; return true
"specular_power": specular_power = val; return true
return false
class FakeEffectRegistry extends RefCounted:
var calls: Array = []
func setup(_assets: String) -> void: pass
func spawn(effect: String, parent: Node3D, one_shot := true) -> Node3D:
calls.append({"effect": effect, "parent": parent.name, "one_shot": one_shot})
var node := Node3D.new()
parent.add_child(node)
return node
class FakeAnimator extends Node:
func get_effect_bone_pose(_bone: String) -> Dictionary:
return {"transform": Transform3D.IDENTITY}
# item_proto 桩:只给武器 subtypeWEAPON_DAGGER=1 BOW=2 FAN=5item_length.h EWeaponSubTypes
class FakeProto extends Node:
const SUB := {19: 0, 2000: 2, 3151: 3, 3159: 3, 4000: 1, 7000: 5}
func item(vnum: int) -> Dictionary:
if SUB.has(vnum):
return {"type": 1, "sub_type": SUB[vnum], "gain_socket_pct": 3}
return {}
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: equip_model_test (item_list + weapon swap)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var assets := AssetRoot.path()
var il_path := assets.path_join("locale/locale/common/item_list.txt")
# item_list.txt 解析
var il := ItemListDB.new()
if not FileAccess.file_exists(il_path):
print(" (skip: no item_list.txt)")
return
_ck(il.load_file(il_path), "item_list loaded")
_ck(il.count > 1000, "item_list count > 1000 (%d)" % il.count)
_ck(il.model(19) == "d:/ymir work/item/weapon/00010.gr2", "item_list.model(19), got '%s'" % il.model(19))
_ck(il.icon(19) == "icon/item/00010.tga", "item_list.icon(19)")
_ck(il.type_of(19) == "WEAPON", "item_list.type_of(19)")
# InstanceBase::__GetRefinedEffect parity. Standard equipment carries
# gain_socket_pct=3, making the final vnum digit its visible refine level.
var heavy_1 := {"type": 1, "sub_type": 3, "gain_socket_pct": 3}
var heavy_9 := {"type": 1, "sub_type": 3, "gain_socket_pct": 3}
_ck(EquipModel.refine_effect_level(3151, heavy_1) == 1,
"3151 -> refine +1")
_ck(EquipModel.refine_effect_specs(3151, heavy_1).is_empty(),
"+1 has no legacy refine particles")
var heavy_fx: Array = EquipModel.refine_effect_specs(3159, heavy_9)
_ck(heavy_fx.size() == 1 and String(heavy_fx[0].effect).ends_with("sword_9.mse")
and String(heavy_fx[0].slot) == "right",
"two-handed +9 -> normal sword_9 on PART_WEAPON")
var dagger_fx: Array = EquipModel.refine_effect_specs(4009,
{"type": 1, "sub_type": 1, "gain_socket_pct": 3})
_ck(dagger_fx.size() == 2 and String(dagger_fx[0].effect).ends_with("sword_9_s.mse")
and String(dagger_fx[1].slot) == "left",
"dagger +9 -> small-sword effect on both hands")
var bow_fx: Array = EquipModel.refine_effect_specs(2009,
{"type": 1, "sub_type": 2, "gain_socket_pct": 3})
_ck(bow_fx.size() == 1 and String(bow_fx[0].effect).ends_with("sword_9_b.mse")
and String(bow_fx[0].slot) == "left",
"bow +9 -> bow effect on PART_WEAPON_LEFT")
var blue_steel_fx: Array = EquipModel.refine_effect_specs(12019,
{"type": 2, "sub_type": 0, "gain_socket_pct": 3})
_ck(blue_steel_fx.size() == 3 and String(blue_steel_fx[2].effect).ends_with("armor_9.mse"),
"Blue Steel +9 -> two special effects plus armor_9")
var real_registry := EffectRegistry.new()
real_registry.setup(assets)
for spec in heavy_fx + dagger_fx + bow_fx + blue_steel_fx:
_ck(real_registry.resolve(String(spec.effect)) != "",
"registered refine MSE resolves: %s" % spec.effect)
# Character select passes the outer PlayerView to EquipModel. Verify it
# forwards palette calls to the native renderer; a direct stub-only test
# cannot catch a missing integration method here.
var forwarded := StubModel.new()
var view := PlayerView.new()
view.model = forwarded
view.set_skin_texture("base.dds", "blue.dds")
_ck(forwarded.skin_source == "base.dds" and forwarded.skin_target == "blue.dds",
"PlayerView forwards set_skin_texture")
view.clear_skin_textures()
_ck(forwarded.skin_source == "" and forwarded.skin_target == "",
"PlayerView forwards clear_skin_textures")
view.free()
forwarded.free()
# EquipModel:装备武器 vnum 19 -> model.weapon_gr2 = 解析后的真实路径
var fc := FakeClient.new()
var model := StubModel.new()
get_root().add_child(fc)
get_root().add_child(model)
var em: Node = EquipModel.new()
get_root().add_child(em)
em.setup(fc, il, func() -> Node: return model, assets)
em.main_getter = func() -> int: return 1000
fc.set_wear(4, 19) # WEAR_WEAPON
await process_frame
var wp: String = model.weapon_gr2
print(" weapon_gr2 -> '", wp, "'")
if wp == "":
# 资产里可能没有这把武器的 gr2 文件;至少确认解析逻辑跑到了(basevnum 约定)
_ck(true, "weapon resolved to '' (gr2 not in assets — resolution logic still exercised)")
else:
_ck(wp.ends_with("00010.gr2") and FileAccess.file_exists(wp), "weapon_gr2 points at a real 00010.gr2")
# 拆下武器 -> weapon_gr2 = ""
fc.set_wear(4, 0)
await process_frame
_ck(model.weapon_gr2 == "", "unequip -> weapon_gr2 cleared")
# armor_model_map 注入 -> gr2_path 切换
em.armor_model_map = {11209: "d:/ymir work/pc/warrior/warrior.gr2"}
fc.set_wear(0, 11209)
await process_frame
_ck(model.gr2_path == "d:/ymir work/pc/warrior/warrior.gr2", "armor_model_map -> gr2_path set")
# 左右手:ActorInstanceAttach.cpp AttachWeapon
# __IsRightHandWeapon: DAGGER / (FAN && 骑马) -> 右;BOW -> 否;其余 -> 右
# __IsLeftHandWeapon : DAGGER / (FAN && 骑马) / BOW -> 左(GetSubModelThing = 同一 gr2
# shield_gr2 槽 = PART_WEAPON_LEFT;参考端不渲染 WEAR_SHIELD(盾在 item_list 没模型)。
var fch := FakeClient.new()
var mh := StubModel.new()
var mh_view := PlayerView.new()
mh_view.name = "PlayerView"
mh_view.model = mh
mh_view._left_bone = "Bip01 L Hand"
var mh_anim := FakeAnimator.new()
mh_view.anim = mh_anim
var fp := FakeProto.new()
get_root().add_child(fch)
get_root().add_child(mh_view)
mh_view.add_child(mh)
mh_view.add_child(mh_anim)
get_root().add_child(fp)
var emh: Node = EquipModel.new()
get_root().add_child(emh)
emh.setup(fch, il, func() -> Node: return mh_view, assets)
emh.main_getter = func() -> int: return 1000
emh.proto = fp
var right_part := Node3D.new()
right_part.name = "Weapon"
mh.add_child(right_part)
var fake_effects := FakeEffectRegistry.new()
emh.effect_registry = fake_effects
var sword_p: String = emh._resolve_weapon(19)
var bow_p: String = emh._resolve_weapon(2000)
var dagger_p: String = emh._resolve_weapon(4000)
var fan_p: String = emh._resolve_weapon(7000)
_ck(sword_p != "" and bow_p != "" and dagger_p != "" and fan_p != "", "weapon gr2 resolvable (19/2000/4000/7000)")
fch.set_wear(4, 19)
await process_frame
_ck(mh.weapon_gr2 == sword_p and mh.shield_gr2 == "", "sword -> right hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2])
fch.set_wear(4, 4000)
await process_frame
_ck(mh.weapon_gr2 == dagger_p and mh.shield_gr2 == dagger_p, "dagger -> both hands (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2])
fch.set_wear(4, 2000)
await process_frame
_ck(mh.weapon_gr2 == "" and mh.shield_gr2 == bow_p, "bow -> left hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2])
fch.set_wear(4, 7000)
await process_frame
_ck(mh.weapon_gr2 == fan_p and mh.shield_gr2 == "", "fan on foot -> right hand only (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2])
fake_effects.calls.clear()
fch.set_wear(4, 3159)
await process_frame
_ck(fake_effects.calls.size() == 1
and String(fake_effects.calls[0].effect).ends_with("sword_9.mse")
and String(fake_effects.calls[0].parent) == "PlayerView"
and not bool(fake_effects.calls[0].one_shot),
"equipping two-handed +9 spawns persistent sword_9 on character bone path (%s; children=%s)" %
[fake_effects.calls, mh.get_children()])
_ck(emh._weapon_refine_fx.size() == 1
and emh._weapon_refine_fx[0].get_child_count() == 1
and emh._weapon_refine_fx[0].get_child(0) is MotionEffectAnchor,
"+9 effect follows equip_right_hand through MotionEffectAnchor")
fch.set_wear(4, 3151)
await process_frame
_ck(fake_effects.calls.size() == 1 and emh._weapon_refine_fx.is_empty(),
"switching +9 -> +1 removes refine particles (%s; active=%s)" %
[fake_effects.calls, emh._weapon_refine_fx])
fch.set_wear(4, 7000)
fch.mount_vnum = 20030
fch.entity_info.emit(1000, {})
await process_frame
_ck(mh.weapon_gr2 == fan_p and mh.shield_gr2 == fan_p, "fan mounted -> both hands (%s | %s)" % [mh.weapon_gr2, mh.shield_gr2])
fch.mount_vnum = 0
fch.set_wear(4, 19)
fch.set_wear(10, 19) # WEAR_SHIELD 不渲染
await process_frame
_ck(mh.shield_gr2 == "", "WEAR_SHIELD is not rendered (%s)" % mh.shield_gr2)
fch.set_wear(4, 0)
await process_frame
_ck(mh.weapon_gr2 == "" and mh.shield_gr2 == "", "unequip -> both hands cleared")
# 头盔(WEAR_HEAD=1)有模型 -> 覆盖 hair_gr2;拆下回角色发型(parts[3]=5
var em2: Node = EquipModel.new()
get_root().add_child(em2)
em2.armor_model_map = {}
em2.setup(fc, il, func() -> Node: return model, assets)
em2.main_getter = func() -> int: return 1000
model.hair_gr2 = ""
# 头盔 vnum 用一个 item_list 里有 model 的(19 有)
fc.set_wear(1, 19)
await process_frame
_ck(model.hair_gr2 == "" or model.hair_gr2.ends_with(".gr2"), "WEAR_HEAD 有模型 -> 覆盖 hair 槽")
# 拆头盔:应尝试从 parts[3] 恢复发型(有 race_spec 才成,无则保持不炸)
fc.set_wear(1, 0)
await process_frame
_ck(true, "拆头盔 -> 回 parts 发型(不炸)")
# armor shape 走 item_proto values[3](对齐 __ArmorVnumToShape
if ClassDB.class_exists("Metin2Proto"):
var pr: Object = ClassDB.instantiate("Metin2Proto")
get_root().add_child(pr)
var ip := assets.path_join("locale/locale/en/item_proto")
if FileAccess.file_exists(ip) and pr.call("load_item_proto", ip):
var em3: Node = EquipModel.new()
get_root().add_child(em3)
em3.setup(fc, il, func() -> Node: return model, assets)
em3.proto = pr
em3.race = 0 # warrior
# 11200 "Monk Plate Armour+0" -> values[3] == 3
_ck(em3._armor_shape_default(11200) == 3, "armor 11200 -> shape 3 (via proto values[3])")
# 11000 "Wolf Armour" -> values[3] == 0,参考端把 0 当有效基础 shape
_ck(em3._armor_shape_default(11000) == 100, "armor 11000 -> shape 100 (via proto values[3])")
_ck(em3._armor_shape_default(12019) == 26, "armor 12019 -> Blue Steel shape 26")
var heavy: Dictionary = pr.item(3150)
_ck(int(heavy.get("type", -1)) == 1 and int(heavy.get("sub_type", -1)) == 3,
"weapon 3150 -> WEAPON_TWO_HANDED")
var heavy_real_9: Dictionary = pr.item(3159)
_ck(EquipModel.refine_effect_level(3159, heavy_real_9) == 9
and EquipModel.refine_effect_specs(3159, heavy_real_9).size() == 1,
"real item_proto 3159 -> two-handed +9 refine effect")
var armor_real_9: Dictionary = pr.item(12019)
_ck(EquipModel.refine_effect_level(12019, armor_real_9) == 9
and EquipModel.refine_effect_specs(12019, armor_real_9).size() == 3,
"real item_proto 12019 -> Blue Steel +9 refine effects")
em3._apply_body(model, 12019)
_ck(model.gr2_path.to_lower().ends_with("warrior_4-1.gr2"),
"armor 12019 -> warrior_4-1 geometry (%s)" % model.gr2_path)
_ck(model.skin_source.to_lower().ends_with("warrior_4-1.dds") and
model.skin_target.to_lower().ends_with("warrior_4-2.dds"),
"armor 12019 -> source/target skin remap (%s -> %s)" % [model.skin_source, model.skin_target])
_ck(em3._armor_specular(11209) == 100, "armor 11209(+9) -> specular 100")
# FakeProto 的 subtype 与真 item_proto 一致
for v in FakeProto.SUB:
var it: Dictionary = pr.item(v)
_ck(int(it.get("type", -1)) == 1 and int(it.get("sub_type", -1)) == FakeProto.SUB[v],
"item_proto %d -> WEAPON sub_type %d (got %s/%s)" % [v, FakeProto.SUB[v], it.get("type"), it.get("sub_type")])
# 时装 / parts[] 优先:服务器把 costume vnum 写进主角 parts[ARMOR],压过装备槽
var seen := {"body": -1, "hair": ""}
var em4: Node = EquipModel.new()
get_root().add_child(em4)
# 用一个记录用的桩:_apply_body / hair 走真逻辑但我们看最终 vnum
em4.setup(fc, il, func() -> Node: return model, assets)
em4.main_getter = func() -> int: return 1000
em4.armor_model_map = {77777: "d:/x/costume.gr2", 11111: "d:/x/armor.gr2"}
# 装备槽是普通盔甲,但 parts[ARMOR] 是时装 -> 用时装
fc.equip[0] = {"vnum": 11111, "count": 1, "wear": 0}
fc.main_parts = [77777, 0, 0, 5]
em4._last_body_vnum = -1
em4.refresh()
_ck(model.gr2_path == "d:/x/costume.gr2", "parts[ARMOR] 时装压过装备槽 (%s)" % model.gr2_path)
# parts[ARMOR] 清 0 -> 回退装备槽
fc.main_parts = [0, 0, 0, 5]
em4._last_body_vnum = -1
em4.refresh()
_ck(model.gr2_path == "d:/x/armor.gr2", "parts[ARMOR]=0 -> 回退 WEAR_BODY (%s)" % model.gr2_path)
# _eff 直接测
_ck(em4._eff([9, 0, 0, 0], 0, [{"vnum": 5}], 0) == 9, "_eff: parts 非 0 优先")
_ck(em4._eff([0, 0, 0, 0], 0, [{"vnum": 5}], 0) == 5, "_eff: parts 0 -> 装备槽")
_ck(em4._eff([], 1, [{"vnum": 1}, {"vnum": 2}], 1) == 2, "_eff: 无 parts -> 装备槽")
# §4.1 修改 4:动物 / 怪物时装(shape 100-103)遮挡武器 + 头发
var em5: Node = EquipModel.new()
get_root().add_child(em5)
em5.setup(fc, il, func() -> Node: return model, assets)
em5.main_getter = func() -> int: return 1000
em5.armor_shape_of = func(v: int) -> int: return 101 if v == 88888 else v
fc.equip[4] = {"vnum": 19, "count": 1, "wear": 4} # 有武器
fc.equip[10] = {"vnum": 19, "count": 1, "wear": 10} # 有盾
fc.main_parts = [88888, 0, 0, 5] # parts[ARMOR] -> shape 101
model.weapon_gr2 = "x"; model.shield_gr2 = "x"; model.hair_gr2 = "x"
em5._last_weapon_vnum = -1
em5._last_body_vnum = -1; em5._last_head_vnum = -2
em5.refresh()
_ck(model.weapon_gr2 == "", "动物时装 -> weapon_gr2 清空 (%s)" % model.weapon_gr2)
_ck(model.shield_gr2 == "", "动物时装 -> shield_gr2 清空 (%s)" % model.shield_gr2)
_ck(model.hair_gr2 == "", "动物时装 -> hair_gr2 清空 (%s)" % model.hair_gr2)
# 脱下动物时装 -> 武器恢复解析(资产里 00010.gr2 可能不存在,至少不再是遮挡态)
fc.main_parts = [0, 0, 0, 5]
em5._last_weapon_vnum = -999
em5._last_body_vnum = -1; em5._last_head_vnum = -2
em5.refresh()
_ck(model.weapon_gr2 == "" or model.weapon_gr2.ends_with("00010.gr2"),
"脱时装 -> weapon 重新解析 (%s)" % model.weapon_gr2)
# 婚纱(shape 201)遮挡武器,但头发保留;捧花例外
var em6: Node = EquipModel.new()
get_root().add_child(em6)
em6.setup(fc, il, func() -> Node: return model, assets)
em6.main_getter = func() -> int: return 1000
em6.armor_shape_of = func(v: int) -> int: return 201 if v == 99999 else v
fc.equip[4] = {"vnum": 19, "count": 1, "wear": 4}
fc.main_parts = [99999, 0, 0, 5]
model.weapon_gr2 = "x"; model.hair_gr2 = "x"
em6._last_weapon_vnum = -1; em6._last_body_vnum = -1; em6._last_head_vnum = -2
em6.refresh()
_ck(model.weapon_gr2 == "", "婚纱 + 剑 -> weapon_gr2 清空")
_ck(model.hair_gr2 != "", "婚纱 -> 头发保留 (%s)" % model.hair_gr2)
# 换捧花(vnum 50201-> 武器恢复
fc.main_parts = [99999, 50201, 0, 5]
em6._last_weapon_vnum = -999; em6._last_body_vnum = -1; em6._last_head_vnum = -2
em6.refresh()
_ck(em6._last_weapon_vnum == 50201, "婚纱 + 捧花 -> 武器 vnum 保留 (%d)" % em6._last_weapon_vnum)