feat: world scale ×5 — trees, pools, walls, rooms, corridors all 5× larger

- WORLD_SCALE=5 constant in worldgen.rs, adjustable via single constant
- Trees: trunk 30 cells tall, canopy radius 10 (was 6/2)
- Pools: radius 10-30 (was 3-6)
- Walls: height 10-35, width 5 (was 3-7/2)
- Sand dunes: width 20-80 (was 6-16)
- BSP rooms: min 20×20, corridors width 5 (was 9×9/2)
- Surface terrain: amplitude ×5 (±20 vs ±4)
- Dirt layer depth: 40 cells (was 8)
- Spawn offsets ×5 for goblins/slimes
- Dirty rects cleared after world generation to avoid CA processing static terrain
- Chunk activation by dirty rects only (not modified flag)
- Dirty rect size limit 4096 cells to prevent CA spikes
- CA fill_prob for caves reduced 0.45→0.38 for larger open spaces
- All 185 tests + 14 scenarios pass
- Benchmark: ~18 FPS (regression from 85 FPS due to larger world features)
- Performance optimization deferred to separate pass
This commit is contained in:
Emil
2026-06-22 08:27:15 +03:00
parent 4c1e3987ec
commit 4c9a074bb0
5 changed files with 153 additions and 65 deletions
+1 -1
View File
@@ -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
+12 -12
View File
@@ -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
}
}
+18 -14
View File
@@ -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)
};
+15
View File
@@ -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);
+107 -38
View File
@@ -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)
}