extends Node # 全地图统一交互:收集角色附近的动作,通过方向键选择、E 执行。 const SCAN_INTERVAL: float = 0.10 const MAX_ACTIONS_VISIBLE: int = 5 const ACCENT: Color = Color("58c7db") const PANEL_COLOR: Color = Color(0.035, 0.071, 0.106, 0.94) const INTERACTION_POINT_MARKERS_SCRIPT: Script = preload("res://_Core/ui/InteractionPointMarkers.gd") var _actions: Array[InteractionAction] = [] var _selected_index: int = 0 var _last_selected_id: String = "" var _scan_elapsed: float = SCAN_INTERVAL var _canvas: CanvasLayer var _panel: PanelContainer var _list: VBoxContainer var _hint: Label var _executing: bool = false var _pointMarkers: Node2D func _ready() -> void: _build_hud() func _process(delta: float) -> void: _scan_elapsed += delta if _scan_elapsed < SCAN_INTERVAL: return _scan_elapsed = 0.0 _refresh_actions() func _unhandled_input(event: InputEvent) -> void: if _actions.is_empty() or _is_text_input_focused(): return if not (event is InputEventKey): return var key_event := event as InputEventKey if not key_event.pressed or key_event.echo: return if key_event.keycode == KEY_UP: _select_offset(-1) get_viewport().set_input_as_handled() elif key_event.keycode == KEY_DOWN: _select_offset(1) get_viewport().set_input_as_handled() elif key_event.keycode == KEY_E: _execute_selected() get_viewport().set_input_as_handled() func is_selection_active() -> bool: return not _actions.is_empty() and not _is_text_input_focused() func _refresh_actions() -> void: if _is_text_input_focused() or SceneManager.is_changing_scene: _set_actions([]) return var player := _local_player() if player == null: _set_actions([]) return var collected: Array[InteractionAction] = [] for node: Node in get_tree().get_nodes_in_group(InteractableComponent.GROUP): var interactable: InteractableComponent = node as InteractableComponent if interactable == null: continue for action: InteractionAction in interactable.get_actions(player): collected.append(action) collected.sort_custom(func(a: InteractionAction, b: InteractionAction) -> bool: if a.priority != b.priority: return a.priority < b.priority if not is_equal_approx(a.distance, b.distance): return a.distance < b.distance return a.title < b.title ) _set_actions(collected) func _set_actions(next_actions: Array[InteractionAction]) -> void: _actions = next_actions if _actions.is_empty(): _selected_index = 0 _last_selected_id = "" _render() return var matched_index := -1 for index in _actions.size(): if _actions[index].id == _last_selected_id: matched_index = index break _selected_index = matched_index if matched_index >= 0 else clampi(_selected_index, 0, _actions.size() - 1) _last_selected_id = _actions[_selected_index].id _render() func _select_offset(offset: int) -> void: if _actions.is_empty(): return _selected_index = posmod(_selected_index + offset, _actions.size()) _last_selected_id = _actions[_selected_index].id _render() func _execute_selected() -> void: if _executing or _actions.is_empty(): return var action: InteractionAction = _actions[_selected_index] if not action.activate.is_valid(): return _executing = true _render() action.activate.call() await get_tree().create_timer(0.18).timeout _executing = false _refresh_actions() func _local_player() -> Node2D: var players := get_tree().get_nodes_in_group("whaletown_local_player") return players.front() as Node2D if not players.is_empty() else null func _build_hud() -> void: _canvas = CanvasLayer.new() _canvas.layer = 90 add_child(_canvas) _panel = PanelContainer.new() _panel.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) _panel.position = Vector2(-250, -282) _panel.size = Vector2(500, 214) _panel.mouse_filter = Control.MOUSE_FILTER_IGNORE _panel.add_theme_stylebox_override("panel", _panel_style()) _canvas.add_child(_panel) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 14) margin.add_theme_constant_override("margin_top", 10) margin.add_theme_constant_override("margin_right", 14) margin.add_theme_constant_override("margin_bottom", 10) _panel.add_child(margin) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 4) margin.add_child(content) _list = VBoxContainer.new() _list.add_theme_constant_override("separation", 2) content.add_child(_list) _hint = Label.new() _hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _hint.add_theme_color_override("font_color", Color(0.67, 0.79, 0.85, 1.0)) _hint.add_theme_font_size_override("font_size", 14) _hint.text = "[↑/↓] 选择 [E] 交互" content.add_child(_hint) _render() func _render() -> void: if not is_instance_valid(_panel): return var show_hints: bool = _should_show_interaction_hints() _panel.visible = show_hints and not _actions.is_empty() _render_interaction_points() for child in _list.get_children(): child.queue_free() if _actions.is_empty(): return var start: int = clampi(_selected_index - 2, 0, maxi(0, _actions.size() - MAX_ACTIONS_VISIBLE)) var finish: int = mini(_actions.size(), start + MAX_ACTIONS_VISIBLE) for index in range(start, finish): var action: InteractionAction = _actions[index] var row := Label.new() row.custom_minimum_size = Vector2(0, 29) row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER row.add_theme_font_size_override("font_size", 16) var selected := index == _selected_index row.text = ("› " if selected else " ") + action.title + (" …" if selected and _executing else "") row.add_theme_color_override("font_color", ACCENT if selected else Color(0.92, 0.96, 0.98, 1.0)) if selected: row.add_theme_stylebox_override("normal", _selected_style()) _list.add_child(row) _hint.text = "[↑/↓] 选择 [E] 交互 [Esc] 关闭" + (" %d 项" % _actions.size() if _actions.size() > 1 else "") func _render_interaction_points() -> void: if not _should_show_interaction_points(): if is_instance_valid(_pointMarkers): var empty_points: Array[Vector2] = [] _pointMarkers.call("set_points", empty_points) return var markers: Node2D = _ensure_point_markers() if markers == null: return var positions: Array[Vector2] = [] for node: Node in get_tree().get_nodes_in_group(InteractableComponent.GROUP): var interactable: InteractableComponent = node as InteractableComponent if interactable == null: continue for position: Vector2 in interactable.get_marker_positions(): positions.append(position) markers.call("set_points", positions) func _ensure_point_markers() -> Node2D: var current_scene: Node = get_tree().current_scene var world_root: Node2D = current_scene as Node2D if world_root == null: return null if is_instance_valid(_pointMarkers) and _pointMarkers.get_parent() == world_root: return _pointMarkers var markers: Node2D = INTERACTION_POINT_MARKERS_SCRIPT.new() as Node2D if markers == null: return null markers.name = "InteractionPointMarkers" markers.z_index = 4096 markers.z_as_relative = false world_root.add_child(markers) _pointMarkers = markers return _pointMarkers func _should_show_interaction_hints() -> bool: return _get_setting_enabled("show_interaction_hints", true) func _should_show_interaction_points() -> bool: return _get_setting_enabled("show_interaction_points", false) func _get_setting_enabled(setting_key: String, fallback: bool) -> bool: var settings_manager: Node = get_node_or_null("/root/SettingsManager") if settings_manager != null and settings_manager.has_method("get_bool"): return bool(settings_manager.call("get_bool", setting_key)) return fallback func _panel_style() -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = PANEL_COLOR style.border_color = Color(0.20, 0.45, 0.54, 0.86) style.set_border_width_all(1) style.corner_radius_top_left = 10 style.corner_radius_top_right = 10 style.corner_radius_bottom_left = 10 style.corner_radius_bottom_right = 10 style.shadow_color = Color(0, 0, 0, 0.35) style.shadow_size = 10 return style func _selected_style() -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = Color(0.12, 0.32, 0.38, 0.74) style.corner_radius_top_left = 6 style.corner_radius_top_right = 6 style.corner_radius_bottom_left = 6 style.corner_radius_bottom_right = 6 style.content_margin_left = 6 return style func _is_text_input_focused() -> bool: var focus_owner := get_viewport().gui_get_focus_owner() return focus_owner is LineEdit or focus_owner is TextEdit