Files
mtgodot-poc/project/weapon_attach_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

166 lines
7.2 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.
# weapon_attach_test —— 武器挂点按种族 + 左右手(真 PlayerView + 真 gr2)。
# godot --headless --path project --script weapon_attach_test.gd
#
# 参考端 playersettingmodule.py RegisterAttachingBoneName
# warrior PART_WEAPON=equip_right_hand (661)
# assassin PART_WEAPON=equip_right PART_WEAPON_LEFT=equip_left (873-874)
# sura PART_WEAPON=equip_right (999)
# shaman PART_WEAPON=equip_right PART_WEAPON_LEFT=equip_left (1190-1191)
# ActorInstanceAttach.cpp AttachWeaponGetAttachingBoneName 失败(该职业没登记左手)就不挂。
# PlayerView 的 shield_gr2 槽 = PART_WEAPON_LEFT(参考端没有盾牌挂件)。
extends SceneTree
const PlayerView = preload("res://ui/player_view.gd")
const RIGHT_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"]
const LEFT_BONE := ["", "equip_left", "", "equip_left"]
const MAX_ORIGIN_M := 5.0
const MAX_MESH_TO_HAND_M := 0.12
# The hand can sit inside the empty volume between pommel/guard vertices; 03150
# measures ~0.278 m to the nearest authored vertex while its transformed AABB
# crosses the hand. This still rejects the former metre-scale displacement.
const MAX_VERTEX_TO_HAND_M := 0.30
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: weapon_attach_test (race attaching bones + left/right hand)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
if not ClassDB.class_exists("Metin2Model"):
print(" (skip: no Metin2Model extension)")
return
var assets := AssetRoot.path()
var weapon_dir := assets.path_join("item/ymir work/item/weapon")
var sword := weapon_dir.path_join("00010.gr2")
var dagger := weapon_dir.path_join("04000.gr2")
var offset_sword := weapon_dir.path_join("03150.gr2")
if not FileAccess.file_exists(sword) or not FileAccess.file_exists(dagger) or not FileAccess.file_exists(offset_sword):
print(" (skip: weapon gr2 assets missing)")
return
await process_frame
for race in 8:
var job := race & 3
var pv = PlayerView.new()
get_root().add_child(pv)
if not pv.build(assets, race):
print(" (skip race %d: body assets missing)" % race)
pv.free()
continue
pv.set("weapon_gr2", sword)
pv.set("shield_gr2", dagger)
for i in 3:
await process_frame
var right := pv.model.get_node_or_null("Weapon") as Node3D
_ck(right != null, "race %d: right-hand weapon attached (%s)" % [race, RIGHT_BONE[job]])
if right:
_ck(_origin_bounded(pv, right, RIGHT_BONE[job]), "race %d: weapon transform remains bounded at %s" % [race, RIGHT_BONE[job]])
_ck(_mesh_reaches_hand(pv, right as MeshInstance3D, RIGHT_BONE[job]), "race %d: weapon mesh grip reaches %s" % [race, RIGHT_BONE[job]])
_ck(_mesh_vertex_near_hand(pv, right as MeshInstance3D, RIGHT_BONE[job]), "race %d: weapon geometry reaches %s" % [race, RIGHT_BONE[job]])
var left := pv.model.get_node_or_null("Shield") as Node3D
if LEFT_BONE[job] == "":
_ck(left == null, "race %d: no PART_WEAPON_LEFT bone registered -> nothing on left hand" % race)
else:
_ck(left != null, "race %d: left-hand weapon attached (%s)" % [race, LEFT_BONE[job]])
if left:
_ck(_origin_bounded(pv, left, LEFT_BONE[job]), "race %d: left weapon transform remains bounded at %s" % [race, LEFT_BONE[job]])
_ck(_mesh_reaches_hand(pv, left as MeshInstance3D, LEFT_BONE[job]), "race %d: left weapon mesh grip reaches %s" % [race, LEFT_BONE[job]])
_ck(_mesh_vertex_near_hand(pv, left as MeshInstance3D, LEFT_BONE[job]), "race %d: left weapon geometry reaches %s" % [race, LEFT_BONE[job]])
# 03150 is a warrior two-handed weapon. It remains attached to PART_WEAPON
# (right hand); MODE_TWOHAND_SWORD moves the left hand onto its grip.
if job == 0:
pv.set("shield_gr2", "")
pv.set("weapon_gr2", offset_sword)
pv.set_motion_mode(3)
for state in ["wait", "walk", "run"]:
pv.set_anim_state(state)
for i in 3:
await process_frame
var attached := pv.model.get_node_or_null("Weapon") as MeshInstance3D
_ck(attached != null, "race %d %s: 03150 attached" % [race, state])
if attached:
_ck(_mesh_reaches_hand(pv, attached, RIGHT_BONE[job]),
"race %d %s: 03150 visible mesh reaches right hand" % [race, state])
_ck(_mesh_vertex_near_hand(pv, attached, RIGHT_BONE[job]),
"race %d %s: 03150 grip geometry reaches right hand" % [race, state])
var left_grip_tolerance := 0.40 if race == 4 else MAX_MESH_TO_HAND_M
_ck(_mesh_reaches_hand(pv, attached, "Bip01 L Hand", left_grip_tolerance),
"race %d %s: two-hand pose places left hand on 03150" % [race, state])
pv.play_attack_motion(3, 14, 1.0)
for i in 3:
await process_frame
_ck(String(pv.anim.get("anim_path")).contains("/twohand_sword/combo_01.msa"),
"race %d: 03150 attack uses twohand_sword combo_01" % race)
pv.queue_free()
await process_frame
func _hand_position(pv: Node, bone: String) -> Variant:
var pose: Dictionary = pv.anim.call("get_effect_bone_pose", bone)
if pose.is_empty():
printerr(" bone %s has no pose" % bone)
return null
return (pv.model as Node3D).global_transform * (pose["transform"] as Transform3D).origin
func _origin_bounded(pv: Node, node: Node3D, bone: String) -> bool:
var hand_v: Variant = _hand_position(pv, bone)
if hand_v == null:
return false
var hand: Vector3 = hand_v
var d := node.global_position.distance_to(hand)
if d > MAX_ORIGIN_M:
printerr(" %s is %.3f m from %s" % [node.name, d, bone])
return d <= MAX_ORIGIN_M
func _mesh_reaches_hand(pv: Node, node: MeshInstance3D, bone: String,
max_distance: float = MAX_MESH_TO_HAND_M) -> bool:
var hand_v: Variant = _hand_position(pv, bone)
if hand_v == null:
return false
var hand: Vector3 = hand_v
var local := node.get_aabb()
var world_box := AABB()
var have := false
for corner in 8:
var p := local.position + Vector3(
local.size.x if (corner & 1) != 0 else 0.0,
local.size.y if (corner & 2) != 0 else 0.0,
local.size.z if (corner & 4) != 0 else 0.0)
p = node.global_transform * p
world_box = world_box.expand(p) if have else AABB(p, Vector3.ZERO)
have = true
var nearest := Vector3(
clampf(hand.x, world_box.position.x, world_box.end.x),
clampf(hand.y, world_box.position.y, world_box.end.y),
clampf(hand.z, world_box.position.z, world_box.end.z))
var d := hand.distance_to(nearest)
if d > max_distance:
printerr(" %s visible AABB is %.3f m from %s (box=%s)" % [node.name, d, bone, world_box])
return d <= max_distance
func _mesh_vertex_near_hand(pv: Node, node: MeshInstance3D, bone: String) -> bool:
var hand_v: Variant = _hand_position(pv, bone)
if hand_v == null or node.mesh == null:
return false
var hand: Vector3 = hand_v
var nearest := INF
for surface in node.mesh.get_surface_count():
var arrays := node.mesh.surface_get_arrays(surface)
if arrays.size() <= Mesh.ARRAY_VERTEX or not arrays[Mesh.ARRAY_VERTEX] is PackedVector3Array:
continue
for local_point in arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array:
nearest = minf(nearest, hand.distance_to(node.global_transform * local_point))
if nearest > MAX_VERTEX_TO_HAND_M:
printerr(" %s nearest visible vertex is %.3f m from %s" % [node.name, nearest, bone])
return nearest <= MAX_VERTEX_TO_HAND_M