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 客户端对拍缺陷发现与修复工程指南
This commit is contained in:
shen
2026-09-19 08:51:25 -07:00
parent 1db9e9a129
commit 66d217b313
650 changed files with 53046 additions and 1586 deletions
+167 -13
View File
@@ -16,6 +16,7 @@ var _attached: Dictionary = {}
var _targets: Array[Dictionary] = []
var _touch_index := -1
var _touch_position := Vector2.ZERO
var world_drop_handler: Callable
func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) -> void:
cursor = cursor_manager
@@ -42,6 +43,18 @@ func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) -
_overlay.add_child(_label)
set_process_input(true)
var _drag_started := false
var _attach_pos := Vector2(-1, -1)
var _just_attached := false
func _on_attach() -> void:
_drag_started = false
_just_attached = true
if is_inside_tree():
_attach_pos = get_viewport().get_mouse_position()
else:
_attach_pos = Vector2(-1, -1)
func attach_item(window: int, cell: int, vnum: int, count: int,
texture: Texture2D = null, source := "inventory", metadata: Dictionary = {},
touch_index := -1, touch_position := Vector2(-1, -1)) -> bool:
@@ -69,6 +82,7 @@ func attach_item(window: int, cell: int, vnum: int, count: int,
if cursor and cursor.has_method("set_cursor"):
cursor.set_cursor(CursorManager.ITEM)
_play_ui("pick.wav")
_on_attach()
return true
func attach_money(amount: int, source := "inventory") -> bool:
@@ -89,12 +103,67 @@ func attach_money(amount: int, source := "inventory") -> bool:
if cursor and cursor.has_method("set_cursor"):
cursor.set_cursor(CursorManager.ITEM)
_play_ui("pick.wav")
_on_attach()
return true
func attach_skill(skill_id: int, slot_idx: int = 0, texture: Texture2D = null,
skill_name := "", touch_index := -1, touch_position := Vector2(-1, -1)) -> bool:
if skill_id <= 0:
return false
_attached = {
"source": "skill",
"skill_id": skill_id,
"slot_idx": slot_idx,
}
_touch_index = touch_index
if touch_position.x >= 0.0 and touch_position.y >= 0.0:
_touch_position = touch_position
if _icon:
_icon.texture = texture
_icon.visible = texture != null
if _label:
_label.text = skill_name if skill_name != "" else "技能 %d" % skill_id
_label.visible = true
if _overlay:
_overlay.visible = true
if cursor and cursor.has_method("set_cursor"):
cursor.set_cursor(CursorManager.ITEM)
_play_ui("pick.wav")
_on_attach()
return true
func attach_emotion(emote_id: int, texture: Texture2D = null,
emote_name := "", touch_index := -1, touch_position := Vector2(-1, -1)) -> bool:
if emote_id <= 0:
return false
_attached = {
"source": "emotion",
"emote_id": emote_id,
}
_touch_index = touch_index
if touch_position.x >= 0.0 and touch_position.y >= 0.0:
_touch_position = touch_position
if _icon:
_icon.texture = texture
_icon.visible = texture != null
if _label:
_label.text = emote_name if emote_name != "" else "动作 %d" % emote_id
_label.visible = true
if _overlay:
_overlay.visible = true
if cursor and cursor.has_method("set_cursor"):
cursor.set_cursor(CursorManager.ITEM)
_play_ui("pick.wav")
_on_attach()
return true
func cancel() -> void:
_attached.clear()
_touch_index = -1
_touch_position = Vector2.ZERO
_drag_started = false
_attach_pos = Vector2(-1, -1)
_just_attached = false
if _overlay:
_overlay.visible = false
if cursor and cursor.has_method("reset"):
@@ -128,47 +197,132 @@ func _process(_dt: float) -> void:
if c == null or not is_instance_valid(c):
_targets.remove_at(i)
func _set_handled() -> void:
var vp := get_viewport()
if vp:
vp.set_input_as_handled()
func _input(event: InputEvent) -> void:
if not is_attached():
return
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
cancel()
get_viewport().set_input_as_handled()
_set_handled()
return
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
cancel()
get_viewport().set_input_as_handled()
_set_handled()
return
if event is InputEventMouseButton and not event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_drop_at(event.position)
get_viewport().set_input_as_handled()
if event is InputEventMouseMotion:
if _attach_pos.x >= 0.0 and (_attach_pos - event.position).length() > 6.0:
_drag_started = true
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
# If user already picked up an item and left-clicks on a target:
if not _just_attached:
if _drop_at(event.position):
_set_handled()
return
# If clicked outside any target, cancel attachment
if _attach_pos.x >= 0.0 and (_attach_pos - event.position).length() > 6.0:
_play_ui("loginfail.wav")
cancel()
_set_handled()
return
else:
# Left mouse release (not pressed)
_just_attached = false
# If dragged, attempt drop at release position
if _drag_started or (_attach_pos.x >= 0.0 and (_attach_pos - event.position).length() > 8.0):
_drop_at(event.position)
_set_handled()
return
# If not dragged (clicked to pick up), keep item attached to cursor!
return
if event is InputEventScreenDrag and event.index == _touch_index:
_touch_position = event.position
get_viewport().set_input_as_handled()
_set_handled()
return
if event is InputEventScreenTouch and not event.pressed and event.index == _touch_index:
_touch_position = event.position
_drop_at(event.position)
get_viewport().set_input_as_handled()
_set_handled()
func _drop_at(position: Vector2) -> void:
func _drop_at(position: Vector2) -> bool:
var payload := attached()
if payload.is_empty():
return false
var icon_center := position + Vector2(24, 24)
var best_idx := -1
var best_dist := 999999.0
# Pass 1: exact hit test (cursor position or icon center)
for i in range(_targets.size() - 1, -1, -1):
var c: Control = _targets[i].get("control", null)
if c == null or not is_instance_valid(c):
_targets.remove_at(i)
continue
if not c.visible or not c.get_global_rect().has_point(position):
if not c.visible:
continue
var cb: Callable = _targets[i].get("callback", Callable())
if cb.is_valid() and bool(cb.call(payload)):
var rect := c.get_global_rect()
if rect.has_point(position):
var d := position.distance_to(rect.get_center())
if d < best_dist:
best_dist = d
best_idx = i
elif rect.has_point(icon_center):
var d := icon_center.distance_to(rect.get_center())
if d < best_dist:
best_dist = d
best_idx = i
# Pass 2: generous hit test with 16px tolerance margin
if best_idx < 0:
for i in range(_targets.size() - 1, -1, -1):
var c: Control = _targets[i].get("control", null)
if c == null or not is_instance_valid(c) or not c.visible:
continue
var rect := c.get_global_rect().grow(16.0)
if rect.has_point(position):
var d := position.distance_to(rect.get_center())
if d < best_dist:
best_dist = d
best_idx = i
elif rect.has_point(icon_center):
var d := icon_center.distance_to(rect.get_center())
if d < best_dist:
best_dist = d
best_idx = i
if best_idx >= 0:
var cb: Callable = _targets[best_idx].get("callback", Callable())
if cb.is_valid():
var ok := false
if cb.get_argument_count() >= 2:
ok = bool(cb.call(payload, position))
else:
ok = bool(cb.call(payload))
if ok:
_play_ui("drop.wav")
cancel()
return true
if world_drop_handler.is_valid():
var ok := false
if world_drop_handler.get_argument_count() >= 2:
ok = bool(world_drop_handler.call(payload, position))
else:
ok = bool(world_drop_handler.call(payload))
if ok:
_play_ui("drop.wav")
cancel()
return
# 与参考端释放到无效区域的取消语义一致。
return true
_play_ui("loginfail.wav")
cancel()
return false
func _play_ui(name: String) -> void:
if audio and audio.has_method("play_ui"):