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

272 lines
8.7 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.
extends SceneTree
# combat_parity_test —— 验证 40250 战斗与视觉对齐:
# 1. 防御碰撞球骨骼世界矩阵绑定(ActorInstanceCollisionDetection.cpp:33-70
# 2. 击退撞墙地形阻挡截断(InstanceBase.cpp BlockMovement / is_blocked
# 3. 攻击挥刀动作期间移动锁定(ActorInstanceMotion.cpp:472 isLock / PythonPlayerInput.cpp:457
# 4. 头顶伤害与 MISS 飘字官方 damagevalue/ DDS 贴图与排版(InstanceBaseEffect.cpp:150-205
const NetPlay = preload("res://net_play.gd")
const NetWorld = preload("res://net_world.gd")
const DamageEffect = preload("res://damage_effect.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
const PlayerCtl = preload("res://player_controller.gd")
const PlayerView = preload("res://ui/player_view.gd")
const MobView = preload("res://ui/mob_view.gd")
var failures := 0
func check(ok: bool, msg: String) -> void:
if ok:
print("OK: %s" % msg)
else:
failures += 1
printerr("FAIL: %s" % msg)
func _init() -> void:
call_deferred("run_all")
func run_all() -> void:
print("--- Running combat_parity_test ---")
test_bone_defending_spheres()
test_knockback_collision_blocking()
test_attack_lock()
test_damage_billboard_textures()
test_actor_shake()
test_resist_fallen()
test_skill_cancel_window()
print("combat_parity_test finished with %d failures" % failures)
quit(1 if failures > 0 else 0)
# 1. 骨骼防御球测试
func test_bone_defending_spheres() -> void:
var np := NetPlay.new()
var vnode := Node3D.new()
root.add_child(vnode)
vnode.position = Vector3(10, 0, 5)
# Mock model
var model := Node3D.new()
model.name = "Metin2Model"
vnode.add_child(model)
# Mock anim
var anim := Node.new()
anim.name = "Metin2AnimPlayer"
var script := GDScript.new()
script.source_code = """
extends Node
func get_effect_bone_pose(bone: String) -> Dictionary:
if bone == "Bip01 Spine":
return {"transform": Transform3D(Basis.IDENTITY, Vector3(0, 1.0, 0))}
return {}
"""
script.reload()
anim.set_script(script)
vnode.add_child(anim)
# Mock get_defending_spheres
var vnode_script := GDScript.new()
vnode_script.source_code = """
extends Node3D
var model: Node3D
var anim: Node
func get_defending_spheres() -> Array:
return [
{"radius": 50.0, "pos": Vector3(0, 50.0, 0), "bone": "Bip01 Spine"},
{"radius": 40.0, "pos": Vector3(0, 0, 80.0), "bone": ""}
]
"""
vnode_script.reload()
vnode.set_script(vnode_script)
vnode.set("model", model)
vnode.set("anim", anim)
var v_pos := NetPlay._actor_cm(NetPlay._world_pos(vnode))
var spheres: Array = np._defending_spheres(1234, vnode, v_pos)
check(spheres.size() == 2, "2 defending spheres produced")
var sp0: Dictionary = spheres[0]
check(float(sp0.radius) == 50.0, "sphere 0 radius is 50")
check(sp0.pos.is_equal_approx(Vector3(1000, -500, 150)), "bone defending sphere accurately transformed with bone matrix")
var sp1: Dictionary = spheres[1]
check(float(sp1.radius) == 40.0, "sphere 1 radius is 40")
vnode.queue_free()
np.queue_free()
# 2. 击退地形阻挡测试
func test_knockback_collision_blocking() -> void:
var nw := NetWorld.new()
root.add_child(nw)
var client_script := GDScript.new()
client_script.source_code = """
extends Node
signal entity_spawned(d)
signal entity_despawned(vid)
signal entity_main_set(vid)
signal vitals_changed(vid, hp, sp)
signal entity_dead(vid)
signal damage(vid, amount, flag)
signal chat(t, v, s)
signal entity_moved(vid, pos)
func is_in_game() -> bool: return true
func get_entity(vid: int) -> Dictionary:
return {"vid": vid, "pos": Vector3(10.0, 0.0, -20.0)}
"""
client_script.reload()
var fake_client := Node.new()
fake_client.set_script(client_script)
root.add_child(fake_client)
var mount := Node3D.new()
root.add_child(mount)
nw.setup(fake_client, mount)
# Spawn dummy entity
var ent_node := Node3D.new()
mount.add_child(ent_node)
nw._by_vid[555] = ent_node
# Mock world with blocked region
var world_script := GDScript.new()
world_script.source_code = """
extends Node
func is_blocked(x: float, z: float) -> bool:
return x >= 11.0
func to_world(pos: Vector3) -> Vector3:
return Vector3(pos.x / 100.0, 0, -pos.y / 100.0)
"""
world_script.reload()
var fake_world := Node.new()
fake_world.set_script(world_script)
nw.world = fake_world
# Push victim towards +x by force 5.0
nw.push_victim(555, Vector2(1, 0), 5.0)
check(nw.is_pushing(555), "victim is in pushing state initially")
# Step physics push until collision
var e: Dictionary = fake_client.call("get_entity", 555)
for i in range(10):
var pos: Vector3 = nw._pushed_position(555, e, 0.05, Vector3(10, 0, -20))
if not nw.is_pushing(555):
break
var cur_pos: Vector3 = nw._pushed_position(555, e, 0.05, Vector3(10, 0, -20))
check(cur_pos.x <= 11.05, "knockback stopped by is_blocked at boundary (x=%f <= 11.05)" % cur_pos.x)
mount.queue_free()
fake_client.queue_free()
fake_world.queue_free()
nw.queue_free()
# 3. 攻击动作锁定移动测试
func test_attack_lock() -> void:
var np := NetPlay.new()
root.add_child(np)
var pc := PlayerCtl.new()
var player_node := Node3D.new()
root.add_child(player_node)
pc.player = player_node
root.add_child(pc)
np.pc = pc
check(not np.is_lock(), "initially not locked")
check(not pc.locked, "pc.locked initially false")
# Start swing
np._emit_swing(1, false)
np._process(0.016)
check(np.is_lock(), "is_lock() is true during attack swing")
check(pc.locked, "pc.locked is true during attack swing")
# Advance past attack duration
np._local_time += 2.0
np._process(0.016)
check(not np.is_lock(), "is_lock() is false after attack ends")
check(not pc.locked, "pc.locked is false after attack ends")
player_node.queue_free()
pc.queue_free()
np.queue_free()
# 4. 伤害数字 DDS 资源与 Billboard 测试
func test_damage_billboard_textures() -> void:
var assets_path := AssetRoot.path()
if not AssetRoot.available():
print("SKIP: damage DDS textures test (assets directory not present)")
return
var target_0: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/target_0.dds")
check(target_0 != null, "target_0.dds loaded successfully via UiAssets")
if target_0:
check(target_0.get_width() > 0 and target_0.get_height() > 0, "target_0.dds has valid dimensions (%dx%d)" % [target_0.get_width(), target_0.get_height()])
var target_miss: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/target_miss.dds")
check(target_miss != null, "target_miss.dds loaded successfully via UiAssets")
var damage_0: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/damage_0.dds")
check(damage_0 != null, "damage_0.dds loaded successfully via UiAssets")
var damage_miss: Texture2D = UiAssets.load_tex(assets_path, "d:/ymir work/effect/affect/damagevalue/damage_miss.dds")
check(damage_miss != null, "damage_miss.dds loaded successfully via UiAssets")
# 5. 受击瞬时微震颤测试(ActorInstanceBattle.cpp __Shake
func test_actor_shake() -> void:
var pv := PlayerView.new()
var mv := MobView.new()
root.add_child(pv)
root.add_child(mv)
check(pv.has_method("shake"), "PlayerView has shake() method")
check(mv.has_method("shake"), "MobView has shake() method")
pv.shake()
check(pv._hit.shake_left > 0.0, "PlayerView shake timer active after shake()")
mv.shake()
check(mv._hit.shake_left > 0.0, "MobView shake timer active after shake()")
pv.queue_free()
mv.queue_free()
# 6. 霸体抗击退测试(ActorInstanceBattle.cpp IsResistFallen
func test_resist_fallen() -> void:
var np := NetPlay.new()
# 普通怪 / 普通角色无霸体
var normal_ent := {"race": 101, "affect_flags": 0}
check(not np._is_resist_fallen(normal_ent), "normal entity is not resist fallen")
# 铁布衫 / 天罡气 (AFFECT_CHEONGEUN 1 << 16)
var iron_body_ent := {"race": 101, "affect_flags": 1 << 16}
check(np._is_resist_fallen(iron_body_ent), "AFFECT_CHEONGEUN entity is resist fallen")
check(not np._can_push(iron_body_ent, 999, false), "resist fallen entity cannot be pushed")
# 巨型怪物 (IS_HUGE_RACE 2493)
var huge_ent := {"race": 2493, "affect_flags": 0}
check(np._is_resist_fallen(huge_ent), "huge race entity is resist fallen")
check(not np._can_push(huge_ent, 888, true), "huge entity cannot be pushed")
np.queue_free()
# 7. 技能后摇打断测试(ActorInstanceMotion.cpp IsCancelEnableSkill
func test_skill_cancel_window() -> void:
var np := NetPlay.new()
# 常规技能在施法期间锁定移动
np.start_skill_cast(2.0, false, false)
check(np.is_lock() == true, "normal skill is locked during cast")
# 带 cancel_enable 的技能在打断窗口内允许移动
np.start_skill_cast(2.0, false, true)
check(np.is_lock() == false, "cancel_enable skill does not lock movement")
np._on_motion_bound("wait")
check(np.is_lock() == false, "is_lock is false after wait")
np.queue_free()