Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions client/browser/js/agency.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import { hasReconcileTarget, getReconcileTarget, advanceReconcile } from './reco
// Each entity also has _syncSpeed (0.4..1.0) that modulates this.
const GRAVITY_WELL_FACTOR = 0.05;

// Flee direction cache: how long (ms) to keep running in the last
// known safe direction when no threat is found. Mirrors server
// behavior: FleeActor computes escape once, guard exits on arrival.
const FLEE_DIR_TIMEOUT_MS = 3000;

/**
* Run one frame of local agency for all mobile entities.
* Called every render frame (~60 Hz).
Expand Down Expand Up @@ -204,6 +209,8 @@ function evaluateFleeing(ent, world, speciesDef) {
const fleeTargets = speciesDef?.flee_targets || [];
if (fleeTargets.length === 0) return evaluateWandering(ent, world);

const now = performance.now();

// Find nearest threat
let nearestThreat = null;
let bestDistSq = Infinity;
Expand All @@ -219,18 +226,29 @@ function evaluateFleeing(ent, world, speciesDef) {
}

if (nearestThreat && bestDistSq < 400) { // ~20 world units sensory range²
// Flee away from threat
// Threat confirmed — cache the flee direction
const dx = ent.x - nearestThreat.x;
const dz = ent.z - nearestThreat.z;
const dist = Math.sqrt(dx * dx + dz * dz) || 1;
ent._fleeDirX = dx / dist;
ent._fleeDirZ = dz / dist;
ent._fleeDirExpiry = now + FLEE_DIR_TIMEOUT_MS;
}

// Use cached flee direction as fallback.
// The server computes the escape target once (FleeActor fires on entry only)
// and holds it until arrival. Mirror this: if the threat is not found this
// frame (client/server divergence, stale positions, etc.), keep running in
// the last known safe direction for a short window.
if (ent._fleeDirX != null && now < ent._fleeDirExpiry) {
return {
type: 'flee',
targetX: clamp(ent.x + (dx / dist) * 8, GRID_SIZE),
targetZ: clamp(ent.z + (dz / dist) * 8, GRID_SIZE),
targetX: clamp(ent.x + ent._fleeDirX * 8, GRID_SIZE),
targetZ: clamp(ent.z + ent._fleeDirZ * 8, GRID_SIZE),
};
}

// No threat nearby — fall through to wander
// No threat and no cached direction — fall through to wander
return evaluateWandering(ent, world);
}

Expand Down
244 changes: 241 additions & 3 deletions client/godot/scenes/main.gd
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
extends Node3D


@onready var camera: Camera3D = $Camera
@onready var camera = $Camera
@onready var renderer: Node = $Renderer
@onready var ground_mi: MultiMeshInstance3D = $Ground
@onready var entity_parent: Node3D = $Entities
Expand All @@ -18,6 +18,7 @@ extends Node3D
@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 legend_label: Label = $HUD/LegendLabel

var _agency: Agency = Agency.new()
var _heartbeat: HeartbeatSender = HeartbeatSender.new()
Expand All @@ -32,6 +33,11 @@ var _fps: int = 0
var _frame_count: int = 0
var _fps_timer: float = 0.0

# Selection state
var _selected_entity = null # World.WorldEntity (inner class, no global type)
var _mouse_down_pos: Vector2 = Vector2.ZERO
var _mouse_down_time: float = 0.0

# Renderer state
var _type_meshes: Dictionary = {}
var _ground_mat: ShaderMaterial = null
Expand All @@ -58,6 +64,9 @@ func _ready() -> void:
)
camera._update_position()

# Clear selection if selected entity dies
World.entity_removed.connect(_on_entity_removed)


# ── Renderer setup ────────────────────────────────────────────────────

Expand Down Expand Up @@ -138,6 +147,9 @@ func _process(delta: float) -> void:
_build_entities()
_update_particle_mesh()

# Update selection visuals every frame
_update_selection_billboard()


## Update ground voxel colors (MultiMesh was built once in setup).
func _build_ground() -> void:
Expand All @@ -149,14 +161,31 @@ func _build_ground() -> void:
## Update all per-type InstancedMesh entities.
func _build_entities() -> void:
var entities: Array = World.get_alive()
renderer.update_entities(_type_meshes, entities)
var sel_id: String = _selected_entity.id if _selected_entity else ""
renderer.update_entities(_type_meshes, entities, sel_id)


# ── Input ─────────────────────────────────────────────────────────────

func _input(event: InputEvent) -> void:
# ── Mouse click detection (click vs drag) ──────────────────────
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_mouse_down_pos = event.position
_mouse_down_time = Time.get_ticks_msec() / 1000.0
elif not event.pressed:
# Check if this was a click (not a drag)
var elapsed: float = (Time.get_ticks_msec() / 1000.0) - _mouse_down_time
var distance: float = _mouse_down_pos.distance_to(event.position)
if elapsed < 0.35 and distance < 12.0:
_select_entity_at_click()

# ── Keyboard ────────────────────────────────────────────────────
if event is InputEventKey and event.pressed:
if event.keycode == KEY_R:
if event.keycode == KEY_ESCAPE:
_deselect_entity()
elif event.keycode == KEY_R:
WS.send_control("rain", {"intensity": 0.8})
_add_hud_event("☔ Rain triggered!")
elif event.keycode == KEY_SPACE:
Expand Down Expand Up @@ -271,3 +300,212 @@ func _log_entity_telemetry() -> void:
ent.reconcile_queue.size(),
]
LilaConstants.log(log_line)


# ── Entity Selection ─────────────────────────────────────────────────

## Build a camera ray from a viewport (screen) position.
func _make_camera_ray(viewport_pos: Vector2) -> Dictionary:
var cam = get_viewport().get_camera_3d()
if cam == null:
return {}
return {
"origin": cam.project_ray_origin(viewport_pos),
"dir": cam.project_ray_normal(viewport_pos),
}

## Closest distance from a point to an infinite ray (origin → dir).
func _point_to_ray_dist(point: Vector3, ray_origin: Vector3, ray_dir: Vector3) -> float:
var v: Vector3 = point - ray_origin
# Project v onto ray_dir; clamp t >= 0 so we only check forward.
var t: float = v.dot(ray_dir)
if t < 0.0:
return point.distance_to(ray_origin)
var closest: Vector3 = ray_origin + ray_dir * t
return point.distance_to(closest)

## Compute the entity's 3D world position (accounts for flight altitude).
func _entity_world_pos(ent) -> Vector3:
var y: float = 0.5 # ground surface
match ent.type:
"INSECT":
y = 1.75
"BIRD":
y = 4.0
return Vector3(ent.x, y, ent.z)

## Find the nearest alive entity by ray-to-point distance in 3D space.
## This works for flying entities (birds/insects) as well as ground entities.
func _find_nearest_entity(ray_origin: Vector3, ray_dir: Vector3):
var best = null
var best_dist: float = INF
for ent in World.get_alive():
var epos: Vector3 = _entity_world_pos(ent)
var dist: float = _point_to_ray_dist(epos, ray_origin, ray_dir)
var hit_radius: float = _get_hit_radius(ent)
if dist < hit_radius and dist < best_dist:
best_dist = dist
best = ent
return best

## Per-type selection hitbox radius (generous for small/flying entities).
func _get_hit_radius(ent) -> float:
match ent.type:
"TREE":
return 2.5
"ANIMAL":
return 2.0
"BIRD":
return 4.0
"INSECT":
return 4.0
"PLANT":
return 2.0
"MICROORGANISM":
return 3.0
return 3.0

## Handle a click-to-select event.
func _select_entity_at_click() -> void:
var ray: Dictionary = _make_camera_ray(_mouse_down_pos)
if ray.is_empty():
LilaConstants.log("[select] no camera")
return
var ent = _find_nearest_entity(ray["origin"], ray["dir"])
if ent != null:
_selected_entity = ent
var epos: Vector3 = _entity_world_pos(ent)
var dist: float = _point_to_ray_dist(epos, ray["origin"], ray["dir"])
_add_hud_event("🔍 %s · %s" % [ent.species.capitalize(), ent.state])
LilaConstants.log("[select] %s (%s) at (%.1f,%.1f,%.1f) ray_dist=%.2f" % [
ent.id, ent.type, epos.x, epos.y, epos.z, dist])
else:
LilaConstants.log("[select] no entity near click ray, %d alive" % World.get_entity_count())
_deselect_entity()

## Clear selection.
func _deselect_entity() -> void:
_selected_entity = null
legend_label.visible = false

## Called when an entity is removed from the world.
func _on_entity_removed(entity_id: String) -> void:
if _selected_entity and _selected_entity.id == entity_id:
_selected_entity = null
legend_label.visible = false

## Update the selection stats billboard on the HUD.
func _update_selection_billboard() -> void:
var selection_label: Label = $HUD/SelectionLabel
if selection_label == null:
return

if _selected_entity:
selection_label.visible = true
var ent = _selected_entity
var type_emoji: String = _get_type_emoji(ent.type)
selection_label.text = "%s %s · %s\n%s" % [
type_emoji,
ent.species.capitalize(),
ent.state,
_format_drives(ent)
]
legend_label.visible = true
legend_label.text = _build_legend(ent.type)
else:
selection_label.visible = false
legend_label.visible = false

## Return an emoji for the entity type.
func _get_type_emoji(etype: String) -> String:
match etype:
"ANIMAL":
return "🦌"
"BIRD":
return "🐦"
"INSECT":
return "🦋"
"PLANT":
return "🌿"
"TREE":
return "🌳"
"MICROORGANISM":
return "🍄"
return "❓"

## Format drive/state variables as a compact stats line.
func _format_drives(ent) -> String:
var sv: Dictionary = ent.drive
var parts: Array[String] = []

match ent.type:
"ANIMAL", "BIRD":
if sv.has("hunger"):
parts.append("🍖%.0f" % (sv["hunger"] * 100.0))
if sv.has("energy"):
parts.append("⚡%.0f" % (sv["energy"] * 100.0))
if sv.has("hydration"):
parts.append("💧%.0f" % (sv["hydration"] * 100.0))
if sv.has("health"):
parts.append("❤️%.0f" % (sv["health"] * 100.0))
if sv.has("reproductive_drive") and sv["reproductive_drive"] > 0.0:
parts.append("💕%.0f" % (sv["reproductive_drive"] * 100.0))
if sv.has("age"):
parts.append("⏳%.1f" % sv["age"])
"PLANT", "TREE":
if sv.has("hydration"):
parts.append("💧%.0f" % (sv["hydration"] * 100.0))
if sv.has("growth"):
parts.append("🌱%.0f" % (sv["growth"] * 100.0))
if sv.has("nutrient_store"):
parts.append("🧪%.0f" % (sv["nutrient_store"] * 100.0))
if sv.has("health"):
parts.append("❤️%.0f" % (sv["health"] * 100.0))
if sv.has("age"):
parts.append("⏳%.1f" % sv["age"])
"INSECT":
if sv.has("hunger"):
parts.append("🍖%.0f" % (sv["hunger"] * 100.0))
if sv.has("energy"):
parts.append("⚡%.0f" % (sv["energy"] * 100.0))
if sv.has("colony_health"):
parts.append("🐝%.0f" % (sv["colony_health"] * 100.0))
if sv.has("reproductive_drive") and sv["reproductive_drive"] > 0.0:
parts.append("💕%.0f" % (sv["reproductive_drive"] * 100.0))
"MICROORGANISM":
if sv.has("population"):
parts.append("🧫%.0f" % (sv["population"] * 100.0))
if sv.has("activity"):
parts.append("🔬%.0f" % (sv["activity"] * 100.0))

return " ".join(parts)

## Build a legend explaining the stat icons for the given entity type.
func _build_legend(etype: String) -> String:
var lines: Array[String] = []
lines.append("─ Stats ─")
match etype:
"ANIMAL", "BIRD":
lines.append("🍖 Hunger (0–100)")
lines.append("⚡ Energy (0–100)")
lines.append("💧 Hydration (0–100)")
lines.append("❤️ Health (0–100)")
lines.append("💕 Repro drive (0–100)")
lines.append("⏳ Age (ticks)")
"PLANT", "TREE":
lines.append("💧 Hydration (0–100)")
lines.append("🌱 Growth (0–100)")
lines.append("🧪 Nutrients (0–100)")
lines.append("❤️ Health (0–100)")
lines.append("⏳ Age (ticks)")
"INSECT":
lines.append("🍖 Hunger (0–100)")
lines.append("⚡ Energy (0–100)")
lines.append("🐝 Colony health (0–100)")
lines.append("💕 Repro drive (0–100)")
"MICROORGANISM":
lines.append("🧫 Population (0–100)")
lines.append("🔬 Activity (0–100)")
_:
lines.append("(no stats)")
return "\n".join(lines)
Loading
Loading