tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! Física de jugador en primera persona sobre un [`VoxelGrid`] — el otro
2//! ingrediente núcleo de un juego voxel (además del picking de [`raycast`]):
3//! **caminar el mundo con gravedad y colisión**. Reusable por cualquier juego
4//! voxel de la suite; no toca la GPU ni el render.
5//!
6//! Coordenadas en **espacio de grilla** (voxel = 1, mundo en `[0, dim]`), las
7//! mismas que [`raycast`](crate::raycast) y `eye_mundo + dim/2`. El jugador es
8//! una caja AABB parada sobre el terreno; `pos` es el **centro de los pies**
9//! (x/z centrados, y en la base). La colisión se resuelve eje por eje
10//! (move-and-resolve), el método clásico y robusto para voxels.
11//!
12//! La mirada (`yaw`/`pitch`) la lleva la app (compartida con la cámara
13//! `orbit`/`fly`); aquí viven sólo las helpers puras de base
14//! ([`forward_h`]/[`right_h`]/[`look_dir`]) para derivar el vector de avance.
15
16use llimphi_3d::glam::Vec3;
17use llimphi_3d::VoxelGrid;
18
19/// Semi-ancho del jugador en X/Z (la caja mide `2·HALF_W` de lado).
20const HALF_W: f32 = 0.3;
21/// Altura total de la caja (de los pies a la coronilla).
22const HEIGHT: f32 = 1.7;
23/// Altura del ojo sobre los pies (la cámara va aquí).
24const EYE: f32 = 1.55;
25/// Aceleración de gravedad (voxels/s²).
26const GRAVITY: f32 = 30.0;
27/// Velocidad horizontal de caminata (voxels/s).
28const MOVE_SPEED: f32 = 8.0;
29/// Velocidad vertical inicial del salto (voxels/s).
30const JUMP_SPEED: f32 = 9.0;
31/// Margen para no “tocar” el voxel del plano siguiente por error de redondeo.
32const EPS: f32 = 1e-3;
33
34/// Avance horizontal unitario para `yaw` (en grilla): `yaw=0` mira a `+Z`.
35pub fn forward_h(yaw: f32) -> Vec3 {
36 let (s, c) = yaw.sin_cos();
37 Vec3::new(s, 0.0, c)
38}
39
40/// "Derecha" horizontal unitaria para `yaw` (perpendicular a [`forward_h`],
41/// con la convención de mano derecha del motor: a `yaw=0`, derecha = `+X`).
42pub fn right_h(yaw: f32) -> Vec3 {
43 let (s, c) = yaw.sin_cos();
44 Vec3::new(c, 0.0, -s)
45}
46
47/// Dirección de mirada completa (incluye `pitch`), misma convención que
48/// [`Camera3d::fly`](llimphi_3d::Camera3d::fly). Útil como `dir` del raycast.
49pub fn look_dir(yaw: f32, pitch: f32) -> Vec3 {
50 let (sy, cy) = yaw.sin_cos();
51 let (sp, cp) = pitch.sin_cos();
52 Vec3::new(cp * sy, sp, cp * cy)
53}
54
55/// Cuerpo físico AABB con velocidad, parado sobre el terreno. Lo usa tanto el
56/// jugador (input directo) como los agentes ([`Critter`](crate::Critter), wander
57/// automático) — misma física, distinta voluntad.
58#[derive(Debug, Clone, Copy)]
59pub struct Player {
60 /// Centro de los pies (espacio de grilla).
61 pub pos: Vec3,
62 /// Velocidad actual (voxels/s).
63 pub vel: Vec3,
64 /// `true` si el último paso terminó apoyado en suelo (habilita el salto).
65 pub on_ground: bool,
66 /// Velocidad de caminata horizontal (voxels/s). Editable por cuerpo: el
67 /// jugador anda rápido, un bicho puede pastar lento.
68 pub speed: f32,
69}
70
71impl Player {
72 /// Jugador parado con los pies en `feet` (centro de los pies, grilla).
73 pub fn new(feet: Vec3) -> Self {
74 Self {
75 pos: feet,
76 vel: Vec3::ZERO,
77 on_ground: false,
78 speed: MOVE_SPEED,
79 }
80 }
81
82 /// Crea un jugador posado sobre la columna `(x, z)` del grid: pies un voxel
83 /// por encima del suelo más alto (o sobre el piso `y=0` si la columna está
84 /// vacía). Centrado en el voxel (`+0.5`).
85 pub fn spawn_on(grid: &VoxelGrid, x: u32, z: u32) -> Self {
86 let top = grid.height_at(x, z).map(|y| y as f32 + 1.0).unwrap_or(0.0);
87 Self::new(Vec3::new(x as f32 + 0.5, top + EPS, z as f32 + 0.5))
88 }
89
90 /// Posición del ojo (cámara) en espacio de grilla.
91 pub fn eye(&self) -> Vec3 {
92 self.pos + Vec3::new(0.0, EYE, 0.0)
93 }
94
95 /// Avanza la física un paso `dt` segundos. `wish` es la dirección de
96 /// caminata **horizontal** deseada (no necesita estar normalizada; se usa
97 /// su dirección × [`MOVE_SPEED`]); `jump` solicita un salto (sólo prende si
98 /// `on_ground`). Resuelve colisión contra `grid` eje por eje.
99 pub fn step(&mut self, grid: &VoxelGrid, wish: Vec3, jump: bool, dt: f32) {
100 let dt = dt.clamp(0.0, 0.05); // nunca un salto de física monstruoso
101
102 // Velocidad horizontal: directa desde el deseo (sin inercia, simple).
103 let flat = Vec3::new(wish.x, 0.0, wish.z);
104 let h = if flat.length_squared() > 1e-6 {
105 flat.normalize() * self.speed
106 } else {
107 Vec3::ZERO
108 };
109 self.vel.x = h.x;
110 self.vel.z = h.z;
111
112 // Gravedad + salto.
113 self.vel.y -= GRAVITY * dt;
114 if jump && self.on_ground {
115 self.vel.y = JUMP_SPEED;
116 }
117
118 // Mover y resolver eje por eje. on_ground se re-evalúa cada paso.
119 self.on_ground = false;
120 self.move_axis(grid, 0, self.vel.x * dt);
121 self.move_axis(grid, 1, self.vel.y * dt);
122 self.move_axis(grid, 2, self.vel.z * dt);
123 }
124
125 /// VUELO libre (modo creativo): sin gravedad ni colisión. La cámara se mueve
126 /// en la dirección deseada `wish` —incluida la VERTICAL (`wish.y` sube/baja)—
127 /// para explorar el mundo desde cualquier ángulo y dictar sucesos. `wish` no
128 /// necesita normalizarse; vuela algo más rápido que la caminata.
129 pub fn volar(&mut self, wish: Vec3, dt: f32) {
130 self.volar_veloz(wish, dt, 1.0);
131 }
132
133 /// Como [`volar`](Self::volar) pero con un **multiplicador de velocidad** `boost`
134 /// (p. ej. escalado por la altitud: más alto → más rápido).
135 pub fn volar_veloz(&mut self, wish: Vec3, dt: f32, boost: f32) {
136 let dt = dt.clamp(0.0, 0.05);
137 self.vel = Vec3::ZERO;
138 self.on_ground = false;
139 if wish.length_squared() > 1e-6 {
140 self.pos += wish.normalize() * self.speed * 1.8 * boost.max(0.1) * dt;
141 }
142 }
143
144 /// AABB `[min, max]` del jugador en `pos`.
145 fn aabb_at(&self, pos: Vec3) -> (Vec3, Vec3) {
146 (
147 Vec3::new(pos.x - HALF_W, pos.y, pos.z - HALF_W),
148 Vec3::new(pos.x + HALF_W, pos.y + HEIGHT, pos.z + HALF_W),
149 )
150 }
151
152 /// Intenta desplazar `amount` en `axis` (0=X,1=Y,2=Z); si la caja chocaría
153 /// con un sólido, cancela el movimiento en ese eje y anula la velocidad
154 /// (marcando `on_ground` si fue al bajar).
155 fn move_axis(&mut self, grid: &VoxelGrid, axis: usize, amount: f32) {
156 if amount == 0.0 {
157 return;
158 }
159 let mut np = self.pos;
160 np[axis] += amount;
161 let (min, max) = self.aabb_at(np);
162 if aabb_hits_solid(grid, min, max) {
163 if axis == 1 && amount < 0.0 {
164 self.on_ground = true;
165 }
166 self.vel[axis] = 0.0;
167 } else {
168 self.pos = np;
169 }
170 }
171}
172
173/// `true` si la caja `[min, max]` solapa algún voxel sólido del grid. Recorre
174/// las celdas enteras que toca la caja (cada voxel `(i,j,k)` ocupa
175/// `[i,i+1)³`).
176fn aabb_hits_solid(grid: &VoxelGrid, min: Vec3, max: Vec3) -> bool {
177 let x0 = min.x.floor() as i32;
178 let y0 = min.y.floor() as i32;
179 let z0 = min.z.floor() as i32;
180 // `-EPS` para que tocar exacto el plano del voxel siguiente no lo cuente.
181 let x1 = (max.x - EPS).floor() as i32;
182 let y1 = (max.y - EPS).floor() as i32;
183 let z1 = (max.z - EPS).floor() as i32;
184 for z in z0..=z1 {
185 for y in y0..=y1 {
186 for x in x0..=x1 {
187 if grid.is_solid(x, y, z) {
188 return true;
189 }
190 }
191 }
192 }
193 false
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 /// Piso sólido en `y=0` (una capa) sobre un grid 8³.
201 fn grid_con_piso() -> VoxelGrid {
202 let mut g = VoxelGrid::new([8, 8, 8]);
203 for z in 0..8 {
204 for x in 0..8 {
205 g.set(x, 0, z, [100, 100, 100]);
206 }
207 }
208 g
209 }
210
211 #[test]
212 fn cae_y_se_apoya_en_el_piso() {
213 let g = grid_con_piso();
214 // Arranca flotando bien arriba.
215 let mut p = Player::new(Vec3::new(4.5, 5.0, 4.5));
216 for _ in 0..240 {
217 p.step(&g, Vec3::ZERO, false, 1.0 / 60.0);
218 }
219 // El piso ocupa [0,1); los pies deben quedar apoyados a y≈1.
220 assert!(p.on_ground, "debería terminar en el suelo");
221 assert!((p.pos.y - 1.0).abs() < 0.05, "pies en y≈1, dio {}", p.pos.y);
222 }
223
224 #[test]
225 fn volar_sube_sin_gravedad_ni_colision() {
226 let _g = grid_con_piso();
227 let mut p = Player::new(Vec3::new(4.5, 5.0, 4.5));
228 let y0 = p.pos.y;
229 // Volar hacia arriba: sube y NO cae (sin gravedad).
230 for _ in 0..30 {
231 p.volar(Vec3::Y, 1.0 / 30.0);
232 }
233 assert!(p.pos.y > y0 + 1.0, "voló hacia arriba: {} → {}", y0, p.pos.y);
234 assert!(!p.on_ground);
235 // Sin deseo, se queda quieto (no cae).
236 let quieto = p.pos;
237 for _ in 0..30 {
238 p.volar(Vec3::ZERO, 1.0 / 30.0);
239 }
240 assert!((p.pos - quieto).length() < 1e-4, "sin wish, flota quieto");
241 // Atraviesa el suelo si vuela hacia abajo (sin colisión).
242 for _ in 0..600 {
243 p.volar(-Vec3::Y, 1.0 / 30.0);
244 }
245 assert!(p.pos.y < 0.0, "el vuelo ignora la colisión: {}", p.pos.y);
246 }
247
248 #[test]
249 fn una_pared_frena_el_avance() {
250 let mut g = grid_con_piso();
251 // Muro en x=6, toda la altura.
252 for y in 0..8 {
253 for z in 0..8 {
254 g.set(6, y, z, [80, 80, 80]);
255 }
256 }
257 let mut p = Player::new(Vec3::new(4.5, 1.0, 4.5));
258 // Camina hacia +X contra el muro un buen rato.
259 for _ in 0..120 {
260 p.step(&g, Vec3::X, false, 1.0 / 60.0);
261 }
262 // No puede atravesar: su borde derecho (pos.x + HALF_W) queda en x<6.
263 assert!(p.pos.x + HALF_W <= 6.0 + EPS, "atravesó el muro: x={}", p.pos.x);
264 assert!(p.pos.x > 4.5, "debería haber avanzado algo hasta el muro");
265 }
266
267 #[test]
268 fn salta_solo_desde_el_suelo() {
269 let g = grid_con_piso();
270 let mut p = Player::new(Vec3::new(4.5, 1.0, 4.5));
271 // Un paso para asentar on_ground.
272 p.step(&g, Vec3::ZERO, false, 1.0 / 60.0);
273 assert!(p.on_ground);
274 // Salta: la velocidad vertical se vuelve positiva y despega.
275 p.step(&g, Vec3::ZERO, true, 1.0 / 60.0);
276 assert!(p.vel.y > 0.0, "el salto debe dar velocidad ascendente");
277 assert!(p.pos.y > 1.0, "debe despegar del piso");
278 // En el aire, un segundo intento de salto no reinyecta velocidad.
279 let antes = p.vel.y;
280 p.step(&g, Vec3::ZERO, true, 1.0 / 60.0);
281 assert!(p.vel.y < antes, "no debe re-saltar en el aire");
282 }
283}