Files
llimphi/llimphi-voxel/src/actor.rs
T
Sergio ccab39f140 refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:40:00 +00:00

728 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `Actor` — un **muñeco de cajas articuladas** (humanoide voxel estilo
//! Minecraft/MagicaVoxel) para *actuar* en una escena filmada, con una pequeña
//! **librería de clips de animación** ([`Clip`]: quieto/caminar/correr/saludar/
//! señalar/festejar). Es el tercer ingrediente de la rama de juego (tras
//! [`Player`](crate::Player) y [`raycast`](crate::raycast)): un personaje
//! **posable y animable**.
//!
//! El cuerpo son 6 cajas (cabeza/torso/2 brazos/2 piernas); cada miembro rota en
//! su articulación (cadera/hombro). Un [`Clip`] es una función `fase → `[`Pose`]
//! (los ángulos de todas las articulaciones), así agregar una animación nueva es
//! escribir una pose, no tocar el render. No toca la GPU: produce una **malla**
//! (`Vec<Vertex3d>` + índices) en espacio local (pies en el origen, mirando a
//! `+Z`) que la app sube a un [`Renderer3d`](llimphi_3d::Renderer3d) por frame
//! (`set_geometry`) y compone con los voxels en [`Scene3d`](llimphi_3d::Scene3d).
use llimphi_3d::glam::{Mat4, Vec3};
use llimphi_3d::{push_cube, Vertex3d};
use serde::{Deserialize, Serialize};
/// Amplitud base de balanceo de miembros al caminar (rad).
const SWING: f32 = 0.7;
/// Duración del **cross-fade** al cambiar de clip (seg): el cuerpo mezcla la pose
/// saliente con la entrante en este lapso, en vez de saltar en seco.
const BLEND_DUR: f32 = 0.22;
/// Suavizado Hermite `3t²−2t³` (deriva nula en los extremos) para el cross-fade.
fn smoothstep(x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
/// Ángulos de todas las articulaciones del muñeco en un instante. Una animación
/// ([`Clip`]) produce una `Pose`; [`Actor::mesh`] la hornea a cajas. Ángulos en
/// radianes; `0` = postura neutra (de pie, brazos colgando).
#[derive(Debug, Clone, Copy, Default)]
pub struct Pose {
/// Balanceo de la pierna izquierda/derecha en la cadera (eje X, adelante+).
pub leg_l: f32,
pub leg_r: f32,
/// Balanceo del brazo izquierdo/derecho en el hombro (eje X, adelante+).
pub arm_l: f32,
pub arm_r: f32,
/// Apertura del brazo izquierdo/derecho hacia el costado/arriba (eje Z). El
/// signo se espeja por lado dentro de [`Actor::mesh`]; positivo = levantar.
pub arm_l_out: f32,
pub arm_r_out: f32,
/// Cabeceo de la cabeza (eje X).
pub head_pitch: f32,
/// Desplazamiento vertical del cuerpo (rebote/respiración), en unidades.
pub bob: f32,
/// Inclinación del torso hacia adelante (eje X, alrededor de los pies).
pub lean: f32,
}
impl Pose {
/// Interpola campo a campo entre dos poses (`t=0`→`a`, `t=1`→`b`). Lo usa el
/// cross-fade entre clips.
pub fn lerp(a: &Pose, b: &Pose, t: f32) -> Pose {
let l = |x: f32, y: f32| x + (y - x) * t;
Pose {
leg_l: l(a.leg_l, b.leg_l),
leg_r: l(a.leg_r, b.leg_r),
arm_l: l(a.arm_l, b.arm_l),
arm_r: l(a.arm_r, b.arm_r),
arm_l_out: l(a.arm_l_out, b.arm_l_out),
arm_r_out: l(a.arm_r_out, b.arm_r_out),
head_pitch: l(a.head_pitch, b.head_pitch),
bob: l(a.bob, b.bob),
lean: l(a.lean, b.lean),
}
}
}
/// Animación: una función determinista `fase → `[`Pose`]. La fase la acumula
/// [`Actor::advance`] a la [`cadence`](Clip::cadence) del clip.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Clip {
/// De pie, respirando apenas.
Idle,
/// Caminata: piernas/brazos en oposición.
Walk,
/// Trote: balanceo amplio + inclinación hacia adelante.
Run,
/// Saludo: un brazo levantado al costado, oscilando.
Wave,
/// Señalar: un brazo extendido hacia adelante, firme.
Point,
/// Festejo: ambos brazos arriba, rebotando.
Cheer,
}
impl Clip {
/// `true` si el clip es un **gesto** (no locomoción) — un momento expresivo que
/// merece un acento musical. Lo usa el director para derivar los "beats del guion".
pub fn is_emote(self) -> bool {
matches!(self, Clip::Wave | Clip::Point | Clip::Cheer)
}
/// Velocidad de avance de la fase (rad/seg): pasos más rápidos = más cadencia.
pub fn cadence(self) -> f32 {
match self {
Clip::Idle => 2.0,
Clip::Walk => 8.0,
Clip::Run => 13.0,
Clip::Wave => 9.0,
Clip::Point => 3.0,
Clip::Cheer => 7.0,
}
}
/// La pose de este clip en la `fase` dada.
pub fn pose(self, phase: f32) -> Pose {
let s = phase.sin();
match self {
Clip::Idle => Pose {
bob: 0.02 * s,
head_pitch: 0.03 * s,
arm_l_out: 0.07,
arm_r_out: 0.07,
..Pose::default()
},
Clip::Walk => Pose {
leg_l: s * SWING,
leg_r: -s * SWING,
arm_l: -s * SWING,
arm_r: s * SWING,
bob: (phase * 2.0).sin().abs() * 0.03,
..Pose::default()
},
Clip::Run => Pose {
leg_l: s,
leg_r: -s,
arm_l: -s * 1.1,
arm_r: s * 1.1,
lean: 0.38,
bob: (phase * 2.0).sin().abs() * 0.06,
..Pose::default()
},
Clip::Wave => Pose {
arm_r_out: 2.35 + 0.18 * s, // levantado al costado, saludando
arm_l_out: 0.08,
head_pitch: -0.05,
..Pose::default()
},
Clip::Point => Pose {
arm_r: -1.5, // extendido hacia adelante (+Z)
arm_l_out: 0.07,
head_pitch: 0.08,
..Pose::default()
},
Clip::Cheer => Pose {
arm_l_out: 2.6,
arm_r_out: 2.6,
bob: (phase * 2.0).sin().abs() * 0.08,
head_pitch: -0.1,
..Pose::default()
},
}
}
}
/// **Edad cuantizada** del personaje: estadios discretos que cambian las
/// proporciones del cuerpo (un bebé es cabezón y de miembros cortos; un adulto es
/// alto y proporcionado). Sirve para *mostrar al niño primero* (el corto: nace en el
/// desierto) y envejecerlo por etapas. Cada edad deriva un [`Build`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Age {
/// Bebé/recién nacido: chiquito, cabezón, miembros cortos.
Baby,
/// Niño.
Child,
/// Joven/adolescente.
Teen,
/// Adulto (proporciones de referencia).
Adult,
/// Anciano (apenas más bajo que el adulto).
Elder,
}
impl Age {
/// Todas las edades, de menor a mayor (para que un editor cicle entre ellas).
pub const ALL: [Age; 5] = [Age::Baby, Age::Child, Age::Teen, Age::Adult, Age::Elder];
/// Nombre legible (español) para la UI.
pub fn label(self) -> &'static str {
match self {
Age::Baby => "bebé",
Age::Child => "niño",
Age::Teen => "joven",
Age::Adult => "adulto",
Age::Elder => "anciano",
}
}
/// La edad siguiente (cicla) — para botones de ciclo.
pub fn next(self) -> Age {
let i = Age::ALL.iter().position(|&a| a == self).unwrap_or(0);
Age::ALL[(i + 1) % Age::ALL.len()]
}
/// `(escala_total, refuerzo_cabeza, escala_miembros)` por edad. Más joven =
/// más chico, cabeza proporcionalmente más grande y miembros más cortos.
fn params(self) -> (f32, f32, f32) {
match self {
Age::Baby => (0.50, 1.55, 0.70),
Age::Child => (0.66, 1.28, 0.82),
Age::Teen => (0.84, 1.08, 0.93),
Age::Adult => (1.00, 1.00, 1.00),
Age::Elder => (0.96, 1.00, 0.97),
}
}
}
/// **Constitución** del muñeco: las medidas de cada parte (posiciones de
/// articulación y tamaños de caja), en el espacio local del actor (pies en el
/// origen, mirando a `+Z`). Es el "esqueleto + modelado" configurable: cambiarla
/// hace personajes distintos (alto/bajo, cabezón, etc.). Se construye por
/// [`Age`](Age) ([`Build::for_age`]) y los pies quedan **siempre en `y=0`** por
/// construcción (`hip_y == leg_len`).
#[derive(Debug, Clone, Copy)]
pub struct Build {
/// Altura aproximada total (referencia).
pub height: f32,
/// Centro y tamaño del torso.
pub torso_y: f32,
pub torso: Vec3,
/// Centro y tamaño de la cabeza.
pub head_y: f32,
pub head: Vec3,
/// Altura del hombro y separación lateral; largo y tamaño del brazo.
pub shoulder_y: f32,
pub shoulder_x: f32,
pub arm_len: f32,
pub arm: Vec3,
/// Altura de la cadera (= `leg_len`, pies en el piso), separación; largo/tamaño
/// de la pierna.
pub hip_y: f32,
pub hip_x: f32,
pub leg_len: f32,
pub leg: Vec3,
/// Tamaño de la mano.
pub hand: Vec3,
}
impl Build {
/// Construye la constitución de una [`Age`]. Bottom-up desde los pies (`y=0`),
/// así cualquier edad queda con los pies en el piso. `Adult` reproduce las
/// proporciones históricas del muñeco (los 11 cubos de siempre).
pub fn for_age(age: Age) -> Build {
let (s, head_boost, limb) = age.params();
let neck = 0.02 * s;
let leg_len = 0.80 * s * limb;
let hip_y = leg_len; // pies en el piso
let torso = Vec3::new(0.55 * s, 0.60 * s, 0.30 * s);
let torso_y = hip_y + torso.y * 0.5;
let shoulder_y = hip_y + torso.y;
let head = Vec3::new(0.42 * s * head_boost, 0.40 * s * head_boost, 0.42 * s * head_boost);
let head_y = shoulder_y + head.y * 0.5 + neck;
Build {
height: head_y + head.y * 0.5,
torso_y,
torso,
head_y,
head,
shoulder_y,
shoulder_x: 0.36 * s,
arm_len: 0.60 * s * limb,
arm: Vec3::new(0.18 * s, 0.60 * s * limb, 0.18 * s),
hip_y,
hip_x: 0.14 * s,
leg_len,
leg: Vec3::new(0.22 * s, leg_len, 0.22 * s),
hand: Vec3::new(0.20 * s, 0.18 * s, 0.20 * s),
}
}
/// Constitución adulta de referencia.
pub fn adult() -> Build {
Build::for_age(Age::Adult)
}
}
/// Personaje articulado. `pos` es el **centro de los pies** en espacio de mundo
/// (las mismas coordenadas del terreno/grid); `facing` el rumbo (yaw, `0`=`+Z`).
/// `clip`/`phase` definen la animación actual. Colores por zona (piel/remera/
/// pantalón). La [`Build`] define las proporciones (edad/personaje).
#[derive(Debug, Clone, Copy)]
pub struct Actor {
/// Centro de los pies, en mundo.
pub pos: Vec3,
/// Rumbo (yaw, radianes; `0` mira a `+Z`).
pub facing: f32,
/// Animación actual.
pub clip: Clip,
/// Fase del clip (acumulada por [`advance`](Self::advance)).
pub phase: f32,
/// Clip saliente durante un cross-fade (`None` si no hay transición en curso).
prev_clip: Option<Clip>,
/// Fase del clip saliente (sigue avanzando durante la mezcla).
prev_phase: f32,
/// Progreso del cross-fade `0..1` (a `1` se descarta el clip saliente).
blend: f32,
/// Color de la piel (cabeza).
pub skin: [f32; 3],
/// Color de la remera (torso + brazos).
pub shirt: [f32; 3],
/// Color del pantalón (piernas).
pub pants: [f32; 3],
/// **IK de mirada** (look-at constraint): si está, la cabeza gira (yaw+pitch,
/// dentro de un rango creíble) para **mirar ese punto de mundo**, por encima del
/// cabeceo del clip — los ojos siguen al objetivo. `None` = cabeza alineada al
/// cuerpo. La fija [`look_at`](Self::look_at).
look_target: Option<Vec3>,
/// Constitución (proporciones por edad/personaje). La fija
/// [`with_age`](Self::with_age) / [`with_build`](Self::with_build).
pub build: Build,
/// Edad actual (estadio cuantizado) — informativo; el cuerpo lo da `build`.
pub age: Age,
}
impl Actor {
/// Actor parado en `pos` (centro de pies, mundo) mirando a `facing`, en
/// [`Clip::Idle`], con una paleta por defecto (piel clara, remera teal,
/// pantalón azul).
pub fn new(pos: Vec3, facing: f32) -> Self {
Self {
pos,
facing,
clip: Clip::Idle,
phase: 0.0,
prev_clip: None,
prev_phase: 0.0,
blend: 1.0,
skin: [0.86, 0.68, 0.54],
shirt: [0.20, 0.62, 0.55],
pants: [0.18, 0.22, 0.34],
look_target: None,
build: Build::adult(),
age: Age::Adult,
}
}
/// Fija la **edad** (estadio cuantizado) → recalcula la constitución del cuerpo.
/// Encadenable: `Actor::new(pos, yaw).with_age(Age::Baby)`. Para *mostrar al
/// niño primero* y envejecerlo por etapas.
pub fn with_age(mut self, age: Age) -> Self {
self.set_age(age);
self
}
/// Cambia la edad en caliente (recalcula `build`).
pub fn set_age(&mut self, age: Age) {
self.age = age;
self.build = Build::for_age(age);
}
/// Fija una constitución arbitraria (personaje a medida, no atado a una edad).
pub fn with_build(mut self, build: Build) -> Self {
self.build = build;
self
}
/// Fija (o limpia con `None`) el **objetivo de mirada** (IK de cabeza): la cabeza
/// y los ojos se orientan hacia ese punto de mundo, dentro de un rango creíble,
/// sin mover el cuerpo. Útil para que un actor "mire a cámara" o siga algo.
pub fn look_at(&mut self, target: Option<Vec3>) {
self.look_target = target;
}
/// Tinta el actor (piel/remera/pantalón) — encadenable tras [`new`](Self::new).
pub fn with_colors(mut self, skin: [f32; 3], shirt: [f32; 3], pants: [f32; 3]) -> Self {
self.skin = skin;
self.shirt = shirt;
self.pants = pants;
self
}
/// Cambia la animación. Si es un clip distinto, arranca un **cross-fade**: la
/// pose saliente se mezcla con la nueva durante [`BLEND_DUR`] segundos (sin
/// saltos). Repetir el mismo clip no corta nada.
pub fn set_clip(&mut self, clip: Clip) {
if self.clip != clip {
self.prev_clip = Some(self.clip);
self.prev_phase = self.phase;
self.clip = clip;
self.phase = 0.0;
self.blend = 0.0;
}
}
/// Avanza la animación `dt` segundos: la fase a la cadencia del clip, y —si hay
/// transición— la fase saliente y el progreso del cross-fade. El movimiento de
/// `pos`/`facing` lo maneja el llamador (la dirección).
pub fn advance(&mut self, dt: f32) {
self.phase += dt * self.clip.cadence();
if let Some(pc) = self.prev_clip {
self.prev_phase += dt * pc.cadence();
self.blend += dt / BLEND_DUR;
if self.blend >= 1.0 {
self.prev_clip = None;
}
}
}
/// La pose actual del cuerpo: la del clip vigente, o —durante un cambio— la
/// **mezcla** suave entre el clip saliente y el entrante.
pub fn pose(&self) -> Pose {
let target = self.clip.pose(self.phase);
match self.prev_clip {
Some(pc) => Pose::lerp(&pc.pose(self.prev_phase), &target, smoothstep(self.blend)),
None => target,
}
}
/// Orienta al actor para mirar hacia `target` (sólo el plano horizontal).
pub fn face_towards(&mut self, target: Vec3) {
let d = target - self.pos;
if d.x.abs() + d.z.abs() > 1e-4 {
self.facing = d.x.atan2(d.z); // yaw=0 → +Z, consistente con forward_h
}
}
/// Matriz de ubicación en mundo: traslada a `pos` y rota por `facing`. La
/// malla de [`mesh`](Self::mesh) está en espacio local; este es el `model`
/// del [`Renderer3d`](llimphi_3d::Renderer3d).
pub fn model(&self) -> Mat4 {
Mat4::from_translation(self.pos) * Mat4::from_rotation_y(self.facing)
}
/// Construye la **malla del cuerpo** en espacio local (pies en el origen,
/// mirando a `+Z`) para la pose del clip/fase actuales. 6 cajas. El cuerpo
/// superior (torso/cabeza/brazos) lleva el `bob`+`lean` de la pose; las
/// piernas quedan plantadas (sólo su balanceo de cadera) para no levantar los
/// pies del suelo. Subir con `Renderer3d::set_geometry` y ubicar con
/// [`model`](Self::model).
pub fn mesh(&self) -> (Vec<Vertex3d>, Vec<u16>) {
let p = self.pose();
let b = &self.build;
let mut v = Vec::with_capacity(8 * 11);
let mut i = Vec::with_capacity(36 * 11);
// Transform del cuerpo superior: rebote vertical + inclinación adelante
// (rotación en X alrededor de los pies/origen).
let body = Mat4::from_translation(Vec3::new(0.0, p.bob, 0.0)) * Mat4::from_rotation_x(p.lean);
// Torso.
push_cube(&mut v, &mut i, body * trs(Vec3::new(0.0, b.torso_y, 0.0), Mat4::IDENTITY, b.torso), self.shirt);
// Cabeza: cabeceo del clip + IK de mirada (yaw/pitch hacia el objetivo). El
// `head_anchor` (sin escala) ancla cabeza, ojos y boca para que giren juntos.
let (look_yaw, look_pitch) = self.look_angles();
let head_rot = Mat4::from_rotation_y(look_yaw) * Mat4::from_rotation_x(p.head_pitch + look_pitch);
let head_anchor = body * Mat4::from_translation(Vec3::new(0.0, b.head_y, 0.0)) * head_rot;
push_cube(&mut v, &mut i, head_anchor * Mat4::from_scale(b.head), self.skin);
// Cara: dos ojos + boca en la cara `+Z` de la cabeza. Las posiciones/tamaños
// van como **fracción del tamaño de la cabeza** → escalan con la edad (un bebé
// cabezón tiene ojos más grandes). Decales finos (apenas sobresalen, estilo
// Minecraft). Parpadeo determinista + boca que se abre con los gestos.
let (hw, hh, hd) = (b.head.x, b.head.y, b.head.z);
let blink = self.blink(); // 1 = abierto, ~0 = cerrado
let eye_sz = Vec3::new(0.095 * hw, (0.15 * hh * blink).max(0.012), 0.05 * hd);
let face_z = hd * 0.49; // casi en la cara +Z
for sx in [0.26_f32, -0.26] {
push_cube(
&mut v,
&mut i,
head_anchor * trs(Vec3::new(sx * hw, 0.12 * hh, face_z), Mat4::IDENTITY, eye_sz),
EYE_COLOR,
);
}
let mouth_open = self.mouth_open();
push_cube(
&mut v,
&mut i,
head_anchor * trs(Vec3::new(0.0, -0.25 * hh, face_z), Mat4::IDENTITY, Vec3::new(0.38 * hw, 0.05 * hh + mouth_open, 0.05 * hd)),
MOUTH_COLOR,
);
// Piernas (sin `body`: pies plantados). Articulación en la cadera.
let leg_rot_r = Mat4::from_rotation_x(p.leg_r);
let leg_rot_l = Mat4::from_rotation_x(p.leg_l);
limb(&mut v, &mut i, Mat4::IDENTITY, Vec3::new(b.hip_x, b.hip_y, 0.0), b.leg_len, b.leg, leg_rot_r, self.pants);
limb(&mut v, &mut i, Mat4::IDENTITY, Vec3::new(-b.hip_x, b.hip_y, 0.0), b.leg_len, b.leg, leg_rot_l, self.pants);
// Brazos (con `body`). Rotación = apertura(Z)·balanceo(X); apertura espejada.
let arm_r_rot = Mat4::from_rotation_z(p.arm_r_out) * Mat4::from_rotation_x(p.arm_r);
let arm_l_rot = Mat4::from_rotation_z(-p.arm_l_out) * Mat4::from_rotation_x(p.arm_l);
let (sh_r, sh_l) = (Vec3::new(b.shoulder_x, b.shoulder_y, 0.0), Vec3::new(-b.shoulder_x, b.shoulder_y, 0.0));
limb(&mut v, &mut i, body, sh_r, b.arm_len, b.arm, arm_r_rot, self.shirt);
limb(&mut v, &mut i, body, sh_l, b.arm_len, b.arm, arm_l_rot, self.shirt);
// Manos: una caja de piel en la punta de cada brazo (a `arm_len` del hombro).
hand_at(&mut v, &mut i, body, sh_r, b.arm_len, arm_r_rot, b.hand, self.skin);
hand_at(&mut v, &mut i, body, sh_l, b.arm_len, arm_l_rot, b.hand, self.skin);
(v, i)
}
/// Ángulos `(yaw, pitch)` de la cabeza para el IK de mirada: dirección al objetivo
/// llevada al espacio local del actor (deshaciendo `facing`) y acotada a un rango
/// creíble (±70° yaw, ±50° pitch) para que el cuello no se quiebre. `(0,0)` si no
/// hay objetivo.
fn look_angles(&self) -> (f32, f32) {
use std::f32::consts::FRAC_PI_2;
let Some(target) = self.look_target else { return (0.0, 0.0) };
// Posición aproximada de la cabeza en mundo (pies + 1.62 de altura).
let head_pos = self.pos + Vec3::new(0.0, 1.62, 0.0);
let d = target - head_pos;
if d.length_squared() < 1e-6 {
return (0.0, 0.0);
}
// A espacio local: deshacer el yaw del cuerpo.
let local = Mat4::from_rotation_y(-self.facing).transform_vector3(d).normalize();
let yaw = local.x.atan2(local.z).clamp(-1.22, 1.22); // ±70°
let pitch = (-local.y).asin().clamp(-0.87, 0.87); // ±50°, mirar arriba/abajo
let _ = FRAC_PI_2;
(yaw, pitch)
}
/// Apertura de párpados `0..1` (1 = abierto). Parpadeo determinista por la fase:
/// un cierre breve cada ~3 unidades de fase. Idle no necesita objetivo.
fn blink(&self) -> f32 {
let ph = self.phase * 0.33; // ciclos lentos
let f = ph - ph.floor();
// Cerrado sólo en una ventana corta (~6% del ciclo).
if f > 0.94 {
// Sube y baja rápido dentro de la ventana (triángulo invertido).
let w = (f - 0.94) / 0.06; // 0..1
(1.0 - (1.0 - (2.0 * w - 1.0).abs())).clamp(0.0, 1.0)
} else {
1.0
}
}
/// Apertura de la boca (unidades): abierta en los gestos expresivos (festejar más
/// que saludar/señalar), cerrada el resto → la cara "reacciona" al clip.
fn mouth_open(&self) -> f32 {
match self.clip {
Clip::Cheer => 0.10 + 0.04 * self.phase.sin().abs(),
Clip::Wave | Clip::Point => 0.05,
_ => 0.0,
}
}
}
/// Color de los ojos (casi negro).
const EYE_COLOR: [f32; 3] = [0.08, 0.07, 0.09];
/// Color de la boca (marrón oscuro).
const MOUTH_COLOR: [f32; 3] = [0.30, 0.12, 0.12];
/// Apila la **mano** en la punta de un miembro: una caja de tamaño `hand` centrada a
/// `len` del pivote `joint` (donde termina la caja del brazo), con la misma rotación
/// `rot` del brazo y el mismo prefijo `pre`.
#[allow(clippy::too_many_arguments)]
fn hand_at(
v: &mut Vec<Vertex3d>,
i: &mut Vec<u16>,
pre: Mat4,
joint: Vec3,
len: f32,
rot: Mat4,
hand: Vec3,
color: [f32; 3],
) {
let m = pre
* Mat4::from_translation(joint)
* rot
* Mat4::from_translation(Vec3::new(0.0, -len, 0.0))
* Mat4::from_scale(hand);
push_cube(v, i, m, color);
}
/// `T(center) · R · S(size)` — caja centrada en `center`, rotada por `rot`,
/// escalada a `size` (un cubo unitario → su caja en el cuerpo).
fn trs(center: Vec3, rot: Mat4, size: Vec3) -> Mat4 {
Mat4::from_translation(center) * rot * Mat4::from_scale(size)
}
/// Apila un **miembro articulado**: caja de tamaño `size` y largo `len` que
/// cuelga del pivote `joint` (su extremo superior) y rota por `rot` en torno a
/// ese pivote; todo prefijado por `pre` (el transform del cuerpo, o identidad
/// para las piernas). El centro de la caja queda a `len/2` por debajo del pivote
/// antes de rotar.
#[allow(clippy::too_many_arguments)]
fn limb(
v: &mut Vec<Vertex3d>,
i: &mut Vec<u16>,
pre: Mat4,
joint: Vec3,
len: f32,
size: Vec3,
rot: Mat4,
color: [f32; 3],
) {
let m = pre
* Mat4::from_translation(joint)
* rot
* Mat4::from_translation(Vec3::new(0.0, -len / 2.0, 0.0))
* Mat4::from_scale(size);
push_cube(v, i, m, color);
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::FRAC_PI_2;
/// Rango en Z de los vértices de la malla (cuánto adelantan/atrasan miembros).
fn z_span(a: &Actor) -> f32 {
let z: Vec<f32> = a.mesh().0.iter().map(|v| v.pos[2]).collect();
z.iter().cloned().fold(f32::MIN, f32::max) - z.iter().cloned().fold(f32::MAX, f32::min)
}
#[test]
fn malla_tiene_once_cajas() {
// 6 del cuerpo (torso/cabeza/2 piernas/2 brazos) + 2 manos + 2 ojos + boca.
let a = Actor::new(Vec3::ZERO, 0.0);
let (v, idx) = a.mesh();
assert_eq!(v.len(), 8 * 11, "11 cajas × 8 vértices");
assert_eq!(idx.len(), 36 * 11, "11 cajas × 36 índices");
}
#[test]
fn edades_cambian_proporciones_y_dejan_los_pies_en_el_piso() {
let baby = Build::for_age(Age::Baby);
let adult = Build::for_age(Age::Adult);
// El bebé es más bajo que el adulto.
assert!(baby.height < adult.height, "bebé {} < adulto {}", baby.height, adult.height);
// ...y CABEZÓN: la cabeza ocupa una fracción mayor de su altura.
let head_frac = |b: &Build| b.head.y / b.height;
assert!(head_frac(&baby) > head_frac(&adult) + 0.05, "bebé cabezón: {} vs {}", head_frac(&baby), head_frac(&adult));
// Toda edad apoya los pies en y=0 (el voxel más bajo de la malla ≈ 0).
for age in [Age::Baby, Age::Child, Age::Teen, Age::Adult, Age::Elder] {
let a = Actor::new(Vec3::ZERO, 0.0).with_age(age);
let ymin = a.mesh().0.iter().map(|v| v.pos[1]).fold(f32::MAX, f32::min);
assert!(ymin.abs() < 1e-3, "pies en el piso para {age:?}: ymin={ymin}");
}
}
#[test]
fn adulto_conserva_los_once_cubos_y_altura_historica() {
// El refactor a Build no cambió al adulto: 11 cajas y altura ~1.82.
let a = Actor::new(Vec3::ZERO, 0.0);
assert_eq!(a.mesh().0.len(), 8 * 11);
assert!((a.build.height - 1.82).abs() < 0.05, "altura adulta {}", a.build.height);
}
#[test]
fn ik_de_mirada_gira_la_cabeza_hacia_el_objetivo() {
// Mirar a la derecha (mundo +X) vs a la izquierda (X): los ojos (vértices más
// adelantados en la cara) deben desplazarse en X en sentidos opuestos.
let eye_centroid_x = |a: &Actor| {
// Los ojos están en la cara +Z, son los vértices con mayor z y |x|≈0.11.
let verts = a.mesh().0;
let zmax = verts.iter().map(|v| v.pos[2]).fold(f32::MIN, f32::max);
let front: Vec<f32> =
verts.iter().filter(|v| v.pos[2] > zmax - 0.05).map(|v| v.pos[0]).collect();
front.iter().sum::<f32>() / front.len().max(1) as f32
};
let mut right = Actor::new(Vec3::ZERO, 0.0);
right.look_at(Some(Vec3::new(10.0, 1.62, 1.0)));
let mut left = Actor::new(Vec3::ZERO, 0.0);
left.look_at(Some(Vec3::new(-10.0, 1.62, 1.0)));
// Mirar a +X adelanta la cara hacia +X; a X, hacia X.
assert!(eye_centroid_x(&right) > eye_centroid_x(&left) + 0.05, "la cabeza sigue al objetivo");
}
#[test]
fn caminar_balancea_las_piernas() {
// A fase π/2 el seno es máximo → las piernas separan al máximo.
let mut a = Actor::new(Vec3::ZERO, 0.0);
a.set_clip(Clip::Walk);
a.advance(FRAC_PI_2 / Clip::Walk.cadence());
assert!(z_span(&a) > 0.5, "al caminar los miembros adelantan/atrasan: {}", z_span(&a));
}
#[test]
fn idle_casi_quieto() {
// En Idle los miembros no se balancean: el span en Z es chico.
let mut a = Actor::new(Vec3::ZERO, 0.0); // Idle por defecto
a.advance(FRAC_PI_2 / Clip::Idle.cadence());
assert!(z_span(&a) < 0.45, "Idle no debería balancear: {}", z_span(&a));
}
#[test]
fn cambiar_de_clip_reinicia_la_fase() {
let mut a = Actor::new(Vec3::ZERO, 0.0);
a.set_clip(Clip::Walk);
a.advance(1.0);
assert!(a.phase > 0.0);
a.set_clip(Clip::Run);
assert_eq!(a.phase, 0.0, "un clip nuevo arranca la pose desde 0");
// Repetir el mismo clip NO corta la fase.
a.advance(0.5);
let ph = a.phase;
a.set_clip(Clip::Run);
assert_eq!(a.phase, ph);
}
#[test]
fn cambio_de_clip_hace_cross_fade() {
// Caminando con las piernas bien abiertas…
let mut a = Actor::new(Vec3::ZERO, 0.0);
a.set_clip(Clip::Walk);
a.advance(FRAC_PI_2 / Clip::Walk.cadence());
let span_walk = z_span(&a);
assert!(span_walk > 0.5);
// …al pasar a Idle, JUSTO después la pose sigue siendo ~la de caminar
// (blend≈0), no salta de golpe a quieto.
a.set_clip(Clip::Idle);
let span_inicio = z_span(&a);
assert!((span_inicio - span_walk).abs() < 0.05, "el cross-fade arranca desde la pose saliente");
// Pasado el blend, ya es Idle (piernas juntas).
a.advance(BLEND_DUR + 0.1);
assert!(z_span(&a) < 0.45, "tras el cross-fade la pose es Idle");
}
#[test]
fn face_towards_mira_a_mas_z() {
let mut a = Actor::new(Vec3::ZERO, 0.0);
a.face_towards(Vec3::new(0.0, 0.0, 5.0));
assert!(a.facing.abs() < 1e-4, "mirar a +Z → yaw≈0");
}
}