From 5d43682498de0508f506f9582ea76e9e137d6650 Mon Sep 17 00:00:00 2001 From: shen <> Date: Sat, 19 Sep 2026 17:01:50 -0700 Subject: [PATCH] fix(parity): implement ground click ripple, target select glow, and 3D projectile obstacle collision with regression probes --- project/fly_object.gd | 25 ++- project/game_scene.gd | 8 + project/net_world.gd | 23 +++ project/player_controller.gd | 2 + project/test_all_map_height_probe.gd | 122 ++++++++++++++ project/test_click_target_effect_parity.gd | 183 +++++++++++++++++++++ 6 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 project/test_all_map_height_probe.gd create mode 100644 project/test_click_target_effect_parity.gd diff --git a/project/fly_object.gd b/project/fly_object.gd index 4dcd8a99..3f003397 100644 --- a/project/fly_object.gd +++ b/project/fly_object.gd @@ -196,12 +196,25 @@ class FlyInstance extends RefCounted: _explode() return false - if data.hit_on_background and _world and _world.has_method("sample_height"): - var gh := float(_world.call("sample_height", pos.x, pos.z)) - if gh > pos.y: - _emit("at_background") - _explode() - return false + if data.hit_on_background: + if _world and _world.has_method("sample_height"): + var gh := float(_world.call("sample_height", pos.x, pos.z)) + if gh > pos.y: + _emit("at_background") + _explode() + return false + # 40250 CFlyingInstance::Update FCheckBackgroundDuringFlying: + # 空间 3D 建筑与静态障碍物碰撞(碰撞层 2: StaticBody3D) + if _world is Node3D and _world.is_inside_tree(): + var w3d: World3D = _world.get_world_3d() + if w3d and w3d.direct_space_state: + var q := PhysicsRayQueryParameters3D.create(last_pos, pos, 2) + var hit := w3d.direct_space_state.intersect_ray(q) + if not hit.is_empty(): + pos = hit.position + _emit("at_background") + _explode() + return false return true func _explode(bomb := true) -> void: diff --git a/project/game_scene.gd b/project/game_scene.gd index 733b0f5a..8727213b 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -239,6 +239,9 @@ func setup(m2client: Node, assets_root: String, # §8.8 game.py OnRender chr.Pick() -> ShowCharacterTextTail:悬停角色也强显名字(pick_show.gd)。 if pc.has_signal("hover_entity_changed") and net_world and net_world.has_method("set_hover_vid"): pc.hover_entity_changed.connect(func(vid: int): net_world.set_hover_vid(int(vid))) + # 40250 EFFECT_PICK: 鼠标点地时生成绿色地面水波光圈 click.mse + if pc.has_signal("ground_clicked"): + pc.ground_clicked.connect(_on_ground_clicked) if _assets != "": net_play.set_asset_root(_assets) # §3.5:加载 playersettingmodule.py 连击段表 @@ -749,6 +752,11 @@ func _on_pickable_added(node: Node3D, _vid: int) -> void: func _on_pickable_removed(vid: int) -> void: _drop_stale_pickables(vid) +# 40250 EFFECT_PICK: 鼠标点地时生成绿色地面水波光圈 click.mse +func _on_ground_clicked(pos: Vector3) -> void: + if fx and is_instance_valid(_mount): + fx.spawn_at("d:/ymir work/effect/etc/click/click.mse", _mount, pos, true) + func _lifecycle_node() -> Node: # AppFlow owns the single lifecycle observer across login/select/game. The # game scene only borrows it for Android back and focus recovery. diff --git a/project/net_world.gd b/project/net_world.gd index 82880f26..af27beac 100644 --- a/project/net_world.gd +++ b/project/net_world.gd @@ -131,6 +131,7 @@ var _fading := {} # vid:int -> true while a culled node twee var _always_show_name := NameShow.ALWAYS_SHOW_NAME_DEFAULT var _alt_held := false var _target_vid := 0 +var _target_effect: Node3D = null var _hover_vid := 0 # player_controller._update_hover_cursor 射线拾到的角色 vid(0 = 无) # §8.8 / §5.4 伤害飘字(damage_effect.gd)。CPythonSystem::IsShowDamage 门(uigameoption # 「伤害数字」radio)+ CInstanceBase::m_DamageQueue(每帧 pop 一条)+ ProcessDamage 分类。 @@ -280,12 +281,30 @@ func set_always_show_name(on: bool) -> void: func set_show_damage(on: bool) -> void: _show_damage = on +func _detach_target_effect() -> void: + if is_instance_valid(_target_effect): + _target_effect.queue_free() + _target_effect = null + +func _attach_target_effect(node: Node3D) -> void: + _detach_target_effect() + if not is_instance_valid(node) or fx == null or not fx.has_method("spawn"): + return + # 40250 EFFECT_TARGET (click_glow_select.mse): 挂在目标脚下的循环旋转发光光圈 + _target_effect = fx.spawn("d:/ymir work/effect/etc/click/click_glow_select.mse", node, false) + if _target_effect: + _target_effect.position = Vector3.ZERO + # targetBoard.GetTargetVID():目标始终强显名字(game.py OnRender ShowCharacterTextTail(targetVID))。 +# 40250 __AttachTargetEffect / __DetachTargetEffect (EFFECT_TARGET: click_glow_select.mse) func set_target_vid(vid: int) -> void: if _target_vid == vid: return var prev := _target_vid _target_vid = vid + _detach_target_effect() + if vid != 0 and _by_vid.has(vid): + _attach_target_effect(_by_vid[vid]) for v in [prev, vid]: if v != 0 and _by_vid.has(v): _set_name_visibility(_by_vid[v]) @@ -445,6 +464,8 @@ func _on_spawn(d: Dictionary, allow_defer: bool = false) -> void: shape = "TALK" node.set_meta("cursor_shape", shape) _by_vid[vid] = node + if vid == _target_vid: + _attach_target_effect(node) _sync_hp_bar(vid, d) _set_name_visibility(node) _refresh_shop_sign(node, str(d.get("shop_sign", ""))) @@ -603,6 +624,8 @@ func _detach_text_tail(n: Node3D) -> void: # §2.1 cull / 40250 DeleteBlendOut: keep the data row, blend the node out. It stays discoverable via # _fading (so it is not rebuilt mid-blend) until the tween frees it. func _fade_and_free(vid: int) -> void: + if vid == _target_vid: + _detach_target_effect() var n: Node3D = _by_vid.get(vid, null) _by_vid.erase(vid) if not is_instance_valid(n): diff --git a/project/player_controller.gd b/project/player_controller.gd index bb4c3028..3f44c441 100644 --- a/project/player_controller.gd +++ b/project/player_controller.gd @@ -10,6 +10,7 @@ extends Node signal anim_state(state: String) signal target_selected(node: Node3D) +signal ground_clicked(world_pos: Vector3) signal ground_item_clicked(vid: int) signal moved(pos: Vector3) # game.py OnRender:wndMgr.IsPickedWindow 门内 chr.Pick() 拾到的角色 -> ShowCharacterTextTail。 @@ -251,6 +252,7 @@ func _on_click(screen_pos: Vector2) -> void: return if cancel_fishing_input.is_valid() and bool(cancel_fishing_input.call()): return + ground_clicked.emit(Vector3(hit.x, hit.y, hit.z)) _goto(hit) # 射线 vs 实体(节点下所有 MeshInstance3D 的世界 AABB 合并)的进入距离 t;不命中返回 -1。 diff --git a/project/test_all_map_height_probe.gd b/project/test_all_map_height_probe.gd new file mode 100644 index 00000000..e17962d9 --- /dev/null +++ b/project/test_all_map_height_probe.gd @@ -0,0 +1,122 @@ +# test_all_map_height_probe.gd +# 严格按照《CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md》手段 1(全图几何与阻挡坐标探针): +# 自动化探针遍历多个核心地图(OutdoorA1、OutdoorC1 等),全面巡检跨河桥梁、高架栈道与悬索吊桥: +# 1. 验证物体中心及行进跨度内 sample_height 稳定采样到表面高度,不跌入河床裸地形; +# 2. 验证桥面行走通道未被 is_blocked 错误拦截; +# 3. 验证侧向偏离点正确回退到裸地形标高; +# 4. 验证高度数据不存在 NaN / Inf 异常。 +extends SceneTree + +const ASSETS_PATH := "res://../assets" + +func _init() -> void: + call_deferred("_run") + +func _run() -> void: + print("=== Starting test_all_map_height_probe (Headless Coordinate Probe Suite) ===") + var fails := [0] + var check = func(cond: bool, msg: String): + if not cond: + fails[0] += 1 + printerr("FAIL: ", msg) + else: + print("PASS: ", msg) + + if not ClassDB.class_exists("Metin2World"): + printerr("FAIL: Metin2World class not found") + quit(1) + return + + var world: Node = ClassDB.instantiate("Metin2World") + get_root().add_child(world) + var assets_abs := ProjectSettings.globalize_path(ASSETS_PATH) + world.set("assets_root", assets_abs) + world.set("objects_enabled", true) + world.set("water_enabled", true) + world.set("load_radius_tiles", -1) + + # ------------------------------------------------------------- + # 巡检 1: OutdoorA1/metin2_map_a1 跨河石桥与悬索吊桥 + # ------------------------------------------------------------- + print("\n--- Probing Map 1: OutdoorA1/metin2_map_a1 ---") + world.set("map_path", "OutdoorA1/metin2_map_a1") + var ok: bool = bool(world.call("load_map")) + check.call(ok, "OutdoorA1 load_map succeeded") + + if ok: + # 桥梁 1: a1-024-10m-bridge (Chunk 002002) + var b1_x := 761.57 + var b1_z := 594.55 + var h1: float = float(world.call("sample_height", b1_x, b1_z)) + var bl1: bool = bool(world.call("is_blocked", b1_x, b1_z)) + print(" A1 Bridge 1 (a1-024-10m-bridge) at (%f, %f): height=%f m, blocked=%s" % [b1_x, b1_z, h1, bl1]) + check.call(h1 >= 204.0 and h1 <= 208.0, "Bridge 1 deck elevation in expected range (204m~208m), got %f" % h1) + check.call(not bl1, "Bridge 1 walking lane is NOT blocked (is_blocked == false)") + + # 跨桥连续性巡检 (沿 x 轴跨度从 751 到 771) + var span_continuous := true + for dx in range(-10, 11, 2): + var step_x := b1_x + float(dx) + var step_h: float = float(world.call("sample_height", step_x, b1_z)) + var step_bl: bool = bool(world.call("is_blocked", step_x, b1_z)) + if step_h < 203.0 or step_bl: + span_continuous = false + printerr(" Span defect at x=%f: height=%f (expected >= 203), blocked=%s" % [step_x, step_h, step_bl]) + break + check.call(span_continuous, "Bridge 1 walking span maintains elevated deck and walkable status across entire length") + + # 桥侧河床自然回退验证 + var off_h: float = float(world.call("sample_height", b1_x, 570.0)) + check.call(off_h < 200.0, "Off-bridge point correctly samples riverbed terrain (%f m < 200m)" % off_h) + + # 桥梁 2: 悬索吊桥 (general_obj_suspension bridge01) + var b2_x := 213.91 + var b2_z := 700.45 + var h2: float = float(world.call("sample_height", b2_x, b2_z)) + var bl2: bool = bool(world.call("is_blocked", b2_x, b2_z)) + print(" A1 Bridge 2 (suspension bridge) at (%f, %f): height=%f m, blocked=%s" % [b2_x, b2_z, h2, bl2]) + check.call(h2 >= 130.0 and h2 <= 140.0, "Suspension bridge deck in expected range (130m~140m), got %f" % h2) + check.call(not bl2, "Suspension bridge walking deck is NOT blocked") + + # ------------------------------------------------------------- + # 巡检 2: OutdoorC1/metin2_map_c1 跨河石桥 + # ------------------------------------------------------------- + print("\n--- Probing Map 2: OutdoorC1/metin2_map_c1 ---") + world.call("unload_map") + world.set("map_path", "OutdoorC1/metin2_map_c1") + var ok_c1: bool = bool(world.call("load_map")) + check.call(ok_c1, "OutdoorC1 load_map succeeded") + + if ok_c1: + # C1 跨河石桥: c1-022-10m-bridge (位于 Chunk 001001, areadata pos: 35755.88, -50457.72, 16555.45, bias=-705.0) + var c1_x := 357.56 + var c1_z := 504.58 + var c1_h: float = float(world.call("sample_height", c1_x, c1_z)) + var c1_bl: bool = bool(world.call("is_blocked", c1_x, c1_z)) + print(" C1 Bridge (c1-022-10m-bridge) at (%f, %f): height=%f m, blocked=%s" % [c1_x, c1_z, c1_h, c1_bl]) + check.call(c1_h >= 170.0 and c1_h <= 180.0, "C1 Bridge deck elevation in expected range (170m~180m), got %f" % c1_h) + check.call(not c1_bl, "C1 Bridge walking lane is NOT blocked") + + # 跨桥连续性巡检 (沿 z 轴方向,因为 rot=90度,桥面沿南北走向展开) + var c1_span_continuous := true + for dz in range(-12, 13, 3): + var step_z := c1_z + float(dz) + var step_h: float = float(world.call("sample_height", c1_x, step_z)) + var step_bl: bool = bool(world.call("is_blocked", c1_x, step_z)) + if step_h < 172.0 or step_bl: + c1_span_continuous = false + printerr(" C1 Span defect at z=%f: height=%f (expected >= 172), blocked=%s" % [step_z, step_h, step_bl]) + break + check.call(c1_span_continuous, "C1 Bridge walking span maintains elevated deck across entire span") + + # 侧向远离桥梁 15m 的点 (河道): (357.56 - 15.0, 504.58) + var c1_off_x := c1_x - 15.0 + var c1_off_h: float = float(world.call("sample_height", c1_off_x, c1_z)) + print(" C1 Off-bridge river at (%f, %f): height=%f m (< bridge %f m)" % [c1_off_x, c1_z, c1_off_h, c1_h]) + check.call(c1_off_h < c1_h - 1.0, "C1 Off-bridge point drops below bridge deck (%f m < %f m)" % [c1_off_h, c1_h]) + + world.call("unload_map") + world.queue_free() + + print("\n=== All Map Height Probes finished with %d failures ===" % fails[0]) + quit(fails[0]) diff --git a/project/test_click_target_effect_parity.gd b/project/test_click_target_effect_parity.gd new file mode 100644 index 00000000..a2fc2e0c --- /dev/null +++ b/project/test_click_target_effect_parity.gd @@ -0,0 +1,183 @@ +# test_click_target_effect_parity.gd +# 严格按照《CLIENT-PARITY-AUDIT-AND-FIX-GUIDE.md》手段 2 与工程原则 3: +# 验证 40250 地面点选波纹 (EFFECT_PICK / click.mse)、目标光圈 (EFFECT_TARGET / click_glow_select.mse) +# 与飞行物建筑碰撞 (FCheckBackgroundDuringFlying) 1:1 对拍测试。 +extends SceneTree + +const PlayerController = preload("res://player_controller.gd") +const NetWorld = preload("res://net_world.gd") +const FlyObject = preload("res://fly_object.gd") + +class MockFx extends RefCounted: + var spawned_effects: Array = [] + func spawn(effect_name: String, parent_node: Node3D, looping: bool) -> Node3D: + spawned_effects.append({"name": effect_name, "parent": parent_node, "looping": looping}) + var eft := Node3D.new() + eft.name = "mock_fx_" + effect_name.get_file().get_basename() + parent_node.add_child(eft) + return eft + func spawn_at(effect_name: String, parent_node: Node3D, pos: Vector3, looping: bool) -> Node3D: + spawned_effects.append({"name": effect_name, "parent": parent_node, "pos": pos, "looping": looping}) + var eft := Node3D.new() + eft.name = "mock_fx_" + effect_name.get_file().get_basename() + parent_node.add_child(eft) + eft.position = pos + return eft + +func _init() -> void: + call_deferred("_run") + +func _run() -> void: + print("=== Starting test_click_target_effect_parity ===") + var fails := [0] + var check = func(cond: bool, msg: String): + if not cond: + fails[0] += 1 + printerr("FAIL: ", msg) + else: + print("PASS: ", msg) + + # ------------------------------------------------------------- + # Test 1: PlayerController ground_clicked 信号与坐标 + # ------------------------------------------------------------- + print("\n--- Test 1: Ground Click Signal & Position ---") + var pc := PlayerController.new() + root.add_child(pc) + var player_node := Node3D.new() + root.add_child(player_node) + player_node.position = Vector3(100.0, 50.0, 100.0) + pc.player = player_node + pc.active = true + + # 模拟世界 + var mock_world = Node.new() + var w_script = GDScript.new() + w_script.source_code = """ +extends Node +func sample_height(x: float, z: float) -> float: + return 25.0 +func is_blocked(_x: float, _z: float) -> bool: + return false +""" + w_script.reload() + mock_world.set_script(w_script) + root.add_child(mock_world) + pc.world = mock_world + + var ground_clicked_pos := [Vector3.ZERO] + var ground_clicked_count := [0] + pc.ground_clicked.connect(func(pos: Vector3): + ground_clicked_count[0] += 1 + ground_clicked_pos[0] = pos + ) + + # 模拟鼠标输入事件点地 (从上方向下发射射线) + # _ray_ground(from, dir): from=(110, 50, 110), dir=(0, -1, 0) -> 命中 y=25.0 处 + var click_hit = pc._ray_ground(Vector3(110.0, 50.0, 110.0), Vector3(0.0, -1.0, 0.0)) + check.call(click_hit != null and absf(click_hit.y - 25.0) < 0.01, "Mock ray_ground hits surface at y=25.0") + + # 手动触发点地流程并验证信号发射 + pc.ground_clicked.emit(click_hit) + check.call(ground_clicked_count[0] == 1, "ground_clicked signal emitted") + check.call(ground_clicked_pos[0].is_equal_approx(Vector3(110.0, 25.0, 110.0)), "ground_clicked emitted exact world position") + + # ------------------------------------------------------------- + # Test 2: NetWorld 目标光圈 AttachTargetEffect / DetachTargetEffect + # ------------------------------------------------------------- + print("\n--- Test 2: NetWorld Target Effect (click_glow_select.mse) ---") + var nw := NetWorld.new() + root.add_child(nw) + + var mock_fx := MockFx.new() + nw.fx = mock_fx + + # 添加一个实体 + var target_entity := Node3D.new() + root.add_child(target_entity) + target_entity.position = Vector3(200.0, 10.0, 300.0) + var vid := 5001 + nw.set("_by_vid", {vid: target_entity}) + + # 选中目标: set_target_vid(5001) + nw.set_target_vid(vid) + check.call(mock_fx.spawned_effects.size() == 1, "Target effect spawned on set_target_vid") + var last_fx: Dictionary = mock_fx.spawned_effects[0] + check.call("click_glow_select.mse" in last_fx.name, "Spawned effect is click_glow_select.mse") + check.call(last_fx.parent == target_entity, "Target effect attached to target entity") + check.call(not last_fx.looping, "Target effect configured as looping (one_shot=false)") + + # 取消选中: set_target_vid(0) + nw.set_target_vid(0) + check.call(nw._target_effect == null, "Target effect detached on set_target_vid(0)") + + # 再次选中并在实体被 _fade_and_free 销毁时验证分离 + nw.set_target_vid(vid) + check.call(nw._target_effect != null, "Target effect re-attached on selecting target again") + nw._fade_and_free(vid) + check.call(nw._target_effect == null, "Target effect cleanly detached on entity removal/death") + + # ------------------------------------------------------------- + # Test 3: Flying Instance 3D Static Obstacle Collision + # ------------------------------------------------------------- + print("\n--- Test 3: Flying Instance 3D Static Obstacle Collision ---") + # 3. 飞行物与静态建筑/障碍物碰撞 (CFlyingInstance::FCheckBackgroundDuringFlying) + # 构造一个挂在场景树中的 Node3D 作为 world,以及一个 StaticBody3D 障碍物 + var fly_mgr := FlyObject.new() + var fly_mount := Node3D.new() + var fly_world := Node3D.new() + root.add_child(fly_mount) + root.add_child(fly_world) + + var wall := StaticBody3D.new() + wall.collision_layer = 2 + wall.collision_mask = 0 + var col_shape := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(10.0, 20.0, 1.0) # x=10m, y=20m, z=1m 厚的墙 + col_shape.shape = box + wall.add_child(col_shape) + wall.position = Vector3(0.0, 10.0, 50.0) # 位于 z=50m 处 + fly_world.add_child(wall) + + # 等待 2 帧让 PhysicsServer3D 完成场景树内静态碰撞体的注册 + for _f in range(2): + await process_frame + + fly_mgr.setup(fly_mount, fly_world) + fly_mgr.manual_step = true + var fly_data := FlyObject.FlyData.new() + fly_data.hit_on_background = true + fly_data.init_vel = 100.0 # 100 m/s + fly_data.flat_range = 200.0 + + # 发射一颗射弹从 z=0 射向 z=100 (穿过 z=50 的墙) + var recorded_events := [] + var inst = fly_mgr.spawn(Vector3(0.0, 10.0, 0.0), Vector3(0.0, 10.0, 100.0), false, fly_data) + inst.handler = func(ev: String, p: Vector3, _v: int): + recorded_events.append({"ev": ev, "pos": p}) + + # 推进时间让射弹穿过墙体 + for i in range(10): + if not inst.alive: + break + fly_mgr.step(0.1) # 10m 步长 + + var hit_bg = false + for e in recorded_events: + if e.ev == "at_background": + hit_bg = true + check.call(not inst.alive, "Projectile stopped and exploded") + check.call(hit_bg, "Projectile hit static obstacle and triggered at_background") + + # 清理 + pc.queue_free() + player_node.queue_free() + mock_world.queue_free() + target_entity.queue_free() + nw.queue_free() + wall.queue_free() + fly_mount.queue_free() + fly_world.queue_free() + + print("\n=== All Click, Target & Fly Parity Tests finished with %d failures ===" % fails[0]) + quit(fails[0])