Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
282 lines
7.9 KiB
GDScript
282 lines
7.9 KiB
GDScript
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
|