- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
272 lines
8.7 KiB
GDScript
272 lines
8.7 KiB
GDScript
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()
|