tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
1//! `conducta` — **capa 3 del movimiento**: cómo un Ser *decide* moverse por el
2//! mundo. Una [`Conducta`] son parámetros de *steering* (declarativos, como las Leyes
3//! y los Andares); un [`Habitante`] es el agente que los ejecuta sobre la física de
4//! cuerpo del [`Player`] (gravedad + colisión), combinando **deambular** (rumbo que
5//! cambia con la inquietud), **gregarismo** (se acerca a su manada) y **miedo** (huye
6//! de una amenaza). Determinista (LCG por agente; sin `rand`).
7//!
8//! Es CPU puro: el agente expone `pos`/`heading`/`fase`, y la app lo pinta con el
9//! cuerpo del Ser (rig + andar) vía [`CharSpec::to_meta`](crate::CharSpec::to_meta).
10
11use llimphi_3d::glam::Vec3;
12use llimphi_3d::VoxelGrid;
13use serde::{Deserialize, Serialize};
14
15use crate::ecuacion::{Expr, Symbols};
16use crate::{forward_h, Player};
17
18const TAU: f32 = std::f32::consts::TAU;
19/// Radio (voxels) en el que un habitante "ve" a su manada y a la amenaza.
20const RADIO_VISION: f32 = 12.0;
21
22/// **Percepts** que una fórmula de peso de impulso puede leer, en orden (los
23/// `Field` de la tabla de símbolos). Son lo que el agente "siente" cada paso,
24/// normalizado a `[0,1]`:
25/// - `cercania_manada` — 1 = centro de la manada pegado, 0 = lejos/sin manada.
26/// - `cercania_amenaza` — 1 = amenaza encima, 0 = lejos/sin amenaza.
27/// - `apinamiento` — fracción del campo de visión ocupada por vecinos.
28/// - `azar` — ruido determinista por paso (no consume el LCG del deambular).
29/// - `en_suelo` — 1 si está apoyado, 0 en el aire.
30pub const PERCEPTS: [&str; 5] =
31 ["cercania_manada", "cercania_amenaza", "apinamiento", "azar", "en_suelo"];
32/// Nombres de los **parámetros** de la conducta (los sliders), en orden. También son
33/// visibles como variables en las fórmulas de peso (`Param` de la tabla de símbolos).
34pub const PARAMS: [&str; 5] = ["velocidad", "inquietud", "salto", "gregario", "miedo"];
35
36/// **Primitiva de steering**: una dirección base que el agente sabe generar cada
37/// paso. El [`Impulso`] la pondera con una fórmula autorable.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39pub enum Primitiva {
40 /// Rumbo de deambular (heading con ruido).
41 Deambular,
42 /// Hacia el centro de la manada visible.
43 Cohesion,
44 /// Lejos del centro de la manada (anti‑apiñamiento).
45 Separacion,
46 /// Lejos de la amenaza.
47 Huir,
48}
49
50impl Primitiva {
51 /// Todas las primitivas, para ciclar en la UI.
52 pub const TODAS: [Primitiva; 4] =
53 [Primitiva::Deambular, Primitiva::Cohesion, Primitiva::Separacion, Primitiva::Huir];
54
55 pub fn label(self) -> &'static str {
56 match self {
57 Primitiva::Deambular => "deambular",
58 Primitiva::Cohesion => "cohesión",
59 Primitiva::Separacion => "separación",
60 Primitiva::Huir => "huir",
61 }
62 }
63
64 /// La siguiente primitiva (ciclo) — para el botón «cambiar» de la UI.
65 pub fn next(self) -> Primitiva {
66 let i = Primitiva::TODAS.iter().position(|&p| p == self).unwrap_or(0);
67 Primitiva::TODAS[(i + 1) % Primitiva::TODAS.len()]
68 }
69}
70
71/// Un **impulso de steering autorable**: una [`Primitiva`] (dirección base) pesada por
72/// una **fórmula** sobre percepts + params. La suma de los impulsos da la dirección
73/// deseada del agente. La fórmula es el texto canónico (se compila con
74/// [`Conducta::symbols`], igual que las Leyes de rejilla).
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct Impulso {
77 pub primitiva: Primitiva,
78 /// Fórmula del peso (texto; ej. `"gregario * 1.5"` o `"miedo * 2.5 * cercania_amenaza"`).
79 pub peso: String,
80}
81
82impl Impulso {
83 pub fn new(primitiva: Primitiva, peso: impl Into<String>) -> Self {
84 Self { primitiva, peso: peso.into() }
85 }
86}
87
88/// Los **impulsos por defecto**: reproducen exactamente el steering cableado histórico
89/// (deambular + cohesión·`gregario·1.5` + huida·`miedo·2.5·cercania_amenaza`). Un
90/// [`Conducta`] con `impulsos` vacío usa estos, así los datos viejos siguen andando.
91pub fn default_impulsos() -> Vec<Impulso> {
92 vec![
93 Impulso::new(Primitiva::Deambular, "1"),
94 Impulso::new(Primitiva::Cohesion, "gregario * 1.5"),
95 Impulso::new(Primitiva::Huir, "miedo * 2.5 * cercania_amenaza"),
96 ]
97}
98
99/// **Conducta** de un Ser: parámetros de locomoción (los sliders) + **impulsos de
100/// steering autorables**. Declarativa y editable; la ejecuta un [`Habitante`].
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct Conducta {
103 /// Velocidad de marcha (voxels/seg).
104 pub velocidad: f32,
105 /// **Inquietud** `[0,1]`: cuán seguido y fuerte cambia de rumbo al deambular.
106 pub inquietud: f32,
107 /// Probabilidad `[0,1]` de pegar un salto al caminar.
108 pub salto: f32,
109 /// **Gregarismo** `[0,1]`: parámetro visible en las fórmulas (peso de cohesión por
110 /// defecto). No se usa directo: entra por la fórmula del impulso.
111 pub gregario: f32,
112 /// **Miedo** `[0,1]`: parámetro visible en las fórmulas (peso de huida por defecto).
113 pub miedo: f32,
114 /// **Impulsos de steering** autorables. Vacío = [`default_impulsos`] (compat).
115 #[serde(default)]
116 pub impulsos: Vec<Impulso>,
117}
118
119impl Default for Conducta {
120 fn default() -> Self {
121 Self {
122 velocidad: 3.0,
123 inquietud: 0.4,
124 salto: 0.08,
125 gregario: 0.35,
126 miedo: 0.6,
127 impulsos: default_impulsos(),
128 }
129 }
130}
131
132impl Conducta {
133 /// Parámetros editables: `(nombre, valor, min, max)` — para armar sliders.
134 pub fn params(&self) -> [(&'static str, f32, f32, f32); 5] {
135 [
136 ("velocidad", self.velocidad, 0.0, 6.0),
137 ("inquietud", self.inquietud, 0.0, 1.0),
138 ("salto", self.salto, 0.0, 1.0),
139 ("gregario", self.gregario, 0.0, 1.0),
140 ("miedo", self.miedo, 0.0, 1.0),
141 ]
142 }
143 /// Fija el parámetro `i` (en el orden de [`params`](Self::params)).
144 pub fn set(&mut self, i: usize, v: f32) {
145 match i {
146 0 => self.velocidad = v.clamp(0.0, 6.0),
147 1 => self.inquietud = v.clamp(0.0, 1.0),
148 2 => self.salto = v.clamp(0.0, 1.0),
149 3 => self.gregario = v.clamp(0.0, 1.0),
150 4 => self.miedo = v.clamp(0.0, 1.0),
151 _ => {}
152 }
153 }
154
155 /// Los valores de los params en el orden de [`PARAMS`] (entorno para las fórmulas).
156 fn param_env(&self) -> [f32; 5] {
157 [self.velocidad, self.inquietud, self.salto, self.gregario, self.miedo]
158 }
159
160 /// Tabla de símbolos de las fórmulas de peso: percepts como `Field`, params como
161 /// `Param`. La comparte el editor (barra de fórmula) y el compilador del agente.
162 pub fn symbols() -> Symbols {
163 Symbols {
164 campos: PERCEPTS.iter().map(|s| s.to_string()).collect(),
165 params: PARAMS.iter().map(|s| s.to_string()).collect(),
166 }
167 }
168
169 /// Los impulsos efectivos: los propios, o [`default_impulsos`] si está vacío.
170 pub fn impulsos_efectivos(&self) -> Vec<Impulso> {
171 if self.impulsos.is_empty() {
172 default_impulsos()
173 } else {
174 self.impulsos.clone()
175 }
176 }
177
178 /// Compila los impulsos efectivos a `(primitiva, Expr)`. Una fórmula que no parsea
179 /// cae a peso `0` (impulso inerte) — el editor muestra el error aparte.
180 fn compilar(&self) -> Vec<(Primitiva, Expr)> {
181 let sym = Conducta::symbols();
182 self.impulsos_efectivos()
183 .iter()
184 .map(|i| (i.primitiva, Expr::parse(&i.peso, &sym).unwrap_or(Expr::Const(0.0))))
185 .collect()
186 }
187}
188
189/// Un **habitante**: un agente que ejecuta una [`Conducta`] sobre la física del
190/// [`Player`]. La app lo pinta con el cuerpo del Ser que representa.
191#[derive(Debug, Clone)]
192pub struct Habitante {
193 body: Player,
194 conducta: Conducta,
195 /// Impulsos compilados (fórmula de peso ya parseada) — se rearman al editar la
196 /// conducta, no en el loop caliente.
197 impulsos: Vec<(Primitiva, Expr)>,
198 /// Rumbo actual (yaw, rad; `0` mira a `+Z`).
199 heading: f32,
200 /// Fase de animación (avanza al caminar) para el andar del cuerpo.
201 pub fase: f32,
202 /// Cuenta atrás hasta el próximo cambio de rumbo (deambular).
203 timer: f32,
204 /// Pide saltar el próximo paso.
205 jump: bool,
206 /// LCG (azar determinista por habitante).
207 rng: u32,
208}
209
210impl Habitante {
211 /// Habitante posado sobre la columna `(x, z)` del grid, con su conducta y semilla.
212 pub fn spawn(grid: &VoxelGrid, x: u32, z: u32, conducta: Conducta, seed: u32) -> Self {
213 let mut body = Player::spawn_on(grid, x, z);
214 body.speed = conducta.velocidad;
215 let impulsos = conducta.compilar();
216 let mut h = Self {
217 body,
218 conducta,
219 impulsos,
220 heading: 0.0,
221 fase: 0.0,
222 timer: 0.0,
223 jump: false,
224 rng: seed | 1,
225 };
226 h.heading = h.next() * TAU;
227 h
228 }
229
230 /// Posición (espacio de grilla, igual que el terreno).
231 pub fn pos(&self) -> Vec3 {
232 self.body.pos
233 }
234 /// Rumbo actual (para orientar el cuerpo).
235 pub fn heading(&self) -> f32 {
236 self.heading
237 }
238
239 /// Actualiza la conducta en caliente (para reflejar ediciones sin re-spawnear).
240 /// Recompila los impulsos (parseo de las fórmulas de peso).
241 pub fn set_conducta(&mut self, c: Conducta) {
242 self.impulsos = c.compilar();
243 self.conducta = c;
244 }
245
246 /// Avanza `dt`: arma la **dirección deseada** como suma ponderada de los impulsos
247 /// autorables (cada primitiva genera una dirección base; su fórmula de peso la
248 /// escala según los percepts y params), y mueve el cuerpo. `vecinos` son las
249 /// posiciones de los demás habitantes (la propia se ignora por distancia ~0).
250 pub fn step(&mut self, grid: &VoxelGrid, vecinos: &[Vec3], amenaza: Option<Vec3>, dt: f32) {
251 let velocidad = self.conducta.velocidad;
252 let inquietud = self.conducta.inquietud;
253 let salto = self.conducta.salto;
254
255 // Deambular: cada tanto, empujar el rumbo (más inquietud = más seguido/fuerte).
256 self.timer -= dt;
257 if self.timer <= 0.0 {
258 self.heading += (self.next() - 0.5) * (0.5 + 2.0 * inquietud);
259 self.timer = (1.4 - inquietud).max(0.25) + self.next() * 1.5;
260 self.jump = self.next() < salto;
261 }
262
263 let pos = self.body.pos;
264
265 // --- Percepts (deterministas; NO consumen el LCG del deambular) ------------
266 // Centro y densidad de la manada visible.
267 let mut centro = Vec3::ZERO;
268 let mut n = 0.0f32;
269 for &v in vecinos {
270 let d = (v - pos).length();
271 if d > 0.3 && d < RADIO_VISION {
272 centro += v;
273 n += 1.0;
274 }
275 }
276 let (hacia_manada, cercania_manada) = if n > 0.0 {
277 let rel = (centro / n) - pos;
278 let d = rel.length();
279 (horiz_norm(rel), (1.0 - d / RADIO_VISION).clamp(0.0, 1.0))
280 } else {
281 (Vec3::ZERO, 0.0)
282 };
283 let apinamiento = (n / 8.0).min(1.0);
284
285 let (lejos_amenaza, cercania_amenaza) = match amenaza {
286 Some(a) => {
287 let d = (pos - a).length();
288 if d > 0.01 && d < RADIO_VISION {
289 (horiz_norm(pos - a), 1.0 - d / RADIO_VISION)
290 } else {
291 (Vec3::ZERO, 0.0)
292 }
293 }
294 None => (Vec3::ZERO, 0.0),
295 };
296
297 // Ruido determinista por paso, sin avanzar el LCG (mezcla del estado actual):
298 // así el orden de sorteos del deambular no cambia y la sim sigue reproducible.
299 let azar = {
300 let mut h = self.rng;
301 h ^= h >> 13;
302 h = h.wrapping_mul(0x9E37_79B1);
303 h ^= h >> 16;
304 (h >> 8) as f32 / (1u32 << 24) as f32
305 };
306 let en_suelo = if self.body.on_ground { 1.0 } else { 0.0 };
307
308 let percepts = [cercania_manada, cercania_amenaza, apinamiento, azar, en_suelo];
309 let params = self.conducta.param_env();
310
311 // --- Suma ponderada de los impulsos autorables ----------------------------
312 let mut desired = Vec3::ZERO;
313 for (prim, peso) in &self.impulsos {
314 let dir = match prim {
315 Primitiva::Deambular => forward_h(self.heading),
316 Primitiva::Cohesion => hacia_manada,
317 Primitiva::Separacion => -hacia_manada,
318 Primitiva::Huir => lejos_amenaza,
319 };
320 let w = peso.eval_scalar(&percepts, ¶ms, dt);
321 desired += dir * w;
322 }
323
324 // Girar el rumbo hacia la dirección deseada (suave, con tope por paso).
325 let desired = horiz_norm(desired);
326 if desired.length_squared() > 1e-4 {
327 let objetivo = desired.x.atan2(desired.z);
328 let giro_max = (4.0 + 6.0 * inquietud) * dt;
329 self.heading = acercar_angulo(self.heading, objetivo, giro_max);
330 }
331
332 // Mover el cuerpo; rebotar si choca (no quedar empujando una pared).
333 self.body.speed = velocidad;
334 let before = self.body.pos;
335 let jump = self.jump && self.body.on_ground;
336 self.body.step(grid, forward_h(self.heading), jump, dt);
337 self.jump = false;
338
339 let movido = (self.body.pos - before).length();
340 if self.body.on_ground && movido < 0.2 * velocidad * dt {
341 self.heading += TAU * 0.5 + (self.next() - 0.5);
342 self.timer = 0.5;
343 }
344 // La fase de animación avanza con el movimiento (para el andar de caminata).
345 self.fase += dt * (3.0 + velocidad);
346 }
347
348 /// Próximo `f32` en `[0,1)` del LCG.
349 fn next(&mut self) -> f32 {
350 self.rng = self.rng.wrapping_mul(1664525).wrapping_add(1013904223);
351 (self.rng >> 8) as f32 / (1u32 << 24) as f32
352 }
353}
354
355/// Normaliza un vector en el plano horizontal (Y=0); cero si es ~nulo.
356fn horiz_norm(v: Vec3) -> Vec3 {
357 let h = Vec3::new(v.x, 0.0, v.z);
358 let l = h.length();
359 if l > 1e-5 {
360 h / l
361 } else {
362 Vec3::ZERO
363 }
364}
365
366/// Acerca `a` hacia `b` (ángulos, rad) a lo sumo `max` por paso, por el lado corto.
367fn acercar_angulo(a: f32, b: f32, max: f32) -> f32 {
368 let mut d = (b - a) % TAU;
369 if d > std::f32::consts::PI {
370 d -= TAU;
371 }
372 if d < -std::f32::consts::PI {
373 d += TAU;
374 }
375 a + d.clamp(-max, max)
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 /// Arena 48×48 con piso y **paredes** perimetrales (corral): los habitantes
383 /// rebotan y no se caen por el borde.
384 fn grid_con_piso() -> VoxelGrid {
385 let n = 48u32;
386 let mut g = VoxelGrid::new([n, 8, n]);
387 for z in 0..n {
388 for x in 0..n {
389 g.set(x, 0, z, [90, 140, 80]);
390 if x == 0 || x == n - 1 || z == 0 || z == n - 1 {
391 for y in 1..6 {
392 g.set(x, y, z, [120, 120, 120]);
393 }
394 }
395 }
396 }
397 g
398 }
399
400 #[test]
401 fn deambula_y_no_atraviesa_el_piso() {
402 let g = grid_con_piso();
403 let mut h = Habitante::spawn(&g, 24, 24, Conducta::default(), 7);
404 let start = h.pos();
405 for _ in 0..600 {
406 h.step(&g, &[], None, 1.0 / 60.0);
407 assert!(h.pos().y >= 1.0 - 0.05, "se hundió: y={}", h.pos().y);
408 }
409 assert!((h.pos() - start).length() > 1.0, "no deambuló");
410 }
411
412 #[test]
413 fn gregario_los_junta_mas_que_indiferentes() {
414 let g = grid_con_piso();
415 // Dos pares lejos entre sí; uno gregario, otro no. Tras un rato, los gregarios
416 // quedan más cerca entre sí que los indiferentes.
417 let separacion = |gregario: f32, seed: u32| {
418 let mut c = Conducta::default();
419 c.gregario = gregario;
420 c.inquietud = 0.2;
421 let mut a = Habitante::spawn(&g, 18, 24, c.clone(), seed);
422 let mut b = Habitante::spawn(&g, 30, 24, c, seed + 100);
423 for _ in 0..900 {
424 let pa = a.pos();
425 let pb = b.pos();
426 a.step(&g, &[pb], None, 1.0 / 60.0);
427 b.step(&g, &[pa], None, 1.0 / 60.0);
428 }
429 (a.pos() - b.pos()).length()
430 };
431 let juntos = separacion(1.0, 1);
432 let sueltos = separacion(0.0, 1);
433 assert!(juntos < sueltos, "gregarios más cerca ({juntos:.1}) que sueltos ({sueltos:.1})");
434 }
435
436 #[test]
437 fn impulsos_por_defecto_parsean_y_reproducen_los_pesos() {
438 // Las fórmulas de fábrica compilan contra los símbolos de la conducta, y sus
439 // pesos evalúan a los valores cableados históricos.
440 let sym = Conducta::symbols();
441 let c = Conducta::default();
442 let env_params = c.param_env();
443 // percepts: cercania_amenaza=1 (amenaza encima).
444 let percepts = [0.0, 1.0, 0.0, 0.0, 1.0];
445 for imp in default_impulsos() {
446 let e = Expr::parse(&imp.peso, &sym).expect("la fórmula de fábrica parsea");
447 let w = e.eval_scalar(&percepts, &env_params, 1.0 / 60.0);
448 match imp.primitiva {
449 Primitiva::Deambular => assert!((w - 1.0).abs() < 1e-4),
450 // gregario=0.35 → 0.35*1.5 = 0.525
451 Primitiva::Cohesion => assert!((w - 0.525).abs() < 1e-4, "cohesión peso={w}"),
452 // miedo=0.6, cercania=1 → 0.6*2.5*1 = 1.5
453 Primitiva::Huir => assert!((w - 1.5).abs() < 1e-4, "huida peso={w}"),
454 _ => {}
455 }
456 }
457 }
458
459 #[test]
460 fn impulso_autorado_cambia_la_conducta() {
461 // Un ser con SÓLO cohesión fuerte se junta más que uno con SÓLO separación,
462 // partiendo de la misma posición y semilla: la fórmula autorada manda.
463 let g = grid_con_piso();
464 let corrida = |imp: Impulso| {
465 let mut c = Conducta::default();
466 c.inquietud = 0.1;
467 c.impulsos = vec![Impulso::new(Primitiva::Deambular, "0.2"), imp];
468 let mut a = Habitante::spawn(&g, 18, 24, c.clone(), 1);
469 let mut b = Habitante::spawn(&g, 30, 24, c, 101);
470 for _ in 0..900 {
471 let pa = a.pos();
472 let pb = b.pos();
473 a.step(&g, &[pb], None, 1.0 / 60.0);
474 b.step(&g, &[pa], None, 1.0 / 60.0);
475 }
476 (a.pos() - b.pos()).length()
477 };
478 let junta = corrida(Impulso::new(Primitiva::Cohesion, "2"));
479 let separa = corrida(Impulso::new(Primitiva::Separacion, "2"));
480 assert!(junta < separa, "cohesión junta ({junta:.1}) vs separación aleja ({separa:.1})");
481 }
482
483 #[test]
484 fn determinismo_con_impulsos_autorados() {
485 // Dos corridas idénticas de un ser con impulsos autorados dan el mismo recorrido:
486 // el percept `azar` no rompe el determinismo (no consume el LCG).
487 let g = grid_con_piso();
488 let run = || {
489 let mut c = Conducta::default();
490 c.impulsos = vec![
491 Impulso::new(Primitiva::Deambular, "1 + azar"),
492 Impulso::new(Primitiva::Huir, "miedo * cercania_amenaza"),
493 ];
494 let amenaza = Some(Vec3::new(24.5, 1.0, 24.5));
495 let mut h = Habitante::spawn(&g, 20, 24, c, 9);
496 for _ in 0..500 {
497 h.step(&g, &[], amenaza, 1.0 / 60.0);
498 }
499 h.pos()
500 };
501 assert_eq!(run(), run(), "misma evolución bit a bit");
502 }
503
504 #[test]
505 fn miedo_aleja_de_la_amenaza() {
506 let g = grid_con_piso();
507 let amenaza = Vec3::new(24.5, 1.0, 24.5);
508 let mut c = Conducta::default();
509 c.miedo = 1.0;
510 c.inquietud = 0.1;
511 let mut h = Habitante::spawn(&g, 22, 24, c, 5); // arranca cerca de la amenaza
512 let d0 = (h.pos() - amenaza).length();
513 for _ in 0..600 {
514 h.step(&g, &[], Some(amenaza), 1.0 / 60.0);
515 }
516 let d1 = (h.pos() - amenaza).length();
517 assert!(d1 > d0 + 2.0, "huyó de la amenaza: {d0:.1} → {d1:.1}");
518 }
519}