Files
settled-reach/client/ui/interaction_list.gd
T
jpmschweitzerandClaude Opus 4.6 1fcf08d21f feat(client): Sprint 6 Touch — z-layer pipeline, cursor, fog, interactions, inventory, stance, radial
Three-scope z-layer rendering pipeline (D-049): world z:0-900 inside
CanvasGroup, insert overlay CanvasLayer 10, UI CanvasLayer 20, modal
CanvasLayer 30. Y-sort contract enforced (entities z_index=0). Reserved
ranges for VFX, airborne, lower floors documented in constants.gd.

Sprint 6 client tickets:
- #429: Cursor state machine — 4 states, 150ms transitions (D-056)
- #430: Fog shader rebuild — 5-layer fragment shader, animated noise (D-059)
- #432: Entity interaction list — vertical multi-verb, insert-styled (D-057)
- #433: World radial menu — 2 spokes, drag-release + click-click (D-058)
- #438: Inventory UI — 3x3 grid, 40x40px, 1-9 hotkeys (D-065)
- #439: Stance indicator — color-coded HUD, C/X keybinds (D-053)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:06:03 +01:00

170 lines
4.2 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
const FADE_IN := 0.12
const FADE_OUT := 0.10
const LABEL_HEIGHT := 22
const LABEL_GAP := 2
const INSERT_FG := Color("#c8d0e0")
const INSERT_DIM := Color("#8b8ba0")
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:
# Clear existing labels
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)
_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