refactor: unify interactable components

This commit is contained in:
ANG-Server
2026-07-21 23:23:26 +08:00
parent 9dee47492c
commit 74a0fb8309
17 changed files with 241 additions and 208 deletions

View File

@@ -0,0 +1,96 @@
class_name InteractableComponent
extends Node
# 可挂载于任意 Node2D 宿主的交互组件。
# 它统一负责交互锚点、距离过滤、白圈位置和静态动作;复杂宿主可通过
# build_interaction_actions(component, player) 返回多个 InteractionAction。
const GROUP: StringName = &"whaletown_interactable_component"
@export_category("Interaction")
@export var interaction_id: String = ""
@export var interaction_title: String = ""
@export var interaction_priority: int = 100
@export var interaction_distance: float = 150.0
@export var activation_method: StringName = &""
@export var show_marker: bool = true
@export_category("Anchor")
@export var anchor_path: NodePath = NodePath("")
var _host: Node2D
func _ready() -> void:
_host = get_parent() as Node2D
if _host == null:
push_error("InteractableComponent 必须挂在 Node2D 宿主下:%s" % get_path())
return
add_to_group(GROUP)
func get_actions(player: Node2D) -> Array[InteractionAction]:
var actions: Array[InteractionAction] = []
if player == null or not is_interaction_active():
return actions
var distance: float = get_anchor_position().distance_to(player.global_position)
if distance > interaction_distance:
return actions
if _host != null and _host.has_method("build_interaction_actions"):
var actions_variant: Variant = _host.call("build_interaction_actions", self, player)
if actions_variant is Array:
for action_variant: Variant in actions_variant as Array:
if action_variant is InteractionAction:
var action: InteractionAction = action_variant as InteractionAction
if action.is_valid():
action.distance = distance
actions.append(action)
return actions
var default_action: InteractionAction = _create_default_action()
if default_action != null:
default_action.distance = distance
actions.append(default_action)
return actions
func get_marker_positions() -> Array[Vector2]:
if not show_marker or not is_interaction_active():
return []
return [get_anchor_position()]
func get_anchor_position() -> Vector2:
var anchor: Node2D = _resolve_anchor()
if anchor == null:
return Vector2.ZERO
var collision_shape: CollisionShape2D = _first_enabled_collision_shape(anchor)
return collision_shape.global_position if collision_shape != null else anchor.global_position
func is_interaction_active() -> bool:
if _host == null:
return false
if _host.has_method("is_interaction_active"):
return bool(_host.call("is_interaction_active", self))
return true
func _create_default_action() -> InteractionAction:
if _host == null or activation_method.is_empty() or interaction_id.strip_edges().is_empty() or interaction_title.strip_edges().is_empty():
return null
if not _host.has_method(activation_method):
push_warning("InteractableComponent: %s 不存在方法 %s" % [_host.get_path(), activation_method])
return null
return InteractionAction.create(interaction_id, interaction_title, interaction_priority, Callable(_host, activation_method))
func _resolve_anchor() -> Node2D:
if _host == null:
return null
if not anchor_path.is_empty():
var explicit_anchor: Node2D = _host.get_node_or_null(anchor_path) as Node2D
if explicit_anchor != null:
return explicit_anchor
return _host
func _first_enabled_collision_shape(anchor: Node2D) -> CollisionShape2D:
var direct_shape: CollisionShape2D = anchor as CollisionShape2D
if direct_shape != null and not direct_shape.disabled and direct_shape.shape != null:
return direct_shape
for child: Node in anchor.get_children():
var collision_shape: CollisionShape2D = child as CollisionShape2D
if collision_shape != null and not collision_shape.disabled and collision_shape.shape != null:
return collision_shape
return null

View File

@@ -0,0 +1 @@
uid://cn3fccr0qc41h

View File

@@ -0,0 +1,20 @@
class_name InteractionAction
extends RefCounted
# 交互管理器使用的强类型动作数据,避免各交互物品以 Dictionary 约定字段。
var id: String = ""
var title: String = ""
var priority: int = 100
var distance: float = INF
var activate: Callable = Callable()
static func create(action_id: String, action_title: String, action_priority: int, callback: Callable) -> InteractionAction:
var action: InteractionAction = InteractionAction.new()
action.id = action_id
action.title = action_title
action.priority = action_priority
action.activate = callback
return action
func is_valid() -> bool:
return not id.strip_edges().is_empty() and not title.strip_edges().is_empty() and activate.is_valid()

View File

