From 2edc7c3098191a58472dfd527dab8188d05489c7 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 20:10:40 +0100 Subject: [PATCH] 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 --- client/data/dialogue-theme.yaml | 47 +++ client/scenes/main.tscn | 13 +- client/scripts/autoloads/game_state.gd | 23 ++ client/scripts/autoloads/sim_bridge.gd | 33 ++ client/scripts/main.gd | 40 +++ client/scripts/protocol/protocol.gd | 28 ++ client/ui/dialogue_box.gd | 400 +++++++++++++++++++------ client/ui/dialogue_box.tscn | 8 +- 8 files changed, 496 insertions(+), 96 deletions(-) create mode 100644 client/data/dialogue-theme.yaml diff --git a/client/data/dialogue-theme.yaml b/client/data/dialogue-theme.yaml new file mode 100644 index 000000000..d45b91aec --- /dev/null +++ b/client/data/dialogue-theme.yaml @@ -0,0 +1,47 @@ +# Dialogue Log Theme — The Settled Reach +# +# Ticket: #535 | Sprint: 14 +# Colors and timing for the unified dialogue log panel. +# Loaded by dialogue_box.gd at runtime. + +# ============================================================ +# PLAYER COLOR +# Fixed color for the player's name in dialogue log entries. +# ============================================================ +player_color: "#e0e8ff" + +# ============================================================ +# NPC COLOR PALETTE +# 8 distinct colors for NPC names. Indexed by hash(npc_name) % 8. +# Must be readable on a dark semi-transparent panel background. +# ============================================================ +npc_colors: + 0: "#4a9ebb" # teal + 1: "#6bc9a6" # green + 2: "#e8c547" # amber + 3: "#d49e5d" # warm orange + 4: "#b586d4" # lavender + 5: "#d45d5d" # muted red + 6: "#5daa7d" # forest + 7: "#7daccc" # sky blue + +# ============================================================ +# TEXT COLORS +# Arrow separator and speech text color. +# ============================================================ +arrow_color: "#8890a0" +speech_color: "#c8d0e0" + +# ============================================================ +# PASSIVE (OVERHEARD) OPACITY +# Base opacity multiplier for non-player-centric lines. +# 1.0 = full opacity, 0.0 = invisible. +# ============================================================ +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" diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index c77b4e6b7..0a866f02b 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=22 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] @@ -21,6 +21,7 @@ [ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"] [ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"] [ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"] +[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -152,6 +153,16 @@ layer = 20 ; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys [node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")] +; #511: F3 debug overlay — real-time game state, toggled by F3 +[node name="DebugOverlay" type="Control" parent="UILayer"] +anchors_preset = 0 +offset_left = 16 +offset_top = 120 +offset_right = 400 +offset_bottom = 400 +mouse_filter = 2 +script = ExtResource("22_debug") + ; D-056: Cursor state machine — insert-styled geometric cursor, topmost in UILayer [node name="CursorRenderer" type="Node2D" parent="UILayer"] script = ExtResource("10_cursor") diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 15120aafd..1fd486ec3 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -59,6 +59,11 @@ var rng_seed: Variant = null # v7 fields (#431, D-059/D-060) var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}] +# v9 fields (#535, D-078): Overheard NPC-to-NPC conversations +var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}] +var conversation_ended: Array = [] # [{speaker_id, target_id}] +var dialogue_response: Variant = null # {line_id, text, speaker_entity_id} — NPC follow-up after player choice + # #126, D-018: Medium-range sound events for fog-edge directional indicators. # Format: [{x, y, event_type, range_category}] — server sends current medium events per tick. var medium_sound_events: Array = [] @@ -169,6 +174,24 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: pending_recognitions = [] + # v9: conversation_events (#535, D-078) — overheard NPC-to-NPC lines + if snapshot.has("conversation_events") and snapshot.conversation_events is Array: + conversation_events = snapshot.conversation_events + else: + conversation_events = [] + + # v9: conversation_ended (#535, D-078) — pairs whose conversation ended + if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array: + conversation_ended = snapshot.conversation_ended + else: + conversation_ended = [] + + # v9: dialogue_response (#535, D-028) — NPC follow-up after player choice + if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary: + dialogue_response = snapshot.dialogue_response + else: + dialogue_response = null + # v8: gauntlet mode (#496) — room_id and gauntlet_mode if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true: gauntlet_mode = true diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index df6724770..39df6d43a 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -396,6 +396,37 @@ func _test_snapshot() -> Dictionary: "total_delay_ticks": total_delay, }) + # #535: Mock overheard NPC-NPC conversation (D-078) + # Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3. + # Conversation ends after 6 exchanges (~30 ticks). + var conv_events: Array = [] + var conv_ended: Array = [] + var conv_start := 3 + var conv_lines := [ + {"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."}, + {"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."}, + {"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."}, + {"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."}, + {"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."}, + {"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."}, + ] + var conv_tick_interval := 5 + var conv_total_ticks := conv_lines.size() * conv_tick_interval + if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks: + var conv_index := (_test_tick - conv_start) / conv_tick_interval + var within_tick := (_test_tick - conv_start) % conv_tick_interval + if within_tick == 0 and conv_index < conv_lines.size(): + var cl: Dictionary = conv_lines[conv_index] + conv_events.append({ + "speaker_id": 10, + "target_id": 11, + "speaker_name": cl.speaker, + "target_name": cl.target, + "occluded_line": cl.line, + }) + elif _test_tick == conv_start + conv_total_ticks: + conv_ended.append({"speaker_id": 10, "target_id": 11}) + return { "tick": _test_tick, "version": Protocol.PROTOCOL_VERSION, @@ -417,6 +448,8 @@ func _test_snapshot() -> Dictionary: "current_dialogue": dialogue, "pending_recognitions": pending_recs, "gauntlet_mode": _test_gauntlet_mode, + "conversation_events": conv_events, + "conversation_ended": conv_ended, } # Generate a small test room: 8x6 room with walls, a door, and floor diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 2d3e5715a..188404165 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -14,10 +14,12 @@ extends Node2D @onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7 @onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests @onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress +@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay @onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button @onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU) var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input +var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution var _camera_anchored: bool = false var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice var _last_dialogue_tick: int = -1 @@ -130,6 +132,10 @@ func _process(_delta: float) -> void: if checklist_overlay and checklist_overlay.has_method("update_from_state"): checklist_overlay.update_from_state() + # #511: Update debug overlay (F3 toggle, dev tool) + if debug_overlay and debug_overlay.has_method("update_from_state"): + debug_overlay.update_from_state() + # D-018 #125: Play close-range sound events via positional 2D audio _play_close_sound_events() @@ -146,6 +152,11 @@ func _process(_delta: float) -> void: # D-061: Show dialogue if server sent one this tick (#434) _consume_dialogue() + # #535: Consume overheard conversation events and responses + _consume_conversation_events() + _consume_conversation_ended() + _consume_dialogue_response() + # Track camera to player position every frame (D-015: locked, no panning) if _camera_anchored: camera.global_position = GameState.player_position * Constants.TILE_SIZE @@ -316,6 +327,7 @@ func _consume_dialogue() -> void: _last_dialogue_tick = GameState.current_tick var dlg: Dictionary = GameState.current_dialogue _last_dialogue_npc_id = dlg.get("npc_entity_id", -1) + _last_dialogue_npc_name = dlg.get("npc_name", "") dialogue_box.show_dialogue( dlg.get("npc_name", ""), dlg.get("speech", ""), @@ -324,6 +336,34 @@ func _consume_dialogue() -> void: GameState.current_dialogue = null +# #535: Consume overheard NPC-NPC conversation events (D-078). +# Each event carries pre-occluded text — render verbatim in the dialogue log. +func _consume_conversation_events() -> void: + if not dialogue_box: + return + for event in GameState.conversation_events: + dialogue_box.append_conversation_event(event) + GameState.conversation_events = [] + + +# #535: Handle conversation_ended events — notify dialogue box to stop tracking pairs. +func _consume_conversation_ended() -> void: + if not dialogue_box: + return + for event in GameState.conversation_ended: + dialogue_box.on_conversation_ended(event) + GameState.conversation_ended = [] + + +# #535: Consume dialogue_response — NPC follow-up line after player picks an option. +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", "")) + GameState.dialogue_response = null + + # D-061: Handle dialogue option selection → send to server func _on_dialogue_option_selected(response_id: String, text: String) -> void: SimBridge.send_input({ diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index e068f5bc9..c21020776 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -180,6 +180,32 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)), } + # v9: conversation_events (#535, D-078) — overheard NPC-to-NPC dialogue lines. + # Each event carries pre-occluded text plus speaker/target attribution. + var conversation_events: Array = [] + var raw_conv_events: Variant = raw.get("conversation_events") + if raw_conv_events is Array: + for raw_ce in raw_conv_events: + if raw_ce is Dictionary and raw_ce.has("occluded_line"): + conversation_events.append({ + "speaker_id": int(raw_ce.get("speaker_id", 0)), + "target_id": int(raw_ce.get("target_id", 0)), + "speaker_name": str(raw_ce.get("speaker_name", "")), + "target_name": str(raw_ce.get("target_name", "")), + "occluded_line": str(raw_ce["occluded_line"]), + }) + + # v9: conversation_ended (#535, D-078) — pairs whose conversation ended this tick. + var conversation_ended: Array = [] + var raw_conv_ended: Variant = raw.get("conversation_ended") + if raw_conv_ended is Array: + for raw_end in raw_conv_ended: + if raw_end is Dictionary: + conversation_ended.append({ + "speaker_id": int(raw_end.get("speaker_id", 0)), + "target_id": int(raw_end.get("target_id", 0)), + }) + return { "tick": tick, "entities": entities, @@ -195,6 +221,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "current_dialogue": current_dialogue, "dialogue_response": dialogue_response, "pending_recognitions": pending_recognitions, + "conversation_events": conversation_events, + "conversation_ended": conversation_ended, } diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index 62eb59c8c..619e106f7 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -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) diff --git a/client/ui/dialogue_box.tscn b/client/ui/dialogue_box.tscn index ac80c616e..aecdab9b2 100644 --- a/client/ui/dialogue_box.tscn +++ b/client/ui/dialogue_box.tscn @@ -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