extends Control ## D-057: Entity interaction vertical list — insert-styled, z-layer 6. ## Compact list of 2-4 verb options, anchored to entity position. ## Sprint suppression (D-055): hidden while sprinting. ## Diegetic test: labels disappear when insert is off. ## ## Replaces the single-line interaction_prompt for multi-verb scenarios. ## Public API matches QA test contract (test_interaction_list.gd). signal verb_selected(kind: String, entity_id: int) const MAX_VERBS := 4 # Intentionally faster than cursor 150ms — text must be readable quickly const FADE_IN := 0.12 const FADE_OUT := 0.10 const LABEL_HEIGHT := 22 const LABEL_GAP := 2 const INSERT_FG := Constants.IMPLANT_TEXT_COLOR const INSERT_DIM := Constants.IMPLANT_TEXT_DIM const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7) var _showing: bool = false var _insert_active: bool = true var _current_target_id: int = -1 var _verb_items: Array = [] # sorted [{kind, label, priority, available}] var _selected_index: int = 0 var _active_tween: Tween = null var _verb_labels: Array[Label] = [] @onready var _vbox: VBoxContainer = $VBox func _ready() -> void: modulate.a = 0.0 visible = false mouse_filter = Control.MOUSE_FILTER_IGNORE func update_from_state() -> void: # D-055: Sprint stance suppresses interaction list if GameState.player_stance == "Sprint": _hide() return # Diegetic toggle — insert off means no overlay data if not _insert_active: _hide() return var interactions: Array = GameState.nearby_interactions if interactions.is_empty(): _hide() return var interaction: Dictionary = interactions[0] var entity_id: int = interaction.get("entity_id", -1) var verbs: Array = interaction.get("verbs", []) if verbs.is_empty(): _hide() return # Sort by priority ascending, cap at MAX_VERBS var sorted: Array = verbs.duplicate() sorted.sort_custom(func(a, b): return a.get("priority", 99) < b.get("priority", 99)) if sorted.size() > MAX_VERBS: sorted = sorted.slice(0, MAX_VERBS) _current_target_id = entity_id _verb_items = sorted _selected_index = 0 _rebuild_labels() _show() func _rebuild_labels() -> void: # Guard: tween callback from previous _hide() may fire after labels already freed for lbl in _verb_labels: if is_instance_valid(lbl): lbl.queue_free() _verb_labels.clear() for i in range(_verb_items.size()): var verb: Dictionary = _verb_items[i] var lbl := Label.new() lbl.text = verb.get("label", "") lbl.add_theme_font_size_override("font_size", 14) lbl.add_theme_color_override("font_color", INSERT_FG if i == _selected_index else INSERT_DIM) lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE _vbox.add_child(lbl) _verb_labels.append(lbl) func _show() -> void: if _showing: return _showing = true visible = true if _active_tween and _active_tween.is_valid(): _active_tween.kill() _active_tween = create_tween() _active_tween.tween_property(self, "modulate:a", 1.0, FADE_IN) func _hide() -> void: if not _showing: return _showing = false _current_target_id = -1 _verb_items.clear() if _active_tween and _active_tween.is_valid(): _active_tween.kill() _active_tween = create_tween() _active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT) # Guard: tween callback from previous _hide() may fire after labels already freed _active_tween.tween_callback(func(): visible = false for lbl in _verb_labels: if is_instance_valid(lbl): lbl.queue_free() _verb_labels.clear() ) # -- Lazy sync for getters (tests may set GameState without calling update) -- func _ensure_synced() -> void: if _current_target_id == -1 and not GameState.nearby_interactions.is_empty(): update_from_state() # -- Public API (QA test contract) ------------------------------------------ func get_visible_verb_count() -> int: _ensure_synced() return _verb_items.size() func is_showing() -> bool: return _showing func get_selected_verb() -> String: _ensure_synced() if _verb_items.is_empty(): return "" return _verb_items[_selected_index].get("kind", "") func get_interaction_target() -> int: _ensure_synced() return _current_target_id func get_verb_labels() -> Array: var labels: Array = [] for verb in _verb_items: labels.append(verb.get("label", "")) return labels func set_insert_active(active: bool) -> void: _insert_active = active if not active and _showing: _hide() func get_z_layer() -> int: return Constants.CANVAS_INSERT