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
This commit is contained in:
Emil
2026-06-20 18:21:26 +03:00
commit 68f6292c4d
19 changed files with 2218 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
Cargo.lock
+13
View File
@@ -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
Binary file not shown.
+195
View File
@@ -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<SubBody>,
pub constraints: Vec<Constraint>,
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<Entity>,
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<Item = &mut Entity> {
self.entities.iter_mut()
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod entity;
pub mod player;
pub use entity::{Entity, EntityId, EntityManager, EntityKind};
pub use player::Player;
+64
View File
@@ -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))
}
}
+311
View File
@@ -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<R: Renderer>(&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<crate::physics::verlet::SubBody>, Vec<crate::physics::verlet::Constraint>)>;
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<usize> = 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);
}
}
}
+112
View File
@@ -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<crate::world::cell::MaterialId> {
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",
}
}
}
+37
View File
@@ -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);
}
}
}
+106
View File
@@ -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);
}
+5
View File
@@ -0,0 +1,5 @@
pub mod verlet;
pub mod collision;
pub use verlet::{SubBody, Constraint, VerletSolver};
pub use collision::resolve_grid_collision;
+154
View File
@@ -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);
}
}
+12
View File
@@ -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;
}
+193
View File
@@ -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();
}
}
+138
View File
@@ -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
}
}
+454
View File
@@ -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<i32> = 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<f32> = 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)];
+122
View File
@@ -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<Cell>,
pub next: Vec<Cell>,
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
}
}
+286
View File
@@ -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<MaterialRegistry> = std::sync::OnceLock::new();
REGISTRY.get_or_init(MaterialRegistry::new)
}
}
+9
View File
@@ -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;