feat(ui): entity-anchored dialogue log with keyboard selection
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 --------------------------------
|
||||
|
||||
+117
-30
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user