Add procedural world generation with regenerate key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

@@ -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 +}