Add procedural world generation with regenerate key
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 08:34 UTC
- commit
- 62af2829e28cca0d085b3e7bfa94e37b871d8155
- parent
- 6d1da60b36
- tree
- browse at this commit
9 files changed +414 -66
| src/camera.rs | +18 -2 | |
| src/colonist.rs | +17 -15 | |
| src/main.rs | +3 -1 | |
| src/noise.rs | added | +91 -0 |
| src/pathfinding.rs | +4 -4 | |
| src/selection.rs | +12 -1 | |
| src/tilemap.rs | +44 -3 | |
| src/world.rs | +2 -40 | |
| src/worldgen.rs | added | +223 -0 |
src/camera.rs +18 -2
| @@ -4,7 +4,10 @@ use bevy::{ | ||
| 4 | 4 | window::PrimaryWindow, |
| 5 | 5 | }; |
| 6 | 6 | |
| 7 | -use crate::world::WorldMap; | |
| 7 | +use crate::{ | |
| 8 | + world::WorldMap, | |
| 9 | + worldgen::{WorldGenerated, spawn_tiles}, | |
| 10 | +}; | |
| 8 | 11 | |
| 9 | 12 | pub struct CameraPlugin; |
| 10 | 13 | |
| @@ -14,7 +17,14 @@ impl Plugin for CameraPlugin { | ||
| 14 | 17 | fn build(&self, app: &mut App) { |
| 15 | 18 | app.add_systems(Startup, spawn_camera).add_systems( |
| 16 | 19 | Update, |
| 17 | - (keyboard_pan, drag_pan, scroll_zoom, clamp_to_world).chain(), | |
| 20 | + ( | |
| 21 | + center_on_spawn.run_if(on_message::<WorldGenerated>), | |
| 22 | + keyboard_pan, | |
| 23 | + drag_pan, | |
| 24 | + scroll_zoom, | |
| 25 | + clamp_to_world, | |
| 26 | + ) | |
| 27 | + .chain(), | |
| 18 | 28 | ); |
| 19 | 29 | } |
| 20 | 30 | } |
| @@ -48,6 +58,12 @@ fn spawn_camera(mut commands: Commands, map: Res<WorldMap>) { | ||
| 48 | 58 | )); |
| 49 | 59 | } |
| 50 | 60 | |
| 61 | +fn center_on_spawn(map: Res<WorldMap>, mut camera: Single<&mut Transform, With<CameraController>>) { | |
| 62 | + if let Some(&tile) = spawn_tiles(&map, 1).first() { | |
| 63 | + camera.translation = WorldMap::tile_center(tile).extend(camera.translation.z); | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 51 | 67 | fn ortho(projection: &mut Projection) -> &mut OrthographicProjection { |
| 52 | 68 | match projection { |
| 53 | 69 | Projection::Orthographic(o) => o, |
src/colonist.rs +17 -15
| @@ -6,6 +6,7 @@ use crate::{ | ||
| 6 | 6 | pathfinding::find_path, |
| 7 | 7 | rng::SimRng, |
| 8 | 8 | world::{TILE_SIZE, WorldMap}, |
| 9 | + worldgen::{WorldGenerated, spawn_tiles}, | |
| 9 | 10 | }; |
| 10 | 11 | |
| 11 | 12 | pub struct ColonistPlugin; |
| @@ -14,7 +15,10 @@ impl Plugin for ColonistPlugin { | ||
| 14 | 15 | fn build(&self, app: &mut App) { |
| 15 | 16 | app.insert_resource(SimRng::new(0x5EED)) |
| 16 | 17 | .add_message::<MoveOrder>() |
| 17 | - .add_systems(Startup, spawn_colonists) | |
| 18 | + .add_systems( | |
| 19 | + Update, | |
| 20 | + spawn_colonists.run_if(on_message::<WorldGenerated>), | |
| 21 | + ) | |
| 18 | 22 | .add_systems(Update, (toggle_pause, (handle_move_orders, wander, follow_path).chain())); |
| 19 | 23 | } |
| 20 | 24 | } |
| @@ -23,10 +27,10 @@ const TILES_PER_SECOND: f32 = 4.0; | ||
| 23 | 27 | const WANDER_RADIUS: i32 = 8; |
| 24 | 28 | const WANDER_PAUSE_SECS: std::ops::Range<f32> = 1.0..4.0; |
| 25 | 29 | |
| 26 | -pub const SPAWNS: [(&str, UVec2, Color); 3] = [ | |
| 27 | - ("Ada", UVec2::new(96, 64), Color::srgb(0.9, 0.3, 0.3)), | |
| 28 | - ("Bram", UVec2::new(99, 62), Color::srgb(0.3, 0.5, 0.95)), | |
| 29 | - ("Cleo", UVec2::new(94, 60), Color::srgb(0.95, 0.75, 0.3)), | |
| 30 | +const STARTING_COLONISTS: [(&str, Color); 3] = [ | |
| 31 | + ("Ada", Color::srgb(0.9, 0.3, 0.3)), | |
| 32 | + ("Bram", Color::srgb(0.3, 0.5, 0.95)), | |
| 33 | + ("Cleo", Color::srgb(0.95, 0.75, 0.3)), | |
| 30 | 34 | ]; |
| 31 | 35 | |
| 32 | 36 | #[derive(Component, Debug)] |
| @@ -58,11 +62,17 @@ pub struct MoveOrder { | ||
| 58 | 62 | |
| 59 | 63 | fn spawn_colonists( |
| 60 | 64 | mut commands: Commands, |
| 65 | + map: Res<WorldMap>, | |
| 66 | + existing: Query<Entity, With<Colonist>>, | |
| 61 | 67 | mut meshes: ResMut<Assets<Mesh>>, |
| 62 | 68 | mut materials: ResMut<Assets<ColorMaterial>>, |
| 63 | 69 | ) { |
| 70 | + for entity in &existing { | |
| 71 | + commands.entity(entity).despawn(); | |
| 72 | + } | |
| 64 | 73 | let mesh = meshes.add(Circle::new(TILE_SIZE * 0.35)); |
| 65 | - for (name, tile, color) in SPAWNS { | |
| 74 | + let tiles = spawn_tiles(&map, STARTING_COLONISTS.len()); | |
| 75 | + for ((name, color), tile) in STARTING_COLONISTS.into_iter().zip(tiles) { | |
| 66 | 76 | commands.spawn(( |
| 67 | 77 | Name::new(name), |
| 68 | 78 | Colonist { name: name.into() }, |
| @@ -175,15 +185,7 @@ pub fn step_towards(pos: Vec2, target: Vec2, max_distance: f32) -> (Vec2, bool) | ||
| 175 | 185 | #[cfg(test)] |
| 176 | 186 | mod tests { |
| 177 | 187 | use super::*; |
| 178 | - use crate::world::{Terrain, static_world}; | |
| 179 | - | |
| 180 | - #[test] | |
| 181 | - fn spawn_tiles_are_walkable() { | |
| 182 | - let map = static_world(); | |
| 183 | - for (name, tile, _) in SPAWNS { | |
| 184 | - assert!(map.move_cost(tile.as_ivec2()).is_some(), "{name}"); | |
| 185 | - } | |
| 186 | - } | |
| 188 | + use crate::world::Terrain; | |
| 187 | 189 | |
| 188 | 190 | #[test] |
| 189 | 191 | fn step_towards_stops_exactly_on_target() { |
src/main.rs +3 -1
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | mod camera; |
| 2 | 2 | mod colonist; |
| 3 | +mod noise; | |
| 3 | 4 | mod pathfinding; |
| 4 | 5 | mod rng; |
| 5 | 6 | mod selection; |
| 6 | 7 | mod tilemap; |
| 7 | 8 | mod world; |
| 9 | +mod worldgen; | |
| 8 | 10 | |
| 9 | 11 | use bevy::prelude::*; |
| 10 | 12 | |
| @@ -24,8 +26,8 @@ fn main() { | ||
| 24 | 26 | }), |
| 25 | 27 | ) |
| 26 | 28 | .insert_resource(ClearColor(Color::srgb(0.08, 0.08, 0.1))) |
| 27 | - .insert_resource(world::static_world()) | |
| 28 | 29 | .add_plugins(( |
| 30 | + worldgen::WorldGenPlugin, | |
| 29 | 31 | tilemap::TilemapPlugin, |
| 30 | 32 | camera::CameraPlugin, |
| 31 | 33 | colonist::ColonistPlugin, |
src/noise.rs +91 -0
| @@ -0,0 +1,91 @@ | ||
| 1 | +use bevy::prelude::*; | |
| 2 | + | |
| 3 | +use crate::world::hash2; | |
| 4 | + | |
| 5 | +#[derive(Clone, Copy, Debug)] | |
| 6 | +pub struct Noise { | |
| 7 | + seed: u32, | |
| 8 | +} | |
| 9 | + | |
| 10 | +impl Noise { | |
| 11 | + pub fn new(seed: u32) -> Self { | |
| 12 | + Self { seed } | |
| 13 | + } | |
| 14 | + | |
| 15 | + fn gradient(&self, cell: IVec2) -> Vec2 { | |
| 16 | + let h = hash2( | |
| 17 | + (cell.x as u32).wrapping_add(self.seed.wrapping_mul(0x632B_E5AB)), | |
| 18 | + (cell.y as u32) ^ self.seed.rotate_left(16), | |
| 19 | + ); | |
| 20 | + Vec2::from_angle(h as f32 / u32::MAX as f32 * std::f32::consts::TAU) | |
| 21 | + } | |
| 22 | + | |
| 23 | + pub fn sample(&self, p: Vec2) -> f32 { | |
| 24 | + let cell = p.floor(); | |
| 25 | + let f = p - cell; | |
| 26 | + let cell = cell.as_ivec2(); | |
| 27 | + let corner = |offset: IVec2| self.gradient(cell + offset).dot(f - offset.as_vec2()); | |
| 28 | + let u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); | |
| 29 | + let bottom = corner(IVec2::new(0, 0)).lerp(corner(IVec2::new(1, 0)), u.x); | |
| 30 | + let top = corner(IVec2::new(0, 1)).lerp(corner(IVec2::new(1, 1)), u.x); | |
| 31 | + (bottom.lerp(top, u.y) * std::f32::consts::SQRT_2).clamp(-1.0, 1.0) | |
| 32 | + } | |
| 33 | + | |
| 34 | + pub fn fbm(&self, p: Vec2, octaves: u32) -> f32 { | |
| 35 | + let (mut sum, mut amplitude, mut frequency, mut total) = (0.0, 1.0, 1.0, 0.0); | |
| 36 | + for octave in 0..octaves { | |
| 37 | + let layer = Noise::new(self.seed.wrapping_add(octave)); | |
| 38 | + sum += layer.sample(p * frequency) * amplitude; | |
| 39 | + total += amplitude; | |
| 40 | + amplitude *= 0.5; | |
| 41 | + frequency *= 2.0; | |
| 42 | + } | |
| 43 | + sum / total | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +#[cfg(test)] | |
| 48 | +mod tests { | |
| 49 | + use super::*; | |
| 50 | + | |
| 51 | + fn grid() -> impl Iterator<Item = Vec2> { | |
| 52 | + (0..40).flat_map(|y| (0..40).map(move |x| Vec2::new(x as f32 * 0.37, y as f32 * 0.29))) | |
| 53 | + } | |
| 54 | + | |
| 55 | + #[test] | |
| 56 | + fn noise_is_deterministic_per_seed() { | |
| 57 | + let (a, b, c) = (Noise::new(3), Noise::new(3), Noise::new(4)); | |
| 58 | + assert!(grid().all(|p| a.fbm(p, 4) == b.fbm(p, 4))); | |
| 59 | + assert!(grid().any(|p| a.fbm(p, 4) != c.fbm(p, 4))); | |
| 60 | + } | |
| 61 | + | |
| 62 | + #[test] | |
| 63 | + fn noise_stays_in_range() { | |
| 64 | + let noise = Noise::new(9); | |
| 65 | + assert!(grid().all(|p| (-1.0..=1.0).contains(&noise.sample(p)))); | |
| 66 | + assert!(grid().all(|p| (-1.0..=1.0).contains(&noise.fbm(p, 5)))); | |
| 67 | + } | |
| 68 | + | |
| 69 | + #[test] | |
| 70 | + fn noise_is_zero_on_lattice_points() { | |
| 71 | + let noise = Noise::new(1); | |
| 72 | + assert_eq!(noise.sample(Vec2::new(5.0, -3.0)), 0.0); | |
| 73 | + } | |
| 74 | + | |
| 75 | + // Terrain is thresholded from noise; discontinuities would show up as speckled, noisy borders. | |
| 76 | + #[test] | |
| 77 | + fn noise_is_continuous() { | |
| 78 | + let noise = Noise::new(5); | |
| 79 | + for p in grid() { | |
| 80 | + let d = (noise.sample(p) - noise.sample(p + Vec2::splat(0.001))).abs(); | |
| 81 | + assert!(d < 0.01, "jump of {d} at {p}"); | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + #[test] | |
| 86 | + fn negative_coordinates_work() { | |
| 87 | + let noise = Noise::new(2); | |
| 88 | + let values: Vec<f32> = (0..20).map(|i| noise.sample(Vec2::new(-i as f32 * 0.7 - 0.3, -2.4))).collect(); | |
| 89 | + assert!(values.iter().any(|v| *v != 0.0)); | |
| 90 | + } | |
| 91 | +} | |
src/pathfinding.rs +4 -4
| @@ -200,10 +200,10 @@ mod tests { | ||
| 200 | 200 | } |
| 201 | 201 | |
| 202 | 202 | #[test] |
| 203 | - fn finds_paths_across_the_static_world() { | |
| 204 | - let map = crate::world::static_world(); | |
| 205 | - let start = UVec2::new(10, 10); | |
| 206 | - let goal = UVec2::new(120, 70); | |
| 203 | + fn finds_paths_across_a_generated_world() { | |
| 204 | + let map = crate::worldgen::generate(crate::worldgen::WORLD_SIZE, 1); | |
| 205 | + let region = crate::worldgen::largest_region(&map); | |
| 206 | + let (start, goal) = (region[0], region[region.len() - 1]); | |
| 207 | 207 | let path = find_path(&map, start, goal).unwrap(); |
| 208 | 208 | assert!(is_connected(start, &path)); |
| 209 | 209 | assert_eq!(path.last(), Some(&goal)); |
src/selection.rs +12 -1
| @@ -4,6 +4,7 @@ use crate::{ | ||
| 4 | 4 | camera::CameraController, |
| 5 | 5 | colonist::{Colonist, MoveOrder, Path, TilePos}, |
| 6 | 6 | world::{TILE_SIZE, WorldMap}, |
| 7 | + worldgen::{WorldGenerated, WorldSeed}, | |
| 7 | 8 | }; |
| 8 | 9 | |
| 9 | 10 | pub struct SelectionPlugin; |
| @@ -14,6 +15,10 @@ impl Plugin for SelectionPlugin { | ||
| 14 | 15 | .init_resource::<Selection>() |
| 15 | 16 | .add_systems(Startup, spawn_tile_info) |
| 16 | 17 | .add_systems( |
| 18 | + Update, | |
| 19 | + clear_selection.run_if(on_message::<WorldGenerated>), | |
| 20 | + ) | |
| 21 | + .add_systems( | |
| 17 | 22 | PostUpdate, |
| 18 | 23 | ( |
| 19 | 24 | update_cursor, |
| @@ -65,6 +70,10 @@ fn update_cursor( | ||
| 65 | 70 | cursor.set_if_neq(Cursor { screen, world, tile }); |
| 66 | 71 | } |
| 67 | 72 | |
| 73 | +fn clear_selection(mut selection: ResMut<Selection>) { | |
| 74 | + *selection = Selection::None; | |
| 75 | +} | |
| 76 | + | |
| 68 | 77 | fn update_selection( |
| 69 | 78 | buttons: Res<ButtonInput<MouseButton>>, |
| 70 | 79 | keys: Res<ButtonInput<KeyCode>>, |
| @@ -170,6 +179,7 @@ fn update_tile_info( | ||
| 170 | 179 | selection: Res<Selection>, |
| 171 | 180 | colonists: Query<(&Colonist, &TilePos, &Path)>, |
| 172 | 181 | time: Res<Time<Virtual>>, |
| 182 | + seed: Res<WorldSeed>, | |
| 173 | 183 | mut text: Single<&mut Text, With<TileInfoText>>, |
| 174 | 184 | ) { |
| 175 | 185 | let selected = match *selection { |
| @@ -182,7 +192,8 @@ fn update_tile_info( | ||
| 182 | 192 | }; |
| 183 | 193 | let paused = if time.is_paused() { "PAUSED\n" } else { "" }; |
| 184 | 194 | let info = format!( |
| 185 | - "{paused}Hover: {}\nSelected: {selected}", | |
| 195 | + "{paused}Seed: {} (R: new world)\nHover: {}\nSelected: {selected}", | |
| 196 | + seed.0, | |
| 186 | 197 | describe_tile(&map, cursor.tile) |
| 187 | 198 | ); |
| 188 | 199 | if text.0 != info { |
src/tilemap.rs +44 -3
| @@ -5,7 +5,10 @@ use bevy::{ | ||
| 5 | 5 | sprite_render::{TileData, TilemapChunk, TilemapChunkTileData}, |
| 6 | 6 | }; |
| 7 | 7 | |
| 8 | -use crate::world::{hash2, Terrain, WorldMap, TILE_SIZE}; | |
| 8 | +use crate::{ | |
| 9 | + world::{TILE_SIZE, Terrain, WorldMap, hash2}, | |
| 10 | + worldgen::WorldGenerated, | |
| 11 | +}; | |
| 9 | 12 | |
| 10 | 13 | pub const CHUNK_SIZE: u32 = 64; |
| 11 | 14 | const TEXTURE_SIZE: u32 = 16; |
| @@ -14,20 +17,28 @@ pub struct TilemapPlugin; | ||
| 14 | 17 | |
| 15 | 18 | impl Plugin for TilemapPlugin { |
| 16 | 19 | fn build(&self, app: &mut App) { |
| 17 | - app.add_systems(Startup, spawn_chunks); | |
| 20 | + app.add_systems(Startup, spawn_chunks).add_systems( | |
| 21 | + Update, | |
| 22 | + refresh_chunks.run_if(on_message::<WorldGenerated>), | |
| 23 | + ); | |
| 18 | 24 | } |
| 19 | 25 | } |
| 20 | 26 | |
| 27 | +#[derive(Component)] | |
| 28 | +struct Chunk { | |
| 29 | + origin: UVec2, | |
| 30 | +} | |
| 31 | + | |
| 21 | 32 | fn spawn_chunks(mut commands: Commands, map: Res<WorldMap>, mut images: ResMut<Assets<Image>>) { |
| 22 | 33 | let tileset = images.add(build_tileset()); |
| 23 | 34 | let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE; |
| 24 | - | |
| 25 | 35 | for cy in 0..chunks.y { |
| 26 | 36 | for cx in 0..chunks.x { |
| 27 | 37 | let origin = UVec2::new(cx, cy) * CHUNK_SIZE; |
| 28 | 38 | let center = (origin.as_vec2() + CHUNK_SIZE as f32 / 2.0) * TILE_SIZE; |
| 29 | 39 | commands.spawn(( |
| 30 | 40 | Name::new(format!("Chunk {cx},{cy}")), |
| 41 | + Chunk { origin }, | |
| 31 | 42 | TilemapChunk { |
| 32 | 43 | chunk_size: UVec2::splat(CHUNK_SIZE), |
| 33 | 44 | tile_display_size: UVec2::splat(TILE_SIZE as u32), |
| @@ -41,6 +52,12 @@ fn spawn_chunks(mut commands: Commands, map: Res<WorldMap>, mut images: ResMut<A | ||
| 41 | 52 | } |
| 42 | 53 | } |
| 43 | 54 | |
| 55 | +fn refresh_chunks(map: Res<WorldMap>, mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>) { | |
| 56 | + for (chunk, mut data) in &mut chunks { | |
| 57 | + data.0 = chunk_tiles(&map, chunk.origin); | |
| 58 | + } | |
| 59 | +} | |
| 60 | + | |
| 44 | 61 | pub fn chunk_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> { |
| 45 | 62 | (0..CHUNK_SIZE) |
| 46 | 63 | .flat_map(|y| (0..CHUNK_SIZE).map(move |x| origin + UVec2::new(x, y))) |
| @@ -114,6 +131,30 @@ mod tests { | ||
| 114 | 131 | assert!(tiles[CHUNK_SIZE as usize * 3].is_none()); |
| 115 | 132 | } |
| 116 | 133 | |
| 134 | + // Chunk entities live for the whole session. World changes (regeneration now, digging and | |
| 135 | + // building later) rewrite their tile data in place; TilemapChunk re-uploads it through change | |
| 136 | + // detection, so no entities, meshes or materials are recreated. | |
| 137 | + #[test] | |
| 138 | + fn regeneration_rewrites_tile_data_of_existing_chunks() { | |
| 139 | + let mut app = App::new(); | |
| 140 | + app.insert_resource(WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Grass)) | |
| 141 | + .add_message::<WorldGenerated>() | |
| 142 | + .add_systems(Update, refresh_chunks.run_if(on_message::<WorldGenerated>)); | |
| 143 | + let map = app.world().resource::<WorldMap>().clone(); | |
| 144 | + let chunk = app | |
| 145 | + .world_mut() | |
| 146 | + .spawn((Chunk { origin: UVec2::ZERO }, TilemapChunkTileData(chunk_tiles(&map, UVec2::ZERO)))) | |
| 147 | + .id(); | |
| 148 | + | |
| 149 | + *app.world_mut().resource_mut::<WorldMap>() = | |
| 150 | + WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Stone); | |
| 151 | + app.world_mut().write_message(WorldGenerated); | |
| 152 | + app.update(); | |
| 153 | + | |
| 154 | + let data = app.world().get::<TilemapChunkTileData>(chunk).unwrap(); | |
| 155 | + assert!(data.0.iter().all(|t| t.unwrap().tileset_index == Terrain::Stone.index())); | |
| 156 | + } | |
| 157 | + | |
| 117 | 158 | #[test] |
| 118 | 159 | fn tileset_has_a_layer_for_every_terrain() { |
| 119 | 160 | let image = build_tileset(); |
src/world.rs +2 -40
| @@ -112,37 +112,6 @@ pub fn hash2(x: u32, y: u32) -> u32 { | ||
| 112 | 112 | h ^ (h >> 15) |
| 113 | 113 | } |
| 114 | 114 | |
| 115 | -pub fn static_world() -> WorldMap { | |
| 116 | - let size = UVec2::new(192, 128); | |
| 117 | - let mut map = WorldMap::filled(size, Terrain::Grass); | |
| 118 | - let lake = Vec2::new(140.0, 40.0); | |
| 119 | - let mountain = Vec2::new(150.0, 105.0); | |
| 120 | - | |
| 121 | - for pos in map.positions() { | |
| 122 | - let p = pos.as_vec2(); | |
| 123 | - let river_x = 60.0 + 8.0 * (p.y / 14.0).sin(); | |
| 124 | - let river_dist = (p.x - river_x).abs(); | |
| 125 | - let lake_dist = p.distance(lake); | |
| 126 | - let mountain_dist = ((p - mountain) / Vec2::new(36.0, 20.0)).length(); | |
| 127 | - | |
| 128 | - let terrain = if mountain_dist < 1.0 { | |
| 129 | - Terrain::Stone | |
| 130 | - } else if lake_dist < 9.0 { | |
| 131 | - Terrain::DeepWater | |
| 132 | - } else if lake_dist < 14.0 || river_dist < 2.5 { | |
| 133 | - Terrain::ShallowWater | |
| 134 | - } else if lake_dist < 17.0 || river_dist < 4.0 { | |
| 135 | - Terrain::Sand | |
| 136 | - } else if hash2(pos.x / 6, pos.y / 6) % 7 == 0 { | |
| 137 | - Terrain::Soil | |
| 138 | - } else { | |
| 139 | - Terrain::Grass | |
| 140 | - }; | |
| 141 | - map.set(pos, terrain); | |
| 142 | - } | |
| 143 | - map | |
| 144 | -} | |
| 145 | - | |
| 146 | 115 | #[cfg(test)] |
| 147 | 116 | mod tests { |
| 148 | 117 | use super::*; |
| @@ -165,7 +134,7 @@ mod tests { | ||
| 165 | 134 | |
| 166 | 135 | #[test] |
| 167 | 136 | fn tile_at_inverts_tile_center() { |
| 168 | - let map = static_world(); | |
| 137 | + let map = WorldMap::filled(UVec2::new(32, 128), Terrain::Grass); | |
| 169 | 138 | for pos in [UVec2::ZERO, UVec2::new(17, 99), map.size - 1] { |
| 170 | 139 | assert_eq!(map.tile_at(WorldMap::tile_center(pos)), Some(pos)); |
| 171 | 140 | } |
| @@ -173,7 +142,7 @@ mod tests { | ||
| 173 | 142 | |
| 174 | 143 | #[test] |
| 175 | 144 | fn tile_at_outside_world_is_none() { |
| 176 | - let map = static_world(); | |
| 145 | + let map = WorldMap::filled(UVec2::new(32, 128), Terrain::Grass); | |
| 177 | 146 | assert_eq!(map.tile_at(Vec2::new(-0.1, 0.0)), None); |
| 178 | 147 | assert_eq!(map.tile_at(map.world_size()), None); |
| 179 | 148 | } |
| @@ -185,11 +154,4 @@ mod tests { | ||
| 185 | 154 | } |
| 186 | 155 | } |
| 187 | 156 | |
| 188 | - #[test] | |
| 189 | - fn static_world_contains_every_terrain() { | |
| 190 | - let map = static_world(); | |
| 191 | - for t in Terrain::ALL { | |
| 192 | - assert!(map.positions().any(|p| map.get(p.as_ivec2()) == Some(t)), "{t:?}"); | |
| 193 | - } | |
| 194 | - } | |
| 195 | 157 | } |
src/worldgen.rs +223 -0
| @@ -0,0 +1,223 @@ | ||
| 1 | +use std::collections::VecDeque; | |
| 2 | + | |
| 3 | +use bevy::prelude::*; | |
| 4 | + | |
| 5 | +use crate::{ | |
| 6 | + noise::Noise, | |
| 7 | + world::{Terrain, WorldMap}, | |
| 8 | +}; | |
| 9 | + | |
| 10 | +pub const WORLD_SIZE: UVec2 = UVec2::new(256, 256); | |
| 11 | +pub const DEFAULT_SEED: u32 = 1; | |
| 12 | + | |
| 13 | +const ELEVATION_SCALE: f32 = 1.0 / 48.0; | |
| 14 | +const FERTILITY_SCALE: f32 = 1.0 / 16.0; | |
| 15 | +const DEEP_WATER_SHARE: f32 = 0.10; | |
| 16 | +const SHALLOW_WATER_SHARE: f32 = 0.06; | |
| 17 | +const SAND_SHARE: f32 = 0.04; | |
| 18 | +const STONE_SHARE: f32 = 0.14; | |
| 19 | +const SOIL_SHARE: f32 = 0.20; | |
| 20 | + | |
| 21 | +pub struct WorldGenPlugin; | |
| 22 | + | |
| 23 | +impl Plugin for WorldGenPlugin { | |
| 24 | + fn build(&self, app: &mut App) { | |
| 25 | + app.insert_resource(WorldSeed(DEFAULT_SEED)) | |
| 26 | + .insert_resource(generate(WORLD_SIZE, DEFAULT_SEED)) | |
| 27 | + .add_message::<WorldGenerated>() | |
| 28 | + .add_systems(Startup, |mut generated: MessageWriter<WorldGenerated>| { | |
| 29 | + generated.write(WorldGenerated); | |
| 30 | + }) | |
| 31 | + .add_systems(Update, regenerate_on_key); | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +#[derive(Resource, Debug, Clone, Copy, PartialEq)] | |
| 36 | +pub struct WorldSeed(pub u32); | |
| 37 | + | |
| 38 | +#[derive(Message, Debug, Clone, Copy)] | |
| 39 | +pub struct WorldGenerated; | |
| 40 | + | |
| 41 | +fn regenerate_on_key( | |
| 42 | + keys: Res<ButtonInput<KeyCode>>, | |
| 43 | + mut seed: ResMut<WorldSeed>, | |
| 44 | + mut map: ResMut<WorldMap>, | |
| 45 | + mut generated: MessageWriter<WorldGenerated>, | |
| 46 | +) { | |
| 47 | + if keys.just_pressed(KeyCode::KeyR) { | |
| 48 | + seed.0 = seed.0.wrapping_add(1); | |
| 49 | + *map = generate(map.size, seed.0); | |
| 50 | + generated.write(WorldGenerated); | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +pub fn generate(size: UVec2, seed: u32) -> WorldMap { | |
| 55 | + let elevation_noise = Noise::new(seed); | |
| 56 | + let fertility_noise = Noise::new(seed.wrapping_add(0x1000)); | |
| 57 | + let mut map = WorldMap::filled(size, Terrain::Grass); | |
| 58 | + let positions: Vec<UVec2> = map.positions().collect(); | |
| 59 | + let elevation: Vec<f32> = positions | |
| 60 | + .iter() | |
| 61 | + .map(|p| elevation_noise.fbm(p.as_vec2() * ELEVATION_SCALE, 5)) | |
| 62 | + .collect(); | |
| 63 | + let fertility: Vec<f32> = positions | |
| 64 | + .iter() | |
| 65 | + .map(|p| fertility_noise.fbm(p.as_vec2() * FERTILITY_SCALE, 3)) | |
| 66 | + .collect(); | |
| 67 | + | |
| 68 | + let deep = quantile(&elevation, DEEP_WATER_SHARE); | |
| 69 | + let shallow = quantile(&elevation, DEEP_WATER_SHARE + SHALLOW_WATER_SHARE); | |
| 70 | + let sand = quantile(&elevation, DEEP_WATER_SHARE + SHALLOW_WATER_SHARE + SAND_SHARE); | |
| 71 | + let stone = quantile(&elevation, 1.0 - STONE_SHARE); | |
| 72 | + let soil = quantile(&fertility, 1.0 - SOIL_SHARE); | |
| 73 | + | |
| 74 | + for ((&pos, &e), &f) in positions.iter().zip(&elevation).zip(&fertility) { | |
| 75 | + let terrain = if e < deep { | |
| 76 | + Terrain::DeepWater | |
| 77 | + } else if e < shallow { | |
| 78 | + Terrain::ShallowWater | |
| 79 | + } else if e < sand { | |
| 80 | + Terrain::Sand | |
| 81 | + } else if e >= stone { | |
| 82 | + Terrain::Stone | |
| 83 | + } else if f >= soil { | |
| 84 | + Terrain::Soil | |
| 85 | + } else { | |
| 86 | + Terrain::Grass | |
| 87 | + }; | |
| 88 | + map.set(pos, terrain); | |
| 89 | + } | |
| 90 | + map | |
| 91 | +} | |
| 92 | + | |
| 93 | +pub fn quantile(values: &[f32], share: f32) -> f32 { | |
| 94 | + let mut sorted = values.to_vec(); | |
| 95 | + sorted.sort_by(f32::total_cmp); | |
| 96 | + let i = ((sorted.len() as f32 * share) as usize).min(sorted.len() - 1); | |
| 97 | + sorted[i] | |
| 98 | +} | |
| 99 | + | |
| 100 | +pub fn largest_region(map: &WorldMap) -> Vec<UVec2> { | |
| 101 | + let mut seen = vec![false; map.size.element_product() as usize]; | |
| 102 | + let index = |p: UVec2| (p.y * map.size.x + p.x) as usize; | |
| 103 | + let mut best = Vec::new(); | |
| 104 | + for start in map.positions() { | |
| 105 | + if seen[index(start)] || map.move_cost(start.as_ivec2()).is_none() { | |
| 106 | + continue; | |
| 107 | + } | |
| 108 | + let region = flood(map, start, |p| { | |
| 109 | + let fresh = !seen[index(p)]; | |
| 110 | + seen[index(p)] = true; | |
| 111 | + fresh | |
| 112 | + }); | |
| 113 | + if region.len() > best.len() { | |
| 114 | + best = region; | |
| 115 | + } | |
| 116 | + } | |
| 117 | + best | |
| 118 | +} | |
| 119 | + | |
| 120 | +fn flood(map: &WorldMap, start: UVec2, mut visit: impl FnMut(UVec2) -> bool) -> Vec<UVec2> { | |
| 121 | + let mut region = Vec::new(); | |
| 122 | + let mut queue = VecDeque::from([start]); | |
| 123 | + visit(start); | |
| 124 | + while let Some(pos) = queue.pop_front() { | |
| 125 | + region.push(pos); | |
| 126 | + for step in [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y] { | |
| 127 | + let next = pos.as_ivec2() + step; | |
| 128 | + if map.move_cost(next).is_some() && visit(next.as_uvec2()) { | |
| 129 | + queue.push_back(next.as_uvec2()); | |
| 130 | + } | |
| 131 | + } | |
| 132 | + } | |
| 133 | + region | |
| 134 | +} | |
| 135 | + | |
| 136 | +pub fn spawn_tiles(map: &WorldMap, count: usize) -> Vec<UVec2> { | |
| 137 | + let center = map.size.as_vec2() / 2.0; | |
| 138 | + let Some(&origin) = largest_region(map) | |
| 139 | + .iter() | |
| 140 | + .min_by(|a, b| a.as_vec2().distance_squared(center).total_cmp(&b.as_vec2().distance_squared(center))) | |
| 141 | + else { | |
| 142 | + return Vec::new(); | |
| 143 | + }; | |
| 144 | + let mut seen = std::collections::HashSet::new(); | |
| 145 | + flood(map, origin, |p| seen.insert(p)) | |
| 146 | + .into_iter() | |
| 147 | + .step_by(2) | |
| 148 | + .take(count) | |
| 149 | + .collect() | |
| 150 | +} | |
| 151 | + | |
| 152 | +#[cfg(test)] | |
| 153 | +mod tests { | |
| 154 | + use super::*; | |
| 155 | + use crate::pathfinding::find_path; | |
| 156 | + | |
| 157 | + const SIZE: UVec2 = UVec2::new(96, 64); | |
| 158 | + | |
| 159 | + fn share(map: &WorldMap, terrain: Terrain) -> f32 { | |
| 160 | + let n = map.positions().filter(|p| map.get(p.as_ivec2()) == Some(terrain)).count(); | |
| 161 | + n as f32 / map.size.element_product() as f32 | |
| 162 | + } | |
| 163 | + | |
| 164 | + #[test] | |
| 165 | + fn same_seed_generates_same_world() { | |
| 166 | + let (a, b, c) = (generate(SIZE, 7), generate(SIZE, 7), generate(SIZE, 8)); | |
| 167 | + let tiles = |m: &WorldMap| m.positions().map(|p| m.get(p.as_ivec2())).collect::<Vec<_>>(); | |
| 168 | + assert_eq!(tiles(&a), tiles(&b)); | |
| 169 | + assert_ne!(tiles(&a), tiles(&c)); | |
| 170 | + } | |
| 171 | + | |
| 172 | + // Elevation thresholds come from quantiles rather than fixed values, so every seed gets the | |
| 173 | + // same terrain mix instead of occasionally being all ocean or all mountain. | |
| 174 | + #[test] | |
| 175 | + fn terrain_shares_are_stable_across_seeds() { | |
| 176 | + for seed in 0..5 { | |
| 177 | + let map = generate(SIZE, seed); | |
| 178 | + assert!((share(&map, Terrain::DeepWater) - DEEP_WATER_SHARE).abs() < 0.02); | |
| 179 | + assert!((share(&map, Terrain::Stone) - STONE_SHARE).abs() < 0.02); | |
| 180 | + for terrain in Terrain::ALL { | |
| 181 | + assert!(share(&map, terrain) > 0.0, "seed {seed} lacks {terrain:?}"); | |
| 182 | + } | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + #[test] | |
| 187 | + fn quantile_picks_value_at_share() { | |
| 188 | + let values: Vec<f32> = (0..10).rev().map(|v| v as f32).collect(); | |
| 189 | + assert_eq!(quantile(&values, 0.0), 0.0); | |
| 190 | + assert_eq!(quantile(&values, 0.3), 3.0); | |
| 191 | + assert_eq!(quantile(&values, 1.0), 9.0); | |
| 192 | + } | |
| 193 | + | |
| 194 | + #[test] | |
| 195 | + fn largest_region_picks_biggest_walkable_area() { | |
| 196 | + let mut map = WorldMap::filled(UVec2::new(6, 3), Terrain::Grass); | |
| 197 | + for y in 0..3 { | |
| 198 | + map.set(UVec2::new(2, y), Terrain::Stone); | |
| 199 | + } | |
| 200 | + let region = largest_region(&map); | |
| 201 | + assert_eq!(region.len(), 9); | |
| 202 | + assert!(region.iter().all(|p| p.x > 2)); | |
| 203 | + } | |
| 204 | + | |
| 205 | + // Colonists must start somewhere they can all reach each other and most of the map, | |
| 206 | + // not on a small island cut off by deep water or stone. | |
| 207 | + #[test] | |
| 208 | + fn spawn_tiles_are_distinct_walkable_and_mutually_reachable() { | |
| 209 | + for seed in 0..5 { | |
| 210 | + let map = generate(SIZE, seed); | |
| 211 | + let tiles = spawn_tiles(&map, 3); | |
| 212 | + assert_eq!(tiles.len(), 3); | |
| 213 | + let region = largest_region(&map); | |
| 214 | + for t in &tiles { | |
| 215 | + assert!(region.contains(t)); | |
| 216 | + assert!(find_path(&map, tiles[0], *t).is_some()); | |
| 217 | + } | |
| 218 | + let mut unique = tiles.clone(); | |
| 219 | + unique.dedup(); | |
| 220 | + assert_eq!(unique.len(), 3); | |
| 221 | + } | |
| 222 | + } | |
| 223 | +} | |