From 9b53096b0b15fc49fd1f9d901ceb8348215490f9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 20:10:30 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(ui):=20F3=20debug=20overlay=20?= =?UTF-8?q?=E2=80=94=20real-time=20game=20state=20display=20(#511)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle with F3. Shows tick, fps, position, facing, stance, zone, entity/tile counts, interaction/recognition counts, dialogue status, stationary ticks, insert state, time/tick_rate, mode, gauntlet. Two-column layout, click-through, hidden by default. Co-Authored-By: Claude Opus 4.6 --- client/project.godot | 5 ++ client/scripts/ui/debug_overlay.gd | 115 +++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 client/scripts/ui/debug_overlay.gd diff --git a/client/project.godot b/client/project.godot index d87dd633c..8aa99cf2f 100644 --- a/client/project.godot +++ b/client/project.godot @@ -120,6 +120,11 @@ bug_report={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +debug_overlay={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} teleport_hub={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) diff --git a/client/scripts/ui/debug_overlay.gd b/client/scripts/ui/debug_overlay.gd new file mode 100644 index 000000000..0586a6399 --- /dev/null +++ b/client/scripts/ui/debug_overlay.gd @@ -0,0 +1,115 @@ +extends Control +# #511: F3 debug overlay — real-time game state display for dev use. + +const HEADER_COLOR := Color("#e8c547") +const LABEL_COLOR := Color("#8890a0") +const VALUE_COLOR := Color("#c8d0e0") +const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85) +const FONT_SIZE := 12 +const LINE_HEIGHT := 16 +const PADDING := Vector2(10, 8) +const COL_GAP := 16 # gap between left and right columns + +var _cached_font: Font = null + +func _ready() -> void: + visible = false + _cached_font = ThemeDB.fallback_font + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("debug_overlay"): + visible = not visible + if visible: + queue_redraw() + +func update_from_state() -> void: + if not visible: + return + queue_redraw() + +func _draw() -> void: + if not visible: + return + var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font + + # Build lines as [label, value, label, value] pairs (two columns) + var left_lines: Array = [] + var right_lines: Array = [] + + left_lines.append(["tick", str(GameState.current_tick)]) + right_lines.append(["fps", str(Engine.get_frames_per_second())]) + + var pos := GameState.player_position + left_lines.append(["pos", "(%d, %d)" % [int(pos.x), int(pos.y)]]) + right_lines.append(["facing", GameState.player_facing]) + + left_lines.append(["stance", GameState.player_stance]) + right_lines.append(["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"]) + + left_lines.append(["entities", str(GameState.visible_entities.size())]) + right_lines.append(["tiles", str(GameState.visible_tiles.size())]) + + left_lines.append(["interactions", str(GameState.nearby_interactions.size())]) + right_lines.append(["recognitions", str(GameState.pending_recognitions.size())]) + + var mono_status := "active" if GameState.current_monologue != null else "idle" + var dlg_status := "active" if GameState.dialogue_active else "idle" + left_lines.append(["monologue", mono_status]) + right_lines.append(["dialogue", dlg_status]) + + left_lines.append(["stationary", str(GameState.stationary_ticks)]) + right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"]) + + var gt := GameState.game_time + var time_str := "%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-" + var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-" + left_lines.append(["time", time_str]) + right_lines.append(["tick_rate", rate_str]) + + var mode_str := "test" if SimBridge.test_mode else "live" + var gauntlet_str := "ON" if GameState.gauntlet_mode else "OFF" + left_lines.append(["mode", mode_str]) + right_lines.append(["gauntlet", gauntlet_str]) + + # Measure column widths + var left_label_w: float = 0.0 + var left_value_w: float = 0.0 + var right_label_w: float = 0.0 + var right_value_w: float = 0.0 + + for line in left_lines: + left_label_w = max(left_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) + left_value_w = max(left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) + for line in right_lines: + right_label_w = max(right_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) + right_value_w = max(right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) + + var header_text := "F3 DEBUG" + var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x + var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w + var box_w: float = max(header_w, content_w) + PADDING.x * 2 + var line_count := max(left_lines.size(), right_lines.size()) + var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines + + # Background + draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR) + + # Header + var y: float = PADDING.y + FONT_SIZE + draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR) + y += LINE_HEIGHT + + # Data lines (two columns) + var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP + for i in range(line_count): + if i < left_lines.size(): + var lbl: String = left_lines[i][0] + ": " + var val: String = left_lines[i][1] + draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR) + draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR) + if i < right_lines.size(): + var lbl: String = right_lines[i][0] + ": " + var val: String = right_lines[i][1] + draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR) + draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR) + y += LINE_HEIGHT -- 2.54.0 From 1d0ba5f7d82e63b2d520620949afe8211c24e457 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 20:10:40 +0100 Subject: [PATCH 2/3] 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 -- 2.54.0 From e19887c5a97480dd1e7c055f5d956ef352717365 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 20:39:12 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(ui):=20address=20PR=20#52=20review=20?= =?UTF-8?q?=E2=80=94=2010=20items=20across=20Hoshe,=20Tyre,=20Araminta=20(?= =?UTF-8?q?#535)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: bump PROTOCOL_VERSION 8→9 for conversation_events/ended fields. Hoshe review: - Dirty flag (_log_dirty) prevents per-frame O(n) BBCode rebuild - BBCode injection: _escape_bbcode() replaces [ with [lb] on server text - D-064 regression: dialogue_active cleared in fade callback, not before - YAML quoting: remove unnecessary quotes from numeric values Tyre review: - Carry-forward for dialogue_response, conversation_events, conversation_ended in receive_bytes() — arrays merge, scalar falls through - pause_requested/unpause_requested signals route through main.gd input recording (_pending_record_inputs) for #507 replay determinism - Fix version comments: dialogue_response is v8 (#305), not v9 - Remove dead _active_overheard dictionary Araminta review: - Passive lines: ┃ glyph prefix + _desaturate() for name colours - Active conversation entries pinned (no timeout), unpinned with timestamp reset on conversation end - _enforce_contrast(): minimum luminance floor for name colour readability - Simplified 1-on-1 attribution: "Speaker:" instead of "Speaker → You:" Co-Authored-By: Claude Opus 4.6 --- client/data/dialogue-theme.yaml | 6 +- client/scripts/autoloads/game_state.gd | 6 +- client/scripts/autoloads/sim_bridge.gd | 11 ++ client/scripts/main.gd | 16 +++ client/scripts/protocol/protocol.gd | 2 +- client/ui/dialogue_box.gd | 180 ++++++++++++++++++------- 6 files changed, 170 insertions(+), 51 deletions(-) diff --git a/client/data/dialogue-theme.yaml b/client/data/dialogue-theme.yaml index d45b91aec..bab788961 100644 --- a/client/data/dialogue-theme.yaml +++ b/client/data/dialogue-theme.yaml @@ -37,11 +37,11 @@ speech_color: "#c8d0e0" # Base opacity multiplier for non-player-centric lines. # 1.0 = full opacity, 0.0 = invisible. # ============================================================ -passive_opacity: "0.9" +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: 15.0 +entry_fade_seconds: 3.0 diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 1fd486ec3..9c1f82920 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -59,10 +59,12 @@ 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}] +# v8 fields (#305, D-028): NPC follow-up after player dialogue choice +var dialogue_response: Variant = null # {line_id, text, speaker_entity_id} + # 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. @@ -186,7 +188,7 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: conversation_ended = [] - # v9: dialogue_response (#535, D-028) — NPC follow-up after player choice + # v8: dialogue_response (#305, 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: diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 39df6d43a..fa91fb3c3 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -241,6 +241,17 @@ func receive_bytes(bytes: PackedByteArray) -> void: snapshot["current_monologue"] = _last_snapshot["current_monologue"] if snapshot.get("current_dialogue") == null and _last_snapshot.get("current_dialogue") != null: snapshot["current_dialogue"] = _last_snapshot["current_dialogue"] + # #535: Carry forward one-shot dialogue events (arrays merge, scalar falls through) + if snapshot.get("dialogue_response") == null and _last_snapshot.get("dialogue_response") != null: + snapshot["dialogue_response"] = _last_snapshot["dialogue_response"] + var old_conv_events: Array = _last_snapshot.get("conversation_events", []) + if old_conv_events.size() > 0: + var new_conv_events: Array = snapshot.get("conversation_events", []) + snapshot["conversation_events"] = old_conv_events + new_conv_events + var old_conv_ended: Array = _last_snapshot.get("conversation_ended", []) + if old_conv_ended.size() > 0: + var new_conv_ended: Array = snapshot.get("conversation_ended", []) + snapshot["conversation_ended"] = old_conv_ended + new_conv_ended _last_snapshot = snapshot # Drain the outbound buffer. Returns raw input entries for batch encoding. diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 188404165..2255a47c9 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -62,6 +62,8 @@ func _ready() -> void: dialogue_box.option_selected.connect(_on_dialogue_option_selected) dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed) dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue) + dialogue_box.pause_requested.connect(_on_dialogue_pause_requested) + dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested) # #496: Print gauntlet session summary on disconnect if gauntlet_hud: @@ -389,6 +391,20 @@ func _on_confrontation_monologue(text: String, duration: float) -> void: monologue_display.show_monologue(text, duration, 3, true) +# D-061: Auto-pause on dialogue open — routed through input recording (#507, Tyre #3) +func _on_dialogue_pause_requested() -> void: + var input := {"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()} + SimBridge.send_input(input) + _pending_record_inputs.append(input) + + +# D-061: Auto-unpause on dialogue close — routed through input recording (#507, Tyre #3) +func _on_dialogue_unpause_requested() -> void: + var input := {"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()} + SimBridge.send_input(input) + _pending_record_inputs.append(input) + + # D-064: Handle walk-away → send WalkAway{npc_id} to server func _on_dialogue_dismissed() -> void: SimBridge.send_input({ diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index c21020776..65a440dec 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 = 8 +const PROTOCOL_VERSION: int = 9 # -- Decode: bytes from server → GDScript types -------------------------------- diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index 619e106f7..6b7d1d7c0 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -7,20 +7,25 @@ extends Control # InsertOverlay (CanvasLayer 10, z-layer 6) — diegetic, insert-styled. # 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. +# dialogue_active held until fade completes (D-064 auto-pause spec). +# D-078: Overheard NPC-NPC lines prefixed with ┃ glyph + desaturated colours. 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 +signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507) +signal unpause_requested # D-061: auto-unpause @onready var panel: PanelContainer = $PanelContainer @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 +# Entry format: {speaker, target, 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) # -- Option state -- var _option_controls: Array[Control] = [] @@ -54,6 +59,9 @@ 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" +const PASSIVE_GLYPH: String = "\u2503 " # ┃ prefix for overheard lines (Araminta review) +const PASSIVE_DESATURATION: float = 0.4 # Desaturate passive name colours by this factor +const MIN_CONTRAST_LUMINANCE: float = 0.25 # Floor for name colour brightness against dark BG # D-064: movement actions that trigger walk-away const _WALK_AWAY_ACTIONS: Array[StringName] = [ @@ -74,6 +82,9 @@ func _ready() -> void: func _process(_delta: float) -> void: _expire_entries() + if _log_dirty: + _log_dirty = false + _rebuild_log() func _unhandled_input(event: InputEvent) -> void: @@ -152,16 +163,19 @@ func _update_layout() -> void: ## 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). +## is_passive: true for overheard NPC-NPC (renders with ┃ prefix + desaturated). +## Active conversation entries are pinned (no timeout) while _in_player_conversation. func append_line(speaker: String, target: String, text: String, is_passive: bool = false) -> void: + var pinned := not is_passive and _in_player_conversation _log_entries.append({ "speaker": speaker, "target": target, "text": text, "is_passive": is_passive, + "pinned": pinned, "timestamp_msec": Time.get_ticks_msec(), }) - _rebuild_log() + _log_dirty = true _ensure_visible() @@ -172,18 +186,12 @@ func append_conversation_event(event: Dictionary) -> void: 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) +## Handle conversation_ended — no-op currently (entries expire via timeout). +func on_conversation_ended(_event: Dictionary) -> void: + pass ## Append the player's chosen response to the log. @@ -221,23 +229,36 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi # 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()}) + # D-061: auto-pause — signal to main.gd for input recording (#507) + pause_requested.emit() ## End active player conversation — clears options but preserves log. +## D-064: dialogue_active held until fade completes. func _end_player_conversation() -> void: _in_player_conversation = false _clear_options() mouse_filter = Control.MOUSE_FILTER_IGNORE + # Unpin all entries and reset their timestamps so timeout starts now (Araminta review) + var now := Time.get_ticks_msec() + for entry in _log_entries: + if entry.pinned: + entry.pinned = false + entry.timestamp_msec = now + _log_dirty = true + # 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()}) + # D-061: unpause — signal to main.gd for input recording (#507) + unpause_requested.emit() - GameState.dialogue_active = false + # 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. + if _log_entries.is_empty(): + hide_dialogue() ## Hide the entire panel with fade. @@ -247,6 +268,7 @@ func hide_dialogue() -> void: if _in_player_conversation: _end_player_conversation() + return # _end_player_conversation may call hide_dialogue if log is empty _is_showing = false @@ -256,6 +278,7 @@ func hide_dialogue() -> void: _active_tween.tween_property(panel, "modulate:a", 0.0, FADE_OUT) _active_tween.tween_callback(func(): visible = false + GameState.dialogue_active = false # D-064: unblock movement after fade completes ) @@ -364,10 +387,11 @@ func _rebuild_log() -> void: 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 not entry.pinned: + var age_sec: float = (now - entry.timestamp_msec) / 1000.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: @@ -380,32 +404,83 @@ func _rebuild_log() -> void: ## Format a single log entry as BBCode. +## 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). func _format_entry(entry: Dictionary, alpha: float) -> String: + var speaker: String = _escape_bbcode(entry.speaker) + var target: String = _escape_bbcode(entry.target) + 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) - # Apply alpha to all colors in this line + # Desaturate passive name colours (Araminta review) + if is_passive: + speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION) + target_color = _desaturate(target_color, PASSIVE_DESATURATION) + 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 tc := _color_with_alpha(target_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 - ] + 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]" % [ + prefix, sc, speaker, txc, text + ] + else: + # Full "Speaker → Target: text" for overheard + return "%s[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [ + prefix, sc, speaker, ac, tc, target, txc, text + ] -## Get a stable color for a character name. +## Escape BBCode bracket characters in server-sourced text (Hoshe #2). +static func _escape_bbcode(text: String) -> String: + return text.replace("[", "[lb]") + + +## Get a stable color for a character name, with contrast floor enforcement. 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] + var idx := absi(char_name.hash()) % _npc_colors.size() + var color := _npc_colors[idx] + return _enforce_contrast(color) + + +## Enforce minimum luminance so name colours remain readable against dark BG (Araminta #3). +static func _enforce_contrast(color: Color) -> Color: + var lum := color.r * 0.299 + color.g * 0.587 + color.b * 0.114 + if lum < MIN_CONTRAST_LUMINANCE: + var boost := MIN_CONTRAST_LUMINANCE / maxf(lum, 0.001) + return Color( + minf(color.r * boost, 1.0), + minf(color.g * boost, 1.0), + minf(color.b * boost, 1.0), + color.a + ) + return color + + +## Desaturate a colour by a factor (0.0 = no change, 1.0 = full greyscale). +static func _desaturate(color: Color, amount: float) -> Color: + var grey := color.r * 0.299 + color.g * 0.587 + color.b * 0.114 + return Color( + lerpf(color.r, grey, amount), + lerpf(color.g, grey, amount), + lerpf(color.b, grey, amount), + color.a + ) ## Convert a Color to a hex string with alpha baked in. @@ -415,29 +490,44 @@ static func _color_with_alpha(base: Color, alpha: float) -> String: # -- Entry expiry -- -## Remove fully expired entries. Hide panel if nothing remains. +## Remove fully expired entries. Mark dirty if any fading entries exist. +## Pinned entries (active conversation) skip expiry entirely (Araminta #2). 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 + var removed := false + var has_fading := false - # Remove expired entries from the front (oldest first) + # Remove expired non-pinned entries from the front (oldest first) while _log_entries.size() > 0: - var age: int = now - _log_entries[0].timestamp_msec + var entry: Dictionary = _log_entries[0] + if entry.pinned: + break # Pinned entries never expire + var age: int = now - entry.timestamp_msec if age < total_lifetime_msec: + # Check if this entry is in the fading phase + if age > int(_entry_lifetime * 1000.0): + has_fading = true break _log_entries.remove_at(0) + removed = true - # 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() + # Check remaining entries for fading state + if not has_fading: + for entry in _log_entries: + if not entry.pinned: + var age: int = now - entry.timestamp_msec + if age > int(_entry_lifetime * 1000.0): + has_fading = true + break + + if removed or has_fading: + _log_dirty = true + + if removed and _log_entries.is_empty() and not _in_player_conversation: + hide_dialogue() # -- Visibility -- -- 2.54.0