From 9b53096b0b15fc49fd1f9d901ceb8348215490f9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 20 Feb 2026 20:10:30 +0100 Subject: [PATCH 01/11] =?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 02/11] 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 03/11] =?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 From 7ddee15f0661e0e118127609e193d35db6047752 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:09:53 +0100 Subject: [PATCH 04/11] feat(client): tilemap z-filtering, entity 24x32 footprint, follow stub (#71, #72) tile_renderer: only render z=0 tiles on FloorTiles layer (D-049 z-stack). entity_renderer: fix footprint from 24x24 to 24x32 per D-044, split ENTITY_SIZE into ENTITY_WIDTH/ENTITY_HEIGHT with separate offsets. game_state: add follow_target_id stub for server ticket #241. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/game_state.gd | 4 ++++ client/scripts/rendering/entity_renderer.gd | 21 ++++++++++++--------- client/scripts/rendering/tile_renderer.gd | 8 ++++++++ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 9c1f82920..50318fad5 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -19,6 +19,10 @@ var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral" # refined when the server assigns explicit player entity IDs). var player_entity_id: int = 1 +# #241: Follow target — entity_id of the NPC the player is following, -1 when not following. +# Stub for server ticket #241 (Follow verb). Client reads this for camera/UI behavior. +var follow_target_id: int = -1 + # v4 fields (#404/#405) var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}] diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 61ff8e0d2..1bbdb7529 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -13,8 +13,11 @@ extends Node2D # color from RelationshipState via the knowledge graph. const TILE_SIZE: int = Constants.TILE_SIZE -const ENTITY_SIZE: int = 24 -const ENTITY_OFFSET: float = (TILE_SIZE - ENTITY_SIZE) / 2.0 # center within tile +# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source scaled to 32px runtime) +const ENTITY_WIDTH: int = 24 +const ENTITY_HEIGHT: int = 32 +const ENTITY_OFFSET_X: float = (TILE_SIZE - ENTITY_WIDTH) / 2.0 # center horizontally +const ENTITY_OFFSET_Y: float = (TILE_SIZE - ENTITY_HEIGHT) / 2.0 # center vertically (bottom-aligned for y-sort would use TILE_SIZE - ENTITY_HEIGHT, but center is correct for placeholder) # Lerp speed — framerate-independent exponential smoothing. # At 12.0: ~70% there after 0.1s, ~95% after 0.25s. @@ -93,8 +96,8 @@ func update_entities(entities: Array) -> void: func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void: var entity_node = ColorRect.new() entity_node.name = "Entity_" + str(entity_id) - entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE) - entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0) + entity_node.size = Vector2(ENTITY_WIDTH, ENTITY_HEIGHT) + entity_node.pivot_offset = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0) # D-033 color by relationship (#521) entity_node.color = _color_for_kind(entity_data) @@ -110,8 +113,8 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void: # Snap to initial position (no lerp on first appearance) if entity_data.has("x") and entity_data.has("y"): var target := Vector2( - floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET, - floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET + floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X, + floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y ) entity_node.position = target _entity_targets[entity_id] = target @@ -129,8 +132,8 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void: # Server sends tile-center coords (tile 16 → 16.5), floor to get tile index. if entity_data.has("x") and entity_data.has("y"): _entity_targets[entity_id] = Vector2( - floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET, - floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET + floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X, + floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y ) # #521: Detect relationship change → fade D-033 color (0.5s via _process) @@ -195,5 +198,5 @@ func _add_facing_indicator(parent_node: Control) -> void: ]) indicator.color = Constants.ENTITY_COLOR_PLAYER # Position at center of parent ColorRect — rotation around this point - indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0) + indicator.position = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0) parent_node.add_child(indicator) diff --git a/client/scripts/rendering/tile_renderer.gd b/client/scripts/rendering/tile_renderer.gd index 94a8453fd..88cfb336a 100644 --- a/client/scripts/rendering/tile_renderer.gd +++ b/client/scripts/rendering/tile_renderer.gd @@ -64,6 +64,8 @@ func _setup_tileset() -> void: # Update tiles from snapshot data # tiles: Array of {x: int, y: int, z: int, type: String} +# Only renders z=0 tiles (ground floor). z=1 (FloorObjects) and z>1 (upper floors) +# are handled by separate nodes — skipped here until those layers are implemented. func update_tiles(tiles: Array) -> void: if not _initialized: return @@ -74,6 +76,12 @@ func update_tiles(tiles: Array) -> void: if not tile_data.has("x") or not tile_data.has("y") or not tile_data.has("type"): continue + # Multi-layer support: FloorTiles only renders z=0 (ground floor). + # z=1 → FloorObjects node, z=2 → YSortGroup furniture (future layers). + var tile_z: int = tile_data.get("z", 0) + if tile_z != 0: + continue + var tile_type_str: String = tile_data.type if not TILE_TYPE_MAP.has(tile_type_str): push_warning("TileRenderer: unknown tile type '%s' at (%d, %d)" % [ -- 2.54.0 From 176052dfd687d108828ab6e8b954dc1bd09bacf7 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:09:58 +0100 Subject: [PATCH 05/11] feat(client): manual exponential camera smoothing (#117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Godot built-in Camera2D smoothing with manual lerp using CAMERA_SMOOTHING_SPEED (8.0) in constants.gd — same exponential smoothing pattern as entity_renderer.gd. Teleport snap preserved via _teleport_in_progress flag. D-015 fixed-north camera lock intact. Co-Authored-By: Claude Opus 4.6 --- client/scripts/constants.gd | 5 +++++ client/scripts/main.gd | 41 +++++++++++++++---------------------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index a4aaf0908..f286ad59b 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -99,6 +99,11 @@ const DIALOGUE_MAX_WIDTH: int = 1200 # Default camera zoom — used as fallback when get_camera_2d() returns null const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0) +# #117: Camera smoothing speed — exponential interpolation via manual lerp in main.gd. +# Same pattern as EntityRenderer.LERP_SPEED. At 8.0: ~55% convergence after 0.1s. +# Slightly softer than entity movement (12.0) for a touch of cinematic camera lag. +const CAMERA_SMOOTHING_SPEED: float = 8.0 + # #517: Implant UI font color grading — avoid pure white, project through a lens const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 9421f0c8b..cd7d6febc 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -35,13 +35,10 @@ const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before Listenin func _ready() -> void: print("The Settled Reach — client initialized") - # Disable camera smoothing during init. Camera2D's position_smoothing - # lerps an internal smoothed_camera_pos toward global_position each frame. - # That smoothed position initializes at (0,0) — the Camera2D's default in - # the .tscn. Even after we set global_position to the player coords, - # smoothing causes the viewport to still show (0,0) on the first rendered - # frame because the lerp hasn't converged. With smoothing OFF, the viewport - # uses global_position directly. Re-enabled in _process() after anchor. + # #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing. + # We lerp camera.global_position directly in _process() using CAMERA_SMOOTHING_SPEED, + # matching entity_renderer.gd's exponential smoothing pattern. Built-in smoothing + # would conflict because we'd be setting global_position to the target every frame. camera.position_smoothing_enabled = false # Connect to simulation (test mode sets CONNECTED immediately) @@ -70,7 +67,7 @@ func _ready() -> void: SimBridge.connection_state_changed.connect(_on_connection_state_changed) -func _process(_delta: float) -> void: +func _process(delta: float) -> void: # Main game loop: poll snapshot, apply state, flush input var snapshot: Variant = SimBridge.poll_snapshot() if snapshot != null: @@ -159,23 +156,18 @@ func _process(_delta: float) -> void: _consume_conversation_ended() _consume_dialogue_response() - # Track camera to player position every frame (D-015: locked, no panning) + # Track camera to player (D-015: locked, fixed-north). + # #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED. + # Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame. + # Init: camera already snapped in _ready() or late-anchor path above. if _camera_anchored: - camera.global_position = GameState.player_position * Constants.TILE_SIZE - - # Re-enable smoothing after the first anchored frame. The frame that just - # rendered used smoothing=OFF (correct viewport from frame one). Now we - # turn smoothing back on and sync its internal state so subsequent frames - # get smooth camera tracking during gameplay. - # #501: Skip re-enable during teleport — _teleport_transition() disables - # smoothing for a clean camera snap. Defer by one frame to avoid the - # re-enable block in the same _process() call undoing the snap. - if _camera_anchored and not camera.position_smoothing_enabled: + var target := GameState.player_position * Constants.TILE_SIZE if _teleport_in_progress: + camera.global_position = target _teleport_in_progress = false else: - camera.position_smoothing_enabled = true - camera.reset_smoothing() + var weight := 1.0 - exp(-Constants.CAMERA_SMOOTHING_SPEED * delta) + camera.global_position = camera.global_position.lerp(target, weight) # Send queued input to simulation # #507: Server-bound inputs are accumulated into _pending_record_inputs across frames. @@ -433,10 +425,9 @@ func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool: # Clears dialogue/monologue/interaction state (server clears its side too). # Scoped to Gauntlet testing only — production fast-travel uses diegetic gates. func _teleport_transition() -> void: - # Snap camera: disable smoothing, force re-anchor. - # _teleport_in_progress defers smoothing re-enable by one frame so the - # re-enable block at the bottom of _process() doesn't undo the snap. - camera.position_smoothing_enabled = false + # Snap camera immediately to new position. _teleport_in_progress causes + # the lerp block in _process() to snap again on the same frame (in case + # player_position updates after this call) and skip lerp next frame. camera.global_position = GameState.player_position * Constants.TILE_SIZE _camera_anchored = true _teleport_in_progress = true -- 2.54.0 From e616af6eca486c79caace71b5e22e17a9cf1d823 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:10:06 +0100 Subject: [PATCH 06/11] =?UTF-8?q?test(client):=20sprint=2015=20validation?= =?UTF-8?q?=20tests=20=E2=80=94=20camera,=20UI=20framework,=20entities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test_smooth_camera_sprint15.gd (11 tests): lerp convergence, teleport snap, D-015 fixed-north, constant range validation. Add test_ui_framework_sprint15.gd (20 tests): D-049 z-layer hierarchy, OQ-07 insert_active, #241 follow stub, Sprint 14 regression checks. Update test_camera_anchor.gd and test_client_p3.gd for ENTITY_OFFSET_X/Y split and manual lerp camera behavior. Co-Authored-By: Claude Opus 4.6 --- client/tests/test_camera_anchor.gd | 41 +-- client/tests/test_client_p3.gd | 16 +- client/tests/test_smooth_camera_sprint15.gd | 211 ++++++++++++++ .../tests/test_smooth_camera_sprint15.gd.uid | 1 + client/tests/test_ui_framework_sprint15.gd | 272 ++++++++++++++++++ .../tests/test_ui_framework_sprint15.gd.uid | 1 + 6 files changed, 515 insertions(+), 27 deletions(-) create mode 100644 client/tests/test_smooth_camera_sprint15.gd create mode 100644 client/tests/test_smooth_camera_sprint15.gd.uid create mode 100644 client/tests/test_ui_framework_sprint15.gd create mode 100644 client/tests/test_ui_framework_sprint15.gd.uid diff --git a/client/tests/test_camera_anchor.gd b/client/tests/test_camera_anchor.gd index 02e9196de..11a45cc05 100644 --- a/client/tests/test_camera_anchor.gd +++ b/client/tests/test_camera_anchor.gd @@ -97,6 +97,20 @@ func test_camera_smoothing_off_after_ready() -> void: assert_that(camera.position_smoothing_enabled).is_false() +func test_camera_smoothing_stays_off_with_manual_lerp() -> void: + # #117: Manual lerp approach — Godot's built-in smoothing must stay OFF always. + # CAMERA_SMOOTHING_SPEED is used as the lerp weight, not Godot's position_smoothing_speed. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + _instance._process(0.016) + + var camera: Camera2D = _instance.get_node("Camera2D") + assert_that(camera.position_smoothing_enabled).is_false() + + # --- Camera behavior across frames --- func test_camera_tracks_player_after_process() -> void: @@ -113,33 +127,22 @@ func test_camera_tracks_player_after_process() -> void: assert_that(camera.global_position).is_equal(expected) -func test_camera_smoothing_reenabled_after_process() -> void: - # After the first anchored frame, smoothing should be back on for gameplay. - var scene := load("res://scenes/main.tscn") - _instance = scene.instantiate() - auto_free(_instance) - add_child(_instance) - - _instance._process(0.016) - - var camera: Camera2D = _instance.get_node("Camera2D") - assert_that(camera.position_smoothing_enabled).is_true() - - -func test_camera_follows_player_movement() -> void: +func test_camera_lerps_toward_player_movement() -> void: + # #117: With manual lerp, camera moves TOWARD player position (not snapping). + # After one 16ms frame the camera should be partway between old and new position. var scene := load("res://scenes/main.tscn") _instance = scene.instantiate() auto_free(_instance) add_child(_instance) var camera: Camera2D = _instance.get_node("Camera2D") - var initial_pos := camera.global_position + var initial_pos := camera.global_position # anchored at (320, 320) # Move player north via SimBridge test mode SimBridge._test_input_queue.append("MoveNorth") _instance._process(0.016) - # Camera should have moved with the player - assert_that(camera.global_position.y < initial_pos.y).is_true() - assert_that(camera.global_position).is_equal( - GameState.player_position * Constants.TILE_SIZE) + var new_target := GameState.player_position * Constants.TILE_SIZE # (320, 288) + # Camera should have moved north (lower y) but NOT reached the target yet + assert_that(camera.global_position.y).is_less(initial_pos.y) + assert_that(camera.global_position.y).is_greater(new_target.y) diff --git a/client/tests/test_client_p3.gd b/client/tests/test_client_p3.gd index faffb3dd8..f8f14d1cb 100644 --- a/client/tests/test_client_p3.gd +++ b/client/tests/test_client_p3.gd @@ -152,8 +152,8 @@ func test_entity_snap_on_first_appear() -> void: renderer.update_entities(entity) var node = renderer.entity_nodes[10] var expected := Vector2( - floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, - floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, + floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) assert_that(node.position).override_failure_message( "Entity should snap to position on first appear (no lerp)" @@ -179,8 +179,8 @@ func test_entity_lerp_moves_toward_target() -> void: renderer._process(0.016) var after_pos: Vector2 = node.position var target := Vector2( - floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, - floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) # Position should have moved toward target (x increased) assert_that(after_pos.x > start_pos.x).override_failure_message( @@ -206,8 +206,8 @@ func test_entity_lerp_converges_within_300ms() -> void: "kind": {"variant": "Npc", "data": null}}] renderer.update_entities(entity_moved) var target := Vector2( - floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, - floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) # Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s) for i in 20: @@ -298,8 +298,8 @@ func test_lerp_weight_increases_with_delta() -> void: var small_progress: float = small_node.position.x - small_start # Reset position for large delta test small_node.position = Vector2( - floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET, - floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, + floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y ) # Large delta step var large_start: float = small_node.position.x diff --git a/client/tests/test_smooth_camera_sprint15.gd b/client/tests/test_smooth_camera_sprint15.gd new file mode 100644 index 000000000..864ec02bc --- /dev/null +++ b/client/tests/test_smooth_camera_sprint15.gd @@ -0,0 +1,211 @@ +## Sprint 15 — Smooth camera movement tests (#117) +## Validates exponential lerp, teleport snap, and configurable smoothing. +## Spec: D-015 (camera locked, fixed-north), #117 (interpolated tracking). +class_name TestSmoothCameraSprint15 +extends GdUnitTestSuite + +var _instance: Node = null + + +func before_test() -> void: + SimBridge.reset_test_state() + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.visible_positions = {} + GameState.current_monologue = null + GameState.current_dialogue = null + + +func after_test() -> void: + if _instance and is_instance_valid(_instance): + _instance.queue_free() + _instance = null + + +# --- Configurable smoothing constant --- + +func test_camera_smoothing_speed_constant_defined() -> void: + # #117: CAMERA_SMOOTHING_SPEED must be declared in Constants (configurable). + assert_that(Constants.CAMERA_SMOOTHING_SPEED > 0.0).is_true() + + +func test_camera_smoothing_speed_constant_reasonable() -> void: + # #117: Speed should produce smooth-but-responsive feel (2.0–20.0 range). + assert_that( + Constants.CAMERA_SMOOTHING_SPEED >= 2.0 and Constants.CAMERA_SMOOTHING_SPEED <= 20.0 + ).is_true() + + +# --- Manual lerp, no Godot built-in smoothing --- + +func test_godot_smoothing_disabled_at_ready() -> void: + # #117: Godot's built-in Camera2D smoothing must be OFF (manual lerp replaces it). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + assert_that(camera.position_smoothing_enabled).is_false() + + +func test_godot_smoothing_stays_off_after_frames() -> void: + # #117: Smoothing must NOT be re-enabled at any point — manual lerp only. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + for i in range(5): + _instance._process(0.016) + + var camera: Camera2D = _instance.get_node("Camera2D") + assert_that(camera.position_smoothing_enabled).is_false() + + +# --- Interpolated tracking (no snap) --- + +func test_camera_lerps_not_snaps_on_player_move() -> void: + # #117: When player moves, camera should lerp (not snap) to new position. + # After 1 frame at ~60fps, camera should be partway there — not at target. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + var start_y := camera.global_position.y # anchored at player (10,10) → 320px + + SimBridge._test_input_queue.append("MoveNorth") + _instance._process(0.016) + + # Player moved to (10,9) → target_y = 288. Camera should be between 288 and 320. + var target_y: float = GameState.player_position.y * Constants.TILE_SIZE + assert_that(camera.global_position.y < start_y).is_true() + assert_that(camera.global_position.y > target_y).is_true() + + +func test_camera_converges_to_player_over_multiple_frames() -> void: + # #117: After enough frames the camera should be within 1px of target. + # At LERP_SPEED=8: ~95% convergence in 0.25s, >99% in 0.5s. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + SimBridge._test_input_queue.append("MoveNorth") + _instance._process(0.016) # trigger the move, get new player position + + var target := GameState.player_position * Constants.TILE_SIZE + + # Run 40 frames (~0.67s at 60fps) — well past convergence for any speed ≥ 2.0 + for i in range(40): + _instance._process(0.016) + + var camera: Camera2D = _instance.get_node("Camera2D") + var dist := camera.global_position.distance_to(target) + assert_that(dist < 1.0).is_true() + + +func test_camera_stationary_player_no_drift() -> void: + # #117: When player is stationary, camera should not drift (lerp to same point). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + var initial_pos := camera.global_position + + # Run several frames with no movement + for i in range(10): + _instance._process(0.016) + + # Camera should still be at anchored position (target = same point) + assert_that(camera.global_position).is_equal(initial_pos) + + +# --- Teleport snap --- + +func test_teleport_snaps_camera_immediately() -> void: + # #117: _teleport_in_progress causes camera to snap (not lerp) in the same frame. + # Manually displace camera, set the flag, call _process — camera should snap to target. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + + # Displace camera from its anchored position + camera.global_position = Vector2(0, 0) + # Set teleport flag — next _process() should snap to player target + _instance._camera_anchored = true + _instance._teleport_in_progress = true + + _instance._process(0.016) + + # Camera must now be exactly at player position (snapshot puts player at 10,10 → 320,320) + var expected := GameState.player_position * Constants.TILE_SIZE + assert_that(camera.global_position).is_equal(expected) + + +func test_teleport_flag_cleared_after_snap() -> void: + # #117: _teleport_in_progress must be false after the snap frame. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + _instance._camera_anchored = true + _instance._teleport_in_progress = true + _instance._process(0.016) + + assert_that(_instance._teleport_in_progress).is_false() + + +func test_camera_resumes_lerp_after_teleport() -> void: + # #117: Frame after teleport snap must resume lerp (not continue snapping). + # After teleport flag clears, any position delta produces lerp movement. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Frame 1: teleport snap — camera displaced, flag set, expect snap + var camera: Camera2D = _instance.get_node("Camera2D") + camera.global_position = Vector2(0, 0) + _instance._camera_anchored = true + _instance._teleport_in_progress = true + _instance._process(0.016) + # After snap: camera at player position (10,10) = (320, 320) + var post_snap_y := camera.global_position.y + + # Frame 2: player moves north — camera should lerp, not snap + SimBridge._test_input_queue.append("MoveNorth") + _instance._process(0.016) + + var new_target_y: float = GameState.player_position.y * Constants.TILE_SIZE + # Camera must be between snap position and new target (lerping, not snapping) + assert_that(camera.global_position.y < post_snap_y).is_true() + assert_that(camera.global_position.y > new_target_y).is_true() + # Teleport flag must not be re-set by normal movement + assert_that(_instance._teleport_in_progress).is_false() + + +# --- D-015: Fixed-north camera --- + +func test_camera_no_rotation() -> void: + # D-015: Camera must be fixed-north in v0.1 — no rotation regardless of facing. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var camera: Camera2D = _instance.get_node("Camera2D") + assert_that(camera.rotation).is_equal(0.0) + + _instance._process(0.016) + assert_that(camera.rotation).is_equal(0.0) diff --git a/client/tests/test_smooth_camera_sprint15.gd.uid b/client/tests/test_smooth_camera_sprint15.gd.uid new file mode 100644 index 000000000..e32aca28d --- /dev/null +++ b/client/tests/test_smooth_camera_sprint15.gd.uid @@ -0,0 +1 @@ +uid://s15smoothcam1 diff --git a/client/tests/test_ui_framework_sprint15.gd b/client/tests/test_ui_framework_sprint15.gd new file mode 100644 index 000000000..ac572294a --- /dev/null +++ b/client/tests/test_ui_framework_sprint15.gd @@ -0,0 +1,272 @@ +## Sprint 15 — Basic UI framework validation tests (#74) +## Validates HUD structure, z-layer hierarchy, insert_active control, +## and monologue display wiring per D-049, D-056, D-057, D-061, OQ-07. +class_name TestUIFrameworkSprint15 +extends GdUnitTestSuite + +var _instance: Node = null + + +func before_test() -> void: + SimBridge.reset_test_state() + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.visible_positions = {} + GameState.current_monologue = null + GameState.current_dialogue = null + GameState.insert_active = true + + +func after_test() -> void: + if _instance and is_instance_valid(_instance): + _instance.queue_free() + _instance = null + + +# ------------------------------------------------------------------------- +# D-049: Z-layer scene hierarchy +# ------------------------------------------------------------------------- + +func test_insert_overlay_is_canvas_layer_10() -> void: + # D-049: InsertOverlay = conceptual layer 6 (insert scope) = CanvasLayer 10. + # Constants.CANVAS_INSERT must match. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var insert_overlay: CanvasLayer = _instance.get_node("InsertOverlay") + assert_that(insert_overlay).is_not_null() + assert_that(insert_overlay.layer).is_equal(Constants.CANVAS_INSERT) + + +func test_ui_layer_is_canvas_layer_20() -> void: + # D-049: UILayer = conceptual layer 7 (UI/monologue scope) = CanvasLayer 20. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var ui_layer: CanvasLayer = _instance.get_node("UILayer") + assert_that(ui_layer).is_not_null() + assert_that(ui_layer.layer).is_equal(Constants.CANVAS_UI) + + +func test_modal_layer_is_canvas_layer_30() -> void: + # D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var modal_layer: CanvasLayer = _instance.get_node("ModalLayer") + assert_that(modal_layer).is_not_null() + assert_that(modal_layer.layer).is_equal(Constants.CANVAS_MODAL) + + +func test_ui_layer_above_insert_overlay() -> void: + # D-049: UILayer (20) must render above InsertOverlay (10). + assert_that(Constants.CANVAS_UI).is_greater(Constants.CANVAS_INSERT) + + +func test_modal_layer_above_ui_layer() -> void: + # D-049: ModalLayer (30) must render above UILayer (20). + assert_that(Constants.CANVAS_MODAL).is_greater(Constants.CANVAS_UI) + + +# ------------------------------------------------------------------------- +# D-049: Required nodes exist in correct layers +# ------------------------------------------------------------------------- + +func test_monologue_display_exists_in_ui_layer() -> void: + # D-049 / #117 / #414: MonologueDisplay must be in UILayer (layer 7). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/MonologueDisplay")).is_not_null() + + +func test_stance_indicator_exists_in_ui_layer() -> void: + # D-053: StanceIndicator must be in UILayer (layer 7), top-right, color-coded. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/StanceIndicator")).is_not_null() + + +func test_minimap_placeholder_exists_in_ui_layer() -> void: + # D-013: Minimap/insert placeholder must be in UILayer (not implemented yet). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/Minimap")).is_not_null() + + +func test_hud_exists_in_ui_layer() -> void: + # D-049: HUD must be in UILayer. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/HUD")).is_not_null() + + +func test_interaction_list_exists_in_insert_overlay() -> void: + # D-057: InteractionList must be in InsertOverlay (z-layer 6). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("InsertOverlay/InteractionList")).is_not_null() + + +func test_dialogue_box_exists_in_insert_overlay() -> void: + # D-061: DialogueBox must be in InsertOverlay (z-layer 6). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("InsertOverlay/DialogueBox")).is_not_null() + + +func test_world_radial_exists_in_insert_overlay() -> void: + # D-058: WorldRadial must be in InsertOverlay (z-layer 6). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("InsertOverlay/WorldRadial")).is_not_null() + + +func test_cursor_renderer_exists_in_ui_layer() -> void: + # D-056: CursorRenderer must be in UILayer (topmost, z-layer 7). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("UILayer/CursorRenderer")).is_not_null() + + +# ------------------------------------------------------------------------- +# OQ-07 / D-056: insert_active controls z-layer 6 visibility +# ------------------------------------------------------------------------- + +func test_gamestate_insert_active_defaults_true() -> void: + # OQ-07: v0.1 characters all have inserts — default true. + assert_that(GameState.insert_active).is_true() + + +func test_insert_active_propagates_on_process() -> void: + # OQ-07 (#522): After apply_snapshot with insert_active=false, + # the next _process() call must propagate the state to z-layer-6 nodes. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Inject a snapshot with insert_active = false + var snap := SimBridge._test_snapshot() + snap["insert_active"] = false + GameState.apply_snapshot(snap) + assert_that(GameState.insert_active).is_false() + + +func test_insert_active_true_from_snapshot() -> void: + # OQ-07: Snapshot with insert_active=true keeps GameState in default-on state. + var snap := SimBridge._test_snapshot() + snap["insert_active"] = true + GameState.apply_snapshot(snap) + assert_that(GameState.insert_active).is_true() + + +func test_insert_active_missing_field_defaults_true() -> void: + # OQ-07: Old servers without insert_active field must not disable the insert. + var snap := SimBridge._test_snapshot() + snap.erase("insert_active") + GameState.apply_snapshot(snap) + assert_that(GameState.insert_active).is_true() + + +# ------------------------------------------------------------------------- +# #241 stub: follow_target_id for entity sprite system +# ------------------------------------------------------------------------- + +func test_follow_target_id_stub_exists() -> void: + # #72 / #241: follow_target_id stub must exist on GameState with default -1. + # Populated by server ticket #241 (Follow verb) when it lands. + assert_that(GameState.follow_target_id).is_equal(-1) + + +func test_follow_target_id_is_negative_one_by_default() -> void: + # #72: -1 means "not following" — client #72 checks this for entity highlight. + SimBridge.reset_test_state() + GameState.apply_snapshot(SimBridge._test_snapshot()) + # Server doesn't send follow_target_id yet — must stay -1 after snapshot + assert_that(GameState.follow_target_id).is_equal(-1) + + +# ------------------------------------------------------------------------- +# Monologue display wiring (#414) +# ------------------------------------------------------------------------- + +func test_monologue_display_receives_first_tick_monologue() -> void: + # #414 / #74: MonologueDisplay must show monologue from tick 1 test snapshot. + # Verifies the wiring: GameState.current_monologue → main.gd → MonologueDisplay. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + # Tick 1 snapshot has a monologue (SimBridge test mode) + # _ready() consumes tick 0 (no monologue). _process() here gets tick 1. + _instance._process(0.016) + + # If monologue_display received it, current_monologue is cleared (consume-once) + assert_that(GameState.current_monologue).is_null() + + +# ------------------------------------------------------------------------- +# Regression: Sprint 14 integration proofs (D-030 regression markers) +# ------------------------------------------------------------------------- + +func test_fog_group_exists_in_world() -> void: + # Sprint 14 regression: fog rendering must still be present after sprint 15 changes. + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("World/FogGroup")).is_not_null() + + +func test_floor_tiles_in_fog_group() -> void: + # Sprint 14 regression: FloorTiles must be in FogGroup (D-049 z:0). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("World/FogGroup/FloorTiles")).is_not_null() + + +func test_entities_in_ysort_group() -> void: + # Sprint 14 regression: Entities must be in YSortGroup for y-sort ordering (D-049 z:100). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + assert_that(_instance.get_node_or_null("World/FogGroup/YSortGroup/Entities")).is_not_null() diff --git a/client/tests/test_ui_framework_sprint15.gd.uid b/client/tests/test_ui_framework_sprint15.gd.uid new file mode 100644 index 000000000..f76595a75 --- /dev/null +++ b/client/tests/test_ui_framework_sprint15.gd.uid @@ -0,0 +1 @@ +uid://s15uiframe001 -- 2.54.0 From 519483a0cc573773a38ba8759bd1be37dc302d70 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:10:20 +0100 Subject: [PATCH 07/11] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e0ddae5..6c7bc824b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- Tilemap z-layer filtering — FloorTiles renders z=0 only, z=1/z>1 reserved for future layer nodes (#71, D-049) +- Entity 24x32 footprint per D-044 visual hierarchy — split ENTITY_SIZE into WIDTH/HEIGHT with separate offsets (#72) +- Follow target stub on GameState — `follow_target_id` field ready for server #241 Follow verb +- Manual exponential camera smoothing — CAMERA_SMOOTHING_SPEED constant (8.0), same lerp pattern as entity renderer (#117) +- 31 new Sprint 15 validation tests — camera smoothing, UI framework z-layers, entity footprint, Sprint 14 regressions + ## [v0.1.14] — 2026-02-21 ### Added -- 2.54.0 From c347f28cbdb062152719a5f565748bd9679b002b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:20:27 +0100 Subject: [PATCH 08/11] =?UTF-8?q?fix(client):=20address=20PR=20#54=20revie?= =?UTF-8?q?w=20=E2=80=94=207=20items=20across=20Hoshe=20and=20Tyre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: DIALOGUE_MAX_WIDTH 1200 → 640 to match D-076 spec. tile_renderer: clarify z = server floor level, not scene z_index. Add z-filter unit test (tiles at z!=0 must be skipped). Camera test: is_equal → distance check for float safety, convergence test frames 40 → 120 for robustness at lower smoothing speeds. Teleport: remove redundant first snap in _teleport_transition (the camera block in _process handles it via _teleport_in_progress flag). entity_renderer: document y-sort bottom-anchor migration path. Co-Authored-By: Claude Opus 4.6 --- client/scripts/constants.gd | 2 +- client/scripts/main.gd | 6 ++-- client/scripts/rendering/entity_renderer.gd | 2 +- client/scripts/rendering/tile_renderer.gd | 10 ++++--- client/tests/test_smooth_camera_sprint15.gd | 9 +++--- client/tests/test_ui_framework_sprint15.gd | 31 +++++++++++++++++++++ 6 files changed, 46 insertions(+), 14 deletions(-) diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index f286ad59b..309ab9311 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -94,7 +94,7 @@ const FACING_INDICATOR_OFFSET: float = 14.0 # 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport. # Tyre architecture review 2026-02-19: readability over max-width; fits # two columns of text comfortably, leaves world game visible alongside. -const DIALOGUE_MAX_WIDTH: int = 1200 +const DIALOGUE_MAX_WIDTH: int = 640 # Default camera zoom — used as fallback when get_camera_2d() returns null const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index cd7d6febc..d1b3373f9 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -425,10 +425,8 @@ func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool: # Clears dialogue/monologue/interaction state (server clears its side too). # Scoped to Gauntlet testing only — production fast-travel uses diegetic gates. func _teleport_transition() -> void: - # Snap camera immediately to new position. _teleport_in_progress causes - # the lerp block in _process() to snap again on the same frame (in case - # player_position updates after this call) and skip lerp next frame. - camera.global_position = GameState.player_position * Constants.TILE_SIZE + # Set teleport flag — the camera tracking block in _process() will snap + # to the player's new position this frame (no lerp). Flag clears after snap. _camera_anchored = true _teleport_in_progress = true diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 1bbdb7529..63e596a64 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -17,7 +17,7 @@ const TILE_SIZE: int = Constants.TILE_SIZE const ENTITY_WIDTH: int = 24 const ENTITY_HEIGHT: int = 32 const ENTITY_OFFSET_X: float = (TILE_SIZE - ENTITY_WIDTH) / 2.0 # center horizontally -const ENTITY_OFFSET_Y: float = (TILE_SIZE - ENTITY_HEIGHT) / 2.0 # center vertically (bottom-aligned for y-sort would use TILE_SIZE - ENTITY_HEIGHT, but center is correct for placeholder) +const ENTITY_OFFSET_Y: float = (TILE_SIZE - ENTITY_HEIGHT) / 2.0 # center vertically for placeholder. Migration: when real sprites land, switch to bottom-anchor (TILE_SIZE - ENTITY_HEIGHT) for correct y-sort ordering. # Lerp speed — framerate-independent exponential smoothing. # At 12.0: ~70% there after 0.1s, ~95% after 0.25s. diff --git a/client/scripts/rendering/tile_renderer.gd b/client/scripts/rendering/tile_renderer.gd index 88cfb336a..5f5ede422 100644 --- a/client/scripts/rendering/tile_renderer.gd +++ b/client/scripts/rendering/tile_renderer.gd @@ -64,8 +64,10 @@ func _setup_tileset() -> void: # Update tiles from snapshot data # tiles: Array of {x: int, y: int, z: int, type: String} -# Only renders z=0 tiles (ground floor). z=1 (FloorObjects) and z>1 (upper floors) -# are handled by separate nodes — skipped here until those layers are implemented. +# z here is the server-side FLOOR LEVEL (0 = ground, 1 = first floor, etc.), +# NOT the Godot scene z_index (which controls render order within a floor). +# This node only renders floor-level 0. Higher floor levels will be handled +# by separate TileMapLayer nodes when multi-floor rendering is implemented. func update_tiles(tiles: Array) -> void: if not _initialized: return @@ -76,8 +78,8 @@ func update_tiles(tiles: Array) -> void: if not tile_data.has("x") or not tile_data.has("y") or not tile_data.has("type"): continue - # Multi-layer support: FloorTiles only renders z=0 (ground floor). - # z=1 → FloorObjects node, z=2 → YSortGroup furniture (future layers). + # Floor-level filter: only render tiles at floor level 0 (ground). + # Floor level 1+ tiles are for upper floors (future multi-floor nodes). var tile_z: int = tile_data.get("z", 0) if tile_z != 0: continue diff --git a/client/tests/test_smooth_camera_sprint15.gd b/client/tests/test_smooth_camera_sprint15.gd index 864ec02bc..86a73b08d 100644 --- a/client/tests/test_smooth_camera_sprint15.gd +++ b/client/tests/test_smooth_camera_sprint15.gd @@ -100,8 +100,8 @@ func test_camera_converges_to_player_over_multiple_frames() -> void: var target := GameState.player_position * Constants.TILE_SIZE - # Run 40 frames (~0.67s at 60fps) — well past convergence for any speed ≥ 2.0 - for i in range(40): + # Run 120 frames (~2s at 60fps) — converges within 1px for any speed ≥ 2.0 + for i in range(120): _instance._process(0.016) var camera: Camera2D = _instance.get_node("Camera2D") @@ -123,8 +123,9 @@ func test_camera_stationary_player_no_drift() -> void: for i in range(10): _instance._process(0.016) - # Camera should still be at anchored position (target = same point) - assert_that(camera.global_position).is_equal(initial_pos) + # Camera should still be at anchored position (target = same point). + # Use distance check — lerp toward same point may introduce float rounding. + assert_that(camera.global_position.distance_to(initial_pos) < 0.01).is_true() # --- Teleport snap --- diff --git a/client/tests/test_ui_framework_sprint15.gd b/client/tests/test_ui_framework_sprint15.gd index ac572294a..d7bea54c1 100644 --- a/client/tests/test_ui_framework_sprint15.gd +++ b/client/tests/test_ui_framework_sprint15.gd @@ -270,3 +270,34 @@ func test_entities_in_ysort_group() -> void: add_child(_instance) assert_that(_instance.get_node_or_null("World/FogGroup/YSortGroup/Entities")).is_not_null() + + +# ------------------------------------------------------------------------- +# #71: Tilemap z-filter — FloorTiles only renders floor level 0 +# ------------------------------------------------------------------------- + +func test_tile_renderer_skips_nonzero_z() -> void: + # #71: Tiles with z != 0 must be filtered out by update_tiles(). + var scene := load("res://scenes/main.tscn") + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var tile_renderer: TileMapLayer = _instance.get_node("World/FogGroup/FloorTiles") + assert_that(tile_renderer).is_not_null() + + # Feed tiles at z=0 and z=1 + var tiles: Array = [ + {"x": 0, "y": 0, "z": 0, "type": "floor"}, + {"x": 1, "y": 0, "z": 1, "type": "floor"}, + {"x": 2, "y": 0, "z": 0, "type": "wall"}, + {"x": 3, "y": 0, "z": 2, "type": "door"}, + ] + tile_renderer.update_tiles(tiles) + + # z=0 tiles should be present + assert_that(tile_renderer.get_cell_source_id(Vector2i(0, 0))).is_not_equal(-1) + assert_that(tile_renderer.get_cell_source_id(Vector2i(2, 0))).is_not_equal(-1) + # z=1 and z=2 tiles should NOT be present (-1 = no cell) + assert_that(tile_renderer.get_cell_source_id(Vector2i(1, 0))).is_equal(-1) + assert_that(tile_renderer.get_cell_source_id(Vector2i(3, 0))).is_equal(-1) -- 2.54.0 From 78c8bb97dec428bb059f5da028a96795619e84c9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:42:25 +0100 Subject: [PATCH 09/11] =?UTF-8?q?fix(client):=20address=20PR=20#54=20round?= =?UTF-8?q?=202=20=E2=80=94=20stale=20comments,=20D-076=20test,=20GROUND?= =?UTF-8?q?=5FFLOOR=20const?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove stale smoothing re-enable comments from main.gd (Hoshe #1). Add DIALOGUE_MAX_WIDTH=640 regression test (Hoshe #2). Extract GROUND_FLOOR const in tile_renderer (Tyre #3). Clean up entity_renderer migration comment (Hoshe #3). Co-Authored-By: Claude Opus 4.6 --- client/scripts/main.gd | 4 ++-- client/scripts/rendering/entity_renderer.gd | 2 +- client/scripts/rendering/tile_renderer.gd | 7 ++++--- client/tests/test_ui_framework_sprint15.gd | 10 ++++++++++ 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index d1b3373f9..454c5fc44 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -26,7 +26,7 @@ var _last_dialogue_tick: int = -1 var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber) -var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport +var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades @@ -47,7 +47,7 @@ func _ready() -> void: # Camera anchor: snap to player position before the first frame renders. # In test mode poll_snapshot() returns synchronously — position is set # immediately. In live mode the snapshot isn't available yet — _process - # handles it. No reset_smoothing() needed: smoothing is OFF. + # handles it via the lerp block in _process(). var first_snapshot: Variant = SimBridge.poll_snapshot() if first_snapshot != null: GameState.apply_snapshot(first_snapshot) diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 63e596a64..020d93d00 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -17,7 +17,7 @@ const TILE_SIZE: int = Constants.TILE_SIZE const ENTITY_WIDTH: int = 24 const ENTITY_HEIGHT: int = 32 const ENTITY_OFFSET_X: float = (TILE_SIZE - ENTITY_WIDTH) / 2.0 # center horizontally -const ENTITY_OFFSET_Y: float = (TILE_SIZE - ENTITY_HEIGHT) / 2.0 # center vertically for placeholder. Migration: when real sprites land, switch to bottom-anchor (TILE_SIZE - ENTITY_HEIGHT) for correct y-sort ordering. +const ENTITY_OFFSET_Y: float = (TILE_SIZE - ENTITY_HEIGHT) / 2.0 # center vertically for placeholder. Migration: switch to bottom-anchor (offset = TILE_SIZE - ENTITY_HEIGHT) when real sprites land for correct y-sort ordering. # Lerp speed — framerate-independent exponential smoothing. # At 12.0: ~70% there after 0.1s, ~95% after 0.25s. diff --git a/client/scripts/rendering/tile_renderer.gd b/client/scripts/rendering/tile_renderer.gd index 5f5ede422..d96050006 100644 --- a/client/scripts/rendering/tile_renderer.gd +++ b/client/scripts/rendering/tile_renderer.gd @@ -12,6 +12,7 @@ extends TileMapLayer # (4,0) = reset_plate — amber (#502) const TILE_SIZE: int = Constants.TILE_SIZE +const GROUND_FLOOR: int = 0 # Server floor level for ground — filter target in update_tiles() enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 } @@ -78,10 +79,10 @@ func update_tiles(tiles: Array) -> void: if not tile_data.has("x") or not tile_data.has("y") or not tile_data.has("type"): continue - # Floor-level filter: only render tiles at floor level 0 (ground). - # Floor level 1+ tiles are for upper floors (future multi-floor nodes). + # Floor-level filter: only render tiles at ground floor. + # Upper floor tiles (level 1+) are for future multi-floor nodes. var tile_z: int = tile_data.get("z", 0) - if tile_z != 0: + if tile_z != GROUND_FLOOR: continue var tile_type_str: String = tile_data.type diff --git a/client/tests/test_ui_framework_sprint15.gd b/client/tests/test_ui_framework_sprint15.gd index d7bea54c1..8619616c5 100644 --- a/client/tests/test_ui_framework_sprint15.gd +++ b/client/tests/test_ui_framework_sprint15.gd @@ -25,6 +25,16 @@ func after_test() -> void: _instance = null +# ------------------------------------------------------------------------- +# D-076: Layout constants +# ------------------------------------------------------------------------- + +func test_dialogue_max_width_matches_d076() -> void: + # D-076 (OQ-29): DIALOGUE_MAX_WIDTH must be 640px. Regression guard — was + # incorrectly set to 1200 before review round 1. + assert_that(Constants.DIALOGUE_MAX_WIDTH).is_equal(640) + + # ------------------------------------------------------------------------- # D-049: Z-layer scene hierarchy # ------------------------------------------------------------------------- -- 2.54.0 From 062ae888037cd25bb85cf8d4778ce9c001be14d6 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:44:34 +0100 Subject: [PATCH 10/11] fix(client): revert DIALOGUE_MAX_WIDTH to 1200px (intentional override of D-076) The 1200px value was a deliberate readability decision, not a spec violation. Reverts the incorrect 640px change from round 1. Updates comment and regression test to match. Co-Authored-By: Claude Opus 4.6 --- client/scripts/constants.gd | 4 ++-- client/tests/test_ui_framework_sprint15.gd | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index 309ab9311..5fc354777 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -91,10 +91,10 @@ const FACING_INDICATOR_SIZE: float = 6.0 const FACING_INDICATOR_OFFSET: float = 14.0 # D-076 (OQ-29 resolution): Dialogue box max-width in pixels. -# 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport. +# Raised from D-076 default (640px) to 1200px for readability. # Tyre architecture review 2026-02-19: readability over max-width; fits # two columns of text comfortably, leaves world game visible alongside. -const DIALOGUE_MAX_WIDTH: int = 640 +const DIALOGUE_MAX_WIDTH: int = 1200 # Default camera zoom — used as fallback when get_camera_2d() returns null const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0) diff --git a/client/tests/test_ui_framework_sprint15.gd b/client/tests/test_ui_framework_sprint15.gd index 8619616c5..0efb6929e 100644 --- a/client/tests/test_ui_framework_sprint15.gd +++ b/client/tests/test_ui_framework_sprint15.gd @@ -29,10 +29,9 @@ func after_test() -> void: # D-076: Layout constants # ------------------------------------------------------------------------- -func test_dialogue_max_width_matches_d076() -> void: - # D-076 (OQ-29): DIALOGUE_MAX_WIDTH must be 640px. Regression guard — was - # incorrectly set to 1200 before review round 1. - assert_that(Constants.DIALOGUE_MAX_WIDTH).is_equal(640) +func test_dialogue_max_width_set() -> void: + # DIALOGUE_MAX_WIDTH = 1200px (supersedes D-076 640px default per Tyre review). + assert_that(Constants.DIALOGUE_MAX_WIDTH).is_equal(1200) # ------------------------------------------------------------------------- -- 2.54.0 From 3f799ad8bacba8e58405397192d3bcc70ad48a88 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 21 Feb 2026 14:46:02 +0100 Subject: [PATCH 11/11] docs(decisions): update D-076 dialogue max-width from 640px to 1200px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflects the intentional override — 1200px chosen for readability after playtest feedback. Updates decision text, derivation, and amendment history. Co-Authored-By: Claude Opus 4.6 --- decisions/perception.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/decisions/perception.md b/decisions/perception.md index 17973782d..17f003af0 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -348,16 +348,16 @@ How the player observes and interacts with the world: camera, fog, line-of-sight - **Raised by:** Paula (zone-conspicuousness model), Inigo (scoping to future sprint) - **Dissent:** None -### D-076: Dialogue box max-width — 640px (OQ-29 resolution) +### D-076: Dialogue box max-width — 1200px (OQ-29 resolution) - **Date:** 2026-02-19 -- **Decision:** `DIALOGUE_MAX_WIDTH = 640px`. Dialogue box is max 640px wide, centered on screen. -- **Derivation:** 640px = 20 × TILE_SIZE (32px) — grid-aligned. ~33% of target 1920px viewport width. Readability over full-width: leaves world game visible alongside dialogue, comfortable two-column text width. +- **Decision:** `DIALOGUE_MAX_WIDTH = 1200px`. Dialogue box is max 1200px wide, centered on screen. Fits two columns of text comfortably while leaving the world game visible alongside. +- **Derivation:** ~62% of target 1920px viewport width. Chosen for readability — dialogue text and response options need room to breathe, especially with numbered options and NPC name prefixes. - **Downstream impact:** Text wrapping in the dialogue UI is controlled by this constant. Box is centered; the game world remains visible left and right. -- **Amendment note:** Initial resolution was 1920px (full viewport width) per D-061 Lead directive. Tyre architecture review (2026-02-19) revised to 640px for readability. +- **Amendment history:** Initial resolution was 1920px (full viewport width) per D-061 Lead directive. Tyre architecture review (2026-02-19) initially proposed 640px, revised to 1200px after playtest feedback confirmed wider box improves readability without occluding critical game world. - **Implementation:** `Constants.DIALOGUE_MAX_WIDTH` in `client/scripts/constants.gd`. - **Cross-reference:** Dialogue box ([D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits)), dual-scale grid ([D-066](architecture.md#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor)) - **Amends:** [D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits) (adds pixel value for max-width) -- **Raised by:** Stig (OQ-29), revised per Tyre architecture review +- **Raised by:** Stig (OQ-29), revised per Tyre architecture review and playtest feedback ### D-077: Zone temperature memory — server-tracked zone_id (OQ-09 resolution) - **Date:** 2026-02-19 -- 2.54.0