- 桥梁与静态物体高度采样修复: - 严格对齐 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 客户端对拍缺陷发现与修复工程指南
334 lines
10 KiB
GDScript
334 lines
10 KiB
GDScript
# MouseController —— ClientVS22 mousemodule.py 的全局物品拖放层。
|
||
#
|
||
# 目标控件通过 register_target() 注册;物品源调用 attach_item() 后,控制器
|
||
# 接管鼠标释放,命中目标时调用回调,释放到窗口外 / 右键 / ESC / 失焦则取消。
|
||
# 光标状态由 CursorManager 管理,跟随图标不参与世界点击。
|
||
extends Node
|
||
|
||
const CursorManager = preload("res://ui/cursor_manager.gd")
|
||
|
||
var cursor: Node
|
||
var audio: Node
|
||
var _overlay: Control
|
||
var _icon: TextureRect
|
||
var _label: Label
|
||
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
|
||
audio = audio_node
|
||
_overlay = Control.new()
|
||
_overlay.name = "MouseItemOverlay"
|
||
_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_overlay.z_index = 1000
|
||
_overlay.visible = false
|
||
_overlay.size = Vector2(72, 72)
|
||
parent.add_child(_overlay)
|
||
_icon = TextureRect.new()
|
||
_icon.position = Vector2(0, 0)
|
||
_icon.size = Vector2(48, 48)
|
||
_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_overlay.add_child(_icon)
|
||
_label = Label.new()
|
||
_label.position = Vector2(2, 48)
|
||
_label.size = Vector2(68, 20)
|
||
_label.add_theme_font_size_override("font_size", 10)
|
||
_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_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:
|
||
if vnum <= 0 or window < 0 or cell < 0:
|
||
return false
|
||
_attached = {"window": window, "cell": cell, "vnum": vnum,
|
||
"count": maxi(1, count), "source": source}
|
||
_touch_index = touch_index
|
||
if touch_position.x >= 0.0 and touch_position.y >= 0.0:
|
||
_touch_position = touch_position
|
||
# Keep the instance fields that the reference mouse module exposes to the
|
||
# receiving window (anti flags, sockets, attributes, ...), while retaining
|
||
# the canonical source coordinates supplied by the caller.
|
||
for key in metadata:
|
||
if key not in ["window", "cell", "vnum", "count", "source"]:
|
||
_attached[key] = metadata[key]
|
||
if _icon:
|
||
_icon.texture = texture
|
||
_icon.visible = texture != null
|
||
if _label:
|
||
_label.text = "#%d%s" % [vnum, (" ×%d" % count) if count > 1 else ""]
|
||
_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_money(amount: int, source := "inventory") -> bool:
|
||
if amount <= 0:
|
||
return false
|
||
_attached = {"window": -1, "cell": -1, "vnum": -1, "count": amount,
|
||
"source": source, "money": true}
|
||
_touch_index = -1
|
||
_touch_position = Vector2.ZERO
|
||
if _icon:
|
||
_icon.texture = null
|
||
_icon.visible = false
|
||
if _label:
|
||
_label.text = "金币 ×%d" % amount
|
||
_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_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"):
|
||
cursor.reset()
|
||
|
||
func is_attached() -> bool:
|
||
return not _attached.is_empty()
|
||
|
||
func attached() -> Dictionary:
|
||
return _attached.duplicate(true)
|
||
|
||
func register_target(control: Control, callback: Callable, owner: Node = null) -> void:
|
||
if control == null or not callback.is_valid():
|
||
return
|
||
_targets.append({"control": control, "callback": callback, "owner": owner})
|
||
|
||
func unregister_owner(owner: Node) -> void:
|
||
for i in range(_targets.size() - 1, -1, -1):
|
||
if _targets[i].get("owner", null) == owner:
|
||
_targets.remove_at(i)
|
||
|
||
func _process(_dt: float) -> void:
|
||
if _overlay and _overlay.visible:
|
||
var position := get_viewport().get_mouse_position()
|
||
if _touch_index >= 0:
|
||
position = _touch_position
|
||
_overlay.global_position = position + Vector2(12, 12)
|
||
# 清理已被窗口销毁的目标,防止拖放回调落到旧 UI。
|
||
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)
|
||
|
||
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()
|
||
_set_handled()
|
||
return
|
||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
|
||
cancel()
|
||
_set_handled()
|
||
return
|
||
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
|
||
_set_handled()
|
||
return
|
||
if event is InputEventScreenTouch and not event.pressed and event.index == _touch_index:
|
||
_touch_position = event.position
|
||
_drop_at(event.position)
|
||
_set_handled()
|
||
|
||
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:
|
||
continue
|
||
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 true
|
||
|
||
_play_ui("loginfail.wav")
|
||
cancel()
|
||
return false
|
||
|
||
func _play_ui(name: String) -> void:
|
||
if audio and audio.has_method("play_ui"):
|
||
audio.play_ui(name)
|
||
|
||
func _notification(what: int) -> void:
|
||
if what == NOTIFICATION_WM_WINDOW_FOCUS_OUT:
|
||
cancel()
|