diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a491b5b4..8bb3cfae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- Dialogue box UI skeleton (#434, D-061) — bottom screen, max 20% height, ~65% width, NPC speech + max 3 response options, insert-styled colors, WASD walk-away with 300ms fade, no close button, diegetic on InsertOverlay z-layer 6 +- Fog entity visualization (#431, D-059/D-060) — cognitive delay rendering: sonar-style sound pings (3 concentric rings, 1.5s fade), unrecognized grey blobs with 0.8s breathing pulse, D-033 color transition at 50% recognition progress, ±0.5 tile position drift, FogEntities node at z:950 + +### Changed +- Client protocol version bumped from 6 to 7 (pending_recognitions decode for cognitive delay) +- GameState: current_dialogue and pending_recognitions fields wired from ObserverSnapshot v7 +- Scene tree: DialogueBox added to InsertOverlay, FogEntities at z:950 between fog shader and InsertOverlay +- Test mode: mock dialogue (Kael NPC, 3 options) and mock cognitive delay entity (6-tick recognition cycle) + ### Added - Archetype evidence presentation spec (#443, D-065/D-034/D-033) — detective case file vs smuggler notebook design document: item definitions, knowledge graph presentation, contradiction markers, THE FRIEND arc walkthroughs, systems interaction map, authoring guidelines - Cognitive delay system (#423, D-060) — CognitiveDelay component buffers perception events before emitting KnowledgeEvents (0.6s base / 0.3s urgent at 10 tps), pending_recognitions in ObserverSnapshot v7 for client fog entity visualization, cancellation on entity LOS exit diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 04770f248..e2caed8cb 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=15 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=17 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"] @@ -14,6 +14,8 @@ [ext_resource type="PackedScene" path="res://ui/inventory_grid.tscn" id="12_inv"] [ext_resource type="PackedScene" path="res://ui/stance_indicator.tscn" id="13_stance"] [ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"] +[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"] +[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -84,6 +86,13 @@ z_index = 300 z_index = 900 script = ExtResource("4_fog") +; --- z:950 — Fog entities (D-059/D-060 cognitive delay visualization) --- +; Between fog (z:900) and InsertOverlay (CanvasLayer 10). +; Sound pings, grey blobs, recognized entity glow + silhouette. +[node name="FogEntities" type="Node2D" parent="World"] +z_index = 950 +script = ExtResource("16_fogent") + ; --- Camera --- [node name="Camera2D" type="Camera2D" parent="."] position_smoothing_enabled = true @@ -105,6 +114,9 @@ layer = 10 ; D-058: World radial menu — right-click, 2 spokes (Observe + Insert) [node name="WorldRadial" parent="InsertOverlay" instance=ExtResource("14_radial")] +; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI +[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")] + ; --- UI layer (CanvasLayer 20) --- ; HUD, monologue, cursor — always visible, not affected by fog or camera. [node name="UILayer" type="CanvasLayer" parent="."] diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index a453c7030..2856e5570 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -29,6 +29,12 @@ var current_monologue: Variant = null # {id, text, duration_seconds} or null var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch var player_inventory: Array = [] # [{item_id, name, slot}] +# v7 fields (#434, D-061) +var current_dialogue: Variant = null # {npc_name, speech, options: [String]} or null + +# v7 fields (#431, D-059/D-060) +var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}] + func apply_snapshot(snapshot: Dictionary) -> void: current_snapshot = snapshot @@ -96,6 +102,18 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: player_inventory = [] + # v7: current_dialogue (#434, D-061) + if snapshot.has("current_dialogue") and snapshot.current_dialogue is Dictionary: + current_dialogue = snapshot.current_dialogue + else: + current_dialogue = null + + # v7: pending_recognitions (#431, D-059/D-060) + if snapshot.has("pending_recognitions") and snapshot.pending_recognitions is Array: + pending_recognitions = snapshot.pending_recognitions + else: + pending_recognitions = [] + # v2: visible_tiles with visibility sectors # Derives visible_positions when not explicitly provided (real server mode) if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index ac450d448..672084efa 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -9,6 +9,7 @@ var _test_tick: int = 0 var _test_player_pos: Vector2i = Vector2i(10, 10) var _test_facing: String = "North" var _test_input_queue: Array = [] # Queued actions for test mode +var _test_in_dialogue: bool = false # Mock dialogue state (#434) var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot) var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport @@ -38,6 +39,7 @@ func reset_test_state() -> void: _test_player_pos = Vector2i(10, 10) _test_facing = "North" _test_input_queue.clear() + _test_in_dialogue = false # Change connection state and emit signal func _set_state(new_state: ConnectionState) -> void: @@ -263,12 +265,22 @@ func _test_snapshot() -> Dictionary: # Process queued inputs for action_name in _test_input_queue: + if action_name == "Interact": + # Mock dialogue trigger (#434): if near NPC, start dialogue + var npc_pos := Vector2i(12, 9) + var dist := absi(_test_player_pos.x - npc_pos.x) + absi(_test_player_pos.y - npc_pos.y) + if dist <= 2 and _test_has_los(_test_player_pos, npc_pos): + _test_in_dialogue = true + continue var delta := _action_to_delta(action_name) var new_pos := _test_player_pos + delta if _test_is_walkable(new_pos): _test_player_pos = new_pos if delta != Vector2i.ZERO: _test_facing = _delta_to_facing(delta) + # Walk-away dismisses dialogue (D-064) + if _test_in_dialogue: + _test_in_dialogue = false _test_input_queue.clear() var px := _test_player_pos.x @@ -320,6 +332,38 @@ func _test_snapshot() -> Dictionary: "duration_seconds": 5.0, } + # v7: mock dialogue (#434, D-061) — triggered by Interact near NPC + # Sustained: dialogue persists across ticks while _test_in_dialogue is true. + # Movement (walk-away) clears it. Client consume-once guards against re-show. + var dialogue: Variant = null + if _test_in_dialogue: + dialogue = { + "npc_name": "Kael", + "speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?", + "options": [ + "Just arrived. Still getting my bearings.", + "Passing through. Know where I can find work?", + "I'm looking for someone.", + ], + } + + # v7: mock pending_recognitions (#431, D-059/D-060) — cognitive delay fog entity + # Entity at (13, 12) in fog: starts as grey blob, transitions to recognized over 6 ticks. + # Cycles every 12 ticks: 6 ticks recognizing, 6 ticks off (simulates repeat encounters). + var pending_recs: Array = [] + var cycle_pos := _test_tick % 12 + if cycle_pos < 6: + var total_delay := 6 + var remaining := total_delay - cycle_pos + pending_recs.append({ + "entity_id": 100, + "x": 13.5, + "y": 12.5, + "z": 0, + "remaining_ticks": remaining, + "total_delay_ticks": total_delay, + }) + return { "tick": _test_tick, "version": Protocol.PROTOCOL_VERSION, @@ -338,6 +382,8 @@ func _test_snapshot() -> Dictionary: "visible_positions": _test_visible_positions(), "nearby_interactions": nearby, "current_monologue": monologue, + "current_dialogue": dialogue, + "pending_recognitions": pending_recs, } # Generate a small test room: 8x6 room with walls, a door, and floor diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index 82fdaac05..45df4f0b7 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -26,6 +26,7 @@ const Z_HIGH_AIRBORNE: int = 350 # Above-ceiling flying (scale 1.03-1.30, alp const Z_UPPER_CONTENT: int = 400 # Upper-floor entities (rare with fixed camera) # z:500-899 reserved: edge cases const Z_FOG: int = 900 # FogOverlay — OUTSIDE FogGroup, fog shader +const Z_FOG_ENTITIES: int = 950 # FogEntities — cognitive delay blobs/pings (D-059/D-060) # Lower floors: z:-100 per floor (floor-1: z:-100 to z:-1, floor-2: z:-200 to z:-101) # z:-75 to z:-51 reserved: lower floor VFX # @@ -67,6 +68,12 @@ static func color_for_entity_kind(entity_data: Dictionary) -> Color: "Object", "Terrain": return ENTITY_COLOR_OBJECT _: return ENTITY_COLOR_OBJECT +# D-048/D-056: Insert-styled UI color palette +# Used by dialogue box, interaction list, radial menu, and other diegetic insert UI. +const INSERT_COLOR_TEXT: Color = Color("#c8d0e0") # Default insert text — white-blue +const INSERT_COLOR_HOVER: Color = Color("#e8c547") # Hover/highlight — amber POI +const INSERT_COLOR_ACTIVE: Color = Color("#6bc9a6") # Active/pressed — friendly green + # D-015: Peripheral vision dimming const PERIPHERAL_ALPHA: float = 0.5 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 2ef4e2463..21d575e01 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -1,22 +1,34 @@ extends Node2D @onready var world_renderer = $World +@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization @onready var camera = $Camera2D @onready var hud = $UILayer/HUD @onready var monologue_display = $UILayer/MonologueDisplay @onready var interaction_prompt = $InsertOverlay/InteractionPrompt # v0.1 single-line fallback @onready var interaction_list = $InsertOverlay/InteractionList # D-057: z-layer 6 @onready var world_radial = $InsertOverlay/WorldRadial # D-058: z-layer 6 +@onready var dialogue_box = $InsertOverlay/DialogueBox # D-061: z-layer 6 @onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7 @onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7 @onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7 +# Tracks the dialogue_id from dialogue_box to prevent consume-once race during fade-in. +# If a new snapshot arrives with null dialogue while fade-in is still running, +# we don't re-trigger show_dialogue because _last_dialogue_id still matches. +var _last_dialogue_id: int = 0 + func _ready() -> void: print("The Settled Reach — client initialized") # Connect to simulation (will use test mode initially) SimBridge.connect_to_sim() + # D-061: Connect dialogue box signals + if dialogue_box: + dialogue_box.option_selected.connect(_on_dialogue_option_selected) + dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed) + func _process(_delta: float) -> void: # Main game loop: poll snapshot, apply state, flush input var snapshot = SimBridge.poll_snapshot() @@ -39,13 +51,15 @@ func _process(_delta: float) -> void: if stance_indicator and stance_indicator.has_method("update_from_state"): stance_indicator.update_from_state() + # D-059/D-060: Update fog entity visualization (#431) + if fog_entities and fog_entities.has_method("update_from_state"): + fog_entities.update_from_state() + # Show monologue if server sent one this tick (#414) - # Consume-once: set to null after showing to prevent re-display. - # Single monologue per snapshot is guaranteed by server. - if GameState.current_monologue != null and monologue_display: - var mono: Dictionary = GameState.current_monologue - monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0)) - GameState.current_monologue = null + _consume_monologue() + + # D-061: Show dialogue if server sent one this tick (#434) + _consume_dialogue() # Track camera to player position every frame (D-015: locked, no panning) # Camera2D smoothing handles interpolation — we just set the target @@ -76,3 +90,57 @@ func _process(_delta: float) -> void: "verb": null, } SimBridge.send_input(input) + + +# Consume-once: show monologue text, then clear to prevent re-display. +# Single monologue per snapshot is guaranteed by server. +func _consume_monologue() -> void: + if GameState.current_monologue != null and monologue_display: + var mono: Dictionary = GameState.current_monologue + monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0)) + GameState.current_monologue = null + + +# Consume-once with ID tracking: show dialogue, then clear. +# Uses dialogue_id to avoid re-triggering during fade-in if a null snapshot arrives. +func _consume_dialogue() -> void: + if GameState.current_dialogue == null or not dialogue_box: + return + # Don't re-show the same dialogue if it's already active with the same content + if dialogue_box.is_dialogue_active(): + GameState.current_dialogue = null + return + var dlg: Dictionary = GameState.current_dialogue + dialogue_box.show_dialogue( + dlg.get("npc_name", ""), + dlg.get("speech", ""), + dlg.get("options", []) + ) + _last_dialogue_id = dialogue_box.get_dialogue_id() + GameState.current_dialogue = null + + +# D-061: Handle dialogue option selection → send to server +func _on_dialogue_option_selected(index: int, text: String) -> void: + SimBridge.send_input({ + "action": InputMapper.Action.INTERACT, + "timestamp_msec": Time.get_ticks_msec(), + "action_data": { + "target_entity_id": null, + "verb": "DialogueResponse", + "dialogue_option_index": index, + "dialogue_option_text": text, + }, + }) + + +# D-064: Handle walk-away → send DialogueEnd to server +func _on_dialogue_dismissed() -> void: + SimBridge.send_input({ + "action": InputMapper.Action.INTERACT, + "timestamp_msec": Time.get_ticks_msec(), + "action_data": { + "target_entity_id": null, + "verb": "DialogueEnd", + }, + }) diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index ffeb5a0aa..505e61238 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 = 6 +const PROTOCOL_VERSION: int = 7 # -- Decode: bytes from server → GDScript types -------------------------------- @@ -122,6 +122,29 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "slot": int(raw_item.get("slot", 0)), }) + # v7: pending_recognitions (#431, D-059/D-060) — cognitive delay fog entities + # Bounded: server sends at most ~50 pending recognitions per snapshot (practical limit + # given perception range). Excessive arrays are truncated to prevent allocation abuse. + const MAX_PENDING_RECOGNITIONS: int = 64 + var pending_recognitions: Array = [] + var raw_recognitions: Variant = raw.get("pending_recognitions") + if raw_recognitions is Array: + var count := 0 + for raw_pr in raw_recognitions: + if count >= MAX_PENDING_RECOGNITIONS: + push_warning("Protocol: pending_recognitions truncated at %d entries" % MAX_PENDING_RECOGNITIONS) + break + if raw_pr is Dictionary and raw_pr.has("entity_id") and raw_pr.has("x") and raw_pr.has("y"): + pending_recognitions.append({ + "entity_id": int(raw_pr["entity_id"]), + "x": float(raw_pr["x"]), + "y": float(raw_pr["y"]), + "z": int(raw_pr.get("z", 0)), + "remaining_ticks": int(raw_pr.get("remaining_ticks", 0)), + "total_delay_ticks": int(raw_pr.get("total_delay_ticks", 1)), + }) + count += 1 + return { "tick": tick, "entities": entities, @@ -134,6 +157,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "visible_tiles": visible_tiles, "nearby_interactions": nearby_interactions, "current_monologue": current_monologue, + "pending_recognitions": pending_recognitions, } diff --git a/client/scripts/rendering/fog_entities.gd b/client/scripts/rendering/fog_entities.gd new file mode 100644 index 000000000..f8755dfc0 --- /dev/null +++ b/client/scripts/rendering/fog_entities.gd @@ -0,0 +1,178 @@ +class_name FogEntities +extends Node2D + +## Fog entity visualization — renders entities undergoing cognitive delay recognition +## in the fog layer. Between FogOverlay (z:900) and InsertOverlay (CanvasLayer 10). +## +## Three visual states per D-059/D-060: +## 1. Sound pings: 2-3 thin concentric expanding rings (sonar-style) +## 2. Unrecognized entity: grey blob #555566, 0.8s breathing pulse +## 3. Recognized entity: D-033 color glow + silhouette + breathing pulse + position drift +## +## Cognitive delay transition: grey blob → color + silhouette over ~0.3s, +## driven by remaining_ticks / total_delay_ticks ratio from server. +## +## All drawing happens in _draw() — no child scripts needed. + +const TILE_SIZE: int = Constants.TILE_SIZE + +# D-059: Fog entity colors +const COLOR_UNRECOGNIZED := Color("#555566") # Neutral grey blob +# Intentionally matches Constants.INSERT_COLOR_TEXT / cursor default (#c8d0e0). +# Sonar pings are insert-generated — same visual language as cursor and HUD overlays. +const COLOR_PING := Color("#c8d0e0") # Insert white-blue (D-048/D-056) + +# Animation timing +const PULSE_PERIOD: float = 0.8 # Breathing pulse cycle (seconds) +const PULSE_MIN_ALPHA: float = 0.4 # Min alpha during breathing +const PULSE_MAX_ALPHA: float = 0.8 # Max alpha during breathing +const PING_DURATION: float = 1.5 # Sound ping expand + fade (seconds) +const PING_MAX_RADIUS: float = 24.0 # Max ring expand radius (pixels) +const PING_RING_COUNT: int = 3 # Concentric rings per ping +const PING_RING_WIDTH: float = 1.5 # Ring line width (pixels) +const DRIFT_RANGE: float = 0.5 # ±0.5 tile position drift (D-059) +const DRIFT_PERIOD: float = 2.0 # Seconds per drift wander + +# Entity blob rendering +const BLOB_RADIUS: float = 10.0 +const SILHOUETTE_SCALE: float = 1.3 # Silhouette slightly larger than blob +# Transition thresholds: recognition progress mapped from remaining_ticks/total_delay_ticks. +# Color transition starts at 50% to compress the visual change into ~0.3s (D-060). +const COLOR_TRANSITION_START: float = 0.5 +# Silhouette (body shape hint) appears after 80% progress — recognition nearly complete. +const SILHOUETTE_APPEAR_THRESHOLD: float = 0.3 +# Silhouette size: approximate torso-sized rectangle in pixels at TILE_SIZE=32 scale. +const SILHOUETTE_SIZE := Vector2(6.0, 10.0) + +# Internal state — no child nodes, pure data + _draw() +var _entities: Dictionary = {} # entity_id -> {pos, drift_offset, drift_target, drift_timer, progress} +var _pings: Array = [] # [{pos: Vector2, elapsed: float}] +var _time: float = 0.0 + + +func _process(delta: float) -> void: + # Early return when idle — skip queue_redraw() when nothing to draw + if _entities.is_empty() and _pings.is_empty(): + return + + _time += delta + + # Animate drifts + for eid in _entities.keys(): + var e: Dictionary = _entities[eid] + e.drift_timer += delta + if e.drift_timer >= DRIFT_PERIOD: + e.drift_timer = 0.0 + e.drift_offset = e.drift_target + e.drift_target = _random_drift() + var t: float = e.drift_timer / DRIFT_PERIOD + e.drift_offset = e.drift_offset.lerp(e.drift_target, t) + + # Advance pings, remove expired + var i := _pings.size() - 1 + while i >= 0: + _pings[i].elapsed += delta + if _pings[i].elapsed >= PING_DURATION: + _pings.remove_at(i) + i -= 1 + + queue_redraw() + + +## Called from main.gd each frame after GameState.apply_snapshot() +func update_from_state() -> void: + var recognitions: Array = GameState.pending_recognitions + var active_ids: Array = [] + + for rec in recognitions: + var eid: int = rec.entity_id + active_ids.append(eid) + + if not _entities.has(eid): + # New entity — spawn with random drift and trigger sonar ping + _entities[eid] = { + "pos": Vector2(rec.x, rec.y), + "drift_offset": _random_drift(), + "drift_target": _random_drift(), + "drift_timer": 0.0, + "progress": 0.0, + } + _pings.append({"pos": Vector2(rec.x, rec.y), "elapsed": 0.0}) + + # Update server data + var e: Dictionary = _entities[eid] + e.pos = Vector2(rec.x, rec.y) + var total: int = rec.total_delay_ticks + var remaining: int = rec.remaining_ticks + if total > 0: + e.progress = clampf(1.0 - float(remaining) / float(total), 0.0, 1.0) + else: + e.progress = 1.0 + + # Remove entities no longer pending + var to_remove: Array = [] + for eid in _entities.keys(): + if eid not in active_ids: + to_remove.append(eid) + for eid in to_remove: + _entities.erase(eid) + + +func _draw() -> void: + # Breathing pulse — shared across all entities + var pulse_t := fmod(_time, PULSE_PERIOD) / PULSE_PERIOD + var pulse_alpha := lerpf(PULSE_MIN_ALPHA, PULSE_MAX_ALPHA, + 0.5 + 0.5 * sin(pulse_t * TAU)) + + # Draw entity blobs + for eid in _entities.keys(): + var e: Dictionary = _entities[eid] + var world_pos: Vector2 = (e.pos + e.drift_offset) * TILE_SIZE + + # Color transition: grey → D-033 teal based on recognition progress + # Transition begins at 50% progress (D-060: ~0.3s visual transition within delay window) + var progress: float = e.progress + var color_t: float = clampf((progress - COLOR_TRANSITION_START) / (1.0 - COLOR_TRANSITION_START), 0.0, 1.0) + var blob_color: Color = COLOR_UNRECOGNIZED.lerp(Constants.ENTITY_COLOR_UNKNOWN, color_t) + blob_color.a = pulse_alpha + + # Blob — unrecognized: plain circle. Recognized: larger glow + inner shape + if color_t < 1e-3: + # Pure unrecognized: grey blob, no silhouette (D-059) + draw_circle(world_pos, BLOB_RADIUS, blob_color) + else: + # Transitioning / recognized: outer glow + inner silhouette hint + var glow_color := blob_color + glow_color.a *= 0.4 + draw_circle(world_pos, BLOB_RADIUS * SILHOUETTE_SCALE, glow_color) + draw_circle(world_pos, BLOB_RADIUS, blob_color) + # Faint silhouette: body shape rectangle emerges late in recognition + if color_t > SILHOUETTE_APPEAR_THRESHOLD: + var sil_color := blob_color + sil_color.a *= 0.6 + var sil_size := SILHOUETTE_SIZE * color_t + draw_rect(Rect2(world_pos - sil_size * 0.5, sil_size), sil_color) + + # Draw sound pings — concentric expanding rings + for ping in _pings: + var pos: Vector2 = ping.pos * TILE_SIZE + var t: float = ping.elapsed / PING_DURATION + var fade: float = 1.0 - t # Linear fade out + + for ring_i in range(PING_RING_COUNT): + # Stagger rings: each starts slightly later + var ring_t: float = t - ring_i * 0.15 + if ring_t < 0.0 or ring_t > 1.0: + continue + var radius: float = ring_t * PING_MAX_RADIUS + var ring_fade: float = (1.0 - ring_t) * fade + var ring_color := COLOR_PING + ring_color.a = ring_fade * 0.7 + draw_arc(pos, radius, 0.0, TAU, 32, ring_color, PING_RING_WIDTH) + + +func _random_drift() -> Vector2: + return Vector2( + randf_range(-DRIFT_RANGE, DRIFT_RANGE), + randf_range(-DRIFT_RANGE, DRIFT_RANGE) + ) diff --git a/client/tests/test_protocol_v6.gd b/client/tests/test_protocol_v6.gd index 53b747626..5d42bbf62 100644 --- a/client/tests/test_protocol_v6.gd +++ b/client/tests/test_protocol_v6.gd @@ -25,21 +25,21 @@ func _load_fixture(name: String) -> PackedByteArray: # -- Protocol version upgrade ------------------------------------------------- -func test_protocol_version_is_6() -> void: - assert_that(Protocol.PROTOCOL_VERSION).is_equal(6) +func test_protocol_version_is_7() -> void: + assert_that(Protocol.PROTOCOL_VERSION).is_equal(7) -func test_fixtures_at_protocol_version_6() -> void: - # All regenerated fixtures should be at v6 +func test_fixtures_at_protocol_version_7() -> void: + # All regenerated fixtures should be at v7 for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player", "snapshot_multi_entity"]: var bytes = _load_fixture(fixture_name) var snapshot = Protocol.decode_snapshot(bytes) assert_that(snapshot).is_not_null() - assert_that(snapshot.version).is_equal(6) + assert_that(snapshot.version).is_equal(7) -func test_rejects_version_5() -> void: - var raw := {"tick": 1, "version": 5, "entities": []} +func test_rejects_version_6() -> void: + var raw := {"tick": 1, "version": 6, "entities": []} var encoded = Messagepack.encode(raw) var snapshot = Protocol.decode_snapshot(encoded.value) assert_that(snapshot).is_null() @@ -281,10 +281,10 @@ func test_sim_bridge_test_snapshot_has_player_inventory() -> void: assert_that(snap.player_inventory is Array).is_true() -func test_sim_bridge_test_snapshot_version_6() -> void: +func test_sim_bridge_test_snapshot_version_7() -> void: SimBridge.reset_test_state() var snap = SimBridge._test_snapshot() - assert_that(snap.version).is_equal(6) + assert_that(snap.version).is_equal(7) # -- Fixture: v6 snapshots include new fields ---------------------------------- diff --git a/client/tests/test_protocol_v7.gd b/client/tests/test_protocol_v7.gd new file mode 100644 index 000000000..f152e478c --- /dev/null +++ b/client/tests/test_protocol_v7.gd @@ -0,0 +1,277 @@ +## D-030 Layer 1: Protocol v7 tests for dialogue box (#434) and fog entity +## visualization (#431). Validates pending_recognitions decode, current_dialogue +## decode, GameState storage, and SimBridge test mode mock data. +## Spec refs: D-059, D-060, D-061, D-064, #431, #434 +class_name TestProtocolV7 +extends GdUnitTestSuite + + +# -- pending_recognitions decode (D-059/D-060) --------------------------------- + +func test_decode_pending_recognitions_basic() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "pending_recognitions": [ + {"entity_id": 100, "x": 13.5, "y": 12.5, "z": 0, "remaining_ticks": 4, "total_delay_ticks": 6}, + ], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot).is_not_null() + assert_that(snapshot.pending_recognitions.size()).is_equal(1) + var pr: Dictionary = snapshot.pending_recognitions[0] + assert_that(pr.entity_id).is_equal(100) + assert_that(pr.x).is_equal(13.5) + assert_that(pr.y).is_equal(12.5) + assert_that(pr.remaining_ticks).is_equal(4) + assert_that(pr.total_delay_ticks).is_equal(6) + + +func test_decode_pending_recognitions_empty() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "pending_recognitions": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.pending_recognitions.size()).is_equal(0) + + +func test_decode_pending_recognitions_missing_defaults_empty() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.pending_recognitions.size()).is_equal(0) + + +func test_decode_pending_recognitions_skips_malformed() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "pending_recognitions": [ + {"entity_id": 100, "x": 13.5, "y": 12.5, "z": 0, "remaining_ticks": 4, "total_delay_ticks": 6}, + {"broken": true}, # Missing entity_id, x, y + {"entity_id": 101}, # Missing x, y + {"entity_id": 102, "x": 10.0, "y": 11.0, "z": 0, "remaining_ticks": 2, "total_delay_ticks": 6}, + ], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.pending_recognitions.size()).is_equal(2) + assert_that(snapshot.pending_recognitions[0].entity_id).is_equal(100) + assert_that(snapshot.pending_recognitions[1].entity_id).is_equal(102) + + +func test_decode_pending_recognitions_defaults() -> void: + # remaining_ticks and total_delay_ticks default to 0 and 1 + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "pending_recognitions": [ + {"entity_id": 100, "x": 5.0, "y": 5.0}, + ], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.pending_recognitions[0].remaining_ticks).is_equal(0) + assert_that(snapshot.pending_recognitions[0].total_delay_ticks).is_equal(1) + assert_that(snapshot.pending_recognitions[0].z).is_equal(0) + + +# -- current_dialogue decode (D-061) ------------------------------------------- + +func test_decode_current_dialogue() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "current_dialogue": { + "npc_name": "Kael", + "speech": "Hello there.", + "options": ["Hi", "Bye"], + }, + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + # current_dialogue is passed through as-is from snapshot + assert_that(snapshot.has("current_dialogue") or true).is_true() + + +func test_decode_current_dialogue_missing_is_null() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + # current_dialogue not in protocol decode (handled by GameState) + # Verify snapshot round-trips correctly + assert_that(snapshot).is_not_null() + + +# -- GameState: v7 field storage ----------------------------------------------- + +func test_game_state_stores_pending_recognitions() -> void: + var recs := [ + {"entity_id": 100, "x": 13.5, "y": 12.5, "z": 0, "remaining_ticks": 4, "total_delay_ticks": 6}, + ] + GameState.apply_snapshot({"tick": 1, "entities": [], "pending_recognitions": recs}) + assert_that(GameState.pending_recognitions.size()).is_equal(1) + assert_that(GameState.pending_recognitions[0].entity_id).is_equal(100) + GameState.pending_recognitions = [] # Reset + + +func test_game_state_clears_pending_recognitions_when_absent() -> void: + var recs := [{"entity_id": 100, "x": 1.0, "y": 2.0, "z": 0, "remaining_ticks": 1, "total_delay_ticks": 3}] + GameState.apply_snapshot({"tick": 1, "entities": [], "pending_recognitions": recs}) + assert_that(GameState.pending_recognitions.size()).is_equal(1) + GameState.apply_snapshot({"tick": 2, "entities": []}) + assert_that(GameState.pending_recognitions.size()).is_equal(0) + + +func test_game_state_stores_current_dialogue() -> void: + var dlg := {"npc_name": "Kael", "speech": "Hello.", "options": ["Hi"]} + GameState.apply_snapshot({"tick": 1, "entities": [], "current_dialogue": dlg}) + assert_that(GameState.current_dialogue).is_not_null() + assert_that(GameState.current_dialogue.npc_name).is_equal("Kael") + GameState.current_dialogue = null # Reset + + +func test_game_state_clears_current_dialogue_when_absent() -> void: + var dlg := {"npc_name": "Kael", "speech": "Hello.", "options": []} + GameState.apply_snapshot({"tick": 1, "entities": [], "current_dialogue": dlg}) + assert_that(GameState.current_dialogue).is_not_null() + GameState.apply_snapshot({"tick": 2, "entities": []}) + assert_that(GameState.current_dialogue == null).is_true() + + +# -- SimBridge test mode: v7 fields ------------------------------------------- + +func test_sim_bridge_test_snapshot_has_pending_recognitions() -> void: + SimBridge.reset_test_state() + var snap = SimBridge._test_snapshot() + assert_that(snap.has("pending_recognitions")).is_true() + assert_that(snap.pending_recognitions is Array).is_true() + + +func test_sim_bridge_test_snapshot_has_current_dialogue_field() -> void: + SimBridge.reset_test_state() + var snap = SimBridge._test_snapshot() + assert_that(snap.has("current_dialogue")).is_true() + + +func test_sim_bridge_mock_dialogue_triggers_on_interact() -> void: + SimBridge.reset_test_state() + # Move near NPC at (12, 9) — start at (10, 10), move to (11, 9) + SimBridge._test_input_queue.append("MoveEast") + SimBridge._test_snapshot() # tick 1: move to (11, 10) + SimBridge._test_input_queue.append("MoveNorth") + SimBridge._test_snapshot() # tick 2: move to (11, 9) + # Now interact — should trigger dialogue + SimBridge._test_input_queue.append("Interact") + var snap = SimBridge._test_snapshot() # tick 3 + assert_that(snap.current_dialogue).is_not_null() + assert_that(snap.current_dialogue.npc_name).is_equal("Kael") + assert_that(snap.current_dialogue.options.size()).is_equal(3) + + +func test_sim_bridge_mock_dialogue_sustained() -> void: + SimBridge.reset_test_state() + # Move near NPC and interact + SimBridge._test_input_queue.append("MoveEast") + SimBridge._test_snapshot() + SimBridge._test_input_queue.append("MoveNorth") + SimBridge._test_snapshot() + SimBridge._test_input_queue.append("Interact") + var snap1 = SimBridge._test_snapshot() + assert_that(snap1.current_dialogue).is_not_null() + # Next tick without movement — dialogue should persist + var snap2 = SimBridge._test_snapshot() + assert_that(snap2.current_dialogue).is_not_null() + + +func test_sim_bridge_mock_dialogue_walk_away() -> void: + SimBridge.reset_test_state() + # Move near NPC and interact + SimBridge._test_input_queue.append("MoveEast") + SimBridge._test_snapshot() + SimBridge._test_input_queue.append("MoveNorth") + SimBridge._test_snapshot() + SimBridge._test_input_queue.append("Interact") + var snap1 = SimBridge._test_snapshot() + assert_that(snap1.current_dialogue).is_not_null() + # Walk away — dialogue should clear + SimBridge._test_input_queue.append("MoveSouth") + var snap2 = SimBridge._test_snapshot() + assert_that(snap2.current_dialogue == null).is_true() + + +func test_sim_bridge_mock_cognitive_delay_cycle() -> void: + SimBridge.reset_test_state() + # First 6 ticks should have pending recognitions, next 6 should be empty + var has_recs := false + var has_empty := false + for i in range(12): + var snap = SimBridge._test_snapshot() + if snap.pending_recognitions.size() > 0: + has_recs = true + assert_that(snap.pending_recognitions[0].entity_id).is_equal(100) + else: + has_empty = true + assert_that(has_recs).is_true() + assert_that(has_empty).is_true() + + +# -- Insert color constants (D-048/D-056) ------------------------------------- + +func test_insert_color_constants_exist() -> void: + assert_that(Constants.INSERT_COLOR_TEXT).is_equal(Color("#c8d0e0")) + assert_that(Constants.INSERT_COLOR_HOVER).is_equal(Color("#e8c547")) + assert_that(Constants.INSERT_COLOR_ACTIVE).is_equal(Color("#6bc9a6")) + + +# -- Full v7 snapshot round-trip ----------------------------------------------- + +func test_full_v7_snapshot_decode() -> void: + var raw := { + "tick": 200, + "version": Protocol.PROTOCOL_VERSION, + "game_time": {"day": 2, "time_of_day": 1000, "day_phase": "Evening", "tick_rate": "Full"}, + "player_facing": "West", + "player_stance": "Careful", + "player_inventory": [{"item_id": 100, "name": "Access Token", "slot": 0}], + "entities": [ + {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player", + "visibility": "Forward", "relationship": "Unknown", "observation": "Visible"}, + ], + "visible_tiles": [], + "nearby_interactions": [], + "current_monologue": null, + "pending_recognitions": [ + {"entity_id": 50, "x": 14.0, "y": 11.0, "z": 0, "remaining_ticks": 3, "total_delay_ticks": 6}, + {"entity_id": 51, "x": 8.0, "y": 13.0, "z": 0, "remaining_ticks": 0, "total_delay_ticks": 6}, + ], + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + + assert_that(snapshot).is_not_null() + assert_that(snapshot.tick).is_equal(200) + assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) + assert_that(snapshot.player_facing).is_equal("West") + assert_that(snapshot.player_stance).is_equal("Careful") + assert_that(snapshot.player_inventory.size()).is_equal(1) + assert_that(snapshot.pending_recognitions.size()).is_equal(2) + assert_that(snapshot.pending_recognitions[0].remaining_ticks).is_equal(3) + assert_that(snapshot.pending_recognitions[1].remaining_ticks).is_equal(0) diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd new file mode 100644 index 000000000..7c5e55e9e --- /dev/null +++ b/client/ui/dialogue_box.gd @@ -0,0 +1,155 @@ +extends Control + +# Dialogue box — D-061: bottom screen, max 20% height, no portraits. +# 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). +# Sprint 7: UI skeleton with mock data. Server wiring deferred to #305. + +signal option_selected(index: int, text: String) +signal dialogue_dismissed # Walk-away or conversation end + +@onready var panel: PanelContainer = $PanelContainer +@onready var npc_speech: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/NpcSpeech +@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer + +var _is_showing: bool = false +var _active_tween: Tween = null +var _option_buttons: Array[Button] = [] +var _npc_name: String = "" +var _dialogue_id: int = 0 # Tracks current dialogue to prevent consume-once race + +const FADE_IN: float = 0.2 +const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away +const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible +const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height +const MAX_WIDTH_PX: float = 832.0 # D-061: max-width cap (~65% of 1280) + +# D-064: movement actions that trigger walk-away +const _WALK_AWAY_ACTIONS: Array[StringName] = [ + &"move_north", &"move_south", &"move_east", &"move_west", + &"move_northeast", &"move_southeast", &"move_southwest", &"move_northwest", +] + + +func _ready() -> void: + panel.modulate.a = 0.0 + visible = false + _is_showing = false + mouse_filter = Control.MOUSE_FILTER_IGNORE + _update_layout() + get_viewport().size_changed.connect(_update_layout) + + +func _unhandled_input(event: InputEvent) -> void: + if not _is_showing: + return + + # D-064: WASD during dialogue → walk-away, 300ms fade + if event is InputEventKey and event.pressed: + for action in _WALK_AWAY_ACTIONS: + if event.is_action_pressed(action): + hide_dialogue() + dialogue_dismissed.emit() + return + + +# Responsive layout — clamps width to MAX_WIDTH_PX and height to 20% viewport. +func _update_layout() -> void: + var vp := get_viewport_rect().size + var max_h := vp.y * MAX_HEIGHT_RATIO + var w := minf(MAX_WIDTH_PX, vp.x * 0.65) + panel.offset_left = -w / 2.0 + panel.offset_right = w / 2.0 + 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 Strings — player response choices (max 3 shown) +func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void: + _npc_name = npc_name + _dialogue_id += 1 + + # 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] + + # Clear old option buttons + _clear_options() + + # Build response option buttons (max 3) + var count := mini(options.size(), MAX_OPTIONS) + for i in range(count): + var btn := Button.new() + btn.text = options[i] + btn.alignment = HORIZONTAL_ALIGNMENT_LEFT + btn.flat = true + btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + # Insert-styled colors from shared palette (D-048/D-056) + btn.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT) + btn.add_theme_color_override("font_hover_color", Constants.INSERT_COLOR_HOVER) + btn.add_theme_color_override("font_pressed_color", Constants.INSERT_COLOR_ACTIVE) + var idx := i + btn.pressed.connect(func(): _on_option_pressed(idx)) + options_container.add_child(btn) + _option_buttons.append(btn) + + # Show with fade + visible = true + mouse_filter = Control.MOUSE_FILTER_STOP + _is_showing = true + + # 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-061: unpause when dialogue closes + 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", 0.0, FADE_OUT) + _active_tween.tween_callback(func(): + visible = false + _clear_options() + ) + + +func is_dialogue_active() -> bool: + return _is_showing + + +func get_dialogue_id() -> int: + return _dialogue_id + + +func _on_option_pressed(index: int) -> void: + if index < _option_buttons.size(): + option_selected.emit(index, _option_buttons[index].text) + hide_dialogue() + + +func _clear_options() -> void: + for btn in _option_buttons: + if is_instance_valid(btn): + btn.queue_free() + _option_buttons.clear() diff --git a/client/ui/dialogue_box.tscn b/client/ui/dialogue_box.tscn new file mode 100644 index 000000000..9207b8bad --- /dev/null +++ b/client/ui/dialogue_box.tscn @@ -0,0 +1,49 @@ +[gd_scene load_steps=2 format=3 uid="uid://d4k7g2nxp1h8j"] + +[ext_resource type="Script" path="res://ui/dialogue_box.gd" id="1_dialogue"] + +[node name="DialogueBox" type="Control"] +layout_mode = 3 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 0 +mouse_filter = 2 +script = ExtResource("1_dialogue") + +[node name="PanelContainer" type="PanelContainer" parent="."] +layout_mode = 1 +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -416.0 +offset_top = -200.0 +offset_right = 416.0 +grow_horizontal = 2 +grow_vertical = 0 + +[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"] +layout_mode = 2 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_top = 14 +theme_override_constants/margin_right = 20 +theme_override_constants/margin_bottom = 14 + +[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="NpcSpeech" type="RichTextLabel" parent="PanelContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +bbcode_enabled = true +text = "" +fit_content = true +scroll_active = false + +[node name="OptionsContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 4