@@ -0,0 +1 @@
uid://dbrui8h3sbjrn

View File

@@ -6,9 +6,8 @@ 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")
const InteractionAnchorUtil = preload("res://_Core/utils/InteractionAnchor.gd")
var _actions: Array[Dictionary] = []
var _actions: Array[InteractionAction] = []
var _selected_index: int = 0
var _last_selected_id: String = ""
var _scan_elapsed: float = SCAN_INTERVAL
@@ -58,32 +57,23 @@ func _refresh_actions() -> void:
if player == null:
_set_actions([])
return
var collected: Array[Dictionary] = []
for node in get_tree().get_nodes_in_group("whaletown_interactable"):
if not is_instance_valid(node) or not node.has_method("get_interaction_actions"):
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
var actions_variant: Variant = node.call("get_interaction_actions", player)
if not (actions_variant is Array):
continue
for item in actions_variant as Array:
if item is Dictionary:
var action: Dictionary = item
if bool(action.get("available", true)):
collected.append(action)
collected.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var priority_a := int(a.get("priority", 100))
var priority_b := int(b.get("priority", 100))
if priority_a != priority_b:
return priority_a < priority_b
var distance_a := float(a.get("distance", INF))
var distance_b := float(b.get("distance", INF))
if not is_equal_approx(distance_a, distance_b):
return distance_a < distance_b
return str(a.get("title", "")) < str(b.get("title", ""))
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[Dictionary]) -> void:
func _set_actions(next_actions: Array[InteractionAction]) -> void:
_actions = next_actions
if _actions.is_empty():
_selected_index = 0
@@ -92,30 +82,29 @@ func _set_actions(next_actions: Array[Dictionary]) -> void:
return
var matched_index := -1
for index in _actions.size():
if str(_actions[index].get("id", "")) == _last_selected_id:
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 = str(_actions[_selected_index].get("id", ""))
_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 = str(_actions[_selected_index].get("id", ""))
_last_selected_id = _actions[_selected_index].id
_render()
func _execute_selected() -> void:
if _executing or _actions.is_empty():
return
var action := _actions[_selected_index]
var callback_variant: Variant = action.get("callback", Callable())
if not (callback_variant is Callable) or not (callback_variant as Callable).is_valid():
var action: InteractionAction = _actions[_selected_index]
if not action.activate.is_valid():
return
_executing = true
_render()
(callback_variant as Callable).call()
action.activate.call()
await get_tree().create_timer(0.18).timeout
_executing = false
_refresh_actions()
@@ -168,13 +157,13 @@ func _render() -> void:
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 := _actions[index]
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 " ") + str(action.get("title", "交互")) + ("" if selected and _executing else "")
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())
@@ -191,19 +180,12 @@ func _render_interaction_points() -> void:
if markers == null:
return
var positions: Array[Vector2] = []
for node: Node in get_tree().get_nodes_in_group("whaletown_interactable"):
if not is_instance_valid(node) or not node.has_method("get_interaction_actions"):
for node: Node in get_tree().get_nodes_in_group(InteractableComponent.GROUP):
var interactable: InteractableComponent = node as InteractableComponent
if interactable == null:
continue
if node.has_method("get_interaction_marker_positions"):
var marker_positions_variant: Variant = node.call("get_interaction_marker_positions")
if marker_positions_variant is Array:
for position_variant: Variant in marker_positions_variant as Array:
if position_variant is Vector2:
positions.append(position_variant as Vector2)
continue
var source_node: Node2D = node as Node2D
if source_node != null:
positions.append(InteractionAnchorUtil.get_position(source_node))
for position: Vector2 in interactable.get_marker_positions():
positions.append(position)
markers.call("set_points", positions)
func _ensure_point_markers() -> Node2D:

View File

@@ -1,14 +0,0 @@
class_name InteractionAnchor
extends RefCounted
# 所有交互距离与地图标记共用的世界锚点。
# Area2D 优先使用实际 CollisionShape2D 的中心,避免脚本节点原点与碰撞区域有偏移时
# 出现“能交互的位置”和白圈不一致的问题。
static func get_position(node: Node2D) -> Vector2:
var area: Area2D = node as Area2D
if area != null:
for child: Node in area.get_children():
var collision_shape: CollisionShape2D = child as CollisionShape2D
if collision_shape != null and not collision_shape.disabled and collision_shape.shape != null:
return collision_shape.global_position
return node.global_position

View File

@@ -1 +0,0 @@
uid://dogltsticcpy2