feat(ui): unified dialogue log with overheard NPC conversations (#535)

Refactors dialogue box into a scrolling conversation log. All dialogue
(player-NPC and overheard NPC-NPC) flows chronologically, oldest at top.
Player response options at the bottom during active conversations.

- Entries expire after configurable timeout (equal for all message types)
- Walk-away clears options but preserves log entries (fair information)
- Per-character name colors from dialogue-theme.yaml (hash-indexed palette)
- Overheard lines render at 90% opacity (D-078)
- Protocol decode for conversation_events + conversation_ended
- GameState fields for conversation_events, conversation_ended, dialogue_response
- Mock Mira/Soren NPC-NPC conversation in test snapshot
- Also wires #511 debug overlay into main.gd and main.tscn

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 20:51:40 +01:00
co-authored by Claude Opus 4.6
parent ef3f4a70fa
commit 2edc7c3098
8 changed files with 496 additions and 96 deletions
+308 -92
View File
@@ -1,28 +1,49 @@
extends Control
# Dialogue box — D-061: bottom screen, max 20% height, no portraits.
# #535: Unified conversation log — player dialogue and overheard NPC-NPC conversations
# flow chronologically. Oldest at top, scrolls up. Options at bottom.
#
# InsertOverlay (CanvasLayer 10, z-layer 6) — diegetic, insert-styled.
# NPC speech top, player response options below, left-aligned.
# Max 3 visible options. No close button — walk-away (WASD) or option select only.
# Auto-pause in single-player when dialogue is open (D-061).
# D-063: Confrontation options render italic, trigger monologue beat before send.
# D-064: Walk-away via WASD dismisses active conversation (log entries persist).
# D-078: Overheard NPC-NPC lines shown at reduced opacity, no response options.
signal option_selected(response_id: String, text: String)
signal dialogue_dismissed # Walk-away or conversation end
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
@onready var panel: PanelContainer = $PanelContainer
@onready var npc_speech: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/NpcSpeech
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
# -- Log state --
var _log_entries: Array[Dictionary] = [] # {speaker, target, text, is_passive, timestamp_msec}
var _active_overheard: Dictionary = {} # "speaker_id:target_id" -> true
var _in_player_conversation: bool = false
# -- Option state --
var _option_controls: Array[Control] = []
var _option_response_ids: Array[String] = []
var _option_texts: Array[String] = []
var _option_is_confrontation: Array[bool] = []
var _npc_name: String = ""
# -- UI state --
var _is_showing: bool = false
var _active_tween: Tween = null
var _beat_tween: Tween = null # D-063: confrontation beat delay
var _option_controls: Array[Control] = []
var _option_response_ids: Array[String] = [] # response_id per option, same index
var _option_texts: Array[String] = [] # raw display text per option
var _option_is_confrontation: Array[bool] = [] # confrontation flag per option
var _npc_name: String = ""
# -- Theme (loaded from data/dialogue-theme.yaml) --
var _player_color: Color = Color("#e0e8ff")
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
const THEME_PATH: String = "res://data/dialogue-theme.yaml"
const FADE_IN: float = 0.2
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
@@ -32,6 +53,7 @@ const MAX_WIDTH_PX: float = Constants.DIALOGUE_MAX_WIDTH # D-076: 640px (OQ-29)
const CONFRONTATION_BEAT_DURATION: float = 1.5 # D-063: pause before sending
const CONFRONTATION_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat
const CONFRONTATION_MONOLOGUE_KEY: String = "dialogue.confrontation_beat"
const PLAYER_NAME: String = "You"
# D-064: movement actions that trigger walk-away
const _WALK_AWAY_ACTIONS: Array[StringName] = [
@@ -45,26 +67,78 @@ func _ready() -> void:
visible = false
_is_showing = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
_load_theme()
_update_layout()
get_viewport().size_changed.connect(_update_layout)
func _process(_delta: float) -> void:
_expire_entries()
func _unhandled_input(event: InputEvent) -> void:
if not _is_showing:
return
if not _in_player_conversation:
return
# D-064: WASD during dialogue → walk-away, 300ms fade
# D-064: WASD during active player dialogue → walk-away
if event is InputEventKey and event.pressed:
for action in _WALK_AWAY_ACTIONS:
if event.is_action_pressed(action):
get_viewport().set_input_as_handled()
_cancel_beat()
hide_dialogue()
_end_player_conversation()
dialogue_dismissed.emit()
return
# Responsive layout — clamps width to MAX_WIDTH_PX and height to 20% viewport.
# -- Theme loading --
func _load_theme() -> void:
# Default NPC palette in case file not found
_npc_colors = [
Color("#4a9ebb"), Color("#6bc9a6"), Color("#e8c547"), Color("#d49e5d"),
Color("#b586d4"), Color("#d45d5d"), Color("#5daa7d"), Color("#7daccc"),
]
if not FileAccess.file_exists(THEME_PATH):
push_warning("DialogueBox: theme file not found: %s — using defaults" % THEME_PATH)
return
var file := FileAccess.open(THEME_PATH, FileAccess.READ)
if file == null:
return
var text := file.get_as_text()
file.close()
var strings := UIStrings._parse_yaml(text)
if strings.has("player_color"):
_player_color = Color(strings["player_color"])
if strings.has("arrow_color"):
_arrow_color = Color(strings["arrow_color"])
if strings.has("speech_color"):
_speech_color = Color(strings["speech_color"])
if strings.has("passive_opacity"):
_passive_opacity = float(strings["passive_opacity"])
if strings.has("entry_lifetime_seconds"):
_entry_lifetime = float(strings["entry_lifetime_seconds"])
if strings.has("entry_fade_seconds"):
_entry_fade = float(strings["entry_fade_seconds"])
# Load NPC colors from indexed keys (npc_colors.0 through npc_colors.7)
var loaded_colors: Array[Color] = []
for i in range(16): # support up to 16 palette entries
var key := "npc_colors.%d" % i
if strings.has(key):
loaded_colors.append(Color(strings[key]))
if loaded_colors.size() > 0:
_npc_colors = loaded_colors
# -- Responsive layout --
func _update_layout() -> void:
var vp := get_viewport_rect().size
var max_h := vp.y * MAX_HEIGHT_RATIO
@@ -74,26 +148,128 @@ func _update_layout() -> void:
panel.offset_top = -max_h
# Show dialogue with NPC speech and response options.
# npc_name: who is speaking (displayed as prefix)
# speech: the NPC's dialogue text
# options: Array of {text, response_id, priority, confrontation} — sorted by priority, max 3 shown.
# D-062: locked options are invisible (server filters before sending).
# D-063: confrontation options render italic.
# -- Log entry management --
## Append a dialogue line to the log.
## speaker/target: display names. text: the spoken line.
## is_passive: true for overheard NPC-NPC (renders at reduced opacity).
func append_line(speaker: String, target: String, text: String, is_passive: bool = false) -> void:
_log_entries.append({
"speaker": speaker,
"target": target,
"text": text,
"is_passive": is_passive,
"timestamp_msec": Time.get_ticks_msec(),
})
_rebuild_log()
_ensure_visible()
## Append an overheard conversation event (D-078).
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
var pair_key := "%s:%s" % [event.get("speaker_id", 0), event.get("target_id", 0)]
_active_overheard[pair_key] = true
append_line(speaker, target, text, true)
## Handle conversation_ended — remove pair from active tracking.
func on_conversation_ended(event: Dictionary) -> void:
var pair_key := "%s:%s" % [event.get("speaker_id", 0), event.get("target_id", 0)]
_active_overheard.erase(pair_key)
# Also try the reverse pair
var reverse_key := "%s:%s" % [event.get("target_id", 0), event.get("speaker_id", 0)]
_active_overheard.erase(reverse_key)
## Append the player's chosen response to the log.
func append_player_line(target_npc: String, text: String) -> void:
append_line(PLAYER_NAME, target_npc, text, false)
## Append an NPC follow-up line (from dialogue_response).
func append_dialogue_response(npc_name: String, text: String) -> void:
append_line(npc_name, PLAYER_NAME, text, false)
# -- Active player conversation --
## Show dialogue with NPC speech and response options.
## npc_name: who is speaking. speech: the NPC's line. options: player choices.
func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void:
_npc_name = npc_name
_cancel_beat()
_in_player_conversation = true
# NPC speech — name prefix in bold
if npc_name.is_empty():
npc_speech.text = speech
else:
npc_speech.text = "[b]%s:[/b] %s" % [npc_name, speech]
# Append NPC's line to the log
if not speech.is_empty():
append_line(npc_name, PLAYER_NAME, speech, false)
# Clear old options
# Clear old options and show new ones
_clear_options()
_show_options(options)
# Sort by priority ascending, cap at MAX_OPTIONS (D-061: max 3 visible)
# Show panel
_ensure_visible()
mouse_filter = Control.MOUSE_FILTER_STOP
GameState.dialogue_active = true # D-064: block movement while in conversation
# D-069: Dialogue dip
AudioManager.apply_dip("dialogue")
# D-061: auto-pause in single-player
SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()})
## End active player conversation — clears options but preserves log.
func _end_player_conversation() -> void:
_in_player_conversation = false
_clear_options()
mouse_filter = Control.MOUSE_FILTER_IGNORE
# D-069: Clear dialogue/confrontation dip
AudioManager.clear_dip()
# D-061: unpause
SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()})
GameState.dialogue_active = false
## Hide the entire panel with fade.
func hide_dialogue() -> void:
if not _is_showing:
return
if _in_player_conversation:
_end_player_conversation()
_is_showing = false
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", 0.0, FADE_OUT)
_active_tween.tween_callback(func():
visible = false
)
func is_dialogue_active() -> bool:
return _in_player_conversation or GameState.dialogue_active
func has_active_entries() -> bool:
return _log_entries.size() > 0 or _in_player_conversation
# -- Options --
func _show_options(options: Array) -> void:
var sorted_opts: Array = options.duplicate()
sorted_opts.sort_custom(func(a, b): return a.get("priority", 0) < b.get("priority", 0))
var count := mini(sorted_opts.size(), MAX_OPTIONS)
@@ -103,7 +279,6 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
var raw_text: String = opt.get("text", "")
var is_confrontation: bool = opt.get("confrontation", false)
# RichTextLabel for BBCode support (D-063: confrontation italic)
var label := RichTextLabel.new()
label.bbcode_enabled = true
label.fit_content = true
@@ -117,13 +292,11 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
else:
label.text = raw_text
# Click handling
var idx := i
label.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_on_option_pressed(idx)
)
# Hover color
label.mouse_entered.connect(_make_hover_on(label))
label.mouse_exited.connect(_make_hover_off(label))
@@ -133,56 +306,6 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
_option_texts.append(raw_text)
_option_is_confrontation.append(is_confrontation)
# Show with fade
visible = true
mouse_filter = Control.MOUSE_FILTER_STOP
_is_showing = true
GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading
# D-069: Dialogue dip — reduce ambient noise to foreground conversation.
# Confrontation options REPLACE (not nest) this dip via apply_dip("confrontation")
# in _start_confrontation_beat(). hide_dialogue()'s clear_dip() restores to base
# volumes regardless of which profile was last active — intentional replacement semantics.
AudioManager.apply_dip("dialogue")
# D-061: auto-pause in single-player when dialogue opens
SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()})
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", 1.0, FADE_IN)
# Hide dialogue with fade (D-064: 300ms)
func hide_dialogue() -> void:
if not _is_showing:
return
_is_showing = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
# D-069: Clear dialogue/confrontation dip — restore bus volumes to player slider settings.
# Safe to call even if confrontation beat already cleared the dip (no-op when empty).
AudioManager.clear_dip()
# D-061: unpause when dialogue closes
SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()})
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", 0.0, FADE_OUT)
_active_tween.tween_callback(func():
visible = false
_clear_options()
GameState.dialogue_active = false # D-064: unblock movement after fade completes
)
func is_dialogue_active() -> bool:
return _is_showing or GameState.dialogue_active
func _on_option_pressed(index: int) -> void:
if index >= _option_controls.size():
@@ -191,46 +314,40 @@ func _on_option_pressed(index: int) -> void:
var text: String = _option_texts[index] if index < _option_texts.size() else ""
var is_confront: bool = _option_is_confrontation[index] if index < _option_is_confrontation.size() else false
# Append player's chosen response to the log
append_player_line(_npc_name, text)
if is_confront:
_start_confrontation_beat(rid, text)
else:
option_selected.emit(rid, text)
hide_dialogue()
_end_player_conversation()
# D-063: Confrontation beat — delay before sending response.
# 1. Dim dialogue to 70%, show monologue, dip audio
# 2. Wait CONFRONTATION_BEAT_DURATION
# 3. Emit option_selected, restore audio, hide dialogue
# -- Confrontation beat (D-063) --
func _start_confrontation_beat(response_id: String, text: String) -> void:
# Disable option clicks during beat
for ctrl in _option_controls:
if is_instance_valid(ctrl):
ctrl.mouse_filter = Control.MOUSE_FILTER_IGNORE
# Dim dialogue box
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
# D-063: monologue beat — text from ui-strings.yaml (D-042)
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
# D-063: audio dip via AudioManager
AudioManager.apply_dip("confrontation")
# Delay, then complete
_beat_tween = create_tween()
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
_beat_tween.tween_callback(func():
AudioManager.clear_dip()
option_selected.emit(response_id, text)
hide_dialogue()
_end_player_conversation()
)
# Cancel an in-flight confrontation beat (e.g. player walks away mid-beat).
func _cancel_beat() -> void:
if _beat_tween and _beat_tween.is_valid():
_beat_tween.kill()
@@ -238,6 +355,105 @@ func _cancel_beat() -> void:
AudioManager.clear_dip()
# -- Log rendering --
## Rebuild the dialogue log BBCode from all non-expired entries.
func _rebuild_log() -> void:
if not dialogue_log:
return
var now := Time.get_ticks_msec()
var bbcode := ""
for entry in _log_entries:
var age_sec: float = (now - entry.timestamp_msec) / 1000.0
var alpha: float = 1.0
if age_sec > _entry_lifetime:
alpha = clampf(1.0 - (age_sec - _entry_lifetime) / _entry_fade, 0.0, 1.0)
if entry.is_passive:
alpha *= _passive_opacity
if alpha <= 0.0:
continue
var line := _format_entry(entry, alpha)
if not bbcode.is_empty():
bbcode += "\n"
bbcode += line
dialogue_log.text = bbcode
## Format a single log entry as BBCode.
func _format_entry(entry: Dictionary, alpha: float) -> String:
var speaker_color := _color_for_name(entry.speaker)
var target_color := _color_for_name(entry.target)
# Apply alpha to all colors in this line
var sc := _color_with_alpha(speaker_color, alpha)
var tc := _color_with_alpha(target_color, alpha)
var ac := _color_with_alpha(_arrow_color, alpha)
var txc := _color_with_alpha(_speech_color, alpha)
return "[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
sc, entry.speaker, ac, tc, entry.target, txc, entry.text
]
## Get a stable color for a character name.
func _color_for_name(char_name: String) -> Color:
if char_name == PLAYER_NAME:
return _player_color
if _npc_colors.is_empty():
return _speech_color
var idx := char_name.hash() % _npc_colors.size()
# hash() can return negative in GDScript — use abs
if idx < 0:
idx = -idx
return _npc_colors[idx]
## Convert a Color to a hex string with alpha baked in.
static func _color_with_alpha(base: Color, alpha: float) -> String:
return Color(base.r, base.g, base.b, base.a * alpha).to_html(true)
# -- Entry expiry --
## Remove fully expired entries. Hide panel if nothing remains.
func _expire_entries() -> void:
if _log_entries.is_empty():
return
var now := Time.get_ticks_msec()
var total_lifetime_msec: int = int((_entry_lifetime + _entry_fade) * 1000.0)
var had_entries := _log_entries.size() > 0
# Remove expired entries from the front (oldest first)
while _log_entries.size() > 0:
var age: int = now - _log_entries[0].timestamp_msec
if age < total_lifetime_msec:
break
_log_entries.remove_at(0)
# Rebuild if entries were removed
if had_entries and _log_entries.size() == 0:
_rebuild_log()
if not _in_player_conversation:
hide_dialogue()
elif had_entries:
# Rebuild to update fading entries
_rebuild_log()
# -- Visibility --
## Ensure panel is visible (fade in if needed).
func _ensure_visible() -> void:
if _is_showing:
return
visible = true
_is_showing = true
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", 1.0, FADE_IN)
func _clear_options() -> void:
for ctrl in _option_controls:
if is_instance_valid(ctrl):
@@ -248,7 +464,7 @@ func _clear_options() -> void:
_option_is_confrontation.clear()
# Hover callbacks — closures that capture the label reference.
# Hover callbacks
static func _make_hover_on(label: RichTextLabel) -> Callable:
return func():
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
+5 -3
View File
@@ -37,12 +37,14 @@ theme_override_constants/margin_bottom = 14
layout_mode = 2
theme_override_constants/separation = 10
[node name="NpcSpeech" type="RichTextLabel" parent="PanelContainer/MarginContainer/VBoxContainer"]
[node name="DialogueLog" type="RichTextLabel" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
bbcode_enabled = true
text = ""
fit_content = true
scroll_active = false
fit_content = false
scroll_active = true
scroll_following = true
[node name="OptionsContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2