New GameplayRenderer extends Node2D with built-in occlusion support. Subclasses override _gameplay_process() and _gameplay_draw() instead of _process() and _draw() — these are skipped when a fullscreen implant app covers gameplay. Migrated all 5 gameplay renderers: - CursorRenderer: hover detection + bracket drawing paused - EntityRenderer: position lerping paused - FogEntities: fog dot rendering paused - SoundIndicatorRenderer: directional arrows paused - WorldRenderer: tile/fog/entity updates paused No more manual occlusion wiring — the base class handles everything. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
177 lines
6.6 KiB
GDScript
177 lines
6.6 KiB
GDScript
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))
|