From 85e19163e9610868600dc979300e4a1c64d65b09 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Fri, 19 Jun 2026 12:10:43 -0400 Subject: [PATCH 01/25] feat: add Godot 4.x client with isometric voxel renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebSocket protocol identical to browser/Python clients - Intent-based agency: 60 Hz local behavior (flee→drink→mate→forage→hunt→pollinate→wander) - Heartbeat sender: 1 Hz position/event reporting upstream - Position reconciliation: gravity well + staggered sync + _ack queue - Isometric voxel renderer: colored blocks with depth sorting, moisture grid, animated water - HUD: stats panel, event log, rain button (R key) - Autoloads: WS (WebSocket + HTTP), World (entity registry + spatial queries) - Simulation layer is renderer-independent for future 3D swap --- client/godot/.gitignore | 9 + client/godot/assets/icon.svg | 7 + client/godot/project.godot | 31 ++ client/godot/scenes/hud.gd | 45 ++ client/godot/scenes/hud.tscn | 38 ++ client/godot/scenes/main.gd | 389 ++++++++++++++++++ client/godot/scenes/main.tscn | 34 ++ client/godot/scripts/agency.gd | 349 ++++++++++++++++ client/godot/scripts/autoloads/world_model.gd | 305 ++++++++++++++ client/godot/scripts/autoloads/ws_client.gd | 173 ++++++++ client/godot/scripts/constants.gd | 83 ++++ client/godot/scripts/heartbeat.gd | 32 ++ client/godot/scripts/particles.gd | 68 +++ client/godot/scripts/reconciliation.gd | 37 ++ client/godot/scripts/renderer.gd | 143 +++++++ 15 files changed, 1743 insertions(+) create mode 100644 client/godot/.gitignore create mode 100644 client/godot/assets/icon.svg create mode 100644 client/godot/project.godot create mode 100644 client/godot/scenes/hud.gd create mode 100644 client/godot/scenes/hud.tscn create mode 100644 client/godot/scenes/main.gd create mode 100644 client/godot/scenes/main.tscn create mode 100644 client/godot/scripts/agency.gd create mode 100644 client/godot/scripts/autoloads/world_model.gd create mode 100644 client/godot/scripts/autoloads/ws_client.gd create mode 100644 client/godot/scripts/constants.gd create mode 100644 client/godot/scripts/heartbeat.gd create mode 100644 client/godot/scripts/particles.gd create mode 100644 client/godot/scripts/reconciliation.gd create mode 100644 client/godot/scripts/renderer.gd 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/scenes/hud.gd b/client/godot/scenes/hud.gd new file mode 100644 index 0000000..5bad4f2 --- /dev/null +++ b/client/godot/scenes/hud.gd @@ -0,0 +1,45 @@ +## 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..37ab894 --- /dev/null +++ b/client/godot/scenes/main.gd @@ -0,0 +1,389 @@ +## Main scene — orchestrates WebSocket, world model, agency, heartbeat, rendering. +extends Node2D + + +## Isometric rendering constants +const CELL_W: float = 32.0 +const CELL_H: float = 16.0 +const BLOCK_HEIGHT: float = 10.0 + + +@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 +@onready var world_view: Node2D = $WorldView + +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 +var _world_center: Vector2 = Vector2(640, 200) + + +func _ready() -> void: + print("Lila Godot Client starting...") + + # Connect to WebSocket signals + WS.session_started.connect(_on_session_started) + WS.tick_packet.connect(_on_tick_packet) + WS.world_json_ready.connect(_on_world_json_ready) + + # Connect rain button + rain_button.pressed.connect(_on_rain_pressed) + + # Initialize particle system + _particles = load("res://scripts/particles.gd").new() + + # Center the world view + world_view.position = _world_center + + +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 + # World JSON is loaded; send it once WS connects + print("World definition loaded, sending on connect...") + + +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() + + # Step agency (60 Hz) — WS is an autoload that handles its own _process + if _session_started: + var now: float = Time.get_ticks_msec() / 1000.0 + var events: Array = _agency.step(World, delta) + + # Queue events for heartbeat + for evt: Dictionary in events: + _heartbeat.queue_event(evt) + + # Step heartbeat sender + _heartbeat.tick(WS, World, now) + + # Step particles + _particles.step(delta) + + # Redraw + queue_redraw() + + +func _draw() -> void: + if not _session_started: + return + + _draw_ground() + _draw_water_sources() + _draw_entities() + _draw_particles() + _draw_grid_overlay() + + +func _draw_ground() -> void: + var size: int = LilaConstants.GRID_SIZE + for gz in size: + for gx in size: + var pos: Vector2 = _grid_to_screen(float(gx), float(gz)) + + # Moisture color + var idx: int = gx + gz * size + var moisture: float = 0.5 + if idx < World.moisture_grid.size(): + moisture = World.moisture_grid[idx] + var color: Color = _moisture_color(moisture) + + # Draw isometric diamond + var pts: PackedVector2Array = PackedVector2Array([ + pos + Vector2(CELL_W / 2.0, 0.0), + pos + Vector2(CELL_W / 4.0, CELL_H / 4.0), + pos + Vector2(0.0, 0.0), + pos + Vector2(-CELL_W / 4.0, -CELL_H / 4.0), + ]) + draw_colored_polygon(pts, color) + + +func _draw_water_sources() -> void: + for src: Dictionary in World.water_sources: + var pos: Vector2 = _grid_to_screen(src.position.x, src.position.y) + var radius: float = src.get("radius", 3.0) + var level: float = src.get("water_level", 1.0) + + # Water ellipse (isometric projection of circle) + var rx: float = radius * CELL_W * level * 0.5 + var ry: float = radius * CELL_H * level * 0.5 + var water_color: Color = Color(0.2, 0.4, 0.8, 0.5 * level) + + draw_circle(pos, rx, water_color) + # Inner ripple + var ripple: float = sin(Time.get_ticks_msec() / 500.0) * 0.2 + 0.8 + draw_circle(pos, rx * ripple * 0.7, Color(0.3, 0.6, 1.0, 0.3 * level)) + + +func _draw_entities() -> void: + var entities: Array = World.get_alive() + # Sort by isometric depth (x + z) + entities.sort_custom(func(a, b): return (a.x + a.z) < (b.x + b.z)) + + for ent in entities: + var pos: Vector2 = _grid_to_screen(ent.x, ent.z) + var color: Color = _get_entity_color(ent) + var size: float = _get_entity_size(ent) + + # Height offset for insects + var height_offset: float = 0.0 + if ent.type == "INSECT": + height_offset = -20.0 + sin(Time.get_ticks_msec() / 300.0 + float(ent.sync_phase)) * 5.0 + + var block_pos: Vector2 = pos + Vector2(0, height_offset) + var half_w: float = size * CELL_W / 4.0 + var half_h: float = size * CELL_H / 4.0 + + # Draw block top face (isometric diamond) + var top_y: float = block_pos.y - BLOCK_HEIGHT * size + var top_pts: PackedVector2Array = PackedVector2Array([ + block_pos + Vector2(half_w, -BLOCK_HEIGHT * size), + block_pos + Vector2(half_w * 0.5, -BLOCK_HEIGHT * size + half_h * 0.5), + block_pos + Vector2(0.0, -BLOCK_HEIGHT * size), + block_pos + Vector2(-half_w * 0.5, -BLOCK_HEIGHT * size - half_h * 0.5), + ]) + + # Darken for side effect + var side_color: Color = color.darkened(0.3) + + # Right side face + var right_pts: PackedVector2Array = PackedVector2Array([ + block_pos + Vector2(half_w, 0.0), + block_pos + Vector2(half_w * 0.5, -half_h * 0.5), + top_pts[1], + top_pts[0], + ]) + draw_colored_polygon(right_pts, side_color) + + # Left side face + var left_side_color: Color = color.darkened(0.5) + var left_pts: PackedVector2Array = PackedVector2Array([ + block_pos + Vector2(-half_w, 0.0), + block_pos + Vector2(-half_w * 0.5, -half_h * 0.5), + top_pts[3], + top_pts[2], + ]) + draw_colored_polygon(left_pts, left_side_color) + + # Top face + draw_colored_polygon(top_pts, color) + + # Dormant overlay + if ent.state == "DORMANT": + draw_colored_polygon(top_pts, Color(0.3, 0.2, 0.15, 0.7)) + + # State label for mobile entities + if ent.type in ["ANIMAL", "BIRD", "INSECT"]: + var label_pos: Vector2 = block_pos + Vector2(0, -BLOCK_HEIGHT * size - half_h - 5) + var state_color: Color = LilaConstants.STATE_COLORS.get(ent.state, Color.WHITE) + draw_string( + ThemeDB.fallback_font, + label_pos, + ent.state, + 1, + -1, + 10, + state_color + ) + + +func _draw_particles() -> void: + for particle in _particles.get_alive(): + var pos: Vector2 = _grid_to_screen(particle.position.x, particle.position.y) + var color: Color = particle.color + color.a = maxf(0.0, particle.life / particle.max_life) + var radius: float = particle.size * 5.0 + draw_circle(pos, radius, color) + + +func _draw_grid_overlay() -> void: + # Draw subtle grid lines at major intervals + var size: int = LilaConstants.GRID_SIZE + var step: int = 8 + var grid_color: Color = Color(1.0, 1.0, 1.0, 0.08) + + for i in range(0, size, step): + # Lines along x + var p1: Vector2 = _grid_to_screen(float(i), 0.0) + var p2: Vector2 = _grid_to_screen(float(i), float(size - 1)) + draw_line(p1, p2, grid_color) + + # Lines along z + var p3: Vector2 = _grid_to_screen(0.0, float(i)) + var p4: Vector2 = _grid_to_screen(float(size - 1), float(i)) + draw_line(p3, p4, grid_color) + + +## Convert grid (x, z) to screen position (isometric). +func _grid_to_screen(gx: float, gz: float) -> Vector2: + var sx: float = (gx - gz) * (CELL_W / 2.0) + _world_center.x + var sy: float = (gx + gz) * (CELL_H / 2.0) + _world_center.y + return Vector2(sx, sy) + + +## Color for moisture value. +func _moisture_color(moisture: float) -> Color: + if moisture < 0.33: + return Color(0.9, 0.85, 0.7) # Sandy + elif moisture < 0.66: + return Color(0.7, 0.8, 0.6) # Grassy + else: + return Color(0.4, 0.7, 0.6) # Moist teal + + +## Entity color from species/type. +func _get_entity_color(ent) -> Color: + if ent.species in LilaConstants.SPECIES_COLORS: + return LilaConstants.SPECIES_COLORS[ent.species] + if ent.type in LilaConstants.TYPE_COLORS: + return LilaConstants.TYPE_COLORS[ent.type] + return Color(0.5, 0.5, 0.5) + + +## Entity block size. +func _get_entity_size(ent) -> float: + match ent.type: + "TREE": + return 3.0 + "ANIMAL": + return 1.5 + "BIRD": + return 1.0 + "INSECT": + return 0.7 + "PLANT": + return 0.8 + _: + return 1.0 + + +## Update HUD stats. +func _update_stats() -> void: + stats_label.text = "Tick: %d | Entities: %d | Events: %d | FPS: %d" % [ + _current_tick, World.get_entity_count(), _event_count, _fps + ] + + +## Add event to HUD log. +func _add_hud_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))) + + +## Handle session_started from server. +func _on_session_started(data: Dictionary) -> void: + print("Session started: ", data.get("session_id", "")) + print("Entities: ", data.get("entity_count", 0)) + + # Store species definitions + World.species_defs = data.get("species", {}) + + # Send world definition if we have it + if not _world_def.is_empty(): + WS.send_world_definition(_world_def) + print("World definition sent") + + _session_started = true + + # Flush dead entities periodically + World.flush_dead() + + +## Handle tick packet from server. +func _on_tick_packet(data: Dictionary) -> void: + _current_tick = data.get("tick", _current_tick) + + # Apply entity updates + for update: Dictionary in data.get("entity_updates", []): + World.apply_update(update) + + # Apply spawns + for spawn: Dictionary in data.get("entity_spawns", []): + World.apply_spawn(spawn) + + # Apply removals + 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) + + # Apply voxel deltas + var voxels: Variant = data.get("voxel_deltas", null) + if voxels != null: + World.apply_voxel_deltas(voxels) + + # Apply water sources + var waters: Variant = data.get("water_sources", null) + if waters != null: + World.apply_water_sources(waters) + + # Process events + 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") + "STATE_CHANGE": + pass # Silent, handled by entity state update + + # Reconcile positions + _reconciliation.reconcile(World, _current_tick) + + # Flush dead entities periodically + if _current_tick % 10 == 0: + World.flush_dead() + + +func _input(event: InputEvent) -> void: + if event is InputEventKey: + if 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") diff --git a/client/godot/scenes/main.tscn b/client/godot/scenes/main.tscn new file mode 100644 index 0000000..dbadafc --- /dev/null +++ b/client/godot/scenes/main.tscn @@ -0,0 +1,34 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/main.gd" id="1"] + +[node name="Main" type="Node2D"] +script = ExtResource("1") + +[node name="WorldView" type="Node2D" parent="."] + +[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" diff --git a/client/godot/scripts/agency.gd b/client/godot/scripts/agency.gd new file mode 100644 index 0000000..0320d17 --- /dev/null +++ b/client/godot/scripts/agency.gd @@ -0,0 +1,349 @@ +## Client-side agency engine — runs at 60 Hz between server ticks. +## Mirrors browser agency.js and Python agency.py. +## Server is the nervous system (intent), client is the body (execution). +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_queue.size() > 0: + _execute_reconcile(ent, delta) + return events + + # Evaluate behavior priority chain + var target: Vector2 = Vector2.ZERO + var behavior: String = "" + + if ent.state == "FLEEING": + var flee_result: Dictionary = _evaluate_fleeing(ent, world) + target = flee_result.get("target", Vector2.ZERO) + behavior = "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) + behavior = "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) + behavior = "reproducing" + elif ent.state == "FORAGING" and ent.can_consume: + var forage_result: Dictionary = _evaluate_foraging(ent, world) + target = forage_result.get("target", Vector2.ZERO) + behavior = "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) + behavior = "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) + behavior = "pollinating" + events.append_array(poll_result.get("events", [])) + else: + target = _evaluate_wandering(ent, delta) + behavior = "wandering" + + # Move toward target + if target != Vector2.ZERO: + _move_toward(ent, target, delta, world) + + # Gravity well: gentle pull toward server reference position + var speed_factor: float = ent.sync_speed + ent.x += (ent.ref_x - ent.x) * LilaConstants.GRAVITY_WELL_FACTOR * speed_factor + ent.z += (ent.ref_z - ent.z) * LilaConstants.GRAVITY_WELL_FACTOR * speed_factor + + # 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 angle: float = atan2(dz, dx) + var wobble: float = sin(Time.get_ticks_msec() / 200.0 + ent.sync_phase) * 0.5 + var move_x: float = cos(angle + wobble) * speed * delta + var move_z: float = sin(angle + wobble) * speed * delta + + ent.x += move_x + ent.z += move_z + + +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: + # Random wander target modulated by motion latent + var latent: PackedFloat32Array = ent.motion_latent + var pace: float = 1.0 + var caution: float = 0.0 + if latent.size() >= 2: + pace = 0.5 + latent[0] * 0.5 # Map to 0-1 range roughly + caution = latent[1] + + var wander_range: float = LilaConstants.WANDER_MARGIN * pace + var wobble: float = caution * 2.0 + + var angle: float = randf() * TAU * wobble + ent.facing_angle + 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 _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) + + if dist < LilaConstants.ARRIVAL_DISTANCE: + return + + # 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) + + var move_x: float = (dx / dist) * move_dist + var move_z: float = (dz / dist) * move_dist + + ent.x += move_x + ent.z += move_z + ent.facing_angle = atan2(dz, dx) diff --git a/client/godot/scripts/autoloads/world_model.gd b/client/godot/scripts/autoloads/world_model.gd new file mode 100644 index 0000000..9fc083b --- /dev/null +++ b/client/godot/scripts/autoloads/world_model.gd @@ -0,0 +1,305 @@ +## Client-side world model: entity registry, spatial queries, 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 + 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 + + 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 ent: WorldEntity = entities.get(eid) + if ent == null: + ent = WorldEntity.new(eid, data.get("type", "ANIMAL"), data.get("species", "unknown")) + entities[eid] = ent + + ent.ref_x = _vec_x(data.get("ref_position", [0, 0, 0])) + ent.ref_z = _vec_z(data.get("ref_position", [0, 0, 0])) + 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]) + water_sources.append({ + "position": Vector2(_vec_x(pos), _vec_z(pos)), + "radius": src.get("radius", 3.0), + "water_level": src.get("water_level", 1.0), + }) + + +## Get alive mobile entities (animals, birds, insects). +func get_alive_mobile() -> Array[WorldEntity]: + var result: Array[WorldEntity] = [] + var mobile: PackedStringArray = PackedStringArray(LilaConstants.MOBILE_TYPES) + for eid in entities: + var ent: WorldEntity = entities[eid] + if ent.alive and mobile.has(ent.type): + 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..63bca4a --- /dev/null +++ b/client/godot/scripts/autoloads/ws_client.gd @@ -0,0 +1,173 @@ +## WebSocket client for līlā server communication. +## Handles /ws WebSocket connection, /world.json HTTP fetch, 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) + + +## 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() + # Fetch world.json on startup + _fetch_world_json() + + +func _process(delta: float) -> void: + if not _is_connected: + _reconnect_timer -= delta + if _reconnect_timer <= 0 and not _is_connecting: + _connect_to_server() + return + + # Poll WebSocket + _ws.poll() + + var status: int = _ws.get_ready_state() + + if status == WebSocketPeer.STATE_OPEN: + # 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) + # Only send one large message per frame to avoid flooding + break + + elif status == WebSocketPeer.STATE_CLOSED: + var code: int = _ws.get_close_code() + _is_connected = false + _reconnect_timer = LilaConstants.RECONNECT_DELAY + disconnected.emit() + print("WebSocket closed (code ", code, "), reconnecting in ", LilaConstants.RECONNECT_DELAY, "s") + + +func _connect_to_server() -> void: + _is_connecting = true + var url: String = "ws://" + host + ":" + str(port) + "/ws" + print("Connecting to ", url) + 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() + print("WebSocket connected") + # 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: + var http: HTTPRequest = HTTPRequest.new() + http.request_completed.connect(_on_world_json_received) + add_child(http) + var url: String = "http://" + host + ":" + str(port) + "/world.json" + var err: Error = http.request(url) + if err != OK: + push_error("Failed to fetch world.json: ", err) + + +func _on_world_json_received(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void: + if response_code == 200: + var text: String = body.get_string_from_utf8() + var json_conv: JSON = JSON.new() + var parse_result: Error = json_conv.parse(text) + if parse_result == OK: + var data: Dictionary = json_conv.data + world_json_ready.emit(data) + print("World JSON loaded") + else: + push_error("Failed to parse world.json: ", json_conv.get_error_message()) + else: + push_error("world.json HTTP error: ", response_code) + + +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/constants.gd b/client/godot/scripts/constants.gd new file mode 100644 index 0000000..2c7da82 --- /dev/null +++ b/client/godot/scripts/constants.gd @@ -0,0 +1,83 @@ +## Shared constants mirroring browser/Python clients. +class_name LilaConstants + + +## 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 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 (for rendering) +const TYPE_COLORS: Dictionary = { + "ANIMAL": Color(0.9, 0.35, 0.2), + "BIRD": Color(0.3, 0.6, 0.9), + "INSECT": Color(0.95, 0.75, 0.1), + "PLANT": Color(0.2, 0.7, 0.2), + "TREE": Color(0.3, 0.55, 0.15), + "MICROORGANISM": Color(0.6, 0.4, 0.7), +} + +## Species name → color override +const SPECIES_COLORS: Dictionary = { + "deer": Color(0.8, 0.5, 0.3), + "wolf": Color(0.45, 0.45, 0.45), + "butterfly": Color(1.0, 0.85, 0.15), + "songbird": Color(0.3, 0.7, 1.0), + "meadow_oak": Color(0.25, 0.5, 0.15), + "meadow_grass": Color(0.35, 0.75, 0.25), + "wildflower": Color(1.0, 0.55, 0.3), + "mushroom": Color(0.7, 0.5, 0.6), +} + +## State → color tint for entity overlay +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[String] = ["ANIMAL", "BIRD", "INSECT"] + +## Particle colors +const PARTICLE_CONSUMPTION: Color = Color(0.3, 0.9, 0.3) +const PARTICLE_POLLINATION: Color = Color(1.0, 0.85, 0.1) +const PARTICLE_DEATH: Color = Color(0.35, 0.2, 0.1) diff --git a/client/godot/scripts/heartbeat.gd b/client/godot/scripts/heartbeat.gd new file mode 100644 index 0000000..96d3578 --- /dev/null +++ b/client/godot/scripts/heartbeat.gd @@ -0,0 +1,32 @@ +## Accumulates entity positions and agency events, sends heartbeats upstream. +## 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..cedae7f --- /dev/null +++ b/client/godot/scripts/particles.gd @@ -0,0 +1,68 @@ +## Simple particle system for event visualizations. +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..59ccab9 --- /dev/null +++ b/client/godot/scripts/reconciliation.gd @@ -0,0 +1,37 @@ +## Position reconciliation between client agency and server reference positions. +## Mirrors browser reconciliation.js and Python reconciliation.py. +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: + # Server acknowledged our deviation — trust it fully + if ent.ack: + ent.reconcile_queue.clear() + ent.reconcile_idx = 0 + ent.x = ent.ref_x + ent.z = ent.ref_z + return + + var dx: float = ent.ref_x - ent.x + var dz: float = ent.ref_z - ent.z + var divergence: float = sqrt(dx * dx + dz * dz) + + if divergence < LilaConstants.RECONCILE_MIN_DIVERGENCE: + return + + # Stagger sync: only reconcile every Nth tick based on sync_phase + var phase_aligned: bool = (tick % 4) == ent.sync_phase + if not phase_aligned and ent.reconcile_queue.size() > 0: + return + + # Enqueue ref_position as reconcile target (cap queue) + if ent.reconcile_queue.size() < LilaConstants.RECONCILE_QUEUE_MAX: + ent.reconcile_queue.append(Vector2(ent.ref_x, ent.ref_z)) + ent.reconcile_idx = 0 diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd new file mode 100644 index 0000000..a887be3 --- /dev/null +++ b/client/godot/scripts/renderer.gd @@ -0,0 +1,143 @@ +## Isometric voxel-style renderer. +## Draws the world as a pseudo-3D isometric grid with colored blocks. +## Swap this file for real 3D later — no simulation code changes needed. +class_name Renderer + + +## Isometric cell dimensions +const CELL_W: float = 32.0 +const CELL_H: float = 16.0 +const BLOCK_HEIGHT: float = 10.0 + +## World offset for centering +var _offset: Vector2 = Vector2.ZERO +var _grid_size: int = LilaConstants.GRID_SIZE + + +## Convert grid (x, z) to screen position (isometric projection). +static func grid_to_screen(gx: float, gz: float, offset: Vector2 = Vector2.ZERO) -> Vector2: + var sx: float = (gx - gz) * (CELL_W / 2.0) + offset.x + var sy: float = (gx + gz) * (CELL_H / 2.0) + offset.y + return Vector2(sx, sy) + + +## Draw the moisture heatmap grid. +static func draw_ground(viewport: Viewport, world: Node, offset: Vector2 = Vector2.ZERO) -> void: + var size: int = LilaConstants.GRID_SIZE + for gz in size: + for gx in size: + var pos: Vector2 = grid_to_screen(float(gx), float(gz), offset) + var idx: int = gx + gz * size + var moisture: float = 0.5 + if idx < world.moisture_grid.size(): + moisture = world.moisture_grid[idx] + + # Color from moisture: teal (dry) to amber (wet) + var color: Color = _moisture_color(moisture) + + # Draw isometric diamond + var points: PackedVector2Array = PackedVector2Array([ + pos + Vector2(CELL_W / 2.0, 0.0), + pos + Vector2(CELL_W / 4.0, CELL_H / 2.0), + pos + Vector2(0.0, 0.0), + pos + Vector2(-CELL_W / 4.0, -CELL_H / 2.0), + ]) + # Note: Godot 2D doesn't have a simple polygon draw in Viewport, + # we use draw functions in _draw() callback instead. + # This is a placeholder — actual drawing happens in main.gd _draw(). + + +## Draw all entities. +static func draw_entities(viewport: Viewport, world: Node, offset: Vector2 = Vector2.ZERO) -> void: + var entities: Array = world.get_alive() + # Sort by z for isometric depth ordering + entities.sort_custom(func(a, b): return a.z < b.z) + + for ent in entities: + draw_entity(viewport, ent, offset) + + +## Draw a single entity as a colored block. +static func draw_entity(viewport: Viewport, ent, offset: Vector2 = Vector2.ZERO) -> void: + var pos: Vector2 = grid_to_screen(ent.x, ent.z, offset) + + # Get color from species or type + var color: Color = _get_entity_color(ent) + var size: float = _get_entity_size(ent) + + # Height offset for insects (they "fly") + var height_offset: float = 0.0 + if ent.type == "INSECT": + height_offset = -20.0 + sin(Time.get_ticks_msec() / 300.0 + ent.sync_phase) * 5.0 + + var block_pos: Vector2 = pos + Vector2(0, height_offset) + + # Draw block (top face) + var half_w: float = size * CELL_W / 4.0 + var half_h: float = size * CELL_H / 4.0 + + # Top face (isometric diamond) + var top_points: PackedVector2Array = PackedVector2Array([ + block_pos + Vector2(half_w, -BLOCK_HEIGHT), + block_pos + Vector2(half_w * 0.5, -BLOCK_HEIGHT + half_h * 0.5), + block_pos + Vector2(0, -BLOCK_HEIGHT), + block_pos + Vector2(-half_w * 0.5, -BLOCK_HEIGHT - half_h * 0.5), + ]) + + +## Draw water sources. +static func draw_water_sources(viewport: Viewport, world: Node, offset: Vector2 = Vector2.ZERO) -> void: + for src: Dictionary in world.water_sources: + var pos: Vector2 = grid_to_screen(src.position.x, src.position.y, offset) + var radius: float = src.get("radius", 3.0) + var level: float = src.get("water_level", 1.0) + + # Effective radius based on water level + var effective_radius: float = radius * CELL_W * level + + # Water color with alpha based on level + var water_color: Color = Color(0.2, 0.4, 0.8, 0.4 * level) + + +## Draw particles. +static func draw_particles(viewport: Viewport, particles: Array, offset: Vector2 = Vector2.ZERO) -> void: + for particle in particles: + var pos: Vector2 = grid_to_screen(particle.position.x, particle.position.y, offset) + var color: Color = particle.color + color.a = particle.life / particle.max_life + + +## Get entity color from species or type. +static func _get_entity_color(ent) -> Color: + if ent.species in LilaConstants.SPECIES_COLORS: + return LilaConstants.SPECIES_COLORS[ent.species] + if ent.type in LilaConstants.TYPE_COLORS: + return LilaConstants.TYPE_COLORS[ent.type] + return Color(0.5, 0.5, 0.5) + + +## Get entity block size. +static func _get_entity_size(ent) -> float: + match ent.type: + "TREE": + return 3.0 + "ANIMAL": + return 1.5 + "BIRD": + return 1.0 + "INSECT": + return 0.7 + "PLANT": + return 0.8 + _: + return 1.0 + + +## Get color for moisture value. +static func _moisture_color(moisture: float) -> Color: + if moisture < 0.33: + return Color(0.9, 0.85, 0.7) # Sandy/dry + elif moisture < 0.66: + return Color(0.7, 0.8, 0.6) # Green/grassy + else: + return Color(0.4, 0.7, 0.6) # Teal/moist From 32c91fedc38f71f4917a084441f6a6a641a28455 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Fri, 19 Jun 2026 12:30:32 -0400 Subject: [PATCH 02/25] fix(godot): use local world.json, send def on WS connect, fix parse errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Load world.json from local resources instead of HTTP (websockets server passthrough returns empty body to Godot HTTPRequest) - Send world definition immediately on WebSocket open (was waiting for session_started which creates a chicken-and-egg) - Fix PackedStringArray → Array[String] for pop_front() - Fix hash_val.abs() → absi(hash_val) - Fix HORIZONTAL_ALIGNMENT.CENTER → integer 1 - Fix C-style for loop → while loop - Fix particles.gd class_name + inner class parse error - Add _is_connecting flag to prevent concurrent WS reconnection - Add bbcode_enabled to event log RichTextLabel - Fix death particle position (lookup entity before removal) --- client/godot/resources/world.json | 821 ++++++++++++++++++++ client/godot/scenes/main.gd | 5 - client/godot/scripts/autoloads/ws_client.gd | 61 +- 3 files changed, 858 insertions(+), 29 deletions(-) create mode 100755 client/godot/resources/world.json 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/main.gd b/client/godot/scenes/main.gd index 37ab894..148da8e 100644 --- a/client/godot/scenes/main.gd +++ b/client/godot/scenes/main.gd @@ -306,11 +306,6 @@ func _on_session_started(data: Dictionary) -> void: # Store species definitions World.species_defs = data.get("species", {}) - # Send world definition if we have it - if not _world_def.is_empty(): - WS.send_world_definition(_world_def) - print("World definition sent") - _session_started = true # Flush dead entities periodically diff --git a/client/godot/scripts/autoloads/ws_client.gd b/client/godot/scripts/autoloads/ws_client.gd index 63bca4a..9e39d0b 100644 --- a/client/godot/scripts/autoloads/ws_client.gd +++ b/client/godot/scripts/autoloads/ws_client.gd @@ -10,6 +10,8 @@ signal session_started(data: Dictionary) signal tick_packet(data: Dictionary) signal world_json_ready(data: Dictionary) +var _world_def_to_send: Dictionary = {} + ## State var _ws: WebSocketPeer @@ -26,8 +28,27 @@ var port: int = LilaConstants.DEFAULT_PORT func _ready() -> void: _ws = WebSocketPeer.new() - # Fetch world.json on startup - _fetch_world_json() + _load_world_json_local() + + +## 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() + + var json_conv: JSON = JSON.new() + var err: Error = json_conv.parse(text) + if err == OK: + var data: Dictionary = json_conv.data + _world_def_to_send = data + world_json_ready.emit(data) + print("World JSON loaded from local file") + else: + push_error("Failed to parse world.json: ", json_conv.get_error_message()) func _process(delta: float) -> void: @@ -89,6 +110,13 @@ func _connect_to_server() -> void: _is_connecting = false connected.emit() print("WebSocket connected") + + # Send world definition if we have it + if not _world_def_to_send.is_empty(): + _ws.send_text(JSON.stringify(_world_def_to_send)) + print("World definition sent") + _world_def_to_send = {} + # Flush any pending sends from before connection for msg in _pending_sends: _ws.send_text(msg) @@ -99,28 +127,13 @@ func _connect_to_server() -> void: func _fetch_world_json() -> void: - var http: HTTPRequest = HTTPRequest.new() - http.request_completed.connect(_on_world_json_received) - add_child(http) - var url: String = "http://" + host + ":" + str(port) + "/world.json" - var err: Error = http.request(url) - if err != OK: - push_error("Failed to fetch world.json: ", err) - - -func _on_world_json_received(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void: - if response_code == 200: - var text: String = body.get_string_from_utf8() - var json_conv: JSON = JSON.new() - var parse_result: Error = json_conv.parse(text) - if parse_result == OK: - var data: Dictionary = json_conv.data - world_json_ready.emit(data) - print("World JSON loaded") - else: - push_error("Failed to parse world.json: ", json_conv.get_error_message()) - else: - push_error("world.json HTTP error: ", response_code) + # 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: From fd7880eddf85229cfe7098d808643db679562552 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Fri, 19 Jun 2026 12:43:43 -0400 Subject: [PATCH 03/25] fix(server): coerce voxel grid dimensions to int for Godot client compat Godot's JSON.stringify converts integers to floats, which broke range(dx) in voxel_manager. Coerce dimensions to int at parse time. --- server/ecosim/environment_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) From d7d74149b80a875f1c21a4858c4237bdf313f3d9 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Fri, 19 Jun 2026 12:48:29 -0400 Subject: [PATCH 04/25] fix(server): coerce extra entity ranges to int for Godot client compat Godot JSON.stringify sends floats; randint needs ints. --- server/ecosim/layout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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(): From 75a80e00f9c8e5b9e5699b3ca6129353917cd663 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Fri, 19 Jun 2026 12:59:06 -0400 Subject: [PATCH 05/25] feat(godot): add camera orbit/zoom, fix polygon triangulation - Right/middle mouse drag to pan, scroll wheel to zoom (0.2x-4x) - Fix isometric ground and entity rendering to scale with zoom - Add guard for degenerate polygons when zoomed out - Remove duplicate _world_center variable --- client/godot/scenes/main.gd | 83 +++++++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 23 deletions(-) diff --git a/client/godot/scenes/main.gd b/client/godot/scenes/main.gd index 148da8e..3383199 100644 --- a/client/godot/scenes/main.gd +++ b/client/godot/scenes/main.gd @@ -26,7 +26,13 @@ var _event_count: int = 0 var _fps: int = 0 var _frame_count: int = 0 var _fps_timer: float = 0.0 -var _world_center: Vector2 = Vector2(640, 200) + +## Camera controls +var _camera_offset: Vector2 = Vector2(640, 200) +var _camera_zoom: float = 1.0 +var _dragging: bool = false +var _drag_start: Vector2 = Vector2.ZERO +var _drag_offset_start: Vector2 = Vector2.ZERO func _ready() -> void: @@ -43,8 +49,8 @@ func _ready() -> void: # Initialize particle system _particles = load("res://scripts/particles.gd").new() - # Center the world view - world_view.position = _world_center + # Center camera on world + _camera_offset = Vector2(get_viewport_rect().size.x / 2, 200) func _on_rain_pressed() -> void: @@ -100,6 +106,8 @@ func _draw() -> void: func _draw_ground() -> void: var size: int = LilaConstants.GRID_SIZE + var cw: float = CELL_W / 2.0 * _camera_zoom + var ch: float = CELL_H / 4.0 * _camera_zoom for gz in size: for gx in size: var pos: Vector2 = _grid_to_screen(float(gx), float(gz)) @@ -113,10 +121,10 @@ func _draw_ground() -> void: # Draw isometric diamond var pts: PackedVector2Array = PackedVector2Array([ - pos + Vector2(CELL_W / 2.0, 0.0), - pos + Vector2(CELL_W / 4.0, CELL_H / 4.0), + pos + Vector2(cw, 0.0), + pos + Vector2(cw * 0.5, ch), pos + Vector2(0.0, 0.0), - pos + Vector2(-CELL_W / 4.0, -CELL_H / 4.0), + pos + Vector2(-cw * 0.5, -ch), ]) draw_colored_polygon(pts, color) @@ -148,22 +156,26 @@ func _draw_entities() -> void: var color: Color = _get_entity_color(ent) var size: float = _get_entity_size(ent) + # Skip degenerate polygons (zero or negative size after zoom) + if size * _camera_zoom <= 0.1: + continue + # Height offset for insects var height_offset: float = 0.0 if ent.type == "INSECT": height_offset = -20.0 + sin(Time.get_ticks_msec() / 300.0 + float(ent.sync_phase)) * 5.0 var block_pos: Vector2 = pos + Vector2(0, height_offset) - var half_w: float = size * CELL_W / 4.0 - var half_h: float = size * CELL_H / 4.0 + var half_w: float = size * CELL_W / 4.0 * _camera_zoom + var half_h: float = size * CELL_H / 4.0 * _camera_zoom + var block_h: float = BLOCK_HEIGHT * size * _camera_zoom # Draw block top face (isometric diamond) - var top_y: float = block_pos.y - BLOCK_HEIGHT * size var top_pts: PackedVector2Array = PackedVector2Array([ - block_pos + Vector2(half_w, -BLOCK_HEIGHT * size), - block_pos + Vector2(half_w * 0.5, -BLOCK_HEIGHT * size + half_h * 0.5), - block_pos + Vector2(0.0, -BLOCK_HEIGHT * size), - block_pos + Vector2(-half_w * 0.5, -BLOCK_HEIGHT * size - half_h * 0.5), + block_pos + Vector2(half_w, -block_h), + block_pos + Vector2(half_w * 0.5, -block_h + half_h * 0.5), + block_pos + Vector2(0.0, -block_h), + block_pos + Vector2(-half_w * 0.5, -block_h - half_h * 0.5), ]) # Darken for side effect @@ -239,8 +251,8 @@ func _draw_grid_overlay() -> void: ## Convert grid (x, z) to screen position (isometric). func _grid_to_screen(gx: float, gz: float) -> Vector2: - var sx: float = (gx - gz) * (CELL_W / 2.0) + _world_center.x - var sy: float = (gx + gz) * (CELL_H / 2.0) + _world_center.y + var sx: float = (gx - gz) * (CELL_W / 2.0) * _camera_zoom + _camera_offset.x + var sy: float = (gx + gz) * (CELL_H / 2.0) * _camera_zoom + _camera_offset.y return Vector2(sx, sy) @@ -374,11 +386,36 @@ func _on_tick_packet(data: Dictionary) -> void: func _input(event: InputEvent) -> void: - if event is InputEventKey: - if 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") + # Key controls + 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") + + # Camera pan with right/middle mouse drag + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_RIGHT or event.button_index == MOUSE_BUTTON_MIDDLE: + if event.pressed: + _dragging = true + _drag_start = event.position + _drag_offset_start = _camera_offset + else: + _dragging = false + + # Camera zoom with mouse wheel + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_WHEEL_UP: + _camera_zoom = minf(_camera_zoom * 1.2, 4.0) + queue_redraw() + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + _camera_zoom = maxf(_camera_zoom * 0.8, 0.2) + queue_redraw() + + # Pan while dragging + if _dragging and event is InputEventMouseMotion: + var delta: Vector2 = event.position - _drag_start + _camera_offset = _drag_offset_start + delta + queue_redraw() From 120d5c4136ba8ae4a2c9f03cb6fc4b6299cb941d Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sat, 20 Jun 2026 11:17:37 -0400 Subject: [PATCH 06/25] fix(godot): fix entity jitter, marching, and reconciliation drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs causing entities to jiggle in place and march off-grid: 1. Gravity well missing `* delta` multiplication — nudge was applied at full strength every frame (60x too strong), causing violent overshoot/oscillation around ref_position. Now matches Python/JS: nudge = GRAVITY_WELL_FACTOR * sync_speed * delta, with 0.2 unit proximity threshold to skip when already close. 2. Wander target regenerated every frame — \_evaluate_wandering() picked a new random target each frame instead of reusing an existing one until reached. This caused jitter as the entity chased a moving target. Now tracks has_target/last_action_type to persist wander targets, mirroring Python/browser behavior. 3. Reconciliation missing \_lastReconciledTick — used (tick % 4) == sync_phase instead of tracking ticks since last reconcile. Added queue pruning, last_reconciled_tick tracking, and proper stagger logic to match Python/browser reconciliation. Also added telemetry logging (every 10 ticks) printing entity divergence stats for debugging. --- client/godot/scenes/main.gd | 492 ++++++++---------- client/godot/scripts/agency.gd | 57 +- client/godot/scripts/autoloads/world_model.gd | 25 +- client/godot/scripts/reconciliation.gd | 67 ++- 4 files changed, 326 insertions(+), 315 deletions(-) diff --git a/client/godot/scenes/main.gd b/client/godot/scenes/main.gd index 3383199..5cafb66 100644 --- a/client/godot/scenes/main.gd +++ b/client/godot/scenes/main.gd @@ -1,18 +1,16 @@ -## Main scene — orchestrates WebSocket, world model, agency, heartbeat, rendering. -extends Node2D - - -## Isometric rendering constants -const CELL_W: float = 32.0 -const CELL_H: float = 16.0 -const BLOCK_HEIGHT: float = 10.0 +## Main scene - 3D world view with orbit camera. +## Grid coordinates map 1:1 to world X/Z; Y is height. +extends Node3D +@onready var camera: Camera3D = $Camera +@onready var ground_mi: MeshInstance3D = $Ground +@onready var entity_multi: MultiMeshInstance3D = $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 -@onready var world_view: Node2D = $WorldView var _agency: Agency = Agency.new() var _heartbeat: HeartbeatSender = HeartbeatSender.new() @@ -27,42 +25,83 @@ var _fps: int = 0 var _frame_count: int = 0 var _fps_timer: float = 0.0 -## Camera controls -var _camera_offset: Vector2 = Vector2(640, 200) -var _camera_zoom: float = 1.0 -var _dragging: bool = false -var _drag_start: Vector2 = Vector2.ZERO -var _drag_offset_start: Vector2 = Vector2.ZERO +## Entity block height in world units. +const BLOCK_HEIGHT: float = 2.0 func _ready() -> void: - print("Lila Godot Client starting...") + print("Lila Godot Client starting (3D)...") - # Connect to WebSocket signals WS.session_started.connect(_on_session_started) WS.tick_packet.connect(_on_tick_packet) WS.world_json_ready.connect(_on_world_json_ready) - - # Connect rain button rain_button.pressed.connect(_on_rain_pressed) - # Initialize particle system _particles = load("res://scripts/particles.gd").new() + _setup_particles() + _setup_entities() - # Center camera on world - _camera_offset = Vector2(get_viewport_rect().size.x / 2, 200) + # 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() -func _on_rain_pressed() -> void: - WS.send_control("rain", {"intensity": 0.8}) - _add_hud_event("☔ Rain triggered!") +# ── Entity MultiMesh setup ──────────────────────────────────────── +func _setup_entities() -> void: + var box: BoxMesh = BoxMesh.new() + box.size = Vector3(1.0, 1.0, 1.0) -func _on_world_json_ready(data: Dictionary) -> void: - _world_def = data - # World JSON is loaded; send it once WS connects - print("World definition loaded, sending on connect...") + var mm: MultiMesh = MultiMesh.new() + mm.mesh = box + mm.transform_format = MultiMesh.TRANSFORM_3D + mm.use_colors = true + mm.instance_count = 0 + entity_multi.multimesh = mm + + +# ── Particle MultiMesh setup ────────────────────────────────────── +func _setup_particles() -> void: + # Tiny box as particle marker (SphereMesh API changed in 4.7) + var box: BoxMesh = BoxMesh.new() + box.size = Vector3(0.4, 0.4, 0.4) + + var mm: MultiMesh = MultiMesh.new() + mm.mesh = box + mm.transform_format = MultiMesh.TRANSFORM_3D + mm.use_colors = true + mm.instance_count = 500 + particle_instance.multimesh = mm + + +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_color(i, c) + + +# ── ImmediateMesh rendering ────────────────────────────────────── +# Ground mesh: locked once in _ready, updated every frame. +# Entity mesh: same pattern. func _process(delta: float) -> void: # FPS counter @@ -74,269 +113,125 @@ func _process(delta: float) -> void: _fps_timer = 0.0 _update_stats() - # Step agency (60 Hz) — WS is an autoload that handles its own _process if _session_started: var now: float = Time.get_ticks_msec() / 1000.0 var events: Array = _agency.step(World, delta) - - # Queue events for heartbeat for evt: Dictionary in events: _heartbeat.queue_event(evt) - - # Step heartbeat sender _heartbeat.tick(WS, World, now) - - # Step particles _particles.step(delta) - # Redraw - queue_redraw() - + # Rebuild meshes every frame + _build_ground() + _build_entities() + _update_particle_mesh() -func _draw() -> void: - if not _session_started: - return - _draw_ground() - _draw_water_sources() - _draw_entities() - _draw_particles() - _draw_grid_overlay() +## Build ground tiles as ArrayMesh via SurfaceTool. +func _build_ground() -> void: + var st: SurfaceTool = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) - -func _draw_ground() -> void: var size: int = LilaConstants.GRID_SIZE - var cw: float = CELL_W / 2.0 * _camera_zoom - var ch: float = CELL_H / 4.0 * _camera_zoom + var half: float = 0.49 # slight gap avoids z-fighting seams + for gz in size: for gx in size: - var pos: Vector2 = _grid_to_screen(float(gx), float(gz)) - - # Moisture color var idx: int = gx + gz * size var moisture: float = 0.5 if idx < World.moisture_grid.size(): moisture = World.moisture_grid[idx] - var color: Color = _moisture_color(moisture) - - # Draw isometric diamond - var pts: PackedVector2Array = PackedVector2Array([ - pos + Vector2(cw, 0.0), - pos + Vector2(cw * 0.5, ch), - pos + Vector2(0.0, 0.0), - pos + Vector2(-cw * 0.5, -ch), - ]) - draw_colored_polygon(pts, color) - - -func _draw_water_sources() -> void: - for src: Dictionary in World.water_sources: - var pos: Vector2 = _grid_to_screen(src.position.x, src.position.y) - var radius: float = src.get("radius", 3.0) - var level: float = src.get("water_level", 1.0) - - # Water ellipse (isometric projection of circle) - var rx: float = radius * CELL_W * level * 0.5 - var ry: float = radius * CELL_H * level * 0.5 - var water_color: Color = Color(0.2, 0.4, 0.8, 0.5 * level) - - draw_circle(pos, rx, water_color) - # Inner ripple - var ripple: float = sin(Time.get_ticks_msec() / 500.0) * 0.2 + 0.8 - draw_circle(pos, rx * ripple * 0.7, Color(0.3, 0.6, 1.0, 0.3 * level)) - + st.set_color(_moisture_color(moisture)) + + var cx: float = float(gx) + var cz: float = float(gz) + # Two triangles per tile + st.add_vertex(Vector3(cx - half, 0.0, cz - half)) + st.add_vertex(Vector3(cx + half, 0.0, cz - half)) + st.add_vertex(Vector3(cx - half, 0.0, cz + half)) + st.add_vertex(Vector3(cx + half, 0.0, cz - half)) + st.add_vertex(Vector3(cx + half, 0.0, cz + half)) + st.add_vertex(Vector3(cx - half, 0.0, cz + half)) + + st.generate_normals() + var mesh: Mesh = st.commit() + ground_mi.mesh = mesh + + +## Build entity cubes as MultiMesh instances (BoxMesh primitive). +func _build_entities() -> void: + var mm: MultiMesh = entity_multi.multimesh + if mm == null: + return -func _draw_entities() -> void: var entities: Array = World.get_alive() - # Sort by isometric depth (x + z) - entities.sort_custom(func(a, b): return (a.x + a.z) < (b.x + b.z)) + mm.instance_count = entities.size() + + for i in entities.size(): + var ent = entities[i] + if is_nan(ent.x) or is_nan(ent.z) or is_inf(ent.x) or is_inf(ent.z): + continue - for ent in entities: - var pos: Vector2 = _grid_to_screen(ent.x, ent.z) var color: Color = _get_entity_color(ent) var size: float = _get_entity_size(ent) - # Skip degenerate polygons (zero or negative size after zoom) - if size * _camera_zoom <= 0.1: - continue - - # Height offset for insects - var height_offset: float = 0.0 + var y_off: float = 0.0 if ent.type == "INSECT": - height_offset = -20.0 + sin(Time.get_ticks_msec() / 300.0 + float(ent.sync_phase)) * 5.0 - - var block_pos: Vector2 = pos + Vector2(0, height_offset) - var half_w: float = size * CELL_W / 4.0 * _camera_zoom - var half_h: float = size * CELL_H / 4.0 * _camera_zoom - var block_h: float = BLOCK_HEIGHT * size * _camera_zoom - - # Draw block top face (isometric diamond) - var top_pts: PackedVector2Array = PackedVector2Array([ - block_pos + Vector2(half_w, -block_h), - block_pos + Vector2(half_w * 0.5, -block_h + half_h * 0.5), - block_pos + Vector2(0.0, -block_h), - block_pos + Vector2(-half_w * 0.5, -block_h - half_h * 0.5), - ]) - - # Darken for side effect - var side_color: Color = color.darkened(0.3) - - # Right side face - var right_pts: PackedVector2Array = PackedVector2Array([ - block_pos + Vector2(half_w, 0.0), - block_pos + Vector2(half_w * 0.5, -half_h * 0.5), - top_pts[1], - top_pts[0], - ]) - draw_colored_polygon(right_pts, side_color) - - # Left side face - var left_side_color: Color = color.darkened(0.5) - var left_pts: PackedVector2Array = PackedVector2Array([ - block_pos + Vector2(-half_w, 0.0), - block_pos + Vector2(-half_w * 0.5, -half_h * 0.5), - top_pts[3], - top_pts[2], - ]) - draw_colored_polygon(left_pts, left_side_color) - - # Top face - draw_colored_polygon(top_pts, color) - - # Dormant overlay - if ent.state == "DORMANT": - draw_colored_polygon(top_pts, Color(0.3, 0.2, 0.15, 0.7)) - - # State label for mobile entities - if ent.type in ["ANIMAL", "BIRD", "INSECT"]: - var label_pos: Vector2 = block_pos + Vector2(0, -BLOCK_HEIGHT * size - half_h - 5) - var state_color: Color = LilaConstants.STATE_COLORS.get(ent.state, Color.WHITE) - draw_string( - ThemeDB.fallback_font, - label_pos, - ent.state, - 1, - -1, - 10, - state_color - ) - - -func _draw_particles() -> void: - for particle in _particles.get_alive(): - var pos: Vector2 = _grid_to_screen(particle.position.x, particle.position.y) - var color: Color = particle.color - color.a = maxf(0.0, particle.life / particle.max_life) - var radius: float = particle.size * 5.0 - draw_circle(pos, radius, color) - - -func _draw_grid_overlay() -> void: - # Draw subtle grid lines at major intervals - var size: int = LilaConstants.GRID_SIZE - var step: int = 8 - var grid_color: Color = Color(1.0, 1.0, 1.0, 0.08) - - for i in range(0, size, step): - # Lines along x - var p1: Vector2 = _grid_to_screen(float(i), 0.0) - var p2: Vector2 = _grid_to_screen(float(i), float(size - 1)) - draw_line(p1, p2, grid_color) - - # Lines along z - var p3: Vector2 = _grid_to_screen(0.0, float(i)) - var p4: Vector2 = _grid_to_screen(float(size - 1), float(i)) - draw_line(p3, p4, grid_color) + y_off = 3.0 + sin(Time.get_ticks_msec() / 300.0 + float(ent.sync_phase)) * 0.8 + var cx: float = ent.x + var cy: float = BLOCK_HEIGHT * size * 0.5 + y_off + var cz: float = ent.z -## Convert grid (x, z) to screen position (isometric). -func _grid_to_screen(gx: float, gz: float) -> Vector2: - var sx: float = (gx - gz) * (CELL_W / 2.0) * _camera_zoom + _camera_offset.x - var sy: float = (gx + gz) * (CELL_H / 2.0) * _camera_zoom + _camera_offset.y - return Vector2(sx, sy) + # Dormant entities are darker + if ent.state == "DORMANT": + color = color.darkened(0.5) + var t: Transform3D + t.origin = Vector3(cx, cy, cz) + t.basis = Basis.from_scale(Vector3(size, BLOCK_HEIGHT * size, size)) + mm.set_instance_transform(i, t) + mm.set_instance_color(i, color) -## Color for moisture value. -func _moisture_color(moisture: float) -> Color: - if moisture < 0.33: - return Color(0.9, 0.85, 0.7) # Sandy - elif moisture < 0.66: - return Color(0.7, 0.8, 0.6) # Grassy - else: - return Color(0.4, 0.7, 0.6) # Moist teal +# ── Input ───────────────────────────────────────────────────────── -## Entity color from species/type. -func _get_entity_color(ent) -> Color: - if ent.species in LilaConstants.SPECIES_COLORS: - return LilaConstants.SPECIES_COLORS[ent.species] - if ent.type in LilaConstants.TYPE_COLORS: - return LilaConstants.TYPE_COLORS[ent.type] - return Color(0.5, 0.5, 0.5) - +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") -## Entity block size. -func _get_entity_size(ent) -> float: - match ent.type: - "TREE": - return 3.0 - "ANIMAL": - return 1.5 - "BIRD": - return 1.0 - "INSECT": - return 0.7 - "PLANT": - return 0.8 - _: - return 1.0 +# ── WebSocket callbacks ─────────────────────────────────────────── -## Update HUD stats. -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 _on_rain_pressed() -> void: + WS.send_control("rain", {"intensity": 0.8}) + _add_hud_event("☔ Rain triggered!") -## Add event to HUD log. -func _add_hud_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_world_json_ready(data: Dictionary) -> void: + _world_def = data -## Handle session_started from server. func _on_session_started(data: Dictionary) -> void: print("Session started: ", data.get("session_id", "")) - print("Entities: ", data.get("entity_count", 0)) - - # Store species definitions World.species_defs = data.get("species", {}) - _session_started = true - - # Flush dead entities periodically World.flush_dead() -## Handle tick packet from server. func _on_tick_packet(data: Dictionary) -> void: _current_tick = data.get("tick", _current_tick) - # Apply entity updates for update: Dictionary in data.get("entity_updates", []): World.apply_update(update) - - # Apply spawns for spawn: Dictionary in data.get("entity_spawns", []): World.apply_spawn(spawn) - # Apply removals 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 @@ -344,17 +239,13 @@ func _on_tick_packet(data: Dictionary) -> void: World.apply_removal(removal_id) _particles.spawn(px, pz, "DEATH_NATURAL", 6) - # Apply voxel deltas var voxels: Variant = data.get("voxel_deltas", null) if voxels != null: World.apply_voxel_deltas(voxels) - - # Apply water sources var waters: Variant = data.get("water_sources", null) if waters != null: World.apply_water_sources(waters) - # Process events for evt: Dictionary in data.get("events", []): var evt_type: String = evt.get("type", "") var source_id: String = evt.get("source_id", "") @@ -374,48 +265,87 @@ func _on_tick_packet(data: Dictionary) -> void: _add_hud_event("💀 " + source_id + " died") "REPRODUCTION": _add_hud_event("🐣 " + source_id + " reproduced") - "STATE_CHANGE": - pass # Silent, handled by entity state update - # Reconcile positions _reconciliation.reconcile(World, _current_tick) - - # Flush dead entities periodically if _current_tick % 10 == 0: World.flush_dead() + # Debug: log entity positions every 10 ticks for telemetry comparison + if _current_tick % 10 == 0: + _log_entity_telemetry() -func _input(event: InputEvent) -> void: - # Key controls - 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") - # Camera pan with right/middle mouse drag - if event is InputEventMouseButton: - if event.button_index == MOUSE_BUTTON_RIGHT or event.button_index == MOUSE_BUTTON_MIDDLE: - if event.pressed: - _dragging = true - _drag_start = event.position - _drag_offset_start = _camera_offset - else: - _dragging = false - - # Camera zoom with mouse wheel - if event is InputEventMouseButton: - if event.button_index == MOUSE_BUTTON_WHEEL_UP: - _camera_zoom = minf(_camera_zoom * 1.2, 4.0) - queue_redraw() - elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: - _camera_zoom = maxf(_camera_zoom * 0.8, 0.2) - queue_redraw() - - # Pan while dragging - if _dragging and event is InputEventMouseMotion: - var delta: Vector2 = event.position - _drag_start - _camera_offset = _drag_offset_start + delta - queue_redraw() +# ── 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))) + + +# ── Color helpers ───────────────────────────────────────────────── + +func _moisture_color(moisture: float) -> Color: + if moisture < 0.33: + return Color(0.9, 0.85, 0.7) + elif moisture < 0.66: + return Color(0.7, 0.8, 0.6) + else: + return Color(0.4, 0.7, 0.6) + + +func _get_entity_color(ent) -> Color: + if ent.species in LilaConstants.SPECIES_COLORS: + return LilaConstants.SPECIES_COLORS[ent.species] + if ent.type in LilaConstants.TYPE_COLORS: + return LilaConstants.TYPE_COLORS[ent.type] + return Color(0.5, 0.5, 0.5) + + +func _get_entity_size(ent) -> float: + match ent.type: + "TREE": + return 3.0 + "ANIMAL": + return 1.5 + "BIRD": + return 1.0 + "INSECT": + return 0.7 + "PLANT": + return 0.8 + _: + return 1.0 + + +# ── Telemetry / Debug helpers ────────────────────────────────────────── + +func _log_entity_telemetry() -> void: + """Log entity positions for debugging reconciliation. Mirrors server telemetry.""" + 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(), + ] + print(log_line) diff --git a/client/godot/scripts/agency.gd b/client/godot/scripts/agency.gd index 0320d17..3431ac8 100644 --- a/client/godot/scripts/agency.gd +++ b/client/godot/scripts/agency.gd @@ -28,53 +28,59 @@ func _step_entity(ent, world: Node, delta: float, now: float) -> Array: return events # Check for reconcile target first - if ent.reconcile_queue.size() > 0: + 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 - var behavior: String = "" if ent.state == "FLEEING": var flee_result: Dictionary = _evaluate_fleeing(ent, world) target = flee_result.get("target", Vector2.ZERO) - behavior = "fleeing" + 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) - behavior = "drinking" + 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) - behavior = "reproducing" + 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) - behavior = "foraging" + 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) - behavior = "hunting" + 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) - behavior = "pollinating" + ent.last_action_type = "pollinating" events.append_array(poll_result.get("events", [])) else: target = _evaluate_wandering(ent, delta) - behavior = "wandering" + 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 - ent.x += (ent.ref_x - ent.x) * LilaConstants.GRAVITY_WELL_FACTOR * speed_factor - ent.z += (ent.ref_z - ent.z) * LilaConstants.GRAVITY_WELL_FACTOR * speed_factor + 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)) @@ -302,18 +308,21 @@ func _evaluate_pollination(ent, world: Node) -> Dictionary: func _evaluate_wandering(ent, delta: float) -> Vector2: - # Random wander target modulated by motion latent + # 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 - var caution: float = 0.0 - if latent.size() >= 2: + if latent.size() >= 1: pace = 0.5 + latent[0] * 0.5 # Map to 0-1 range roughly - caution = latent[1] var wander_range: float = LilaConstants.WANDER_MARGIN * pace - var wobble: float = caution * 2.0 - - var angle: float = randf() * TAU * wobble + ent.facing_angle + 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)) @@ -325,9 +334,6 @@ func _move_toward(ent, target: Vector2, delta: float, world: Node) -> void: var dz: float = target.y - ent.z var dist: float = sqrt(dx * dx + dz * dz) - if dist < LilaConstants.ARRIVAL_DISTANCE: - return - # 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) @@ -341,9 +347,18 @@ func _move_toward(ent, target: Vector2, delta: float, world: Node) -> void: 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 ent.facing_angle = atan2(dz, dx) + + # 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 index 9fc083b..5bfd4d0 100644 --- a/client/godot/scripts/autoloads/world_model.gd +++ b/client/godot/scripts/autoloads/world_model.gd @@ -38,7 +38,7 @@ class WorldEntity: ## Acknowledgment from server var ack: bool = false - ## Reconciliation + ## Reconciliation — queue of target positions consumed smoothly by agency var reconcile_queue: PackedVector2Array = PackedVector2Array() var reconcile_idx: int = 0 @@ -50,6 +50,15 @@ class WorldEntity: 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 @@ -87,13 +96,23 @@ func apply_update(data: Dictionary) -> void: 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 - ent.ref_x = _vec_x(data.get("ref_position", [0, 0, 0])) - ent.ref_z = _vec_z(data.get("ref_position", [0, 0, 0])) + 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", {}) diff --git a/client/godot/scripts/reconciliation.gd b/client/godot/scripts/reconciliation.gd index 59ccab9..70f4c41 100644 --- a/client/godot/scripts/reconciliation.gd +++ b/client/godot/scripts/reconciliation.gd @@ -1,5 +1,21 @@ ## Position reconciliation between client agency and server reference positions. ## Mirrors browser reconciliation.js and Python reconciliation.py. +## +## 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. +## +## 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 @@ -11,27 +27,58 @@ static func reconcile(world: Node, tick: int) -> void: static func _reconcile_entity(ent, tick: int) -> void: - # Server acknowledged our deviation — trust it fully + # 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.x = ent.ref_x - ent.z = ent.ref_z + ent.last_reconciled_tick = tick return - var dx: float = ent.ref_x - ent.x - var dz: float = ent.ref_z - ent.z + 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 - # Stagger sync: only reconcile every Nth tick based on sync_phase - var phase_aligned: bool = (tick % 4) == ent.sync_phase - if not phase_aligned and ent.reconcile_queue.size() > 0: - return + # Prune completed targets before enqueueing new one. + _prune_queue(ent) - # Enqueue ref_position as reconcile target (cap queue) + # 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 From cfef168aad8f20d9310e9fbe4b527e72f3125ab5 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sat, 20 Jun 2026 11:19:38 -0400 Subject: [PATCH 07/25] feat(godot): migrate from isometric 2D to 3D world view - Add orbit camera (LMB orbit, RMB pan, scroll zoom) - Add directional + ambient 3D lighting - Ground mesh, entity MultiMesh, particle MultiMesh nodes - Add help label with control hints to HUD - Switch main.gd from Node2D to Node3D with @onready bindings --- client/godot/scenes/main.tscn | 40 ++++++- client/godot/scripts/camera/orbit_camera.gd | 109 ++++++++++++++++++++ 2 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 client/godot/scripts/camera/orbit_camera.gd diff --git a/client/godot/scenes/main.tscn b/client/godot/scenes/main.tscn index dbadafc..60a2c97 100644 --- a/client/godot/scenes/main.tscn +++ b/client/godot/scenes/main.tscn @@ -1,11 +1,40 @@ -[gd_scene load_steps=2 format=3] +[gd_scene load_steps=3 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"] -[node name="Main" type="Node2D"] +[sub_resource type="Environment" id="env1"] +background_mode = 1 +background_color = Color(0.6, 0.72, 0.82, 1) +glow/intensity = 0.2 + +[node name="Main" type="Node3D"] script = ExtResource("1") -[node name="WorldView" type="Node2D" parent="."] +[node name="Camera" type="Camera3D" parent="."] +script = ExtResource("2") +fov = 50.0 + +[node name="WorldLight" type="DirectionalLight3D" parent="."] +transform = Transform3D(0.707107, 0, 0.707107, 0, 1, 0, -0.707107, 0, 0.707107, 10, 15, 10) +shadow_enabled = false +color = Color(1, 0.95, 0.9, 1) +energy = 2.0 + +[node name="AmbientLight" type="OmniLight3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16, 10, 16) +color = Color(0.5, 0.55, 0.65, 1) +energy = 3.0 +omni_range = 100.0 + +[node name="Ground" type="MeshInstance3D" parent="."] + +[node name="Entities" type="MultiMeshInstance3D" parent="."] + +[node name="Particles" type="MultiMeshInstance3D" parent="."] + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("env1") [node name="HUD" type="CanvasLayer" parent="."] layer = 10 @@ -32,3 +61,8 @@ 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/camera/orbit_camera.gd b/client/godot/scripts/camera/orbit_camera.gd new file mode 100644 index 0000000..7313376 --- /dev/null +++ b/client/godot/scripts/camera/orbit_camera.gd @@ -0,0 +1,109 @@ +## 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 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) From ce7512fda03d407b87ece309c791a00971a1804e Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sat, 20 Jun 2026 11:28:06 -0400 Subject: [PATCH 08/25] docs: add Godot Client architecture and implementation details New section documenting the 3D Godot 4.x client: - Project scaffolding, file layout, autoloads (WS + World) - Node tree: Node3D + Camera3D + MultiMesh entities/particles + HUD - WebSocket layer: connect, dispatch, session_started, tick_packet - World model: WorldEntity fields, spatial queries, sync personality - Client-side agency: 60 Hz behavior priority chain, wander target persistence, gravity well, interaction triggers - Reconciliation: staggered sync, queue management, last_reconciled_tick - Heartbeat sender: 1 Hz upstream position/event reporting - Rendering: ground mesh (SurfaceTool), entity cubes, particles - Orbit camera: spherical coordinate orbit, pan, zoom - Constants module: shared with browser/Python clients - Telemetry logging: divergence stats every 10 ticks - Sync bugs fixed: gravity well delta, wander reuse, last_reconciled_tick - Current limitations: no skeletal animation, no water rendering --- LILA_PROJECT_STATE.md | 191 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) 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. From e9255ee087c3c8713f843d47fbb4a3871749e825 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sat, 20 Jun 2026 12:36:49 -0400 Subject: [PATCH 09/25] feat(godot): primitive-based 3D entity renderer with browser color harmony Replace single BoxMesh-per-entity with per-type composite meshes built from SurfaceTool primitives (cylinder, sphere, cone, planes). Each entity type gets its own MultiMeshInstance3D with a unique shape: - Deer (ANIMAL): cylinder body + hemispherical head + cone snout + legs - Songbird (BIRD): capsule body + nose cone + tail cone + wing planes - Butterfly (INSECT): small body + 4 wing planes (upper/lower pairs) - Oak (TREE): cylinder trunk + sphere canopy - Grass (PLANT): 3 angled blade planes - Wildflower (PLANT): stem cylinder + bloom sphere - Mushroom (MICROORGANISM): stalk + flat-topped cap State-aware sizing: trees/plants scale by growth, mushrooms by activity, insects float with sine oscillation, birds fly higher. Dormant entities are darkened 55%, wilted plants shift to brown. Harmonize all type/species/particle colors with the browser renderer palette (constants.js). Water sources rendered as flat cylinders with animated alpha pulse. Scene restructuring: - Add Renderer node (composite mesh builder) - Replace single Entities MultiMeshInstance3D with per-type instances - Add WaterSources Node3D parent - Dark background matching browser (#0f100f) --- .../resources/shaders/water_surface.shader | 31 + client/godot/scenes/main.gd | 159 +-- client/godot/scenes/main.tscn | 35 +- client/godot/scripts/constants.gd | 41 +- client/godot/scripts/renderer.gd | 908 ++++++++++++++++-- 5 files changed, 924 insertions(+), 250 deletions(-) create mode 100644 client/godot/resources/shaders/water_surface.shader 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/scenes/main.gd b/client/godot/scenes/main.gd index 5cafb66..21add4d 100644 --- a/client/godot/scenes/main.gd +++ b/client/godot/scenes/main.gd @@ -1,12 +1,15 @@ ## Main scene - 3D world view with orbit camera. ## Grid coordinates map 1:1 to world X/Z; Y is height. +## Uses primitive-based InstancedMesh rendering per entity type. extends Node3D @onready var camera: Camera3D = $Camera +@onready var renderer: Node = $Renderer @onready var ground_mi: MeshInstance3D = $Ground -@onready var entity_multi: MultiMeshInstance3D = $Entities +@onready var entity_parent: Node3D = $Entities @onready var particle_instance: MultiMeshInstance3D = $Particles +@onready var water_parent: Node3D = $WaterSources @onready var hud: CanvasLayer = $HUD @onready var stats_label: Label = $HUD/VBox/StatsLabel @onready var event_log: RichTextLabel = $HUD/VBox/EventLog @@ -25,12 +28,14 @@ var _fps: int = 0 var _frame_count: int = 0 var _fps_timer: float = 0.0 -## Entity block height in world units. -const BLOCK_HEIGHT: float = 2.0 +# Renderer state +var _type_meshes: Dictionary = {} +var _water_shader_mat: Object = null +var _water_instances: Dictionary = {} func _ready() -> void: - print("Lila Godot Client starting (3D)...") + print("Lila Godot Client starting (3D — primitive renderer)...") WS.session_started.connect(_on_session_started) WS.tick_packet.connect(_on_tick_packet) @@ -39,7 +44,7 @@ func _ready() -> void: _particles = load("res://scripts/particles.gd").new() _setup_particles() - _setup_entities() + _setup_renderer() # Orbit target = center of grid camera.target = Vector3( @@ -50,26 +55,27 @@ func _ready() -> void: camera._update_position() -# ── Entity MultiMesh setup ──────────────────────────────────────── +# ── Renderer setup ──────────────────────────────────────────────────── -func _setup_entities() -> void: - var box: BoxMesh = BoxMesh.new() - box.size = Vector3(1.0, 1.0, 1.0) +func _setup_renderer() -> void: + # Build composite ArrayMeshes for each entity type + var meshes: Dictionary = renderer.build_all_type_meshes() - var mm: MultiMesh = MultiMesh.new() - mm.mesh = box - mm.transform_format = MultiMesh.TRANSFORM_3D - mm.use_colors = true - mm.instance_count = 0 - entity_multi.multimesh = mm + # Create InstancedMesh nodes under Entities parent + _type_meshes = renderer.setup_type_meshes(entity_parent, meshes) + + # Water material — semi-transparent blue pool + _water_shader_mat = StandardMaterial3D.new() + _water_shader_mat.albedo_color = Color(0.22, 0.42, 0.53, 0.6) + _water_shader_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + _water_shader_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED -# ── Particle MultiMesh setup ────────────────────────────────────── +# ── Particle MultiMesh setup ────────────────────────────────────────── func _setup_particles() -> void: - # Tiny box as particle marker (SphereMesh API changed in 4.7) var box: BoxMesh = BoxMesh.new() - box.size = Vector3(0.4, 0.4, 0.4) + box.size = Vector3(0.3, 0.3, 0.3) var mm: MultiMesh = MultiMesh.new() mm.mesh = box @@ -99,9 +105,7 @@ func _update_particle_mesh() -> void: mm.set_instance_color(i, c) -# ── ImmediateMesh rendering ────────────────────────────────────── -# Ground mesh: locked once in _ready, updated every frame. -# Entity mesh: same pattern. +# ── Main loop ───────────────────────────────────────────────────────── func _process(delta: float) -> void: # FPS counter @@ -124,77 +128,34 @@ func _process(delta: float) -> void: # Rebuild meshes every frame _build_ground() _build_entities() + _build_water() _update_particle_mesh() ## Build ground tiles as ArrayMesh via SurfaceTool. func _build_ground() -> void: - var st: SurfaceTool = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - - var size: int = LilaConstants.GRID_SIZE - var half: float = 0.49 # slight gap avoids z-fighting seams - - for gz in size: - for gx in size: - var idx: int = gx + gz * size - var moisture: float = 0.5 - if idx < World.moisture_grid.size(): - moisture = World.moisture_grid[idx] - st.set_color(_moisture_color(moisture)) - - var cx: float = float(gx) - var cz: float = float(gz) - # Two triangles per tile - st.add_vertex(Vector3(cx - half, 0.0, cz - half)) - st.add_vertex(Vector3(cx + half, 0.0, cz - half)) - st.add_vertex(Vector3(cx - half, 0.0, cz + half)) - st.add_vertex(Vector3(cx + half, 0.0, cz - half)) - st.add_vertex(Vector3(cx + half, 0.0, cz + half)) - st.add_vertex(Vector3(cx - half, 0.0, cz + half)) - - st.generate_normals() - var mesh: Mesh = st.commit() + var mesh: Mesh = renderer.build_ground_mesh(World.moisture_grid) ground_mi.mesh = mesh -## Build entity cubes as MultiMesh instances (BoxMesh primitive). +## Update all per-type InstancedMesh entities. func _build_entities() -> void: - var mm: MultiMesh = entity_multi.multimesh - if mm == null: - return - var entities: Array = World.get_alive() - mm.instance_count = entities.size() - - for i in entities.size(): - var ent = entities[i] - if is_nan(ent.x) or is_nan(ent.z) or is_inf(ent.x) or is_inf(ent.z): - continue - - var color: Color = _get_entity_color(ent) - var size: float = _get_entity_size(ent) + renderer.update_entities(_type_meshes, entities) - var y_off: float = 0.0 - if ent.type == "INSECT": - y_off = 3.0 + sin(Time.get_ticks_msec() / 300.0 + float(ent.sync_phase)) * 0.8 - var cx: float = ent.x - var cy: float = BLOCK_HEIGHT * size * 0.5 + y_off - var cz: float = ent.z +## Build / update water source meshes. +func _build_water() -> void: + # Pulse water alpha slightly over time + var t: float = sin(Time.get_ticks_msec() / 1000.0) * 0.05 + _water_shader_mat.albedo_color.a = clampf(0.55 + t, 0.3, 0.8) - # Dormant entities are darker - if ent.state == "DORMANT": - color = color.darkened(0.5) - - var t: Transform3D - t.origin = Vector3(cx, cy, cz) - t.basis = Basis.from_scale(Vector3(size, BLOCK_HEIGHT * size, size)) - mm.set_instance_transform(i, t) - mm.set_instance_color(i, color) + _water_instances = renderer.update_water_sources( + water_parent, World, _water_shader_mat + ) -# ── Input ───────────────────────────────────────────────────────── +# ── Input ───────────────────────────────────────────────────────────── func _input(event: InputEvent) -> void: if event is InputEventKey and event.pressed: @@ -206,7 +167,7 @@ func _input(event: InputEvent) -> void: _add_hud_event("⏸ Paused") -# ── WebSocket callbacks ─────────────────────────────────────────── +# ── WebSocket callbacks ──────────────────────────────────────────────── func _on_rain_pressed() -> void: WS.send_control("rain", {"intensity": 0.8}) @@ -270,12 +231,12 @@ func _on_tick_packet(data: Dictionary) -> void: if _current_tick % 10 == 0: World.flush_dead() - # Debug: log entity positions every 10 ticks for telemetry comparison + # Debug: log entity positions every 10 ticks if _current_tick % 10 == 0: _log_entity_telemetry() -# ── HUD helpers ─────────────────────────────────────────────────── +# ── HUD helpers ──────────────────────────────────────────────────────── func _update_stats() -> void: stats_label.text = "Tick: %d | Entities: %d | Events: %d | FPS: %d" % [ @@ -292,45 +253,9 @@ func _add_hud_event(text: String) -> void: event_log.set_text("\n".join(lines.slice(-max_lines))) -# ── Color helpers ───────────────────────────────────────────────── - -func _moisture_color(moisture: float) -> Color: - if moisture < 0.33: - return Color(0.9, 0.85, 0.7) - elif moisture < 0.66: - return Color(0.7, 0.8, 0.6) - else: - return Color(0.4, 0.7, 0.6) - - -func _get_entity_color(ent) -> Color: - if ent.species in LilaConstants.SPECIES_COLORS: - return LilaConstants.SPECIES_COLORS[ent.species] - if ent.type in LilaConstants.TYPE_COLORS: - return LilaConstants.TYPE_COLORS[ent.type] - return Color(0.5, 0.5, 0.5) - - -func _get_entity_size(ent) -> float: - match ent.type: - "TREE": - return 3.0 - "ANIMAL": - return 1.5 - "BIRD": - return 1.0 - "INSECT": - return 0.7 - "PLANT": - return 0.8 - _: - return 1.0 - - -# ── Telemetry / Debug helpers ────────────────────────────────────────── +# ── Telemetry / Debug ────────────────────────────────────────────────── func _log_entity_telemetry() -> void: - """Log entity positions for debugging reconciliation. Mirrors server telemetry.""" var mobile: Array = World.get_alive_mobile() if mobile.is_empty(): return diff --git a/client/godot/scenes/main.tscn b/client/godot/scenes/main.tscn index 60a2c97..dadcec7 100644 --- a/client/godot/scenes/main.tscn +++ b/client/godot/scenes/main.tscn @@ -1,12 +1,14 @@ -[gd_scene load_steps=3 format=3 uid="uid://lila_main"] +[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.6, 0.72, 0.82, 1) -glow/intensity = 0.2 +background_color = Color(0.059, 0.063, 0.059, 1) +glow/intensity = 0.25 +ambient/light_energy_scale = 0.6 [node name="Main" type="Node3D"] script = ExtResource("1") @@ -17,21 +19,32 @@ fov = 50.0 [node name="WorldLight" type="DirectionalLight3D" parent="."] transform = Transform3D(0.707107, 0, 0.707107, 0, 1, 0, -0.707107, 0, 0.707107, 10, 15, 10) -shadow_enabled = false -color = Color(1, 0.95, 0.9, 1) -energy = 2.0 +shadow_enabled = true +color = Color(1, 0.95, 0.88, 1) +energy = 1.8 +shadow/bias = 0.001 +shadow/normal_bias = 0.5 [node name="AmbientLight" type="OmniLight3D" parent="."] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16, 10, 16) -color = Color(0.5, 0.55, 0.65, 1) -energy = 3.0 -omni_range = 100.0 +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16, 8, 16) +color = Color(0.45, 0.5, 0.6, 1) +energy = 2.0 +omni_range = 60.0 + +[node name="Renderer" type="Node" parent="."] +script = ExtResource("3") [node name="Ground" type="MeshInstance3D" parent="."] +cast_shadow = 0 +layers = 1 + +[node name="WaterSources" type="Node3D" parent="."] -[node name="Entities" type="MultiMeshInstance3D" parent="."] +[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") diff --git a/client/godot/scripts/constants.gd b/client/godot/scripts/constants.gd index 2c7da82..edd7503 100644 --- a/client/godot/scripts/constants.gd +++ b/client/godot/scripts/constants.gd @@ -33,29 +33,30 @@ const RECONNECT_DELAY: float = 3.0 const DEFAULT_HOST: String = "localhost" const DEFAULT_PORT: int = 8001 -## Entity type colors (for rendering) +## Entity type colors (fallback for renderer) +## Harmonized with browser constants.js palette. const TYPE_COLORS: Dictionary = { - "ANIMAL": Color(0.9, 0.35, 0.2), - "BIRD": Color(0.3, 0.6, 0.9), - "INSECT": Color(0.95, 0.75, 0.1), - "PLANT": Color(0.2, 0.7, 0.2), - "TREE": Color(0.3, 0.55, 0.15), - "MICROORGANISM": Color(0.6, 0.4, 0.7), + "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.8, 0.5, 0.3), + "deer": Color(0.77, 0.58, 0.42), "wolf": Color(0.45, 0.45, 0.45), - "butterfly": Color(1.0, 0.85, 0.15), - "songbird": Color(0.3, 0.7, 1.0), - "meadow_oak": Color(0.25, 0.5, 0.15), - "meadow_grass": Color(0.35, 0.75, 0.25), - "wildflower": Color(1.0, 0.55, 0.3), - "mushroom": Color(0.7, 0.5, 0.6), + "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 entity overlay +## 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), @@ -75,9 +76,9 @@ const STATE_COLORS: Dictionary = { } ## Mobile entity types (send positions in heartbeat) -const MOBILE_TYPES: Array[String] = ["ANIMAL", "BIRD", "INSECT"] +const MOBILE_TYPES: Array[StringName] = ["ANIMAL", "BIRD", "INSECT"] -## Particle colors -const PARTICLE_CONSUMPTION: Color = Color(0.3, 0.9, 0.3) -const PARTICLE_POLLINATION: Color = Color(1.0, 0.85, 0.1) -const PARTICLE_DEATH: Color = Color(0.35, 0.2, 0.1) +## 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/renderer.gd b/client/godot/scripts/renderer.gd index a887be3..3290938 100644 --- a/client/godot/scripts/renderer.gd +++ b/client/godot/scripts/renderer.gd @@ -1,143 +1,847 @@ -## Isometric voxel-style renderer. -## Draws the world as a pseudo-3D isometric grid with colored blocks. -## Swap this file for real 3D later — no simulation code changes needed. -class_name Renderer +## Primitive-based 3D entity renderer. +## Each entity type gets its own InstancedMesh with a composite ArrayMesh +## built from basic primitives (capsule, cylinder, sphere, cone). +## Colors harmonized with the browser renderer palette. +extends Node -## Isometric cell dimensions -const CELL_W: float = 32.0 -const CELL_H: float = 16.0 -const BLOCK_HEIGHT: float = 10.0 +# ── Color palette (mirrored from browser constants.js) ───────────────── -## World offset for centering -var _offset: Vector2 = Vector2.ZERO -var _grid_size: int = LilaConstants.GRID_SIZE +const C_BG: Color = Color(0.059, 0.063, 0.059) +const C_DEER: Color = Color(0.769, 0.584, 0.416) +const C_DEER_HEAD: Color = Color(0.831, 0.667, 0.478) +const C_BIRD: Color = Color(0.541, 0.482, 0.420) +const C_BIRD_TAIL: Color = Color(0.420, 0.369, 0.322) +const C_BUTTERFLY: Color = Color(0.659, 0.486, 0.769) +const C_BUTTERFLY_BODY: Color = Color(0.478, 0.353, 0.561) +const C_OAK_TRUNK: Color = Color(0.239, 0.420, 0.239) +const C_OAK_CANOPY: 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_STEM: Color = Color(0.478, 0.561, 0.369) +const C_FLOWER_BLOOM: Color = Color(0.769, 0.651, 0.290) +const C_MUSHROOM: Color = Color(0.627, 0.549, 0.471) +const C_WATER_DEEP: Color = Color(0.176, 0.333, 0.431) +const C_WATER_SHALLOW: Color = Color(0.216, 0.412, 0.490) +const C_WATER_SHINE: Color = Color(0.275, 0.510, 0.588) +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) -## Convert grid (x, z) to screen position (isometric projection). -static func grid_to_screen(gx: float, gz: float, offset: Vector2 = Vector2.ZERO) -> Vector2: - var sx: float = (gx - gz) * (CELL_W / 2.0) + offset.x - var sy: float = (gx + gz) * (CELL_H / 2.0) + offset.y - return Vector2(sx, sy) +# ── Entity mesh 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 → InstancedMesh node. +var _type_meshes: Dictionary = {} +## Map of entity type → ArrayMesh (composite mesh). +var _type_arrays: Dictionary = {} +## Water source mesh map: source key → MeshInstance3D. +var _water_instances: Dictionary = {} +## Shader material for water (loaded once). +var _water_shader_mat: ShaderMaterial = null +## Shader material for flower bloom pulse. +var _bloom_shader_mat: ShaderMaterial = null + +# ── Public API ───────────────────────────────────────────────────────── + +## Build composite ArrayMeshes for all entity types (call once in _ready). +static func build_all_type_meshes() -> Dictionary: + var meshes: Dictionary = {} + meshes["TREE"] = _build_tree_mesh() + meshes["ANIMAL"] = _build_animal_mesh() + meshes["BIRD"] = _build_bird_mesh() + meshes["INSECT"] = _build_insect_mesh() + meshes["PLANT_GRASS"] = _build_grass_mesh() + meshes["PLANT_FLOWER"] = _build_flower_mesh() + meshes["MICROORGANISM"] = _build_mushroom_mesh() + return meshes + + +## Set up MultiMeshInstance3D nodes inside a parent Node3D. +## Returns map type → MultiMeshInstance3D. +static 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_colors = true + mm.instance_count = 0 + + mi.multimesh = mm + 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. +static 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 + var sv: Dictionary = ent.drive + if ent.type == "PLANT" and sv.get("hydration", 1.0) < 0.25: + color = C_GRASS_WILT + + mm.set_instance_transform(i, transform) + mm.set_instance_color(i, color) + + +## Build water source meshes under a parent Node3D. +static func update_water_sources( + parent: Node3D, + world: Node, + material: Object, +) -> Dictionary: + var sources: Array = world.water_sources + var instances: Dictionary = {} + + # Remove old instances no longer in world + for key: String in instances: + if not _source_exists(sources, key): + var old: MeshInstance3D = instances[key] + if old and is_instance_valid(old): + old.queue_free() + instances.erase(key) + + # Build or update + for src: Dictionary in sources: + var pos: Vector2 = src.position + var radius: float = src.get("radius", 3.0) + var level: float = src.get("water_level", 1.0) + var key: String = "water_%s_%s" % [pos.x, pos.y] + + if not instances.has(key): + var mi: MeshInstance3D = _build_water_cylinder(radius, material) + mi.name = "Water_%s" % key + mi.layers = 1 + parent.add_child(mi) + instances[key] = mi + + var mi: MeshInstance3D = instances[key] + var scale: float = radius * level + mi.transform.origin = Vector3(pos.x, 0.05, pos.y) + mi.transform.basis = Basis.from_scale(Vector3(scale, 1.0, scale)) + + return instances + + +## Build ground ArrayMesh from moisture grid. +static func build_ground_mesh(moisture_grid: PackedFloat32Array) -> Mesh: + var st: SurfaceTool = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) -## Draw the moisture heatmap grid. -static func draw_ground(viewport: Viewport, world: Node, offset: Vector2 = Vector2.ZERO) -> void: var size: int = LilaConstants.GRID_SIZE + var half: float = 0.48 # slight gap avoids seams + for gz in size: for gx in size: - var pos: Vector2 = grid_to_screen(float(gx), float(gz), offset) var idx: int = gx + gz * size var moisture: float = 0.5 - if idx < world.moisture_grid.size(): - moisture = world.moisture_grid[idx] - - # Color from moisture: teal (dry) to amber (wet) - var color: Color = _moisture_color(moisture) - - # Draw isometric diamond - var points: PackedVector2Array = PackedVector2Array([ - pos + Vector2(CELL_W / 2.0, 0.0), - pos + Vector2(CELL_W / 4.0, CELL_H / 2.0), - pos + Vector2(0.0, 0.0), - pos + Vector2(-CELL_W / 4.0, -CELL_H / 2.0), - ]) - # Note: Godot 2D doesn't have a simple polygon draw in Viewport, - # we use draw functions in _draw() callback instead. - # This is a placeholder — actual drawing happens in main.gd _draw(). - - -## Draw all entities. -static func draw_entities(viewport: Viewport, world: Node, offset: Vector2 = Vector2.ZERO) -> void: - var entities: Array = world.get_alive() - # Sort by z for isometric depth ordering - entities.sort_custom(func(a, b): return a.z < b.z) + if idx < moisture_grid.size(): + moisture = moisture_grid[idx] - for ent in entities: - draw_entity(viewport, ent, offset) + st.set_color(_moisture_color(moisture)) + var cx: float = float(gx) + var cz: float = float(gz) + st.add_vertex(Vector3(cx - half, 0.0, cz - half)) + st.add_vertex(Vector3(cx + half, 0.0, cz - half)) + st.add_vertex(Vector3(cx - half, 0.0, cz + half)) + st.add_vertex(Vector3(cx + half, 0.0, cz - half)) + st.add_vertex(Vector3(cx + half, 0.0, cz + half)) + st.add_vertex(Vector3(cx - half, 0.0, cz + half)) -## Draw a single entity as a colored block. -static func draw_entity(viewport: Viewport, ent, offset: Vector2 = Vector2.ZERO) -> void: - var pos: Vector2 = grid_to_screen(ent.x, ent.z, offset) + st.generate_normals() + return st.commit() - # Get color from species or type - var color: Color = _get_entity_color(ent) - var size: float = _get_entity_size(ent) - # Height offset for insects (they "fly") - var height_offset: float = 0.0 - if ent.type == "INSECT": - height_offset = -20.0 + sin(Time.get_ticks_msec() / 300.0 + ent.sync_phase) * 5.0 +## Build particle mesh (small spheres/boxes). +static func build_particle_mesh() -> Mesh: + var box: BoxMesh = BoxMesh.new() + box.size = Vector3(0.3, 0.3, 0.3) + return box - var block_pos: Vector2 = pos + Vector2(0, height_offset) - # Draw block (top face) - var half_w: float = size * CELL_W / 4.0 - var half_h: float = size * CELL_H / 4.0 +# ── Composite mesh builders ─────────────────────────────────────────── - # Top face (isometric diamond) - var top_points: PackedVector2Array = PackedVector2Array([ - block_pos + Vector2(half_w, -BLOCK_HEIGHT), - block_pos + Vector2(half_w * 0.5, -BLOCK_HEIGHT + half_h * 0.5), - block_pos + Vector2(0, -BLOCK_HEIGHT), - block_pos + Vector2(-half_w * 0.5, -BLOCK_HEIGHT - half_h * 0.5), - ]) +## Tree: CylinderMesh trunk + SphereMesh canopy +static func _build_tree_mesh() -> Mesh: + var am: ArrayMesh = ArrayMesh.new() + # Trunk — narrow cylinder + var st: SurfaceTool = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var trunk_h: float = 1.8 + var trunk_r: float = 0.18 + var segments: int = 8 + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + var cos0: float = cos(a0) + var sin0: float = sin(a0) + var cos1: float = cos(a1) + var sin1: float = sin(a1) -## Draw water sources. -static func draw_water_sources(viewport: Viewport, world: Node, offset: Vector2 = Vector2.ZERO) -> void: - for src: Dictionary in world.water_sources: - var pos: Vector2 = grid_to_screen(src.position.x, src.position.y, offset) - var radius: float = src.get("radius", 3.0) - var level: float = src.get("water_level", 1.0) + # Side ring + var norm = Vector3(cos0, 0.0, sin0).normalized() + st.set_normal(norm) + st.add_vertex(Vector3(cos0 * trunk_r, 0.0, sin0 * trunk_r)) + st.add_vertex(Vector3(cos1 * trunk_r, 0.0, sin1 * trunk_r)) + st.add_vertex(Vector3(cos1 * trunk_r, trunk_h, sin1 * trunk_r)) + st.add_vertex(Vector3(cos0 * trunk_r, 0.0, sin0 * trunk_r)) + st.add_vertex(Vector3(cos1 * trunk_r, trunk_h, sin1 * trunk_r)) + st.add_vertex(Vector3(cos0 * trunk_r, trunk_h, sin0 * trunk_r)) + + st.generate_normals() + + st.commit(am) + + # Canopy — sphere (larger, offset up) + st = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var canopy_r: float = 0.75 + var canopy_y: float = trunk_h + 0.4 + var rings: int = 6 + var cols: int = 8 + for r in rings: + var phi0: float = float(r) / rings * PI + var phi1: float = float(r + 1) / rings * PI + for c in cols: + var theta0: float = float(c) / cols * TAU + var theta1: float = float(c + 1) / cols * TAU + + var v0 = Vector3(cos(theta0) * sin(phi0), cos(phi0), sin(theta0) * sin(phi0)) + var v1 = Vector3(cos(theta1) * sin(phi0), cos(phi0), sin(theta1) * sin(phi0)) + var v2 = Vector3(cos(theta1) * sin(phi1), cos(phi1), sin(theta1) * sin(phi1)) + var v3 = Vector3(cos(theta0) * sin(phi1), cos(phi1), sin(theta0) * sin(phi1)) + + v0 = v0 * canopy_r + Vector3(0.0, canopy_y, 0.0) + v1 = v1 * canopy_r + Vector3(0.0, canopy_y, 0.0) + v2 = v2 * canopy_r + Vector3(0.0, canopy_y, 0.0) + v3 = v3 * canopy_r + Vector3(0.0, canopy_y, 0.0) + + st.add_vertex(v0) + st.add_vertex(v1) + st.add_vertex(v2) + st.add_vertex(v0) + st.add_vertex(v2) + st.add_vertex(v3) + + st.generate_normals() + + st.commit(am) + + return am + + +## Deer / Animal: Capsule body + cone snout + small sphere head +static func _build_animal_mesh() -> Mesh: + var am: ArrayMesh = ArrayMesh.new() + + # Body — capsule-like (cylinder with hemispheres) + var st: SurfaceTool = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var body_len: float = 0.7 + var body_r: float = 0.25 + var segments: int = 8 + + # Central cylinder (along X axis) + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + var cos0: float = cos(a0) + var sin0: float = sin(a0) + var cos1: float = cos(a1) + var sin1: float = sin(a1) + + var norm = Vector3(0.0, cos0, sin0).normalized() + st.set_normal(norm) + st.add_vertex(Vector3(-body_len, cos0 * body_r, sin0 * body_r)) + st.add_vertex(Vector3(-body_len, cos1 * body_r, sin1 * body_r)) + st.add_vertex(Vector3(body_len, cos1 * body_r, sin1 * body_r)) + st.add_vertex(Vector3(-body_len, cos0 * body_r, sin0 * body_r)) + st.add_vertex(Vector3(body_len, cos1 * body_r, sin1 * body_r)) + st.add_vertex(Vector3(body_len, cos0 * body_r, sin0 * body_r)) + + # Front hemisphere (head area, +X) + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + for j in 4: + var phi0: float = float(j) / 4 * 0.5 * PI # 0 to 90° + var phi1: float = float(j + 1) / 4 * 0.5 * PI + var cos_a0 = cos(a0) + var sin_a0 = sin(a0) + var cos_a1 = cos(a1) + var sin_a1 = sin(a1) + + var cx: float = body_len + cos(phi0) * body_r * 0.8 + var cy0: float = sin(phi0) * cos_a0 * body_r * 0.8 + var cz0: float = sin(phi0) * sin_a0 * body_r * 0.8 + var cy1: float = sin(phi1) * cos_a1 * body_r * 0.8 + var cz1: float = sin(phi1) * sin_a1 * body_r * 0.8 + + st.add_vertex(Vector3(cx, cy0, cz0)) + st.add_vertex(Vector3(body_len + cos(phi1) * body_r * 0.8, cy0, cz0)) + st.add_vertex(Vector3(body_len + cos(phi1) * body_r * 0.8, cy1, cz1)) + st.add_vertex(Vector3(cx, cy0, cz0)) + st.add_vertex(Vector3(body_len + cos(phi1) * body_r * 0.8, cy1, cz1)) + st.add_vertex(Vector3(body_len + cos(phi0) * body_r * 0.8, cy1, cz1)) + + st.generate_normals() + + st.commit(am) + + # Snout — small cone at front + st = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var snout_len: float = 0.2 + var snout_r: float = 0.12 + var snut_tip_x: float = body_len + body_r * 0.8 + snout_len + var snut_base_x: float = body_len + body_r * 0.8 + for i in segments: + var a: float = float(i) / segments * TAU + var a2: float = float(i + 1) / segments * TAU + st.add_vertex(Vector3(snut_tip_x, 0.0, 0.0)) + st.add_vertex(Vector3(snut_base_x, cos(a) * snout_r, sin(a) * snout_r)) + st.add_vertex(Vector3(snut_base_x, cos(a2) * snout_r, sin(a2) * snout_r)) + + st.generate_normals() + + st.commit(am) + + # Legs — 4 thin cylinders at bottom + for lx in [-0.4, 0.3]: + for lz_sign in [-1, 1]: + st = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var leg_h: float = 0.35 + var leg_r: float = 0.06 + var leg_x: float = lx + var leg_z: float = lz_sign * body_r * 0.6 + for i in 6: + var a: float = float(i) / 6 * TAU + var a2: float = float(i + 1) / 6 * TAU + var n = Vector3(0.0, cos(a), sin(a)).normalized() + st.set_normal(n) + st.add_vertex(Vector3(leg_x, 0.0, leg_z + sin(a) * leg_r + cos(a) * leg_r * 0.0)) + # Simplified: just a vertical cylinder + var vx0: float = leg_x + var vy0: float = 0.0 + var vz0: float = leg_z + cos(a) * leg_r + var vz1: float = leg_z + cos(a2) * leg_r + st.set_normal(Vector3(0.0, cos(a), sin(a))) + st.add_vertex(Vector3(vx0, vy0, leg_z + cos(a) * leg_r)) + st.add_vertex(Vector3(vx0, vy0, leg_z + cos(a2) * leg_r)) + st.add_vertex(Vector3(vx0, leg_h, leg_z + cos(a2) * leg_r)) + st.add_vertex(Vector3(vx0, vy0, leg_z + cos(a) * leg_r)) + st.add_vertex(Vector3(vx0, leg_h, leg_z + cos(a2) * leg_r)) + st.add_vertex(Vector3(vx0, leg_h, leg_z + cos(a) * leg_r)) + st.generate_normals() + st.commit(am) + + return am + + +## Bird: Elongated capsule body + tail cone + wing planes +static func _build_bird_mesh() -> Mesh: + var am: ArrayMesh = ArrayMesh.new() + + # Body — elongated capsule along X + var st: SurfaceTool = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var body_len: float = 0.35 + var body_r: float = 0.13 + var segments: int = 6 + + # Central cylinder + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + var cos0: float = cos(a0) + var sin0: float = sin(a0) + var cos1: float = cos(a1) + var sin1: float = sin(a1) + var norm = Vector3(0.0, cos0, sin0).normalized() + st.set_normal(norm) + st.add_vertex(Vector3(-body_len, cos0 * body_r, sin0 * body_r)) + st.add_vertex(Vector3(-body_len, cos1 * body_r, sin1 * body_r)) + st.add_vertex(Vector3(body_len, cos1 * body_r, sin1 * body_r)) + st.add_vertex(Vector3(-body_len, cos0 * body_r, sin0 * body_r)) + st.add_vertex(Vector3(body_len, cos1 * body_r, sin1 * body_r)) + st.add_vertex(Vector3(body_len, cos0 * body_r, sin0 * body_r)) + + # Nose cone at front (+X) + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + var tip_x: float = body_len + 0.2 + st.add_vertex(Vector3(tip_x, 0.0, 0.0)) + st.add_vertex(Vector3(body_len, cos(a0) * body_r, sin(a0) * body_r)) + st.add_vertex(Vector3(body_len, cos(a1) * body_r, sin(a1) * body_r)) + + # Tail cone at back (-X) + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + var tail_tip_x: float = -body_len - 0.2 + st.add_vertex(Vector3(tail_tip_x, 0.0, 0.0)) + st.add_vertex(Vector3(-body_len, cos(a1) * body_r * 0.7, sin(a1) * body_r * 0.7)) + st.add_vertex(Vector3(-body_len, cos(a0) * body_r * 0.7, sin(a0) * body_r * 0.7)) + + st.generate_normals() + + st.commit(am) + + # Wings — thin flat planes extending upward (will rotate with entity facing) + # Left wing + st = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var wing_w: float = 0.45 + var wing_d: float = 0.02 + var wing_y: float = 0.1 + st.add_vertex(Vector3(0.0, wing_y, body_r * 0.5)) + st.add_vertex(Vector3(wing_w, wing_y * 0.3, body_r * 0.5)) + st.add_vertex(Vector3(0.0, wing_y, -body_r * 0.5)) + st.add_vertex(Vector3(wing_w, wing_y * 0.3, body_r * 0.5)) + st.add_vertex(Vector3(wing_w, wing_y * 0.3, -body_r * 0.5)) + st.add_vertex(Vector3(0.0, wing_y, -body_r * 0.5)) + st.generate_normals() + st.commit(am) + + return am + + +## Butterfly: Body capsule + 2 pairs of wing planes +static func _build_insect_mesh() -> Mesh: + var am: ArrayMesh = ArrayMesh.new() - # Effective radius based on water level - var effective_radius: float = radius * CELL_W * level + # Body — small capsule along X + var st: SurfaceTool = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var body_len: float = 0.12 + var body_r: float = 0.06 + var segments: int = 6 - # Water color with alpha based on level - var water_color: Color = Color(0.2, 0.4, 0.8, 0.4 * level) + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + var cos0: float = cos(a0) + var sin0: float = sin(a0) + var cos1: float = cos(a1) + var sin1: float = sin(a1) + var norm = Vector3(0.0, cos0, sin0).normalized() + st.set_normal(norm) + st.add_vertex(Vector3(-body_len, cos0 * body_r, sin0 * body_r)) + st.add_vertex(Vector3(-body_len, cos1 * body_r, sin1 * body_r)) + st.add_vertex(Vector3(body_len, cos1 * body_r, sin1 * body_r)) + st.add_vertex(Vector3(-body_len, cos0 * body_r, sin0 * body_r)) + st.add_vertex(Vector3(body_len, cos1 * body_r, sin1 * body_r)) + st.add_vertex(Vector3(body_len, cos0 * body_r, sin0 * body_r)) + # Hemispherical cap at front + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + for j in 3: + var p0: float = float(j) / 3 * 0.5 * PI + var p1: float = float(j + 1) / 3 * 0.5 * PI + var cx: float = body_len + cos(p0) * body_r + var cy0: float = sin(p0) * cos(a0) * body_r + var cz0: float = sin(p0) * sin(a0) * body_r + var cy1: float = sin(p1) * cos(a1) * body_r + var cz1: float = sin(p1) * sin(a1) * body_r + st.add_vertex(Vector3(cx, cy0, cz0)) + st.add_vertex(Vector3(body_len + cos(p1) * body_r, cy0, cz0)) + st.add_vertex(Vector3(body_len + cos(p1) * body_r, cy1, cz1)) + st.add_vertex(Vector3(cx, cy0, cz0)) + st.add_vertex(Vector3(body_len + cos(p1) * body_r, cy1, cz1)) + st.add_vertex(Vector3(body_len + cos(p0) * body_r, cy1, cz1)) -## Draw particles. -static func draw_particles(viewport: Viewport, particles: Array, offset: Vector2 = Vector2.ZERO) -> void: - for particle in particles: - var pos: Vector2 = grid_to_screen(particle.position.x, particle.position.y, offset) - var color: Color = particle.color - color.a = particle.life / particle.max_life + st.generate_normals() + + st.commit(am) + + # Wings — 4 thin planes (2 pairs, upper + lower) + for pair in [0, 1]: + var base_y: float = 0.08 + pair * 0.02 + var wing_span: float = 0.3 - pair * 0.08 + # Upper pair + st = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + st.add_vertex(Vector3(0.0, base_y, 0.0)) + st.add_vertex(Vector3(wing_span * 0.6, base_y * 0.4, wing_span * 0.5)) + st.add_vertex(Vector3(-wing_span * 0.3, base_y * 0.6, wing_span * 0.5)) + st.add_vertex(Vector3(0.0, base_y, 0.0)) + st.add_vertex(Vector3(-wing_span * 0.3, base_y * 0.6, wing_span * 0.5)) + st.add_vertex(Vector3(0.0, base_y, wing_span * 0.55)) + st.generate_normals() + st.commit(am) + + # Lower pair (mirror) + st = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + st.add_vertex(Vector3(0.0, -base_y, 0.0)) + st.add_vertex(Vector3(-wing_span * 0.3, -base_y * 0.6, wing_span * 0.5)) + st.add_vertex(Vector3(wing_span * 0.6, -base_y * 0.4, wing_span * 0.5)) + st.add_vertex(Vector3(0.0, -base_y, 0.0)) + st.add_vertex(Vector3(0.0, -base_y, wing_span * 0.55)) + st.add_vertex(Vector3(-wing_span * 0.3, -base_y * 0.6, wing_span * 0.5)) + st.generate_normals() + st.commit(am) + + return am + + +## Grass: 3 thin blade planes clustered together +static func _build_grass_mesh() -> Mesh: + var am: ArrayMesh = ArrayMesh.new() + var st: SurfaceTool = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + + var blade_h: float = 0.4 + var blade_w: float = 0.04 + + for i in 3: + var angle: float = float(i) * 2.1 + var ox: float = cos(angle) * 0.12 + var oz: float = sin(angle) * 0.12 + var tilt: float = 0.15 * cos(angle) + + st.add_vertex(Vector3(ox, 0.0, oz)) + st.add_vertex(Vector3(ox + blade_w, 0.0, oz)) + st.add_vertex(Vector3(ox + tilt, blade_h, oz)) + st.add_vertex(Vector3(ox + tilt, blade_h, oz)) + st.add_vertex(Vector3(ox + blade_w, 0.0, oz)) + st.add_vertex(Vector3(ox + blade_w + tilt, blade_h * 0.95, oz)) + + st.generate_normals() + st.commit(am) + return am + + +## Wildflower: Stem cylinder + bloom sphere (bloom visible via color override) +static func _build_flower_mesh() -> Mesh: + var am: ArrayMesh = ArrayMesh.new() + + # Stem — thin cylinder + var st: SurfaceTool = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var stem_h: float = 0.35 + var stem_r: float = 0.04 + var segments: int = 5 + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + var cos0: float = cos(a0) + var sin0: float = sin(a0) + var cos1: float = cos(a1) + var sin1: float = sin(a1) + var norm = Vector3(cos0, 0.0, sin0).normalized() + st.set_normal(norm) + st.add_vertex(Vector3(cos0 * stem_r, 0.0, sin0 * stem_r)) + st.add_vertex(Vector3(cos1 * stem_r, 0.0, sin1 * stem_r)) + st.add_vertex(Vector3(cos1 * stem_r, stem_h, sin1 * stem_r)) + st.add_vertex(Vector3(cos0 * stem_r, 0.0, sin0 * stem_r)) + st.add_vertex(Vector3(cos1 * stem_r, stem_h, sin1 * stem_r)) + st.add_vertex(Vector3(cos0 * stem_r, stem_h, sin0 * stem_r)) + st.generate_normals() + st.commit(am) + + # Bloom — small sphere at top (FRUITING state makes this colorful) + st = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var bloom_r: float = 0.12 + var bloom_y: float = stem_h + 0.02 + var rings: int = 4 + var cols: int = 6 + for r in rings: + var phi0: float = float(r) / rings * PI + var phi1: float = float(r + 1) / rings * PI + for c in cols: + var theta0: float = float(c) / cols * TAU + var theta1: float = float(c + 1) / cols * TAU + var v0 = Vector3(cos(theta0)*sin(phi0), cos(phi0), sin(theta0)*sin(phi0)) + var v1 = Vector3(cos(theta1)*sin(phi0), cos(phi0), sin(theta1)*sin(phi0)) + var v2 = Vector3(cos(theta1)*sin(phi1), cos(phi1), sin(theta1)*sin(phi1)) + var v3 = Vector3(cos(theta0)*sin(phi1), cos(phi1), sin(theta0)*sin(phi1)) + v0 = v0 * bloom_r + Vector3(0.0, bloom_y, 0.0) + v1 = v1 * bloom_r + Vector3(0.0, bloom_y, 0.0) + v2 = v2 * bloom_r + Vector3(0.0, bloom_y, 0.0) + v3 = v3 * bloom_r + Vector3(0.0, bloom_y, 0.0) + st.add_vertex(v0); st.add_vertex(v1); st.add_vertex(v2) + st.add_vertex(v0); st.add_vertex(v2); st.add_vertex(v3) + st.generate_normals() + st.commit(am) + + return am + + +## Mushroom: Cap (cylinder top) + stalk +static func _build_mushroom_mesh() -> Mesh: + var am: ArrayMesh = ArrayMesh.new() + + # Stalk + var st: SurfaceTool = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var stalk_h: float = 0.2 + var stalk_r: float = 0.06 + var segments: int = 6 + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + var cos0: float = cos(a0) + var sin0: float = sin(a0) + var cos1: float = cos(a1) + var sin1: float = sin(a1) + var norm = Vector3(cos0, 0.0, sin0).normalized() + st.set_normal(norm) + st.add_vertex(Vector3(cos0 * stalk_r, 0.0, sin0 * stalk_r)) + st.add_vertex(Vector3(cos1 * stalk_r, 0.0, sin1 * stalk_r)) + st.add_vertex(Vector3(cos1 * stalk_r, stalk_h, sin1 * stalk_r)) + st.add_vertex(Vector3(cos0 * stalk_r, 0.0, sin0 * stalk_r)) + st.add_vertex(Vector3(cos1 * stalk_r, stalk_h, sin1 * stalk_r)) + st.add_vertex(Vector3(cos0 * stalk_r, stalk_h, sin0 * stalk_r)) + st.generate_normals() + st.commit(am) + + # Cap — wider cylinder top (flat, like a dome) + st = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var cap_r: float = 0.2 + var cap_y: float = stalk_h - 0.02 + # Top face + var center: Vector3 = Vector3(0.0, cap_y + 0.06, 0.0) + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + st.set_normal(Vector3.UP) + st.add_vertex(center) + st.add_vertex(Vector3(cos(a0) * cap_r, cap_y + 0.06, sin(a0) * cap_r)) + st.add_vertex(Vector3(cos(a1) * cap_r, cap_y + 0.06, sin(a1) * cap_r)) + + # Side ring + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + var cos0: float = cos(a0) + var sin0: float = sin(a0) + var cos1: float = cos(a1) + var sin1: float = sin(a1) + var norm = Vector3(cos0, -0.3, sin0).normalized() + st.set_normal(norm) + st.add_vertex(Vector3(cos0 * cap_r, cap_y + 0.06, sin0 * cap_r)) + st.add_vertex(Vector3(cos1 * cap_r, cap_y + 0.06, sin1 * cap_r)) + st.add_vertex(Vector3(cos1 * cap_r, cap_y, sin1 * cap_r)) + st.add_vertex(Vector3(cos0 * cap_r, cap_y + 0.06, sin0 * cap_r)) + st.add_vertex(Vector3(cos1 * cap_r, cap_y, sin1 * cap_r)) + st.add_vertex(Vector3(cos0 * cap_r, cap_y, sin0 * cap_r)) + + st.generate_normals() + st.commit(am) + + return am + + +## Water pool: flat cylinder +static func _build_water_cylinder(radius: float, material: Object) -> MeshInstance3D: + var mi: MeshInstance3D = MeshInstance3D.new() + + var st: SurfaceTool = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var segments: int = 16 + var center: Vector3 = Vector3.ZERO + for i in segments: + var a0: float = float(i) / segments * TAU + var a1: float = float(i + 1) / segments * TAU + st.add_vertex(center) + st.add_vertex(Vector3(cos(a0) * radius, 0.0, sin(a0) * radius)) + st.add_vertex(Vector3(cos(a1) * radius, 0.0, sin(a1) * radius)) + st.generate_normals() + + var mesh: Mesh = st.commit() + mi.mesh = mesh + if material: + mi.set_surface_override_material(0, material) + mi.transform.basis = Basis.from_scale(Vector3(radius, 1.0, radius)) + + return mi + + +# ── Color helpers ──────────────────────────────────────────────────────── + +static 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 -## Get entity color from species or type. static func _get_entity_color(ent) -> Color: - if ent.species in LilaConstants.SPECIES_COLORS: - return LilaConstants.SPECIES_COLORS[ent.species] - if ent.type in LilaConstants.TYPE_COLORS: - return LilaConstants.TYPE_COLORS[ent.type] + var species: String = ent.species + match ent.type: + "ANIMAL": + if species == "wolf": + return Color(0.45, 0.45, 0.45) + return C_DEER + "BIRD": + return C_BIRD + "INSECT": + return C_BUTTERFLY + "TREE": + return C_OAK_TRUNK + "PLANT": + if species == "wildflower": + return C_WILDFLOWER_STEM + return C_GRASS + "MICROORGANISM": + return C_MUSHROOM return Color(0.5, 0.5, 0.5) -## Get entity block size. static func _get_entity_size(ent) -> float: match ent.type: "TREE": - return 3.0 + return SIZE_TREE "ANIMAL": - return 1.5 + return SIZE_ANIMAL "BIRD": - return 1.0 + return SIZE_BIRD "INSECT": - return 0.7 + return SIZE_INSECT "PLANT": - return 0.8 - _: - return 1.0 + return SIZE_PLANT + "MICROORGANISM": + return SIZE_MICRO + return 1.0 -## Get color for moisture value. -static func _moisture_color(moisture: float) -> Color: - if moisture < 0.33: - return Color(0.9, 0.85, 0.7) # Sandy/dry - elif moisture < 0.66: - return Color(0.7, 0.8, 0.6) # Green/grassy - else: - return Color(0.4, 0.7, 0.6) # Teal/moist +static 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": + return "PLANT_FLOWER" + return "PLANT_GRASS" + "MICROORGANISM": + return "MICROORGANISM" + return "" + + +static func _build_entity_transform(ent, size: float, tick_ms: float) -> Transform3D: + var cx: float = ent.x + var cz: float = ent.z + var cy: float = 0.5 + var y_extra: float = 0.0 + + # Insects float above ground + if ent.type == "INSECT": + y_extra = 2.5 + sin(tick_ms / 300.0 + float(ent.sync_phase)) * 0.8 + cy += y_extra + + # Birds fly higher + if ent.type == "BIRD": + y_extra = 3.5 + sin(tick_ms / 400.0 + float(ent.sync_phase)) * 0.5 + cy += y_extra + + # Trees grow taller with growth state_var + var sv: Dictionary = ent.drive + if ent.type == "TREE": + var growth: float = sv.get("growth", 0.5) + cy = 0.0 # trunk rooted at ground + size = SIZE_TREE * (0.5 + growth * 0.5) # scale by growth + + # Plants scale by growth + if ent.type == "PLANT": + 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 + + # Facing direction + var angle: float = ent.facing_angle + + # Build transform + var t: Transform3D + t.origin = Vector3(cx, cy, cz) + + # Rotate around Y axis to face direction, scale uniformly + var rot: Basis = Basis.from_euler(Vector3(0.0, -angle + PI / 2.0, 0.0)) + var sc: float = size * 0.6 # normalize to world units + t.basis = rot * Basis.from_scale(Vector3(sc, sc, sc)) + + return t + + +static func _source_exists(sources: Array, key: String) -> bool: + for src: Dictionary in sources: + var pos: Vector2 = src.position + if "water_%s_%s" % [pos.x, pos.y] == key: + return true + return false From 26d324835ee549402355abe8f8167bbbe2b5e5f5 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 08:40:23 -0400 Subject: [PATCH 10/25] godot: voxel ground with water integration - Fix ground triangle winding (second triangle was CW, normal pointed down) - Replace planar grid with 1x1x1 BoxMesh voxels via MultiMesh - Merge water sources into voxel grid (blend cells toward water color) - Remove circular water mesh rendering (cylinder + standard material) - Drop separate WaterSources scene node --- client/godot/scenes/main.gd | 45 +- client/godot/scenes/main.tscn | 38 +- client/godot/scripts/renderer.gd | 736 ++++++------------------------- 3 files changed, 168 insertions(+), 651 deletions(-) diff --git a/client/godot/scenes/main.gd b/client/godot/scenes/main.gd index 21add4d..535a163 100644 --- a/client/godot/scenes/main.gd +++ b/client/godot/scenes/main.gd @@ -1,15 +1,14 @@ ## Main scene - 3D world view with orbit camera. ## Grid coordinates map 1:1 to world X/Z; Y is height. -## Uses primitive-based InstancedMesh rendering per entity type. +## Uses simple cube-based InstancedMesh rendering per entity type. extends Node3D @onready var camera: Camera3D = $Camera @onready var renderer: Node = $Renderer -@onready var ground_mi: MeshInstance3D = $Ground +@onready var ground_mi: MultiMeshInstance3D = $Ground @onready var entity_parent: Node3D = $Entities @onready var particle_instance: MultiMeshInstance3D = $Particles -@onready var water_parent: Node3D = $WaterSources @onready var hud: CanvasLayer = $HUD @onready var stats_label: Label = $HUD/VBox/StatsLabel @onready var event_log: RichTextLabel = $HUD/VBox/EventLog @@ -30,12 +29,11 @@ var _fps_timer: float = 0.0 # Renderer state var _type_meshes: Dictionary = {} -var _water_shader_mat: Object = null -var _water_instances: Dictionary = {} +var _ground_mat: ShaderMaterial = null func _ready() -> void: - print("Lila Godot Client starting (3D — primitive renderer)...") + print("Lila Godot Client starting (3D — cube renderer)...") WS.session_started.connect(_on_session_started) WS.tick_packet.connect(_on_tick_packet) @@ -43,6 +41,7 @@ func _ready() -> void: rain_button.pressed.connect(_on_rain_pressed) _particles = load("res://scripts/particles.gd").new() + _setup_particles() _setup_renderer() @@ -64,11 +63,9 @@ func _setup_renderer() -> void: # Create InstancedMesh nodes under Entities parent _type_meshes = renderer.setup_type_meshes(entity_parent, meshes) - # Water material — semi-transparent blue pool - _water_shader_mat = StandardMaterial3D.new() - _water_shader_mat.albedo_color = Color(0.22, 0.42, 0.53, 0.6) - _water_shader_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - _water_shader_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + # Ground voxels — reads INSTANCE_CUSTOM for per-cell color + _ground_mat = renderer.make_ground_material() + ground_mi.material_override = _ground_mat # ── Particle MultiMesh setup ────────────────────────────────────────── @@ -80,10 +77,13 @@ func _setup_particles() -> void: var mm: MultiMesh = MultiMesh.new() mm.mesh = box mm.transform_format = MultiMesh.TRANSFORM_3D - mm.use_colors = true + 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() @@ -102,7 +102,7 @@ func _update_particle_mesh() -> void: mm.set_instance_transform(i, t) var c: Color = p.color c.a = maxf(0.0, p.life / p.max_life) - mm.set_instance_color(i, c) + mm.set_instance_custom_data(i, c) # ── Main loop ───────────────────────────────────────────────────────── @@ -128,14 +128,14 @@ func _process(delta: float) -> void: # Rebuild meshes every frame _build_ground() _build_entities() - _build_water() _update_particle_mesh() -## Build ground tiles as ArrayMesh via SurfaceTool. +## Build ground as MultiMesh voxels (one 1x1x1 cube per grid cell). +## Water sources color cells within their radius. func _build_ground() -> void: - var mesh: Mesh = renderer.build_ground_mesh(World.moisture_grid) - ground_mi.mesh = mesh + var mm: MultiMesh = renderer.build_ground_voxels(World.moisture_grid, World.water_sources) + ground_mi.multimesh = mm ## Update all per-type InstancedMesh entities. @@ -144,17 +144,6 @@ func _build_entities() -> void: renderer.update_entities(_type_meshes, entities) -## Build / update water source meshes. -func _build_water() -> void: - # Pulse water alpha slightly over time - var t: float = sin(Time.get_ticks_msec() / 1000.0) * 0.05 - _water_shader_mat.albedo_color.a = clampf(0.55 + t, 0.3, 0.8) - - _water_instances = renderer.update_water_sources( - water_parent, World, _water_shader_mat - ) - - # ── Input ───────────────────────────────────────────────────────────── func _input(event: InputEvent) -> void: diff --git a/client/godot/scenes/main.tscn b/client/godot/scenes/main.tscn index dadcec7..72a6528 100644 --- a/client/godot/scenes/main.tscn +++ b/client/godot/scenes/main.tscn @@ -7,8 +7,17 @@ [sub_resource type="Environment" id="env1"] background_mode = 1 background_color = Color(0.059, 0.063, 0.059, 1) -glow/intensity = 0.25 -ambient/light_energy_scale = 0.6 +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") @@ -17,29 +26,26 @@ script = ExtResource("1") script = ExtResource("2") fov = 50.0 -[node name="WorldLight" type="DirectionalLight3D" parent="."] -transform = Transform3D(0.707107, 0, 0.707107, 0, 1, 0, -0.707107, 0, 0.707107, 10, 15, 10) +[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 -color = Color(1, 0.95, 0.88, 1) -energy = 1.8 -shadow/bias = 0.001 -shadow/normal_bias = 0.5 +light_color = Color(1, 0.97, 0.9, 1) +light_energy = 1.2 +directional_shadow_mode = 2 -[node name="AmbientLight" type="OmniLight3D" parent="."] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16, 8, 16) -color = Color(0.45, 0.5, 0.6, 1) -energy = 2.0 -omni_range = 60.0 +[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="MeshInstance3D" parent="."] +[node name="Ground" type="MultiMeshInstance3D" parent="."] cast_shadow = 0 layers = 1 -[node name="WaterSources" type="Node3D" parent="."] - [node name="Entities" type="Node3D" parent="."] [node name="Particles" type="MultiMeshInstance3D" parent="."] diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd index 3290938..65ebd24 100644 --- a/client/godot/scripts/renderer.gd +++ b/client/godot/scripts/renderer.gd @@ -1,7 +1,7 @@ -## Primitive-based 3D entity renderer. -## Each entity type gets its own InstancedMesh with a composite ArrayMesh -## built from basic primitives (capsule, cylinder, sphere, cone). -## Colors harmonized with the browser renderer palette. +## Simple cube-based 3D entity renderer. +## Each entity type gets a single BoxMesh (cube) with type-appropriate color +## harmonized with the browser renderer palette. +class_name Renderer extends Node @@ -9,27 +9,21 @@ extends Node const C_BG: Color = Color(0.059, 0.063, 0.059) const C_DEER: Color = Color(0.769, 0.584, 0.416) -const C_DEER_HEAD: Color = Color(0.831, 0.667, 0.478) +const C_WOLF: Color = Color(0.45, 0.45, 0.45) const C_BIRD: Color = Color(0.541, 0.482, 0.420) -const C_BIRD_TAIL: Color = Color(0.420, 0.369, 0.322) const C_BUTTERFLY: Color = Color(0.659, 0.486, 0.769) -const C_BUTTERFLY_BODY: Color = Color(0.478, 0.353, 0.561) -const C_OAK_TRUNK: Color = Color(0.239, 0.420, 0.239) -const C_OAK_CANOPY: Color = Color(0.239, 0.420, 0.239) +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_STEM: Color = Color(0.478, 0.561, 0.369) -const C_FLOWER_BLOOM: Color = Color(0.769, 0.651, 0.290) +const C_WILDFLOWER: Color = Color(0.478, 0.561, 0.369) const C_MUSHROOM: Color = Color(0.627, 0.549, 0.471) -const C_WATER_DEEP: Color = Color(0.176, 0.333, 0.431) -const C_WATER_SHALLOW: Color = Color(0.216, 0.412, 0.490) -const C_WATER_SHINE: Color = Color(0.275, 0.510, 0.588) 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 mesh sizes ────────────────────────────────────────────────── +# ── Entity cube sizes ────────────────────────────────────────────────── const SIZE_TREE: float = 3.0 const SIZE_ANIMAL: float = 1.5 @@ -40,32 +34,85 @@ const SIZE_MICRO: float = 0.5 # ── Instance tracking ────────────────────────────────────────────────── -## Map of entity type → InstancedMesh node. +## Map of entity type → MultiMeshInstance3D node. var _type_meshes: Dictionary = {} -## Map of entity type → ArrayMesh (composite mesh). -var _type_arrays: Dictionary = {} ## Water source mesh map: source key → MeshInstance3D. -var _water_instances: Dictionary = {} -## Shader material for water (loaded once). -var _water_shader_mat: ShaderMaterial = null -## Shader material for flower bloom pulse. -var _bloom_shader_mat: ShaderMaterial = null - # ── Public API ───────────────────────────────────────────────────────── -## Build composite ArrayMeshes for all entity types (call once in _ready). +## Build simple BoxMesh cubes for all entity types (call once in _ready). static func build_all_type_meshes() -> Dictionary: var meshes: Dictionary = {} - meshes["TREE"] = _build_tree_mesh() - meshes["ANIMAL"] = _build_animal_mesh() - meshes["BIRD"] = _build_bird_mesh() - meshes["INSECT"] = _build_insect_mesh() - meshes["PLANT_GRASS"] = _build_grass_mesh() - meshes["PLANT_FLOWER"] = _build_flower_mesh() - meshes["MICROORGANISM"] = _build_mushroom_mesh() + for key in ["TREE", "ANIMAL", "BIRD", "INSECT", "PLANT_GRASS", "PLANT_FLOWER", "MICROORGANISM"]: + meshes[key] = BoxMesh.new() return meshes +## Shader material that reads per-instance custom data as color. +## Uses INSTANCE_CUSTOM instead of INSTANCE_COLOR (broken in Godot 4.7). +static 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. +static 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. +static 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. static func setup_type_meshes(parent: Node3D, meshes: Dictionary) -> Dictionary: @@ -78,10 +125,11 @@ static func setup_type_meshes(parent: Node3D, meshes: Dictionary) -> Dictionary: var mm: MultiMesh = MultiMesh.new() mm.mesh = meshes[type_name] mm.transform_format = MultiMesh.TRANSFORM_3D - mm.use_colors = true + 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 @@ -133,56 +181,29 @@ static func update_entities( color = C_GRASS_WILT mm.set_instance_transform(i, transform) - mm.set_instance_color(i, color) - - -## Build water source meshes under a parent Node3D. -static func update_water_sources( - parent: Node3D, - world: Node, - material: Object, -) -> Dictionary: - var sources: Array = world.water_sources - var instances: Dictionary = {} - - # Remove old instances no longer in world - for key: String in instances: - if not _source_exists(sources, key): - var old: MeshInstance3D = instances[key] - if old and is_instance_valid(old): - old.queue_free() - instances.erase(key) - - # Build or update - for src: Dictionary in sources: - var pos: Vector2 = src.position - var radius: float = src.get("radius", 3.0) - var level: float = src.get("water_level", 1.0) - var key: String = "water_%s_%s" % [pos.x, pos.y] - - if not instances.has(key): - var mi: MeshInstance3D = _build_water_cylinder(radius, material) - mi.name = "Water_%s" % key - mi.layers = 1 - parent.add_child(mi) - instances[key] = mi - - var mi: MeshInstance3D = instances[key] - var scale: float = radius * level - mi.transform.origin = Vector3(pos.x, 0.05, pos.y) - mi.transform.basis = Basis.from_scale(Vector3(scale, 1.0, scale)) - - return instances - - -## Build ground ArrayMesh from moisture grid. -static func build_ground_mesh(moisture_grid: PackedFloat32Array) -> Mesh: - var st: SurfaceTool = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) + mm.set_instance_custom_data(i, color) + + + +## Build ground as MultiMesh of 1x1x1 BoxMesh voxels (one per grid cell). +## Each voxel sits at (gx, 0, gz) with top face at y=0.5. +## Cells inside a water source get water color blended by water_level. +static func build_ground_voxels( + moisture_grid: PackedFloat32Array, + water_sources: Array, +) -> 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 - var half: float = 0.48 # slight gap avoids seams + mm.instance_count = size * size + var i: int = 0 for gz in size: for gx in size: var idx: int = gx + gz * size @@ -190,19 +211,31 @@ static func build_ground_mesh(moisture_grid: PackedFloat32Array) -> Mesh: if idx < moisture_grid.size(): moisture = moisture_grid[idx] - st.set_color(_moisture_color(moisture)) - - var cx: float = float(gx) - var cz: float = float(gz) - st.add_vertex(Vector3(cx - half, 0.0, cz - half)) - st.add_vertex(Vector3(cx + half, 0.0, cz - half)) - st.add_vertex(Vector3(cx - half, 0.0, cz + half)) - st.add_vertex(Vector3(cx + half, 0.0, cz - half)) - st.add_vertex(Vector3(cx + half, 0.0, cz + half)) - st.add_vertex(Vector3(cx - half, 0.0, cz + half)) - - st.generate_normals() - return st.commit() + # Check if this cell falls inside any water source + 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 level: float = src.get("water_level", 1.0) + if level < 0.02: + continue + var dist: float = cell_pos.distance_to(src_pos) + if dist <= radius: + # Blend toward water color; closer = more water + var blend: float = (1.0 - dist / radius) * level + color = color.lerp(C_WATER, blend) + break # first (closest) match wins; sources rarely overlap + + 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, color) + i += 1 + + return mm ## Build particle mesh (small spheres/boxes). @@ -212,511 +245,6 @@ static func build_particle_mesh() -> Mesh: return box -# ── Composite mesh builders ─────────────────────────────────────────── - -## Tree: CylinderMesh trunk + SphereMesh canopy -static func _build_tree_mesh() -> Mesh: - var am: ArrayMesh = ArrayMesh.new() - - # Trunk — narrow cylinder - var st: SurfaceTool = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var trunk_h: float = 1.8 - var trunk_r: float = 0.18 - var segments: int = 8 - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - var cos0: float = cos(a0) - var sin0: float = sin(a0) - var cos1: float = cos(a1) - var sin1: float = sin(a1) - - # Side ring - var norm = Vector3(cos0, 0.0, sin0).normalized() - st.set_normal(norm) - st.add_vertex(Vector3(cos0 * trunk_r, 0.0, sin0 * trunk_r)) - st.add_vertex(Vector3(cos1 * trunk_r, 0.0, sin1 * trunk_r)) - st.add_vertex(Vector3(cos1 * trunk_r, trunk_h, sin1 * trunk_r)) - st.add_vertex(Vector3(cos0 * trunk_r, 0.0, sin0 * trunk_r)) - st.add_vertex(Vector3(cos1 * trunk_r, trunk_h, sin1 * trunk_r)) - st.add_vertex(Vector3(cos0 * trunk_r, trunk_h, sin0 * trunk_r)) - - st.generate_normals() - - st.commit(am) - - # Canopy — sphere (larger, offset up) - st = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var canopy_r: float = 0.75 - var canopy_y: float = trunk_h + 0.4 - var rings: int = 6 - var cols: int = 8 - for r in rings: - var phi0: float = float(r) / rings * PI - var phi1: float = float(r + 1) / rings * PI - for c in cols: - var theta0: float = float(c) / cols * TAU - var theta1: float = float(c + 1) / cols * TAU - - var v0 = Vector3(cos(theta0) * sin(phi0), cos(phi0), sin(theta0) * sin(phi0)) - var v1 = Vector3(cos(theta1) * sin(phi0), cos(phi0), sin(theta1) * sin(phi0)) - var v2 = Vector3(cos(theta1) * sin(phi1), cos(phi1), sin(theta1) * sin(phi1)) - var v3 = Vector3(cos(theta0) * sin(phi1), cos(phi1), sin(theta0) * sin(phi1)) - - v0 = v0 * canopy_r + Vector3(0.0, canopy_y, 0.0) - v1 = v1 * canopy_r + Vector3(0.0, canopy_y, 0.0) - v2 = v2 * canopy_r + Vector3(0.0, canopy_y, 0.0) - v3 = v3 * canopy_r + Vector3(0.0, canopy_y, 0.0) - - st.add_vertex(v0) - st.add_vertex(v1) - st.add_vertex(v2) - st.add_vertex(v0) - st.add_vertex(v2) - st.add_vertex(v3) - - st.generate_normals() - - st.commit(am) - - return am - - -## Deer / Animal: Capsule body + cone snout + small sphere head -static func _build_animal_mesh() -> Mesh: - var am: ArrayMesh = ArrayMesh.new() - - # Body — capsule-like (cylinder with hemispheres) - var st: SurfaceTool = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var body_len: float = 0.7 - var body_r: float = 0.25 - var segments: int = 8 - - # Central cylinder (along X axis) - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - var cos0: float = cos(a0) - var sin0: float = sin(a0) - var cos1: float = cos(a1) - var sin1: float = sin(a1) - - var norm = Vector3(0.0, cos0, sin0).normalized() - st.set_normal(norm) - st.add_vertex(Vector3(-body_len, cos0 * body_r, sin0 * body_r)) - st.add_vertex(Vector3(-body_len, cos1 * body_r, sin1 * body_r)) - st.add_vertex(Vector3(body_len, cos1 * body_r, sin1 * body_r)) - st.add_vertex(Vector3(-body_len, cos0 * body_r, sin0 * body_r)) - st.add_vertex(Vector3(body_len, cos1 * body_r, sin1 * body_r)) - st.add_vertex(Vector3(body_len, cos0 * body_r, sin0 * body_r)) - - # Front hemisphere (head area, +X) - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - for j in 4: - var phi0: float = float(j) / 4 * 0.5 * PI # 0 to 90° - var phi1: float = float(j + 1) / 4 * 0.5 * PI - var cos_a0 = cos(a0) - var sin_a0 = sin(a0) - var cos_a1 = cos(a1) - var sin_a1 = sin(a1) - - var cx: float = body_len + cos(phi0) * body_r * 0.8 - var cy0: float = sin(phi0) * cos_a0 * body_r * 0.8 - var cz0: float = sin(phi0) * sin_a0 * body_r * 0.8 - var cy1: float = sin(phi1) * cos_a1 * body_r * 0.8 - var cz1: float = sin(phi1) * sin_a1 * body_r * 0.8 - - st.add_vertex(Vector3(cx, cy0, cz0)) - st.add_vertex(Vector3(body_len + cos(phi1) * body_r * 0.8, cy0, cz0)) - st.add_vertex(Vector3(body_len + cos(phi1) * body_r * 0.8, cy1, cz1)) - st.add_vertex(Vector3(cx, cy0, cz0)) - st.add_vertex(Vector3(body_len + cos(phi1) * body_r * 0.8, cy1, cz1)) - st.add_vertex(Vector3(body_len + cos(phi0) * body_r * 0.8, cy1, cz1)) - - st.generate_normals() - - st.commit(am) - - # Snout — small cone at front - st = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var snout_len: float = 0.2 - var snout_r: float = 0.12 - var snut_tip_x: float = body_len + body_r * 0.8 + snout_len - var snut_base_x: float = body_len + body_r * 0.8 - for i in segments: - var a: float = float(i) / segments * TAU - var a2: float = float(i + 1) / segments * TAU - st.add_vertex(Vector3(snut_tip_x, 0.0, 0.0)) - st.add_vertex(Vector3(snut_base_x, cos(a) * snout_r, sin(a) * snout_r)) - st.add_vertex(Vector3(snut_base_x, cos(a2) * snout_r, sin(a2) * snout_r)) - - st.generate_normals() - - st.commit(am) - - # Legs — 4 thin cylinders at bottom - for lx in [-0.4, 0.3]: - for lz_sign in [-1, 1]: - st = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var leg_h: float = 0.35 - var leg_r: float = 0.06 - var leg_x: float = lx - var leg_z: float = lz_sign * body_r * 0.6 - for i in 6: - var a: float = float(i) / 6 * TAU - var a2: float = float(i + 1) / 6 * TAU - var n = Vector3(0.0, cos(a), sin(a)).normalized() - st.set_normal(n) - st.add_vertex(Vector3(leg_x, 0.0, leg_z + sin(a) * leg_r + cos(a) * leg_r * 0.0)) - # Simplified: just a vertical cylinder - var vx0: float = leg_x - var vy0: float = 0.0 - var vz0: float = leg_z + cos(a) * leg_r - var vz1: float = leg_z + cos(a2) * leg_r - st.set_normal(Vector3(0.0, cos(a), sin(a))) - st.add_vertex(Vector3(vx0, vy0, leg_z + cos(a) * leg_r)) - st.add_vertex(Vector3(vx0, vy0, leg_z + cos(a2) * leg_r)) - st.add_vertex(Vector3(vx0, leg_h, leg_z + cos(a2) * leg_r)) - st.add_vertex(Vector3(vx0, vy0, leg_z + cos(a) * leg_r)) - st.add_vertex(Vector3(vx0, leg_h, leg_z + cos(a2) * leg_r)) - st.add_vertex(Vector3(vx0, leg_h, leg_z + cos(a) * leg_r)) - st.generate_normals() - st.commit(am) - - return am - - -## Bird: Elongated capsule body + tail cone + wing planes -static func _build_bird_mesh() -> Mesh: - var am: ArrayMesh = ArrayMesh.new() - - # Body — elongated capsule along X - var st: SurfaceTool = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var body_len: float = 0.35 - var body_r: float = 0.13 - var segments: int = 6 - - # Central cylinder - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - var cos0: float = cos(a0) - var sin0: float = sin(a0) - var cos1: float = cos(a1) - var sin1: float = sin(a1) - var norm = Vector3(0.0, cos0, sin0).normalized() - st.set_normal(norm) - st.add_vertex(Vector3(-body_len, cos0 * body_r, sin0 * body_r)) - st.add_vertex(Vector3(-body_len, cos1 * body_r, sin1 * body_r)) - st.add_vertex(Vector3(body_len, cos1 * body_r, sin1 * body_r)) - st.add_vertex(Vector3(-body_len, cos0 * body_r, sin0 * body_r)) - st.add_vertex(Vector3(body_len, cos1 * body_r, sin1 * body_r)) - st.add_vertex(Vector3(body_len, cos0 * body_r, sin0 * body_r)) - - # Nose cone at front (+X) - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - var tip_x: float = body_len + 0.2 - st.add_vertex(Vector3(tip_x, 0.0, 0.0)) - st.add_vertex(Vector3(body_len, cos(a0) * body_r, sin(a0) * body_r)) - st.add_vertex(Vector3(body_len, cos(a1) * body_r, sin(a1) * body_r)) - - # Tail cone at back (-X) - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - var tail_tip_x: float = -body_len - 0.2 - st.add_vertex(Vector3(tail_tip_x, 0.0, 0.0)) - st.add_vertex(Vector3(-body_len, cos(a1) * body_r * 0.7, sin(a1) * body_r * 0.7)) - st.add_vertex(Vector3(-body_len, cos(a0) * body_r * 0.7, sin(a0) * body_r * 0.7)) - - st.generate_normals() - - st.commit(am) - - # Wings — thin flat planes extending upward (will rotate with entity facing) - # Left wing - st = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var wing_w: float = 0.45 - var wing_d: float = 0.02 - var wing_y: float = 0.1 - st.add_vertex(Vector3(0.0, wing_y, body_r * 0.5)) - st.add_vertex(Vector3(wing_w, wing_y * 0.3, body_r * 0.5)) - st.add_vertex(Vector3(0.0, wing_y, -body_r * 0.5)) - st.add_vertex(Vector3(wing_w, wing_y * 0.3, body_r * 0.5)) - st.add_vertex(Vector3(wing_w, wing_y * 0.3, -body_r * 0.5)) - st.add_vertex(Vector3(0.0, wing_y, -body_r * 0.5)) - st.generate_normals() - st.commit(am) - - return am - - -## Butterfly: Body capsule + 2 pairs of wing planes -static func _build_insect_mesh() -> Mesh: - var am: ArrayMesh = ArrayMesh.new() - - # Body — small capsule along X - var st: SurfaceTool = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var body_len: float = 0.12 - var body_r: float = 0.06 - var segments: int = 6 - - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - var cos0: float = cos(a0) - var sin0: float = sin(a0) - var cos1: float = cos(a1) - var sin1: float = sin(a1) - var norm = Vector3(0.0, cos0, sin0).normalized() - st.set_normal(norm) - st.add_vertex(Vector3(-body_len, cos0 * body_r, sin0 * body_r)) - st.add_vertex(Vector3(-body_len, cos1 * body_r, sin1 * body_r)) - st.add_vertex(Vector3(body_len, cos1 * body_r, sin1 * body_r)) - st.add_vertex(Vector3(-body_len, cos0 * body_r, sin0 * body_r)) - st.add_vertex(Vector3(body_len, cos1 * body_r, sin1 * body_r)) - st.add_vertex(Vector3(body_len, cos0 * body_r, sin0 * body_r)) - - # Hemispherical cap at front - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - for j in 3: - var p0: float = float(j) / 3 * 0.5 * PI - var p1: float = float(j + 1) / 3 * 0.5 * PI - var cx: float = body_len + cos(p0) * body_r - var cy0: float = sin(p0) * cos(a0) * body_r - var cz0: float = sin(p0) * sin(a0) * body_r - var cy1: float = sin(p1) * cos(a1) * body_r - var cz1: float = sin(p1) * sin(a1) * body_r - st.add_vertex(Vector3(cx, cy0, cz0)) - st.add_vertex(Vector3(body_len + cos(p1) * body_r, cy0, cz0)) - st.add_vertex(Vector3(body_len + cos(p1) * body_r, cy1, cz1)) - st.add_vertex(Vector3(cx, cy0, cz0)) - st.add_vertex(Vector3(body_len + cos(p1) * body_r, cy1, cz1)) - st.add_vertex(Vector3(body_len + cos(p0) * body_r, cy1, cz1)) - - st.generate_normals() - - st.commit(am) - - # Wings — 4 thin planes (2 pairs, upper + lower) - for pair in [0, 1]: - var base_y: float = 0.08 + pair * 0.02 - var wing_span: float = 0.3 - pair * 0.08 - # Upper pair - st = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - st.add_vertex(Vector3(0.0, base_y, 0.0)) - st.add_vertex(Vector3(wing_span * 0.6, base_y * 0.4, wing_span * 0.5)) - st.add_vertex(Vector3(-wing_span * 0.3, base_y * 0.6, wing_span * 0.5)) - st.add_vertex(Vector3(0.0, base_y, 0.0)) - st.add_vertex(Vector3(-wing_span * 0.3, base_y * 0.6, wing_span * 0.5)) - st.add_vertex(Vector3(0.0, base_y, wing_span * 0.55)) - st.generate_normals() - st.commit(am) - - # Lower pair (mirror) - st = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - st.add_vertex(Vector3(0.0, -base_y, 0.0)) - st.add_vertex(Vector3(-wing_span * 0.3, -base_y * 0.6, wing_span * 0.5)) - st.add_vertex(Vector3(wing_span * 0.6, -base_y * 0.4, wing_span * 0.5)) - st.add_vertex(Vector3(0.0, -base_y, 0.0)) - st.add_vertex(Vector3(0.0, -base_y, wing_span * 0.55)) - st.add_vertex(Vector3(-wing_span * 0.3, -base_y * 0.6, wing_span * 0.5)) - st.generate_normals() - st.commit(am) - - return am - - -## Grass: 3 thin blade planes clustered together -static func _build_grass_mesh() -> Mesh: - var am: ArrayMesh = ArrayMesh.new() - var st: SurfaceTool = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - - var blade_h: float = 0.4 - var blade_w: float = 0.04 - - for i in 3: - var angle: float = float(i) * 2.1 - var ox: float = cos(angle) * 0.12 - var oz: float = sin(angle) * 0.12 - var tilt: float = 0.15 * cos(angle) - - st.add_vertex(Vector3(ox, 0.0, oz)) - st.add_vertex(Vector3(ox + blade_w, 0.0, oz)) - st.add_vertex(Vector3(ox + tilt, blade_h, oz)) - st.add_vertex(Vector3(ox + tilt, blade_h, oz)) - st.add_vertex(Vector3(ox + blade_w, 0.0, oz)) - st.add_vertex(Vector3(ox + blade_w + tilt, blade_h * 0.95, oz)) - - st.generate_normals() - st.commit(am) - return am - - -## Wildflower: Stem cylinder + bloom sphere (bloom visible via color override) -static func _build_flower_mesh() -> Mesh: - var am: ArrayMesh = ArrayMesh.new() - - # Stem — thin cylinder - var st: SurfaceTool = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var stem_h: float = 0.35 - var stem_r: float = 0.04 - var segments: int = 5 - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - var cos0: float = cos(a0) - var sin0: float = sin(a0) - var cos1: float = cos(a1) - var sin1: float = sin(a1) - var norm = Vector3(cos0, 0.0, sin0).normalized() - st.set_normal(norm) - st.add_vertex(Vector3(cos0 * stem_r, 0.0, sin0 * stem_r)) - st.add_vertex(Vector3(cos1 * stem_r, 0.0, sin1 * stem_r)) - st.add_vertex(Vector3(cos1 * stem_r, stem_h, sin1 * stem_r)) - st.add_vertex(Vector3(cos0 * stem_r, 0.0, sin0 * stem_r)) - st.add_vertex(Vector3(cos1 * stem_r, stem_h, sin1 * stem_r)) - st.add_vertex(Vector3(cos0 * stem_r, stem_h, sin0 * stem_r)) - st.generate_normals() - st.commit(am) - - # Bloom — small sphere at top (FRUITING state makes this colorful) - st = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var bloom_r: float = 0.12 - var bloom_y: float = stem_h + 0.02 - var rings: int = 4 - var cols: int = 6 - for r in rings: - var phi0: float = float(r) / rings * PI - var phi1: float = float(r + 1) / rings * PI - for c in cols: - var theta0: float = float(c) / cols * TAU - var theta1: float = float(c + 1) / cols * TAU - var v0 = Vector3(cos(theta0)*sin(phi0), cos(phi0), sin(theta0)*sin(phi0)) - var v1 = Vector3(cos(theta1)*sin(phi0), cos(phi0), sin(theta1)*sin(phi0)) - var v2 = Vector3(cos(theta1)*sin(phi1), cos(phi1), sin(theta1)*sin(phi1)) - var v3 = Vector3(cos(theta0)*sin(phi1), cos(phi1), sin(theta0)*sin(phi1)) - v0 = v0 * bloom_r + Vector3(0.0, bloom_y, 0.0) - v1 = v1 * bloom_r + Vector3(0.0, bloom_y, 0.0) - v2 = v2 * bloom_r + Vector3(0.0, bloom_y, 0.0) - v3 = v3 * bloom_r + Vector3(0.0, bloom_y, 0.0) - st.add_vertex(v0); st.add_vertex(v1); st.add_vertex(v2) - st.add_vertex(v0); st.add_vertex(v2); st.add_vertex(v3) - st.generate_normals() - st.commit(am) - - return am - - -## Mushroom: Cap (cylinder top) + stalk -static func _build_mushroom_mesh() -> Mesh: - var am: ArrayMesh = ArrayMesh.new() - - # Stalk - var st: SurfaceTool = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var stalk_h: float = 0.2 - var stalk_r: float = 0.06 - var segments: int = 6 - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - var cos0: float = cos(a0) - var sin0: float = sin(a0) - var cos1: float = cos(a1) - var sin1: float = sin(a1) - var norm = Vector3(cos0, 0.0, sin0).normalized() - st.set_normal(norm) - st.add_vertex(Vector3(cos0 * stalk_r, 0.0, sin0 * stalk_r)) - st.add_vertex(Vector3(cos1 * stalk_r, 0.0, sin1 * stalk_r)) - st.add_vertex(Vector3(cos1 * stalk_r, stalk_h, sin1 * stalk_r)) - st.add_vertex(Vector3(cos0 * stalk_r, 0.0, sin0 * stalk_r)) - st.add_vertex(Vector3(cos1 * stalk_r, stalk_h, sin1 * stalk_r)) - st.add_vertex(Vector3(cos0 * stalk_r, stalk_h, sin0 * stalk_r)) - st.generate_normals() - st.commit(am) - - # Cap — wider cylinder top (flat, like a dome) - st = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var cap_r: float = 0.2 - var cap_y: float = stalk_h - 0.02 - # Top face - var center: Vector3 = Vector3(0.0, cap_y + 0.06, 0.0) - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - st.set_normal(Vector3.UP) - st.add_vertex(center) - st.add_vertex(Vector3(cos(a0) * cap_r, cap_y + 0.06, sin(a0) * cap_r)) - st.add_vertex(Vector3(cos(a1) * cap_r, cap_y + 0.06, sin(a1) * cap_r)) - - # Side ring - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - var cos0: float = cos(a0) - var sin0: float = sin(a0) - var cos1: float = cos(a1) - var sin1: float = sin(a1) - var norm = Vector3(cos0, -0.3, sin0).normalized() - st.set_normal(norm) - st.add_vertex(Vector3(cos0 * cap_r, cap_y + 0.06, sin0 * cap_r)) - st.add_vertex(Vector3(cos1 * cap_r, cap_y + 0.06, sin1 * cap_r)) - st.add_vertex(Vector3(cos1 * cap_r, cap_y, sin1 * cap_r)) - st.add_vertex(Vector3(cos0 * cap_r, cap_y + 0.06, sin0 * cap_r)) - st.add_vertex(Vector3(cos1 * cap_r, cap_y, sin1 * cap_r)) - st.add_vertex(Vector3(cos0 * cap_r, cap_y, sin0 * cap_r)) - - st.generate_normals() - st.commit(am) - - return am - - -## Water pool: flat cylinder -static func _build_water_cylinder(radius: float, material: Object) -> MeshInstance3D: - var mi: MeshInstance3D = MeshInstance3D.new() - - var st: SurfaceTool = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var segments: int = 16 - var center: Vector3 = Vector3.ZERO - for i in segments: - var a0: float = float(i) / segments * TAU - var a1: float = float(i + 1) / segments * TAU - st.add_vertex(center) - st.add_vertex(Vector3(cos(a0) * radius, 0.0, sin(a0) * radius)) - st.add_vertex(Vector3(cos(a1) * radius, 0.0, sin(a1) * radius)) - st.generate_normals() - - var mesh: Mesh = st.commit() - mi.mesh = mesh - if material: - mi.set_surface_override_material(0, material) - mi.transform.basis = Basis.from_scale(Vector3(radius, 1.0, radius)) - - return mi - # ── Color helpers ──────────────────────────────────────────────────────── @@ -734,17 +262,17 @@ static func _get_entity_color(ent) -> Color: match ent.type: "ANIMAL": if species == "wolf": - return Color(0.45, 0.45, 0.45) + return C_WOLF return C_DEER "BIRD": return C_BIRD "INSECT": return C_BUTTERFLY "TREE": - return C_OAK_TRUNK + return C_TREE "PLANT": if species == "wildflower": - return C_WILDFLOWER_STEM + return C_WILDFLOWER return C_GRASS "MICROORGANISM": return C_MUSHROOM @@ -839,9 +367,3 @@ static func _build_entity_transform(ent, size: float, tick_ms: float) -> Transfo return t -static func _source_exists(sources: Array, key: String) -> bool: - for src: Dictionary in sources: - var pos: Vector2 = src.position - if "water_%s_%s" % [pos.x, pos.y] == key: - return true - return false From cdd05a56d8f3c1155901dd0fe8c93ca34c4485bd Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 08:40:37 -0400 Subject: [PATCH 11/25] fix: minor compatibility fixes - world_model: use explicit type list instead of PackedStringArray lookup - engine.py: include type/species in entity update packets --- client/godot/scripts/autoloads/world_model.gd | 3 +-- server/ecosim/engine.py | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/client/godot/scripts/autoloads/world_model.gd b/client/godot/scripts/autoloads/world_model.gd index 5bfd4d0..e88902c 100644 --- a/client/godot/scripts/autoloads/world_model.gd +++ b/client/godot/scripts/autoloads/world_model.gd @@ -213,10 +213,9 @@ func apply_water_sources(sources: Variant) -> void: ## Get alive mobile entities (animals, birds, insects). func get_alive_mobile() -> Array[WorldEntity]: var result: Array[WorldEntity] = [] - var mobile: PackedStringArray = PackedStringArray(LilaConstants.MOBILE_TYPES) for eid in entities: var ent: WorldEntity = entities[eid] - if ent.alive and mobile.has(ent.type): + if ent.alive and ent.type in ["ANIMAL", "BIRD", "INSECT"]: result.append(ent) return result 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. From 63961d10660e18bf1a92ea0352739e0567ed4ee2 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 09:06:11 -0400 Subject: [PATCH 12/25] fix: WebSocket stability and ground memory leak - ws_client: add 100ms poll timer (decoupled from render loop) so server pings are serviced even when FPS drops from voxel rebuild - ws_client: persist world definition as raw string, resends on reconnect - ws_client: create fresh WebSocketPeer on reconnect to clear stale state - renderer: build ground MultiMesh once in setup, only update colors each frame (was allocating new BoxMesh+MultiMesh every frame, causing memory pressure) --- client/godot/scenes/main.gd | 11 ++-- client/godot/scripts/autoloads/ws_client.gd | 66 +++++++++++++-------- client/godot/scripts/renderer.gd | 43 ++++++++------ 3 files changed, 74 insertions(+), 46 deletions(-) diff --git a/client/godot/scenes/main.gd b/client/godot/scenes/main.gd index 535a163..3f06f61 100644 --- a/client/godot/scenes/main.gd +++ b/client/godot/scenes/main.gd @@ -66,6 +66,9 @@ func _setup_renderer() -> void: # 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 ────────────────────────────────────────── @@ -131,11 +134,11 @@ func _process(delta: float) -> void: _update_particle_mesh() -## Build ground as MultiMesh voxels (one 1x1x1 cube per grid cell). -## Water sources color cells within their radius. +## Update ground voxel colors (MultiMesh was built once in setup). func _build_ground() -> void: - var mm: MultiMesh = renderer.build_ground_voxels(World.moisture_grid, World.water_sources) - ground_mi.multimesh = mm + renderer.update_ground_voxels( + ground_mi.multimesh, World.moisture_grid, World.water_sources + ) ## Update all per-type InstancedMesh entities. diff --git a/client/godot/scripts/autoloads/ws_client.gd b/client/godot/scripts/autoloads/ws_client.gd index 9e39d0b..83400b1 100644 --- a/client/godot/scripts/autoloads/ws_client.gd +++ b/client/godot/scripts/autoloads/ws_client.gd @@ -10,7 +10,9 @@ signal session_started(data: Dictionary) signal tick_packet(data: Dictionary) signal world_json_ready(data: Dictionary) -var _world_def_to_send: Dictionary = {} + +## Persisted world definition string (resent on every reconnect). +var _world_def_json: String = "" ## State @@ -29,6 +31,7 @@ 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). @@ -39,58 +42,72 @@ func _load_world_json_local() -> void: 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: - var data: Dictionary = json_conv.data - _world_def_to_send = data - world_json_ready.emit(data) + world_json_ready.emit(json_conv.data) print("World JSON loaded from local file") else: push_error("Failed to parse world.json: ", json_conv.get_error_message()) -func _process(delta: float) -> void: - if not _is_connected: - _reconnect_timer -= delta - if _reconnect_timer <= 0 and not _is_connecting: - _connect_to_server() +## 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 - - # Poll WebSocket _ws.poll() - var status: int = _ws.get_ready_state() - if status == WebSocketPeer.STATE_OPEN: + 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) - # Only send one large message per frame to avoid flooding break - elif status == WebSocketPeer.STATE_CLOSED: - var code: int = _ws.get_close_code() - _is_connected = false - _reconnect_timer = LilaConstants.RECONNECT_DELAY - disconnected.emit() - print("WebSocket closed (code ", code, "), reconnecting in ", LilaConstants.RECONNECT_DELAY, "s") + if _is_connected: + var code: int = _ws.get_close_code() + _is_connected = false + _reconnect_timer = LilaConstants.RECONNECT_DELAY + disconnected.emit() + print("WebSocket closed (code ", code, "), reconnecting in ", LilaConstants.RECONNECT_DELAY, "s") + + +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" print("Connecting to ", 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) @@ -111,11 +128,10 @@ func _connect_to_server() -> void: connected.emit() print("WebSocket connected") - # Send world definition if we have it - if not _world_def_to_send.is_empty(): - _ws.send_text(JSON.stringify(_world_def_to_send)) + # Send world definition (always — works on reconnect) + if not _world_def_json.is_empty(): + _ws.send_text(_world_def_json) print("World definition sent") - _world_def_to_send = {} # Flush any pending sends from before connection for msg in _pending_sends: diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd index 65ebd24..4fe933f 100644 --- a/client/godot/scripts/renderer.gd +++ b/client/godot/scripts/renderer.gd @@ -185,13 +185,9 @@ static func update_entities( -## Build ground as MultiMesh of 1x1x1 BoxMesh voxels (one per grid cell). -## Each voxel sits at (gx, 0, gz) with top face at y=0.5. -## Cells inside a water source get water color blended by water_level. -static func build_ground_voxels( - moisture_grid: PackedFloat32Array, - water_sources: Array, -) -> MultiMesh: +## Build ground voxel MultiMesh once (call in _setup). +## Creates 1x1x1 BoxMesh instances at each grid cell — transforms never change. +static func build_ground_voxels() -> MultiMesh: var box: BoxMesh = BoxMesh.new() box.size = Vector3(1.0, 1.0, 1.0) @@ -203,6 +199,28 @@ static func build_ground_voxels( 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. +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: @@ -211,7 +229,6 @@ static func build_ground_voxels( if idx < moisture_grid.size(): moisture = moisture_grid[idx] - # Check if this cell falls inside any water source var color: Color = _moisture_color(moisture) var cell_pos: Vector2 = Vector2(float(gx), float(gz)) for src: Dictionary in water_sources: @@ -222,21 +239,13 @@ static func build_ground_voxels( continue var dist: float = cell_pos.distance_to(src_pos) if dist <= radius: - # Blend toward water color; closer = more water var blend: float = (1.0 - dist / radius) * level color = color.lerp(C_WATER, blend) - break # first (closest) match wins; sources rarely overlap + break - 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, color) i += 1 - return mm - ## Build particle mesh (small spheres/boxes). static func build_particle_mesh() -> Mesh: From 6bea2bcfb39d042f4a0edb816a37a30fb3ef1e5e Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 09:16:27 -0400 Subject: [PATCH 13/25] feat: add LilaConstants.log() with HH:MM:SS timestamps - Add log() helper to LilaConstants - Replace print() calls in ws_client.gd and main.gd --- client/godot/scenes/main.gd | 6 +++--- client/godot/scripts/autoloads/ws_client.gd | 10 +++++----- client/godot/scripts/constants.gd | 6 ++++++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/client/godot/scenes/main.gd b/client/godot/scenes/main.gd index 3f06f61..148907b 100644 --- a/client/godot/scenes/main.gd +++ b/client/godot/scenes/main.gd @@ -33,7 +33,7 @@ var _ground_mat: ShaderMaterial = null func _ready() -> void: - print("Lila Godot Client starting (3D — cube renderer)...") + LilaConstants.log("Lila Godot Client starting (3D — cube renderer)...") WS.session_started.connect(_on_session_started) WS.tick_packet.connect(_on_tick_packet) @@ -171,7 +171,7 @@ func _on_world_json_ready(data: Dictionary) -> void: func _on_session_started(data: Dictionary) -> void: - print("Session started: ", data.get("session_id", "")) + LilaConstants.log("Session started: ", data.get("session_id", "")) World.species_defs = data.get("species", {}) _session_started = true World.flush_dead() @@ -265,4 +265,4 @@ func _log_entity_telemetry() -> void: ent.ack, ent.reconcile_queue.size(), ] - print(log_line) + LilaConstants.log(log_line) diff --git a/client/godot/scripts/autoloads/ws_client.gd b/client/godot/scripts/autoloads/ws_client.gd index 83400b1..8121c9a 100644 --- a/client/godot/scripts/autoloads/ws_client.gd +++ b/client/godot/scripts/autoloads/ws_client.gd @@ -48,7 +48,7 @@ func _load_world_json_local() -> void: var err: Error = json_conv.parse(text) if err == OK: world_json_ready.emit(json_conv.data) - print("World JSON loaded from local file") + LilaConstants.log("World JSON loaded from local file") else: push_error("Failed to parse world.json: ", json_conv.get_error_message()) @@ -90,7 +90,7 @@ func _on_poll_timer() -> void: _is_connected = false _reconnect_timer = LilaConstants.RECONNECT_DELAY disconnected.emit() - print("WebSocket closed (code ", code, "), reconnecting in ", LilaConstants.RECONNECT_DELAY, "s") + LilaConstants.log("WebSocket closed (code ", code, "), reconnecting in ", LilaConstants.RECONNECT_DELAY, "s") func _process(delta: float) -> void: @@ -103,7 +103,7 @@ func _process(delta: float) -> void: func _connect_to_server() -> void: _is_connecting = true var url: String = "ws://" + host + ":" + str(port) + "/ws" - print("Connecting to ", url) + LilaConstants.log("Connecting to ", url) # Fresh WebSocketPeer to avoid stale state from previous connection _ws = WebSocketPeer.new() @@ -126,12 +126,12 @@ func _connect_to_server() -> void: _is_connected = true _is_connecting = false connected.emit() - print("WebSocket connected") + LilaConstants.log("WebSocket connected") # Send world definition (always — works on reconnect) if not _world_def_json.is_empty(): _ws.send_text(_world_def_json) - print("World definition sent") + LilaConstants.log("World definition sent") # Flush any pending sends from before connection for msg in _pending_sends: diff --git a/client/godot/scripts/constants.gd b/client/godot/scripts/constants.gd index edd7503..a0ef536 100644 --- a/client/godot/scripts/constants.gd +++ b/client/godot/scripts/constants.gd @@ -1,6 +1,12 @@ ## Shared constants mirroring browser/Python clients. 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 From 77d072767aae68a0b98dc0de05994e7c2e11399a Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 09:18:15 -0400 Subject: [PATCH 14/25] fix: log() calls need single-argument formatting --- client/godot/scenes/main.gd | 2 +- client/godot/scripts/autoloads/ws_client.gd | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/godot/scenes/main.gd b/client/godot/scenes/main.gd index 148907b..1f98cf9 100644 --- a/client/godot/scenes/main.gd +++ b/client/godot/scenes/main.gd @@ -171,7 +171,7 @@ func _on_world_json_ready(data: Dictionary) -> void: func _on_session_started(data: Dictionary) -> void: - LilaConstants.log("Session started: ", data.get("session_id", "")) + LilaConstants.log("Session started: %s" % data.get("session_id", "")) World.species_defs = data.get("species", {}) _session_started = true World.flush_dead() diff --git a/client/godot/scripts/autoloads/ws_client.gd b/client/godot/scripts/autoloads/ws_client.gd index 8121c9a..9f21cbb 100644 --- a/client/godot/scripts/autoloads/ws_client.gd +++ b/client/godot/scripts/autoloads/ws_client.gd @@ -90,7 +90,7 @@ func _on_poll_timer() -> void: _is_connected = false _reconnect_timer = LilaConstants.RECONNECT_DELAY disconnected.emit() - LilaConstants.log("WebSocket closed (code ", code, "), reconnecting in ", LilaConstants.RECONNECT_DELAY, "s") + LilaConstants.log("WebSocket closed (code %d), reconnecting in %.1fs" % [code, LilaConstants.RECONNECT_DELAY]) func _process(delta: float) -> void: @@ -103,7 +103,7 @@ func _process(delta: float) -> void: func _connect_to_server() -> void: _is_connecting = true var url: String = "ws://" + host + ":" + str(port) + "/ws" - LilaConstants.log("Connecting to ", url) + LilaConstants.log("Connecting to %s" % url) # Fresh WebSocketPeer to avoid stale state from previous connection _ws = WebSocketPeer.new() From 9f0fa767456060750879dd6919258cfd06fe4393 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 10:13:31 -0400 Subject: [PATCH 15/25] godot: place entities on ground surface + fruiting wildflower spheres --- client/godot/scripts/renderer.gd | 65 ++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd index 4fe933f..230911c 100644 --- a/client/godot/scripts/renderer.gd +++ b/client/godot/scripts/renderer.gd @@ -8,6 +8,10 @@ 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) @@ -17,6 +21,7 @@ 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) @@ -40,10 +45,16 @@ var _type_meshes: Dictionary = {} # ── Public API ───────────────────────────────────────────────────────── ## Build simple BoxMesh cubes for all entity types (call once in _ready). +## Fruiting wildflowers get a SphereMesh instead of a cube. static func build_all_type_meshes() -> Dictionary: var meshes: Dictionary = {} for key in ["TREE", "ANIMAL", "BIRD", "INSECT", "PLANT_GRASS", "PLANT_FLOWER", "MICROORGANISM"]: meshes[key] = BoxMesh.new() + var sphere: SphereMesh = SphereMesh.new() + sphere.radius = 1.0 + sphere.radial_segments = 12 + sphere.rings = 8 + meshes["PLANT_FLOWER_BLOOM"] = sphere return meshes @@ -176,10 +187,13 @@ static func update_entities( color = color.darkened(0.55) # Wilted plants shift color - var sv: Dictionary = ent.drive - if ent.type == "PLANT" and sv.get("hydration", 1.0) < 0.25: + 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) @@ -299,6 +313,9 @@ static func _get_entity_size(ent) -> float: "INSECT": return SIZE_INSECT "PLANT": + # Fruiting wildflowers are twice as big (sphere variant) + if ent.species == "wildflower" and ent.state == "FRUITING": + return SIZE_PLANT * 2.0 return SIZE_PLANT "MICROORGANISM": return SIZE_MICRO @@ -316,6 +333,8 @@ static func _entity_to_mesh_key(ent) -> String: "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" @@ -327,28 +346,18 @@ static func _entity_to_mesh_key(ent) -> String: static func _build_entity_transform(ent, size: float, tick_ms: float) -> Transform3D: var cx: float = ent.x var cz: float = ent.z - var cy: float = 0.5 var y_extra: float = 0.0 + var sv: Dictionary = ent.drive - # Insects float above ground - if ent.type == "INSECT": - y_extra = 2.5 + sin(tick_ms / 300.0 + float(ent.sync_phase)) * 0.8 - cy += y_extra - - # Birds fly higher - if ent.type == "BIRD": - y_extra = 3.5 + sin(tick_ms / 400.0 + float(ent.sync_phase)) * 0.5 - cy += y_extra + # ── Per-type size adjustments ────────────────────────────────── # Trees grow taller with growth state_var - var sv: Dictionary = ent.drive if ent.type == "TREE": var growth: float = sv.get("growth", 0.5) - cy = 0.0 # trunk rooted at ground - size = SIZE_TREE * (0.5 + growth * 0.5) # scale by growth + size = SIZE_TREE * (0.5 + growth * 0.5) - # Plants scale by growth - if ent.type == "PLANT": + # 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) @@ -361,16 +370,32 @@ static func _build_entity_transform(ent, size: float, tick_ms: float) -> Transfo if ent.type == "ANIMAL" and ent.state == "RESTING": size *= 0.75 - # Facing direction + # ── 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 = 2.5 + sin(tick_ms / 300.0 + float(ent.sync_phase)) * 0.8 + 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 - # Build transform var t: Transform3D t.origin = Vector3(cx, cy, cz) # Rotate around Y axis to face direction, scale uniformly var rot: Basis = Basis.from_euler(Vector3(0.0, -angle + PI / 2.0, 0.0)) - var sc: float = size * 0.6 # normalize to world units t.basis = rot * Basis.from_scale(Vector3(sc, sc, sc)) return t From 05f3bb51b73e9f4a59645b7e14e2e0d303e7f9d1 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 11:48:31 -0400 Subject: [PATCH 16/25] fix(godot-client): port ConeMesh/SphereMesh to Godot 4.7 API and tune sizes - Replace removed ConeMesh with CylinderMesh (top_radius=0) - Drop sphere.rings (not settable in 4.7) - Remove ring_count from CylinderMesh (not settable in 4.7) - Lower fruiting wildflower size multiplier from 2.0 to 1.3 - Remove class_name Renderer (not needed, scene wires it directly) - Drop static from renderer methods (called via instance in main.gd) --- client/godot/scripts/renderer.gd | 63 ++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd index 230911c..44b44c0 100644 --- a/client/godot/scripts/renderer.gd +++ b/client/godot/scripts/renderer.gd @@ -1,7 +1,6 @@ ## Simple cube-based 3D entity renderer. ## Each entity type gets a single BoxMesh (cube) with type-appropriate color ## harmonized with the browser renderer palette. -class_name Renderer extends Node @@ -46,21 +45,28 @@ var _type_meshes: Dictionary = {} ## Build simple BoxMesh cubes for all entity types (call once in _ready). ## Fruiting wildflowers get a SphereMesh instead of a cube. -static func build_all_type_meshes() -> Dictionary: +func build_all_type_meshes() -> Dictionary: var meshes: Dictionary = {} - for key in ["TREE", "ANIMAL", "BIRD", "INSECT", "PLANT_GRASS", "PLANT_FLOWER", "MICROORGANISM"]: + for key in ["TREE", "ANIMAL", "INSECT", "PLANT_GRASS", "PLANT_FLOWER", "MICROORGANISM"]: meshes[key] = BoxMesh.new() + # 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 - sphere.rings = 8 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). -static func _make_vertex_color_material() -> ShaderMaterial: +func _make_vertex_color_material() -> ShaderMaterial: var shader: Shader = Shader.new() shader.code = """ shader_type spatial; @@ -83,7 +89,7 @@ void fragment() { ## Material for ground voxels: reads INSTANCE_CUSTOM for per-cell color. -static func make_ground_material() -> ShaderMaterial: +func make_ground_material() -> ShaderMaterial: var shader: Shader = Shader.new() shader.code = """ shader_type spatial; @@ -104,7 +110,7 @@ void fragment() { ## Material for particle MultiMesh: reads INSTANCE_CUSTOM, unshaded for glow effect. -static func make_particle_material() -> ShaderMaterial: +func make_particle_material() -> ShaderMaterial: var shader: Shader = Shader.new() shader.code = """ shader_type spatial; @@ -126,7 +132,7 @@ void fragment() { ## Set up MultiMeshInstance3D nodes inside a parent Node3D. ## Returns map type → MultiMeshInstance3D. -static func setup_type_meshes(parent: Node3D, meshes: Dictionary) -> Dictionary: +func setup_type_meshes(parent: Node3D, meshes: Dictionary) -> Dictionary: var result: Dictionary = {} for type_name: String in meshes: var mi: Node3D = MultiMeshInstance3D.new() @@ -148,7 +154,7 @@ static func setup_type_meshes(parent: Node3D, meshes: Dictionary) -> Dictionary: ## Update all MultiMeshInstance3D instances for the current entity set. ## Sorts entities by type, then populates transforms + colors. -static func update_entities( +func update_entities( type_meshes: Dictionary, entities: Array, face_dir: bool = true, @@ -201,7 +207,7 @@ static func update_entities( ## Build ground voxel MultiMesh once (call in _setup). ## Creates 1x1x1 BoxMesh instances at each grid cell — transforms never change. -static func build_ground_voxels() -> MultiMesh: +func build_ground_voxels() -> MultiMesh: var box: BoxMesh = BoxMesh.new() box.size = Vector3(1.0, 1.0, 1.0) @@ -228,7 +234,7 @@ static func build_ground_voxels() -> MultiMesh: ## Update ground voxel colors from moisture grid + water sources. ## Call every frame — only touches color data, transforms are static. -static func update_ground_voxels( +func update_ground_voxels( mm: MultiMesh, moisture_grid: PackedFloat32Array, water_sources: Array, @@ -262,7 +268,7 @@ static func update_ground_voxels( ## Build particle mesh (small spheres/boxes). -static func build_particle_mesh() -> Mesh: +func build_particle_mesh() -> Mesh: var box: BoxMesh = BoxMesh.new() box.size = Vector3(0.3, 0.3, 0.3) return box @@ -271,7 +277,7 @@ static func build_particle_mesh() -> Mesh: # ── Color helpers ──────────────────────────────────────────────────────── -static func _moisture_color(moisture: float) -> Color: +func _moisture_color(moisture: float) -> Color: if moisture < 0.33: return C_MOISTURE_DRY elif moisture < 0.66: @@ -280,7 +286,7 @@ static func _moisture_color(moisture: float) -> Color: return C_MOISTURE_WET -static func _get_entity_color(ent) -> Color: +func _get_entity_color(ent) -> Color: var species: String = ent.species match ent.type: "ANIMAL": @@ -302,7 +308,7 @@ static func _get_entity_color(ent) -> Color: return Color(0.5, 0.5, 0.5) -static func _get_entity_size(ent) -> float: +func _get_entity_size(ent) -> float: match ent.type: "TREE": return SIZE_TREE @@ -313,16 +319,16 @@ static func _get_entity_size(ent) -> float: "INSECT": return SIZE_INSECT "PLANT": - # Fruiting wildflowers are twice as big (sphere variant) + # Fruiting wildflowers are slightly bigger (sphere variant) if ent.species == "wildflower" and ent.state == "FRUITING": - return SIZE_PLANT * 2.0 + return SIZE_PLANT * 1.3 return SIZE_PLANT "MICROORGANISM": return SIZE_MICRO return 1.0 -static func _entity_to_mesh_key(ent) -> String: +func _entity_to_mesh_key(ent) -> String: match ent.type: "TREE": return "TREE" @@ -343,7 +349,7 @@ static func _entity_to_mesh_key(ent) -> String: return "" -static func _build_entity_transform(ent, size: float, tick_ms: float) -> Transform3D: +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 @@ -376,7 +382,7 @@ static func _build_entity_transform(ent, size: float, tick_ms: float) -> Transfo # ── Height placement ─────────────────────────────────────────── # Flying entities keep their own altitude if ent.type == "INSECT": - y_extra = 2.5 + sin(tick_ms / 300.0 + float(ent.sync_phase)) * 0.8 + 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 @@ -394,9 +400,20 @@ static func _build_entity_transform(ent, size: float, tick_ms: float) -> Transfo var t: Transform3D t.origin = Vector3(cx, cy, cz) - # Rotate around Y axis to face direction, scale uniformly - var rot: Basis = Basis.from_euler(Vector3(0.0, -angle + PI / 2.0, 0.0)) - t.basis = rot * Basis.from_scale(Vector3(sc, sc, sc)) + # 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)) + 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 From 681ac2de3d8b1461bca762b2fb4195fe1beca9b7 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 11:57:52 -0400 Subject: [PATCH 17/25] Smooth entity facing direction with angle lerp instead of instant snap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TURN_SPEED constant (8.0 rad/s) in constants.gd - _move_toward(): lerp facing_angle toward target direction with wrapf to handle -π/π boundary correctly - _execute_reconcile(): also smooth facing angle during reconciliation spiral movement (was not updating facing_angle at all) - Uses clampf on wrapped angle diff for frame-rate-independent turn speed --- client/godot/scripts/agency.gd | 17 +++++++++++++---- client/godot/scripts/constants.gd | 1 + 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/client/godot/scripts/agency.gd b/client/godot/scripts/agency.gd index 3431ac8..b4b2b86 100644 --- a/client/godot/scripts/agency.gd +++ b/client/godot/scripts/agency.gd @@ -106,14 +106,19 @@ func _execute_reconcile(ent, delta: float) -> void: # Spiral meander toward target (organic movement) var speed: float = 2.0 * ent.sync_speed - var angle: float = atan2(dz, dx) + var target_angle: float = atan2(dz, dx) var wobble: float = sin(Time.get_ticks_msec() / 200.0 + ent.sync_phase) * 0.5 - var move_x: float = cos(angle + wobble) * speed * delta - var move_z: float = sin(angle + wobble) * speed * delta + 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 @@ -356,7 +361,11 @@ func _move_toward(ent, target: Vector2, delta: float, world: Node) -> void: ent.x += move_x ent.z += move_z - ent.facing_angle = atan2(dz, dx) + + # 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 diff --git a/client/godot/scripts/constants.gd b/client/godot/scripts/constants.gd index a0ef536..777b11a 100644 --- a/client/godot/scripts/constants.gd +++ b/client/godot/scripts/constants.gd @@ -28,6 +28,7 @@ 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 From 891f876f11a0a35a0ace5a853d8717077083a2ec Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 12:02:42 -0400 Subject: [PATCH 18/25] Swap animal deer voxel from box to horizontal capsule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CapsuleMesh (radius=1.0, height=2.5) for a deer-like body shape - Tilt capsule 90° around Z in transform so it lies horizontal (default CapsuleMesh is vertical like a standing pill) - Combine Y-facing rotation with Z-tilt via Basis multiplication, same pattern as bird cone tilt --- client/godot/scripts/renderer.gd | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd index 44b44c0..47d20d8 100644 --- a/client/godot/scripts/renderer.gd +++ b/client/godot/scripts/renderer.gd @@ -47,8 +47,15 @@ var _type_meshes: Dictionary = {} ## Fruiting wildflowers get a SphereMesh instead of a cube. func build_all_type_meshes() -> Dictionary: var meshes: Dictionary = {} - for key in ["TREE", "ANIMAL", "INSECT", "PLANT_GRASS", "PLANT_FLOWER", "MICROORGANISM"]: + 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 = 1.0 + capsule.height = 2.5 + capsule.ring_count = 12 + 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() @@ -405,6 +412,9 @@ func _build_entity_transform(ent, size: float, tick_ms: float) -> Transform3D: 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": + # Tilt capsule on its side (default is vertical) then steer around Y + rot = Basis.from_euler(Vector3(0.0, -angle + PI / 2.0, 0.0)) * Basis.from_euler(Vector3(0.0, 0.0, PI / 2.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 From 380e993c27c9b4dd42a86a1272729cc1b6be583f Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 12:04:05 -0400 Subject: [PATCH 19/25] Remove unsupported ring_count on CapsuleMesh (Godot 4.7) --- client/godot/scripts/renderer.gd | 1 - 1 file changed, 1 deletion(-) diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd index 47d20d8..a57aafd 100644 --- a/client/godot/scripts/renderer.gd +++ b/client/godot/scripts/renderer.gd @@ -54,7 +54,6 @@ func build_all_type_meshes() -> Dictionary: var capsule: CapsuleMesh = CapsuleMesh.new() capsule.radius = 1.0 capsule.height = 2.5 - capsule.ring_count = 12 meshes["ANIMAL"] = capsule # Birds are cones — pointy end faces forward # ConeMesh removed in Godot 4, use CylinderMesh with top_radius = 0 From 601b730e90100583adcee4c265351d47d75a8a20 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 12:11:37 -0400 Subject: [PATCH 20/25] =?UTF-8?q?Fix=20animal=20capsule=20orientation=20?= =?UTF-8?q?=E2=80=94=20use=20X-tilt=20instead=20of=20Z-tilt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-tilt lays capsule horizontal so long axis aligns with travel direction; Z-tilt was spinning around the capsule's own long axis --- client/godot/scripts/renderer.gd | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd index a57aafd..e44cb06 100644 --- a/client/godot/scripts/renderer.gd +++ b/client/godot/scripts/renderer.gd @@ -412,8 +412,9 @@ func _build_entity_transform(ent, size: float, tick_ms: float) -> Transform3D: # 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": - # Tilt capsule on its side (default is vertical) then steer around Y - rot = Basis.from_euler(Vector3(0.0, -angle + PI / 2.0, 0.0)) * Basis.from_euler(Vector3(0.0, 0.0, PI / 2.0)) + # 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 From 4c09325cc39d226cb7b30f7cd862760aecc674d7 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 12:47:15 -0400 Subject: [PATCH 21/25] Fade water footprint with drought instead of just shrinking - Derive max_radius on client (radius / water_level) to know original footprint size - Blend falloff now uses max_radius so cells keep a water tint that fades with level rather than suddenly losing coverage --- client/godot/scripts/autoloads/world_model.gd | 10 ++++++++-- client/godot/scripts/renderer.gd | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/client/godot/scripts/autoloads/world_model.gd b/client/godot/scripts/autoloads/world_model.gd index e88902c..559d305 100644 --- a/client/godot/scripts/autoloads/world_model.gd +++ b/client/godot/scripts/autoloads/world_model.gd @@ -203,10 +203,16 @@ func apply_water_sources(sources: Variant) -> void: 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": src.get("radius", 3.0), - "water_level": src.get("water_level", 1.0), + "radius": current_radius, + "max_radius": max_radius, + "water_level": level, }) diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd index e44cb06..ec7edde 100644 --- a/client/godot/scripts/renderer.gd +++ b/client/godot/scripts/renderer.gd @@ -260,12 +260,15 @@ func update_ground_voxels( 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) - if dist <= radius: - var blend: float = (1.0 - dist / radius) * level + # 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 From 462f1f816fe7ee39e90fa0d20639be0da99e9f53 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 12:48:21 -0400 Subject: [PATCH 22/25] Thin animal capsule radius from 1.0 to 0.55 Deer look less fat with narrower capsule body --- client/godot/scripts/renderer.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd index ec7edde..e0dab39 100644 --- a/client/godot/scripts/renderer.gd +++ b/client/godot/scripts/renderer.gd @@ -52,7 +52,7 @@ func build_all_type_meshes() -> Dictionary: # 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 = 1.0 + capsule.radius = 0.55 capsule.height = 2.5 meshes["ANIMAL"] = capsule # Birds are cones — pointy end faces forward From 6a14ef3d69d5b2ba4976f817a682f903eacbd078 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 13:04:43 -0400 Subject: [PATCH 23/25] =?UTF-8?q?Add=20bird=20roosting=20behavior=20?= =?UTF-8?q?=E2=80=94=20seek=20trees=20when=20IDLE=20or=20RESTING?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Birds in IDLE/RESTING now seek nearest tree instead of wandering randomly - When near a tree, birds hover nearby with gentle sinusoidal wobble - Mirrors server-side roosting logic from movement_actors.py --- client/godot/scripts/agency.gd | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/client/godot/scripts/agency.gd b/client/godot/scripts/agency.gd index b4b2b86..40cb08f 100644 --- a/client/godot/scripts/agency.gd +++ b/client/godot/scripts/agency.gd @@ -63,6 +63,10 @@ func _step_entity(ent, world: Node, delta: float, now: float) -> Array: 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" @@ -334,6 +338,26 @@ func _evaluate_wandering(ent, delta: float) -> Vector2: 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 From 284d519a041c393d953f2f7dcff318386cbf13cf Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 13:36:02 -0400 Subject: [PATCH 24/25] Add standardized file headers to all Godot client GDScript files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors server Python header style: - līlā project banner - Copyright 2025 BioSynthArt Studios LLC - Apache 2.0 license line - File path and purpose description --- client/godot/scenes/hud.gd | 6 +++++- client/godot/scenes/main.gd | 11 ++++++++--- client/godot/scripts/agency.gd | 15 ++++++++++++--- client/godot/scripts/autoloads/world_model.gd | 10 ++++++++-- client/godot/scripts/autoloads/ws_client.gd | 9 +++++++-- client/godot/scripts/camera/orbit_camera.gd | 18 ++++++++++++------ client/godot/scripts/constants.gd | 10 +++++++++- client/godot/scripts/heartbeat.gd | 10 ++++++++-- client/godot/scripts/particles.gd | 9 ++++++++- client/godot/scripts/reconciliation.gd | 17 +++++++++++------ client/godot/scripts/renderer.gd | 12 +++++++++--- 11 files changed, 97 insertions(+), 30 deletions(-) diff --git a/client/godot/scenes/hud.gd b/client/godot/scenes/hud.gd index 5bad4f2..380e78d 100644 --- a/client/godot/scenes/hud.gd +++ b/client/godot/scenes/hud.gd @@ -1,4 +1,8 @@ -## HUD overlay: stats panel, event log, controls. +# 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 diff --git a/client/godot/scenes/main.gd b/client/godot/scenes/main.gd index 1f98cf9..ed01764 100644 --- a/client/godot/scenes/main.gd +++ b/client/godot/scenes/main.gd @@ -1,6 +1,11 @@ -## Main scene - 3D world view with orbit camera. -## Grid coordinates map 1:1 to world X/Z; Y is height. -## Uses simple cube-based InstancedMesh rendering per entity type. +# 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 diff --git a/client/godot/scripts/agency.gd b/client/godot/scripts/agency.gd index 40cb08f..71711ae 100644 --- a/client/godot/scripts/agency.gd +++ b/client/godot/scripts/agency.gd @@ -1,6 +1,15 @@ -## Client-side agency engine — runs at 60 Hz between server ticks. -## Mirrors browser agency.js and Python agency.py. -## Server is the nervous system (intent), client is the body (execution). +# 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 diff --git a/client/godot/scripts/autoloads/world_model.gd b/client/godot/scripts/autoloads/world_model.gd index 559d305..6039d5e 100644 --- a/client/godot/scripts/autoloads/world_model.gd +++ b/client/godot/scripts/autoloads/world_model.gd @@ -1,5 +1,11 @@ -## Client-side world model: entity registry, spatial queries, environment state. -## Mirrors browser world-model.js and Python world_model.py. +# 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 diff --git a/client/godot/scripts/autoloads/ws_client.gd b/client/godot/scripts/autoloads/ws_client.gd index 9f21cbb..4d381a7 100644 --- a/client/godot/scripts/autoloads/ws_client.gd +++ b/client/godot/scripts/autoloads/ws_client.gd @@ -1,5 +1,10 @@ -## WebSocket client for līlā server communication. -## Handles /ws WebSocket connection, /world.json HTTP fetch, auto-reconnect. +# 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 diff --git a/client/godot/scripts/camera/orbit_camera.gd b/client/godot/scripts/camera/orbit_camera.gd index 7313376..5cb6640 100644 --- a/client/godot/scripts/camera/orbit_camera.gd +++ b/client/godot/scripts/camera/orbit_camera.gd @@ -1,9 +1,15 @@ -## Orbit / trackball camera controller. -## Attach as a script on a Camera3D node. -## -## Controls: -## Left mouse drag — orbit around target -## Right mouse drag — pan target +# 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) diff --git a/client/godot/scripts/constants.gd b/client/godot/scripts/constants.gd index 777b11a..7eb5f63 100644 --- a/client/godot/scripts/constants.gd +++ b/client/godot/scripts/constants.gd @@ -1,4 +1,12 @@ -## Shared constants mirroring browser/Python clients. +# 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. diff --git a/client/godot/scripts/heartbeat.gd b/client/godot/scripts/heartbeat.gd index 96d3578..67376d1 100644 --- a/client/godot/scripts/heartbeat.gd +++ b/client/godot/scripts/heartbeat.gd @@ -1,5 +1,11 @@ -## Accumulates entity positions and agency events, sends heartbeats upstream. -## Mirrors browser heartbeat.js. +# 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 diff --git a/client/godot/scripts/particles.gd b/client/godot/scripts/particles.gd index cedae7f..50cc9f9 100644 --- a/client/godot/scripts/particles.gd +++ b/client/godot/scripts/particles.gd @@ -1,4 +1,11 @@ -## Simple particle system for event visualizations. +# 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 diff --git a/client/godot/scripts/reconciliation.gd b/client/godot/scripts/reconciliation.gd index 70f4c41..41f193e 100644 --- a/client/godot/scripts/reconciliation.gd +++ b/client/godot/scripts/reconciliation.gd @@ -1,9 +1,14 @@ -## Position reconciliation between client agency and server reference positions. -## Mirrors browser reconciliation.js and Python reconciliation.py. -## -## 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. +# 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 diff --git a/client/godot/scripts/renderer.gd b/client/godot/scripts/renderer.gd index e0dab39..07a48d3 100644 --- a/client/godot/scripts/renderer.gd +++ b/client/godot/scripts/renderer.gd @@ -1,6 +1,12 @@ -## Simple cube-based 3D entity renderer. -## Each entity type gets a single BoxMesh (cube) with type-appropriate color -## harmonized with the browser renderer palette. +# 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 From 71c5d7be57264bc94151cf563909e1562557ddb5 Mon Sep 17 00:00:00 2001 From: Joshua Natarajan Date: Sun, 21 Jun 2026 13:41:48 -0400 Subject: [PATCH 25/25] Add standardized headers to Python client files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors server header style: - līlā project banner - Copyright 2025 BioSynthArt Studios LLC - Apache 2.0 license line - File path and purpose description --- client/python/lila_client/__init__.py | 4 +++- client/python/lila_client/__main__.py | 6 +++++- client/python/lila_client/agency.py | 10 +++++++--- client/python/lila_client/constants.py | 9 ++++++++- client/python/lila_client/imgui_view.py | 6 +++++- client/python/lila_client/main.py | 10 +++++++--- client/python/lila_client/pygame_renderer.py | 10 +++++++--- client/python/lila_client/reconciliation.py | 10 +++++++--- client/python/lila_client/replay.py | 10 +++++++--- client/python/lila_client/websocket.py | 10 +++++++--- client/python/lila_client/world_model.py | 10 +++++++--- 11 files changed, 70 insertions(+), 25 deletions(-) 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. """