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..b25d0b4af 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 + # One-shot: dialogue data sent once on the trigger tick, not every frame + 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.", + ], + } + _test_in_dialogue = false + + # 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..03cc8d15c 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 # diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 2ef4e2463..ab30b66aa 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -1,12 +1,14 @@ 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 @@ -39,6 +41,10 @@ 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. @@ -47,6 +53,16 @@ func _process(_delta: float) -> void: monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0)) GameState.current_monologue = null + # D-061: Show dialogue if server sent one this tick (#434) + if GameState.current_dialogue != null and dialogue_box: + var dlg: Dictionary = GameState.current_dialogue + dialogue_box.show_dialogue( + dlg.get("npc_name", ""), + dlg.get("speech", ""), + dlg.get("options", []) + ) + GameState.current_dialogue = null + # Track camera to player position every frame (D-015: locked, no panning) # Camera2D smoothing handles interpolation — we just set the target camera.global_position = GameState.player_position * Constants.TILE_SIZE diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index ffeb5a0aa..a3fe60895 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,21 @@ 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 + var pending_recognitions: Array = [] + var raw_recognitions: Variant = raw.get("pending_recognitions") + if raw_recognitions is Array: + for raw_pr in raw_recognitions: + 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)), + }) + return { "tick": tick, "entities": entities, @@ -134,6 +149,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..189995a60 --- /dev/null +++ b/client/scripts/rendering/fog_entities.gd @@ -0,0 +1,165 @@ +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 +const COLOR_PING := Color("#c8d0e0") # Insert white-blue (D-056 cursor default) + +# 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 + +# 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: + _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 - 0.5) / 0.5, 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 < 0.01: + # 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: small rectangle hint (body shape emerging from fog) + if color_t > 0.3: + var sil_color := blob_color + sil_color.a *= 0.6 + var sil_size := Vector2(6.0, 10.0) * 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) + )