diff --git a/Cargo.lock b/Cargo.lock index d29223c..86a29c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3498,6 +3498,15 @@ dependencies = [ "dominium-core", ] +[[package]] +name = "dominium-render-plan" +version = "0.1.0" +dependencies = [ + "dominium-core", + "dominium-iso", + "serde", +] + [[package]] name = "double-ended-peekable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 63fbeea..bbd0998 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,6 +148,7 @@ members = [ "crates/modules/dominium/dominium-core", "crates/modules/dominium/dominium-physics", "crates/modules/dominium/dominium-iso", + "crates/modules/dominium/dominium-render-plan", # ============================================================ # modules/gioser/ — Landing WASM (chacana + 4 elementos) diff --git a/crates/modules/dominium/dominium-render-plan/Cargo.toml b/crates/modules/dominium/dominium-render-plan/Cargo.toml new file mode 100644 index 0000000..80f406b --- /dev/null +++ b/crates/modules/dominium/dominium-render-plan/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "dominium-render-plan" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "dominium — maqueta isométrica agnóstica: convierte un World en una lista de quads 2D ordenada por profundidad, lista para cualquier backend (GPUI, web, tui)." + +[dependencies] +dominium-core = { path = "../dominium-core" } +dominium-iso = { path = "../dominium-iso" } +serde = { workspace = true } diff --git a/crates/modules/dominium/dominium-render-plan/src/lib.rs b/crates/modules/dominium/dominium-render-plan/src/lib.rs new file mode 100644 index 0000000..44698b4 --- /dev/null +++ b/crates/modules/dominium/dominium-render-plan/src/lib.rs @@ -0,0 +1,389 @@ +//! `dominium-render-plan` — la maqueta isométrica, agnóstica de backend. +//! +//! El último eslabón antes de la pantalla. Toma un [`World`] lógico, lo +//! proyecta con un [`IsoProjector`] y emite una lista plana de +//! [`Quad`]s 2D ya ordenados de atrás hacia adelante: cualquier backend +//! (GPUI, `` web, TUI) sólo tiene que pintarlos en orden. +//! +//! Aquí no hay `gpui`, ni `wgpu`, ni `f64`: sólo aritmética `f32` y +//! `dominium-iso`. La regla de la spec —cero dependencias gráficas en el +//! núcleo— se respeta hasta el penúltimo crate. +//! +//! ```text +//! World ──► build_plan(iso, weights, cfg) ──► RenderPlan { quads } +//! │ +//! backend.paint(quad) ◄─────┘ (en orden) +//! ``` +//! +//! - Una celda → un quad-rombo aproximado, coloreado por la mezcla de sus +//! 5 capas (la altura sale del `Z` compuesto, el color de la psique del +//! suelo). +//! - Un Lemming → un quad-marca posado sobre el relieve de su celda. +//! - Todo se ordena por `depth = x + y` (orden de pintor isométrico). + +#![forbid(unsafe_code)] + +use dominium_core::World; +use dominium_iso::{IsoProjector, ZWeights}; +use serde::{Deserialize, Serialize}; + +/// Color RGBA lineal, componentes en `0.0..=1.0`. +pub type Color = [f32; 4]; + +/// Un rectángulo 2D en coordenadas de pantalla, listo para pintar. El +/// origen `(0,0)` es el centro de la proyección; el backend traslada. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Quad { + /// Esquina superior-izquierda, eje X de pantalla. + pub x: f32, + /// Esquina superior-izquierda, eje Y de pantalla. + pub y: f32, + /// Ancho en pixels. + pub w: f32, + /// Alto en pixels. + pub h: f32, + /// Color RGBA. + pub color: Color, + /// Clave de orden de pintor: menor = más al fondo. El plan ya viene + /// ordenado, pero se conserva por si el backend reordena. + pub depth: f32, +} + +/// Paleta: un color por capa de la grilla, más el de los Lemmings. El +/// color de cada celda es la mezcla de estos pesada por el valor relativo +/// de cada capa. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct Palette { + /// Color de una celda sin ningún campo (terreno desnudo). + pub floor: Color, + pub materia: Color, + pub psique: Color, + pub poder: Color, + pub oro: Color, + pub degradacion: Color, + /// Color de la marca de un Lemming. + pub lemming: Color, +} + +impl Default for Palette { + /// Paleta "tablero psicológico": verde materia, azul psique, rojo + /// poder, ámbar oro, violeta degradación. + fn default() -> Self { + Self { + floor: [0.10, 0.11, 0.13, 1.0], + materia: [0.30, 0.72, 0.38, 1.0], + psique: [0.32, 0.55, 0.86, 1.0], + poder: [0.84, 0.27, 0.24, 1.0], + oro: [0.90, 0.74, 0.24, 1.0], + degradacion: [0.52, 0.30, 0.62, 1.0], + lemming: [0.96, 0.96, 0.98, 1.0], + } + } +} + +/// Ajustes de la maqueta: tamaños de quad y paleta. Lo que un panel +/// expondría como controles de presentación (no afectan la simulación). +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct PlanConfig { + /// Lado del quad de una celda, en pixels. + pub tile: f32, + /// Lado del quad-marca de un Lemming, en pixels. + pub lemming_size: f32, + /// Cuánto se eleva la marca del Lemming sobre el relieve de su celda, + /// en unidades de `Z`. + pub lemming_lift: f32, + pub palette: Palette, +} + +impl Default for PlanConfig { + fn default() -> Self { + Self { + tile: 18.0, + lemming_size: 9.0, + lemming_lift: 0.6, + palette: Palette::default(), + } + } +} + +/// Lista de quads ordenada de atrás hacia adelante + caja envolvente. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RenderPlan { + /// Quads ya ordenados por `depth` ascendente: píntalos en orden. + pub quads: Vec, + /// Caja envolvente de todos los quads — el backend la usa para + /// centrar o escalar la vista. + pub min_x: f32, + pub min_y: f32, + pub max_x: f32, + pub max_y: f32, +} + +impl RenderPlan { + /// Ancho de la caja envolvente. + pub fn width(&self) -> f32 { + self.max_x - self.min_x + } + + /// Alto de la caja envolvente. + pub fn height(&self) -> f32 { + self.max_y - self.min_y + } +} + +/// Mezcla `n` colores con pesos: `Σ wᵢ·colorᵢ / Σ wᵢ`. Alpha del primero. +fn blend(parts: &[(f32, Color)]) -> Color { + let total: f32 = parts.iter().map(|(w, _)| *w).sum(); + if total <= f32::EPSILON { + return [0.0, 0.0, 0.0, 1.0]; + } + let mut out = [0.0f32; 4]; + for (w, c) in parts { + let k = w / total; + for ch in 0..3 { + out[ch] += k * c[ch]; + } + } + out[3] = 1.0; + out +} + +/// Color de una celda: mezcla de la paleta pesada por el valor relativo +/// de sus 5 capas. Una celda vacía cae al color `floor`. +fn cell_color(world: &World, idx: usize, pal: &Palette) -> Color { + let g = &world.grid; + let layers = [ + (g.materia[idx].max(0.0), pal.materia), + (g.psique[idx].max(0.0), pal.psique), + (g.poder[idx].max(0.0), pal.poder), + (g.oro[idx].max(0.0), pal.oro), + (g.degradacion[idx].max(0.0), pal.degradacion), + ]; + let total: f32 = layers.iter().map(|(v, _)| *v).sum(); + if total <= f32::EPSILON { + return pal.floor; + } + blend(&layers) +} + +/// Construye la maqueta isométrica de un `World`. +/// +/// Emite un quad por celda (coloreado por sus capas, elevado por el `Z` +/// compuesto de `weights`) y un quad-marca por Lemming vivo, posado sobre +/// el relieve de su celda. El resultado viene ordenado por profundidad de +/// pintor: el backend sólo recorre `plan.quads` y pinta. +pub fn build_plan( + world: &World, + iso: &IsoProjector, + weights: &ZWeights, + cfg: &PlanConfig, +) -> RenderPlan { + let g = &world.grid; + let mut quads: Vec = Vec::with_capacity(g.cells() + world.lemmings.len()); + + // --- Celdas: un quad-rombo aproximado por celda --- + for cy in 0..g.height { + for cx in 0..g.width { + let idx = g.idx(cx, cy); + let z = weights.z_of(g, idx); + let (sx, sy) = iso.project(cx as f32, cy as f32, z); + quads.push(Quad { + x: sx - cfg.tile * 0.5, + y: sy - cfg.tile * 0.5, + w: cfg.tile, + h: cfg.tile, + color: cell_color(world, idx, &cfg.palette), + depth: cx as f32 + cy as f32, + }); + } + } + + // --- Lemmings: una marca posada sobre el relieve de su celda --- + let lem = &world.lemmings; + for i in 0..lem.len() { + let (px, py) = (lem.pos_x[i], lem.pos_y[i]); + let (cx, cy) = g.clamp_cell(px, py); + let z = weights.z_of(g, g.idx(cx, cy)) + cfg.lemming_lift; + let (sx, sy) = iso.project(px, py, z); + quads.push(Quad { + x: sx - cfg.lemming_size * 0.5, + y: sy - cfg.lemming_size * 0.5, + w: cfg.lemming_size, + h: cfg.lemming_size, + color: cfg.palette.lemming, + // +0.5 → la marca se pinta después de su celda y de las + // celdas con su misma diagonal. + depth: px + py + 0.5, + }); + } + + // --- Orden de pintor: atrás (depth bajo) primero --- + quads.sort_by(|a, b| { + a.depth.partial_cmp(&b.depth).unwrap_or(core::cmp::Ordering::Equal) + }); + + // --- Caja envolvente --- + let mut plan = RenderPlan { quads, ..Default::default() }; + if let Some(first) = plan.quads.first() { + plan.min_x = first.x; + plan.min_y = first.y; + plan.max_x = first.x + first.w; + plan.max_y = first.y + first.h; + for q in &plan.quads { + plan.min_x = plan.min_x.min(q.x); + plan.min_y = plan.min_y.min(q.y); + plan.max_x = plan.max_x.max(q.x + q.w); + plan.max_y = plan.max_y.max(q.y + q.h); + } + } + plan +} + +#[cfg(test)] +mod tests { + use super::*; + + fn iso() -> IsoProjector { + IsoProjector::new(1.0, 10.0) + } + + #[test] + fn empty_world_yields_one_quad_per_cell() { + let world = World::new(5, 4); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + assert_eq!(plan.quads.len(), 20); + } + + #[test] + fn each_lemming_adds_a_quad() { + let mut world = World::new(8, 8); + world.lemmings.spawn(2.0, 3.0, 50.0, [1.0, 0.0, 0.0, 0.0]); + world.lemmings.spawn(5.0, 5.0, 50.0, [0.0, 1.0, 0.0, 0.0]); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + // 64 celdas + 2 marcas. + assert_eq!(plan.quads.len(), 66); + } + + #[test] + fn quads_are_depth_sorted_back_to_front() { + let mut world = World::new(6, 6); + world.lemmings.spawn(3.0, 3.0, 50.0, [0.0; 4]); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + for w in plan.quads.windows(2) { + assert!(w[0].depth <= w[1].depth, "deben ir de atrás hacia adelante"); + } + } + + #[test] + fn lemming_draws_after_its_cell() { + // Lemming en la celda (2,2): su marca (depth 4.5) debe ir tras la + // celda (2,2) (depth 4.0). + let mut world = World::new(6, 6); + world.lemmings.spawn(2.0, 2.0, 50.0, [0.0; 4]); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + let cfg = PlanConfig::default(); + let marca = plan + .quads + .iter() + .find(|q| q.w == cfg.lemming_size) + .expect("hay una marca"); + assert_eq!(marca.depth, 4.5); + } + + #[test] + fn empty_cell_uses_floor_color() { + let world = World::new(3, 3); + let cfg = PlanConfig::default(); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &cfg); + assert_eq!(plan.quads[0].color, cfg.palette.floor); + } + + #[test] + fn high_materia_cell_leans_green() { + // Sólo la celda (1,1) tiene campo → sólo ella escapa del color + // `floor`; el resto del tablero queda desnudo. + let mut world = World::new(3, 3); + let idx = world.grid.idx(1, 1); + world.grid.materia[idx] = 100.0; + let cfg = PlanConfig::default(); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &cfg); + let painted: Vec<_> = plan + .quads + .iter() + .filter(|q| q.w == cfg.tile && q.color != cfg.palette.floor) + .collect(); + assert_eq!(painted.len(), 1, "una sola celda con campo"); + assert_eq!(painted[0].color, cfg.palette.materia); + } + + #[test] + fn cell_color_blends_two_layers() { + let mut world = World::new(3, 3); + let idx = world.grid.idx(0, 0); + world.grid.materia[idx] = 50.0; + world.grid.poder[idx] = 50.0; + let pal = Palette::default(); + let c = cell_color(&world, idx, &pal); + // Mezcla 50/50 de verde materia y rojo poder → canal por canal. + for ch in 0..3 { + let expected = 0.5 * pal.materia[ch] + 0.5 * pal.poder[ch]; + assert!((c[ch] - expected).abs() < 1e-5); + } + } + + #[test] + fn bounding_box_encloses_every_quad() { + let mut world = World::new(7, 5); + world.lemmings.spawn(3.0, 2.0, 50.0, [0.0; 4]); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + for q in &plan.quads { + assert!(q.x >= plan.min_x - 1e-3); + assert!(q.y >= plan.min_y - 1e-3); + assert!(q.x + q.w <= plan.max_x + 1e-3); + assert!(q.y + q.h <= plan.max_y + 1e-3); + } + assert!(plan.width() > 0.0 && plan.height() > 0.0); + } + + #[test] + fn plan_is_deterministic() { + let mut world = World::new(10, 10); + world.lemmings.spawn(4.0, 6.0, 50.0, [0.5, 0.2, 0.1, 0.7]); + let idx = world.grid.idx(2, 2); + world.grid.materia[idx] = 33.0; + let a = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + let b = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + assert_eq!(a.quads, b.quads); + } + + #[test] + fn z_weights_raise_the_terrain() { + // Con materia alta y peso de relieve, la celda sube (menor y). + let mut world = World::new(3, 3); + let idx = world.grid.idx(1, 1); + world.grid.materia[idx] = 50.0; + let flat = build_plan( + &world, + &iso(), + &ZWeights { materia: 0.0, ..ZWeights::default() }, + &PlanConfig::default(), + ); + let raised = build_plan( + &world, + &iso(), + &ZWeights { materia: 1.0, ..ZWeights::default() }, + &PlanConfig::default(), + ); + let cfg = PlanConfig::default(); + // La celda (1,1) es la única con campo → la única coloreada + // `materia`; la identificamos por color, no por `depth`. + let pick = |p: &RenderPlan| { + p.quads + .iter() + .find(|q| q.w == cfg.tile && q.color == cfg.palette.materia) + .unwrap() + .y + }; + assert!(pick(&raised) < pick(&flat), "el relieve sube la celda"); + } +}