From 03ff3c5fef28463d3fc12bd68bc6ddde184c8711 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 13:43:08 +0100 Subject: [PATCH] feat(ui): entity-anchored dialogue log with keyboard selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log entries store entity IDs and resolve display names at render time from _entity_display lookup — enables retroactive name update when player learns an NPC's real name. Color index from server replaces name-hash coloring for stable NPC colors. Dialogue options: switched RichTextLabel to Label (fixes stacking bug), added 1/2/3 number key selection, numbered option labels. Interaction list: added background panel, mouse hover highlighting, click-to-interact, pointing hand cursor. Co-Authored-By: Claude Opus 4.6 --- client/data/dialogue-theme.yaml | 4 +- client/scripts/constants.gd | 2 +- client/scripts/main.gd | 18 ++-- client/scripts/protocol/protocol.gd | 2 +- client/ui/dialogue_box.gd | 147 ++++++++++++++++++++++------ client/ui/interaction_list.gd | 76 +++++++++++++- 6 files changed, 204 insertions(+), 45 deletions(-) diff --git a/client/data/dialogue-theme.yaml b/client/data/dialogue-theme.yaml index bab788961..6481de1a7 100644 --- a/client/data/dialogue-theme.yaml +++ b/client/data/dialogue-theme.yaml @@ -43,5 +43,5 @@ passive_opacity: 0.9 # ENTRY TIMING # All entry types share the same lifetime and fade duration. # ============================================================ -entry_lifetime_seconds: 15.0 -entry_fade_seconds: 3.0 +entry_lifetime_seconds: 45.0 +entry_fade_seconds: 5.0 diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index 1fe78761e..a4aaf0908 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -94,7 +94,7 @@ const FACING_INDICATOR_OFFSET: float = 14.0 # 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport. # Tyre architecture review 2026-02-19: readability over max-width; fits # two columns of text comfortably, leaves world game visible alongside. -const DIALOGUE_MAX_WIDTH: int = 640 +const DIALOGUE_MAX_WIDTH: int = 1200 # Default camera zoom — used as fallback when get_camera_2d() returns null const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 2255a47c9..9421f0c8b 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -250,22 +250,16 @@ func _play_close_sound_events() -> void: # D-067: Recognition chime — fires sfx_monologue_chime when a fog entity # enters the cognitive delay recognition queue for the first time. # "The chime marks the character's attention shifting" (D-067). -# Entities that complete recognition (leave pending_recognitions) are removed -# from _known_recognition_ids so they can chime again if re-encountered. +# IDs persist for the session — one chime per entity, no re-trigger on +# fog oscillation or server re-send. Cleared on room change (teleport). func _play_recognition_chimes() -> void: - var active_ids: Dictionary = {} for rec in GameState.pending_recognitions: if not rec is Dictionary or not rec.has("entity_id"): continue var eid: int = rec.entity_id - active_ids[eid] = true if not _known_recognition_ids.has(eid): _known_recognition_ids[eid] = true AudioManager.play(AudioManager.CHIME_RECOGNITION) - # Expire IDs no longer in the recognition queue - for eid in _known_recognition_ids.keys(): - if not active_ids.has(eid): - _known_recognition_ids.erase(eid) # D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id @@ -358,11 +352,16 @@ func _consume_conversation_ended() -> void: # #535: Consume dialogue_response — NPC follow-up line after player picks an option. +# Updates dialogue_box entity display registry with speaker identity from the wire. func _consume_dialogue_response() -> void: if GameState.dialogue_response == null or not dialogue_box: return var dr: Dictionary = GameState.dialogue_response - dialogue_box.append_dialogue_response(_last_dialogue_npc_name, dr.get("text", "")) + var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id) + var speaker_color_index: int = dr.get("speaker_color_index", -1) + var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name) + dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index) + dialogue_box.append_dialogue_response(speaker_name, dr.get("text", "")) GameState.dialogue_response = null @@ -446,6 +445,7 @@ func _teleport_transition() -> void: GameState.current_monologue = null GameState.current_dialogue = null GameState.dialogue_active = false + _known_recognition_ids.clear() # D-067: reset chimes for new room if dialogue_box and dialogue_box.is_dialogue_active(): dialogue_box.hide_dialogue() diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 65a440dec..a3a86f046 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -11,7 +11,7 @@ class_name Protocol ## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs. ## Reject snapshots where version != this value. -const PROTOCOL_VERSION: int = 9 +const PROTOCOL_VERSION: int = 12 # -- Decode: bytes from server → GDScript types -------------------------------- diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index 6b7d1d7c0..0935d90d9 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -21,12 +21,17 @@ signal unpause_requested # D-061: auto-unpause @onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer # -- Log state -- -# Entry format: {speaker, target, text, is_passive, pinned, timestamp_msec} +# Entry format (legacy): {speaker: String, target: String, text, is_passive, pinned, timestamp_msec} +# Entry format (entity-anchored): {speaker_id: int, target_id: int, text, is_passive, pinned, timestamp_msec} # pinned entries do not expire (active conversation lines, Araminta review). var _log_entries: Array[Dictionary] = [] var _in_player_conversation: bool = false var _log_dirty: bool = false # Dirty flag — prevents per-frame O(n) BBCode rebuild (Hoshe #1) +# Entity ID → {name: String, color_index: int} +# Populated from server events; drives retroactive re-render when NPC names resolve. +var _entity_display: Dictionary = {} + # -- Option state -- var _option_controls: Array[Control] = [] var _option_response_ids: Array[String] = [] @@ -45,8 +50,8 @@ var _npc_colors: Array[Color] = [] var _arrow_color: Color = Color("#8890a0") var _speech_color: Color = Color("#c8d0e0") var _passive_opacity: float = 0.9 -var _entry_lifetime: float = 15.0 -var _entry_fade: float = 3.0 +var _entry_lifetime: float = 45.0 +var _entry_fade: float = 5.0 const THEME_PATH: String = "res://data/dialogue-theme.yaml" @@ -93,6 +98,17 @@ func _unhandled_input(event: InputEvent) -> void: if not _in_player_conversation: return + # Number keys 1-3 select dialogue options + if event is InputEventKey and event.pressed: + var key_index := -1 + if event.keycode == KEY_1: key_index = 0 + elif event.keycode == KEY_2: key_index = 1 + elif event.keycode == KEY_3: key_index = 2 + if key_index >= 0 and key_index < _option_controls.size(): + get_viewport().set_input_as_handled() + _on_option_pressed(key_index) + return + # D-064: WASD during active player dialogue → walk-away if event is InputEventKey and event.pressed: for action in _WALK_AWAY_ACTIONS: @@ -153,7 +169,7 @@ func _load_theme() -> void: func _update_layout() -> void: var vp := get_viewport_rect().size var max_h := vp.y * MAX_HEIGHT_RATIO - var w := minf(MAX_WIDTH_PX, vp.x * 0.65) + var w := minf(MAX_WIDTH_PX, vp.x * 0.85) panel.offset_left = -w / 2.0 panel.offset_right = w / 2.0 panel.offset_top = -max_h @@ -180,13 +196,59 @@ func append_line(speaker: String, target: String, text: String, is_passive: bool ## Append an overheard conversation event (D-078). +## Stores entity IDs for retroactive name resolution when NPC display names change. func append_conversation_event(event: Dictionary) -> void: - var speaker: String = event.get("speaker_name", "?") - var target: String = event.get("target_name", "?") var text: String = event.get("occluded_line", "") if text.is_empty(): return - append_line(speaker, target, text, true) + + var speaker_id: int = event.get("speaker_id", -1) + var target_id: int = event.get("target_id", -1) + var speaker_name: String = event.get("speaker_name", "?") + var target_name: String = event.get("target_name", "?") + var speaker_color_index: int = event.get("speaker_color_index", -1) + var target_color_index: int = event.get("target_color_index", -1) + + # Update entity display registry — set dirty if a known name changed (retroactive update) + if speaker_id >= 0: + var prev: Dictionary = _entity_display.get(speaker_id, {}) + _entity_display[speaker_id] = {"name": speaker_name, "color_index": speaker_color_index} + if prev.has("name") and prev.get("name", "") != speaker_name: + _log_dirty = true + if target_id >= 0: + var prev: Dictionary = _entity_display.get(target_id, {}) + _entity_display[target_id] = {"name": target_name, "color_index": target_color_index} + if prev.has("name") and prev.get("name", "") != target_name: + _log_dirty = true + + var entry: Dictionary = { + "text": text, + "is_passive": true, + "pinned": false, + "timestamp_msec": Time.get_ticks_msec(), + } + if speaker_id >= 0: + entry["speaker_id"] = speaker_id + entry["target_id"] = target_id + else: + # Fallback: no entity IDs on wire, store raw names for legacy rendering + entry["speaker"] = speaker_name + entry["target"] = target_name + + _log_entries.append(entry) + _log_dirty = true + _ensure_visible() + + +## Update entity display name and color index — called when Talk responses arrive. +## Triggers retroactive log re-render if the entity's displayed name has changed. +func update_entity_display(entity_id: int, name: String, color_index: int) -> void: + if entity_id < 0: + return + var prev: Dictionary = _entity_display.get(entity_id, {}) + _entity_display[entity_id] = {"name": name, "color_index": color_index} + if prev.has("name") and prev.get("name", "") != name: + _log_dirty = true ## Handle conversation_ended — no-op currently (entries expire via timeout). @@ -234,7 +296,8 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi ## End active player conversation — clears options but preserves log. -## D-064: dialogue_active held until fade completes. +## D-064: dialogue_active cleared immediately so WASD resumes. +## Log entries remain visible and expire via timeout (cosmetic only). func _end_player_conversation() -> void: _in_player_conversation = false _clear_options() @@ -254,9 +317,10 @@ func _end_player_conversation() -> void: # D-061: unpause — signal to main.gd for input recording (#507) unpause_requested.emit() - # D-064: dialogue_active stays true — cleared after fade completes in hide callback. - # If log entries still exist, panel stays visible and entries expire via timeout. - # If no entries, hide immediately with fade. + # D-064: unblock movement immediately — log entries stay visible but don't block input. + GameState.dialogue_active = false + + # If no entries remain, hide the panel with fade. if _log_entries.is_empty(): hide_dialogue() @@ -302,18 +366,15 @@ func _show_options(options: Array) -> void: var raw_text: String = opt.get("text", "") var is_confrontation: bool = opt.get("confrontation", false) - var label := RichTextLabel.new() - label.bbcode_enabled = true - label.fit_content = true - label.scroll_active = false + var label := Label.new() + label.add_theme_font_size_override("font_size", 14) + label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.mouse_filter = Control.MOUSE_FILTER_STOP label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - label.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT) - if is_confrontation: - label.text = "[i]%s[/i]" % raw_text - else: - label.text = raw_text + var numbered_text := "%d. %s" % [i + 1, raw_text] + label.text = numbered_text var idx := i label.gui_input.connect(func(event: InputEvent): @@ -407,15 +468,42 @@ func _rebuild_log() -> void: ## Hoshe #2: escape BBCode brackets in server-sourced strings. ## Araminta: passive lines get ┃ prefix + desaturated colours. ## Non-blocking: 1-on-1 player dialogue simplifies to "Speaker:" (no → You). +## Entity-anchored entries resolve display name and color from _entity_display. func _format_entry(entry: Dictionary, alpha: float) -> String: - var speaker: String = _escape_bbcode(entry.speaker) - var target: String = _escape_bbcode(entry.target) + var speaker: String + var target: String + var speaker_color: Color + var target_color: Color + var involves_player: bool + + if entry.has("speaker_id"): + # Entity-anchored entry: resolve from _entity_display registry + var sp_data: Dictionary = _entity_display.get(entry["speaker_id"], {}) + var tg_data: Dictionary = _entity_display.get(entry.get("target_id", -1), {}) + speaker = _escape_bbcode(sp_data.get("name", "?")) + target = _escape_bbcode(tg_data.get("name", "?")) + var sp_ci: int = sp_data.get("color_index", -1) + var tg_ci: int = tg_data.get("color_index", -1) + if sp_ci >= 0 and not _npc_colors.is_empty(): + speaker_color = _enforce_contrast(_npc_colors[sp_ci % _npc_colors.size()]) + else: + speaker_color = _color_for_name(sp_data.get("name", "?")) + if tg_ci >= 0 and not _npc_colors.is_empty(): + target_color = _enforce_contrast(_npc_colors[tg_ci % _npc_colors.size()]) + else: + target_color = _color_for_name(tg_data.get("name", "?")) + involves_player = false # Overheard entries never involve the player directly + else: + # Legacy string-keyed entry (player dialogue, backward compat) + speaker = _escape_bbcode(entry.get("speaker", "?")) + target = _escape_bbcode(entry.get("target", "?")) + speaker_color = _color_for_name(entry.get("speaker", "?")) + target_color = _color_for_name(entry.get("target", "?")) + involves_player = (entry.get("speaker", "") == PLAYER_NAME) or (entry.get("target", "") == PLAYER_NAME) + var text: String = _escape_bbcode(entry.text) var is_passive: bool = entry.is_passive - var speaker_color := _color_for_name(entry.speaker) - var target_color := _color_for_name(entry.target) - # Desaturate passive name colours (Araminta review) if is_passive: speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION) @@ -429,7 +517,6 @@ func _format_entry(entry: Dictionary, alpha: float) -> String: var prefix := PASSIVE_GLYPH if is_passive else "" # Non-blocking: simplify 1-on-1 player dialogue — no arrow for Speaker → You or You → Speaker - var involves_player := entry.speaker == PLAYER_NAME or entry.target == PLAYER_NAME if involves_player and not is_passive: # Just "Speaker: text" or "You: text" return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [ @@ -555,11 +642,11 @@ func _clear_options() -> void: # Hover callbacks -static func _make_hover_on(label: RichTextLabel) -> Callable: +static func _make_hover_on(label: Control) -> Callable: return func(): - label.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER) + label.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER) -static func _make_hover_off(label: RichTextLabel) -> Callable: +static func _make_hover_off(label: Control) -> Callable: return func(): - label.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT) + label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT) diff --git a/client/ui/interaction_list.gd b/client/ui/interaction_list.gd index a36fd7515..2a96d6850 100644 --- a/client/ui/interaction_list.gd +++ b/client/ui/interaction_list.gd @@ -19,10 +19,12 @@ 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 @@ -37,6 +39,21 @@ func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE +func _process(_delta: float) -> void: + if _showing: + _update_screen_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) + + func update_from_state() -> void: # D-055: Sprint stance suppresses interaction list if GameState.player_stance == "Sprint": @@ -70,6 +87,7 @@ func update_from_state() -> void: _current_target_id = entity_id _verb_items = sorted _selected_index = 0 + _cache_entity_position() _rebuild_labels() _show() @@ -84,15 +102,46 @@ func _rebuild_labels() -> void: for i in range(_verb_items.size()): var verb: Dictionary = _verb_items[i] var lbl := Label.new() - lbl.text = verb.get("label", "") + 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_IGNORE + 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 + + +## 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 @@ -161,6 +210,29 @@ func get_verb_labels() -> Array: 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: