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()