diff --git a/LILA_PROJECT_STATE.md b/LILA_PROJECT_STATE.md index cb03a6b..7ec1293 100644 --- a/LILA_PROJECT_STATE.md +++ b/LILA_PROJECT_STATE.md @@ -936,6 +936,197 @@ Expand the shipped search pipeline from 17-dim rate tuning to trait-space search --- +## Godot Client — 3D Visualization (Milestone 4, Partially Shipped) + +**Goal:** 3D visualization of trait-based ecosystems with orbit camera, intent-driven client agency, and real-time telemetry. Built with Godot 4.x (GDScript, `gl_compatibility` renderer). + +The Godot client mirrors the architecture of the browser and Python clients: WebSocket connection → intent packet parsing → local agency at 60 Hz → heartbeat absorption upstream. It renders the world in 3D with an orbit camera, MultiMesh-based entity rendering, and a particle system for event visualization. + +### Project Scaffolding (`client/godot/`) + +```ngodot/client/ +├── project.godot # Godot 4.7 project, gl_compatibility renderer, 1280×720 +├── resources/ +│ └── world.json # Local world definition (sent to server on connect) +├── scenes/ +│ ├── main.tscn # Main scene: Node3D + Camera3D + MultiMesh nodes + HUD +│ └── main.gd # Entry point: WS callbacks, mesh building, render loop +├── scripts/ +│ ├── autoloads/ +│ │ ├── ws_client.gd # WebSocket client (connect, poll, dispatch, send) +│ │ └── world_model.gd # WorldEntity registry + spatial queries (mirrors browser/Python) +│ ├── agency.gd # Client-side behavior engine (60 Hz, mirrors browser/Python) +│ ├── heartbeat.gd # HeartbeatSender — upstream position/event reporting (1 Hz) +│ ├── reconciliation.gd # Gravity-well position reconciliation +│ ├── particles.gd # Particle system for event visualizations (RefCounted) +│ ├── constants.gd # Shared constants (mirrors browser/Python) +│ ├── renderer.gd # Isometric helper (unused in 3D, kept for reference) +│ └── camera/ +│ └── orbit_camera.gd # Orbit/trackball camera controller +└── .godot/ # Editor state (git-ignored) +``` + +**Autoloads:** `WS` (ws_client.gd) and `World` (world_model.gd) are registered as singletons in `project.godot`. All other scripts access them globally. + +### Node Tree (`scenes/main.tscn`) + +``` +Main (Node3D) +├── Camera (Camera3D + orbit_camera.gd) — 50° FOV, spherical orbit +├── WorldLight (DirectionalLight3D) — warm key light, no shadows +├── AmbientLight (OmniLight3D) — soft fill, 100-unit range +├── Ground (MeshInstance3D) — moisture heatmap grid (rebuilt every frame) +├── Entities (MultiMeshInstance3D) — per-entity BoxMesh cubes +├── Particles (MultiMeshInstance3D) — event particles (cap 500) +├── WorldEnvironment (WorldEnvironment) — sky color, glow +└── HUD (CanvasLayer, layer 10) + └── VBox (VBoxContainer) + ├── StatsLabel (Label) — tick, entity count, events, FPS + ├── EventLog (RichTextLabel) — color-coded event stream (50-line buffer) + ├── RainButton (Button) — "☔ Rain" + └── HelpLabel (Label) — control hints +``` + +### WebSocket Layer (`scripts/autoloads/ws_client.gd`) + +Connects to `ws://localhost:8001/ws`, sends local `world.json` as the session starter. Signals emitted: + +- `connected` / `disconnected` — connection state +- `session_started(data)` — server ack with species definitions +- `tick_packet(data)` — intent packet with entity_updates, entity_spawns, entity_removals, voxel_deltas, water_sources, events +- `world_json_ready(data)` — local world.json parsed + +**Dispatch logic:** Messages with `"type": "session_started"` route to `session_started`. Messages with `"tick"` key (no `"type"` field) route to `tick_packet`. Auto-reconnects with 3-second backoff. + +**Heartbeat send:** `send_heartbeat(positions: Dictionary, events: Array)` — called by HeartbeatSender at 1 Hz. Sends `"type": "heartbeat"` with entity positions and client-reported events. + +### World Model (`scripts/autoloads/world_model.gd`) + +Mirrors browser `world-model.js` and Python `world_model.py`. + +**WorldEntity fields:** +- `id`, `type`, `species`, `skeleton_id` — identity +- `x`, `z` — client-agency position (updated at 60 Hz by agency) +- `ref_x`, `ref_z` — server reference position (gravity well anchor) +- `state`, `drive`, `motion_latent` — intent fields from server +- `can_consume`, `can_predate`, `can_pollinate`, `repro_eligible`, `can_drink` — eligibility flags +- `ack` — server acknowledged our deviation +- `reconcile_queue`, `reconcile_idx` — target positions for smooth reconciliation +- `target_x`, `target_z`, `has_target`, `last_action_type` — wander target persistence +- `last_reconciled_tick` — stagger tracking for reconciliation +- `facing_angle`, `alive` — rendering state +- `sync_phase` (0–3), `sync_speed` (0.4–1.0) — deterministic per-entity sync personality from ID hash + +**Spatial queries:** `find_nearest()`, `find_nearest_species()`, `find_nearest_mate()`, `find_nearest_water()` — used by agency evaluators. + +### Client-Side Agency (`scripts/agency.gd`) + +Runs at 60 Hz in `_process(delta)`. For each mobile entity: + +1. **Reconcile check** — if entity has a pending reconcile target, execute spiral-meander toward it (from reconciliation queue). Returns early. +2. **Behavior priority chain:** + - FLEEING → `_evaluate_fleeing()` — flee away from nearest threat + - DRINKING / thirsty → `_evaluate_drinking()` — approach water source edge + - repro_eligible → `_evaluate_mate_seeking()` — seek nearest same-species entity + - FORAGING + can_consume → `_evaluate_foraging()` — approach food by diet_order + - HUNTING + can_predate → `_evaluate_hunting()` — approach prey + - can_pollinate → `_evaluate_pollination()` — approach FRUITING flowers + - default → `_evaluate_wandering()` — pick random wander target (reused until reached, per `has_target`/`last_action_type`) +3. **Move toward target** — `_move_toward()` with species speed, latent-modulated pace, delta-scaled step +4. **Gravity well** — gentle pull toward `ref_position`: `nudge = 0.05 × sync_speed × delta`, skipped when distance < 0.2 + +**Wander target persistence:** Tracks `has_target`, `target_x`, `target_z`, `last_action_type` per entity. When wandering, reuses an existing target until reached (distance < 0.5), then picks a new one. Prevents the "jitter in place" bug where a new random target was generated every frame. + +**Interaction triggers:** When an entity arrives at its target (within `ARRIVAL_DISTANCE = 0.8`), fires a client event (consumption, predation, pollination, repro) with a 2-second per-target cooldown. Events are queued by HeartbeatSender. + +### Reconciliation (`scripts/reconciliation.gd`) + +Called once per server tick (after tick packet processing). For each mobile entity: + +1. **Stagger check:** `ticks_since_last < sync_phase` → skip (spreads sync across frames) +2. **Ack handling:** if server acknowledged, clear queue and update `last_reconciled_tick` +3. **Divergence check:** if `divergence < 0.1`, prune queue and update tick +4. **Enqueue:** append `ref_position` as reconcile target (cap at 2) +5. **Prune:** keep only the latest target if queue overflows + +The agency system consumes reconcile targets at 60 Hz via `_execute_reconcile()`, which moves the entity toward its queued target using a spiral-meander pattern. When the entity arrives (distance < 0.8), the reconcile index advances and the entity returns to normal agency. + +### Heartbeat Sender (`scripts/heartbeat.gd`) + +Accumulates entity positions and agency events, sends at 1 Hz interval. Builds a `positions` dict keyed by entity ID with `[x, 0.0, z]` values for all alive mobile consumers. Queued events are drained on send. + +### Rendering (`scenes/main.gd`) + +**Ground mesh:** Rebuilt every frame via `SurfaceTool` with `PRIMITIVE_TRIANGLES`. Each grid cell becomes two triangles colored by moisture: sandy (dry < 0.33) → green (0.33–0.66) → teal (moist > 0.66). Cells are 0.98×0.98 to avoid z-fighting seams. + +**Entity mesh:** `MultiMeshInstance3D` with `BoxMesh` primitive. Entity size scales by type (TREE=3.0, ANIMAL=1.5, BIRD=1.0, INSECT=0.7, PLANT=0.8). Color from species or type lookup. Insects have a vertical bob (sin wave + sync_phase offset). Dormant entities are darkened 50%. + +**Particle mesh:** `MultiMeshInstance3D` with small `BoxMesh` (0.4³). Cap 500 particles. Spawned on death, consumption, and pollination events from server tick packets. Particles expand outward with damping (0.95×) and fade over 0.5–1.5s. + +### Orbit Camera (`scripts/camera/orbit_camera.gd`) + +Spherical coordinate orbit around a world-space target point. + +**Controls:** +- Left mouse drag — orbit (theta/phi angles) +- Right mouse drag — pan target (perpendicular to view direction) +- Scroll wheel — zoom (distance ×/÷ 1.15 per tick, clamped 5–200) + +**Parameters (exported for editor tuning):** +- `target` — world-space orbit center (set to grid center on init) +- `distance` — 45.0 default +- `theta` — horizontal angle, π/4 default +- `phi` — vertical angle, π/3 default (clamped 0.05 to π-0.05) +- `zoom_factor` — 1.15 per scroll tick +- `pan_speed` — 0.03 world units per pixel +- `orbit_speed` — 0.003 radians per pixel + +### Constants (`scripts/constants.gd`) + +Mirrors browser `constants.js` and Python `constants.py`: + +- `GRID_SIZE = 32` — world dimensions +- `SERVER_TICK_RATE = 2.0` — 0.5 Hz intent packets +- `HEARTBEAT_INTERVAL_MS = 1000` — 1 Hz client heartbeats +- `RECONCILE_MIN_DIVERGENCE = 0.1` — skip sync below this +- `RECONCILE_QUEUE_MAX = 2` — cap reconcile targets +- `GRAVITY_WELL_FACTOR = 0.05` — gentle pull strength +- `INTERACTION_COOLDOWN = 2.0` — seconds between re-interactions +- `WANDER_MARGIN = 4.0` — wander target range +- `ARRIVAL_DISTANCE = 0.8` — distance threshold for target arrival +- Color dictionaries: `TYPE_COLORS`, `SPECIES_COLORS`, `STATE_COLORS` +- `MOBILE_TYPES = ["ANIMAL", "BIRD", "INSECT"]` +- Particle colors: `PARTICLE_CONSUMPTION`, `PARTICLE_POLLINATION`, `PARTICLE_DEATH` + +### Telemetry Logging + +Every 10 ticks, the client prints a debug line with entity position divergence stats: +``` +[telemetry] tick=30 entities=29 | deer_01: local=(6.84,19.22) ref=(6.87,19.16) div=0.065 ack=true queue=0 | ... +``` +This mirrors the server's telemetry bus format for cross-referencing client vs. server state during debugging. + +### Sync Bugs Fixed (PR: `fix/godot-client-sync`) + +Three critical bugs were identified by comparing Godot agency/reconciliation against the browser and Python clients: + +1. **Gravity well missing `delta`** — nudge was applied at full strength every frame (60× too strong), causing violent overshoot/oscillation around ref_position. Fixed: `nudge = 0.05 × sync_speed × delta`, with 0.2 proximity threshold. + +2. **Wander target regenerated every frame** — `_evaluate_wandering()` picked a new random target each frame, causing jitter as the entity chased a moving target. Combined with the facing_angle bias from `randf() * TAU * wobble + ent.facing_angle`, this also caused a slow "marching" drift. Fixed: track `has_target`/`last_action_type` to persist wander targets until reached. + +3. **Reconciliation missing `last_reconciled_tick`** — used `(tick % 4) == sync_phase` instead of tracking ticks since last reconcile. No queue pruning, no update on negligible divergence. Fixed: added `last_reconciled_tick` tracking, queue pruning, and proper stagger logic to match Python/browser. + +### Current Limitations + +- **No skeletal animation** — entities render as colored BoxMesh cubes sized by type. The motion latent is consumed by the agency system for movement modulation but not yet mapped to bone transforms. +- **No skeletal rigs** — Blender models and Godot skeletons are not yet integrated (deferred to full Milestone 4). +- **Ground mesh rebuilt every frame** — `SurfaceTool` commit is O(grid²) per frame. For 32×32 this is fine (~1000 triangles). Future: commit once, update via vertex buffer or shader. +- **No water source rendering** — water sources are tracked in the world model but not yet rendered in 3D. +- **`gl_compatibility` renderer** — chosen for broad hardware support. Forward+ or Vulkan would allow more sophisticated materials, PBR, and shadows. +- **No replay** — the Godot client connects live only. No JSONL replay mode like the Python client. + +--- + ## Pending — Milestone 4: Godot Client + Trained Motion Model **Goal:** 3D visualization of trait-based ecosystems with latent-driven skeletal animation. Built against the stable trait-based engine from Milestone 2, not the current hand-coded species. diff --git a/client/godot/.gitignore b/client/godot/.gitignore new file mode 100644 index 0000000..7cc2939 --- /dev/null +++ b/client/godot/.gitignore @@ -0,0 +1,9 @@ +# Godot editor files +*.import +*.uid +*.tscn.new +*.godot/ + +# OS files +.DS_Store +Thumbs.db diff --git a/client/godot/assets/icon.svg b/client/godot/assets/icon.svg new file mode 100644 index 0000000..fc19f65 --- /dev/null +++ b/client/godot/assets/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/client/godot/project.godot b/client/godot/project.godot new file mode 100644 index 0000000..1067a10 --- /dev/null +++ b/client/godot/project.godot @@ -0,0 +1,31 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Lila Client" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7") +config/icon="res://assets/icon.svg" + +[autoload] + +WS="*res://scripts/autoloads/ws_client.gd" +World="*res://scripts/autoloads/world_model.gd" + +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=720 +window/stretch/mode="canvas_items" + +[rendering] + +renderer/rendering_method="gl_compatibility" diff --git a/client/godot/resources/shaders/water_surface.shader b/client/godot/resources/shaders/water_surface.shader new file mode 100644 index 0000000..c05d817 --- /dev/null +++ b/client/godot/resources/shaders/water_surface.shader @@ -0,0 +1,31 @@ +shader_type spatial; +render_mode unshaded, depth_prepass_alpha; + +uniform float u_time : hint_range(0, 1000) = 0.0; +uniform float u_level : hint_range(0.0, 1.0) = 1.0; +uniform vec3 u_color_deep = vec3(0.176, 0.333, 0.431); +uniform vec3 u_color_shallow = vec3(0.216, 0.412, 0.490); +uniform vec3 u_shine_color = vec3(0.275, 0.510, 0.588); + +void fragment() { + float dist = length(UV * 2.0 - 1.0); + + float ripple1 = sin(dist * 12.0 - u_time * 3.0) * 0.5 + 0.5; + float ripple2 = sin(dist * 8.0 - u_time * 2.0 + 1.5) * 0.5 + 0.5; + float ripple = mix(ripple1, ripple2, 0.5); + + float ripple_strength = smoothstep(0.3, 1.0, dist) * 0.3; + float ripple_mod = 1.0 + ripple * ripple_strength; + + float depth_mix = dist * 0.7 + ripple * ripple_strength * 0.5; + vec3 base_color = mix(u_color_deep, u_color_shallow, clamp(depth_mix, 0.0, 1.0)); + + float specular = pow(1.0 - dist, 4.0) * 0.4; + base_color += u_shine_color * specular; + + float edge_fade = 1.0 - smoothstep(0.8, 1.0, dist); + + ALBEDO = base_color * ripple_mod; + ALPHA = edge_fade * (0.4 + u_level * 0.4); + EMISSION = base_color * ripple * ripple_strength * 0.15; +} diff --git a/client/godot/resources/world.json b/client/godot/resources/world.json new file mode 100755 index 0000000..b9a2fb2 --- /dev/null +++ b/client/godot/resources/world.json @@ -0,0 +1,821 @@ +{ + "version": "0.1", + "session_id": "demo-alpha-001", + "environment": { + "type": "MEADOW", + "biome": "TEMPERATE", + "climate": { + "temperature": 22.0, + "humidity": 0.6, + "rainfall": 0.4, + "wind_speed": 0.15, + "light_level": 0.85 + }, + "soil": { + "nitrogen": 0.7, + "phosphorus": 0.6, + "potassium": 0.5, + "moisture": 0.65, + "organic_matter": 0.4, + "ph": 6.8 + }, + "voxel_grid": { + "dimensions": [ + 32, + 32, + 32 + ], + "cell_size": 1.0 + }, + "water_sources": [ + { + "position": [ + 6.0, + 0.0, + 20.0 + ], + "radius": 3.0 + }, + { + "position": [ + 25.0, + 0.0, + 7.0 + ], + "radius": 2.0 + } + ] + }, + "model": { + "adapter": "mlp", + "seed": 42 + }, + "rates": { + "consumption": 2.0, + "hunger": 1.0, + "thirst": 1.0, + "growth": 1.0, + "reproduction": 1.0, + "water_replenishment": 0.4 + }, + "randomize": { + "jitter": 1.5, + "extra_grass": [ + 0, + 4 + ], + "extra_flowers": [ + 0, + 2 + ], + "transform": true + }, + "entities": [ + { + "id": "deer_01", + "type": "ANIMAL", + "species": "deer", + "sex": "female", + "position": [ + 16.0, + 0.0, + 14.0 + ], + "metadata": { + "diet": "herbivore", + "body_mass": 60.0, + "metabolism_rate": 1.0, + "sensory_range": 12.0, + "movement_speed": 3.0, + "lifespan": 800.0, + "reproduction_threshold": 0.8 + }, + "skeleton_id": "quadruped_medium" + }, + { + "id": "deer_02", + "type": "ANIMAL", + "species": "deer", + "sex": "male", + "position": [ + 20.0, + 0.0, + 18.0 + ], + "metadata": { + "diet": "herbivore", + "body_mass": 55.0, + "metabolism_rate": 1.1, + "sensory_range": 12.0, + "movement_speed": 3.2, + "lifespan": 800.0, + "reproduction_threshold": 0.8 + }, + "skeleton_id": "quadruped_medium" + }, + { + "id": "butterfly_01", + "type": "INSECT", + "species": "butterfly", + "sex": "female", + "position": [ + 10.0, + 0.0, + 8.0 + ], + "metadata": { + "diet": "herbivore", + "colony_size": 1, + "metabolism_rate": 0.6, + "pollination_range": 6.0, + "movement_speed": 2.0, + "lifespan": 150.0 + }, + "skeleton_id": "insect_wing" + }, + { + "id": "butterfly_02", + "type": "INSECT", + "species": "butterfly", + "sex": "male", + "position": [ + 18.0, + 0.0, + 10.0 + ], + "metadata": { + "diet": "herbivore", + "colony_size": 1, + "metabolism_rate": 0.6, + "pollination_range": 6.0, + "movement_speed": 2.0, + "lifespan": 150.0 + }, + "skeleton_id": "insect_wing" + }, + { + "id": "butterfly_03", + "type": "INSECT", + "species": "butterfly", + "sex": "female", + "position": [ + 14.0, + 0.0, + 20.0 + ], + "metadata": { + "diet": "herbivore", + "colony_size": 1, + "metabolism_rate": 0.6, + "pollination_range": 6.0, + "movement_speed": 2.0, + "lifespan": 150.0 + }, + "skeleton_id": "insect_wing" + }, + { + "id": "butterfly_04", + "type": "INSECT", + "species": "butterfly", + "sex": "male", + "position": [ + 22.0, + 0.0, + 18.0 + ], + "metadata": { + "diet": "herbivore", + "colony_size": 1, + "metabolism_rate": 0.6, + "pollination_range": 6.0, + "movement_speed": 2.0, + "lifespan": 150.0 + }, + "skeleton_id": "insect_wing" + }, + { + "id": "songbird_01", + "type": "BIRD", + "species": "songbird", + "sex": "female", + "position": [ + 14.0, + 0.0, + 6.0 + ], + "metadata": { + "diet": "omnivore", + "body_mass": 0.025, + "metabolism_rate": 0.8, + "sensory_range": 10.0, + "movement_speed": 4.0, + "lifespan": 600.0, + "reproduction_threshold": 0.7 + }, + "skeleton_id": "bird_small" + }, + { + "id": "songbird_02", + "type": "BIRD", + "species": "songbird", + "sex": "male", + "position": [ + 22.0, + 0.0, + 14.0 + ], + "metadata": { + "diet": "omnivore", + "body_mass": 0.025, + "metabolism_rate": 0.8, + "sensory_range": 10.0, + "movement_speed": 4.0, + "lifespan": 600.0, + "reproduction_threshold": 0.7 + }, + "skeleton_id": "bird_small" + }, + { + "id": "oak_01", + "type": "TREE", + "species": "meadow_oak", + "position": [ + 8.0, + 0.0, + 8.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.005, + "root_depth": 2.0, + "canopy_radius": 4.0, + "height_max": 12.0, + "trunk_radius": 0.6, + "shade_factor": 0.35, + "nutrient_demand": { + "nitrogen": 0.02, + "phosphorus": 0.01 + }, + "water_demand": 0.05 + } + }, + { + "id": "oak_02", + "type": "TREE", + "species": "meadow_oak", + "position": [ + 24.0, + 0.0, + 22.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.005, + "root_depth": 2.0, + "canopy_radius": 3.5, + "height_max": 10.0, + "trunk_radius": 0.5, + "shade_factor": 0.3, + "nutrient_demand": { + "nitrogen": 0.02, + "phosphorus": 0.01 + }, + "water_demand": 0.05 + } + }, + { + "id": "grass_01", + "type": "PLANT", + "species": "meadow_grass", + "position": [ + 12.0, + 0.0, + 12.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.06, + "root_depth": 0.1, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.005, + "phosphorus": 0.002 + }, + "water_demand": 0.02 + } + }, + { + "id": "grass_02", + "type": "PLANT", + "species": "meadow_grass", + "position": [ + 14.0, + 0.0, + 10.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.06, + "root_depth": 0.1, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.005, + "phosphorus": 0.002 + }, + "water_demand": 0.02 + } + }, + { + "id": "grass_03", + "type": "PLANT", + "species": "meadow_grass", + "position": [ + 16.0, + 0.0, + 16.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.06, + "root_depth": 0.1, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.005, + "phosphorus": 0.002 + }, + "water_demand": 0.02 + } + }, + { + "id": "grass_04", + "type": "PLANT", + "species": "meadow_grass", + "position": [ + 18.0, + 0.0, + 14.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.06, + "root_depth": 0.1, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.005, + "phosphorus": 0.002 + }, + "water_demand": 0.02 + } + }, + { + "id": "grass_05", + "type": "PLANT", + "species": "meadow_grass", + "position": [ + 20.0, + 0.0, + 12.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.06, + "root_depth": 0.1, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.005, + "phosphorus": 0.002 + }, + "water_demand": 0.02 + } + }, + { + "id": "grass_06", + "type": "PLANT", + "species": "meadow_grass", + "position": [ + 22.0, + 0.0, + 16.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.06, + "root_depth": 0.1, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.005, + "phosphorus": 0.002 + }, + "water_demand": 0.02 + } + }, + { + "id": "grass_07", + "type": "PLANT", + "species": "meadow_grass", + "position": [ + 10.0, + 0.0, + 14.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.06, + "root_depth": 0.1, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.005, + "phosphorus": 0.002 + }, + "water_demand": 0.02 + } + }, + { + "id": "grass_08", + "type": "PLANT", + "species": "meadow_grass", + "position": [ + 14.0, + 0.0, + 18.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.06, + "root_depth": 0.1, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.005, + "phosphorus": 0.002 + }, + "water_demand": 0.02 + } + }, + { + "id": "grass_09", + "type": "PLANT", + "species": "meadow_grass", + "position": [ + 16.0, + 0.0, + 20.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.06, + "root_depth": 0.1, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.005, + "phosphorus": 0.002 + }, + "water_demand": 0.02 + } + }, + { + "id": "grass_10", + "type": "PLANT", + "species": "meadow_grass", + "position": [ + 20.0, + 0.0, + 20.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.06, + "root_depth": 0.1, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.005, + "phosphorus": 0.002 + }, + "water_demand": 0.02 + } + }, + { + "id": "grass_11", + "type": "PLANT", + "species": "meadow_grass", + "position": [ + 24.0, + 0.0, + 14.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.06, + "root_depth": 0.1, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.005, + "phosphorus": 0.002 + }, + "water_demand": 0.02 + } + }, + { + "id": "grass_12", + "type": "PLANT", + "species": "meadow_grass", + "position": [ + 26.0, + 0.0, + 18.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.06, + "root_depth": 0.1, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.005, + "phosphorus": 0.002 + }, + "water_demand": 0.02 + } + }, + { + "id": "flower_01", + "type": "PLANT", + "species": "wildflower", + "position": [ + 11.0, + 0.0, + 6.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.09, + "root_depth": 0.15, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.008, + "phosphorus": 0.004 + }, + "water_demand": 0.025 + } + }, + { + "id": "flower_02", + "type": "PLANT", + "species": "wildflower", + "position": [ + 15.0, + 0.0, + 8.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.09, + "root_depth": 0.15, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.008, + "phosphorus": 0.004 + }, + "water_demand": 0.025 + } + }, + { + "id": "flower_03", + "type": "PLANT", + "species": "wildflower", + "position": [ + 19.0, + 0.0, + 9.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.09, + "root_depth": 0.15, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.008, + "phosphorus": 0.004 + }, + "water_demand": 0.025 + } + }, + { + "id": "flower_04", + "type": "PLANT", + "species": "wildflower", + "position": [ + 22.0, + 0.0, + 11.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.09, + "root_depth": 0.15, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.008, + "phosphorus": 0.004 + }, + "water_demand": 0.025 + } + }, + { + "id": "flower_05", + "type": "PLANT", + "species": "wildflower", + "position": [ + 13.0, + 0.0, + 11.0 + ], + "metadata": { + "metabolism": "photosynthetic", + "growth_rate": 0.09, + "root_depth": 0.15, + "canopy_radius": 0.0, + "nutrient_demand": { + "nitrogen": 0.008, + "phosphorus": 0.004 + }, + "water_demand": 0.025 + } + } + ], + "species_definitions": [ + { + "species_id": "deer", + "functional_group": "herbivore", + "entity_class": "ANIMAL", + "body_mass_kg": 80.0, + "locomotion": "quadruped", + "skeleton_id": "quadruped_medium", + "thermoregulation": "endotherm", + "diet_type": "herbivore", + "diet_breadth": [ + "graminoid", + "forb" + ], + "trophic_level": 2.0, + "reproductive_strategy": "K_selected", + "clutch_size": 1, + "generation_time_ticks": 5000, + "thermal_range": [ + 0, + 40 + ], + "drought_tolerance": 0.3, + "shade_tolerance": 0.3, + "sensory_range_multiplier": 1.0, + "movement_budget": 0.4, + "resource_tags": [] + }, + { + "species_id": "butterfly", + "functional_group": "pollinator", + "entity_class": "INSECT", + "body_mass_kg": 0.0005, + "locomotion": "flight_insect", + "skeleton_id": "insect_wing", + "thermoregulation": "ectotherm", + "diet_type": "nectarivore", + "diet_breadth": [ + "forb:fruiting" + ], + "trophic_level": 2.0, + "reproductive_strategy": "r_selected", + "clutch_size": 3, + "generation_time_ticks": 2000, + "thermal_range": [ + 10, + 35 + ], + "drought_tolerance": 0.1, + "shade_tolerance": 0.5, + "sensory_range_multiplier": 1.2, + "movement_budget": 0.6, + "resource_tags": [], + "floral_affinity": [ + "insect_generalist" + ] + }, + { + "species_id": "meadow_oak", + "functional_group": "producer", + "entity_class": "TREE", + "body_mass_kg": 5000.0, + "locomotion": "rooted", + "skeleton_id": null, + "thermoregulation": "autotroph", + "diet_type": "autotroph", + "diet_breadth": [], + "trophic_level": 1.0, + "reproductive_strategy": "K_selected", + "clutch_size": 1, + "generation_time_ticks": 20000, + "thermal_range": [ + -10, + 40 + ], + "drought_tolerance": 0.5, + "shade_tolerance": 0.2, + "sensory_range_multiplier": 0.0, + "movement_budget": 0.0, + "canopy_radius": 3.0, + "root_persistence": true, + "resource_tags": [ + "mast" + ] + }, + { + "species_id": "meadow_grass", + "functional_group": "producer", + "entity_class": "PLANT", + "body_mass_kg": 0.01, + "locomotion": "sessile", + "skeleton_id": null, + "thermoregulation": "autotroph", + "diet_type": "autotroph", + "diet_breadth": [], + "trophic_level": 1.0, + "reproductive_strategy": "r_selected", + "clutch_size": 2, + "generation_time_ticks": 500, + "thermal_range": [ + 5, + 35 + ], + "drought_tolerance": 0.2, + "shade_tolerance": 0.4, + "sensory_range_multiplier": 0.0, + "movement_budget": 0.0, + "spread_mode": "runner", + "spread_range": 2.0, + "spread_chance": 0.008, + "spread_cooldown": 80, + "root_persistence": true, + "resource_tags": [ + "graminoid" + ] + }, + { + "species_id": "wildflower", + "functional_group": "producer", + "entity_class": "PLANT", + "body_mass_kg": 0.05, + "locomotion": "sessile", + "skeleton_id": null, + "thermoregulation": "autotroph", + "diet_type": "autotroph", + "diet_breadth": [], + "trophic_level": 1.0, + "reproductive_strategy": "r_selected", + "clutch_size": 1, + "generation_time_ticks": 800, + "thermal_range": [ + 5, + 35 + ], + "drought_tolerance": 0.05, + "shade_tolerance": 0.3, + "sensory_range_multiplier": 0.0, + "movement_budget": 0.0, + "spread_mode": "runner", + "spread_range": 3.5, + "spread_chance": 0.005, + "spread_cooldown": 120, + "root_persistence": true, + "resource_tags": [ + "forb" + ], + "pollination_syndrome": "insect_generalist" + }, + { + "species_id": "songbird", + "functional_group": "insectivore", + "entity_class": "BIRD", + "body_mass_kg": 0.025, + "locomotion": "flight_bird", + "skeleton_id": "bird_small", + "thermoregulation": "endotherm", + "diet_type": "omnivore", + "diet_breadth": [ + "pollinator", + "forb:fruiting" + ], + "trophic_level": 2.5, + "reproductive_strategy": "r_selected", + "clutch_size": 1, + "generation_time_ticks": 3000, + "thermal_range": [ + 5, + 35 + ], + "drought_tolerance": 0.2, + "shade_tolerance": 0.6, + "sensory_range_multiplier": 2.0, + "movement_budget": 0.5, + "roost_affinity": ["meadow_oak"], + "resource_tags": [] + } + ] +} diff --git a/client/godot/scenes/hud.gd b/client/godot/scenes/hud.gd new file mode 100644 index 0000000..380e78d --- /dev/null +++ b/client/godot/scenes/hud.gd @@ -0,0 +1,49 @@ +# līlā — Godot 4.x 3D Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# scenes/hud.gd — HUD overlay: stats panel, event log, controls +extends CanvasLayer + + +@onready var stats_label: Label = $VBox/StatsLabel +@onready var event_log: RichTextLabel = $VBox/EventLog +@onready var rain_button: Button = $VBox/RainButton + +var _event_count: int = 0 +var _tick: int = 0 +var _fps: int = 0 +var _frame_count: int = 0 +var _fps_timer: float = 0.0 + + +func _process(delta: float) -> void: + _frame_count += 1 + _fps_timer += delta + if _fps_timer >= 1.0: + _fps = _frame_count / _fps_timer + _frame_count = 0 + _fps_timer = 0.0 + + stats_label.text = "Tick: %d | Entities: %d | Events: %d | FPS: %d" % [ + _tick, World.get_entity_count(), _event_count, _fps + ] + + +func update_tick(tick: int) -> void: + _tick = tick + + +func add_event(text: String) -> void: + _event_count += 1 + event_log.append_text("[color=ffcc66]%s[/color]\n" % text) + # Keep last 50 events + var max_lines: int = 50 + var lines: PackedStringArray = event_log.text.split("\n") + if lines.size() > max_lines: + event_log.set_text("\n".join(lines.slice(-max_lines))) + + +func on_rain_button_pressed() -> void: + WS.send_control("rain", {"intensity": 0.8}) + add_event("☔ Rain triggered!") diff --git a/client/godot/scenes/hud.tscn b/client/godot/scenes/hud.tscn new file mode 100644 index 0000000..8110168 --- /dev/null +++ b/client/godot/scenes/hud.tscn @@ -0,0 +1,38 @@ +[gd_scene load_steps=5 format=3 uid="uid://hud123"] + +[ext_resource type="Script" path="res://scenes/hud.gd" id="1_hud"] + +[node name="HUD" type="CanvasLayer"] +layer = 10 +script = ExtResource("1_hud") + +[node name="VBox" type="VBoxContainer" parent="."] +anchors_preset = 1 +anchor_right = 0.15000001 +anchor_bottom = 1.0 +grow_vertical = 2 +theme_override_constants/separation = 6 + +[node name="Panel" type="Panel" parent="VBox"] +layout_mode = 3 +anchors_preset = 7 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="StatsLabel" type="Label" parent="VBox"] +layout_mode = 1 +text = "Tick: 0 | Entities: 0 | Events: 0 | FPS: 0" +theme_override_font_sizes/font_size = 14 + +[node name="EventLog" type="RichTextLabel" parent="VBox"] +layout_mode = 1 +size_flags_vertical = 3 +text = "" +theme_override_font_sizes/font_size = 12 + +[node name="RainButton" type="Button" parent="VBox"] +layout_mode = 1 +text = "☔ Rain" +pressed.connect(funcref(get_node("/^/HUD"), "on_rain_button_pressed")) diff --git a/client/godot/scenes/main.gd b/client/godot/scenes/main.gd new file mode 100644 index 0000000..ed01764 --- /dev/null +++ b/client/godot/scenes/main.gd @@ -0,0 +1,273 @@ +# līlā — Godot 4.x 3D Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# scenes/main.gd — Main scene: 3D world view with orbit camera +# +# Grid coordinates map 1:1 to world X/Z; Y is height. +# Uses primitive-based MultiMeshInstance3D rendering per entity type. +extends Node3D + + +@onready var camera: Camera3D = $Camera +@onready var renderer: Node = $Renderer +@onready var ground_mi: MultiMeshInstance3D = $Ground +@onready var entity_parent: Node3D = $Entities +@onready var particle_instance: MultiMeshInstance3D = $Particles +@onready var hud: CanvasLayer = $HUD +@onready var stats_label: Label = $HUD/VBox/StatsLabel +@onready var event_log: RichTextLabel = $HUD/VBox/EventLog +@onready var rain_button: Button = $HUD/VBox/RainButton + +var _agency: Agency = Agency.new() +var _heartbeat: HeartbeatSender = HeartbeatSender.new() +var _particles: RefCounted +var _reconciliation: Reconciliation = Reconciliation.new() + +var _world_def: Dictionary = {} +var _session_started: bool = false +var _current_tick: int = 0 +var _event_count: int = 0 +var _fps: int = 0 +var _frame_count: int = 0 +var _fps_timer: float = 0.0 + +# Renderer state +var _type_meshes: Dictionary = {} +var _ground_mat: ShaderMaterial = null + + +func _ready() -> void: + LilaConstants.log("Lila Godot Client starting (3D — cube renderer)...") + + WS.session_started.connect(_on_session_started) + WS.tick_packet.connect(_on_tick_packet) + WS.world_json_ready.connect(_on_world_json_ready) + rain_button.pressed.connect(_on_rain_pressed) + + _particles = load("res://scripts/particles.gd").new() + + _setup_particles() + _setup_renderer() + + # Orbit target = center of grid + camera.target = Vector3( + float(LilaConstants.GRID_SIZE) / 2.0, + 0.0, + float(LilaConstants.GRID_SIZE) / 2.0 + ) + camera._update_position() + + +# ── Renderer setup ──────────────────────────────────────────────────── + +func _setup_renderer() -> void: + # Build composite ArrayMeshes for each entity type + var meshes: Dictionary = renderer.build_all_type_meshes() + + # Create InstancedMesh nodes under Entities parent + _type_meshes = renderer.setup_type_meshes(entity_parent, meshes) + + # Ground voxels — reads INSTANCE_CUSTOM for per-cell color + _ground_mat = renderer.make_ground_material() + ground_mi.material_override = _ground_mat + # Build voxel MultiMesh once — transforms are static, only colors change + var ground_mm: MultiMesh = renderer.build_ground_voxels() + ground_mi.multimesh = ground_mm + + +# ── Particle MultiMesh setup ────────────────────────────────────────── + +func _setup_particles() -> void: + var box: BoxMesh = BoxMesh.new() + box.size = Vector3(0.3, 0.3, 0.3) + + var mm: MultiMesh = MultiMesh.new() + mm.mesh = box + mm.transform_format = MultiMesh.TRANSFORM_3D + mm.use_custom_data = true + mm.instance_count = 500 + particle_instance.multimesh = mm + + # Particles need a material that reads INSTANCE_CUSTOM for per-instance colors. + particle_instance.material_override = renderer.make_particle_material() + + +func _update_particle_mesh() -> void: + var alive: Array = _particles.get_alive() + var count: int = alive.size() + var mm: MultiMesh = particle_instance.multimesh + if mm == null: + return + + mm.instance_count = count + var t: Transform3D + for i in count: + var p = alive[i] + t.origin = Vector3(p.position.x, 0.5, p.position.y) + var s: float = p.size * 2.0 + t.basis = Basis.from_scale(Vector3(s, s, s)) + mm.set_instance_transform(i, t) + var c: Color = p.color + c.a = maxf(0.0, p.life / p.max_life) + mm.set_instance_custom_data(i, c) + + +# ── Main loop ───────────────────────────────────────────────────────── + +func _process(delta: float) -> void: + # FPS counter + _frame_count += 1 + _fps_timer += delta + if _fps_timer >= 1.0: + _fps = _frame_count / _fps_timer + _frame_count = 0 + _fps_timer = 0.0 + _update_stats() + + if _session_started: + var now: float = Time.get_ticks_msec() / 1000.0 + var events: Array = _agency.step(World, delta) + for evt: Dictionary in events: + _heartbeat.queue_event(evt) + _heartbeat.tick(WS, World, now) + _particles.step(delta) + + # Rebuild meshes every frame + _build_ground() + _build_entities() + _update_particle_mesh() + + +## Update ground voxel colors (MultiMesh was built once in setup). +func _build_ground() -> void: + renderer.update_ground_voxels( + ground_mi.multimesh, World.moisture_grid, World.water_sources + ) + + +## Update all per-type InstancedMesh entities. +func _build_entities() -> void: + var entities: Array = World.get_alive() + renderer.update_entities(_type_meshes, entities) + + +# ── Input ───────────────────────────────────────────────────────────── + +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed: + if event.keycode == KEY_R: + WS.send_control("rain", {"intensity": 0.8}) + _add_hud_event("☔ Rain triggered!") + elif event.keycode == KEY_SPACE: + WS.send_control("pause") + _add_hud_event("⏸ Paused") + + +# ── WebSocket callbacks ──────────────────────────────────────────────── + +func _on_rain_pressed() -> void: + WS.send_control("rain", {"intensity": 0.8}) + _add_hud_event("☔ Rain triggered!") + + +func _on_world_json_ready(data: Dictionary) -> void: + _world_def = data + + +func _on_session_started(data: Dictionary) -> void: + LilaConstants.log("Session started: %s" % data.get("session_id", "")) + World.species_defs = data.get("species", {}) + _session_started = true + World.flush_dead() + + +func _on_tick_packet(data: Dictionary) -> void: + _current_tick = data.get("tick", _current_tick) + + for update: Dictionary in data.get("entity_updates", []): + World.apply_update(update) + for spawn: Dictionary in data.get("entity_spawns", []): + World.apply_spawn(spawn) + + for removal_id: String in data.get("entity_removals", []): + var ent = World.get_entity(removal_id) + var px: float = ent.x if ent != null else 0.0 + var pz: float = ent.z if ent != null else 0.0 + World.apply_removal(removal_id) + _particles.spawn(px, pz, "DEATH_NATURAL", 6) + + var voxels: Variant = data.get("voxel_deltas", null) + if voxels != null: + World.apply_voxel_deltas(voxels) + var waters: Variant = data.get("water_sources", null) + if waters != null: + World.apply_water_sources(waters) + + for evt: Dictionary in data.get("events", []): + var evt_type: String = evt.get("type", "") + var source_id: String = evt.get("source_id", "") + var pos: Variant = evt.get("position", [0, 0, 0]) + var px: float = pos[0] if pos is Array else 0.0 + var pz: float = pos[2] if pos is Array and pos.size() > 2 else 0.0 + + match evt_type.to_upper(): + "CONSUMPTION": + _particles.spawn(px, pz, "CONSUMPTION", 6) + _add_hud_event("🌿 " + source_id + " consumed") + "POLLINATION": + _particles.spawn(px, pz, "POLLINATION", 8) + _add_hud_event("🦋 " + source_id + " pollinated") + "DEATH_NATURAL", "DEATH_STARVE": + _particles.spawn(px, pz, "DEATH_NATURAL", 6) + _add_hud_event("💀 " + source_id + " died") + "REPRODUCTION": + _add_hud_event("🐣 " + source_id + " reproduced") + + _reconciliation.reconcile(World, _current_tick) + if _current_tick % 10 == 0: + World.flush_dead() + + # Debug: log entity positions every 10 ticks + if _current_tick % 10 == 0: + _log_entity_telemetry() + + +# ── HUD helpers ──────────────────────────────────────────────────────── + +func _update_stats() -> void: + stats_label.text = "Tick: %d | Entities: %d | Events: %d | FPS: %d" % [ + _current_tick, World.get_entity_count(), _event_count, _fps + ] + + +func _add_hud_event(text: String) -> void: + _event_count += 1 + event_log.append_text("[color=ffcc66]%s[/color]\n" % text) + var max_lines: int = 50 + var lines: PackedStringArray = event_log.text.split("\n") + if lines.size() > max_lines: + event_log.set_text("\n".join(lines.slice(-max_lines))) + + +# ── Telemetry / Debug ────────────────────────────────────────────────── + +func _log_entity_telemetry() -> void: + var mobile: Array = World.get_alive_mobile() + if mobile.is_empty(): + return + var log_line: String = "[telemetry] tick=%d entities=%d" % [_current_tick, mobile.size()] + for i in minf(5, mobile.size()): + var ent = mobile[i] + var divergence: float = sqrt( + (ent.x - ent.ref_x) ** 2 + (ent.z - ent.ref_z) ** 2 + ) + log_line += " | %s: local=(%.2f,%.2f) ref=(%.2f,%.2f) div=%.3f ack=%s queue=%d" % [ + ent.id, + ent.x, ent.z, + ent.ref_x, ent.ref_z, + divergence, + ent.ack, + ent.reconcile_queue.size(), + ] + LilaConstants.log(log_line) diff --git a/client/godot/scenes/main.tscn b/client/godot/scenes/main.tscn new file mode 100644 index 0000000..72a6528 --- /dev/null +++ b/client/godot/scenes/main.tscn @@ -0,0 +1,87 @@ +[gd_scene load_steps=4 format=3 uid="uid://lila_main"] + +[ext_resource type="Script" path="res://scenes/main.gd" id="1"] +[ext_resource type="Script" path="res://scripts/camera/orbit_camera.gd" id="2"] +[ext_resource type="Script" path="res://scripts/renderer.gd" id="3"] + +[sub_resource type="Environment" id="env1"] +background_mode = 1 +background_color = Color(0.059, 0.063, 0.059, 1) +ambient_light_source = 1 +ambient_light_color = Color(0.25, 0.25, 0.3, 1) +ambient_light_energy = 0.5 +tonemap_mode = 3 +tonemap_white = 6.0 +glow_enabled = true +glow_intensity = 0.3 +glow_bloom = 0.1 +fog_enabled = true +fog_light_color = Color(0.15, 0.18, 0.15, 1) +fog_density = 0.005 + +[node name="Main" type="Node3D"] +script = ExtResource("1") + +[node name="Camera" type="Camera3D" parent="."] +script = ExtResource("2") +fov = 50.0 + +[node name="SunLight" type="DirectionalLight3D" parent="."] +transform = Transform3D(0.866, -0.354, 0.354, 0, 0.707, 0.707, -0.5, -0.612, 0.612, 16, 40, 16) +shadow_enabled = true +light_color = Color(1, 0.97, 0.9, 1) +light_energy = 1.2 +directional_shadow_mode = 2 + +[node name="FillLight" type="DirectionalLight3D" parent="."] +transform = Transform3D(-0.866, 0.25, -0.433, 0, 0.866, 0.5, 0.5, 0.433, -0.75, 16, 10, 16) +light_color = Color(0.7, 0.8, 1.0, 1) +light_energy = 0.3 +shadow_enabled = false + +[node name="Renderer" type="Node" parent="."] +script = ExtResource("3") + +[node name="Ground" type="MultiMeshInstance3D" parent="."] +cast_shadow = 0 +layers = 1 + +[node name="Entities" type="Node3D" parent="."] + +[node name="Particles" type="MultiMeshInstance3D" parent="."] +cast_shadow = 0 +layers = 1 + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("env1") + +[node name="HUD" type="CanvasLayer" parent="."] +layer = 10 + +[node name="VBox" type="VBoxContainer" parent="HUD"] +anchors_preset = 1 +anchor_right = 0.15000001 +anchor_bottom = 1.0 +grow_vertical = 2 +theme_override_constants/separation = 6 + +[node name="StatsLabel" type="Label" parent="HUD/VBox"] +layout_mode = 1 +text = "Tick: 0 | Entities: 0 | Events: 0 | FPS: 0" +theme_override_font_sizes/font_size = 14 + +[node name="EventLog" type="RichTextLabel" parent="HUD/VBox"] +layout_mode = 1 +size_flags_vertical = 3 +bbcode_enabled = true +text = "" +theme_override_font_sizes/font_size = 12 + +[node name="RainButton" type="Button" parent="HUD/VBox"] +layout_mode = 1 +text = "☔ Rain" + +[node name="HelpLabel" type="Label" parent="HUD/VBox"] +layout_mode = 1 +text = "LMB: orbit | RMB: pan | Scroll: zoom | R: rain | Space: pause" +theme_override_font_sizes/font_size = 10 diff --git a/client/godot/scripts/agency.gd b/client/godot/scripts/agency.gd new file mode 100644 index 0000000..71711ae --- /dev/null +++ b/client/godot/scripts/agency.gd @@ -0,0 +1,406 @@ +# līlā — Godot 4.x 3D Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# scripts/agency.gd — Client-side agency engine +# +# Runs at 60 Hz between server ticks, mirroring browser agency.js and +# Python agency.py. The server is the nervous system (intent), the client +# is the body (execution). +# +# Evaluates behavior priority chains for all mobile entities (animals, +# birds, insects) and emits client-side interaction events. +class_name Agency + + +var _cooldowns: Dictionary = {} # "source_target" -> timestamp + + +## Step all mobile entities. Returns array of client events to send upstream. +func step(world: Node, delta: float) -> Array: + var events: Array = [] + var mobile: Array = world.get_alive_mobile() + var now: float = Time.get_ticks_msec() / 1000.0 + + for ent in mobile: + var ent_events: Array = _step_entity(ent, world, delta, now) + for evt in ent_events: + events.append(evt) + + return events + + +func _step_entity(ent, world: Node, delta: float, now: float) -> Array: + var events: Array = [] + + if not ent.alive: + return events + + # Check for reconcile target first + if ent.reconcile_idx < ent.reconcile_queue.size(): + ent.last_action_type = "reconciling" + _execute_reconcile(ent, delta) + return events + + # Evaluate behavior priority chain + var target: Vector2 = Vector2.ZERO + + if ent.state == "FLEEING": + var flee_result: Dictionary = _evaluate_fleeing(ent, world) + target = flee_result.get("target", Vector2.ZERO) + ent.last_action_type = "fleeing" + elif ent.state == "DRINKING" or (ent.can_drink and ent.drive.get("hydration", 1.0) < 0.3): + var drink_result: Dictionary = _evaluate_drinking(ent, world) + target = drink_result.get("target", Vector2.ZERO) + ent.last_action_type = "drinking" + elif ent.repro_eligible and ent.drive.get("reproductive_drive", 0.0) > 0.5: + var mate_result: Dictionary = _evaluate_mate_seeking(ent, world) + target = mate_result.get("target", Vector2.ZERO) + ent.last_action_type = "seek_mate" + elif ent.state == "FORAGING" and ent.can_consume: + var forage_result: Dictionary = _evaluate_foraging(ent, world) + target = forage_result.get("target", Vector2.ZERO) + ent.last_action_type = "foraging" + events.append_array(forage_result.get("events", [])) + elif ent.state == "HUNTING" and ent.can_predate: + var hunt_result: Dictionary = _evaluate_hunting(ent, world) + target = hunt_result.get("target", Vector2.ZERO) + ent.last_action_type = "hunting" + events.append_array(hunt_result.get("events", [])) + elif ent.can_pollinate: + var poll_result: Dictionary = _evaluate_pollination(ent, world) + target = poll_result.get("target", Vector2.ZERO) + ent.last_action_type = "pollinating" + events.append_array(poll_result.get("events", [])) + elif ent.type == "BIRD" and (ent.state == "RESTING" or ent.state == "IDLE"): + var roost_result: Dictionary = _evaluate_roosting(ent, world) + target = roost_result.get("target", Vector2.ZERO) + ent.last_action_type = "roosting" + else: + target = _evaluate_wandering(ent, delta) + ent.last_action_type = "wander" + + # Move toward target + if target != Vector2.ZERO: + _move_toward(ent, target, delta, world) + + # Gravity well: gentle pull toward server reference position + # Multiplied by delta for frame-rate independence (mirrors Python/browser) + var speed_factor: float = ent.sync_speed + var dx_gw: float = ent.ref_x - ent.x + var dz_gw: float = ent.ref_z - ent.z + var dist_gw: float = sqrt(dx_gw * dx_gw + dz_gw * dz_gw) + if dist_gw > 0.2: # Skip if already close enough (mirrors Python/browser threshold) + var nudge: float = LilaConstants.GRAVITY_WELL_FACTOR * speed_factor * delta + ent.x += dx_gw * nudge + ent.z += dz_gw * nudge + + # Clamp to grid bounds + ent.x = clampf(ent.x, 0.0, float(LilaConstants.GRID_SIZE - 1)) + ent.z = clampf(ent.z, 0.0, float(LilaConstants.GRID_SIZE - 1)) + + return events + + +func _execute_reconcile(ent, delta: float) -> void: + if ent.reconcile_idx >= ent.reconcile_queue.size(): + ent.reconcile_queue.clear() + ent.reconcile_idx = 0 + return + + var target: Vector2 = ent.reconcile_queue[ent.reconcile_idx] + var dx: float = target.x - ent.x + var dz: float = target.y - ent.z + var dist: float = sqrt(dx * dx + dz * dz) + + if dist < LilaConstants.ARRIVAL_DISTANCE: + ent.reconcile_idx += 1 + return + + # Spiral meander toward target (organic movement) + var speed: float = 2.0 * ent.sync_speed + var target_angle: float = atan2(dz, dx) + var wobble: float = sin(Time.get_ticks_msec() / 200.0 + ent.sync_phase) * 0.5 + var move_angle: float = target_angle + wobble + var move_x: float = cos(move_angle) * speed * delta + var move_z: float = sin(move_angle) * speed * delta + + ent.x += move_x + ent.z += move_z + + # Smoothly interpolate facing direction (no instant snaps) + var angle_diff: float = wrapf(target_angle - ent.facing_angle, -PI, PI) + ent.facing_angle += clampf(angle_diff, -LilaConstants.TURN_SPEED * delta, LilaConstants.TURN_SPEED * delta) + + +func _evaluate_fleeing(ent, world: Node) -> Dictionary: + # Find nearest threat based on species definitions + var species_def: Dictionary = world.species_defs.get(ent.species, {}) + var flee_targets: Array = species_def.get("flee_targets", []) + + if flee_targets.is_empty(): + return {"target": Vector2.ZERO} + + var best: Variant = world.find_nearest(ent.x, ent.z, PackedStringArray(["ANIMAL", "BIRD"])) + if best == null or flee_targets.has(best.species): + if best != null: + # Flee away from threat + var away_x: float = ent.x - best.x + var away_z: float = ent.z - best.z + var len: float = sqrt(away_x * away_x + away_z * away_z) + if len > 0.01: + away_x /= len + away_z /= len + # Clamp to grid + var target_x: float = clampf(ent.x + away_x * 10.0, 0.0, float(LilaConstants.GRID_SIZE - 1)) + var target_z: float = clampf(ent.z + away_z * 10.0, 0.0, float(LilaConstants.GRID_SIZE - 1)) + return {"target": Vector2(target_x, target_z)} + + return {"target": Vector2.ZERO} + + +func _evaluate_drinking(ent, world: Node) -> Dictionary: + var water: Dictionary = world.find_nearest_water(ent.x, ent.z) + if water.is_empty(): + return {"target": Vector2.ZERO} + + var pos: Vector2 = water.position + var radius: float = water.get("radius", 3.0) + # Move to water source edge + var dx: float = pos.x - ent.x + var dz: float = pos.y - ent.z + var dist: float = sqrt(dx * dx + dz * dz) + + if dist <= radius: + return {"target": Vector2.ZERO} # Already at water + + var edge_x: float = pos.x - (dx / dist) * radius + var edge_z: float = pos.y - (dz / dist) * radius + return {"target": Vector2(edge_x, edge_z)} + + +func _evaluate_mate_seeking(ent, world: Node) -> Dictionary: + var mate: Variant = world.find_nearest_mate(ent) + if mate == null: + return {"target": Vector2.ZERO} + + var dx: float = mate.x - ent.x + var dz: float = mate.z - ent.z + var dist: float = sqrt(dx * dx + dz * dz) + + if dist < LilaConstants.ARRIVAL_DISTANCE: + # Check cooldown and fire repro event + var key: String = ent.id + "_" + mate.id + var last: float = _cooldowns.get(key, 0.0) + var now: float = Time.get_ticks_msec() / 1000.0 + if now - last > LilaConstants.INTERACTION_COOLDOWN: + _cooldowns[key] = now + var events: Array = [] + events.append({ + "type": "repro", + "parent_id": ent.id, + "offspring_count": 1, + "client_position": [ent.x, 0.0, ent.z], + }) + return {"target": Vector2.ZERO, "events": events} + + return {"target": Vector2(mate.x, mate.z)} + + +func _evaluate_foraging(ent, world: Node) -> Dictionary: + var species_def: Dictionary = world.species_defs.get(ent.species, {}) + var diet_order: Array = species_def.get("diet_order", []) + + var events: Array = [] + var best_target: Vector2 = Vector2.ZERO + var best_dist: float = INF + var best_entity: Variant = null + + for diet_entry in diet_order: + var target_species: String = diet_entry[0] + var candidates: Array = world.get_alive() + for candidate in candidates: + if candidate.species == target_species: + var dist: float = sqrt((ent.x - candidate.x) ** 2 + (ent.z - candidate.z) ** 2) + if dist < best_dist: + best_dist = dist + best_target = Vector2(candidate.x, candidate.z) + best_entity = candidate + + if best_entity != null and best_dist < LilaConstants.ARRIVAL_DISTANCE: + # Check cooldown and fire consumption event + var key: String = ent.id + "_" + best_entity.id + var last: float = _cooldowns.get(key, 0.0) + var now: float = Time.get_ticks_msec() / 1000.0 + if now - last > LilaConstants.INTERACTION_COOLDOWN: + _cooldowns[key] = now + events.append({ + "type": "consumption", + "source_id": ent.id, + "target_id": best_entity.id, + "position": [ent.x, 0.0, ent.z], + }) + return {"target": Vector2.ZERO, "events": events} + + if best_target != Vector2.ZERO: + return {"target": best_target, "events": events} + return {"target": Vector2.ZERO, "events": events} + + +func _evaluate_hunting(ent, world: Node) -> Dictionary: + var species_def: Dictionary = world.species_defs.get(ent.species, {}) + var diet_order: Array = species_def.get("diet_order", []) + + var events: Array = [] + var best_target: Vector2 = Vector2.ZERO + var best_dist: float = INF + var best_entity: Variant = null + + # Find prey from diet breadth + for diet_entry in diet_order: + var target_species: String = diet_entry[0] + var candidates: Array = world.get_alive_mobile() + for candidate in candidates: + if candidate.species == target_species: + var dist: float = sqrt((ent.x - candidate.x) ** 2 + (ent.z - candidate.z) ** 2) + if dist < best_dist: + best_dist = dist + best_target = Vector2(candidate.x, candidate.z) + best_entity = candidate + + if best_entity != null and best_dist < LilaConstants.ARRIVAL_DISTANCE: + var key: String = ent.id + "_" + best_entity.id + var last: float = _cooldowns.get(key, 0.0) + var now: float = Time.get_ticks_msec() / 1000.0 + if now - last > LilaConstants.INTERACTION_COOLDOWN: + _cooldowns[key] = now + events.append({ + "type": "predation", + "source_id": ent.id, + "target_id": best_entity.id, + "position": [ent.x, 0.0, ent.z], + }) + return {"target": Vector2.ZERO, "events": events} + + if best_target != Vector2.ZERO: + return {"target": best_target, "events": events} + return {"target": Vector2.ZERO, "events": events} + + +func _evaluate_pollination(ent, world: Node) -> Dictionary: + var species_def: Dictionary = world.species_defs.get(ent.species, {}) + var poll_targets: Array = species_def.get("pollination_targets", []) + + var events: Array = [] + var best_target: Vector2 = Vector2.ZERO + var best_dist: float = INF + var best_entity: Variant = null + + var candidates: Array = world.get_alive() + for candidate in candidates: + if candidate.type == "PLANT" and candidate.state == "FRUITING": + if poll_targets.is_empty() or poll_targets.has(candidate.species): + var dist: float = sqrt((ent.x - candidate.x) ** 2 + (ent.z - candidate.z) ** 2) + if dist < best_dist: + best_dist = dist + best_target = Vector2(candidate.x, candidate.z) + best_entity = candidate + + if best_entity != null and best_dist < LilaConstants.ARRIVAL_DISTANCE: + var key: String = ent.id + "_" + best_entity.id + var last: float = _cooldowns.get(key, 0.0) + var now: float = Time.get_ticks_msec() / 1000.0 + if now - last > LilaConstants.INTERACTION_COOLDOWN: + _cooldowns[key] = now + events.append({ + "type": "pollination", + "source_id": ent.id, + "target_id": best_entity.id, + "position": [ent.x, 0.0, ent.z], + }) + return {"target": Vector2.ZERO, "events": events} + + if best_target != Vector2.ZERO: + return {"target": best_target, "events": events} + return {"target": Vector2.ZERO, "events": events} + + +func _evaluate_wandering(ent, delta: float) -> Vector2: + # Reuse existing wander target if still valid and not reached (mirrors Python/browser) + if ent.has_target and ent.last_action_type == "wander": + var dtx: float = ent.target_x - ent.x + var dtz: float = ent.target_z - ent.z + if sqrt(dtx * dtx + dtz * dtz) > 0.5: + return Vector2(ent.target_x, ent.target_z) + + # Pick new wander target modulated by motion latent + var latent: PackedFloat32Array = ent.motion_latent + var pace: float = 1.0 + if latent.size() >= 1: + pace = 0.5 + latent[0] * 0.5 # Map to 0-1 range roughly + + var wander_range: float = LilaConstants.WANDER_MARGIN * pace + var angle: float = randf() * TAU + var target_x: float = clampf(ent.x + cos(angle) * wander_range, 0.0, float(LilaConstants.GRID_SIZE - 1)) + var target_z: float = clampf(ent.z + sin(angle) * wander_range, 0.0, float(LilaConstants.GRID_SIZE - 1)) + + return Vector2(target_x, target_z) + + +func _evaluate_roosting(ent, world: Node) -> Dictionary: + # Find nearest tree to roost near + var best: Variant = world.find_nearest(ent.x, ent.z, PackedStringArray(["TREE"])) + if best == null: + return {"target": Vector2.ZERO} + + var dx: float = best.x - ent.x + var dz: float = best.z - ent.z + var dist: float = sqrt(dx * dx + dz * dz) + + if dist < LilaConstants.ARRIVAL_DISTANCE: + # Already near a tree — hover nearby instead of orbiting it + var wobble: float = sin(Time.get_ticks_msec() / 500.0 + ent.sync_phase) * 0.5 + var hover_x: float = best.x + cos(wobble) * 1.5 + var hover_z: float = best.z + sin(wobble) * 1.5 + return {"target": Vector2(hover_x, hover_z)} + + return {"target": Vector2(best.x, best.z)} + + +func _move_toward(ent, target: Vector2, delta: float, world: Node) -> void: + var dx: float = target.x - ent.x + var dz: float = target.y - ent.z + var dist: float = sqrt(dx * dx + dz * dz) + + # Speed from species definition + var species_def: Dictionary = world.species_defs.get(ent.species, {}) + var max_speed: float = species_def.get("movement_speed", 2.0) + + # Modulate by motion latent pace + var latent: PackedFloat32Array = ent.motion_latent + var pace: float = 1.0 + if latent.size() >= 1: + pace = 0.5 + latent[0] * 0.5 + + var move_dist: float = max_speed * pace * delta + move_dist = minf(move_dist, dist) + + if dist < LilaConstants.ARRIVAL_DISTANCE: + ent.has_target = false + return + + var move_x: float = (dx / dist) * move_dist + var move_z: float = (dz / dist) * move_dist + + ent.x += move_x + ent.z += move_z + + # Smoothly interpolate facing direction (no instant snaps) + var target_angle: float = atan2(dz, dx) + var angle_diff: float = wrapf(target_angle - ent.facing_angle, -PI, PI) + ent.facing_angle += clampf(angle_diff, -LilaConstants.TURN_SPEED * delta, LilaConstants.TURN_SPEED * delta) + + # Track target for wander persistence (mirrors Python/browser hasTarget) + ent.target_x = target.x + ent.target_z = target.y + ent.has_target = true diff --git a/client/godot/scripts/autoloads/world_model.gd b/client/godot/scripts/autoloads/world_model.gd new file mode 100644 index 0000000..6039d5e --- /dev/null +++ b/client/godot/scripts/autoloads/world_model.gd @@ -0,0 +1,335 @@ +# līlā — Godot 4.x 3D Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# scripts/autoloads/world_model.gd — Client-side world model +# +# Entity registry, spatial queries, and environment state. Mirrors browser +# world-model.js and Python world_model.py. +extends Node + + +## Signals emitted for renderer consumption +signal entity_spawned(entity_id: String) +signal entity_removed(entity_id: String) +signal entities_updated() + + +## WorldEntity — per-entity client state +class WorldEntity: + var id: String + var type: String + var species: String + var skeleton_id: String = "" + + ## Local (agency) position in grid units + var x: float = 0.0 + var z: float = 0.0 + + ## Server reference position (gravity well anchor) + var ref_x: float = 0.0 + var ref_z: float = 0.0 + + var state: String = "IDLE" + var drive: Dictionary = {} + var motion_latent: PackedFloat32Array = PackedFloat32Array([0.0, 0.0, 0.0, 0.0]) + + ## Eligibility flags from server + var can_consume: bool = false + var can_predate: bool = false + var can_pollinate: bool = false + var repro_eligible: bool = false + var can_drink: bool = false + + ## Acknowledgment from server + var ack: bool = false + + ## Reconciliation — queue of target positions consumed smoothly by agency + var reconcile_queue: PackedVector2Array = PackedVector2Array() + var reconcile_idx: int = 0 + + ## Sync personality (derived from id hash) + var sync_phase: int = 0 + var sync_speed: float = 1.0 + + ## Rendering + var facing_angle: float = 0.0 + var alive: bool = true + + ## Wander target persistence (mirrors browser/Python hasTarget + targetX/targetZ) + var target_x: float = 0.0 + var target_z: float = 0.0 + var has_target: bool = false + var last_action_type: String = "" + + ## Reconciliation tracking (mirrors browser/Python _lastReconciledTick) + var last_reconciled_tick: int = -10 + + func _init(entity_id: String, etype: String, especies: String): + id = entity_id + type = etype + species = especies + # Deterministic sync personality from id hash + var hash_val: int = hash(entity_id) + var abs_hash: int = absi(hash_val) + sync_phase = abs_hash % 4 + sync_speed = 0.4 + (abs_hash % 10) * 0.06 + + +## Entity registry keyed by entity id +var entities: Dictionary = {} + +## Species definitions from session_started +var species_defs: Dictionary = {} + +## Moisture grid (GRID_SIZE x GRID_SIZE) +var moisture_grid: PackedFloat32Array + +## Water sources: array of {position: Vector2, radius: float, water_level: float} +var water_sources: Array[Dictionary] = [] + + +func _ready() -> void: + var size: int = LilaConstants.GRID_SIZE * LilaConstants.GRID_SIZE + moisture_grid = PackedFloat32Array() + moisture_grid.resize(size) + moisture_grid.fill(0.5) + + +## Apply a single entity update from a tick packet. +func apply_update(data: Dictionary) -> void: + var eid: String = data.get("id", "") + if eid.is_empty(): + return + + var is_new: bool = false + var ent: WorldEntity = entities.get(eid) + if ent == null: + is_new = true + ent = WorldEntity.new(eid, data.get("type", "ANIMAL"), data.get("species", "unknown")) + entities[eid] = ent + + var pos: Variant = data.get("ref_position", [0, 0, 0]) + ent.ref_x = _vec_x(pos) + ent.ref_z = _vec_z(pos) + + # Initialize local position from server ref on first contact + # (mirrors Python client's apply_update behavior) + if is_new: + ent.x = ent.ref_x + ent.z = ent.ref_z + + ent.state = data.get("state", ent.state) + + var drive_data: Dictionary = data.get("drive", {}) + if not drive_data.is_empty(): + ent.drive = drive_data + + var latent: Variant = data.get("motion_latent", []) + if latent is Array: + ent.motion_latent = PackedFloat32Array(latent) + + ent.can_consume = data.get("_can_consume", false) + ent.can_predate = data.get("_can_predate", false) + ent.can_pollinate = data.get("_can_pollinate", false) + ent.repro_eligible = data.get("_repro_eligible", false) + ent.can_drink = data.get("_can_drink", false) + ent.ack = data.get("_ack", false) + + +## Apply entity spawn from tick packet. +func apply_spawn(data: Dictionary) -> void: + var eid: String = data.get("id", "") + var etype: String = data.get("type", "ANIMAL") + var especies: String = data.get("species", "unknown") + + var ent: WorldEntity = WorldEntity.new(eid, etype, especies) + ent.skeleton_id = data.get("skeleton_id", "") + + var pos: Variant = data.get("ref_position", [0, 0, 0]) + ent.ref_x = _vec_x(pos) + ent.ref_z = _vec_z(pos) + ent.x = ent.ref_x + ent.z = ent.ref_z + + ent.state = data.get("state", "IDLE") + + var drive_data: Dictionary = data.get("drive", {}) + if not drive_data.is_empty(): + ent.drive = drive_data + + var latent: Variant = data.get("motion_latent", []) + if latent is Array: + ent.motion_latent = PackedFloat32Array(latent) + + entities[eid] = ent + entity_spawned.emit(eid) + + +## Remove an entity by id. +func apply_removal(eid: String) -> void: + if entities.has(eid): + entities[eid].alive = false + entity_removed.emit(eid) + + +## Remove all dead entities from registry. +func flush_dead() -> void: + var dead: PackedStringArray = PackedStringArray() + for eid in entities: + if not entities[eid].alive: + dead.append(eid) + for eid in dead: + entities.erase(eid) + + +## Apply voxel deltas for moisture layer. +func apply_voxel_deltas(deltas: Variant) -> void: + if deltas == null: + return + # Can be Dictionary keyed by layer, or direct {coord: value} + var moisture_deltas: Dictionary = {} + if deltas is Dictionary: + moisture_deltas = deltas.get("moisture", deltas) + if moisture_deltas == null: + moisture_deltas = deltas + + for key: String in moisture_deltas: + var coords: PackedInt32Array = _parse_coord_key(key) + if coords.size() == 3: + var idx: int = coords[0] + coords[1] * LilaConstants.GRID_SIZE + coords[2] * LilaConstants.GRID_SIZE * LilaConstants.GRID_SIZE + if idx >= 0 and idx < moisture_grid.size(): + moisture_grid[idx] = moisture_deltas[key] + + +## Apply water sources from tick packet. +func apply_water_sources(sources: Variant) -> void: + if sources == null or sources is not Array: + return + water_sources.clear() + for src: Dictionary in sources: + var pos: Variant = src.get("position", [0, 0, 0]) + var current_radius: float = src.get("radius", 3.0) + var level: float = src.get("water_level", 1.0) + # Derive max_radius so the renderer can use the full footprint for blending + # (server sets radius = max_radius * water_level each tick) + var max_radius: float = current_radius / level if level > 0.01 else current_radius + water_sources.append({ + "position": Vector2(_vec_x(pos), _vec_z(pos)), + "radius": current_radius, + "max_radius": max_radius, + "water_level": level, + }) + + +## Get alive mobile entities (animals, birds, insects). +func get_alive_mobile() -> Array[WorldEntity]: + var result: Array[WorldEntity] = [] + for eid in entities: + var ent: WorldEntity = entities[eid] + if ent.alive and ent.type in ["ANIMAL", "BIRD", "INSECT"]: + result.append(ent) + return result + + +## Get all alive entities. +func get_alive() -> Array[WorldEntity]: + var result: Array[WorldEntity] = [] + for eid in entities: + if entities[eid].alive: + result.append(entities[eid]) + return result + + +## Find nearest alive entity of given type(s) from position (x, z). +func find_nearest(pos_x: float, pos_z: float, type_filter: PackedStringArray = PackedStringArray()) -> WorldEntity: + var best: WorldEntity = null + var best_dist: float = INF + for eid in entities: + var ent: WorldEntity = entities[eid] + if not ent.alive: + continue + if not type_filter.is_empty() and not type_filter.has(ent.type): + continue + var dist: float = _dist(pos_x, pos_z, ent.x, ent.z) + if dist < best_dist: + best_dist = dist + best = ent + return best + + +## Find nearest entity of a specific species. +func find_nearest_species(pos_x: float, pos_z: float, species_name: String) -> WorldEntity: + var best: WorldEntity = null + var best_dist: float = INF + for eid in entities: + var ent: WorldEntity = entities[eid] + if not ent.alive or ent.species != species_name: + continue + var dist: float = _dist(pos_x, pos_z, ent.x, ent.z) + if dist < best_dist: + best_dist = dist + best = ent + return best + + +## Find nearest mate (same species, alive) for an entity. +func find_nearest_mate(ent: WorldEntity) -> WorldEntity: + var best: WorldEntity = null + var best_dist: float = INF + for eid in entities: + var other: WorldEntity = entities[eid] + if not other.alive or other.id == ent.id or other.species != ent.species: + continue + var dist: float = _dist(ent.x, ent.z, other.x, other.z) + if dist < best_dist: + best_dist = dist + best = other + return best + + +## Find nearest water source from position. +func find_nearest_water(pos_x: float, pos_z: float) -> Dictionary: + var best: Dictionary = {} + var best_dist: float = INF + for src: Dictionary in water_sources: + var pos: Vector2 = src.position + var dist: float = sqrt((pos_x - pos.x) ** 2 + (pos_z - pos.y) ** 2) + if dist < best_dist: + best_dist = dist + best = src + return best + + +## Get entity by id. +func get_entity(eid: String) -> WorldEntity: + return entities.get(eid) + + +func get_entity_count() -> int: + return entities.size() + + +# -- Helpers -- + +static func _vec_x(v: Variant) -> float: + if v is Array and v.size() >= 1: + return float(v[0]) + return 0.0 + +static func _vec_z(v: Variant) -> float: + if v is Array and v.size() >= 3: + return float(v[2]) + elif v is Array and v.size() >= 2: + return float(v[1]) + return 0.0 + +static func _dist(x1: float, z1: float, x2: float, z2: float) -> float: + return sqrt((x1 - x2) ** 2 + (z1 - z2) ** 2) + +static func _parse_coord_key(key: String) -> PackedInt32Array: + var parts: PackedStringArray = key.split(",", false) + var result: PackedInt32Array = PackedInt32Array() + for p in parts: + result.append(p.to_int()) + return result diff --git a/client/godot/scripts/autoloads/ws_client.gd b/client/godot/scripts/autoloads/ws_client.gd new file mode 100644 index 0000000..4d381a7 --- /dev/null +++ b/client/godot/scripts/autoloads/ws_client.gd @@ -0,0 +1,207 @@ +# līlā — Godot 4.x 3D Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# scripts/autoloads/ws_client.gd — WebSocket client for līlā server +# +# Handles /ws WebSocket connection, /world.json HTTP fetch, and auto-reconnect. +extends Node + + +## Signals +signal connected +signal disconnected +signal session_started(data: Dictionary) +signal tick_packet(data: Dictionary) +signal world_json_ready(data: Dictionary) + + +## Persisted world definition string (resent on every reconnect). +var _world_def_json: String = "" + + +## State +var _ws: WebSocketPeer +var _is_connected: bool = false +var _is_connecting: bool = false +var _reconnect_timer: float = 0.0 +var _pending_sends: Array[String] = [] + + +## Host and port (overridable via scene properties or project settings) +var host: String = LilaConstants.DEFAULT_HOST +var port: int = LilaConstants.DEFAULT_PORT + + +func _ready() -> void: + _ws = WebSocketPeer.new() + _load_world_json_local() + _setup_poll_timer() + + +## Load world.json from local resources (avoids HTTP issues with websockets server). +func _load_world_json_local() -> void: + var file: FileAccess = FileAccess.open("res://resources/world.json", FileAccess.READ) + if file == null: + push_error("Cannot open res://resources/world.json") + return + var text: String = file.get_as_text() + file.close() + _world_def_json = text.strip_edges() + + var json_conv: JSON = JSON.new() + var err: Error = json_conv.parse(text) + if err == OK: + world_json_ready.emit(json_conv.data) + LilaConstants.log("World JSON loaded from local file") + else: + push_error("Failed to parse world.json: ", json_conv.get_error_message()) + + +## Dedicated poll timer ensures WebSocket I/O is serviced even when +## the render loop stalls (e.g. heavy voxel rebuild frames). +## Server pings every 20s with 10s timeout, so 100ms polling is safe. +var _poll_timer: Timer +func _setup_poll_timer() -> void: + _poll_timer = Timer.new() + _poll_timer.wait_time = 0.1 + _poll_timer.one_shot = false + _poll_timer.timeout.connect(_on_poll_timer) + add_child(_poll_timer) + _poll_timer.start() + +func _on_poll_timer() -> void: + if _ws == null: + return + _ws.poll() + var status: int = _ws.get_ready_state() + + if status == WebSocketPeer.STATE_OPEN and _is_connected: + # Read incoming messages + while _ws.get_available_packet_count() > 0: + var packet: PackedByteArray = _ws.get_packet() + var text: String = packet.get_string_from_utf8() + _dispatch(text) + # Flush pending sends + while _pending_sends.size() > 0: + var msg: String = _pending_sends.pop_front() + var err: Error = _ws.send_text(msg) + if err != OK: + push_error("WebSocket send failed: ", err) + break + elif status == WebSocketPeer.STATE_CLOSED: + if _is_connected: + var code: int = _ws.get_close_code() + _is_connected = false + _reconnect_timer = LilaConstants.RECONNECT_DELAY + disconnected.emit() + LilaConstants.log("WebSocket closed (code %d), reconnecting in %.1fs" % [code, LilaConstants.RECONNECT_DELAY]) + + +func _process(delta: float) -> void: + if not _is_connected and not _is_connecting: + _reconnect_timer -= delta + if _reconnect_timer <= 0: + _connect_to_server() + + +func _connect_to_server() -> void: + _is_connecting = true + var url: String = "ws://" + host + ":" + str(port) + "/ws" + LilaConstants.log("Connecting to %s" % url) + + # Fresh WebSocketPeer to avoid stale state from previous connection + _ws = WebSocketPeer.new() + + var err: Error = _ws.connect_to_url(url) + if err != OK: + push_error("Failed to connect to WebSocket: ", err) + _reconnect_timer = LilaConstants.RECONNECT_DELAY + _is_connecting = false + return + + # Wait for actual open + var wait: float = 0.0 + while _ws.get_ready_state() != WebSocketPeer.STATE_OPEN and wait < 5.0: + _ws.poll() + await get_tree().create_timer(0.05).timeout + wait += 0.05 + + if _ws.get_ready_state() == WebSocketPeer.STATE_OPEN: + _is_connected = true + _is_connecting = false + connected.emit() + LilaConstants.log("WebSocket connected") + + # Send world definition (always — works on reconnect) + if not _world_def_json.is_empty(): + _ws.send_text(_world_def_json) + LilaConstants.log("World definition sent") + + # Flush any pending sends from before connection + for msg in _pending_sends: + _ws.send_text(msg) + _pending_sends.clear() + else: + _is_connecting = false + _reconnect_timer = LilaConstants.RECONNECT_DELAY + + +func _fetch_world_json() -> void: + # Send a control request over WebSocket to get world definition. + # Server sends it back as the first message, or we fetch via WS. + # For now: send empty world definition request, server responds with session_started. + # The browser client fetches via HTTP — but Godot's HTTPRequest has issues + # with websockets library's HTTP passthrough, so we skip HTTP entirely + # and send world def directly if we have one. + pass + + +func _dispatch(text: String) -> void: + var json_conv: JSON = JSON.new() + var err: Error = json_conv.parse(text) + if err != OK: + push_error("Invalid JSON from server: ", json_conv.get_error_message()) + return + + var data: Dictionary = json_conv.data + var type: String = data.get("type", "") + + if type == "session_started": + session_started.emit(data) + elif type.is_empty() and data.has("tick"): + # Tick packets don't have a "type" field, they have "tick" + tick_packet.emit(data) + else: + # Pass through unknown types + tick_packet.emit(data) + + +## Send a JSON message to the server. +func send(data: Dictionary) -> void: + var json_str: String = JSON.stringify(data) + if _is_connected and _ws.get_ready_state() == WebSocketPeer.STATE_OPEN: + _ws.send_text(json_str) + else: + _pending_sends.append(json_str) + + +## Send world definition to start a session. +func send_world_definition(world_def: Dictionary) -> void: + send(world_def) + + +## Send heartbeat with positions and events. +func send_heartbeat(positions: Dictionary, events: Array) -> void: + send({ + "type": "heartbeat", + "positions": positions, + "events": events, + }) + + +## Send a control message (pause, resume, shutdown, rain). +func send_control(type: String, extra: Dictionary = {}) -> void: + var msg: Dictionary = {"type": type} + msg.merge(extra) + send(msg) diff --git a/client/godot/scripts/camera/orbit_camera.gd b/client/godot/scripts/camera/orbit_camera.gd new file mode 100644 index 0000000..5cb6640 --- /dev/null +++ b/client/godot/scripts/camera/orbit_camera.gd @@ -0,0 +1,115 @@ +# līlā — Godot 4.x 3D Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# scripts/camera/orbit_camera.gd — Orbit / trackball camera controller +# +# Attach as a script on a Camera3D node. +# +# Controls: +# Left mouse drag — orbit around target +# Right mouse drag — pan target +# Scroll — zoom in/out +## Scroll wheel — zoom in / out +## R / Space — forwarded (not consumed) + +extends Camera3D + +## World-space point the camera orbits around. +@export var target: Vector3 = Vector3.ZERO + +## Distance from target. +@export var distance: float = 45.0 + +## Horizontal angle (radians). 0 = looking down +Z. +@export var theta: float = PI / 4.0 + +## Vertical angle (radians). PI/2 = straight on, smaller = higher. +@export var phi: float = PI / 3.0 + +## Zoom range +@export var min_distance: float = 5.0 +@export var max_distance: float = 200.0 + +## Zoom multiplier per scroll tick. +@export var zoom_factor: float = 1.15 + +## Pan speed (world units per pixel of drag). +@export var pan_speed: float = 0.03 + +## Orbit speed multiplier. +@export var orbit_speed: float = 0.003 + +var _dragging_orbit: bool = false +var _dragging_pan: bool = false +var _drag_start: Vector2 = Vector2.ZERO + + +func _ready() -> void: + _update_position() + + +func _process(_delta: float) -> void: + # Smoothly follow target in case it moves externally + global_transform.origin = get_desired_position() + + +func _input(event: InputEvent) -> void: + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_LEFT: + if event.pressed: + _dragging_orbit = true + _drag_start = event.position + else: + _dragging_orbit = false + + elif event.button_index == MOUSE_BUTTON_RIGHT: + if event.pressed: + _dragging_pan = true + _drag_start = event.position + else: + _dragging_pan = false + + elif event.button_index == MOUSE_BUTTON_WHEEL_UP: + distance = maxf(distance / zoom_factor, min_distance) + _update_position() + + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + distance = minf(distance * zoom_factor, max_distance) + _update_position() + + if _dragging_orbit and event is InputEventMouseMotion: + var delta: Vector2 = event.position - _drag_start + theta -= delta.x * orbit_speed + phi = clampf(phi + delta.y * orbit_speed, 0.05, PI - 0.05) + _drag_start = event.position + _update_position() + + if _dragging_pan and event is InputEventMouseMotion: + var delta: Vector2 = event.position - _drag_start + _pan_target(delta) + _drag_start = event.position + _update_position() + + +func _pan_target(screen_delta: Vector2) -> void: + # Compute camera right and up vectors for panning + var forward: Vector3 = global_transform.basis.z + var right: Vector3 = global_transform.basis.x + var up: Vector3 = global_transform.basis.y + + # Pan perpendicular to view direction + var pan_move: Vector3 = -right * screen_delta.x * pan_speed + up * screen_delta.y * pan_speed + target += pan_move + + +func get_desired_position() -> Vector3: + var x: float = distance * sin(phi) * cos(theta) + var y: float = distance * cos(phi) + var z: float = distance * sin(phi) * sin(theta) + return target + Vector3(x, y, z) + + +func _update_position() -> void: + position = get_desired_position() + look_at(target, Vector3.UP) diff --git a/client/godot/scripts/constants.gd b/client/godot/scripts/constants.gd new file mode 100644 index 0000000..7eb5f63 --- /dev/null +++ b/client/godot/scripts/constants.gd @@ -0,0 +1,99 @@ +# līlā — Godot 4.x 3D Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# scripts/constants.gd — Shared constants for the Godot client +# +# Mirrors browser constants.js and server ecosystem constants. Covers +# grid dimensions, tick rates, interaction cooldowns, reconciliation +# thresholds, particle colors, and entity type palettes. +class_name LilaConstants + +## Print with HH:MM:SS timestamp prefix. +static func log(msg: Variant) -> void: + var t: Dictionary = Time.get_datetime_dict_from_system() + var ts: String = "%02d:%02d:%02d" % [t.hour, t.minute, t.second] + print("[%s] %s" % [ts, str(msg)]) + + +## Grid dimensions (matches server voxel grid) +const GRID_SIZE: int = 32 + +## Cell size in pixels (2D render space) +const CELL_PX: int = 18 + +## Server tick rate in seconds (push frequency) +const SERVER_TICK_RATE: float = 2.0 + +## Heartbeat interval in milliseconds (client → server) +const HEARTBEAT_INTERVAL_MS: int = 1000 + +## Reconciliation thresholds +const RECONCILE_MIN_DIVERGENCE: float = 0.1 +const RECONCILE_NUDGE_FACTOR: float = 0.15 +const RECONCILE_SNAP_FACTOR: float = 0.5 +const RECONCILE_QUEUE_MAX: int = 2 + +## Agency +const GRAVITY_WELL_FACTOR: float = 0.05 +const TURN_SPEED: float = 8.0 # radians per second for smooth facing interpolation +const INTERACTION_COOLDOWN: float = 2.0 +const WANDER_MARGIN: float = 4.0 +const ARRIVAL_DISTANCE: float = 0.8 + +## Reconnect delay in seconds +const RECONNECT_DELAY: float = 3.0 + +## Default server address +const DEFAULT_HOST: String = "localhost" +const DEFAULT_PORT: int = 8001 + +## Entity type colors (fallback for renderer) +## Harmonized with browser constants.js palette. +const TYPE_COLORS: Dictionary = { + "ANIMAL": Color(0.77, 0.58, 0.42), # deer brown #c4956a + "BIRD": Color(0.54, 0.48, 0.42), # bird brown #8a7b6b + "INSECT": Color(0.66, 0.49, 0.77), # butterfly purple #a87cc4 + "PLANT": Color(0.42, 0.56, 0.37), # grass green #6b8f5e + "TREE": Color(0.24, 0.42, 0.24), # oak green #3d6b3d + "MICROORGANISM": Color(0.63, 0.55, 0.47), # mushroom #a08c78 +} + +## Species name → color override +const SPECIES_COLORS: Dictionary = { + "deer": Color(0.77, 0.58, 0.42), + "wolf": Color(0.45, 0.45, 0.45), + "butterfly": Color(0.66, 0.49, 0.77), + "songbird": Color(0.54, 0.48, 0.42), + "meadow_oak": Color(0.24, 0.42, 0.24), + "meadow_grass": Color(0.42, 0.56, 0.37), + "wildflower": Color(0.48, 0.56, 0.37), + "mushroom": Color(0.63, 0.55, 0.47), +} + +## State → color tint for HUD overlay / debug +const STATE_COLORS: Dictionary = { + "IDLE": Color(0.6, 0.6, 0.6), + "FORAGING": Color(0.3, 0.8, 0.3), + "HUNTING": Color(0.9, 0.2, 0.2), + "FLEEING": Color(1.0, 0.3, 0.3), + "RESTING": Color(0.5, 0.5, 0.8), + "DRINKING": Color(0.2, 0.5, 0.9), + "REPRODUCING": Color(0.9, 0.4, 0.7), + "DYING": Color(0.3, 0.1, 0.1), + "GROWING": Color(0.3, 0.7, 0.3), + "WILTING": Color(0.6, 0.5, 0.2), + "DORMANT": Color(0.4, 0.3, 0.2), + "FRUITING": Color(1.0, 0.8, 0.1), + "POLLINATING": Color(1.0, 0.9, 0.3), + "ACTIVE": Color(0.5, 0.6, 0.5), + "BLOOMING": Color(0.8, 0.7, 0.5), +} + +## Mobile entity types (send positions in heartbeat) +const MOBILE_TYPES: Array[StringName] = ["ANIMAL", "BIRD", "INSECT"] + +## Particle colors (harmonized with browser event colors) +const PARTICLE_CONSUMPTION: Color = Color(0.35, 0.85, 0.37) # #8faa6e +const PARTICLE_POLLINATION: Color = Color(0.77, 0.65, 0.29) # #c4a64a +const PARTICLE_DEATH: Color = Color(0.43, 0.35, 0.35) # #6e5a5a diff --git a/client/godot/scripts/heartbeat.gd b/client/godot/scripts/heartbeat.gd new file mode 100644 index 0000000..67376d1 --- /dev/null +++ b/client/godot/scripts/heartbeat.gd @@ -0,0 +1,38 @@ +# līlā — Godot 4.x 3D Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# scripts/heartbeat.gd — Client heartbeat sender +# +# Accumulates entity positions and agency events, sends them to the server +# at 1 Hz intervals. Mirrors browser heartbeat.js. +class_name HeartbeatSender + + +var _last_send: float = 0.0 +var _queued_events: Array = [] + + +## Call every frame. Sends heartbeat when interval elapses. +func tick(ws: Node, world: Node, now: float) -> void: + var interval: float = float(LilaConstants.HEARTBEAT_INTERVAL_MS) / 1000.0 + if now - _last_send < interval: + return + + _last_send = now + + # Build positions dict (only alive mobile consumers) + var positions: Dictionary = {} + var mobile: Array = world.get_alive_mobile() + for ent in mobile: + positions[ent.id] = [ent.x, 0.0, ent.z] + + # Send heartbeat + if positions.size() > 0 or _queued_events.size() > 0: + ws.send_heartbeat(positions, _queued_events.duplicate()) + _queued_events.clear() + + +## Queue a client event for next heartbeat. +func queue_event(evt: Dictionary) -> void: + _queued_events.append(evt) diff --git a/client/godot/scripts/particles.gd b/client/godot/scripts/particles.gd new file mode 100644 index 0000000..50cc9f9 --- /dev/null +++ b/client/godot/scripts/particles.gd @@ -0,0 +1,75 @@ +# līlā — Godot 4.x 3D Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# scripts/particles.gd — Simple particle system for event visualizations +# +# Spawns particles for consumption, pollination, death, and other +# ecosystem events. Particles are pooled and updated each frame. +extends RefCounted + + +class Particle: + var position: Vector2 # Grid coordinates + var velocity: Vector2 # Grid units per second + var color: Color + var life: float + var max_life: float + var size: float + + func is_alive() -> bool: + return life > 0.0 + + func step(delta: float) -> void: + life -= delta + position += velocity * delta + velocity *= 0.95 # Damping + + +var _particles: Array[Particle] = [] +const MAX_PARTICLES: int = 500 + + +## Update all particles, remove dead ones. +func step(delta: float) -> void: + var i: int = _particles.size() - 1 + while i >= 0: + var p: Particle = _particles[i] + p.step(delta) + if not p.is_alive(): + _particles.remove_at(i) + i -= 1 + + +## Spawn particles at a grid position for an event type. +func spawn(grid_x: float, grid_z: float, event_type: String, count: int = 8) -> void: + var color: Color + match event_type.to_upper(): + "CONSUMPTION": + color = LilaConstants.PARTICLE_CONSUMPTION + "POLLINATION": + color = LilaConstants.PARTICLE_POLLINATION + "DEATH_NATURAL", "DEATH_STARVE": + color = LilaConstants.PARTICLE_DEATH + _: + color = Color(1.0, 1.0, 1.0) + + for i in count: + if _particles.size() >= MAX_PARTICLES: + _particles.remove_at(0) + + var p: Particle = Particle.new() + p.position = Vector2(grid_x, grid_z) + var angle: float = randf() * TAU + var speed: float = randf_range(0.5, 2.0) + p.velocity = Vector2(cos(angle), sin(angle)) * speed + p.color = color + p.life = randf_range(0.5, 1.5) + p.max_life = p.life + p.size = randf_range(0.2, 0.5) + _particles.append(p) + + +## Get all alive particles. +func get_alive() -> Array[Particle]: + return _particles diff --git a/client/godot/scripts/reconciliation.gd b/client/godot/scripts/reconciliation.gd new file mode 100644 index 0000000..41f193e --- /dev/null +++ b/client/godot/scripts/reconciliation.gd @@ -0,0 +1,89 @@ +# līlā — Godot 4.x 3D Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# scripts/reconciliation.gd — Position reconciliation +# +# Reconciles client-agency positions with server reference positions. +# Mirrors browser reconciliation.js and Python reconciliation.py. +# +# When a new tick packet arrives, reconcile client positions within bounds; +# gently correct when divergence exceeds expected travel distance. +## +## Each tick, divergent entities get their ref_position enqueued as a +## reconcile target. The agency system then smoothly meanders toward +## that target over the next ~2 seconds. If a new target arrives before +## the old one is reached, the entity transitions smoothly (no snap). +## +## Each entity has a unique sync personality (sync_phase, sync_speed) +## so they don't all queue reconciliation targets at the same time. +## +## Additionally, a continuous gravity well pulls all entities gently +## toward their ref_position during normal agency, preventing sudden +## direction changes when new tick targets arrive. +class_name Reconciliation + + +## Called after each tick packet. Reconciles all mobile entities. +static func reconcile(world: Node, tick: int) -> void: + var mobile: Array = world.get_alive_mobile() + for ent in mobile: + _reconcile_entity(ent, tick) + + +static func _reconcile_entity(ent, tick: int) -> void: + # Staggered reaction: each entity has a sync_phase (0..3) + # Only enqueue reconcile targets when ticks since last reconcile >= phase. + # This spreads the "nudge" across frames so it looks organic. + var ticks_since_last: int = tick - ent.last_reconciled_tick + if ticks_since_last < ent.sync_phase: + return # not this entity's turn yet + + # Server acknowledged our deviation — trust it fully. + # Clear any pending reconcile targets since server now matches us. + if ent.ack: + ent.reconcile_queue.clear() + ent.reconcile_idx = 0 + ent.last_reconciled_tick = tick + return + + var dx: float = ent.x - ent.ref_x + var dz: float = ent.z - ent.ref_z + var divergence: float = sqrt(dx * dx + dz * dz) + + if divergence < LilaConstants.RECONCILE_MIN_DIVERGENCE: + # Negligible drift — nothing to reconcile. + # Prune completed targets. + _prune_queue(ent) + ent.last_reconciled_tick = tick + return + + # Prune completed targets before enqueueing new one. + _prune_queue(ent) + + # Enqueue the ref_position as a reconcile target. + # If there's already an unfinished target, append — the agency + # system will chain through them smoothly. + if ent.reconcile_queue.size() < LilaConstants.RECONCILE_QUEUE_MAX: + ent.reconcile_queue.append(Vector2(ent.ref_x, ent.ref_z)) + + # If the queue grew too long (entity falling behind), keep only + # the latest target to avoid chasing ghosts. + if ent.reconcile_queue.size() > LilaConstants.RECONCILE_QUEUE_MAX: + ent.reconcile_queue = PackedVector2Array([ent.reconcile_queue[-1]]) + ent.reconcile_idx = 0 + + ent.last_reconciled_tick = tick + + +static func _prune_queue(ent) -> void: + """Prune completed targets from the reconcile queue.""" + if ent.reconcile_idx > 0 and ent.reconcile_idx < ent.reconcile_queue.size(): + var remaining: PackedVector2Array = PackedVector2Array() + for i in range(ent.reconcile_idx, ent.reconcile_queue.size()): + remaining.append(ent.reconcile_queue[i]) + ent.reconcile_queue = remaining + ent.reconcile_idx = 0 + elif ent.reconcile_idx >= ent.reconcile_queue.size(): + ent.reconcile_queue.clear() + ent.reconcile_idx = 0 diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd new file mode 100644 index 0000000..07a48d3 --- /dev/null +++ b/client/godot/scripts/renderer.gd @@ -0,0 +1,439 @@ +# līlā — Godot 4.x 3D Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# scripts/renderer.gd — Primitive-based 3D entity renderer +# +# Each entity type gets a MeshInstance with type-appropriate shape and color, +# harmonized with the browser renderer palette. Uses MultiMeshInstance3D +# for efficient per-type instancing. +extends Node + + +# ── Color palette (mirrored from browser constants.js) ───────────────── + +const C_BG: Color = Color(0.059, 0.063, 0.059) + +## Top of ground voxels (1x1x1 BoxMesh centered at y=0.0 → surface at y=0.5) +const GROUND_SURFACE_Y: float = 0.5 + +const C_DEER: Color = Color(0.769, 0.584, 0.416) +const C_WOLF: Color = Color(0.45, 0.45, 0.45) +const C_BIRD: Color = Color(0.541, 0.482, 0.420) +const C_BUTTERFLY: Color = Color(0.659, 0.486, 0.769) +const C_TREE: Color = Color(0.239, 0.420, 0.239) +const C_GRASS: Color = Color(0.420, 0.561, 0.369) +const C_GRASS_WILT: Color = Color(0.478, 0.447, 0.329) +const C_WILDFLOWER: Color = Color(0.478, 0.561, 0.369) +const C_MUSHROOM: Color = Color(0.627, 0.549, 0.471) +const C_FRUITING: Color = Color(0.95, 0.85, 0.12) + +const C_MOISTURE_DRY: Color = Color(0.400, 0.345, 0.235) +const C_MOISTURE_MID: Color = Color(0.263, 0.275, 0.235) +const C_MOISTURE_WET: Color = Color(0.188, 0.227, 0.204) +const C_WATER: Color = Color(0.15, 0.35, 0.55) + +# ── Entity cube sizes ────────────────────────────────────────────────── + +const SIZE_TREE: float = 3.0 +const SIZE_ANIMAL: float = 1.5 +const SIZE_BIRD: float = 1.0 +const SIZE_INSECT: float = 0.7 +const SIZE_PLANT: float = 0.8 +const SIZE_MICRO: float = 0.5 + +# ── Instance tracking ────────────────────────────────────────────────── + +## Map of entity type → MultiMeshInstance3D node. +var _type_meshes: Dictionary = {} +## Water source mesh map: source key → MeshInstance3D. +# ── Public API ───────────────────────────────────────────────────────── + +## Build simple BoxMesh cubes for all entity types (call once in _ready). +## Fruiting wildflowers get a SphereMesh instead of a cube. +func build_all_type_meshes() -> Dictionary: + var meshes: Dictionary = {} + for key in ["TREE", "INSECT", "PLANT_GRASS", "PLANT_FLOWER", "MICROORGANISM"]: + meshes[key] = BoxMesh.new() + # Animals are horizontal capsules (deer body shape) + # Default CapsuleMesh is vertical — we tilt it on its side in the transform + var capsule: CapsuleMesh = CapsuleMesh.new() + capsule.radius = 0.55 + capsule.height = 2.5 + meshes["ANIMAL"] = capsule + # Birds are cones — pointy end faces forward + # ConeMesh removed in Godot 4, use CylinderMesh with top_radius = 0 + var cone: CylinderMesh = CylinderMesh.new() + cone.bottom_radius = 1.0 + cone.top_radius = 0.0 + cone.height = 2.0 + cone.radial_segments = 12 + meshes["BIRD"] = cone + var sphere: SphereMesh = SphereMesh.new() + sphere.radius = 1.0 + sphere.radial_segments = 12 + meshes["PLANT_FLOWER_BLOOM"] = sphere + return meshes + + +## Shader material that reads per-instance custom data as color. +## Uses INSTANCE_CUSTOM instead of INSTANCE_COLOR (broken in Godot 4.7). +func _make_vertex_color_material() -> ShaderMaterial: + var shader: Shader = Shader.new() + shader.code = """ +shader_type spatial; +render_mode blend_mix, cull_back, depth_draw_always; + +void vertex() { + COLOR = INSTANCE_CUSTOM; +} + +void fragment() { + ALBEDO = COLOR.rgb; + ALPHA = COLOR.a; + ROUGHNESS = 0.85; + METALLIC = 0.0; +} +""" + var mat: ShaderMaterial = ShaderMaterial.new() + mat.shader = shader + return mat + + +## Material for ground voxels: reads INSTANCE_CUSTOM for per-cell color. +func make_ground_material() -> ShaderMaterial: + var shader: Shader = Shader.new() + shader.code = """ +shader_type spatial; + +void vertex() { + COLOR = INSTANCE_CUSTOM; +} + +void fragment() { + ALBEDO = COLOR.rgb; + ROUGHNESS = 0.9; + METALLIC = 0.0; +} +""" + var mat: ShaderMaterial = ShaderMaterial.new() + mat.shader = shader + return mat + + +## Material for particle MultiMesh: reads INSTANCE_CUSTOM, unshaded for glow effect. +func make_particle_material() -> ShaderMaterial: + var shader: Shader = Shader.new() + shader.code = """ +shader_type spatial; +render_mode blend_mix, cull_back, depth_draw_always, unshaded; + +void vertex() { + COLOR = INSTANCE_CUSTOM; +} + +void fragment() { + ALBEDO = COLOR.rgb; + ALPHA = COLOR.a; +} +""" + var mat: ShaderMaterial = ShaderMaterial.new() + mat.shader = shader + return mat + + +## Set up MultiMeshInstance3D nodes inside a parent Node3D. +## Returns map type → MultiMeshInstance3D. +func setup_type_meshes(parent: Node3D, meshes: Dictionary) -> Dictionary: + var result: Dictionary = {} + for type_name: String in meshes: + var mi: Node3D = MultiMeshInstance3D.new() + mi.name = "Instances_%s" % type_name + mi.layers = 1 + + var mm: MultiMesh = MultiMesh.new() + mm.mesh = meshes[type_name] + mm.transform_format = MultiMesh.TRANSFORM_3D + mm.use_custom_data = true + mm.instance_count = 0 + + mi.multimesh = mm + mi.material_override = _make_vertex_color_material() + parent.add_child(mi) + result[type_name] = mi + return result + + +## Update all MultiMeshInstance3D instances for the current entity set. +## Sorts entities by type, then populates transforms + colors. +func update_entities( + type_meshes: Dictionary, + entities: Array, + face_dir: bool = true, +) -> void: + # Bucket entities by mesh key + var buckets: Dictionary = {} + for type_name in type_meshes: + buckets[type_name] = [] + + for ent in entities: + var key: String = _entity_to_mesh_key(ent) + if key and buckets.has(key): + buckets[key].append(ent) + + # Also handle state-based color tinting + var tick_ms: float = float(Time.get_ticks_msec()) + + for key: String in buckets: + var mi: Node3D = type_meshes[key] + if mi == null: + continue + var list: Array = buckets[key] + var mm: MultiMesh = mi.multimesh + if mm == null: + continue + mm.instance_count = list.size() + + for i in list.size(): + var ent = list[i] + var color: Color = _get_entity_color(ent) + var size: float = _get_entity_size(ent) + var transform: Transform3D = _build_entity_transform(ent, size, tick_ms) + + # Dormant / wilted entities darker + if ent.state == "DORMANT" or ent.state == "DEAD": + color = color.darkened(0.55) + + # Wilted plants shift color + if ent.type == "PLANT" and ent.drive.get("hydration", 1.0) < 0.25: + color = C_GRASS_WILT + + # Fruiting plants turn bright red (only flowers bloom, not grass) + if ent.type == "PLANT" and ent.state == "FRUITING" and ent.species == "wildflower": + color = C_FRUITING + + mm.set_instance_transform(i, transform) + mm.set_instance_custom_data(i, color) + + + +## Build ground voxel MultiMesh once (call in _setup). +## Creates 1x1x1 BoxMesh instances at each grid cell — transforms never change. +func build_ground_voxels() -> MultiMesh: + var box: BoxMesh = BoxMesh.new() + box.size = Vector3(1.0, 1.0, 1.0) + + var mm: MultiMesh = MultiMesh.new() + mm.mesh = box + mm.transform_format = MultiMesh.TRANSFORM_3D + mm.use_custom_data = true + + var size: int = LilaConstants.GRID_SIZE + mm.instance_count = size * size + + var i: int = 0 + for gz in size: + for gx in size: + var t: Transform3D + t.origin = Vector3(float(gx), 0.0, float(gz)) + t.basis = Basis.IDENTITY + mm.set_instance_transform(i, t) + mm.set_instance_custom_data(i, C_MOISTURE_MID) + i += 1 + + return mm + + +## Update ground voxel colors from moisture grid + water sources. +## Call every frame — only touches color data, transforms are static. +func update_ground_voxels( + mm: MultiMesh, + moisture_grid: PackedFloat32Array, + water_sources: Array, +) -> void: + var size: int = LilaConstants.GRID_SIZE + + var i: int = 0 + for gz in size: + for gx in size: + var idx: int = gx + gz * size + var moisture: float = 0.5 + if idx < moisture_grid.size(): + moisture = moisture_grid[idx] + + var color: Color = _moisture_color(moisture) + var cell_pos: Vector2 = Vector2(float(gx), float(gz)) + for src: Dictionary in water_sources: + var src_pos: Vector2 = src.position + var radius: float = src.get("radius", 3.0) + var max_radius: float = src.get("max_radius", radius) + var level: float = src.get("water_level", 1.0) + if level < 0.02: + continue + var dist: float = cell_pos.distance_to(src_pos) + # Use max_radius for blend falloff so the water footprint fades + # with drought rather than just shrinking its visible area. + if dist <= max_radius: + var blend: float = (1.0 - dist / max_radius) * level + color = color.lerp(C_WATER, blend) + break + + mm.set_instance_custom_data(i, color) + i += 1 + + +## Build particle mesh (small spheres/boxes). +func build_particle_mesh() -> Mesh: + var box: BoxMesh = BoxMesh.new() + box.size = Vector3(0.3, 0.3, 0.3) + return box + + + +# ── Color helpers ──────────────────────────────────────────────────────── + +func _moisture_color(moisture: float) -> Color: + if moisture < 0.33: + return C_MOISTURE_DRY + elif moisture < 0.66: + return C_MOISTURE_MID + else: + return C_MOISTURE_WET + + +func _get_entity_color(ent) -> Color: + var species: String = ent.species + match ent.type: + "ANIMAL": + if species == "wolf": + return C_WOLF + return C_DEER + "BIRD": + return C_BIRD + "INSECT": + return C_BUTTERFLY + "TREE": + return C_TREE + "PLANT": + if species == "wildflower": + return C_WILDFLOWER + return C_GRASS + "MICROORGANISM": + return C_MUSHROOM + return Color(0.5, 0.5, 0.5) + + +func _get_entity_size(ent) -> float: + match ent.type: + "TREE": + return SIZE_TREE + "ANIMAL": + return SIZE_ANIMAL + "BIRD": + return SIZE_BIRD + "INSECT": + return SIZE_INSECT + "PLANT": + # Fruiting wildflowers are slightly bigger (sphere variant) + if ent.species == "wildflower" and ent.state == "FRUITING": + return SIZE_PLANT * 1.3 + return SIZE_PLANT + "MICROORGANISM": + return SIZE_MICRO + return 1.0 + + +func _entity_to_mesh_key(ent) -> String: + match ent.type: + "TREE": + return "TREE" + "ANIMAL": + return "ANIMAL" + "BIRD": + return "BIRD" + "INSECT": + return "INSECT" + "PLANT": + if ent.species == "wildflower" and ent.state == "FRUITING": + return "PLANT_FLOWER_BLOOM" + if ent.species == "wildflower": + return "PLANT_FLOWER" + return "PLANT_GRASS" + "MICROORGANISM": + return "MICROORGANISM" + return "" + + +func _build_entity_transform(ent, size: float, tick_ms: float) -> Transform3D: + var cx: float = ent.x + var cz: float = ent.z + var y_extra: float = 0.0 + var sv: Dictionary = ent.drive + + # ── Per-type size adjustments ────────────────────────────────── + + # Trees grow taller with growth state_var + if ent.type == "TREE": + var growth: float = sv.get("growth", 0.5) + size = SIZE_TREE * (0.5 + growth * 0.5) + + # Plants scale by growth (skip for fruiting wildflowers — sized in _get_entity_size) + if ent.type == "PLANT" and not (ent.species == "wildflower" and ent.state == "FRUITING"): + var growth: float = sv.get("growth", 0.3) + size = SIZE_PLANT * (0.3 + growth * 0.7) + + # Mushroom size by activity + if ent.type == "MICROORGANISM": + var activity: float = sv.get("activity", 0.5) + size = SIZE_MICRO * (0.3 + activity * 0.7) + + # Resting animals are smaller (crouched) + if ent.type == "ANIMAL" and ent.state == "RESTING": + size *= 0.75 + + # ── Compute scaled size (world units) ────────────────────────── + var sc: float = size * 0.6 + + # ── Height placement ─────────────────────────────────────────── + # Flying entities keep their own altitude + if ent.type == "INSECT": + y_extra = 1.25 + sin(tick_ms / 300.0 + float(ent.sync_phase)) * 0.4 + elif ent.type == "BIRD": + y_extra = 3.5 + sin(tick_ms / 400.0 + float(ent.sync_phase)) * 0.5 + + # Ground entities rest on top of ground voxels (y = GROUND_SURFACE_Y) + var cy: float + if ent.type == "INSECT" or ent.type == "BIRD": + cy = GROUND_SURFACE_Y + y_extra + else: + # Center Y = ground surface + half the cube height + cy = GROUND_SURFACE_Y + sc / 2.0 + + # ── Build transform ──────────────────────────────────────────── + var angle: float = ent.facing_angle + + var t: Transform3D + t.origin = Vector3(cx, cy, cz) + + # Rotate around Y axis to face direction + var rot: Basis + if ent.type == "BIRD": + # Tilt cone forward (points along +X) then steer around Y + rot = Basis.from_euler(Vector3(PI / 2.0, -angle + PI / 2.0, 0.0)) + elif ent.type == "ANIMAL": + # Steer around Y, then tilt capsule 90° around X so long axis lies horizontal + # and points in the direction of travel + rot = Basis.from_euler(Vector3(0.0, -angle + PI / 2.0, 0.0)) * Basis.from_euler(Vector3(PI / 2.0, 0.0, 0.0)) + else: + rot = Basis.from_euler(Vector3(0.0, -angle + PI / 2.0, 0.0)) + # Insects are squished vertically to look more like flat flyers + var scale: Vector3 + if ent.type == "INSECT": + scale = Vector3(sc, sc / 5.0, sc) + else: + scale = Vector3(sc, sc, sc) + t.basis = rot * Basis.from_scale(scale) + + return t + + diff --git a/client/python/lila_client/__init__.py b/client/python/lila_client/__init__.py index 672b643..0719fb9 100644 --- a/client/python/lila_client/__init__.py +++ b/client/python/lila_client/__init__.py @@ -1 +1,3 @@ -# līlā Python ImGui Client +# līlā — Python Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 diff --git a/client/python/lila_client/__main__.py b/client/python/lila_client/__main__.py index 1c30620..042857e 100644 --- a/client/python/lila_client/__main__.py +++ b/client/python/lila_client/__main__.py @@ -1,4 +1,8 @@ -"""līlā Python Client — allow ``python -m lila_client``.""" +# līlā — Python Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# lila_client/__main__.py — allow ``python -m lila_client`` from .main import main diff --git a/client/python/lila_client/agency.py b/client/python/lila_client/agency.py index 9b86ce6..5ac358f 100644 --- a/client/python/lila_client/agency.py +++ b/client/python/lila_client/agency.py @@ -1,6 +1,10 @@ -"""līlā Python Client — Client-side agency engine. - -Between server ticks, each mobile entity decides what to do based on: +# līlā — Python Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# lila_client/agency.py — Client-side agency engine +# +# Between server ticks, each mobile entity decides what to do based on: - Server intent (state + drives + eligibility flags) - Local perception (nearest food, water, threats from world model) - Motion latent (modulates speed, hesitation, path curvature) diff --git a/client/python/lila_client/constants.py b/client/python/lila_client/constants.py index 1e3fd81..f70e63f 100644 --- a/client/python/lila_client/constants.py +++ b/client/python/lila_client/constants.py @@ -1,4 +1,11 @@ -"""līlā Python Client — Constants and Configuration.""" +# līlā — Python Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# lila_client/constants.py — Constants and Configuration +# +# Shared constants mirroring browser/Python clients. Covers grid dimensions, +# tick rates, interaction cooldowns, reconciliation thresholds, and colors. import pathlib diff --git a/client/python/lila_client/imgui_view.py b/client/python/lila_client/imgui_view.py index fba64b5..590aa13 100644 --- a/client/python/lila_client/imgui_view.py +++ b/client/python/lila_client/imgui_view.py @@ -1,4 +1,8 @@ -"""līlā Python Client — Dear PyGui debug viewer.""" +# līlā — Python Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# lila_client/imgui_view.py — Dear PyGui debug viewer from __future__ import annotations diff --git a/client/python/lila_client/main.py b/client/python/lila_client/main.py index 73e7e89..fa4c7a5 100644 --- a/client/python/lila_client/main.py +++ b/client/python/lila_client/main.py @@ -1,6 +1,10 @@ -"""līlā Python Client — Main entry point. - -Usage: +# līlā — Python Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# lila_client/main.py — Main entry point +# +# Usage: lila-client [--host localhost] [--port 8001] [--world path/to/world.json] """ diff --git a/client/python/lila_client/pygame_renderer.py b/client/python/lila_client/pygame_renderer.py index 2b68615..d79436f 100644 --- a/client/python/lila_client/pygame_renderer.py +++ b/client/python/lila_client/pygame_renderer.py @@ -1,6 +1,10 @@ -"""līlā Python Client — Pygame scene renderer. - -Mirrors the browser client's canvas renderer: moisture heatmap, grid, +# līlā — Python Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# lila_client/pygame_renderer.py — Pygame scene renderer +# +# Mirrors the browser client's canvas renderer: moisture heatmap, grid, water sources, and entities drawn as layered sprites. """ diff --git a/client/python/lila_client/reconciliation.py b/client/python/lila_client/reconciliation.py index 9055abe..43dbe75 100644 --- a/client/python/lila_client/reconciliation.py +++ b/client/python/lila_client/reconciliation.py @@ -1,6 +1,10 @@ -"""līlā Python Client — Reconciliation (Client ↔ Server Position Sync). - -When a new tick packet arrives, reconcile client-agency positions +# līlā — Python Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# lila_client/reconciliation.py — Reconciliation (Client ↔ Server Position Sync) +# +# When a new tick packet arrives, reconcile client-agency positions with server reference positions. Trust the client within bounds; gently correct when divergence exceeds expected travel distance. diff --git a/client/python/lila_client/replay.py b/client/python/lila_client/replay.py index 5ccc268..77d53c3 100644 --- a/client/python/lila_client/replay.py +++ b/client/python/lila_client/replay.py @@ -1,6 +1,10 @@ -"""līlā Python Client — Replay mode for post-mortem analysis. - -Reads a session's JSONL telemetry log and replays events in the viewer, +# līlā — Python Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# lila_client/replay.py — Replay mode for post-mortem analysis +# +# Reads a session's JSONL telemetry log and replays events in the viewer, allowing you to scrub through time and inspect what happened at each tick. Usage: diff --git a/client/python/lila_client/websocket.py b/client/python/lila_client/websocket.py index 73f4247..4c25d87 100644 --- a/client/python/lila_client/websocket.py +++ b/client/python/lila_client/websocket.py @@ -1,6 +1,10 @@ -"""līlā Python Client — WebSocket connection manager. - -Handles connecting to the server, sending world definitions, receiving tick packets, +# līlā — Python Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# lila_client/websocket.py — WebSocket connection manager +# +# Handles connecting to the server, sending world definitions, receiving tick packets, and subscribing to telemetry events. Runs asyncio in a background thread with thread-safe queues for communication with the ImGui main loop. """ diff --git a/client/python/lila_client/world_model.py b/client/python/lila_client/world_model.py index ba6ffcd..77d5a95 100644 --- a/client/python/lila_client/world_model.py +++ b/client/python/lila_client/world_model.py @@ -1,6 +1,10 @@ -"""līlā Python Client — World Model (local scene graph). - -Mirrors the browser client's WorldModel for entity tracking and spatial queries. +# līlā — Python Client +# Copyright 2025 BioSynthArt Studios LLC +# Licensed under the Apache License, Version 2.0 +# +# lila_client/world_model.py — World Model (local scene graph) +# +# Mirrors the browser client's WorldModel for entity tracking and spatial queries. Used by both the ImGui renderer and the local agency system. """ diff --git a/server/ecosim/engine.py b/server/ecosim/engine.py index 817310c..199f1d4 100755 --- a/server/ecosim/engine.py +++ b/server/ecosim/engine.py @@ -1106,6 +1106,8 @@ def _build_tick_packet(self, dt: float) -> dict[str, Any]: update: dict[str, Any] = { "id": eid, + "type": e["type"], + "species": e.get("species"), "state": e["state"], # Reference position — gravity well for reconciliation, # not an authoritative command. Client may deviate. diff --git a/server/ecosim/environment_manager.py b/server/ecosim/environment_manager.py index 49719a9..a28bc0a 100644 --- a/server/ecosim/environment_manager.py +++ b/server/ecosim/environment_manager.py @@ -55,7 +55,8 @@ def __init__( self.climate: dict[str, float] = climate # ── Voxel Grid ── - dims = tuple(voxel_grid_cfg.get("dimensions", [32, 32, 32])) + raw_dims = voxel_grid_cfg.get("dimensions", [32, 32, 32]) + dims = tuple(int(d) for d in raw_dims) # Godot JSON stringify sends floats cell = voxel_grid_cfg.get("cell_size", 1.0) self.voxels = VoxelManager(dimensions=dims, cell_size=cell) diff --git a/server/ecosim/layout.py b/server/ecosim/layout.py index f3fbbc2..0cbda59 100644 --- a/server/ecosim/layout.py +++ b/server/ecosim/layout.py @@ -229,8 +229,8 @@ def _spawn_extra_entities( rng: random.Random, ) -> None: """Spawn extra grass and flower entities for visual density.""" - extra_grass_range = cfg.get("extra_grass", [0, 4]) - extra_flowers_range = cfg.get("extra_flowers", [0, 2]) + extra_grass_range = [int(x) for x in cfg.get("extra_grass", [0, 4])] + extra_flowers_range = [int(x) for x in cfg.get("extra_flowers", [0, 2])] grass_tpl = flower_tpl = None for e in entities.values():