tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-voxel/src/conducta.rs Rust · 519 líneas · 19.8 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

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, &params, 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}