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) const ENTITY_OFFSET := Vector2(0, -12) # nudge above entity sprite center var _showing: bool = false var _insert_active: bool = true var _current_target_id: int = -1 var _entity_world_pos: Vector2 = Vector2.ZERO # cached world tile position of target var _verb_items: Array = [] # sorted [{kind, label, priority, available}] var _selected_index: int = 0 var _active_tween: Tween = null var _verb_labels: Array[Label] = [] # #537: D-033 relationship color — cached per target, drawn as left-edge accent bar var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM var _last_screen_pos: Vector2 = Vector2.ZERO @onready var _vbox: VBoxContainer = $VBox func _ready() -> void: modulate.a = 0.0 visible = false mouse_filter = Control.MOUSE_FILTER_IGNORE func _process(_delta: float) -> void: if _showing: _update_screen_position() if position != _last_screen_pos: _last_screen_pos = position queue_redraw() func _draw() -> void: if not _showing or _verb_labels.is_empty(): return var pad := 6.0 var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2) draw_rect(bg_rect, INSERT_BG) draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0) # #537: D-033 relationship color accent — 3px left-edge bar signals NPC relationship var bar_rect := Rect2(-pad, -pad, 3.0, bg_rect.size.y) draw_rect(bar_rect, _relationship_color * Color(1, 1, 1, 0.85)) 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 _cache_entity_position() _cache_entity_relationship() _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 = " %s " % 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_STOP lbl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND var idx := i lbl.gui_input.connect( func(event: InputEvent): if ( event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT ): _select_and_interact(idx) ) lbl.mouse_entered.connect(func(): _hover_index(idx)) lbl.mouse_exited.connect(func(): _unhover_index(idx)) _vbox.add_child(lbl) _verb_labels.append(lbl) ## Cache the target entity's world tile position from GameState.visible_entities. func _cache_entity_position() -> void: for entity in GameState.visible_entities: if entity.get("entity_id") == _current_target_id: _entity_world_pos = Vector2(entity.x, entity.y) return ## #537: Cache relationship color for the target entity (D-033 palette). ## Falls back to IMPLANT_TEXT_DIM for non-NPC or unknown entities. func _cache_entity_relationship() -> void: for entity in GameState.visible_entities: if entity.get("entity_id") == _current_target_id: var rel: String = str(entity.get("relationship", "Unknown")) _relationship_color = Constants.color_for_relationship(rel) return _relationship_color = Constants.IMPLANT_TEXT_DIM ## Convert entity world position to screen coords and reposition this Control. ## Runs every frame while showing so the list tracks the entity as the camera moves. func _update_screen_position() -> void: var camera := get_viewport().get_camera_2d() if camera == null: return var viewport_size := get_viewport_rect().size var cam_center := camera.get_screen_center_position() var zoom: Vector2 = ( camera.zoom if camera.zoom.length_squared() > 0.01 else Constants.CAMERA_DEFAULT_ZOOM ) var world_px := _entity_world_pos * Constants.TILE_SIZE var screen_pos := (world_px - cam_center) * zoom + viewport_size / 2.0 # Anchor above the entity, centered horizontally position = screen_pos + ENTITY_OFFSET * zoom - Vector2(size.x / 2.0, size.y) 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 hide_list() -> void: _hide() 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 _hover_index(idx: int) -> void: _selected_index = idx _update_label_colors() func _unhover_index(_idx: int) -> void: pass # keep last hover highlighted func _select_and_interact(idx: int) -> void: if idx < 0 or idx >= _verb_items.size(): return _selected_index = idx verb_selected.emit(_verb_items[idx].get("kind", ""), _current_target_id) func _update_label_colors() -> void: for i in range(_verb_labels.size()): if is_instance_valid(_verb_labels[i]): _verb_labels[i].add_theme_color_override( "font_color", INSERT_FG if i == _selected_index else INSERT_DIM ) 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