class_name FogEntities extends GameplayRenderer ## 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 _gameplay_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 _gameplay_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))