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:
+13
-1
@@ -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="."]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
Reference in New Issue
Block a user