commit 68f6292c4d1de0392484c3e521a194e1ed8cb030 Author: Emil Date: Sat Jun 20 18:21:26 2026 +0300 feat: Verbatim MVP - terminal renderer, cellular automaton, Verlet physics - World: 250x250 grid with 14 materials (sand, water, lava, stone, wood, etc.) - Physics: cellular automaton for materials + Verlet solver for entities - Entity: multi-cell humanoid (7 sub-bodies with distance constraints) - Render: terminal renderer with ANSI colors and diff-based updates - Game loop: fixed 60Hz timestep with accumulator pattern - Input: WASD movement, number keys for material painting diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ff1a06b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "verbatim" +version = "0.1.0" +edition = "2024" + +[dependencies] +crossterm = "0.28" +clap = { version = "4", features = ["derive"] } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 diff --git a/assets/DejaVuSansMono.ttf b/assets/DejaVuSansMono.ttf new file mode 100644 index 0000000..67ce048 Binary files /dev/null and b/assets/DejaVuSansMono.ttf differ diff --git a/src/entity/entity.rs b/src/entity/entity.rs new file mode 100644 index 0000000..9d53960 --- /dev/null +++ b/src/entity/entity.rs @@ -0,0 +1,195 @@ +use crate::physics::verlet::{SubBody, Constraint}; +use crate::world::cell::MaterialId; + +pub type EntityId = u32; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum EntityKind { + Player, + Goblin, + Corpse, +} + +pub struct Entity { + pub id: EntityId, + pub kind: EntityKind, + pub bodies: Vec, + pub constraints: Vec, + pub alive: bool, + pub health: f32, + pub max_health: f32, + pub constraint_stiffness: f32, + pub on_fire: bool, + pub fire_timer: u32, +} + +impl Entity { + pub fn new(id: EntityId, kind: EntityKind) -> Self { + Self { + id, + kind, + bodies: Vec::new(), + constraints: Vec::new(), + alive: true, + health: 100.0, + max_health: 100.0, + constraint_stiffness: 1.0, + on_fire: false, + fire_timer: 0, + } + } + + pub fn center(&self) -> (f32, f32) { + if self.bodies.is_empty() { + return (0.0, 0.0); + } + let mut sx = 0.0; + let mut sy = 0.0; + let mut n = 0; + for b in &self.bodies { + if b.alive { + sx += b.x; + sy += b.y; + n += 1; + } + } + if n > 0 { + (sx / n as f32, sy / n as f32) + } else { + (self.bodies[0].x, self.bodies[0].y) + } + } + + pub fn head(&self) -> Option<&SubBody> { + self.bodies.first() + } + + pub fn head_mut(&mut self) -> Option<&mut SubBody> { + self.bodies.first_mut() + } + + pub fn kill(&mut self) { + self.alive = false; + self.health = 0.0; + self.constraint_stiffness = 0.0; + for c in &mut self.constraints { + c.stiffness = 0.0; + } + if self.kind == EntityKind::Player || self.kind == EntityKind::Goblin { + self.kind = EntityKind::Corpse; + } + } + + pub fn take_damage(&mut self, dmg: f32) { + self.health -= dmg; + if self.health <= 0.0 && self.alive { + self.kill(); + } + } + + pub fn build_humanoid(&mut self, cx: f32, cy: f32) { + self.bodies.clear(); + self.constraints.clear(); + + let r = 0.4; + let mat = match self.kind { + EntityKind::Player => MaterialId::Flesh, + EntityKind::Goblin => MaterialId::Flesh, + EntityKind::Corpse => MaterialId::Flesh, + }; + + self.bodies.push(SubBody::new(cx, cy - 2.0, r, mat)); + self.bodies.push(SubBody::new(cx, cy - 1.0, r, mat)); + self.bodies.push(SubBody::new(cx - 0.8, cy - 1.0, r, mat)); + self.bodies.push(SubBody::new(cx + 0.8, cy - 1.0, r, mat)); + self.bodies.push(SubBody::new(cx - 0.5, cy, r, mat)); + self.bodies.push(SubBody::new(cx + 0.5, cy, r, mat)); + self.bodies.push(SubBody::new(cx, cy + 1.0, r, MaterialId::Bone)); + + let s = self.constraint_stiffness; + let mk = |a: usize, b: usize, len: f32| Constraint::new(a, b, len, s); + + self.constraints.push(mk(0, 1, 1.0)); + self.constraints.push(mk(1, 2, 0.9)); + self.constraints.push(mk(1, 3, 0.9)); + self.constraints.push(mk(2, 4, 0.9)); + self.constraints.push(mk(3, 5, 0.9)); + self.constraints.push(mk(1, 6, 2.0)); + self.constraints.push(mk(4, 5, 1.0)); + self.constraints.push(mk(4, 6, 1.1)); + self.constraints.push(mk(5, 6, 1.1)); + } + + pub fn apply_fire_damage(&mut self) { + if !self.on_fire { + return; + } + self.fire_timer += 1; + let dmg = 0.5; + self.take_damage(dmg); + for b in &mut self.bodies { + if b.alive { + b.health -= dmg; + } + } + if self.fire_timer > 180 { + self.on_fire = false; + self.fire_timer = 0; + } + } +} + +pub struct EntityManager { + entities: Vec, + next_id: EntityId, +} + +impl EntityManager { + pub fn new() -> Self { + Self { + entities: Vec::new(), + next_id: 0, + } + } + + pub fn spawn(&mut self, kind: EntityKind) -> EntityId { + let id = self.next_id; + self.next_id += 1; + let mut e = Entity::new(id, kind); + match kind { + EntityKind::Player => { + e.max_health = 100.0; + e.health = 100.0; + } + EntityKind::Goblin => { + e.max_health = 40.0; + e.health = 40.0; + } + EntityKind::Corpse => { + e.alive = false; + } + } + self.entities.push(e); + id + } + + pub fn get(&self, id: EntityId) -> Option<&Entity> { + self.entities.iter().find(|e| e.id == id) + } + + pub fn get_mut(&mut self, id: EntityId) -> Option<&mut Entity> { + self.entities.iter_mut().find(|e| e.id == id) + } + + pub fn all(&self) -> &[Entity] { + &self.entities + } + + pub fn all_mut(&mut self) -> &mut [Entity] { + &mut self.entities + } + + pub fn iter_mut(&mut self) -> impl Iterator { + self.entities.iter_mut() + } +} diff --git a/src/entity/mod.rs b/src/entity/mod.rs new file mode 100644 index 0000000..0431bb3 --- /dev/null +++ b/src/entity/mod.rs @@ -0,0 +1,5 @@ +pub mod entity; +pub mod player; + +pub use entity::{Entity, EntityId, EntityManager, EntityKind}; +pub use player::Player; diff --git a/src/entity/player.rs b/src/entity/player.rs new file mode 100644 index 0000000..65938dd --- /dev/null +++ b/src/entity/player.rs @@ -0,0 +1,64 @@ +use crate::entity::entity::{Entity, EntityKind, EntityManager, EntityId}; + +pub struct Player { + pub entity_id: EntityId, + pub move_speed: f32, + pub jump_force: f32, +} + +impl Player { + pub fn new(manager: &mut EntityManager) -> Self { + let id = manager.spawn(EntityKind::Player); + Self { + entity_id: id, + move_speed: 0.15, + jump_force: 0.8, + } + } + + pub fn spawn_at(&self, manager: &mut EntityManager, cx: f32, cy: f32) { + if let Some(e) = manager.get_mut(self.entity_id) { + e.build_humanoid(cx, cy); + } + } + + pub fn move_left(&self, manager: &mut EntityManager) { + if let Some(e) = manager.get_mut(self.entity_id) { + if let Some(head) = e.head_mut() { + head.add_vel(-self.move_speed, 0.0); + } + } + } + + pub fn move_right(&self, manager: &mut EntityManager) { + if let Some(e) = manager.get_mut(self.entity_id) { + if let Some(head) = e.head_mut() { + head.add_vel(self.move_speed, 0.0); + } + } + } + + pub fn jump(&self, manager: &mut EntityManager, on_ground: bool) { + if on_ground { + if let Some(e) = manager.get_mut(self.entity_id) { + for b in &mut e.bodies { + if b.alive { + b.add_vel(0.0, -self.jump_force); + } + } + } + } + } + + pub fn entity<'a>(&self, manager: &'a EntityManager) -> Option<&'a Entity> { + manager.get(self.entity_id) + } + + pub fn entity_mut<'a>(&self, manager: &'a mut EntityManager) -> Option<&'a mut Entity> { + manager.get_mut(self.entity_id) + } + + pub fn center(&self, manager: &EntityManager) -> (f32, f32) { + manager.get(self.entity_id).map(|e| e.center()).unwrap_or((0.0, 0.0)) + } +} diff --git a/src/game.rs b/src/game.rs new file mode 100644 index 0000000..8a711c6 --- /dev/null +++ b/src/game.rs @@ -0,0 +1,311 @@ +use std::time::{Duration, Instant}; + +use crate::entity::{EntityManager, EntityKind}; +use crate::input::{Action, InputHandler}; +use crate::physics::verlet::VerletSolver; +use crate::physics::collision::resolve_grid_collision; +use crate::render::Renderer; +use crate::world::cell::MaterialId; +use crate::world::grid::Grid; +use crate::world::cellular::CellularAutomaton; +use crate::entity::player::Player; + +pub struct Game { + pub grid: Grid, + pub ca: CellularAutomaton, + pub verlet: VerletSolver, + pub entities: EntityManager, + pub player: Player, + pub input: InputHandler, + pub cam_x: i32, + pub cam_y: i32, + pub running: bool, + pub tick: u64, + pub fixed_dt: Duration, + pub accumulator: Duration, + pub last_time: Instant, +} + +impl Game { + pub fn new() -> Self { + let mut entities = EntityManager::new(); + let player = Player::new(&mut entities); + Self { + grid: Grid::new(), + ca: CellularAutomaton::new(), + verlet: VerletSolver::new(), + entities, + player, + input: InputHandler::new(), + cam_x: 100, + cam_y: 100, + running: true, + tick: 0, + fixed_dt: Duration::from_millis(16), + accumulator: Duration::ZERO, + last_time: Instant::now(), + } + } + + pub fn init_world(&mut self) { + let w = self.grid.width; + let h = self.grid.height; + + for x in 0..w { + self.grid.set_material(x as i32, (h - 1) as i32, MaterialId::Stone); + self.grid.set_material(x as i32, (h - 2) as i32, MaterialId::Dirt); + } + + for x in 0..w { + let surface = (h as i32 - 3) - ((x as f32 * 0.1).sin() * 5.0) as i32; + let surface = surface.max(10).min(h as i32 - 3); + for y in surface..(h as i32 - 2) { + if y == surface { + self.grid.set_material(x as i32, y, MaterialId::Grass); + } else { + self.grid.set_material(x as i32, y, MaterialId::Dirt); + } + } + } + + let cx = (w / 2) as f32; + let cy = (h as f32 / 2.0) - 20.0; + self.player.spawn_at(&mut self.entities, cx, cy); + + let (px, py) = self.player.center(&self.entities); + self.center_camera_on(px, py); + + self.grid.fill_border(MaterialId::Stone); + } + + fn center_camera_on(&mut self, px: f32, py: f32) { + self.cam_x = px as i32 - 40; + self.cam_y = py as i32 - 12; + } + + pub fn run(&mut self, renderer: &mut R) { + let _ = renderer.init(); + self.init_world(); + + self.last_time = Instant::now(); + + while self.running { + let now = Instant::now(); + let frame_time = now.duration_since(self.last_time); + self.last_time = now; + self.accumulator += frame_time; + + while self.accumulator >= self.fixed_dt { + self.fixed_update(); + self.accumulator -= self.fixed_dt; + } + + let (px, py) = self.player.center(&self.entities); + self.center_camera_on(px, py); + + let _ = renderer.render(&self.grid, &self.entities, self.cam_x, self.cam_y); + + self.handle_input(renderer.viewport_w(), renderer.viewport_h()); + } + + let _ = renderer.shutdown(); + } + + fn handle_input(&mut self, vw: usize, vh: usize) { + let action = self.input.poll(); + match action { + Action::Quit => self.running = false, + Action::MoveLeft => self.player.move_left(&mut self.entities), + Action::MoveRight => self.player.move_right(&mut self.entities), + Action::Jump => { + let on_ground = self.check_on_ground(); + self.player.jump(&mut self.entities, on_ground); + } + Action::MoveCameraLeft => self.cam_x -= 5, + Action::MoveCameraRight => self.cam_x += 5, + Action::MoveCameraUp => self.cam_y -= 5, + Action::MoveCameraDown => self.cam_y += 5, + Action::Paint(brush) => { + let mat = brush.to_material(); + let cx = self.cam_x + (vw as i32 / 2); + let cy = self.cam_y + (vh as i32 / 2); + let r = 2; + for dy in -r..=r { + for dx in -r..=r { + if dx * dx + dy * dy <= r * r + 1 { + if let Some(m) = mat { + self.grid.set_material(cx + dx, cy + dy, m); + } else { + self.grid.set(cx + dx, cy + dy, crate::world::cell::Cell::empty()); + } + } + } + } + } + Action::None => {} + } + } + + fn check_on_ground(&self) -> bool { + if let Some(e) = self.player.entity(&self.entities) { + for b in &e.bodies { + if !b.alive { + continue; + } + let bx = b.x as i32; + let by = b.y as i32; + let below = self.grid.get(bx, by + 1); + if below.is_solid() { + return true; + } + } + } + false + } + + fn fixed_update(&mut self) { + self.tick += 1; + + self.ca.step(&mut self.grid); + + self.update_entities(); + + self.apply_world_damage(); + + if self.tick % 30 == 0 { + self.try_spawn_goblin(); + } + } + + fn update_entities(&mut self) { + let solver = self.verlet.clone(); + let grid = &self.grid; + + let entities_data: Vec<(usize, Vec, Vec)>; + entities_data = { + let mut data = Vec::new(); + for (i, e) in self.entities.all().iter().enumerate() { + data.push((i, e.bodies.clone(), e.constraints.clone())); + } + data + }; + + for (idx, mut bodies, constraints) in entities_data { + for b in &mut bodies { + if !b.alive { + continue; + } + + if b.on_fire { + b.fire_timer += 1; + b.health -= 0.3; + if b.fire_timer > 120 { + b.on_fire = false; + b.fire_timer = 0; + } + } + } + + solver.integrate(&mut bodies); + + for b in &mut bodies { + if !b.alive { + continue; + } + let result = resolve_grid_collision(grid, b); + if result.touching_lava { + b.health -= 2.0; + if !b.on_fire { + b.on_fire = true; + } + } + if result.touching_fire { + b.health -= 0.5; + if !b.on_fire && b.health < 80.0 { + b.on_fire = true; + } + } + if result.touching_acid { + b.health -= 1.0; + } + if result.in_liquid { + let body_density = crate::world::material::MaterialRegistry::instance() + .get(b.material).density; + if body_density > result.liquid_density { + b.y += 0.02; + } + } + } + + solver.solve_constraints(&mut bodies, &constraints, 3); + + if let Some(e) = self.entities.all_mut().get_mut(idx) { + e.bodies = bodies; + + let mut total_health = 0.0; + let mut alive_count = 0; + for b in &e.bodies { + if b.alive { + total_health += b.health; + alive_count += 1; + } + } + if alive_count > 0 { + let avg = total_health / alive_count as f32; + if avg < 0.0 && e.alive { + e.kill(); + } + } + + let any_on_fire = e.bodies.iter().any(|b| b.alive && b.on_fire); + e.on_fire = any_on_fire; + if e.on_fire { + e.apply_fire_damage(); + } + } + } + } + + fn apply_world_damage(&mut self) { + let mut to_kill: Vec = Vec::new(); + for (i, e) in self.entities.all().iter().enumerate() { + if !e.alive { + continue; + } + let mut dead_parts = 0; + for b in &e.bodies { + if !b.alive || b.health <= 0.0 { + dead_parts += 1; + } + } + if dead_parts == e.bodies.len() { + to_kill.push(i); + } + } + for i in to_kill { + if let Some(e) = self.entities.all_mut().get_mut(i) { + e.kill(); + } + } + } + + fn try_spawn_goblin(&mut self) { + let alive_goblins = self.entities.all().iter().filter(|e| e.alive && e.kind == EntityKind::Goblin).count(); + if alive_goblins >= 3 { + return; + } + + let (px, py) = self.player.center(&self.entities); + let spawn_x = px as i32 + if px as i32 % 2 == 0 { 15 } else { -15 }; + let spawn_y = py as i32 - 5; + + if !self.grid.in_bounds(spawn_x, spawn_y) { + return; + } + + let id = self.entities.spawn(EntityKind::Goblin); + if let Some(g) = self.entities.get_mut(id) { + g.build_humanoid(spawn_x as f32, spawn_y as f32); + } + } +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..fa46edd --- /dev/null +++ b/src/input.rs @@ -0,0 +1,112 @@ +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use std::time::Duration; + +pub enum Action { + MoveLeft, + MoveRight, + Jump, + MoveCameraUp, + MoveCameraDown, + MoveCameraLeft, + MoveCameraRight, + Paint(MaterialBrush), + Quit, + None, +} + +pub enum MaterialBrush { + Sand, + Water, + Stone, + Lava, + Wood, + Acid, + Grass, + Dirt, + Fire, + Flesh, + Erase, +} + +pub struct InputHandler { + pub paint_brush: MaterialBrush, +} + +impl InputHandler { + pub fn new() -> Self { + Self { + paint_brush: MaterialBrush::Sand, + } + } + + pub fn poll(&mut self) -> Action { + if !event::poll(Duration::from_millis(0)).unwrap_or(false) { + return Action::None; + } + match event::read() { + Ok(Event::Key(KeyEvent { code, modifiers, .. })) => { + if modifiers.contains(KeyModifiers::CONTROL) && code == KeyCode::Char('c') { + return Action::Quit; + } + match code { + KeyCode::Char('q') => Action::Quit, + KeyCode::Left | KeyCode::Char('a') => Action::MoveLeft, + KeyCode::Right | KeyCode::Char('d') => Action::MoveRight, + KeyCode::Up | KeyCode::Char('w') | KeyCode::Char(' ') => Action::Jump, + KeyCode::Char('h') => Action::MoveCameraLeft, + KeyCode::Char('l') => Action::MoveCameraRight, + KeyCode::Char('k') => Action::MoveCameraUp, + KeyCode::Char('j') => Action::MoveCameraDown, + KeyCode::Char('1') => { self.paint_brush = MaterialBrush::Sand; Action::Paint(MaterialBrush::Sand) } + KeyCode::Char('2') => { self.paint_brush = MaterialBrush::Water; Action::Paint(MaterialBrush::Water) } + KeyCode::Char('3') => { self.paint_brush = MaterialBrush::Stone; Action::Paint(MaterialBrush::Stone) } + KeyCode::Char('4') => { self.paint_brush = MaterialBrush::Lava; Action::Paint(MaterialBrush::Lava) } + KeyCode::Char('5') => { self.paint_brush = MaterialBrush::Wood; Action::Paint(MaterialBrush::Wood) } + KeyCode::Char('6') => { self.paint_brush = MaterialBrush::Acid; Action::Paint(MaterialBrush::Acid) } + KeyCode::Char('7') => { self.paint_brush = MaterialBrush::Grass; Action::Paint(MaterialBrush::Grass) } + KeyCode::Char('8') => { self.paint_brush = MaterialBrush::Dirt; Action::Paint(MaterialBrush::Dirt) } + KeyCode::Char('9') => { self.paint_brush = MaterialBrush::Fire; Action::Paint(MaterialBrush::Fire) } + KeyCode::Char('0') => { self.paint_brush = MaterialBrush::Flesh; Action::Paint(MaterialBrush::Flesh) } + KeyCode::Char('x') => { self.paint_brush = MaterialBrush::Erase; Action::Paint(MaterialBrush::Erase) } + _ => Action::None, + } + } + _ => Action::None, + } + } +} + +impl MaterialBrush { + pub fn to_material(&self) -> Option { + use crate::world::cell::MaterialId; + match self { + MaterialBrush::Sand => Some(MaterialId::Sand), + MaterialBrush::Water => Some(MaterialId::Water), + MaterialBrush::Stone => Some(MaterialId::Stone), + MaterialBrush::Lava => Some(MaterialId::Lava), + MaterialBrush::Wood => Some(MaterialId::Wood), + MaterialBrush::Acid => Some(MaterialId::Acid), + MaterialBrush::Grass => Some(MaterialId::Grass), + MaterialBrush::Dirt => Some(MaterialId::Dirt), + MaterialBrush::Fire => Some(MaterialId::Fire), + MaterialBrush::Flesh => Some(MaterialId::Flesh), + MaterialBrush::Erase => None, + } + } + + pub fn name(&self) -> &'static str { + match self { + MaterialBrush::Sand => "Sand", + MaterialBrush::Water => "Water", + MaterialBrush::Stone => "Stone", + MaterialBrush::Lava => "Lava", + MaterialBrush::Wood => "Wood", + MaterialBrush::Acid => "Acid", + MaterialBrush::Grass => "Grass", + MaterialBrush::Dirt => "Dirt", + MaterialBrush::Fire => "Fire", + MaterialBrush::Flesh => "Flesh", + MaterialBrush::Erase => "Erase", + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..dd0fb5a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,37 @@ +mod world; +mod physics; +mod entity; +mod render; +mod input; +mod game; + +use clap::Parser; +use game::Game; +use render::terminal::TerminalRenderer; + +#[derive(Parser, Debug)] +#[command(name = "verbatim", about = "ASCII physics RPG - Noita meets Caves of Qud")] +struct Cli { + #[arg(long, default_value = "terminal")] + render_mode: String, +} + +fn main() { + let cli = Cli::parse(); + + match cli.render_mode.as_str() { + "terminal" => { + let mut renderer = TerminalRenderer::new(); + let mut game = Game::new(); + game.run(&mut renderer); + } + "vulkan" => { + eprintln!("Vulkan renderer not yet implemented. Use --render-mode terminal."); + std::process::exit(1); + } + _ => { + eprintln!("Unknown render mode: {}. Use 'terminal' or 'vulkan'.", cli.render_mode); + std::process::exit(1); + } + } +} diff --git a/src/physics/collision.rs b/src/physics/collision.rs new file mode 100644 index 0000000..cd44535 --- /dev/null +++ b/src/physics/collision.rs @@ -0,0 +1,106 @@ +use crate::world::cell::MaterialId; +use crate::world::grid::Grid; +use crate::physics::verlet::SubBody; + +pub struct CollisionResult { + pub on_ground: bool, + pub in_liquid: bool, + pub liquid_density: f32, + pub touching_lava: bool, + pub touching_fire: bool, + pub touching_acid: bool, +} + +impl CollisionResult { + pub fn none() -> Self { + Self { + on_ground: false, + in_liquid: false, + liquid_density: 0.0, + touching_lava: false, + touching_fire: false, + touching_acid: false, + } + } +} + +pub fn resolve_grid_collision(grid: &Grid, body: &mut SubBody) -> CollisionResult { + let mut result = CollisionResult::none(); + + let r = body.radius; + let min_x = (body.x - r).floor() as i32; + let max_x = (body.x + r).ceil() as i32; + let min_y = (body.y - r).floor() as i32; + let max_y = (body.y + r).ceil() as i32; + + for cy in min_y..=max_y { + for cx in min_x..=max_x { + if !grid.in_bounds(cx, cy) { + continue; + } + let cell = grid.get(cx, cy); + if cell.is_empty() { + continue; + } + + if cell.is_liquid() { + result.in_liquid = true; + result.liquid_density = result.liquid_density.max(cell.density()); + if cell.material == MaterialId::Lava { + result.touching_lava = true; + } + if cell.material == MaterialId::Acid { + result.touching_acid = true; + } + apply_liquid_drag(body, cell.density()); + continue; + } + + if cell.material == MaterialId::Fire { + result.touching_fire = true; + continue; + } + + if cell.is_solid() { + let closest_x = body.x.max(cx as f32).min((cx + 1) as f32); + let closest_y = body.y.max(cy as f32).min((cy + 1) as f32); + let dx = body.x - closest_x; + let dy = body.y - closest_y; + let dist_sq = dx * dx + dy * dy; + if dist_sq < r * r { + let dist = dist_sq.sqrt(); + if dist > 0.0001 { + let overlap = r - dist; + let nx = dx / dist; + let ny = dy / dist; + body.x += nx * overlap; + body.y += ny * overlap; + if ny < -0.5 { + result.on_ground = true; + } + } else { + let bcx = cx as f32 + 0.5; + let bcy = cy as f32 + 0.5; + let dx = body.x - bcx; + let dy = body.y - bcy; + let dist = (dx * dx + dy * dy).sqrt(); + if dist > 0.0001 { + body.x = bcx + dx / dist * r * 1.1; + body.y = bcy + dy / dist * r * 1.1; + } + } + } + } + } + } + + result +} + +fn apply_liquid_drag(body: &mut SubBody, density: f32) { + let drag = 1.0 - density * 0.08; + let drag = drag.max(0.5); + let vx = body.vx() * drag; + let vy = body.vy() * drag; + body.set_vel(vx, vy); +} diff --git a/src/physics/mod.rs b/src/physics/mod.rs new file mode 100644 index 0000000..e8cdda0 --- /dev/null +++ b/src/physics/mod.rs @@ -0,0 +1,5 @@ +pub mod verlet; +pub mod collision; + +pub use verlet::{SubBody, Constraint, VerletSolver}; +pub use collision::resolve_grid_collision; diff --git a/src/physics/verlet.rs b/src/physics/verlet.rs new file mode 100644 index 0000000..dee1996 --- /dev/null +++ b/src/physics/verlet.rs @@ -0,0 +1,154 @@ +use crate::world::cell::MaterialId; + +#[derive(Clone, Copy, Debug)] +pub struct SubBody { + pub x: f32, + pub y: f32, + pub old_x: f32, + pub old_y: f32, + pub ax: f32, + pub ay: f32, + pub radius: f32, + pub material: MaterialId, + pub alive: bool, + pub health: f32, + pub on_fire: bool, + pub fire_timer: u32, +} + +impl SubBody { + pub fn new(x: f32, y: f32, radius: f32, material: MaterialId) -> Self { + Self { + x, + y, + old_x: x, + old_y: y, + ax: 0.0, + ay: 0.0, + radius, + material, + alive: true, + health: 100.0, + on_fire: false, + fire_timer: 0, + } + } + + #[inline] + pub fn vx(&self) -> f32 { + self.x - self.old_x + } + + #[inline] + pub fn vy(&self) -> f32 { + self.y - self.old_y + } + + #[inline] + pub fn set_vel(&mut self, vx: f32, vy: f32) { + self.old_x = self.x - vx; + self.old_y = self.y - vy; + } + + #[inline] + pub fn add_vel(&mut self, vx: f32, vy: f32) { + self.old_x -= vx; + self.old_y -= vy; + } + + #[inline] + pub fn apply_force(&mut self, fx: f32, fy: f32) { + self.ax += fx; + self.ay += fy; + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Constraint { + pub a: usize, + pub b: usize, + pub rest_length: f32, + pub stiffness: f32, +} + +impl Constraint { + pub fn new(a: usize, b: usize, rest_length: f32, stiffness: f32) -> Self { + Self { + a, + b, + rest_length, + stiffness, + } + } +} + +#[derive(Clone)] +pub struct VerletSolver { + pub gravity: f32, + pub damping: f32, + pub dt: f32, +} + +impl VerletSolver { + pub fn new() -> Self { + Self { + gravity: 0.3, + damping: 0.98, + dt: 1.0, + } + } + + pub fn integrate(&self, bodies: &mut [SubBody]) { + for b in bodies.iter_mut() { + if !b.alive { + continue; + } + let vx = (b.x - b.old_x) * self.damping; + let vy = (b.y - b.old_y) * self.damping; + b.old_x = b.x; + b.old_y = b.y; + b.x += vx + b.ax * self.dt * self.dt; + b.y += vy + (b.ay + self.gravity) * self.dt * self.dt; + b.ax = 0.0; + b.ay = 0.0; + } + } + + pub fn solve_constraints(&self, bodies: &mut [SubBody], constraints: &[Constraint], iterations: u32) { + for _ in 0..iterations { + for c in constraints { + let (ba, bb) = if c.a < bodies.len() && c.b < bodies.len() { + (bodies[c.a], bodies[c.b]) + } else { + continue; + }; + if !ba.alive || !bb.alive { + continue; + } + let dx = bb.x - ba.x; + let dy = bb.y - ba.y; + let dist = (dx * dx + dy * dy).sqrt(); + if dist < 0.0001 { + continue; + } + let diff = (dist - c.rest_length) / dist; + let sx = dx * 0.5 * diff * c.stiffness; + let sy = dy * 0.5 * diff * c.stiffness; + bodies[c.a].x += sx; + bodies[c.a].y += sy; + bodies[c.b].x -= sx; + bodies[c.b].y -= sy; + } + } + } + + pub fn step( + &self, + bodies: &mut [SubBody], + constraints: &[Constraint], + iterations: u32, + ) { + self.integrate(bodies); + self.solve_constraints(bodies, constraints, iterations); + } +} diff --git a/src/render/mod.rs b/src/render/mod.rs new file mode 100644 index 0000000..90418b5 --- /dev/null +++ b/src/render/mod.rs @@ -0,0 +1,12 @@ +pub mod terminal; + +use crate::entity::EntityManager; +use crate::world::grid::Grid; + +pub trait Renderer { + fn init(&mut self) -> std::io::Result<()>; + fn render(&mut self, grid: &Grid, entities: &EntityManager, cam_x: i32, cam_y: i32) -> std::io::Result<()>; + fn shutdown(&mut self) -> std::io::Result<()>; + fn viewport_w(&self) -> usize; + fn viewport_h(&self) -> usize; +} diff --git a/src/render/terminal.rs b/src/render/terminal.rs new file mode 100644 index 0000000..2a213ca --- /dev/null +++ b/src/render/terminal.rs @@ -0,0 +1,193 @@ +use std::io::{self, Write, stdout}; +use crossterm::{ + cursor::{Hide, MoveTo, Show}, + event::{DisableMouseCapture, EnableMouseCapture}, + execute, queue, + style::{Color, SetBackgroundColor, SetForegroundColor, ResetColor, Print}, + terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, size as term_size}, +}; + +use crate::entity::EntityManager; +use crate::render::Renderer; +use crate::world::cell::MaterialId; +use crate::world::grid::Grid; +use crate::world::material::MaterialRegistry; + +pub struct TerminalRenderer { + width: usize, + height: usize, + prev_frame: Vec<(char, (u8, u8, u8), (u8, u8, u8))>, + initialized: bool, +} + +impl TerminalRenderer { + pub fn new() -> Self { + Self { + width: 80, + height: 25, + prev_frame: Vec::new(), + initialized: false, + } + } + + fn detect_size(&mut self) -> io::Result<()> { + let (w, h) = term_size().unwrap_or((80, 25)); + self.width = (w as usize).min(120).max(40); + self.height = (h as usize).min(50).max(15); + Ok(()) + } + + fn empty_cell() -> (char, (u8, u8, u8), (u8, u8, u8)) { + (' ', (0, 0, 0), (10, 10, 15)) + } +} + +impl Renderer for TerminalRenderer { + fn init(&mut self) -> io::Result<()> { + self.detect_size()?; + let total = self.width * self.height; + self.prev_frame = vec![Self::empty_cell(); total]; + + terminal::enable_raw_mode().map_err(|e| io::Error::other(e))?; + execute!( + stdout(), + EnterAlternateScreen, + Hide, + EnableMouseCapture, + Clear(ClearType::All), + )?; + + self.initialized = true; + Ok(()) + } + + fn render(&mut self, grid: &Grid, entities: &EntityManager, cam_x: i32, cam_y: i32) -> io::Result<()> { + if !self.initialized { + return Ok(()); + } + + let reg = MaterialRegistry::instance(); + let mut out = stdout(); + let mut frame: Vec<(char, (u8, u8, u8), (u8, u8, u8))>; + let total = self.width * self.height; + frame = vec![Self::empty_cell(); total]; + + for dy in 0..self.height { + for dx in 0..self.width { + let wx = cam_x + dx as i32; + let wy = cam_y + dy as i32; + if !grid.in_bounds(wx, wy) { + let idx = dy * self.width + dx; + frame[idx] = ('?', (80, 80, 80), (10, 10, 15)); + continue; + } + let cell = grid.get(wx, wy); + let mat = reg.get(cell.material); + let idx = dy * self.width + dx; + if cell.is_empty() { + frame[idx] = (' ', mat.color_fg, mat.color_bg); + } else { + let ch = if cell.material == MaterialId::Lava { + if cell.variant % 2 == 0 { '#' } else { '#' } + } else if cell.material == MaterialId::Water { + if cell.variant % 3 == 0 { '~' } else { '~' } + } else { + mat.display_char + }; + let fg = if cell.material == MaterialId::Lava { + let flicker = cell.variant as u8; + let r = 255u8.min(200 + flicker / 2); + (r, 60, 20) + } else { + mat.color_fg + }; + frame[idx] = (ch, fg, mat.color_bg); + } + } + } + + for e in entities.all() { + for b in &e.bodies { + if !b.alive { + continue; + } + let sx = b.x as i32 - cam_x; + let sy = b.y as i32 - cam_y; + if sx >= 0 && sx < self.width as i32 && sy >= 0 && sy < self.height as i32 { + let idx = sy as usize * self.width + sx as usize; + let ch = match e.kind { + crate::entity::EntityKind::Player if e.alive => '@', + crate::entity::EntityKind::Goblin if e.alive => 'g', + _ => '%', + }; + let fg = if e.on_fire { + (255, 160, 40) + } else if !e.alive { + (100, 60, 60) + } else { + match e.kind { + crate::entity::EntityKind::Player => (255, 255, 100), + crate::entity::EntityKind::Goblin => (100, 220, 100), + _ => (180, 50, 50), + } + }; + frame[idx] = (ch, fg, (20, 10, 10)); + } + } + } + + let mut prev_color: Option<(Color, Color)> = None; + for i in 0..total { + if frame[i] == self.prev_frame[i] { + continue; + } + let dx = i % self.width; + let dy = i / self.width; + let (ch, fg, bg) = frame[i]; + + queue!(out, MoveTo(dx as u16, dy as u16))?; + + let new_fg = Color::Rgb { r: fg.0, g: fg.1, b: fg.2 }; + let new_bg = Color::Rgb { r: bg.0, g: bg.1, b: bg.2 }; + if prev_color != Some((new_fg, new_bg)) { + queue!(out, SetForegroundColor(new_fg), SetBackgroundColor(new_bg))?; + prev_color = Some((new_fg, new_bg)); + } + queue!(out, Print(ch))?; + } + + out.flush()?; + self.prev_frame = frame; + Ok(()) + } + + fn shutdown(&mut self) -> io::Result<()> { + if !self.initialized { + return Ok(()); + } + execute!( + stdout(), + ResetColor, + Show, + LeaveAlternateScreen, + DisableMouseCapture, + )?; + terminal::disable_raw_mode().map_err(|e| io::Error::other(e))?; + self.initialized = false; + Ok(()) + } + + fn viewport_w(&self) -> usize { + self.width + } + + fn viewport_h(&self) -> usize { + self.height + } +} + +impl Drop for TerminalRenderer { + fn drop(&mut self) { + let _ = self.shutdown(); + } +} diff --git a/src/world/cell.rs b/src/world/cell.rs new file mode 100644 index 0000000..033e882 --- /dev/null +++ b/src/world/cell.rs @@ -0,0 +1,138 @@ +use crate::world::material::MaterialRegistry; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[repr(u8)] +pub enum MaterialId { + Empty = 0, + Sand = 1, + Water = 2, + Stone = 3, + Lava = 4, + Wood = 5, + Flesh = 6, + Bone = 7, + Steam = 8, + Fire = 9, + Acid = 10, + Smoke = 11, + Grass = 12, + Dirt = 13, +} + +impl MaterialId { + pub const ALL: [MaterialId; 14] = [ + MaterialId::Empty, + MaterialId::Sand, + MaterialId::Water, + MaterialId::Stone, + MaterialId::Lava, + MaterialId::Wood, + MaterialId::Flesh, + MaterialId::Bone, + MaterialId::Steam, + MaterialId::Fire, + MaterialId::Acid, + MaterialId::Smoke, + MaterialId::Grass, + MaterialId::Dirt, + ]; + + pub fn from_u8(v: u8) -> Self { + unsafe { std::mem::transmute(v) } + } + + pub fn display_char(self) -> char { + match self { + MaterialId::Empty => ' ', + MaterialId::Sand => '.', + MaterialId::Water => '~', + MaterialId::Stone => '#', + MaterialId::Lava => '#', + MaterialId::Wood => 'T', + MaterialId::Flesh => '%', + MaterialId::Bone => '`', + MaterialId::Steam => '~', + MaterialId::Fire => '^', + MaterialId::Acid => '~', + MaterialId::Smoke => '*', + MaterialId::Grass => '"', + MaterialId::Dirt => ':', + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Cell { + pub material: MaterialId, + pub temp: f32, + pub updated_this_tick: bool, + pub variant: u8, +} + +impl Cell { + pub fn empty() -> Self { + Self { + material: MaterialId::Empty, + temp: 20.0, + updated_this_tick: false, + variant: 0, + } + } + + pub fn new(material: MaterialId) -> Self { + let temp = match material { + MaterialId::Lava => 1200.0, + MaterialId::Fire => 800.0, + MaterialId::Steam => 150.0, + MaterialId::Smoke => 120.0, + _ => 20.0, + }; + Self { + material, + temp, + updated_this_tick: false, + variant: rand_u8(), + } + } + + pub fn is_empty(self) -> bool { + self.material == MaterialId::Empty + } + + pub fn is_solid(self) -> bool { + let reg = MaterialRegistry::instance(); + reg.get(self.material).solid + } + + pub fn is_liquid(self) -> bool { + let reg = MaterialRegistry::instance(); + reg.get(self.material).liquid + } + + pub fn is_gas(self) -> bool { + let reg = MaterialRegistry::instance(); + reg.get(self.material).gas + } + + pub fn is_static(self) -> bool { + let reg = MaterialRegistry::instance(); + reg.get(self.material).static_ + } + + pub fn density(self) -> f32 { + let reg = MaterialRegistry::instance(); + reg.get(self.material).density + } + + pub fn display_char(self) -> char { + self.material.display_char() + } +} + +fn rand_u8() -> u8 { + static mut COUNTER: u8 = 0; + unsafe { + COUNTER = COUNTER.wrapping_add(7); + COUNTER + } +} diff --git a/src/world/cellular.rs b/src/world/cellular.rs new file mode 100644 index 0000000..4c18d1a --- /dev/null +++ b/src/world/cellular.rs @@ -0,0 +1,454 @@ +use crate::world::cell::{Cell, MaterialId}; +use crate::world::grid::Grid; +use crate::world::material::MaterialRegistry; + +pub struct CellularAutomaton { + tick: u64, + rng_state: u64, +} + +impl CellularAutomaton { + pub fn new() -> Self { + Self { + tick: 0, + rng_state: 0x1234567890ABCDEF, + } + } + + #[inline] + fn rand(&mut self) -> u32 { + self.rng_state ^= self.rng_state << 13; + self.rng_state ^= self.rng_state >> 7; + self.rng_state ^= self.rng_state << 17; + (self.rng_state & 0xFFFFFFFF) as u32 + } + + #[inline] + fn rand_bool(&mut self) -> bool { + self.rand() & 1 == 1 + } + + pub fn step(&mut self, grid: &mut Grid) { + grid.reset_tick_flags(); + let flip = self.rand_bool(); + let h = grid.height; + let w = grid.width; + + for y_idx in (0..h).rev() { + let y = y_idx as i32; + let xs: Vec = if flip { + (0..w as i32).collect() + } else { + (0..w as i32).rev().collect() + }; + + for x in xs { + let cell = grid.get(x, y); + if cell.updated_this_tick || cell.is_empty() || cell.is_static() { + continue; + } + match cell.material { + MaterialId::Sand => self.update_sand(grid, x, y), + MaterialId::Water => self.update_water(grid, x, y), + MaterialId::Lava => self.update_lava(grid, x, y), + MaterialId::Steam => self.update_steam(grid, x, y), + MaterialId::Fire => self.update_fire(grid, x, y), + MaterialId::Smoke => self.update_smoke(grid, x, y), + MaterialId::Acid => self.update_acid(grid, x, y), + MaterialId::Flesh => self.update_flesh(grid, x, y), + MaterialId::Grass => self.update_grass(grid, x, y), + MaterialId::Dirt => self.update_dirt(grid, x, y), + _ => {} + } + } + } + + self.heat_transfer(grid); + self.tick += 1; + } + + fn try_move_down(&mut self, grid: &mut Grid, x: i32, y: i32, _mat: MaterialId, density: f32) { + let below = grid.get(x, y + 1); + if below.is_empty() || (below.is_liquid() && below.density() < density) { + let src = grid.get(x, y); + let i_dst = grid.idx(x, y + 1); + let i_src = grid.idx(x, y); + grid.cells[i_dst] = src; + grid.cells[i_dst].updated_this_tick = true; + grid.cells[i_src] = Cell::empty(); + return; + } + + let dir = if self.rand_bool() { 1 } else { -1 }; + let dl = grid.get(x - dir, y + 1); + let dr = grid.get(x + dir, y + 1); + + let can_left = grid.in_bounds(x - dir, y + 1) + && (dl.is_empty() || (dl.is_liquid() && dl.density() < density)); + let can_right = grid.in_bounds(x + dir, y + 1) + && (dr.is_empty() || (dr.is_liquid() && dr.density() < density)); + + if can_left && can_right { + if self.rand_bool() { + self.do_swap(grid, x, y, x - dir, y + 1); + } else { + self.do_swap(grid, x, y, x + dir, y + 1); + } + } else if can_left { + self.do_swap(grid, x, y, x - dir, y + 1); + } else if can_right { + self.do_swap(grid, x, y, x + dir, y + 1); + } + } + + #[inline] + fn do_swap(&self, grid: &mut Grid, x1: i32, y1: i32, x2: i32, y2: i32) { + let a = grid.get(x1, y1); + let b = grid.get(x2, y2); + let i1 = grid.idx(x1, y1); + let i2 = grid.idx(x2, y2); + grid.cells[i1] = b; + grid.cells[i2] = a; + grid.cells[i2].updated_this_tick = true; + } + + fn update_sand(&mut self, grid: &mut Grid, x: i32, y: i32) { + self.try_move_down(grid, x, y, MaterialId::Sand, 1.5); + } + + fn update_water(&mut self, grid: &mut Grid, x: i32, y: i32) { + let below = grid.get(x, y + 1); + if below.is_empty() || (below.is_liquid() && below.density() < 1.0) { + self.do_swap(grid, x, y, x, y + 1); + return; + } + + let dir = if self.rand_bool() { 1 } else { -1 }; + let dl = grid.get(x - dir, y + 1); + let dr = grid.get(x + dir, y + 1); + + let can_dl = grid.in_bounds(x - dir, y + 1) + && (dl.is_empty() || (dl.is_liquid() && dl.density() < 1.0)); + let can_dr = grid.in_bounds(x + dir, y + 1) + && (dr.is_empty() || (dr.is_liquid() && dr.density() < 1.0)); + + if can_dl && can_dr { + if self.rand_bool() { + self.do_swap(grid, x, y, x - dir, y + 1); + } else { + self.do_swap(grid, x, y, x + dir, y + 1); + } + } else if can_dl { + self.do_swap(grid, x, y, x - dir, y + 1); + } else if can_dr { + self.do_swap(grid, x, y, x + dir, y + 1); + } else { + let can_l = grid.in_bounds(x - dir, y) && grid.get(x - dir, y).is_empty(); + let can_r = grid.in_bounds(x + dir, y) && grid.get(x + dir, y).is_empty(); + if can_l && can_r { + if self.rand_bool() { + self.do_swap(grid, x, y, x - dir, y); + } else { + self.do_swap(grid, x, y, x + dir, y); + } + } else if can_l { + self.do_swap(grid, x, y, x - dir, y); + } else if can_r { + self.do_swap(grid, x, y, x + dir, y); + } + } + + let cell = grid.get(x, y); + if cell.temp > 100.0 { + let mut new = cell; + new.material = MaterialId::Steam; + new.temp = 110.0; + let i = grid.idx(x, y); + grid.cells[i] = new; + } + } + + fn update_lava(&mut self, grid: &mut Grid, x: i32, y: i32) { + let cell = grid.get(x, y); + if cell.temp < 800.0 { + let mut new = cell; + new.material = MaterialId::Stone; + let i = grid.idx(x, y); + grid.cells[i] = new; + return; + } + + let below = grid.get(x, y + 1); + if below.is_empty() { + self.do_swap(grid, x, y, x, y + 1); + return; + } + + let dir = if self.rand_bool() { 1 } else { -1 }; + if grid.in_bounds(x - dir, y + 1) && grid.get(x - dir, y + 1).is_empty() { + self.do_swap(grid, x, y, x - dir, y + 1); + return; + } + if grid.in_bounds(x + dir, y + 1) && grid.get(x + dir, y + 1).is_empty() { + self.do_swap(grid, x, y, x + dir, y + 1); + return; + } + + if self.rand() % 10 == 0 { + if grid.in_bounds(x - dir, y) && grid.get(x - dir, y).is_empty() { + self.do_swap(grid, x, y, x - dir, y); + } else if grid.in_bounds(x + dir, y) && grid.get(x + dir, y).is_empty() { + self.do_swap(grid, x, y, x + dir, y); + } + } + + self.lava_interact(grid, x, y); + } + + fn lava_interact(&mut self, grid: &mut Grid, x: i32, y: i32) { + for &(dx, dy) in &NEIGHBORS4 { + let nx = x + dx; + let ny = y + dy; + if !grid.in_bounds(nx, ny) { + continue; + } + let neighbor = grid.get(nx, ny); + match neighbor.material { + MaterialId::Water => { + let i_n = grid.idx(nx, ny); + grid.cells[i_n] = Cell::new(MaterialId::Steam); + let lava = grid.get(x, y); + let mut new_lava = lava; + new_lava.temp -= 50.0; + let i_l = grid.idx(x, y); + grid.cells[i_l] = new_lava; + } + MaterialId::Wood | MaterialId::Grass | MaterialId::Flesh if neighbor.temp < 300.0 => { + let i_n = grid.idx(nx, ny); + grid.cells[i_n] = Cell::new(MaterialId::Fire); + } + MaterialId::Sand if neighbor.temp > 1700.0 => { + let i_n = grid.idx(nx, ny); + grid.cells[i_n] = Cell::new(MaterialId::Stone); + } + _ => {} + } + } + } + + fn update_steam(&mut self, grid: &mut Grid, x: i32, y: i32) { + let cell = grid.get(x, y); + if cell.temp < 80.0 { + let mut new = cell; + new.material = MaterialId::Water; + new.temp = 50.0; + let i = grid.idx(x, y); + grid.cells[i] = new; + return; + } + + if y > 0 && grid.get(x, y - 1).is_empty() { + self.do_swap(grid, x, y, x, y - 1); + return; + } + + let dir = if self.rand_bool() { 1 } else { -1 }; + if grid.in_bounds(x - dir, y - 1) && grid.get(x - dir, y - 1).is_empty() { + self.do_swap(grid, x, y, x - dir, y - 1); + return; + } + if grid.in_bounds(x + dir, y - 1) && grid.get(x + dir, y - 1).is_empty() { + self.do_swap(grid, x, y, x + dir, y - 1); + return; + } + + if self.rand() % 3 == 0 { + if grid.in_bounds(x - dir, y) && grid.get(x - dir, y).is_empty() { + self.do_swap(grid, x, y, x - dir, y); + } else if grid.in_bounds(x + dir, y) && grid.get(x + dir, y).is_empty() { + self.do_swap(grid, x, y, x + dir, y); + } + } + } + + fn update_fire(&mut self, grid: &mut Grid, x: i32, y: i32) { + let cell = grid.get(x, y); + if cell.temp < 100.0 || self.rand() % 20 == 0 { + let i = grid.idx(x, y); + if self.rand() % 3 == 0 { + grid.cells[i] = Cell::new(MaterialId::Smoke); + } else { + grid.cells[i] = Cell::empty(); + } + return; + } + + let mut new = cell; + new.temp -= 15.0; + let i = grid.idx(x, y); + grid.cells[i] = new; + + if y > 0 && grid.get(x, y - 1).is_empty() && self.rand() % 2 == 0 { + self.do_swap(grid, x, y, x, y - 1); + } + + for &(dx, dy) in &NEIGHBORS4 { + let nx = x + dx; + let ny = y + dy; + if !grid.in_bounds(nx, ny) { + continue; + } + let neighbor = grid.get(nx, ny); + let reg = crate::world::material::MaterialRegistry::instance(); + let mat = reg.get(neighbor.material); + if mat.flammable && neighbor.temp < mat.ignition_temp { + let mut new_n = neighbor; + new_n.material = MaterialId::Fire; + new_n.temp = 400.0; + let i_n = grid.idx(nx, ny); + grid.cells[i_n] = new_n; + } + } + } + + fn update_smoke(&mut self, grid: &mut Grid, x: i32, y: i32) { + if self.rand() % 60 == 0 { + let i = grid.idx(x, y); + grid.cells[i] = Cell::empty(); + return; + } + + if y > 0 && grid.get(x, y - 1).is_empty() { + self.do_swap(grid, x, y, x, y - 1); + return; + } + + let dir = if self.rand_bool() { 1 } else { -1 }; + if grid.in_bounds(x - dir, y - 1) && grid.get(x - dir, y - 1).is_empty() { + self.do_swap(grid, x, y, x - dir, y - 1); + } else if grid.in_bounds(x + dir, y - 1) && grid.get(x + dir, y - 1).is_empty() { + self.do_swap(grid, x, y, x + dir, y - 1); + } else if grid.in_bounds(x - dir, y) && grid.get(x - dir, y).is_empty() { + self.do_swap(grid, x, y, x - dir, y); + } else if grid.in_bounds(x + dir, y) && grid.get(x + dir, y).is_empty() { + self.do_swap(grid, x, y, x + dir, y); + } + } + + fn update_acid(&mut self, grid: &mut Grid, x: i32, y: i32) { + for &(dx, dy) in &NEIGHBORS4 { + let nx = x + dx; + let ny = y + dy; + if !grid.in_bounds(nx, ny) { + continue; + } + let neighbor = grid.get(nx, ny); + if neighbor.material != MaterialId::Empty + && neighbor.material != MaterialId::Acid + && neighbor.material != MaterialId::Stone + && self.rand() % 4 == 0 + { + let i_n = grid.idx(nx, ny); + grid.cells[i_n] = Cell::empty(); + if self.rand() % 2 == 0 { + let i = grid.idx(x, y); + grid.cells[i] = Cell::empty(); + return; + } + } + } + + let below = grid.get(x, y + 1); + if below.is_empty() || (below.is_liquid() && below.density() < 1.2) { + self.do_swap(grid, x, y, x, y + 1); + return; + } + + let dir = if self.rand_bool() { 1 } else { -1 }; + if grid.in_bounds(x - dir, y + 1) && grid.get(x - dir, y + 1).is_empty() { + self.do_swap(grid, x, y, x - dir, y + 1); + } else if grid.in_bounds(x + dir, y + 1) && grid.get(x + dir, y + 1).is_empty() { + self.do_swap(grid, x, y, x + dir, y + 1); + } else if grid.in_bounds(x - dir, y) && grid.get(x - dir, y).is_empty() { + self.do_swap(grid, x, y, x - dir, y); + } else if grid.in_bounds(x + dir, y) && grid.get(x + dir, y).is_empty() { + self.do_swap(grid, x, y, x + dir, y); + } + } + + fn update_flesh(&mut self, grid: &mut Grid, x: i32, y: i32) { + let cell = grid.get(x, y); + if cell.temp > 200.0 { + let mut new = cell; + new.material = MaterialId::Fire; + new.temp = 400.0; + let i = grid.idx(x, y); + grid.cells[i] = new; + } + } + + fn update_grass(&mut self, grid: &mut Grid, x: i32, y: i32) { + let cell = grid.get(x, y); + if cell.temp > 250.0 { + let i = grid.idx(x, y); + grid.cells[i] = Cell::new(MaterialId::Fire); + } + } + + fn update_dirt(&mut self, grid: &mut Grid, x: i32, y: i32) { + let cell = grid.get(x, y); + if cell.temp < 0.0 { + let mut new = cell; + new.material = MaterialId::Stone; + let i = grid.idx(x, y); + grid.cells[i] = new; + } + } + + fn heat_transfer(&mut self, grid: &mut Grid) { + let w = grid.width; + let h = grid.height; + let temps: Vec = grid.cells.iter().map(|c| c.temp).collect(); + + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let cell = grid.cells[i]; + if cell.is_empty() || cell.is_static() { + continue; + } + let reg = crate::world::material::MaterialRegistry::instance(); + let mat = reg.get(cell.material); + let k = mat.heat_conductivity; + if k == 0.0 { + continue; + } + + let mut sum = 0.0; + let mut count = 0; + for &(dx, dy) in &NEIGHBORS4 { + let nx = x as i32 + dx; + let ny = y as i32 + dy; + if nx < 0 || nx >= w as i32 || ny < 0 || ny >= h as i32 { + continue; + } + let ni = ny as usize * w + nx as usize; + sum += temps[ni]; + count += 1; + } + if count > 0 { + let avg = sum / count as f32; + let mut new = cell; + new.temp += (avg - cell.temp) * k * 0.5; + grid.cells[i] = new; + } + } + } + } + + pub fn tick_count(&self) -> u64 { + self.tick + } +} + +const NEIGHBORS4: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; diff --git a/src/world/grid.rs b/src/world/grid.rs new file mode 100644 index 0000000..cf0cf94 --- /dev/null +++ b/src/world/grid.rs @@ -0,0 +1,122 @@ +use crate::world::cell::{Cell, MaterialId}; + +pub const WORLD_W: usize = 250; +pub const WORLD_H: usize = 250; + +pub struct Grid { + pub cells: Vec, + pub next: Vec, + pub width: usize, + pub height: usize, +} + +impl Grid { + pub fn new() -> Self { + let size = WORLD_W * WORLD_H; + Self { + cells: vec![Cell::empty(); size], + next: vec![Cell::empty(); size], + width: WORLD_W, + height: WORLD_H, + } + } + + #[inline] + pub fn idx(&self, x: i32, y: i32) -> usize { + (y as usize) * self.width + (x as usize) + } + + #[inline] + pub fn in_bounds(&self, x: i32, y: i32) -> bool { + x >= 0 && x < self.width as i32 && y >= 0 && y < self.height as i32 + } + + #[inline] + pub fn get(&self, x: i32, y: i32) -> Cell { + if !self.in_bounds(x, y) { + return Cell::new(MaterialId::Stone); + } + self.cells[self.idx(x, y)] + } + + #[inline] + pub fn get_mut(&mut self, x: i32, y: i32) -> &mut Cell { + let i = self.idx(x, y); + &mut self.cells[i] + } + + #[inline] + pub fn set(&mut self, x: i32, y: i32, cell: Cell) { + if self.in_bounds(x, y) { + let i = self.idx(x, y); + self.cells[i] = cell; + self.next[i] = cell; + } + } + + #[inline] + pub fn set_material(&mut self, x: i32, y: i32, mat: MaterialId) { + if self.in_bounds(x, y) { + let cell = Cell::new(mat); + let i = self.idx(x, y); + self.cells[i] = cell; + self.next[i] = cell; + } + } + + pub fn clear(&mut self) { + for c in &mut self.cells { + *c = Cell::empty(); + } + for c in &mut self.next { + *c = Cell::empty(); + } + } + + pub fn fill_rect(&mut self, x0: i32, y0: i32, w: i32, h: i32, mat: MaterialId) { + for dy in 0..h { + for dx in 0..w { + self.set_material(x0 + dx, y0 + dy, mat); + } + } + } + + pub fn fill_border(&mut self, mat: MaterialId) { + for x in 0..self.width { + self.set_material(x as i32, 0, mat); + self.set_material(x as i32, (self.height - 1) as i32, mat); + } + for y in 0..self.height { + self.set_material(0, y as i32, mat); + self.set_material((self.width - 1) as i32, y as i32, mat); + } + } + + pub fn swap(&mut self) { + std::mem::swap(&mut self.cells, &mut self.next); + } + + pub fn reset_tick_flags(&mut self) { + for c in &mut self.cells { + c.updated_this_tick = false; + } + } + + pub fn dump_region(&self, x0: i32, y0: i32, w: usize, h: usize) -> String { + let mut buf = String::with_capacity(w * h + h); + for dy in 0..h { + for dx in 0..w { + let x = x0 + dx as i32; + let y = y0 + dy as i32; + let ch = if self.in_bounds(x, y) { + self.get(x, y).display_char() + } else { + '?' + }; + buf.push(ch); + } + buf.push('\n'); + } + buf + } +} diff --git a/src/world/material.rs b/src/world/material.rs new file mode 100644 index 0000000..e279d79 --- /dev/null +++ b/src/world/material.rs @@ -0,0 +1,286 @@ +use crate::world::cell::MaterialId; + +#[derive(Clone, Copy, Debug)] +pub struct Material { + pub id: MaterialId, + pub name: &'static str, + pub density: f32, + pub solid: bool, + pub liquid: bool, + pub gas: bool, + pub static_: bool, + pub flammable: bool, + pub ignition_temp: f32, + pub melt_temp: f32, + pub heat_conductivity: f32, + pub color_fg: (u8, u8, u8), + pub color_bg: (u8, u8, u8), + pub display_char: char, +} + +impl Material { + pub const fn empty() -> Self { + Self { + id: MaterialId::Empty, + name: "empty", + density: 0.0, + solid: false, + liquid: false, + gas: false, + static_: true, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: f32::INFINITY, + heat_conductivity: 0.0, + color_fg: (0, 0, 0), + color_bg: (0, 0, 0), + display_char: ' ', + } + } +} + +pub struct MaterialRegistry { + materials: [Material; 14], +} + +impl MaterialRegistry { + fn new() -> Self { + Self { + materials: [ + Material { + id: MaterialId::Empty, + name: "empty", + density: 0.0, + solid: false, + liquid: false, + gas: false, + static_: true, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: f32::INFINITY, + heat_conductivity: 0.0, + color_fg: (15, 15, 20), + color_bg: (10, 10, 15), + display_char: ' ', + }, + Material { + id: MaterialId::Sand, + name: "sand", + density: 1.5, + solid: true, + liquid: false, + gas: false, + static_: false, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: 1700.0, + heat_conductivity: 0.2, + color_fg: (218, 178, 90), + color_bg: (60, 50, 30), + display_char: '.', + }, + Material { + id: MaterialId::Water, + name: "water", + density: 1.0, + solid: false, + liquid: true, + gas: false, + static_: false, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: 0.0, + heat_conductivity: 0.4, + color_fg: (64, 128, 220), + color_bg: (20, 40, 80), + display_char: '~', + }, + Material { + id: MaterialId::Stone, + name: "stone", + density: 3.0, + solid: true, + liquid: false, + gas: false, + static_: true, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: 1200.0, + heat_conductivity: 0.3, + color_fg: (120, 120, 130), + color_bg: (40, 40, 50), + display_char: '#', + }, + Material { + id: MaterialId::Lava, + name: "lava", + density: 2.5, + solid: false, + liquid: true, + gas: false, + static_: false, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: f32::INFINITY, + heat_conductivity: 0.5, + color_fg: (255, 80, 20), + color_bg: (120, 20, 0), + display_char: '#', + }, + Material { + id: MaterialId::Wood, + name: "wood", + density: 0.8, + solid: true, + liquid: false, + gas: false, + static_: true, + flammable: true, + ignition_temp: 300.0, + melt_temp: f32::INFINITY, + heat_conductivity: 0.1, + color_fg: (140, 90, 50), + color_bg: (50, 30, 20), + display_char: 'T', + }, + Material { + id: MaterialId::Flesh, + name: "flesh", + density: 1.06, + solid: true, + liquid: false, + gas: false, + static_: false, + flammable: true, + ignition_temp: 200.0, + melt_temp: f32::INFINITY, + heat_conductivity: 0.15, + color_fg: (180, 50, 50), + color_bg: (60, 15, 15), + display_char: '%', + }, + Material { + id: MaterialId::Bone, + name: "bone", + density: 1.8, + solid: true, + liquid: false, + gas: false, + static_: true, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: f32::INFINITY, + heat_conductivity: 0.1, + color_fg: (220, 210, 190), + color_bg: (60, 55, 50), + display_char: '`', + }, + Material { + id: MaterialId::Steam, + name: "steam", + density: 0.3, + solid: false, + liquid: false, + gas: true, + static_: false, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: f32::INFINITY, + heat_conductivity: 0.2, + color_fg: (200, 200, 220), + color_bg: (30, 30, 40), + display_char: '~', + }, + Material { + id: MaterialId::Fire, + name: "fire", + density: 0.1, + solid: false, + liquid: false, + gas: true, + static_: false, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: f32::INFINITY, + heat_conductivity: 0.6, + color_fg: (255, 160, 40), + color_bg: (100, 30, 0), + display_char: '^', + }, + Material { + id: MaterialId::Acid, + name: "acid", + density: 1.2, + solid: false, + liquid: true, + gas: false, + static_: false, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: f32::INFINITY, + heat_conductivity: 0.3, + color_fg: (100, 255, 60), + color_bg: (20, 60, 10), + display_char: '~', + }, + Material { + id: MaterialId::Smoke, + name: "smoke", + density: 0.2, + solid: false, + liquid: false, + gas: true, + static_: false, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: f32::INFINITY, + heat_conductivity: 0.1, + color_fg: (100, 100, 100), + color_bg: (20, 20, 20), + display_char: '*', + }, + Material { + id: MaterialId::Grass, + name: "grass", + density: 1.0, + solid: true, + liquid: false, + gas: false, + static_: true, + flammable: true, + ignition_temp: 250.0, + melt_temp: f32::INFINITY, + heat_conductivity: 0.1, + color_fg: (80, 200, 60), + color_bg: (20, 50, 15), + display_char: '"', + }, + Material { + id: MaterialId::Dirt, + name: "dirt", + density: 1.3, + solid: true, + liquid: false, + gas: false, + static_: true, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: f32::INFINITY, + heat_conductivity: 0.2, + color_fg: (100, 70, 50), + color_bg: (40, 30, 20), + display_char: ':', + }, + ], + } + } + + pub fn get(&self, id: MaterialId) -> &Material { + &self.materials[id as usize] + } + + pub fn instance() -> &'static MaterialRegistry { + static REGISTRY: std::sync::OnceLock = std::sync::OnceLock::new(); + REGISTRY.get_or_init(MaterialRegistry::new) + } +} diff --git a/src/world/mod.rs b/src/world/mod.rs new file mode 100644 index 0000000..680c6ff --- /dev/null +++ b/src/world/mod.rs @@ -0,0 +1,9 @@ +pub mod cell; +pub mod material; +pub mod grid; +pub mod cellular; + +pub use cell::{Cell, MaterialId}; +pub use material::{Material, MaterialRegistry}; +pub use grid::{Grid, WORLD_W, WORLD_H}; +pub use cellular::CellularAutomaton;