feat(client): add fog entity visualization and wire Sprint 7 UI (#431, #434)

Fog entity cognitive delay rendering (D-059/D-060): sonar-style sound
pings (3 concentric rings, 1.5s fade), unrecognized grey blobs with
breathing pulse, D-033 color transition at 50% recognition progress,
±0.5 tile position drift. FogEntities node at z:950 between fog
shader and InsertOverlay.

Protocol v7 bump to match server PR #23 (pending_recognitions field).
Wires dialogue box and fog entities into game loop with mock test data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 01:19:24 +01:00
co-authored by Claude Opus 4.6
parent 2d56131221
commit 2659de0c28
7 changed files with 276 additions and 2 deletions
+13 -1
View File
@@ -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="."]
+18
View File
@@ -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:
+46
View File
@@ -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
+1
View File
@@ -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
#
+16
View File
@@ -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
+17 -1
View File
@@ -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,
}
+165
View File
@@ -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)
)