tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main llimphi-voxel-app/src/world.rs Rust · 1050 líneas · 46.1 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · Sergio ·

1//! Contenido de la escena: terreno voxel + atmósfera + un "monumento" malla
2//! flotante. Aísla el *qué se muestra* del *cómo se maneja* (`main.rs`), así la
3//! app puede ganar personalidad (más estructuras, entidades, reglas) tocando
4//! sólo aquí, sin reescribir el bucle.
5
6use llimphi_3d::glam::{Mat4, Vec3};
7use llimphi_3d::{
8    Atmosphere, Camera3d, Hud, HudQuad, Renderer3d, Scene3d, VoxelGrid, VoxelRenderer,
9};
10use llimphi_ui::llimphi_hal::wgpu;
11use llimphi_voxel::{
12    multitud_caminando, Bicho, BichoUso, Bioma, BiomaPalette, Caminante, CasaTipo, CasaUso,
13    Critter, DetalleSer, Enjambre, Player, Sembrada, Sembrado,
14};
15
16/// Paleta de bichos (ovejas, marrones, terrosos, celestes…).
17const HERD_PALETTE: [[u8; 3]; 5] = [
18    [236, 236, 232],
19    [205, 170, 125],
20    [120, 92, 70],
21    [96, 132, 176],
22    [206, 198, 96],
23];
24
25/// Formato de la textura intermedia de Llimphi (target de `gpu_paint_with`).
26pub const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
27
28/// Mundo render-able + editable: el motor voxel (terreno) + una malla de
29/// triángulos (el monumento), compuestos por [`Scene3d`] en un depth
30/// compartido. Conserva el [`VoxelGrid`] para editar (romper/colocar) por
31/// raycast y subir incremental con `sync`.
32pub struct World {
33    scene: Scene3d,
34    voxel: VoxelRenderer,
35    monument: Renderer3d,
36    grid: VoxelGrid,
37    dim: [u32; 3],
38    /// Jugador en primera persona (modo "explorar"): camina el terreno con
39    /// gravedad y colisión. En modo órbita simplemente se ignora.
40    player: Player,
41    /// Bichos que deambulan el terreno (misma física que el jugador). Se
42    /// dibujan como cajas en el pase de ray-march (`VoxelRenderer::entities`).
43    critters: Vec<Critter>,
44    /// Overlay screen-space para la mira de primera persona.
45    hud: Hud,
46    /// Si dibujar el monumento-malla flotante (se apaga p.ej. en `--poses`).
47    show_monument: bool,
48    /// **Mapa real** para streaming infinito (modo `--mapa`): si está, la ventana se
49    /// regenera de aquí al caminar, sin borde. `None` en los mundos procedurales.
50    mapa: Option<std::sync::Arc<nahual_geo_voxel::MapaVoxel>>,
51    /// Columna de mundo `[wx, wz]` donde cae la esquina local `(0,0)` de la ventana.
52    origin: [i32; 2],
53    /// Origen de mundo INICIAL de la ventana (al construir). El origen de streaming
54    /// que consume `scroll_to` (que arranca en `[0,0,0]` como el `brick_origin` del
55    /// renderer) es `origin - origin0` → así el ring buffer sube sólo la franja nueva.
56    origin0: [i32; 2],
57    /// **Ceniza**: los voxels que se desprendieron del mundo y andan por el aire.
58    /// Se dibujan como cajas en el mismo pase que los critters.
59    ceniza: Enjambre,
60    /// **La siembra del bioma**: cada animal y cada planta que la ventana estampó,
61    /// con lo necesario para sacarlo y volver a ponerlo. Es lo que deja que el que
62    /// tenés al lado sea un ser analítico y el del fondo siga siendo su estampa de
63    /// voxels. Vacía en los mundos que no vienen de un bioma (mapa real, desierto).
64    siembra: Vec<Sembrado>,
65    /// El bioma que sembró, para volver a estampar lo que se aleja.
66    bioma: Option<(Bioma, BiomaPalette)>,
67    /// Índices de `siembra` que AHORA son ser SDF (y están borrados del grid).
68    en_sdf: Vec<usize>,
69    /// Gente caminando cerca del jugador (la multitud del catálogo).
70    gente: Vec<Caminante>,
71    /// Reloj del mundo: la fase de todo lo que se mueve (paso, viento, aleteo).
72    reloj: f32,
73}
74
75impl World {
76    /// Paso de reubicación de la ventana de streaming (voxels): se regenera sólo al
77    /// cruzar un múltiplo de esto → no se rehace el mundo cada voxel caminado.
78    const STREAM_STEP: i32 = 16;
79
80    /// **Mundo desde un mapa real, STREAMEADO** (modo `--mapa`): la ventana de `dim`
81    /// se desliza sobre un `MapaVoxel` grande a medida que el jugador camina, así el
82    /// mundo **no tiene borde** (Minecraft-style: se explora sin agotarlo; más allá
83    /// del mapa, el relieve del borde/mar se extiende). Reusa física/cámara/HUD.
84    pub fn build_mapa(
85        device: &wgpu::Device,
86        queue: &wgpu::Queue,
87        mapa: nahual_geo_voxel::MapaVoxel,
88        dim: [u32; 3],
89    ) -> Self {
90        // Ventana inicial centrada en el centro del mapa.
91        let origin = [
92            mapa.proj.ancho as i32 / 2 - dim[0] as i32 / 2,
93            mapa.proj.largo as i32 / 2 - dim[2] as i32 / 2,
94        ];
95        let grid = mapa.generar_ventana(dim, origin);
96        let mut voxel = VoxelRenderer::new(device, queue, FMT, &grid);
97        voxel.sun_dir = [0.5, 0.55, 0.42];
98        voxel.atmosphere = Atmosphere {
99            sky_zenith: [72, 124, 198],
100            sky_horizon: [206, 220, 236],
101            // Niebla moderada que difumina el borde lejano de la ventana.
102            fog_density: 1.1 / dim[0] as f32,
103            god_rays: 0.4,
104        };
105        let monument = Renderer3d::new(device, FMT);
106        // Reposar en una CALLE (columna baja), no en un techo.
107        let (cx, cz) = (dim[0] / 2, dim[2] / 2);
108        let (mut sx, mut sz, mut best) = (cx, cz, u32::MAX);
109        let r = (dim[0] / 4).max(8);
110        for z in cz.saturating_sub(r)..(cz + r).min(dim[2] - 1) {
111            for x in cx.saturating_sub(r)..(cx + r).min(dim[0] - 1) {
112                if let Some(h) = grid.height_at(x, z) {
113                    if h < best {
114                        best = h;
115                        sx = x;
116                        sz = z;
117                    }
118                }
119            }
120        }
121        let player = Player::spawn_on(&grid, sx, sz);
122        let hud = Hud::new(device, FMT);
123        let mut world = Self {
124            scene: Scene3d::new(),
125            voxel,
126            monument,
127            grid,
128            dim,
129            player,
130            critters: Vec::new(),
131            hud,
132            show_monument: false,
133            mapa: Some(std::sync::Arc::new(mapa)),
134            origin,
135            origin0: origin,
136            ceniza: Enjambre::ceniza(0x1CE),
137            siembra: Vec::new(),
138            bioma: None,
139            en_sdf: Vec::new(),
140            gente: Vec::new(),
141            reloj: 0.0,
142        };
143        for _ in 0..40 {
144            world.tick_sin_gpu(1.0 / 30.0);
145        }
146        world
147    }
148
149    /// **Streaming toroidal**: si el jugador se alejó del centro más de un
150    /// [`STREAM_STEP`] (múltiplo de brick), desliza la ventana regenerando el terreno
151    /// del `MapaVoxel` en el origen nuevo y **sube sólo la franja que entra** vía
152    /// [`VoxelRenderer::scroll_to`] (ring buffer, sin re-subir la ventana entera → sin
153    /// tirón). Traslada al jugador el mismo delta → queda quieto en el mundo mientras
154    /// el terreno se re-centra bajo él. Sin borde. No-op fuera de `--mapa`.
155    pub fn follow_mapa(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
156        let Some(mapa) = self.mapa.clone() else { return };
157        let step = Self::STREAM_STEP;
158        let cx = self.dim[0] as i32 / 2;
159        let cz = self.dim[2] as i32 / 2;
160        let ddx = ((self.player.pos.x.floor() as i32 - cx) / step) * step;
161        let ddz = ((self.player.pos.z.floor() as i32 - cz) / step) * step;
162        if ddx == 0 && ddz == 0 {
163            return;
164        }
165        self.origin[0] += ddx;
166        self.origin[1] += ddz;
167        self.grid = mapa.generar_ventana(self.dim, self.origin);
168        // Origen de streaming del renderer = delta acumulado desde el arranque
169        // (su `brick_origin` empezó en 0). Sube sólo la franja nueva.
170        let stream = [self.origin[0] - self.origin0[0], 0, self.origin[1] - self.origin0[1]];
171        self.voxel.scroll_to(device, queue, stream, &self.grid);
172        // El jugador retrocede el mismo delta → posición de mundo intacta.
173        self.player.pos.x -= ddx as f32;
174        self.player.pos.z -= ddz as f32;
175    }
176
177    /// Construye el mundo: terreno procedural (de `llimphi-voxel`) subido al
178    /// brick pool, atmósfera diurna, y un cubo-malla coloreado como monumento.
179    /// Necesita `device`/`queue` (de ahí que se construya perezoso, en la 1ª
180    /// pintada GPU).
181    pub fn build(device: &wgpu::Device, queue: &wgpu::Queue, dim_xz: u32, seed: u32) -> Self {
182        let dy = (dim_xz * 4 / 10).max(48);
183        let dim = [dim_xz, dy, dim_xz];
184
185        let grid = llimphi_voxel::terrain(dim, seed);
186        let mut voxel = VoxelRenderer::new(device, queue, FMT, &grid);
187        voxel.sun_dir = [0.55, 0.5, 0.32];
188        voxel.atmosphere = Atmosphere {
189            sky_zenith: [64, 118, 196],
190            sky_horizon: [202, 218, 236],
191            fog_density: 0.5 / dim_xz as f32,
192            god_rays: 0.6,
193        };
194
195        let monument = Renderer3d::new(device, FMT);
196
197        // Jugador posado sobre la columna central del terreno.
198        let player = Player::spawn_on(&grid, dim[0] / 2, dim[2] / 2);
199
200        // Manada esparcida por el interior, en una grilla con jitter
201        // determinista (cada bicho su semilla → rumbos distintos).
202        let margin = 16u32;
203        let (cols, rows) = (7u32, 5u32);
204        let mut critters = Vec::with_capacity((cols * rows) as usize);
205        let mut k: u32 = 0x9e3779b9;
206        let span_x = dim[0].saturating_sub(2 * margin).max(1);
207        let span_z = dim[2].saturating_sub(2 * margin).max(1);
208        for r in 0..rows {
209            for c in 0..cols {
210                // Jitter LCG por celda para que no quede una grilla perfecta.
211                k = k.wrapping_mul(1664525).wrapping_add(1013904223);
212                let jx = (k >> 16) % 9;
213                k = k.wrapping_mul(1664525).wrapping_add(1013904223);
214                let jz = (k >> 16) % 9;
215                let x = margin + span_x * c / (cols - 1) + jx;
216                let z = margin + span_z * r / (rows - 1) + jz;
217                let color = HERD_PALETTE[(r * cols + c) as usize % HERD_PALETTE.len()];
218                critters.push(Critter::spawn_on(&grid, x.min(dim[0] - 1), z.min(dim[2] - 1), color, k));
219            }
220        }
221
222        let hud = Hud::new(device, FMT);
223
224        let mut world = Self {
225            scene: Scene3d::new(),
226            voxel,
227            monument,
228            grid,
229            dim,
230            player,
231            critters,
232            hud,
233            show_monument: true,
234            mapa: None,
235            origin: [0, 0],
236            origin0: [0, 0],
237            ceniza: Enjambre::ceniza(0x1CE),
238            siembra: Vec::new(),
239            bioma: None,
240            en_sdf: Vec::new(),
241            gente: Vec::new(),
242            reloj: 0.0,
243        };
244        // Calentar la manada para que arranque desparramada (no en grilla).
245        for _ in 0..150 {
246            world.tick_sin_gpu(1.0 / 30.0);
247        }
248        world
249    }
250
251    /// Construye el **mundo completo**: TODO lo que se puede crear con el motor,
252    /// junto, para caminarlo — terreno con colinas, **flora** (árboles, arbustos,
253    /// flores, pasto), **minerales** que asoman en los acantilados, un **pueblo
254    /// procedural** (casas de varios estilos + plazas con fuente/bancos/árboles) y
255    /// **fauna** variada (vacas, ovejas, ciervos, zorros, caballos, gallinas,
256    /// mariposas…), todo esparcido por la misma semilla. Sin monumento ni manada
257    /// analítica: la vida está horneada en el propio terreno.
258    pub fn build_todo(device: &wgpu::Device, queue: &wgpu::Queue, dim_xz: u32, seed: u32) -> Self {
259        let dy = (dim_xz * 4 / 10).max(48);
260        let dim = [dim_xz, dy, dim_xz];
261
262        // Partimos de la pradera del proyecto de arranque (ya trae flora +
263        // minerales + fauna) y le sumamos un pueblo procedural encima.
264        let project = llimphi_voxel::Project::starter();
265        let mut bioma = project
266            .biomas
267            .iter()
268            .find(|b| b.name == "pradera")
269            .cloned()
270            .expect("bioma pradera");
271        // Colinas suaves: hay acantilados donde asoman los minerales, pero se
272        // camina cómodo y las casas encuentran dónde asentarse.
273        bioma.relief = 0.4;
274        bioma.mountains = 0.3;
275        bioma.water_level = 0.0;
276        bioma.rivers = 0.0;
277        // Un pueblo mezclado, en manchas conectadas por rutas (campo abierto en
278        // medio) → se nace en pradera y se camina hacia el pueblo.
279        bioma.densidad_urbana = 0.42;
280        bioma.casas = vec![
281            CasaUso { tipo: CasaTipo::Cabana, densidad: 0.3 },
282            CasaUso { tipo: CasaTipo::Moderna, densidad: 0.22 },
283            CasaUso { tipo: CasaTipo::Granja, densidad: 0.2 },
284            CasaUso { tipo: CasaTipo::Adobe, densidad: 0.2 },
285            CasaUso { tipo: CasaTipo::Tienda, densidad: 0.15 },
286            CasaUso { tipo: CasaTipo::Torre, densidad: 0.1 },
287            CasaUso { tipo: CasaTipo::Flintstone, densidad: 0.12 },
288            CasaUso { tipo: CasaTipo::Hobbit, densidad: 0.12 },
289            CasaUso { tipo: CasaTipo::Duplex, densidad: 0.15 },
290        ];
291        // Fauna variada (además de la que ya trae la pradera). Desde que existe el
292        // bestiario SDF, el catálogo del bioma puede ser el catálogo entero: lo que
293        // se ve de cerca es el ser analítico, y lo de lejos su estampa de voxels.
294        bioma.fauna.extend_from_slice(&[
295            BichoUso { bicho: Bicho::Ciervo, densidad: 0.20 },
296            BichoUso { bicho: Bicho::Zorro, densidad: 0.12 },
297            BichoUso { bicho: Bicho::Caballo, densidad: 0.14 },
298            BichoUso { bicho: Bicho::Gallina, densidad: 0.16 },
299            BichoUso { bicho: Bicho::Pato, densidad: 0.12 },
300            BichoUso { bicho: Bicho::Cabra, densidad: 0.14 },
301            BichoUso { bicho: Bicho::Cerdo, densidad: 0.12 },
302            BichoUso { bicho: Bicho::Pajaro, densidad: 0.16 },
303            BichoUso { bicho: Bicho::Perro, densidad: 0.10 },
304            BichoUso { bicho: Bicho::Gato, densidad: 0.08 },
305            BichoUso { bicho: Bicho::Burro, densidad: 0.08 },
306            BichoUso { bicho: Bicho::Llama, densidad: 0.08 },
307            BichoUso { bicho: Bicho::Conejo, densidad: 0.14 },
308            BichoUso { bicho: Bicho::Ardilla, densidad: 0.10 },
309            BichoUso { bicho: Bicho::Mariposa, densidad: 0.14 },
310            BichoUso { bicho: Bicho::Cardenal, densidad: 0.10 },
311            BichoUso { bicho: Bicho::Gorrion, densidad: 0.12 },
312            BichoUso { bicho: Bicho::Cuervo, densidad: 0.08 },
313            BichoUso { bicho: Bicho::Ganso, densidad: 0.08 },
314            BichoUso { bicho: Bicho::Serpiente, densidad: 0.06 },
315        ]);
316        // Y la vegetación. La pradera del catálogo viene MUY tupida (árboles a
317        // 0,008 por columna): con el bosque cerrado no se ve un animal ni a diez
318        // pasos, y la primera toma salió con la cámara metida adentro de un
319        // arbusto. Acá se rehace la lista — menos árboles, y adelante los que la
320        // técnica analítica sabe dibujar bien de cerca (el orden manda: gana el
321        // primero que acierta en la columna).
322        {
323            use llimphi_voxel::{Forma, ObjetoUso};
324            let hoja = bioma.objetos.first().map(|o| o.material).unwrap_or(0);
325            let flor = bioma.objetos.last().map(|o| o.material).unwrap_or(hoja);
326            bioma.objetos = vec![
327                ObjetoUso { material: hoja, densidad: 0.0022, forma: Forma::Ficus },
328                ObjetoUso { material: hoja, densidad: 0.0016, forma: Forma::Sauce },
329                ObjetoUso { material: hoja, densidad: 0.0018, forma: Forma::Jacaranda },
330                ObjetoUso { material: hoja, densidad: 0.0035, forma: Forma::Arbol },
331                ObjetoUso { material: hoja, densidad: 0.0045, forma: Forma::Arbusto },
332                ObjetoUso { material: flor, densidad: 0.0060, forma: Forma::Girasol },
333                ObjetoUso { material: flor, densidad: 0.0070, forma: Forma::Margarita },
334                ObjetoUso { material: flor, densidad: 0.0080, forma: Forma::Lavanda },
335                ObjetoUso { material: hoja, densidad: 0.0060, forma: Forma::Helecho },
336                ObjetoUso { material: hoja, densidad: 0.0300, forma: Forma::Pasto },
337            ];
338        }
339        let palette = project.bioma_palette(&bioma);
340        // Con siembra: la lista de lo que quedó plantado, para que el anillo SDF
341        // pueda cambiar de técnica a los que tengas al lado (`repartir_sdf`).
342        let (grid, siembra) = bioma.generate_window_con_siembra(seed, &palette, dim, [0, 0]);
343        let mut voxel = VoxelRenderer::new(device, queue, FMT, &grid);
344        voxel.sun_dir = [0.5, 0.52, 0.42];
345        voxel.atmosphere = Atmosphere {
346            sky_zenith: [72, 124, 198],
347            sky_horizon: [206, 220, 236],
348            fog_density: 0.45 / dim_xz as f32,
349            god_rays: 0.5,
350        };
351
352        let monument = Renderer3d::new(device, FMT);
353        // Buscamos una columna ABIERTA (fuera de mancha de pueblo) cerca del centro
354        // para nacer en el campo, no dentro de una casa. Espiral de búsqueda.
355        let (mut sx, mut sz) = (dim[0] / 2, dim[2] / 2);
356        'find: for r in 0..(dim[0] / 2) {
357            for &(dx, dz) in &[(r, 0i32), (0, r as i32), (r, r as i32), (r, -(r as i32))] {
358                let x = ((dim[0] / 2) as i32 + dx as i32).clamp(2, dim[0] as i32 - 3) as u32;
359                let z = ((dim[2] / 2) as i32 + dz).clamp(2, dim[2] as i32 - 3) as u32;
360                if !bioma.in_town(seed, x as i32, z as i32, dim) {
361                    sx = x;
362                    sz = z;
363                    break 'find;
364                }
365            }
366        }
367        let player = Player::spawn_on(&grid, sx, sz);
368        let hud = Hud::new(device, FMT);
369
370        // Además de la fauna horneada en el terreno, un puñado de bichos que
371        // DEAMBULAN (misma física que el jugador) para que el mundo tenga
372        // movimiento — se dibujan como cajas en el pase de ray-march.
373        let mut critters = Vec::new();
374        let mut k: u32 = 0x1234_5678 ^ seed;
375        for i in 0..14u32 {
376            k = k.wrapping_mul(1664525).wrapping_add(1013904223);
377            let x = 20 + (k >> 8) % dim[0].saturating_sub(40).max(1);
378            k = k.wrapping_mul(1664525).wrapping_add(1013904223);
379            let z = 20 + (k >> 8) % dim[2].saturating_sub(40).max(1);
380            let color = HERD_PALETTE[i as usize % HERD_PALETTE.len()];
381            critters.push(Critter::spawn_on(&grid, x.min(dim[0] - 1), z.min(dim[2] - 1), color, k));
382        }
383
384        let mut world = Self {
385            scene: Scene3d::new(),
386            voxel,
387            monument,
388            grid,
389            dim,
390            player,
391            critters,
392            hud,
393            show_monument: false,
394            mapa: None,
395            origin: [0, 0],
396            origin0: [0, 0],
397            ceniza: Enjambre::ceniza(0x1CE),
398            siembra,
399            bioma: Some((bioma, palette)),
400            en_sdf: Vec::new(),
401            // Gente caminando por el campo, cerca de donde se nace: la multitud del
402            // catálogo puesta en el mundo. Se posan sobre el relieve cada cuadro.
403            gente: multitud_caminando(
404                seed ^ 0x6E7E,
405                Self::GENTE,
406                Vec3::new(sx as f32 - 14.0, 0.0, sz as f32 - 18.0),
407                28.0,
408                40.0,
409                0.0,
410            ),
411            reloj: 0.0,
412        };
413        for _ in 0..120 {
414            world.tick_sin_gpu(1.0 / 30.0);
415        }
416        world
417    }
418
419    /// Construye un mundo de **desierto** (vía [`WorldRecipe::desert`](llimphi_voxel::WorldRecipe)):
420    /// llano de arena con pocas montañas/ríos y cactus ralos, sin manada (escena
421    /// solemne del corto de nacimiento) y sin el monumento. Atmósfera diurna cálida.
422    /// Pensado para el modo `--born`.
423    pub fn build_desert(device: &wgpu::Device, queue: &wgpu::Queue, dim_xz: u32, seed: u32) -> Self {
424        let dy = (dim_xz * 4 / 10).max(48);
425        let dim = [dim_xz, dy, dim_xz];
426
427        // Desierto **llano** (como lo pidió el corto): sin mesas/relieve ni ríos,
428        // sólo una ondulación de dunas mínima. Así el huevo siempre tiene despeje y
429        // el plano de seguimiento (que retrocede detrás del sujeto) ve arena abierta
430        // en vez de meterse dentro de una columna de terreno.
431        // El bioma de desierto del proyecto de arranque, resuelto a su paleta y
432        // aplanado (sin mesas/relieve ni ríos) — la semilla del CLI manda.
433        let project = llimphi_voxel::Project::starter();
434        let mut bioma = project
435            .biomas
436            .iter()
437            .find(|b| b.name == "desierto")
438            .cloned()
439            .expect("bioma desierto");
440        bioma.mountains = 0.0;
441        bioma.relief = 0.0;
442        bioma.dune = 0.035;
443        bioma.rivers = 0.0;
444        let palette = project.bioma_palette(&bioma);
445        let grid = bioma.generate_window(seed, &palette, dim, [0, 0]);
446        let mut voxel = VoxelRenderer::new(device, queue, FMT, &grid);
447        voxel.sun_dir = [0.5, 0.62, 0.30]; // sol alto y cálido
448        voxel.atmosphere = Atmosphere {
449            sky_zenith: [108, 152, 206],  // azul de mediodía
450            sky_horizon: [226, 212, 178], // horizonte arenoso/caluroso
451            fog_density: 0.18 / dim_xz as f32, // baja: hay tomas aéreas (cámara lejos)
452            god_rays: 0.5,
453        };
454
455        let monument = Renderer3d::new(device, FMT);
456        let player = Player::spawn_on(&grid, dim[0] / 2, dim[2] / 2);
457        let hud = Hud::new(device, FMT);
458
459        Self {
460            scene: Scene3d::new(),
461            voxel,
462            monument,
463            grid,
464            dim,
465            player,
466            critters: Vec::new(), // desierto vacío
467            hud,
468            show_monument: false,
469            mapa: None,
470            origin: [0, 0],
471            origin0: [0, 0],
472            ceniza: Enjambre::ceniza(0x1CE),
473            siembra: Vec::new(),
474            bioma: None,
475            en_sdf: Vec::new(),
476            gente: Vec::new(),
477            reloj: 0.0,
478        }
479    }
480
481    /// Avanza un frame de vida: deambula la manada y vuelca sus cajas al
482    /// renderer (la capa de entidades del ray-march). Barato (instancing
483    /// analítico) — llamar cada frame, en cualquier modo.
484    pub fn tick(&mut self, queue: &wgpu::Queue, dt: f32) {
485        self.tick_sin_gpu(dt);
486        // El anillo SDF: quién deja de ser voxel y pasa a ser analítico. Toca el
487        // grid, así que necesita la cola para subir los bricks que cambiaron.
488        self.repartir_sdf(queue);
489    }
490
491    /// La parte del cuadro que no toca la GPU (la usa el constructor para
492    /// «asentar» el mundo antes de que exista un cuadro).
493    pub fn tick_sin_gpu(&mut self, dt: f32) {
494        self.reloj += dt;
495        for c in &mut self.critters {
496            c.step(&self.grid, dt);
497        }
498        self.ceniza.paso(dt);
499        self.voxel.entities.clear();
500        self.voxel.entities.extend(self.critters.iter().map(|c| c.entity()));
501        // Lo que sobra del cupo de cajas del motor (64) es para la ceniza, lo más
502        // cerca del ojo primero: la que no entra es la que no se ve.
503        let resto = 64usize.saturating_sub(self.voxel.entities.len());
504        if resto > 0 && !self.ceniza.is_empty() {
505            let ojo = self.player.eye();
506            self.voxel.entities.extend(self.ceniza.entidades(resto, Some(ojo)));
507        }
508    }
509
510    /// Cuántas personas caminan por el mundo.
511    const GENTE: usize = 9;
512    /// Hasta dónde llega el anillo SDF de la FAUNA (voxels). Generoso: un animal
513    /// mide un par de metros, así que a cincuenta pasos todavía se le ve la forma.
514    const RADIO_FAUNA: f32 = 62.0;
515    /// Y el de la FLORA. Corto a propósito: los árboles son cientos y a diez metros
516    /// la estampa de voxels ya cuenta lo mismo. El anillo chico es lo que deja el
517    /// presupuesto libre para los animales y la gente, que es donde se nota.
518    const RADIO_FLORA: f32 = 26.0;
519    /// Histéresis: se entra al anillo a `RADIO` y se sale a `RADIO · SALIDA`. Sin
520    /// esto, el que queda justo en el borde alterna de técnica todos los cuadros —
521    /// y cada alternancia re-sube bricks a la GPU.
522    const SALIDA: f32 = 1.18;
523
524    /// **El reparto entre las dos técnicas de graficación**, cuadro a cuadro.
525    ///
526    /// Lo que el bioma sembró en voxels sigue en voxels **salvo lo que tenés
527    /// cerca**: eso se borra del grid y se dibuja como ser analítico (silueta lisa,
528    /// cara, andar). Al alejarse vuelve a su estampa. El mundo NO se regenera: se
529    /// re-suben los pocos bricks que cambiaron (`VoxelRenderer::sync`).
530    ///
531    /// El presupuesto del uniform (40 seres / 640 primitivas) se reparte por
532    /// cercanía, y el detalle de cada uno baja con la distancia: de cerca la cara,
533    /// de lejos la silueta.
534    fn repartir_sdf(&mut self, queue: &wgpu::Queue) {
535        let Some((bioma, _)) = self.bioma.as_ref() else {
536            return;
537        };
538        let ojo = self.player.eye();
539        let dist = |s: &Sembrado| {
540            let c = Vec3::new(
541                s.base[0] as f32 + 0.5,
542                s.base[1] as f32 + s.alto() * 0.5,
543                s.base[2] as f32 + 0.5,
544            );
545            ojo.distance(c)
546        };
547
548        // 1) Quién califica: por clase, por distancia, y con histéresis para los
549        //    que ya están adentro.
550        let mut candidatos: Vec<(f32, usize)> = Vec::new();
551        for (i, s) in self.siembra.iter().enumerate() {
552            let radio = match s.clase {
553                Sembrada::Fauna { .. } => Self::RADIO_FAUNA,
554                Sembrada::Flora { .. } => Self::RADIO_FLORA,
555            };
556            let radio = if self.en_sdf.contains(&i) { radio * Self::SALIDA } else { radio };
557            let d = dist(s);
558            if d <= radio {
559                candidatos.push((d, i));
560            }
561        }
562        // La FAUNA primero y la flora después, cada grupo por cercanía. Con un
563        // orden puramente por distancia, las flores y los arbustos que tenés a dos
564        // pasos se comen el presupuesto y no queda un solo animal analítico en el
565        // cuadro — que es justo lo que se vino a ver. Una toma salió así: un
566        // girasol precioso y ni una vaca.
567        candidatos.sort_by(|a, b| {
568            let clase = |i: usize| match self.siembra[i].clase {
569                Sembrada::Fauna { .. } => 0u8,
570                Sembrada::Flora { .. } => 1,
571            };
572            clase(a.1).cmp(&clase(b.1)).then(a.0.total_cmp(&b.0))
573        });
574
575        // 2) El presupuesto del uniform. La gente entra primero (es lo que más se
576        //    mira) y el resto se reparte por el orden de arriba, bajando el detalle
577        //    antes de descartar a nadie.
578        let cupo_gente = self.gente.len().min(llimphi_3d::VOXEL_MAX_SERS / 3);
579        let mut sers: Vec<llimphi_3d::SdfSer> = Vec::with_capacity(llimphi_3d::VOXEL_MAX_SERS);
580        let mut gastado = 0usize;
581        let mut gente: Vec<(f32, usize)> = self
582            .gente
583            .iter()
584            .enumerate()
585            .map(|(i, c)| (ojo.distance(c.pos), i))
586            .collect();
587        gente.sort_by(|a, b| a.0.total_cmp(&b.0));
588        for (d, i) in gente.into_iter().take(cupo_gente) {
589            let c = &self.gente[i];
590            let detalle = DetalleSer::por_distancia(d, c.persona.altura);
591            if gastado + detalle.costo() > llimphi_3d::VOXEL_MAX_SER_PRIMS {
592                break;
593            }
594            let ser = c.persona.posada(c.clip, c.fase, c.pos, c.yaw, None, detalle);
595            gastado += ser.prims.len();
596            sers.push(ser);
597        }
598
599        // 3) Los del anillo: los que YA están sin estampar se dibujan sí o sí (si
600        //    no, quedarían de agujero en el mundo); los nuevos entran mientras
601        //    alcance el presupuesto.
602        let mut quiero: Vec<(f32, usize, bool)> = candidatos
603            .iter()
604            .map(|&(d, i)| (d, i, self.en_sdf.contains(&i)))
605            .collect();
606        quiero.sort_by(|a, b| b.2.cmp(&a.2).then(a.0.total_cmp(&b.0)));
607
608        let mut dibujar: Vec<(f32, usize)> = Vec::new();
609        for (d, i, ya) in quiero {
610            if sers.len() + dibujar.len() >= llimphi_3d::VOXEL_MAX_SERS {
611                break;
612            }
613            let s = &self.siembra[i];
614            let mut detalle = DetalleSer::por_distancia(d, s.alto());
615            while gastado + detalle.costo() > llimphi_3d::VOXEL_MAX_SER_PRIMS {
616                detalle = match detalle {
617                    DetalleSer::Hero => DetalleSer::Medio,
618                    DetalleSer::Medio => DetalleSer::Lejos,
619                    DetalleSer::Lejos => break,
620                };
621            }
622            if gastado + detalle.costo() > llimphi_3d::VOXEL_MAX_SER_PRIMS {
623                if ya {
624                    continue; // sin cupo, pero sigue borrado: se re-estampa abajo
625                }
626                break;
627            }
628            let ser = s.ser_sdf(self.reloj, detalle);
629            gastado += ser.prims.len();
630            sers.push(ser);
631            dibujar.push((d, i));
632        }
633
634        // 4) Cambios de técnica, **a cuentagotas**: `VoxelGrid::take_dirty` devuelve
635        //    UNA caja que envuelve todo lo tocado y `sync` re-sube sus bricks.
636        //    Cambiando dos sujetos en esquinas opuestas del anillo, esa caja es
637        //    media ventana. Con tope por cuadro la caja queda chica, y lo que no
638        //    entró pasa al siguiente — gratis, porque la histéresis ya evita que
639        //    nadie oscile en el borde.
640        const POR_CUADRO: usize = 3;
641        let elegidos: Vec<usize> = dibujar.iter().map(|&(_, i)| i).collect();
642        let mut cambios = 0usize;
643        let previos = std::mem::take(&mut self.en_sdf);
644        for i in previos {
645            if !elegidos.contains(&i) && cambios < POR_CUADRO {
646                // Se aleja: vuelve a ser su estampa de voxels.
647                bioma.estampar(&mut self.grid, &self.siembra[i], self.dim);
648                cambios += 1;
649            } else {
650                self.en_sdf.push(i);
651            }
652        }
653        for &i in &elegidos {
654            if !self.en_sdf.contains(&i) {
655                if cambios >= POR_CUADRO {
656                    // No entra este cuadro: tampoco se dibuja, o saldría encimado
657                    // con su propio fantasma de voxels.
658                    if let Some(p) = dibujar.iter().position(|&(_, j)| j == i) {
659                        sers.remove(cupo_gente.min(sers.len()) + p);
660                        dibujar.remove(p);
661                    }
662                    continue;
663                }
664                bioma.desestampar(&mut self.grid, &self.siembra[i], self.dim);
665                self.en_sdf.push(i);
666                cambios += 1;
667            }
668        }
669        // Y el que sigue borrado pero no entró en el cupo del uniform: se dibuja
670        // igual con el detalle más barato, o quedaría un hueco en el terreno.
671        for &i in &self.en_sdf.clone() {
672            if !dibujar.iter().any(|&(_, j)| j == i) {
673                let s = &self.siembra[i];
674                sers.push(s.ser_sdf(self.reloj, DetalleSer::Lejos));
675            }
676        }
677        // Válvula de comparación: `SDF_RING=0` deja el mundo ENTERO en voxels (se
678        // vuelve a estampar lo que se había sacado y no se dibuja ningún ser
679        // analítico). Es para sacar las dos tomas del mismo sitio, con la misma
680        // cámara, y ver de qué se trata el cambio de técnica — el "antes" no se
681        // puede reconstruir de memoria.
682        let anillo = std::env::var("SDF_RING").map(|v| v != "0").unwrap_or(true);
683        if !anillo {
684            for &i in &self.en_sdf {
685                bioma.estampar(&mut self.grid, &self.siembra[i], self.dim);
686            }
687            sers.clear();
688            cambios += 1;
689        }
690        if cambios > 0 {
691            self.voxel.sync(queue, &mut self.grid);
692        }
693        self.voxel.sers = sers;
694
695        // 5) La gente sigue caminando y pisando el relieve.
696        self.caminar_gente();
697    }
698
699    /// Avanza la multitud: cada uno a su paso, y **posado sobre el terreno** (la
700    /// vereda del catálogo era plana; un campo con lomas, no).
701    fn caminar_gente(&mut self) {
702        for c in &mut self.gente {
703            let vel = c.persona.altura * 0.62;
704            let paso = if c.yaw.abs() < 0.1 { vel } else { -vel };
705            c.pos.z += paso * (1.0 / 60.0);
706            c.fase += vel * 2.2 / 60.0;
707            // Rebote en los bordes de la ventana (el mundo tiene borde).
708            let margen = 6.0;
709            if c.pos.z < margen || c.pos.z > self.dim[2] as f32 - margen {
710                c.yaw = if c.yaw.abs() < 0.1 { std::f32::consts::PI } else { 0.0 };
711                c.pos.z = c.pos.z.clamp(margen, self.dim[2] as f32 - margen);
712            }
713            c.pos.x = c.pos.x.clamp(margen, self.dim[0] as f32 - margen);
714            // Pisar el relieve: la altura de la columna de abajo.
715            if let Some(h) = self.grid.height_at(c.pos.x as u32, c.pos.z as u32) {
716                let objetivo = h as f32 + 1.0;
717                // Suavizado: subir de golpe un escalón se ve como un salto.
718                c.pos.y += (objetivo - c.pos.y).clamp(-0.35, 0.35);
719            }
720        }
721    }
722
723    /// El punto que mirar para VER el catálogo: el centro del ser analítico más
724    /// cercano al ojo que no sea el propio pie del jugador.
725    ///
726    /// **En coordenadas CENTRADAS**, como todo lo que consume [`Camera3d`]: la
727    /// siembra vive en coords de grilla y devolverla cruda dejaba la cámara mirando
728    /// media ventana más allá — la toma salía de puro cielo y no había nada que
729    /// delatara por qué.
730    pub fn mirar_sdf(&self, ojo: Vec3) -> Option<Vec3> {
731        let centro = self.world_center();
732        // Un animal antes que una planta: es lo que cuenta la historia del cambio
733        // de técnica (una flor también, pero un ciervo se entiende de una).
734        let hay_fauna = self
735            .en_sdf
736            .iter()
737            .any(|i| matches!(self.siembra[*i].clase, Sembrada::Fauna { .. }));
738        self.siembra
739            .iter()
740            .enumerate()
741            .filter(|(i, _)| self.en_sdf.contains(i))
742            .filter(|(_, s)| !hay_fauna || matches!(s.clase, Sembrada::Fauna { .. }))
743            .map(|(_, s)| {
744                Vec3::new(
745                    s.base[0] as f32 + 0.5,
746                    s.base[1] as f32 + s.alto() * 0.45,
747                    s.base[2] as f32 + 0.5,
748                ) - centro
749            })
750            .filter(|c| ojo.distance(*c) > 2.5)
751            .min_by(|a, b| ojo.distance(*a).total_cmp(&ojo.distance(*b)))
752    }
753
754    /// Altura del terreno en una columna, **en coordenadas centradas** (para no
755    /// plantar la cámara adentro de una loma).
756    pub fn altura_en(&self, x: f32, z: f32) -> Option<f32> {
757        let centro = self.world_center();
758        let (gx, gz) = (x + centro.x, z + centro.z);
759        if gx < 0.0 || gz < 0.0 || gx >= self.dim[0] as f32 || gz >= self.dim[2] as f32 {
760            return None;
761        }
762        self.grid
763            .height_at(gx as u32, gz as u32)
764            .map(|h| h as f32 + 1.0 - centro.y)
765    }
766
767    /// Cuántos seres analíticos hay en el cuadro y cuántas primitivas gastan — el
768    /// número que dice si el anillo entra en el presupuesto.
769    pub fn cuenta_sdf(&self) -> (usize, usize) {
770        (
771            self.voxel.sers.len(),
772            self.voxel.sers.iter().map(|s| s.prims.len()).sum(),
773        )
774    }
775
776    /// **Desmorona** el mundo donde apunta el rayo: arranca un puñado de voxels y
777    /// los pone a subir como ceniza (gravedad al revés). Devuelve `false` si el rayo
778    /// no pegó en nada.
779    pub fn desprender_ceniza(&mut self, queue: &wgpu::Queue, origin: [f32; 3], dir: [f32; 3]) -> bool {
780        let max = self.dim[0] as f32 * 3.0;
781        let Some(hit) = llimphi_voxel::raycast(&self.grid, origin, dir, max) else {
782            return false;
783        };
784        let [cx, cy, cz] = hit.cell;
785        let centro = Vec3::new(cx as f32 + 0.5, cy as f32 + 0.5, cz as f32 + 0.5);
786        let n = self.ceniza.desprender(&mut self.grid, centro, 3.5, 28);
787        if n > 0 {
788            self.voxel.sync(queue, &mut self.grid);
789        }
790        n > 0
791    }
792
793    /// Avanza la física del jugador `dt` segundos con la caminata deseada
794    /// (`wish`, horizontal, espacio de grilla) y `jump`. Devuelve el ojo del
795    /// jugador en **mundo** (centrado en el origen, listo para la cámara).
796    pub fn step_player(&mut self, wish: Vec3, jump: bool, dt: f32) -> Vec3 {
797        self.player.step(&self.grid, wish, jump, dt);
798        self.player.eye() - self.world_center()
799    }
800
801    /// Ojo del jugador en coords centradas (para la cámara), sin avanzar la física.
802    /// Se usa tras [`follow_mapa`](Self::follow_mapa), que puede reubicar al jugador.
803    pub fn eye(&self) -> Vec3 {
804        self.player.eye() - self.world_center()
805    }
806
807    /// Niebla base del mapa (la que difumina el borde de la ventana a ras de suelo).
808    fn fog_base(&self) -> f32 {
809        1.1 / self.dim[0] as f32
810    }
811
812    /// Altura del jugador SOBRE el terreno de su columna (voxels), `0` si está al ras.
813    fn altitud(&self) -> f32 {
814        let px = (self.player.pos.x.max(0.0) as u32).min(self.dim[0] - 1);
815        let pz = (self.player.pos.z.max(0.0) as u32).min(self.dim[2] - 1);
816        let suelo = self.grid.height_at(px, pz).unwrap_or(0) as f32;
817        (self.player.pos.y - suelo).max(0.0)
818    }
819
820    /// **Vuelo libre** (creativo): mueve al jugador en `wish` (con vertical), sin
821    /// gravedad ni colisión. La velocidad **escala con la altitud** (más alto → más
822    /// rápido, para cubrir territorio) y la **niebla se abre** con la altura (más
823    /// alto → ves más lejos, hasta el borde de la ventana). Devuelve el ojo centrado.
824    pub fn fly_player(&mut self, wish: Vec3, dt: f32) -> Vec3 {
825        let alt = self.altitud();
826        let boost = (1.0 + alt / 14.0).min(16.0);
827        self.player.volar_veloz(wish, dt, boost);
828        // Niebla que se disipa al subir → vista lejana.
829        self.voxel.atmosphere.fog_density = self.fog_base() / (1.0 + alt / 30.0);
830        self.player.eye() - self.world_center()
831    }
832
833    /// Restaura la niebla base (al volver a caminar tras haber volado alto).
834    pub fn reset_mapa_fog(&mut self) {
835        if self.mapa.is_some() {
836            self.voxel.atmosphere.fog_density = self.fog_base();
837        }
838    }
839
840    /// Dimensión `[dx, dy, dz]` de la ventana actual.
841    pub fn dim(&self) -> [u32; 3] {
842        self.dim
843    }
844
845    /// Origen de la ventana de streaming (columna de mundo de la esquina). Debug.
846    pub fn origin_debug(&self) -> [i32; 2] {
847        self.origin
848    }
849
850    /// Reposa al jugador sobre la columna central (al entrar a modo explorar,
851    /// por si el terreno cambió por ediciones).
852    pub fn respawn_player(&mut self) {
853        self.player = Player::spawn_on(&self.grid, self.dim[0] / 2, self.dim[2] / 2);
854    }
855
856    /// Reposa al jugador sobre una columna concreta `(x, z)` (clamp al grid).
857    /// Útil para encuadrar una vista en primera persona desde un mirador.
858    pub fn spawn_player_at(&mut self, x: u32, z: u32) {
859        let x = x.min(self.dim[0] - 1);
860        let z = z.min(self.dim[2] - 1);
861        self.player = Player::spawn_on(&self.grid, x, z);
862    }
863
864    /// Centro (en **mundo**) del bicho más cercano a `eye_world`, o `None` si no
865    /// hay manada. Para encuadrar un bicho en una toma de primera persona.
866    pub fn nearest_critter(&self, eye_world: Vec3) -> Option<Vec3> {
867        let center = self.world_center();
868        self.critters
869            .iter()
870            .map(|c| {
871                let e = c.entity();
872                Vec3::new(e.pos[0], e.pos[1], e.pos[2]) - center
873            })
874            .min_by(|a, b| {
875                a.distance_squared(eye_world)
876                    .total_cmp(&b.distance_squared(eye_world))
877            })
878    }
879
880    /// Medio-`dim`: el offset entre espacio de grilla (`[0,dim]`) y mundo
881    /// (centrado en el origen). `mundo = grilla - centro`.
882    fn world_center(&self) -> Vec3 {
883        Vec3::new(self.dim[0] as f32, self.dim[1] as f32, self.dim[2] as f32) * 0.5
884    }
885
886    /// Edita el mundo por **raycast** desde un origen/dirección (espacio de
887    /// grilla = `eye_mundo + dim/2`): `build=false` cava un cráter en el voxel
888    /// golpeado; `build=true` deposita un bloque en la celda vacía adyacente.
889    /// Sube sólo lo tocado (`sync` incremental). Devuelve `true` si pegó algo.
890    /// Se llama desde el hilo GPU (necesita `queue`).
891    pub fn apply_edit(&mut self, queue: &wgpu::Queue, origin: [f32; 3], dir: [f32; 3], build: bool) -> bool {
892        let max = self.dim[0] as f32 * 3.0;
893        let Some(hit) = llimphi_voxel::raycast(&self.grid, origin, dir, max) else {
894            return false;
895        };
896        if build {
897            let [px, py, pz] = hit.place;
898            let r = 1i32;
899            for dz in -r..=r {
900                for dy in -r..=r {
901                    for dx in -r..=r {
902                        let (x, y, z) = (px + dx, py + dy, pz + dz);
903                        if x >= 0 && y >= 0 && z >= 0 {
904                            self.grid.set(x as u32, y as u32, z as u32, [222, 184, 96]);
905                        }
906                    }
907                }
908            }
909        } else {
910            let [cx, cy, cz] = hit.cell;
911            let r = 4i32;
912            for dz in -r..=r {
913                for dy in -r..=r {
914                    for dx in -r..=r {
915                        if dx * dx + dy * dy + dz * dz <= r * r {
916                            let (x, y, z) = (cx + dx, cy + dy, cz + dz);
917                            if x >= 0 && y >= 0 && z >= 0 {
918                                self.grid.clear(x as u32, y as u32, z as u32);
919                            }
920                        }
921                    }
922                }
923            }
924        }
925        self.voxel.sync(queue, &mut self.grid);
926        true
927    }
928
929    /// Posiciona el monumento: flota sobre el centro del mundo y gira con
930    /// `angle` (rad). El mundo voxel ocupa `y ∈ [-dy/2, dy/2]`; lo dejamos a
931    /// ~`0.45·dy` para que asome entre los picos (y se ocluya con ellos — prueba
932    /// la convivencia voxel+malla en vivo).
933    pub fn animate(&mut self, angle: f32) {
934        let dy = self.dim[1] as f32;
935        let size = self.dim[0] as f32 * 0.11;
936        let y = dy * 0.45;
937        let m = Mat4::from_translation(Vec3::new(0.0, y, 0.0))
938            * Mat4::from_rotation_y(angle)
939            * Mat4::from_scale(Vec3::splat(size));
940        self.monument.set_model(m);
941    }
942
943    /// Compone terreno + monumento sobre `target` (depth compartido). Firma
944    /// compatible con la closure de `gpu_paint_with` (más la cámara).
945    pub fn render(
946        &mut self,
947        device: &wgpu::Device,
948        queue: &wgpu::Queue,
949        encoder: &mut wgpu::CommandEncoder,
950        target: &wgpu::TextureView,
951        viewport: (u32, u32),
952        camera: &Camera3d,
953    ) {
954        self.render_with(device, queue, encoder, target, viewport, camera, &[]);
955    }
956
957    /// Como [`render`](Self::render) pero agrega **mallas extra** al pase (mismo
958    /// depth compartido → se ocluyen con el terreno y el monumento). Es el gancho
959    /// para los **actores** de una escena filmada: la app posa cada actor en su
960    /// propio [`Renderer3d`](llimphi_3d::Renderer3d) y los pasa aquí.
961    #[allow(clippy::too_many_arguments)]
962    pub fn render_with(
963        &mut self,
964        device: &wgpu::Device,
965        queue: &wgpu::Queue,
966        encoder: &mut wgpu::CommandEncoder,
967        target: &wgpu::TextureView,
968        viewport: (u32, u32),
969        camera: &Camera3d,
970        extra: &[&Renderer3d],
971    ) {
972        let mut meshes: Vec<&Renderer3d> = Vec::with_capacity(1 + extra.len());
973        if self.show_monument {
974            meshes.push(&self.monument);
975        }
976        meshes.extend_from_slice(extra);
977        self.scene.render(
978            device,
979            queue,
980            encoder,
981            target,
982            viewport,
983            camera,
984            Some(&self.voxel),
985            &meshes,
986        );
987    }
988
989    /// Enciende/apaga el dibujo del monumento-malla flotante (se apaga en
990    /// `--poses` para no tapar la fila de actores).
991    pub fn show_monument(&mut self, v: bool) {
992        self.show_monument = v;
993    }
994
995    /// Posición en **mundo** (centrado en el origen) del **suelo** sobre la
996    /// columna de grilla `(gx, gz)`: pies un voxel por encima del terreno (o del
997    /// piso `y=0` si la columna está vacía). Para posar un actor sobre el relieve.
998    pub fn ground_at(&self, gx: u32, gz: u32) -> Vec3 {
999        let gx = gx.min(self.dim[0] - 1);
1000        let gz = gz.min(self.dim[2] - 1);
1001        let top = self.grid.height_at(gx, gz).map(|y| y as f32 + 1.0).unwrap_or(0.0);
1002        Vec3::new(gx as f32 + 0.5, top, gz as f32 + 0.5) - self.world_center()
1003    }
1004
1005    /// Cota del suelo en un punto de **mundo** (el mismo espacio que devuelve
1006    /// [`ground_at`](Self::ground_at)). Es lo que necesita el IK de pies de un actor:
1007    /// muestrear bajo cada pie por separado, no bajo el centro del cuerpo.
1008    pub fn ground_y(&self, wx: f32, wz: f32) -> f32 {
1009        let c = self.world_center();
1010        let gx = (wx + c.x).max(0.0) as u32;
1011        let gz = (wz + c.z).max(0.0) as u32;
1012        self.ground_at(gx, gz).y
1013    }
1014
1015    /// Pinta el HUD de primera persona encima de la escena (pase screen-space):
1016    /// la **mira** centrada + un panel con el **modo** y las **coordenadas** del
1017    /// jugador (lectura del propio `Player`, espacio de grilla). Llamar *después*
1018    /// de [`render`](Self::render), en modo explorar.
1019    pub fn draw_hud(
1020        &mut self,
1021        device: &wgpu::Device,
1022        queue: &wgpu::Queue,
1023        encoder: &mut wgpu::CommandEncoder,
1024        target: &wgpu::TextureView,
1025        viewport: (u32, u32),
1026    ) {
1027        let mut quads = HudQuad::crosshair(viewport, 9.0, 2.0, [1.0, 1.0, 1.0, 0.85]).to_vec();
1028
1029        // Texto: modo + coordenadas. La grilla es entera, redondeamos a voxel.
1030        let p = self.player.pos;
1031        let coords = format!("X {} Y {} Z {}", p.x as i32, p.y as i32, p.z as i32);
1032        let px = 2.0; // pixel de glifo (10×14 por carácter)
1033        let (tx, ty) = (16.0, 14.0);
1034        let lh = 7.0 * px + 6.0; // alto de línea (glifo + interlínea)
1035
1036        // Panel de fondo translúcido para legibilidad sobre cualquier terreno.
1037        let pw = HudQuad::text_width(&coords, px).max(HudQuad::text_width("EXPLORAR", px));
1038        quads.push(HudQuad {
1039            x: tx - 8.0,
1040            y: ty - 8.0,
1041            w: pw + 16.0,
1042            h: lh * 2.0 + 8.0,
1043            color: [0.0, 0.0, 0.0, 0.45],
1044        });
1045        quads.extend(HudQuad::text("EXPLORAR", tx, ty, px, [0.62, 0.95, 0.78, 0.95]));
1046        quads.extend(HudQuad::text(&coords, tx, ty + lh, px, [0.95, 0.97, 1.0, 0.95]));
1047
1048        self.hud.render(device, queue, encoder, target, viewport, &quads);
1049    }
1050}