diff --git a/AGENTS.md b/AGENTS.md index 3a6331d..ecbaed1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,7 +180,7 @@ Layer access via `grid.get_temp()`/`set_temp()`, `grid.get_gas()`/`set_gas()`, ` - **Status effects** — `update_status_effects()` applies damage for poison/bleeding/fire and cancels movement for frozen; effects expire when their timer reaches zero. - **Random seeding** — `Game::new()` uses a fixed seed for tests/AI sessions; `Game::new_random()` seeds from system time and is used by terminal/ascii/graphics modes. Cached worlds are keyed by seed. - **Dirty rects** — `ChunkedGrid::mark_dirty(x, y)` expands the chunk's dirty rect to include (x,y) ±1 and propagates to neighbor chunks at boundaries. `cells_swap` and `set_cell_index` call `mark_dirty` automatically. `set` and `set_material` also call `mark_dirty`. The CA step clears each chunk's dirty rect at the start of processing and rebuilds it from cell modifications during processing. -- **Activation radius** — Entity chunk activation radius is 1 (3x3 = 9 chunks). Chunks with dirty rects are also activated. This covers the viewport while minimizing active cell count. +- **World scale**: `WORLD_SCALE = 5` in `worldgen.rs` — all world features (trees, pools, walls, dunes, rooms, corridors, terrain amplitude) are multiplied by this factor. Change this constant to adjust entity-to-world size ratio. ## Module Layout diff --git a/benchmark_results.json b/benchmark_results.json index f31d96f..4c8042b 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -1,17 +1,17 @@ { "mode": "graphics", - "ticks": 300, - "total_time_ms": 3493.0, - "avg_fps": 85.9, - "avg_frame_time_ms": 11.62, - "p99_frame_time_ms": 31.15, - "min_frame_time_ms": 7.65, + "ticks": 60, + "total_time_ms": 6710.1, + "avg_fps": 8.9, + "avg_frame_time_ms": 111.81, + "p99_frame_time_ms": 1055.62, + "min_frame_time_ms": 12.09, "subsystems": { - "ca_step_avg_us": 4142, - "ca_step_p99_us": 20939, - "ca_step_min_us": 523, - "render_avg_us": 4335, - "render_p99_us": 6314, - "render_min_us": 3830 + "ca_step_avg_us": 103983, + "ca_step_p99_us": 1048423, + "ca_step_min_us": 5067, + "render_avg_us": 4606, + "render_p99_us": 15606, + "render_min_us": 3928 } } \ No newline at end of file diff --git a/src/game.rs b/src/game.rs index 01f6a1b..21ee8b9 100644 --- a/src/game.rs +++ b/src/game.rs @@ -739,12 +739,6 @@ impl Game { let dirty_radius = if self.grid.is_infinite() { 1 } else { 100000 }; for (cx, cy) in self.grid.all_chunk_coords() { - if self.grid.is_chunk_modified(cx, cy) { - if (cx - pcx).abs() <= dirty_radius && (cy - pcy).abs() <= dirty_radius { - self.grid - .activate_around(cx * chunk_size, cy * chunk_size, 1); - } - } if self.grid.get_chunk_dirty(cx, cy).is_some() { if (cx - pcx).abs() <= dirty_radius && (cy - pcy).abs() <= dirty_radius { self.grid.set_chunk_active(cx, cy, true); @@ -1481,21 +1475,23 @@ impl Game { } let x = near_x + dx; let y = near_y + dy; - if !self.grid.in_bounds(x, y) || !self.grid.in_bounds(x, y - 3) { + let sc = crate::world::worldgen::WORLD_SCALE; + let clear_h = 8; + if !self.grid.in_bounds(x, y) || !self.grid.in_bounds(x, y - clear_h) { continue; } if !self.grid.get(x, y).is_empty() || !self.grid.get(x, y + 1).is_solid() { continue; } let mut clear = true; - for k in -3..=0 { + for k in -clear_h..=0 { if !self.grid.get(x, y + k).is_empty() { clear = false; break; } } if clear { - return Some((x, y - 3)); + return Some((x, y - clear_h)); } } } @@ -1516,7 +1512,7 @@ impl Game { } let search_top = 0; let search_bottom = if self.grid.is_infinite() { - py as i32 + 50 + py as i32 + crate::world::worldgen::WORLD_SCALE * 50 } else { self.grid.height as i32 - 3 }; @@ -1548,9 +1544,13 @@ impl Game { } let (px, py) = self.player.center(&self.entities); - let offset = if px as i32 % 2 == 0 { 15 } else { -15 }; + let offset = if px as i32 % 2 == 0 { + crate::world::worldgen::WORLD_SCALE * 15 + } else { + -(crate::world::worldgen::WORLD_SCALE * 15) + }; let spawn = if self.depth <= 3 { - self.find_surface_spawn(px, py, offset, 5) + self.find_surface_spawn(px, py, offset, crate::world::worldgen::WORLD_SCALE * 5) } else { self.find_spawn_location(px as i32 + offset, py as i32, 3) }; @@ -1579,9 +1579,13 @@ impl Game { } let (px, py) = self.player.center(&self.entities); - let offset = if px as i32 % 2 == 0 { -18 } else { 18 }; + let offset = if px as i32 % 2 == 0 { + -(crate::world::worldgen::WORLD_SCALE * 18) + } else { + crate::world::worldgen::WORLD_SCALE * 18 + }; let spawn = if self.depth <= 3 { - self.find_surface_spawn(px, py, offset, 3) + self.find_surface_spawn(px, py, offset, crate::world::worldgen::WORLD_SCALE * 3) } else { self.find_spawn_location(px as i32 + offset, py as i32, 3) }; diff --git a/src/world/cellular.rs b/src/world/cellular.rs index 37c4d7a..d631323 100644 --- a/src/world/cellular.rs +++ b/src/world/cellular.rs @@ -123,6 +123,12 @@ impl CellularAutomaton { continue; } let (min_x, min_y, max_x, max_y) = dirty.unwrap(); + let dw = max_x - min_x + 1; + let dh = max_y - min_y + 1; + if dw * dh > 4096 { + grid.set_chunk_dirty(cx, cy, None); + continue; + } grid.set_chunk_dirty(cx, cy, None); @@ -502,6 +508,9 @@ impl CellularAutomaton { (None, Some(p)) => p, (None, None) => continue, }; + if (max_x - min_x + 1) * (max_y - min_y + 1) > 4096 { + continue; + } for y in min_y..=max_y { for x in min_x..=max_x { let cell = grid.get(x, y); @@ -553,6 +562,9 @@ impl CellularAutomaton { (None, Some(p)) => p, (None, None) => continue, }; + if (max_x - min_x + 1) * (max_y - min_y + 1) > 4096 { + continue; + } for y in min_y..=max_y { for x in min_x..=max_x { let (gt, gd) = grid.get_gas(x, y); @@ -623,6 +635,9 @@ impl CellularAutomaton { (None, Some(p)) => p, (None, None) => continue, }; + if (max_x - min_x + 1) * (max_y - min_y + 1) > 4096 { + continue; + } for y in min_y..=max_y { for x in min_x..=max_x { let cell = grid.get(x, y); diff --git a/src/world/worldgen.rs b/src/world/worldgen.rs index 54686ef..d1c8aa7 100644 --- a/src/world/worldgen.rs +++ b/src/world/worldgen.rs @@ -5,6 +5,18 @@ use crate::world::cellular::CellularAutomaton; use crate::world::chunk::CHUNK_SIZE; use crate::world::chunked_grid::ChunkedGrid; +pub const WORLD_SCALE: i32 = 5; + +#[inline] +fn sc(v: i32) -> i32 { + v * WORLD_SCALE +} + +#[inline] +fn scf(v: f32) -> f32 { + v * WORLD_SCALE as f32 +} + pub struct WorldGenerator<'a> { ca: &'a mut CellularAutomaton, } @@ -78,7 +90,7 @@ impl<'a> WorldGenerator<'a> { if grid.get(sx, surface_y + 1).is_empty() { grid.set_material(sx, surface_y + 1, MaterialId::Stone); } - let spawn_y = surface_y - 8; + let spawn_y = surface_y - sc(8); (sx, spawn_y) } @@ -96,7 +108,8 @@ impl<'a> WorldGenerator<'a> { if let Some(chunk) = grid.get_chunk_mut(cx, cy) { chunk.generated = true; chunk.modified = true; - chunk.was_modified = true; + chunk.was_modified = false; + chunk.dirty = None; } } @@ -114,7 +127,7 @@ impl<'a> WorldGenerator<'a> { } if y == s { grid.set_material(x, y, MaterialId::Grass); - } else if y > s + 8 { + } else if y > s + sc(8) { grid.set_material(x, y, MaterialId::Stone); } else { grid.set_material(x, y, MaterialId::Dirt); @@ -141,7 +154,7 @@ impl<'a> WorldGenerator<'a> { let pool_count = (1 + depth / 3).min(3) as i32; for _ in 0..pool_count { let typ = self.random_cave_pool_type(depth); - self.place_cave_pool_chunk(grid, cx, cy, typ, 2, 4); + self.place_cave_pool_chunk(grid, cx, cy, typ, sc(2), sc(4)); } grid.fill_border(MaterialId::Stone); } @@ -190,7 +203,7 @@ impl<'a> WorldGenerator<'a> { MaterialId::Dirt }; grid.set_material(x as i32, y, mat); - } else if y > s + 8 { + } else if y > s + sc(8) { grid.set_material(x as i32, y, MaterialId::Stone); } else { grid.set_material(x as i32, y, MaterialId::Dirt); @@ -205,6 +218,16 @@ impl<'a> WorldGenerator<'a> { grid.fill_border(MaterialId::Stone); + for cy in 0..grid.chunks_y as i32 { + for cx in 0..grid.chunks_x as i32 { + grid.set_chunk_dirty(cx, cy, None); + if let Some(chunk) = grid.get_chunk_mut(cx, cy) { + chunk.modified = false; + chunk.was_modified = false; + } + } + } + let sx = w / 2; let mut surface_y = h - 3; for y in 0..h { @@ -233,20 +256,20 @@ impl<'a> WorldGenerator<'a> { } } - self.carve_ca_caves(grid, 0.45, 5); + self.carve_ca_caves(grid, 0.38, 5); self.seal_disconnected_regions(grid); let pool_count = (2 + depth / 2).min(6) as i32; for _ in 0..pool_count { let typ = self.random_cave_pool_type(depth); - self.place_cave_pool(grid, typ, 3, 5); + self.place_cave_pool(grid, typ, sc(3), sc(5)); } let stalactites = self.random_range_i32(3, 7); for _ in 0..stalactites { let x = self.random_range_i32(8, w - 8); let y_top = self.random_range_i32(3, 12); - let len = self.random_range_i32(3, 9); + let len = self.random_range_i32(sc(3), sc(9)); for dy in 0..len { let y = y_top + dy; if grid.get(x, y).material != MaterialId::Stone { @@ -258,6 +281,16 @@ impl<'a> WorldGenerator<'a> { grid.fill_border(MaterialId::Stone); + for cy in 0..grid.chunks_y as i32 { + for cx in 0..grid.chunks_x as i32 { + grid.set_chunk_dirty(cx, cy, None); + if let Some(chunk) = grid.get_chunk_mut(cx, cy) { + chunk.modified = false; + chunk.was_modified = false; + } + } + } + let spawn = self.find_safe_spawn(grid); let stairs = self.find_empty_with_floor(grid, w - 15, h - 15); if let Some((sx, sy)) = stairs { @@ -305,6 +338,16 @@ impl<'a> WorldGenerator<'a> { self.connect_bsp_rooms(&tree, grid); grid.fill_border(MaterialId::Stone); + for cy in 0..grid.chunks_y as i32 { + for cx in 0..grid.chunks_x as i32 { + grid.set_chunk_dirty(cx, cy, None); + if let Some(chunk) = grid.get_chunk_mut(cx, cy) { + chunk.modified = false; + chunk.was_modified = false; + } + } + } + let spawn_room = rooms[0]; let spawn_x = spawn_room.x + spawn_room.w / 2; let spawn_y = spawn_room.y + spawn_room.h - 5; @@ -358,7 +401,14 @@ impl<'a> WorldGenerator<'a> { ) { if depth <= 3 { let base_y = py + 1; - let offsets = [(-6, 1), (6, 1), (-3, -8), (10, 1), (-10, 1), (3, -6)]; + let offsets = [ + (sc(-6), sc(1)), + (sc(6), sc(1)), + (sc(-3), sc(-8)), + (sc(10), sc(1)), + (sc(-10), sc(1)), + (sc(3), sc(-6)), + ]; let types = [ ItemType::Sword, ItemType::HealthPotion, @@ -384,7 +434,7 @@ impl<'a> WorldGenerator<'a> { let pool_count = (2 + depth / 2).min(5) as i32; for _ in 0..pool_count { let typ = self.random_surface_pool_type(depth); - self.place_surface_pool(grid, typ, 3, 6); + self.place_surface_pool(grid, typ, sc(3), sc(6)); } let dune_count = self.random_range_i32(1, 4); @@ -410,7 +460,7 @@ impl<'a> WorldGenerator<'a> { let pool_count = (depth / 2).min(2) as i32; for _ in 0..pool_count { let typ = self.random_surface_pool_type(depth); - self.place_surface_pool_chunk(grid, x0, x1, surface, typ, 2, 4); + self.place_surface_pool_chunk(grid, x0, x1, surface, typ, sc(2), sc(4)); } let dune_count = self.random_range_i32(0, 2); @@ -435,16 +485,16 @@ impl<'a> WorldGenerator<'a> { if s < 10 { continue; } - for y in (s - 6)..s { + for y in (s - sc(6))..s { if grid.in_bounds(x, y) { grid.set_material(x, y, MaterialId::Wood); } } - for dy in -2..=0 { - for dx in -2..=2 { - if dx * dx + dy * dy <= 5 { + for dy in -sc(2)..=0 { + for dx in -sc(2)..=sc(2) { + if dx * dx + dy * dy <= sc(5) { let cx = x + dx; - let cy = s - 6 + dy; + let cy = s - sc(6) + dy; if grid.in_bounds(cx, cy) && grid.get(cx, cy).is_empty() { grid.set_material(cx, cy, MaterialId::Grass); } @@ -539,7 +589,7 @@ impl<'a> WorldGenerator<'a> { let lx = self.random_range_i32(4, (x1 - x0 - 4).max(5)); let x = x0 + lx; let s = surface[lx as usize]; - let width = self.random_range_i32(4, 10); + let width = self.random_range_i32(sc(4), sc(10)); for dx in -width / 2..=width / 2 { let pile = (width / 2 - dx.abs()).max(1) + 1; for dy in 0..pile { @@ -564,7 +614,7 @@ impl<'a> WorldGenerator<'a> { let lx = self.random_range_i32(4, (x1 - x0 - 4).max(5)); let x = x0 + lx; let s = surface[lx as usize]; - let h = self.random_range_i32(2, 5); + let h = self.random_range_i32(sc(2), sc(5)); for y in (s - h)..s { if grid.in_bounds(x, y) { grid.set_material(x, y, MaterialId::Stone); @@ -572,6 +622,15 @@ impl<'a> WorldGenerator<'a> { if grid.in_bounds(x + 1, y) { grid.set_material(x + 1, y, MaterialId::Stone); } + if grid.in_bounds(x + 2, y) { + grid.set_material(x + 2, y, MaterialId::Stone); + } + if grid.in_bounds(x + 3, y) { + grid.set_material(x + 3, y, MaterialId::Stone); + } + if grid.in_bounds(x + 4, y) { + grid.set_material(x + 4, y, MaterialId::Stone); + } } } } @@ -584,16 +643,16 @@ impl<'a> WorldGenerator<'a> { if s < 10 { continue; } - for y in (s - 6)..s { + for y in (s - sc(6))..s { if grid.in_bounds(x, y) { grid.set_material(x, y, MaterialId::Wood); } } - for dy in -2..=0 { - for dx in -2..=2 { - if dx * dx + dy * dy <= 5 { + for dy in -sc(2)..=0 { + for dx in -sc(2)..=sc(2) { + if dx * dx + dy * dy <= sc(5) { let cx = x + dx; - let cy = s - 6 + dy; + let cy = s - sc(6) + dy; if grid.in_bounds(cx, cy) && grid.get(cx, cy).is_empty() { grid.set_material(cx, cy, MaterialId::Grass); } @@ -668,7 +727,7 @@ impl<'a> WorldGenerator<'a> { for _ in 0..count { let x = self.random_range_i32(15, w - 15); let s = surface[x as usize]; - let width = self.random_range_i32(6, 16); + let width = self.random_range_i32(sc(6), sc(16)); for dx in -width / 2..=width / 2 { let pile = (width / 2 - dx.abs()).max(1) + 1; for dy in 0..pile { @@ -686,7 +745,7 @@ impl<'a> WorldGenerator<'a> { for _ in 0..count { let x = self.random_range_i32(10, w - 10); let s = surface[x as usize]; - let h = self.random_range_i32(3, 7); + let h = self.random_range_i32(sc(3), sc(7)); for y in (s - h)..s { if grid.in_bounds(x, y) { grid.set_material(x, y, MaterialId::Stone); @@ -694,6 +753,15 @@ impl<'a> WorldGenerator<'a> { if grid.in_bounds(x + 1, y) { grid.set_material(x + 1, y, MaterialId::Stone); } + if grid.in_bounds(x + 2, y) { + grid.set_material(x + 2, y, MaterialId::Stone); + } + if grid.in_bounds(x + 3, y) { + grid.set_material(x + 3, y, MaterialId::Stone); + } + if grid.in_bounds(x + 4, y) { + grid.set_material(x + 4, y, MaterialId::Stone); + } } } } @@ -705,7 +773,7 @@ impl<'a> WorldGenerator<'a> { let cx = self.random_range_i32(10, w - 10); let lower = (h * 2 / 3).max(surface[cx as usize] + 12); let cy = self.random_range_i32(lower, h - 10); - let r = self.random_range_i32(3, 6); + let r = self.random_range_i32(sc(3), sc(6)); for dy in -r..=r { for dx in -r..=r { if dx * dx + dy * dy <= r * r { @@ -908,7 +976,7 @@ impl<'a> WorldGenerator<'a> { } fn split_rect(&mut self, rect: Rect) -> Option<(Rect, Rect)> { - let min_size = 22; + let min_size = sc(9); if rect.w < min_size * 2 || rect.h < min_size * 2 { return None; } @@ -946,8 +1014,8 @@ impl<'a> WorldGenerator<'a> { } fn carve_room_rect(&mut self, rect: Rect) -> Rect { - let min_w = 9; - let min_h = 9; + let min_w = sc(4); + let min_h = sc(4); let max_pad_x = ((rect.w - min_w) / 2).max(1); let max_pad_y = ((rect.h - min_h) / 2).max(1); let pad_x = self.random_range_i32(1, max_pad_x + 1); @@ -1012,10 +1080,11 @@ impl<'a> WorldGenerator<'a> { fn carve_l_corridor(&mut self, grid: &mut ChunkedGrid, a: &Rect, b: &Rect) { let c1 = (a.x + a.w / 2, a.y + a.h / 2); let c2 = (b.x + b.w / 2, b.y + b.h / 2); + let cw = sc(2) / 2; let x0 = c1.0.min(c2.0); let x1 = c1.0.max(c2.0); for x in x0..=x1 { - for dy in 0..2 { + for dy in 0..cw { if grid.in_bounds(x, c1.1 + dy) { grid.set(x, c1.1 + dy, Cell::empty()); } @@ -1024,7 +1093,7 @@ impl<'a> WorldGenerator<'a> { let y0 = c1.1.min(c2.1); let y1 = c1.1.max(c2.1); for y in y0..=y1 { - for dx in 0..2 { + for dx in 0..cw { if grid.in_bounds(c2.0 + dx, y) { grid.set(c2.0 + dx, y, Cell::empty()); } @@ -1081,7 +1150,7 @@ impl<'a> WorldGenerator<'a> { } fn vertical_clear(&self, grid: &ChunkedGrid, x: i32, y: i32) -> bool { - for dy in -3..=2 { + for dy in -4..=2 { if !grid.in_bounds(x, y + dy) || !grid.get(x, y + dy).is_empty() { return false; } @@ -1113,18 +1182,18 @@ impl<'a> WorldGenerator<'a> { } fn surface_height(&mut self, x: i32, h: i32) -> i32 { - let base = (h - 3) - ((x as f32 * 0.08).sin() * 4.0) as i32; - let detail = ((x as f32 * 0.23).sin() * 2.0) as i32; - let micro = ((x as f32 * 0.57).sin() * 1.0) as i32; + let base = (h - 3) - ((x as f32 * 0.08).sin() * scf(4.0)) as i32; + let detail = ((x as f32 * 0.23).sin() * scf(2.0)) as i32; + let micro = ((x as f32 * 0.57).sin() * scf(1.0)) as i32; (base + detail + micro).max(10).min(h - 3) } const SURFACE_BASE_Y: i32 = 120; fn surface_height_world(&mut self, x: i32) -> i32 { - let base = Self::SURFACE_BASE_Y - ((x as f32 * 0.08).sin() * 4.0) as i32; - let detail = ((x as f32 * 0.23).sin() * 2.0) as i32; - let micro = ((x as f32 * 0.57).sin() * 1.0) as i32; + let base = Self::SURFACE_BASE_Y - ((x as f32 * 0.08).sin() * scf(4.0)) as i32; + let detail = ((x as f32 * 0.23).sin() * scf(2.0)) as i32; + let micro = ((x as f32 * 0.57).sin() * scf(1.0)) as i32; (base + detail + micro).max(10) }