354 lines
14 KiB
GDScript
354 lines
14 KiB
GDScript
class_name SceneHitRouter
|
||
extends RefCounted
|
||
|
||
# 场景点击路由:
|
||
# - 负责给“纯展示贴图”补透明 Button 命中区,保持主 UI 图层不被鼠标事件污染。
|
||
# - 普通菜单按钮放在 SceneHitLayer,随页面固定;农场建筑命中区放在 FarmHitLayer,随缩放根节点一起缩放。
|
||
# - 这里只做点击分发和按压动画,不写具体业务,业务通过 callbacks 回到 game_scene/panel。
|
||
const UiMotionScript := preload("res://scripts/ui/common/ui_motion.gd")
|
||
|
||
var _owner: Control
|
||
var _farm_scene_root: Control
|
||
var _pet_display: Control
|
||
var _scene_hit_layer: Control
|
||
var _farm_hit_layer: Control
|
||
var _add_land_hit: Button
|
||
var _callbacks: Dictionary = {}
|
||
var _farm_direct_targets: Array[Dictionary] = []
|
||
var _top_button_group_hits: Array[Button] = []
|
||
var _top_button_group_hits_enabled := true
|
||
var _hit_layers_enabled := true
|
||
var _visitor_mode := false
|
||
|
||
|
||
func setup(options: Dictionary) -> void:
|
||
_owner = options.get("owner") as Control
|
||
_farm_scene_root = options.get("farm_scene_root") as Control
|
||
_pet_display = options.get("pet_display") as Control
|
||
_callbacks = options.get("callbacks", {})
|
||
# 先建命中层,再关闭装饰节点事件阻挡,最后挂业务入口,保证层级始终在可点击元素上方。
|
||
_setup_layers()
|
||
_disable_decorative_mouse_blocks()
|
||
_call("keep_modal_layers_on_top")
|
||
_setup_farm_hits(options.get("farm_targets", {}))
|
||
_setup_scene_hits()
|
||
|
||
|
||
func scene_hit_layer() -> Control:
|
||
return _scene_hit_layer
|
||
|
||
|
||
func farm_hit_layer() -> Control:
|
||
return _farm_hit_layer
|
||
|
||
|
||
func add_land_hit() -> Button:
|
||
return _add_land_hit
|
||
|
||
|
||
func set_hit_layers_enabled(enabled: bool) -> void:
|
||
_hit_layers_enabled = enabled
|
||
if _scene_hit_layer != null:
|
||
_scene_hit_layer.visible = enabled
|
||
if _farm_hit_layer != null:
|
||
_farm_hit_layer.visible = enabled and not _visitor_mode
|
||
_apply_scene_hit_button_mode()
|
||
|
||
|
||
func set_visitor_mode(enabled: bool) -> void:
|
||
_visitor_mode = enabled
|
||
if _farm_hit_layer != null:
|
||
_farm_hit_layer.visible = _hit_layers_enabled and not _visitor_mode
|
||
_apply_scene_hit_button_mode()
|
||
|
||
|
||
func set_top_button_group_hits_enabled(enabled: bool) -> void:
|
||
_top_button_group_hits_enabled = enabled
|
||
for hit_button in _top_button_group_hits:
|
||
if not is_instance_valid(hit_button):
|
||
continue
|
||
hit_button.visible = enabled
|
||
hit_button.disabled = not enabled
|
||
hit_button.mouse_filter = Control.MOUSE_FILTER_STOP if enabled else Control.MOUSE_FILTER_IGNORE
|
||
_apply_scene_hit_button_mode()
|
||
|
||
|
||
func handle_direct_scene_hit(position: Vector2) -> bool:
|
||
if not _hit_layers_enabled:
|
||
return false
|
||
# Web/移动端部分透明 Button 会被缩放或弹窗层影响,这里保留一条直接坐标命中的兜底路径。
|
||
for entry in _direct_targets():
|
||
if _trigger_direct_node_hit(entry["path"], position, _callback(entry["callback"]), float(entry.get("grow", 10.0))):
|
||
return true
|
||
if _visitor_mode:
|
||
return false
|
||
for entry in _farm_direct_targets:
|
||
var target := entry.get("target") as Control
|
||
var callback: Callable = entry.get("callback", Callable())
|
||
var offset: Vector2 = entry.get("offset", Vector2.ZERO)
|
||
var size: Vector2 = entry.get("size", Vector2.ZERO)
|
||
var grow := float(entry.get("grow", 10.0))
|
||
if _trigger_direct_control_hit(target, position, callback, offset, size, grow):
|
||
return true
|
||
return false
|
||
|
||
|
||
func _setup_layers() -> void:
|
||
if _owner == null:
|
||
return
|
||
# 固定 UI 命中层挂主场景,z_index 高于顶部/左侧菜单但低于 ModalLayer。
|
||
_scene_hit_layer = Control.new()
|
||
_scene_hit_layer.name = "SceneHitLayer"
|
||
_scene_hit_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_scene_hit_layer.z_index = 1000
|
||
_scene_hit_layer.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
_owner.add_child(_scene_hit_layer)
|
||
|
||
if _farm_scene_root == null:
|
||
return
|
||
# 农场命中层挂缩放根节点,确保双指缩放后建筑热区和视觉位置一致。
|
||
_farm_hit_layer = Control.new()
|
||
_farm_hit_layer.name = "FarmHitLayer"
|
||
_farm_hit_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_farm_hit_layer.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
_farm_scene_root.add_child(_farm_hit_layer)
|
||
|
||
|
||
func _setup_farm_hits(targets: Dictionary) -> void:
|
||
# 建筑热区使用原版可点范围的近似矩形,偏移量按贴图视觉而不是节点边界调校。
|
||
var house_art := targets.get("house_art") as Control
|
||
if house_art != null:
|
||
var house_callback := _callback("open_house_upgrade_panel")
|
||
_create_farm_node_hit_button(
|
||
"HouseUpgradeHit",
|
||
house_art,
|
||
Vector2(52.0, 42.0),
|
||
Vector2(260.0, 238.0),
|
||
house_callback,
|
||
house_art
|
||
)
|
||
_register_farm_direct_target(house_art, Vector2(52.0, 42.0), Vector2(260.0, 238.0), house_callback, 12.0)
|
||
|
||
var dog_house_art := targets.get("dog_house_art") as Control
|
||
if dog_house_art != null:
|
||
var dog_callback := _callback("open_pet_panel")
|
||
_create_farm_node_hit_button(
|
||
"DogHouseHit",
|
||
dog_house_art,
|
||
Vector2(-10.0, -8.0),
|
||
dog_house_art.size + Vector2(24.0, 24.0),
|
||
dog_callback,
|
||
dog_house_art
|
||
)
|
||
_register_farm_direct_target(dog_house_art, Vector2(-10.0, -8.0), dog_house_art.size + Vector2(24.0, 24.0), dog_callback, 10.0)
|
||
|
||
var add_land_art := targets.get("add_land_art") as Control
|
||
if add_land_art != null:
|
||
var add_land_callback := _callback("open_land_extend_panel")
|
||
_add_land_hit = _create_farm_node_hit_button(
|
||
"AddLandHit",
|
||
add_land_art,
|
||
Vector2.ZERO,
|
||
add_land_art.size,
|
||
add_land_callback,
|
||
add_land_art
|
||
)
|
||
_register_farm_direct_target(add_land_art, Vector2.ZERO, add_land_art.size, add_land_callback, 10.0)
|
||
|
||
|
||
func _setup_scene_hits() -> void:
|
||
for entry in _button_targets():
|
||
_create_node_hit_button(entry["name"], entry["path"], _callback(entry["callback"]), bool(entry.get("allow_hidden", false)))
|
||
_apply_scene_hit_button_mode()
|
||
|
||
|
||
func _direct_targets() -> Array[Dictionary]:
|
||
# 直接坐标命中列表用于 _input 兜底;路径必须和场景树节点名保持同步。
|
||
return [
|
||
{"path": "TopLayer/Avatar", "callback": "open_player_info_panel", "grow": 8.0},
|
||
{"path": "TopLayer/HeadMask", "callback": "open_player_info_panel", "grow": 8.0},
|
||
{"path": "TopLayer/TopButtonGroup/TopLog", "callback": "open_log_panel"},
|
||
{"path": "TopLayer/TopButtonGroup/TopLandup", "callback": "open_land_upgrade_panel"},
|
||
{"path": "TopLayer/TopButtonGroup/TopPay", "callback": "open_pay_panel"},
|
||
{"path": "TopLayer/TopButtonGroup/TopPayGift", "callback": "open_level_gift_panel"},
|
||
{"path": "TopLayer/TopButtonGroup/TopLottery", "callback": "open_lottery_panel"},
|
||
{"path": "TopLayer/TopHide", "callback": "toggle_top_button_group"},
|
||
{"path": "LeftMenu/MarketButtonArt", "callback": "open_market_panel"},
|
||
{"path": "LeftMiddleMenu/Notice", "callback": "open_notice_panel"},
|
||
{"path": "LeftMiddleMenu/Sign", "callback": "open_sign_panel"},
|
||
{"path": "LeftMiddleMenu/RewardGift", "callback": "open_online_gift_panel"},
|
||
{"path": "ExchangeGold", "callback": "open_gold_exchange_panel"},
|
||
{"path": "BackHome", "callback": "back_to_own_farm", "grow": 8.0},
|
||
]
|
||
|
||
|
||
func _button_targets() -> Array[Dictionary]:
|
||
# 透明 Button 命中列表用于正常点击;新增入口优先加这里,再按需要加入 direct_targets。
|
||
return [
|
||
{"name": "AvatarHit", "path": "TopLayer/Avatar", "callback": "open_player_info_panel"},
|
||
{"name": "TopLogHit", "path": "TopLayer/TopButtonGroup/TopLog", "callback": "open_log_panel"},
|
||
{"name": "TopLandupHit", "path": "TopLayer/TopButtonGroup/TopLandup", "callback": "open_land_upgrade_panel"},
|
||
{"name": "TopPayHit", "path": "TopLayer/TopButtonGroup/TopPay", "callback": "open_pay_panel"},
|
||
{"name": "TopPayGiftHit", "path": "TopLayer/TopButtonGroup/TopPayGift", "callback": "open_level_gift_panel"},
|
||
{"name": "TopLotteryHit", "path": "TopLayer/TopButtonGroup/TopLottery", "callback": "open_lottery_panel"},
|
||
{"name": "TopHideHit", "path": "TopLayer/TopHide", "callback": "toggle_top_button_group"},
|
||
{"name": "NoticeHit", "path": "LeftMiddleMenu/Notice", "callback": "open_notice_panel"},
|
||
{"name": "SignHit", "path": "LeftMiddleMenu/Sign", "callback": "open_sign_panel"},
|
||
{"name": "RewardGiftHit", "path": "LeftMiddleMenu/RewardGift", "callback": "open_online_gift_panel"},
|
||
{"name": "ExchangeGoldHit", "path": "ExchangeGold", "callback": "open_gold_exchange_panel"},
|
||
{"name": "BackHomeHit", "path": "BackHome", "callback": "back_to_own_farm", "allow_hidden": true},
|
||
]
|
||
|
||
|
||
func _trigger_direct_node_hit(node_path: NodePath, position: Vector2, callback: Callable, grow := 10.0) -> bool:
|
||
if _owner == null or not callback.is_valid():
|
||
return false
|
||
if not _top_button_group_hits_enabled and str(node_path).begins_with("TopLayer/TopButtonGroup/"):
|
||
return false
|
||
var target := _owner.get_node_or_null(node_path) as Control
|
||
if target == null:
|
||
return false
|
||
return _trigger_direct_control_hit(target, position, callback, Vector2.ZERO, target.size, grow)
|
||
|
||
|
||
func _trigger_direct_control_hit(target: Control, position: Vector2, callback: Callable, offset := Vector2.ZERO, size := Vector2.ZERO, grow := 10.0) -> bool:
|
||
if _owner == null or target == null or not callback.is_valid():
|
||
return false
|
||
if not target.is_visible_in_tree():
|
||
return false
|
||
var hit_rect := _scaled_global_hit_rect(target, offset, size)
|
||
if not hit_rect.grow(grow).has_point(position):
|
||
return false
|
||
# grow 给 H5 触摸留容错,视觉上仍由原贴图负责反馈。
|
||
UiMotionScript.play_click_pulse(_owner, target)
|
||
callback.call()
|
||
return true
|
||
|
||
|
||
func _scaled_global_hit_rect(target: Control, offset: Vector2, size: Vector2) -> Rect2:
|
||
var global_rect := target.get_global_rect()
|
||
var target_size := target.size
|
||
var scale := Vector2(
|
||
global_rect.size.x / maxf(target_size.x, 1.0),
|
||
global_rect.size.y / maxf(target_size.y, 1.0)
|
||
)
|
||
var rect_size := size
|
||
if rect_size == Vector2.ZERO:
|
||
rect_size = target_size
|
||
return Rect2(global_rect.position + offset * scale, rect_size * scale)
|
||
|
||
|
||
func _disable_decorative_mouse_blocks() -> void:
|
||
if _owner == null:
|
||
return
|
||
# 这些贴图只参与展示,不能截断上层透明 Button 的点击。
|
||
for path in [
|
||
"TopLayer/TopBase",
|
||
"TopLayer/TopButtonGroup/TopPanelBack",
|
||
"TopLayer/TopButtonGroup/TopPanelFront",
|
||
"TopLayer/TopHide",
|
||
]:
|
||
var node := _owner.get_node_or_null(path) as Control
|
||
if node != null:
|
||
node.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
|
||
|
||
func _create_node_hit_button(name: String, node_path: NodePath, callback: Callable, allow_hidden := false) -> void:
|
||
if _owner == null or _scene_hit_layer == null:
|
||
return
|
||
var target := _owner.get_node_or_null(node_path) as Control
|
||
if target == null:
|
||
return
|
||
if not allow_hidden and not target.is_visible_in_tree():
|
||
return
|
||
# 固定 UI 直接使用全局矩形创建命中按钮,不跟随农场缩放。
|
||
var hit_button := _new_hit_button(name, target.get_global_rect().position, target.get_global_rect().size)
|
||
hit_button.z_index = 1001
|
||
hit_button.pressed.connect(callback)
|
||
hit_button.button_down.connect(_play_press_scale.bind(target))
|
||
hit_button.button_up.connect(_play_release_scale.bind(target))
|
||
_scene_hit_layer.add_child(hit_button)
|
||
if str(node_path).begins_with("TopLayer/TopButtonGroup/"):
|
||
_top_button_group_hits.append(hit_button)
|
||
_apply_scene_hit_button_mode()
|
||
|
||
|
||
func _create_farm_node_hit_button(name: String, target: Control, offset: Vector2, size: Vector2, callback: Callable, press_target: Control = null) -> Button:
|
||
if target == null or _farm_hit_layer == null:
|
||
return null
|
||
var global_position := target.get_global_rect().position + offset
|
||
# 视觉坐标先取全局,再转回 FarmHitLayer 局部坐标,防止父节点缩放后命中区错位。
|
||
var local_position := _farm_hit_layer.get_global_transform().affine_inverse() * global_position
|
||
var hit_button := _new_hit_button(name, local_position, size)
|
||
hit_button.z_index = 1001
|
||
hit_button.pressed.connect(callback)
|
||
if press_target != null:
|
||
hit_button.button_down.connect(_play_press_scale.bind(press_target))
|
||
hit_button.button_up.connect(_play_release_scale.bind(press_target))
|
||
_farm_hit_layer.add_child(hit_button)
|
||
return hit_button
|
||
|
||
|
||
func _register_farm_direct_target(target: Control, offset: Vector2, size: Vector2, callback: Callable, grow := 10.0) -> void:
|
||
if target == null or not callback.is_valid():
|
||
return
|
||
_farm_direct_targets.append({
|
||
"target": target,
|
||
"offset": offset,
|
||
"size": size,
|
||
"callback": callback,
|
||
"grow": grow,
|
||
})
|
||
|
||
|
||
func _new_hit_button(button_name: String, position: Vector2, size: Vector2) -> Button:
|
||
# 热区按钮必须无文本无主题背景,只承担 mouse_filter=STOP 的点击截获职责。
|
||
# z_index 使用绝对值,避免挂在 SceneHitLayer 下时相对叠加到 ModalLayer 之上。
|
||
var hit_button := Button.new()
|
||
hit_button.name = button_name
|
||
hit_button.position = position
|
||
hit_button.size = size
|
||
hit_button.z_as_relative = false
|
||
hit_button.z_index = 1001
|
||
hit_button.focus_mode = Control.FOCUS_NONE
|
||
hit_button.text = ""
|
||
hit_button.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
_call("apply_empty_button_style", [hit_button])
|
||
return hit_button
|
||
|
||
|
||
func _apply_scene_hit_button_mode() -> void:
|
||
if _scene_hit_layer == null:
|
||
return
|
||
for child in _scene_hit_layer.get_children():
|
||
var button := child as Button
|
||
if button == null:
|
||
continue
|
||
var is_back_home := button.name == "BackHomeHit"
|
||
var enabled := _hit_layers_enabled and ((is_back_home and _visitor_mode) or ((not is_back_home) and not _visitor_mode))
|
||
if button in _top_button_group_hits and not _top_button_group_hits_enabled:
|
||
enabled = false
|
||
button.visible = enabled
|
||
button.disabled = not enabled
|
||
button.mouse_filter = Control.MOUSE_FILTER_STOP if enabled else Control.MOUSE_FILTER_IGNORE
|
||
|
||
|
||
func _callback(name: String) -> Callable:
|
||
var callback: Callable = _callbacks.get(name, Callable())
|
||
return callback
|
||
|
||
|
||
func _call(name: String, args: Array = []) -> Variant:
|
||
var callback := _callback(name)
|
||
if not callback.is_valid():
|
||
return null
|
||
return callback.callv(args)
|
||
|
||
|
||
func _play_press_scale(target: Control) -> void:
|
||
UiMotionScript.play_press_scale(_owner, target)
|
||
|
||
|
||
func _play_release_scale(target: Control) -> void:
|
||
UiMotionScript.play_release_scale(_owner, target)
|