diff --git a/Cargo.lock b/Cargo.lock index 86a29c0..c403b21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3475,6 +3475,28 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "dominium" +version = "0.1.0" +dependencies = [ + "dominium-canvas-gpui", + "dominium-core", + "dominium-iso", + "dominium-physics", + "dominium-render-plan", + "gpui", + "nahual-launcher", + "nahual-theme", +] + +[[package]] +name = "dominium-canvas-gpui" +version = "0.1.0" +dependencies = [ + "dominium-render-plan", + "gpui", +] + [[package]] name = "dominium-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index bbd0998..fa87bfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,6 +149,7 @@ members = [ "crates/modules/dominium/dominium-physics", "crates/modules/dominium/dominium-iso", "crates/modules/dominium/dominium-render-plan", + "crates/modules/dominium/dominium-canvas-gpui", # ============================================================ # modules/gioser/ — Landing WASM (chacana + 4 elementos) @@ -223,6 +224,7 @@ members = [ "crates/apps/cosmobiologia", "crates/apps/cosmobiologia-cli", "crates/apps/cosmobiologia-server", + "crates/apps/dominium", ] [workspace.package] diff --git a/crates/apps/dominium/Cargo.toml b/crates/apps/dominium/Cargo.toml new file mode 100644 index 0000000..e61053b --- /dev/null +++ b/crates/apps/dominium/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "dominium" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "dominium — simulador psicológico de campo medio: ventana GPUI con maqueta isométrica viva, panel de estadísticas y bucle de simulación." + +[[bin]] +name = "dominium" +path = "src/main.rs" + +[dependencies] +dominium-core = { path = "../../modules/dominium/dominium-core" } +dominium-physics = { path = "../../modules/dominium/dominium-physics" } +dominium-iso = { path = "../../modules/dominium/dominium-iso" } +dominium-render-plan = { path = "../../modules/dominium/dominium-render-plan" } +dominium-canvas-gpui = { path = "../../modules/dominium/dominium-canvas-gpui" } +nahual-theme = { path = "../../modules/nahual/libs/theme" } +nahual-launcher = { path = "../../modules/nahual/libs/launcher" } +gpui = { workspace = true } diff --git a/crates/apps/dominium/src/main.rs b/crates/apps/dominium/src/main.rs new file mode 100644 index 0000000..627b6b5 --- /dev/null +++ b/crates/apps/dominium/src/main.rs @@ -0,0 +1,310 @@ +//! `dominium` — la ventana viva del simulador de campo medio. +//! +//! Compone toda la cadena de dominium en un app GPUI: +//! +//! ```text +//! dominium-core ─► dominium-physics ─► dominium-iso ─► +//! dominium-render-plan ─► dominium-canvas-gpui ─► [esta ventana] +//! ``` +//! +//! Un bucle de fondo avanza la simulación ~11 veces por segundo; cada +//! tick reconstruye la maqueta isométrica y la repinta. El panel +//! derecho muestra las estadísticas agregadas y dos controles +//! (play/pausa, re-sembrar). Cuando la población colapsa, el mundo se +//! re-siembra solo: la demo nunca se queda en negro. + +use std::time::Duration; + +use dominium_canvas_gpui::DominiumCanvas; +use dominium_core::{SimParams, World}; +use dominium_iso::{IsoProjector, ZWeights}; +use dominium_physics::tick; +use dominium_render_plan::{build_plan, PlanConfig}; +use gpui::{ + div, hsla, prelude::*, px, Context, IntoElement, Render, SharedString, Window, +}; +use nahual_launcher::launch_app; +use nahual_theme::Theme; + +/// Lado de la grilla cuadrada del mundo. +const GRID: usize = 40; +/// Población inicial de Lemmings. +const LEMMINGS: usize = 50; +/// Periodo del bucle de simulación. +const TICK_MS: u64 = 90; + +/// PRNG mínimo (LCG de 64 bits) — siembra reproducible sin dependencias. +struct Lcg(u64); + +impl Lcg { + fn new(seed: u64) -> Self { + Self(seed) + } + fn next_u32(&mut self) -> u32 { + // Constantes de Knuth (MMIX). + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (self.0 >> 33) as u32 + } + /// Flotante uniforme en `[0, 1)`. + fn next_f32(&mut self) -> f32 { + (self.next_u32() >> 8) as f32 / (1u32 << 24) as f32 + } +} + +/// Siembra un mundo: continentes de `materia`, vetas de `oro`, niebla de +/// `psique` y una población de Lemmings con sesgos y acciones variadas. +fn seed(seed: u64) -> World { + let mut w = World::new(GRID, GRID); + let mut rng = Lcg::new(seed); + for cy in 0..GRID { + for cx in 0..GRID { + let idx = w.grid.idx(cx, cy); + // m² concentra la materia en parches → aspecto de continentes. + let m = rng.next_f32(); + w.grid.materia[idx] = m * m * 60.0; + if rng.next_f32() > 0.92 { + w.grid.oro[idx] = rng.next_f32() * 40.0; + } + w.grid.psique[idx] = rng.next_f32() * 12.0; + } + } + for _ in 0..LEMMINGS { + let x = rng.next_f32() * (GRID as f32 - 1.0); + let y = rng.next_f32() * (GRID as f32 - 1.0); + let psi = [ + rng.next_f32(), + rng.next_f32(), + rng.next_f32(), + rng.next_f32(), + ]; + let i = w.lemmings.spawn(x, y, 30.0 + rng.next_f32() * 40.0, psi); + w.lemmings.accion[i] = (rng.next_u32() % 6) as u8; + } + w +} + +/// Estadísticas agregadas de un instante de la simulación. +struct Stats { + poblacion: usize, + materia: f32, + oro: f32, + energia: f32, +} + +/// El estado del simulador y su presentación. +struct Sim { + world: World, + params: SimParams, + iso: IsoProjector, + weights: ZWeights, + cfg: PlanConfig, + running: bool, + /// Ticks transcurridos en la época actual. + tick: u64, + /// Cuántas veces se re-sembró el mundo (colapso poblacional). + epoch: u64, + /// Semilla rodante para cada re-siembra. + rng_seed: u64, +} + +impl Sim { + fn new(cx: &mut Context) -> Self { + let rng_seed = 0xD0_31_31_07; + let sim = Self { + world: seed(rng_seed), + params: SimParams::default(), + iso: IsoProjector::new(12.0, 0.05), + weights: ZWeights::default(), + cfg: PlanConfig { + tile: 15.0, + lemming_size: 8.0, + lemming_lift: 0.7, + palette: Default::default(), + }, + running: true, + tick: 0, + epoch: 0, + rng_seed, + }; + sim.start_loop(cx); + sim + } + + /// Lanza el bucle de fondo que avanza la simulación. + fn start_loop(&self, cx: &mut Context) { + cx.spawn(async move |this, cx| loop { + cx.background_executor() + .timer(Duration::from_millis(TICK_MS)) + .await; + let alive = this.update(cx, |sim, cx| { + if sim.running { + sim.advance(); + cx.notify(); + } + }); + if alive.is_err() { + break; // la entidad murió → ventana cerrada. + } + }) + .detach(); + } + + /// Un paso de simulación; re-siembra si la población colapsa. + fn advance(&mut self) { + tick(&mut self.world, &self.params); + self.tick += 1; + if self.world.lemmings.is_empty() { + self.epoch += 1; + self.rng_seed = self.rng_seed.wrapping_mul(2862933555777941757).wrapping_add(1); + self.world = seed(self.rng_seed); + self.tick = 0; + } + } + + /// Re-siembra el mundo a mano (botón ↺). + fn reseed(&mut self) { + self.rng_seed = self.rng_seed.wrapping_add(0x9E3779B9); + self.world = seed(self.rng_seed); + self.tick = 0; + self.epoch += 1; + } + + /// Calcula las estadísticas del instante actual. + fn stats(&self) -> Stats { + let g = &self.world.grid; + Stats { + poblacion: self.world.lemmings.len(), + materia: g.materia.iter().sum(), + oro: g.oro.iter().sum(), + energia: self.world.lemmings.energia.iter().sum(), + } + } +} + +/// Fila etiqueta/valor del panel de estadísticas. +fn stat_row(label: &str, value: String, theme: &Theme) -> impl IntoElement { + div() + .flex() + .flex_row() + .justify_between() + .child(div().text_color(theme.fg_muted).child(SharedString::from(label.to_string()))) + .child(div().text_color(theme.fg_text).child(SharedString::from(value))) +} + +impl Render for Sim { + fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::global(cx).clone(); + let panel = hsla(220.0 / 360.0, 0.18, 0.10, 1.0); + let chip = hsla(220.0 / 360.0, 0.16, 0.16, 1.0); + let canvas_bg = hsla(220.0 / 360.0, 0.22, 0.06, 1.0); + let accent = theme.accent; + let stats = self.stats(); + + // --- Barra de estado --- + let estado = if self.running { "● corriendo" } else { "‖ en pausa" }; + let status = div() + .h(px(34.)) + .flex() + .flex_row() + .items_center() + .justify_between() + .px(px(14.)) + .bg(panel) + .text_color(theme.fg_text) + .child(SharedString::from(format!( + "dominium · campo medio · época {} · tick {}", + self.epoch, self.tick + ))) + .child(div().text_color(accent).child(SharedString::from(estado.to_string()))); + + // --- Maqueta isométrica --- + let plan = build_plan(&self.world, &self.iso, &self.weights, &self.cfg); + let canvas = div() + .flex_1() + .overflow_hidden() + .child(DominiumCanvas::new(plan).background(canvas_bg)); + + // --- Botones de control --- + let play_label = if self.running { "‖ Pausar" } else { "▶ Reanudar" }; + let play = div() + .id("play") + .px(px(10.)) + .py(px(7.)) + .bg(chip) + .rounded(px(5.)) + .text_color(theme.fg_text) + .cursor_pointer() + .hover(|s| s.bg(theme.bg_row_hover)) + .child(SharedString::from(play_label.to_string())) + .on_click(cx.listener(|sim, _ev, _w, cx| { + sim.running = !sim.running; + cx.notify(); + })); + let reset = div() + .id("reset") + .px(px(10.)) + .py(px(7.)) + .bg(chip) + .rounded(px(5.)) + .text_color(theme.fg_text) + .cursor_pointer() + .hover(|s| s.bg(theme.bg_row_hover)) + .child("↺ Re-sembrar") + .on_click(cx.listener(|sim, _ev, _w, cx| { + sim.reseed(); + cx.notify(); + })); + + // --- Panel de estadísticas --- + let side = div() + .w(px(216.)) + .flex() + .flex_col() + .gap(px(10.)) + .p(px(12.)) + .bg(panel) + .text_color(theme.fg_text) + .child(div().text_color(theme.fg_muted).child("[SIM]")) + .child(play) + .child(reset) + .child(div().h(px(1.)).bg(theme.border)) + .child(stat_row("Población", format!("{}", stats.poblacion), &theme)) + .child(stat_row("Materia", format!("{:.0}", stats.materia), &theme)) + .child(stat_row("Oro", format!("{:.0}", stats.oro), &theme)) + .child(stat_row("Energía", format!("{:.0}", stats.energia), &theme)) + .child(div().h(px(1.)).bg(theme.border)) + .child( + div() + .text_color(theme.fg_muted) + .child(SharedString::from(format!("grilla {GRID}×{GRID}"))), + ) + .child( + div() + .text_color(theme.fg_muted) + .child("relieve = materia (Z)"), + ); + + // --- Composición --- + div() + .size_full() + .flex() + .flex_col() + .bg(theme.bg_app) + .child(status) + .child( + div() + .flex() + .flex_row() + .flex_1() + .child(canvas) + .child(side), + ) + } +} + +fn main() { + launch_app("brahman · dominium", (1120., 720.), Sim::new); +} diff --git a/crates/modules/dominium/dominium-canvas-gpui/Cargo.toml b/crates/modules/dominium/dominium-canvas-gpui/Cargo.toml new file mode 100644 index 0000000..31764c3 --- /dev/null +++ b/crates/modules/dominium/dominium-canvas-gpui/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "dominium-canvas-gpui" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "dominium — backend GPUI: un Element que pinta un RenderPlan isométrico como quads, centrado en sus bounds. El único crate de dominium que toca gpui." + +[dependencies] +dominium-render-plan = { path = "../dominium-render-plan" } +gpui = { workspace = true } diff --git a/crates/modules/dominium/dominium-canvas-gpui/src/lib.rs b/crates/modules/dominium/dominium-canvas-gpui/src/lib.rs new file mode 100644 index 0000000..7729170 --- /dev/null +++ b/crates/modules/dominium/dominium-canvas-gpui/src/lib.rs @@ -0,0 +1,174 @@ +//! `dominium-canvas-gpui` — el único crate de dominium que importa `gpui`. +//! +//! Toda la cadena `dominium-core → physics → iso → render-plan` es +//! agnóstica de backend. Este crate cierra el circuito: un [`Element`] +//! de GPUI que recibe un [`RenderPlan`] ya resuelto y lo vuelca a +//! `paint_quad`, centrando la maqueta en los bounds disponibles. +//! +//! Si mañana el frontend fuera web o TUI, se escribe un +//! `dominium-canvas-web` hermano sin tocar una línea del núcleo. + +#![forbid(unsafe_code)] + +use std::panic; + +use dominium_render_plan::{Color, RenderPlan}; +use gpui::{ + fill, hsla, point, px, size, App, Bounds, Element, ElementId, GlobalElementId, Hsla, + InspectorElementId, IntoElement, LayoutId, Pixels, Style, Window, +}; + +/// Convierte un color RGBA lineal (`[f32;4]`) a `Hsla`, que es lo que +/// GPUI consume. Misma convención de conversión que el backend de +/// `pineal` — sin gamma. +pub fn rgba_to_hsla(c: Color) -> Hsla { + let (r, g, b, a) = (c[0], c[1], c[2], c[3]); + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) * 0.5; + let delta = max - min; + if delta.abs() < 1e-6 { + return hsla(0.0, 0.0, l, a); + } + let s = if l < 0.5 { + delta / (max + min) + } else { + delta / (2.0 - max - min) + }; + let h = if max == r { + ((g - b) / delta).rem_euclid(6.0) + } else if max == g { + (b - r) / delta + 2.0 + } else { + (r - g) / delta + 4.0 + }; + hsla(h / 6.0, s, l, a) +} + +/// `Element` GPUI que pinta una maqueta isométrica. +/// +/// Construir uno nuevo en cada `render()` del host con el `RenderPlan` +/// del frame actual — el Element no guarda estado entre frames. +pub struct DominiumCanvas { + plan: RenderPlan, + background: Option, +} + +impl DominiumCanvas { + /// Envuelve un `RenderPlan` listo para pintar. + pub fn new(plan: RenderPlan) -> Self { + Self { plan, background: None } + } + + /// Pinta un fondo sólido antes de los quads. + pub fn background(mut self, color: Hsla) -> Self { + self.background = Some(color); + self + } +} + +impl IntoElement for DominiumCanvas { + type Element = Self; + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for DominiumCanvas { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0)); + style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0)); + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _layout: &mut Self::RequestLayoutState, + _window: &mut Window, + _cx: &mut App, + ) -> Self::PrepaintState { + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + _cx: &mut App, + ) { + let ox: f32 = bounds.origin.x.into(); + let oy: f32 = bounds.origin.y.into(); + let bw: f32 = bounds.size.width.into(); + let bh: f32 = bounds.size.height.into(); + + if let Some(bg) = self.background { + window.paint_quad(fill(bounds, bg)); + } + + // Centra la maqueta: el centro de la caja envolvente del plan + // se alinea con el centro de los bounds del Element. + let plan_cx = (self.plan.min_x + self.plan.max_x) * 0.5; + let plan_cy = (self.plan.min_y + self.plan.max_y) * 0.5; + let off_x = ox + bw * 0.5 - plan_cx; + let off_y = oy + bh * 0.5 - plan_cy; + + // Los quads ya vienen ordenados de atrás hacia adelante. + for q in &self.plan.quads { + let rect = Bounds { + origin: point(px(q.x + off_x), px(q.y + off_y)), + size: size(px(q.w), px(q.h)), + }; + window.paint_quad(fill(rect, rgba_to_hsla(q.color))); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pure_red_maps_to_hue_zero() { + let h = rgba_to_hsla([1.0, 0.0, 0.0, 1.0]); + assert!((h.h - 0.0).abs() < 1e-6); + assert!((h.s - 1.0).abs() < 1e-6); + assert!((h.l - 0.5).abs() < 1e-6); + } + + #[test] + fn grey_has_zero_saturation() { + let h = rgba_to_hsla([0.4, 0.4, 0.4, 0.8]); + assert!((h.s - 0.0).abs() < 1e-6); + assert!((h.a - 0.8).abs() < 1e-6); + } + + #[test] + fn alpha_passes_through() { + let h = rgba_to_hsla([0.0, 0.0, 1.0, 0.25]); + assert!((h.a - 0.25).abs() < 1e-6); + } +}