fix(parity): implement ground click ripple, target select glow, and 3D projectile obstacle collision with regression probes

This commit is contained in:
shen
2026-09-19 17:01:50 -07:00
parent 66d217b313
commit 5d43682498
6 changed files with 357 additions and 6 deletions
+19 -6
View File
@@ -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:
+8
View File
@@ -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.
+23
View File
@@ -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 射线拾到的角色 vid0 = 无)
# §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):
+2
View File
@@ -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 OnRenderwndMgr.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。
+122
View File
@@ -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])
+183
View File
@@ -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])