Add heightmap, rivers, ore veins and trees to worldgen
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 08:58 UTC
- commit
- 43e70287599a319c76dcde4943ffba888eb7bb86
- parent
- 3f9920c94c
- tree
- browse at this commit
8 files changed +637 -84
| CLAUDE.md | +3 -3 | |
| src/colonist.rs | +4 -1 | |
| src/pathfinding.rs | +12 -1 | |
| src/selection.rs | +23 -7 | |
| src/tilemap.rs | +178 -48 | |
| src/world.rs | +106 -5 | |
| src/worldgen.rs | +307 -17 | |
| tasks.md | +4 -2 |
CLAUDE.md +3 -3
| @@ -11,10 +11,10 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 11 | 11 | |
| 12 | 12 | ## Layout |
| 13 | 13 | - `src/main.rs` app wiring: DefaultPlugins (nearest sampling, canvas `#bevy`, fit_canvas_to_parent), adds plugins. |
| 14 | -- `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters), `WorldMap` resource (row-major, (0,0)=bottom-left, y up), `TILE_SIZE`=16 world units, `hash2` deterministic int hash. | |
| 14 | +- `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters; rock = Stone/IronOre/GoldOre, impassable), `Plant` enum (Tree; separate layer, adds move cost). `WorldMap` resource: per-tile terrain, `Option<Plant>`, height in meters (0..`MAX_HEIGHT`=100); row-major, (0,0)=bottom-left, y up. `move_cost(pos)` = terrain + plant cost (None = impassable); `step_cost(from,to)` adds `UPHILL_COST_PER_METER` × climb — use step_cost for anything that moves. `TILE_SIZE`=16 world units, `hash2` deterministic int hash. | |
| 15 | 15 | - `src/noise.rs` seeded 2D gradient (Perlin-style, quintic fade) noise + `fbm` (octave i uses seed+i). Range [-1,1]. |
| 16 | -- `src/worldgen.rs` `WorldGenPlugin` inserts `WorldSeed` + `WorldMap` (256x256) at build time and writes `WorldGenerated` message at Startup; R → seed+1, regenerate, write message. `generate(size, seed)`: elevation fbm → deep/shallow/sand/stone by **quantile** thresholds (fixed terrain shares per seed), fertility fbm top 20% → soil. `largest_region` (4-conn flood of walkable), `spawn_tiles(map, n)` = BFS from largest-region tile nearest map center. Everything reacting to a new world runs `.run_if(on_message::<WorldGenerated>)`: tilemap refresh, colonist respawn, camera recenter, selection clear. | |
| 17 | -- `src/tilemap.rs` renders map as `TilemapChunk`s (bevy::sprite_render) of `CHUNK_SIZE`=64; chunk transform = chunk center (TilemapChunk is centered on its transform). Tile (x,y) world center = (pos+0.5)*TILE_SIZE, so map spans [0, size*TILE_SIZE]. Tileset is generated in code (no assets): stacked 16x16 layers -> `reinterpret_stacked_2d_as_array`. Per-tile tint via `TileData.color`. Chunks spawned once at Startup (`Chunk{origin}`); world changes overwrite `TilemapChunkTileData` in place (bevy's `update_tilemap_chunk_indices` re-uploads via change detection). | |
| 16 | +- `src/worldgen.rs` `WorldGenPlugin` inserts `WorldSeed` + `WorldMap` (256x256) at build time and writes `WorldGenerated` message at Startup; R → seed+1, regenerate, write message. `generate(size, seed)`: elevation fbm → height (min-max normalised to meters) and deep/shallow/sand/rock by **quantile** thresholds (fixed terrain shares per seed); ore = quantiles of two ore noises among rock tiles (gold 3%, iron 10%); fertility fbm top 20% → soil; rivers; trees (forest noise top 35% → 45% tree chance, else 3%; only Grass/Soil, placed after rivers). Rivers: `FlowField` = priority flood from water + non-rock edge tiles over non-rock tiles, each tile gets a downstream neighbour (spills out of depressions at lowest rim); `pick_rivers` traces 40 random mountain-foot sources, keeps the 4 longest spaced ≥40 apart; `carve_river` → ShallowWater, wider downstream, never over rock/deep water. `largest_region` (4-conn flood of walkable), `spawn_tiles(map, n)` = BFS from largest-region tile nearest map center. Everything reacting to a new world runs `.run_if(on_message::<WorldGenerated>)`: tilemap refresh, colonist respawn, camera recenter, selection clear. | |
| 17 | +- `src/tilemap.rs` renders map as `TilemapChunk`s (bevy::sprite_render) of `CHUNK_SIZE`=64; chunk transform = chunk center (TilemapChunk is centered on its transform). Tile (x,y) world center = (pos+0.5)*TILE_SIZE, so map spans [0, size*TILE_SIZE]. Tileset is generated in code (no assets): stacked 16x16 layers -> `reinterpret_stacked_2d_as_array`. Per-tile tint via `TileData.color`. Two layers of chunks (`Layer::Terrain` z=0, `Layer::Plants` z=0.5, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × `hillshade` (light from NW, `RELIEF` exaggeration) × height brightness, water unshaded; ore = stone texture with coloured specks; world changes overwrite `TilemapChunkTileData` in place (bevy's `update_tilemap_chunk_indices` re-uploads via change detection). | |
| 18 | 18 | - `src/camera.rs` `CameraController` on Camera2d: WASD/arrows pan (shift = fast, speed scaled by ortho scale), middle/right drag pan (from cursor delta, not AccumulatedMouseMotion — that doesn't fire for web), scroll zoom toward cursor (`zoom_towards`), camera center clamped to world rect. |
| 19 | 19 | - `src/selection.rs` `Cursor` resource (screen/world/tile) and `Selection` enum (None/Tile/Colonist(Entity)). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform). LMB: pick nearest colonist within `PICK_RADIUS` of cursor world pos, else tile, off-map clears; Esc clears. RMB click (press/release within `CLICK_SLOP_PX`, else it was a drag-pan) writes `MoveOrder` for selected colonist. Gizmos: hover rect, selected rect / colonist circle + remaining path linestrip. Top-left UI text (clock + speed/PAUSED, seed, hover, selection). Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`. |
| 20 | 20 | - `src/pathfinding.rs` `find_path(map, start, goal) -> Option<Vec<UVec2>>` A*, 8-dir, no corner cutting, cost = (10|14) * `Terrain::move_cost` of entered tile (100 = normal, None = impassable), octile heuristic. Path excludes start, includes goal; start==goal → empty. |
src/colonist.rs +4 -1
| @@ -154,7 +154,10 @@ fn follow_path( | ||
| 154 | 154 | let Some(&next) = path.0.front() else { |
| 155 | 155 | continue; |
| 156 | 156 | }; |
| 157 | - let cost = map.move_cost(next.as_ivec2()).unwrap_or(100) as f32 / 100.0; | |
| 157 | + let cost = map | |
| 158 | + .step_cost(tile.0.as_ivec2(), next.as_ivec2()) | |
| 159 | + .unwrap_or(100) as f32 | |
| 160 | + / 100.0; | |
| 158 | 161 | let max_distance = TILES_PER_SECOND * TILE_SIZE / cost * time.delta_secs(); |
| 159 | 162 | let target = WorldMap::tile_center(next); |
| 160 | 163 | let arrived; |
src/pathfinding.rs +12 -1
| @@ -46,7 +46,7 @@ pub fn find_path(map: &WorldMap, start: UVec2, goal: UVec2) -> Option<Vec<UVec2> | ||
| 46 | 46 | let current_cost = cost_so_far[index(current)]; |
| 47 | 47 | for step in NEIGHBORS { |
| 48 | 48 | let next = current + step; |
| 49 | - let Some(terrain_cost) = map.move_cost(next) else { | |
| 49 | + let Some(terrain_cost) = map.step_cost(current, next) else { | |
| 50 | 50 | continue; |
| 51 | 51 | }; |
| 52 | 52 | let diagonal = step.x != 0 && step.y != 0; |
| @@ -196,6 +196,17 @@ mod tests { | ||
| 196 | 196 | } |
| 197 | 197 | |
| 198 | 198 | #[test] |
| 199 | + fn walks_around_a_hill_rather_than_over_it() { | |
| 200 | + let mut map = map_from(&[ | |
| 201 | + ".....", // | |
| 202 | + ".....", | |
| 203 | + ]); | |
| 204 | + map.set_height(UVec2::new(2, 0), 10.0); | |
| 205 | + let path = find_path(&map, UVec2::new(0, 0), UVec2::new(4, 0)).unwrap(); | |
| 206 | + assert!(!path.contains(&UVec2::new(2, 0))); | |
| 207 | + } | |
| 208 | + | |
| 209 | + #[test] | |
| 199 | 210 | fn crosses_slow_terrain_when_it_is_the_only_way() { |
| 200 | 211 | let map = map_from(&["..~.."]); |
| 201 | 212 | let path = find_path(&map, UVec2::new(0, 0), UVec2::new(4, 0)).unwrap(); |
src/selection.rs +23 -7
| @@ -221,10 +221,17 @@ fn update_tile_info( | ||
| 221 | 221 | } |
| 222 | 222 | |
| 223 | 223 | pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String { |
| 224 | - match tile.and_then(|t| map.get(t.as_ivec2()).map(|terrain| (t, terrain))) { | |
| 225 | - Some((t, terrain)) => format!("({}, {}) {terrain:?}", t.x, t.y), | |
| 226 | - None => "-".into(), | |
| 227 | - } | |
| 224 | + let Some((t, terrain)) = tile.and_then(|t| map.get(t.as_ivec2()).map(|terrain| (t, terrain))) | |
| 225 | + else { | |
| 226 | + return "-".into(); | |
| 227 | + }; | |
| 228 | + let p = t.as_ivec2(); | |
| 229 | + let plant = map | |
| 230 | + .plant(p) | |
| 231 | + .map(|plant| format!(", {plant:?}")) | |
| 232 | + .unwrap_or_default(); | |
| 233 | + let height = map.height(p).unwrap_or(0.0); | |
| 234 | + format!("({}, {}) {terrain:?}{plant}, {height:.0} m", t.x, t.y) | |
| 228 | 235 | } |
| 229 | 236 | |
| 230 | 237 | pub fn describe_speed(speed: SimSpeed, paused: bool) -> String { |
| @@ -373,9 +380,18 @@ mod tests { | ||
| 373 | 380 | } |
| 374 | 381 | |
| 375 | 382 | #[test] |
| 376 | - fn describe_tile_shows_coordinates_and_terrain() { | |
| 377 | - let map = WorldMap::filled(UVec2::new(4, 4), Terrain::Sand); | |
| 378 | - assert_eq!(describe_tile(&map, Some(UVec2::new(1, 2))), "(1, 2) Sand"); | |
| 383 | + fn describe_tile_shows_coordinates_terrain_plant_and_height() { | |
| 384 | + let mut map = WorldMap::filled(UVec2::new(4, 4), Terrain::Sand); | |
| 385 | + map.set_height(UVec2::new(1, 2), 12.4); | |
| 386 | + assert_eq!( | |
| 387 | + describe_tile(&map, Some(UVec2::new(1, 2))), | |
| 388 | + "(1, 2) Sand, 12 m" | |
| 389 | + ); | |
| 390 | + map.set_plant(UVec2::new(1, 2), Some(crate::world::Plant::Tree)); | |
| 391 | + assert_eq!( | |
| 392 | + describe_tile(&map, Some(UVec2::new(1, 2))), | |
| 393 | + "(1, 2) Sand, Tree, 12 m" | |
| 394 | + ); | |
| 379 | 395 | assert_eq!(describe_tile(&map, None), "-"); |
| 380 | 396 | } |
| 381 | 397 | |
src/tilemap.rs +178 -48
| @@ -2,16 +2,20 @@ use bevy::{ | ||
| 2 | 2 | asset::RenderAssetUsages, |
| 3 | 3 | prelude::*, |
| 4 | 4 | render::render_resource::{Extent3d, TextureDimension, TextureFormat}, |
| 5 | - sprite_render::{TileData, TilemapChunk, TilemapChunkTileData}, | |
| 5 | + sprite_render::{AlphaMode2d, TileData, TilemapChunk, TilemapChunkTileData}, | |
| 6 | 6 | }; |
| 7 | 7 | |
| 8 | 8 | use crate::{ |
| 9 | - world::{TILE_SIZE, Terrain, WorldMap, hash2}, | |
| 9 | + world::{MAX_HEIGHT, Plant, TILE_SIZE, Terrain, WorldMap, hash2}, | |
| 10 | 10 | worldgen::WorldGenerated, |
| 11 | 11 | }; |
| 12 | 12 | |
| 13 | 13 | pub const CHUNK_SIZE: u32 = 64; |
| 14 | 14 | const TEXTURE_SIZE: u32 = 16; |
| 15 | +const RELIEF: f32 = 0.35; | |
| 16 | +const LIGHT: Vec3 = Vec3::new(-1.0, 1.0, 1.5); | |
| 17 | + | |
| 18 | +type Texture = Vec<[u8; 4]>; | |
| 15 | 19 | |
| 16 | 20 | pub struct TilemapPlugin; |
| 17 | 21 | |
| @@ -22,86 +26,186 @@ impl Plugin for TilemapPlugin { | ||
| 22 | 26 | } |
| 23 | 27 | } |
| 24 | 28 | |
| 29 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 30 | +enum Layer { | |
| 31 | + Terrain, | |
| 32 | + Plants, | |
| 33 | +} | |
| 34 | + | |
| 35 | +impl Layer { | |
| 36 | + fn z(self) -> f32 { | |
| 37 | + match self { | |
| 38 | + Layer::Terrain => 0.0, | |
| 39 | + Layer::Plants => 0.5, | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + fn tiles(self, map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> { | |
| 44 | + match self { | |
| 45 | + Layer::Terrain => chunk_tiles(map, origin), | |
| 46 | + Layer::Plants => plant_tiles(map, origin), | |
| 47 | + } | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 25 | 51 | #[derive(Component)] |
| 26 | 52 | struct Chunk { |
| 27 | 53 | origin: UVec2, |
| 54 | + layer: Layer, | |
| 28 | 55 | } |
| 29 | 56 | |
| 30 | 57 | fn spawn_chunks(mut commands: Commands, map: Res<WorldMap>, mut images: ResMut<Assets<Image>>) { |
| 31 | - let tileset = images.add(build_tileset()); | |
| 58 | + let terrain = images.add(stack_tileset( | |
| 59 | + Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect(), | |
| 60 | + )); | |
| 61 | + let plants = images.add(stack_tileset( | |
| 62 | + Plant::ALL.iter().map(|&p| plant_texture(p)).collect(), | |
| 63 | + )); | |
| 32 | 64 | let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE; |
| 33 | - for cy in 0..chunks.y { | |
| 34 | - for cx in 0..chunks.x { | |
| 35 | - let origin = UVec2::new(cx, cy) * CHUNK_SIZE; | |
| 36 | - let center = (origin.as_vec2() + CHUNK_SIZE as f32 / 2.0) * TILE_SIZE; | |
| 37 | - commands.spawn(( | |
| 38 | - Name::new(format!("Chunk {cx},{cy}")), | |
| 39 | - Chunk { origin }, | |
| 40 | - TilemapChunk { | |
| 41 | - chunk_size: UVec2::splat(CHUNK_SIZE), | |
| 42 | - tile_display_size: UVec2::splat(TILE_SIZE as u32), | |
| 43 | - tileset: tileset.clone(), | |
| 44 | - ..default() | |
| 45 | - }, | |
| 46 | - TilemapChunkTileData(chunk_tiles(&map, origin)), | |
| 47 | - Transform::from_translation(center.extend(0.0)), | |
| 48 | - )); | |
| 65 | + for (layer, tileset) in [(Layer::Terrain, terrain), (Layer::Plants, plants)] { | |
| 66 | + for cy in 0..chunks.y { | |
| 67 | + for cx in 0..chunks.x { | |
| 68 | + let origin = UVec2::new(cx, cy) * CHUNK_SIZE; | |
| 69 | + let center = (origin.as_vec2() + CHUNK_SIZE as f32 / 2.0) * TILE_SIZE; | |
| 70 | + commands.spawn(( | |
| 71 | + Name::new(format!("{layer:?} chunk {cx},{cy}")), | |
| 72 | + Chunk { origin, layer }, | |
| 73 | + TilemapChunk { | |
| 74 | + chunk_size: UVec2::splat(CHUNK_SIZE), | |
| 75 | + tile_display_size: UVec2::splat(TILE_SIZE as u32), | |
| 76 | + tileset: tileset.clone(), | |
| 77 | + alpha_mode: AlphaMode2d::Blend, | |
| 78 | + }, | |
| 79 | + TilemapChunkTileData(layer.tiles(&map, origin)), | |
| 80 | + Transform::from_translation(center.extend(layer.z())), | |
| 81 | + )); | |
| 82 | + } | |
| 49 | 83 | } |
| 50 | 84 | } |
| 51 | 85 | } |
| 52 | 86 | |
| 53 | 87 | fn refresh_chunks(map: Res<WorldMap>, mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>) { |
| 54 | 88 | for (chunk, mut data) in &mut chunks { |
| 55 | - data.0 = chunk_tiles(&map, chunk.origin); | |
| 89 | + data.0 = chunk.layer.tiles(&map, chunk.origin); | |
| 56 | 90 | } |
| 57 | 91 | } |
| 58 | 92 | |
| 93 | +fn chunk_positions(origin: UVec2) -> impl Iterator<Item = UVec2> { | |
| 94 | + (0..CHUNK_SIZE).flat_map(move |y| (0..CHUNK_SIZE).map(move |x| origin + UVec2::new(x, y))) | |
| 95 | +} | |
| 96 | + | |
| 59 | 97 | pub fn chunk_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> { |
| 60 | - (0..CHUNK_SIZE) | |
| 61 | - .flat_map(|y| (0..CHUNK_SIZE).map(move |x| origin + UVec2::new(x, y))) | |
| 98 | + chunk_positions(origin) | |
| 62 | 99 | .map(|pos| { |
| 63 | 100 | map.get(pos.as_ivec2()).map(|terrain| TileData { |
| 64 | 101 | tileset_index: terrain.index(), |
| 65 | - color: tile_tint(pos), | |
| 102 | + color: tint(jitter(pos) * shade(map, pos)), | |
| 66 | 103 | ..default() |
| 67 | 104 | }) |
| 68 | 105 | }) |
| 69 | 106 | .collect() |
| 70 | 107 | } |
| 71 | 108 | |
| 72 | -fn tile_tint(pos: UVec2) -> Color { | |
| 73 | - let v = 0.92 + (hash2(pos.x, pos.y) % 9) as f32 * 0.01; | |
| 109 | +pub fn plant_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> { | |
| 110 | + chunk_positions(origin) | |
| 111 | + .map(|pos| { | |
| 112 | + map.plant(pos.as_ivec2()).map(|plant| TileData { | |
| 113 | + tileset_index: plant.index(), | |
| 114 | + color: tint(jitter(pos) * hillshade(map, pos)), | |
| 115 | + ..default() | |
| 116 | + }) | |
| 117 | + }) | |
| 118 | + .collect() | |
| 119 | +} | |
| 120 | + | |
| 121 | +fn tint(v: f32) -> Color { | |
| 74 | 122 | Color::srgb(v, v, v) |
| 75 | 123 | } |
| 76 | 124 | |
| 77 | -pub fn tileset_layer_count(terrains: u32) -> u32 { | |
| 78 | - match terrains { | |
| 125 | +fn jitter(pos: UVec2) -> f32 { | |
| 126 | + 0.92 + (hash2(pos.x, pos.y) % 9) as f32 * 0.01 | |
| 127 | +} | |
| 128 | + | |
| 129 | +fn shade(map: &WorldMap, pos: UVec2) -> f32 { | |
| 130 | + match map.get(pos.as_ivec2()) { | |
| 131 | + Some(t) if t.is_water() => 1.0, | |
| 132 | + _ => hillshade(map, pos) * height_brightness(map, pos), | |
| 133 | + } | |
| 134 | +} | |
| 135 | + | |
| 136 | +fn height_brightness(map: &WorldMap, pos: UVec2) -> f32 { | |
| 137 | + 0.9 + 0.2 * map.height(pos.as_ivec2()).unwrap_or(0.0) / MAX_HEIGHT | |
| 138 | +} | |
| 139 | + | |
| 140 | +pub fn hillshade(map: &WorldMap, pos: UVec2) -> f32 { | |
| 141 | + let p = pos.as_ivec2(); | |
| 142 | + let center = map.height(p).unwrap_or(0.0); | |
| 143 | + let h = |d: IVec2| map.height(p + d).unwrap_or(center); | |
| 144 | + let dx = (h(IVec2::X) - h(IVec2::NEG_X)) / 2.0; | |
| 145 | + let dy = (h(IVec2::Y) - h(IVec2::NEG_Y)) / 2.0; | |
| 146 | + let normal = Vec3::new(-dx * RELIEF, -dy * RELIEF, 1.0).normalize(); | |
| 147 | + let light = LIGHT.normalize(); | |
| 148 | + (normal.dot(light) / light.z).clamp(0.6, 1.25) | |
| 149 | +} | |
| 150 | + | |
| 151 | +fn terrain_texture(terrain: Terrain) -> Texture { | |
| 152 | + let base = terrain.color(); | |
| 153 | + let layer = terrain.index() as u32; | |
| 154 | + (0..TEXTURE_SIZE * TEXTURE_SIZE) | |
| 155 | + .map(|i| { | |
| 156 | + let speck = terrain | |
| 157 | + .speck_color() | |
| 158 | + .filter(|_| hash2(i / 2 + i / 32, layer) % 5 == 0); | |
| 159 | + let jitter = (hash2(i, layer) % 16) as f32 / 255.0 - 8.0 / 255.0; | |
| 160 | + let c = speck.unwrap_or(base); | |
| 161 | + Srgba::new(c.red + jitter, c.green + jitter, c.blue + jitter, 1.0).to_u8_array() | |
| 162 | + }) | |
| 163 | + .collect() | |
| 164 | +} | |
| 165 | + | |
| 166 | +fn plant_texture(plant: Plant) -> Texture { | |
| 167 | + match plant { | |
| 168 | + Plant::Tree => tree_texture(), | |
| 169 | + } | |
| 170 | +} | |
| 171 | + | |
| 172 | +fn tree_texture() -> Texture { | |
| 173 | + let leaves = Srgba::rgb_u8(38, 92, 42); | |
| 174 | + let highlight = Srgba::rgb_u8(66, 126, 58); | |
| 175 | + let trunk = Srgba::rgb_u8(92, 64, 40); | |
| 176 | + let canopy_center = Vec2::new(7.5, 6.5); | |
| 177 | + (0..TEXTURE_SIZE * TEXTURE_SIZE) | |
| 178 | + .map(|i| { | |
| 179 | + let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); | |
| 180 | + let p = Vec2::new(x as f32, y as f32); | |
| 181 | + let from_center = p - canopy_center; | |
| 182 | + if from_center.length() <= 6.0 { | |
| 183 | + let lit = from_center.dot(Vec2::new(-1.0, -1.0)) > 3.0; | |
| 184 | + if lit { highlight } else { leaves }.to_u8_array() | |
| 185 | + } else if (7..=8).contains(&x) && y >= 12 && y <= 14 { | |
| 186 | + trunk.to_u8_array() | |
| 187 | + } else { | |
| 188 | + [0, 0, 0, 0] | |
| 189 | + } | |
| 190 | + }) | |
| 191 | + .collect() | |
| 192 | +} | |
| 193 | + | |
| 194 | +pub fn tileset_layer_count(textures: u32) -> u32 { | |
| 195 | + match textures { | |
| 79 | 196 | 0 | 1 => 2, |
| 80 | 197 | n if n % 6 == 0 => n + 1, |
| 81 | 198 | n => n, |
| 82 | 199 | } |
| 83 | 200 | } |
| 84 | 201 | |
| 85 | -fn build_tileset() -> Image { | |
| 86 | - let layers = tileset_layer_count(Terrain::ALL.len() as u32); | |
| 87 | - let data = (0..layers) | |
| 88 | - .flat_map(|layer| { | |
| 89 | - let base = Terrain::ALL | |
| 90 | - .get(layer as usize) | |
| 91 | - .map_or(Srgba::NONE, |t| t.color()); | |
| 92 | - (0..TEXTURE_SIZE * TEXTURE_SIZE).flat_map(move |i| { | |
| 93 | - let jitter = (hash2(i, layer) % 16) as f32 / 255.0 - 8.0 / 255.0; | |
| 94 | - let c = Srgba::new( | |
| 95 | - base.red + jitter, | |
| 96 | - base.green + jitter, | |
| 97 | - base.blue + jitter, | |
| 98 | - 1.0, | |
| 99 | - ); | |
| 100 | - c.to_u8_array() | |
| 101 | - }) | |
| 102 | - }) | |
| 202 | +fn stack_tileset(textures: Vec<Texture>) -> Image { | |
| 203 | + let layers = tileset_layer_count(textures.len() as u32); | |
| 204 | + let empty = vec![[0, 0, 0, 0]; (TEXTURE_SIZE * TEXTURE_SIZE) as usize]; | |
| 205 | + let data = (0..layers as usize) | |
| 206 | + .flat_map(|i| textures.get(i).unwrap_or(&empty).clone()) | |
| 207 | + .flatten() | |
| 103 | 208 | .collect(); |
| 104 | - | |
| 105 | 209 | let mut image = Image::new( |
| 106 | 210 | Extent3d { |
| 107 | 211 | width: TEXTURE_SIZE, |
| @@ -149,6 +253,7 @@ mod tests { | ||
| 149 | 253 | .spawn(( |
| 150 | 254 | Chunk { |
| 151 | 255 | origin: UVec2::ZERO, |
| 256 | + layer: Layer::Terrain, | |
| 152 | 257 | }, |
| 153 | 258 | TilemapChunkTileData(chunk_tiles(&map, UVec2::ZERO)), |
| 154 | 259 | )) |
| @@ -168,11 +273,36 @@ mod tests { | ||
| 168 | 273 | } |
| 169 | 274 | |
| 170 | 275 | #[test] |
| 171 | - fn tileset_has_a_layer_for_every_terrain() { | |
| 172 | - let image = build_tileset(); | |
| 276 | + fn tileset_has_a_layer_for_every_texture() { | |
| 277 | + let textures = Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect(); | |
| 278 | + let image = stack_tileset(textures); | |
| 173 | 279 | assert!(image.texture_descriptor.size.depth_or_array_layers >= Terrain::ALL.len() as u32); |
| 174 | 280 | } |
| 175 | 281 | |
| 282 | + #[test] | |
| 283 | + fn plant_layer_only_has_tiles_where_plants_grow() { | |
| 284 | + let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass); | |
| 285 | + map.set_plant(UVec2::new(1, 0), Some(Plant::Tree)); | |
| 286 | + let tiles = plant_tiles(&map, UVec2::ZERO); | |
| 287 | + assert!(tiles[0].is_none()); | |
| 288 | + assert_eq!(tiles[1].unwrap().tileset_index, Plant::Tree.index()); | |
| 289 | + } | |
| 290 | + | |
| 291 | + // Light comes from the north-west (LIGHT has -x, +y), the convention for hillshaded maps. | |
| 292 | + #[test] | |
| 293 | + fn slopes_facing_the_light_are_brighter_than_slopes_facing_away() { | |
| 294 | + let mut map = WorldMap::filled(UVec2::new(5, 1), Terrain::Grass); | |
| 295 | + for (x, h) in [0.0, 5.0, 10.0, 5.0, 0.0].into_iter().enumerate() { | |
| 296 | + map.set_height(UVec2::new(x as u32, 0), h); | |
| 297 | + } | |
| 298 | + let flat = WorldMap::filled(UVec2::new(3, 1), Terrain::Grass); | |
| 299 | + let facing_west = hillshade(&map, UVec2::new(1, 0)); | |
| 300 | + let facing_east = hillshade(&map, UVec2::new(3, 0)); | |
| 301 | + let level = hillshade(&flat, UVec2::new(1, 0)); | |
| 302 | + assert!(facing_west > level); | |
| 303 | + assert!(facing_east < level); | |
| 304 | + } | |
| 305 | + | |
| 176 | 306 | // wgpu's GLES backend (WebGL2) guesses the texture view dimension from the layer count: |
| 177 | 307 | // 1 layer => D2, 6 => Cube, multiples of 6 => CubeArray. The tilemap shader needs D2Array, |
| 178 | 308 | // so the tileset is padded with an unused layer to dodge those counts. |
src/world.rs +106 -5
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | use bevy::prelude::*; |
| 2 | 2 | |
| 3 | 3 | pub const TILE_SIZE: f32 = 16.0; |
| 4 | +pub const MAX_HEIGHT: f32 = 100.0; | |
| 5 | +pub const UPHILL_COST_PER_METER: f32 = 20.0; | |
| 4 | 6 | |
| 5 | 7 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 6 | 8 | pub enum Terrain { |
| @@ -10,16 +12,20 @@ pub enum Terrain { | ||
| 10 | 12 | ShallowWater, |
| 11 | 13 | DeepWater, |
| 12 | 14 | Stone, |
| 15 | + IronOre, | |
| 16 | + GoldOre, | |
| 13 | 17 | } |
| 14 | 18 | |
| 15 | 19 | impl Terrain { |
| 16 | - pub const ALL: [Terrain; 6] = [ | |
| 20 | + pub const ALL: [Terrain; 8] = [ | |
| 17 | 21 | Terrain::Grass, |
| 18 | 22 | Terrain::Soil, |
| 19 | 23 | Terrain::Sand, |
| 20 | 24 | Terrain::ShallowWater, |
| 21 | 25 | Terrain::DeepWater, |
| 22 | 26 | Terrain::Stone, |
| 27 | + Terrain::IronOre, | |
| 28 | + Terrain::GoldOre, | |
| 23 | 29 | ]; |
| 24 | 30 | |
| 25 | 31 | pub fn index(self) -> u16 { |
| @@ -31,10 +37,18 @@ impl Terrain { | ||
| 31 | 37 | Terrain::Grass | Terrain::Soil => Some(100), |
| 32 | 38 | Terrain::Sand => Some(130), |
| 33 | 39 | Terrain::ShallowWater => Some(300), |
| 34 | - Terrain::DeepWater | Terrain::Stone => None, | |
| 40 | + Terrain::DeepWater | Terrain::Stone | Terrain::IronOre | Terrain::GoldOre => None, | |
| 35 | 41 | } |
| 36 | 42 | } |
| 37 | 43 | |
| 44 | + pub fn is_water(self) -> bool { | |
| 45 | + matches!(self, Terrain::ShallowWater | Terrain::DeepWater) | |
| 46 | + } | |
| 47 | + | |
| 48 | + pub fn is_rock(self) -> bool { | |
| 49 | + matches!(self, Terrain::Stone | Terrain::IronOre | Terrain::GoldOre) | |
| 50 | + } | |
| 51 | + | |
| 38 | 52 | pub fn color(self) -> Srgba { |
| 39 | 53 | match self { |
| 40 | 54 | Terrain::Grass => Srgba::rgb_u8(92, 140, 64), |
| @@ -42,7 +56,34 @@ impl Terrain { | ||
| 42 | 56 | Terrain::Sand => Srgba::rgb_u8(214, 196, 140), |
| 43 | 57 | Terrain::ShallowWater => Srgba::rgb_u8(72, 132, 184), |
| 44 | 58 | Terrain::DeepWater => Srgba::rgb_u8(36, 76, 140), |
| 45 | - Terrain::Stone => Srgba::rgb_u8(118, 118, 124), | |
| 59 | + Terrain::Stone | Terrain::IronOre | Terrain::GoldOre => Srgba::rgb_u8(118, 118, 124), | |
| 60 | + } | |
| 61 | + } | |
| 62 | + | |
| 63 | + pub fn speck_color(self) -> Option<Srgba> { | |
| 64 | + match self { | |
| 65 | + Terrain::IronOre => Some(Srgba::rgb_u8(178, 104, 72)), | |
| 66 | + Terrain::GoldOre => Some(Srgba::rgb_u8(236, 196, 60)), | |
| 67 | + _ => None, | |
| 68 | + } | |
| 69 | + } | |
| 70 | +} | |
| 71 | + | |
| 72 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 73 | +pub enum Plant { | |
| 74 | + Tree, | |
| 75 | +} | |
| 76 | + | |
| 77 | +impl Plant { | |
| 78 | + pub const ALL: [Plant; 1] = [Plant::Tree]; | |
| 79 | + | |
| 80 | + pub fn index(self) -> u16 { | |
| 81 | + self as u16 | |
| 82 | + } | |
| 83 | + | |
| 84 | + pub fn extra_move_cost(self) -> u32 { | |
| 85 | + match self { | |
| 86 | + Plant::Tree => 100, | |
| 46 | 87 | } |
| 47 | 88 | } |
| 48 | 89 | } |
| @@ -51,13 +92,18 @@ impl Terrain { | ||
| 51 | 92 | pub struct WorldMap { |
| 52 | 93 | pub size: UVec2, |
| 53 | 94 | tiles: Vec<Terrain>, |
| 95 | + plants: Vec<Option<Plant>>, | |
| 96 | + heights: Vec<f32>, | |
| 54 | 97 | } |
| 55 | 98 | |
| 56 | 99 | impl WorldMap { |
| 57 | 100 | pub fn filled(size: UVec2, terrain: Terrain) -> Self { |
| 101 | + let n = size.element_product() as usize; | |
| 58 | 102 | Self { |
| 59 | 103 | size, |
| 60 | - tiles: vec![terrain; size.element_product() as usize], | |
| 104 | + tiles: vec![terrain; n], | |
| 105 | + plants: vec![None; n], | |
| 106 | + heights: vec![0.0; n], | |
| 61 | 107 | } |
| 62 | 108 | } |
| 63 | 109 | |
| @@ -79,8 +125,35 @@ impl WorldMap { | ||
| 79 | 125 | self.tiles[i] = terrain; |
| 80 | 126 | } |
| 81 | 127 | |
| 128 | + pub fn plant(&self, pos: IVec2) -> Option<Plant> { | |
| 129 | + self.in_bounds(pos) | |
| 130 | + .then(|| self.plants[self.index(pos.as_uvec2())]) | |
| 131 | + .flatten() | |
| 132 | + } | |
| 133 | + | |
| 134 | + pub fn set_plant(&mut self, pos: UVec2, plant: Option<Plant>) { | |
| 135 | + let i = self.index(pos); | |
| 136 | + self.plants[i] = plant; | |
| 137 | + } | |
| 138 | + | |
| 139 | + pub fn height(&self, pos: IVec2) -> Option<f32> { | |
| 140 | + self.in_bounds(pos) | |
| 141 | + .then(|| self.heights[self.index(pos.as_uvec2())]) | |
| 142 | + } | |
| 143 | + | |
| 144 | + pub fn set_height(&mut self, pos: UVec2, height: f32) { | |
| 145 | + let i = self.index(pos); | |
| 146 | + self.heights[i] = height; | |
| 147 | + } | |
| 148 | + | |
| 82 | 149 | pub fn move_cost(&self, pos: IVec2) -> Option<u32> { |
| 83 | - self.get(pos).and_then(Terrain::move_cost) | |
| 150 | + let terrain = self.get(pos)?.move_cost()?; | |
| 151 | + Some(terrain + self.plant(pos).map_or(0, Plant::extra_move_cost)) | |
| 152 | + } | |
| 153 | + | |
| 154 | + pub fn step_cost(&self, from: IVec2, to: IVec2) -> Option<u32> { | |
| 155 | + let climb = (self.height(to)? - self.height(from)?).max(0.0); | |
| 156 | + Some(self.move_cost(to)? + (climb * UPHILL_COST_PER_METER).round() as u32) | |
| 84 | 157 | } |
| 85 | 158 | |
| 86 | 159 | pub fn positions(&self) -> impl Iterator<Item = UVec2> + use<> { |
| @@ -147,9 +220,37 @@ mod tests { | ||
| 147 | 220 | } |
| 148 | 221 | |
| 149 | 222 | #[test] |
| 223 | + fn trees_slow_movement_but_do_not_block_it() { | |
| 224 | + let mut map = WorldMap::filled(UVec2::new(2, 1), Terrain::Grass); | |
| 225 | + let open = map.move_cost(IVec2::ZERO).unwrap(); | |
| 226 | + map.set_plant(UVec2::ZERO, Some(Plant::Tree)); | |
| 227 | + assert!(map.move_cost(IVec2::ZERO).unwrap() > open); | |
| 228 | + } | |
| 229 | + | |
| 230 | + #[test] | |
| 231 | + fn walking_uphill_costs_more_than_downhill_or_flat() { | |
| 232 | + let mut map = WorldMap::filled(UVec2::new(3, 1), Terrain::Grass); | |
| 233 | + map.set_height(UVec2::new(1, 0), 5.0); | |
| 234 | + let (low, high, flat) = (IVec2::new(0, 0), IVec2::new(1, 0), IVec2::new(2, 0)); | |
| 235 | + let up = map.step_cost(low, high).unwrap(); | |
| 236 | + let down = map.step_cost(high, low).unwrap(); | |
| 237 | + assert!(up > down); | |
| 238 | + assert_eq!(down, map.step_cost(low, flat).unwrap()); | |
| 239 | + } | |
| 240 | + | |
| 241 | + #[test] | |
| 242 | + fn rock_and_water_classification() { | |
| 243 | + assert!(Terrain::IronOre.is_rock() && Terrain::IronOre.move_cost().is_none()); | |
| 244 | + assert!(Terrain::ShallowWater.is_water() && !Terrain::ShallowWater.is_rock()); | |
| 245 | + } | |
| 246 | + | |
| 247 | + #[test] | |
| 150 | 248 | fn terrain_index_matches_position_in_all() { |
| 151 | 249 | for (i, t) in Terrain::ALL.iter().enumerate() { |
| 152 | 250 | assert_eq!(t.index() as usize, i); |
| 153 | 251 | } |
| 252 | + for (i, p) in Plant::ALL.iter().enumerate() { | |
| 253 | + assert_eq!(p.index() as usize, i); | |
| 254 | + } | |
| 154 | 255 | } |
| 155 | 256 | } |
src/worldgen.rs +307 -17
| @@ -1,10 +1,14 @@ | ||
| 1 | -use std::collections::VecDeque; | |
| 1 | +use std::{ | |
| 2 | + cmp::Reverse, | |
| 3 | + collections::{BinaryHeap, VecDeque}, | |
| 4 | +}; | |
| 2 | 5 | |
| 3 | 6 | use bevy::prelude::*; |
| 4 | 7 | |
| 5 | 8 | use crate::{ |
| 6 | 9 | noise::Noise, |
| 7 | - world::{Terrain, WorldMap}, | |
| 10 | + rng::SimRng, | |
| 11 | + world::{MAX_HEIGHT, Plant, Terrain, WorldMap, hash2}, | |
| 8 | 12 | }; |
| 9 | 13 | |
| 10 | 14 | pub const WORLD_SIZE: UVec2 = UVec2::new(256, 256); |
| @@ -17,6 +21,16 @@ const SHALLOW_WATER_SHARE: f32 = 0.06; | ||
| 17 | 21 | const SAND_SHARE: f32 = 0.04; |
| 18 | 22 | const STONE_SHARE: f32 = 0.14; |
| 19 | 23 | const SOIL_SHARE: f32 = 0.20; |
| 24 | +const ORE_SCALE: f32 = 1.0 / 6.0; | |
| 25 | +const IRON_SHARE_OF_ROCK: f32 = 0.10; | |
| 26 | +const GOLD_SHARE_OF_ROCK: f32 = 0.03; | |
| 27 | +const FOREST_SCALE: f32 = 1.0 / 32.0; | |
| 28 | +const FOREST_SHARE: f32 = 0.35; | |
| 29 | +const FOREST_TREE_PERCENT: u32 = 45; | |
| 30 | +const LONE_TREE_PERCENT: u32 = 3; | |
| 31 | +const RIVER_COUNT: usize = 4; | |
| 32 | +const RIVER_CANDIDATES: usize = 40; | |
| 33 | +const RIVER_SOURCE_SPACING: f32 = 40.0; | |
| 20 | 34 | |
| 21 | 35 | pub struct WorldGenPlugin; |
| 22 | 36 | |
| @@ -52,18 +66,25 @@ fn regenerate_on_key( | ||
| 52 | 66 | } |
| 53 | 67 | |
| 54 | 68 | 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 | 69 | let mut map = WorldMap::filled(size, Terrain::Grass); |
| 58 | 70 | 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(); | |
| 71 | + let sample = |offset: u32, scale: f32, octaves: u32| -> Vec<f32> { | |
| 72 | + let noise = Noise::new(seed.wrapping_add(offset)); | |
| 73 | + positions | |
| 74 | + .iter() | |
| 75 | + .map(|p| noise.fbm(p.as_vec2() * scale, octaves)) | |
| 76 | + .collect() | |
| 77 | + }; | |
| 78 | + let elevation = sample(0, ELEVATION_SCALE, 5); | |
| 79 | + let fertility = sample(0x1000, FERTILITY_SCALE, 3); | |
| 80 | + let iron = sample(0x2000, ORE_SCALE, 2); | |
| 81 | + let gold = sample(0x3000, ORE_SCALE, 2); | |
| 82 | + let forest = sample(0x4000, FOREST_SCALE, 3); | |
| 83 | + | |
| 84 | + let (low, high) = (quantile(&elevation, 0.0), quantile(&elevation, 1.0)); | |
| 85 | + for (&pos, &e) in positions.iter().zip(&elevation) { | |
| 86 | + map.set_height(pos, (e - low) / (high - low).max(f32::EPSILON) * MAX_HEIGHT); | |
| 87 | + } | |
| 67 | 88 | |
| 68 | 89 | let deep = quantile(&elevation, DEEP_WATER_SHARE); |
| 69 | 90 | let shallow = quantile(&elevation, DEEP_WATER_SHARE + SHALLOW_WATER_SHARE); |
| @@ -73,26 +94,178 @@ pub fn generate(size: UVec2, seed: u32) -> WorldMap { | ||
| 73 | 94 | ); |
| 74 | 95 | let stone = quantile(&elevation, 1.0 - STONE_SHARE); |
| 75 | 96 | let soil = quantile(&fertility, 1.0 - SOIL_SHARE); |
| 97 | + let is_rock = |i: usize| elevation[i] >= stone; | |
| 98 | + let rock_values = |values: &[f32]| -> Vec<f32> { | |
| 99 | + (0..values.len()) | |
| 100 | + .filter(|&i| is_rock(i)) | |
| 101 | + .map(|i| values[i]) | |
| 102 | + .collect() | |
| 103 | + }; | |
| 104 | + let iron_min = quantile(&rock_values(&iron), 1.0 - IRON_SHARE_OF_ROCK); | |
| 105 | + let gold_min = quantile(&rock_values(&gold), 1.0 - GOLD_SHARE_OF_ROCK); | |
| 76 | 106 | |
| 77 | - for ((&pos, &e), &f) in positions.iter().zip(&elevation).zip(&fertility) { | |
| 107 | + for (i, &pos) in positions.iter().enumerate() { | |
| 108 | + let e = elevation[i]; | |
| 78 | 109 | let terrain = if e < deep { |
| 79 | 110 | Terrain::DeepWater |
| 80 | 111 | } else if e < shallow { |
| 81 | 112 | Terrain::ShallowWater |
| 82 | 113 | } else if e < sand { |
| 83 | 114 | Terrain::Sand |
| 84 | - } else if e >= stone { | |
| 115 | + } else if is_rock(i) && gold[i] >= gold_min { | |
| 116 | + Terrain::GoldOre | |
| 117 | + } else if is_rock(i) && iron[i] >= iron_min { | |
| 118 | + Terrain::IronOre | |
| 119 | + } else if is_rock(i) { | |
| 85 | 120 | Terrain::Stone |
| 86 | - } else if f >= soil { | |
| 121 | + } else if fertility[i] >= soil { | |
| 87 | 122 | Terrain::Soil |
| 88 | 123 | } else { |
| 89 | 124 | Terrain::Grass |
| 90 | 125 | }; |
| 91 | 126 | map.set(pos, terrain); |
| 92 | 127 | } |
| 128 | + | |
| 129 | + for river in pick_rivers(&map, seed) { | |
| 130 | + carve_river(&mut map, &river); | |
| 131 | + } | |
| 132 | + | |
| 133 | + let forest_min = quantile(&forest, 1.0 - FOREST_SHARE); | |
| 134 | + for (i, &pos) in positions.iter().enumerate() { | |
| 135 | + let fertile = matches!( | |
| 136 | + map.get(pos.as_ivec2()), | |
| 137 | + Some(Terrain::Grass | Terrain::Soil) | |
| 138 | + ); | |
| 139 | + let percent = if forest[i] >= forest_min { | |
| 140 | + FOREST_TREE_PERCENT | |
| 141 | + } else { | |
| 142 | + LONE_TREE_PERCENT | |
| 143 | + }; | |
| 144 | + if fertile && hash2(pos.x ^ seed, pos.y) % 100 < percent { | |
| 145 | + map.set_plant(pos, Some(Plant::Tree)); | |
| 146 | + } | |
| 147 | + } | |
| 93 | 148 | map |
| 94 | 149 | } |
| 95 | 150 | |
| 151 | +fn mountain_feet(map: &WorldMap) -> Vec<UVec2> { | |
| 152 | + map.positions() | |
| 153 | + .filter(|p| { | |
| 154 | + let p = p.as_ivec2(); | |
| 155 | + map.move_cost(p).is_some() | |
| 156 | + && !map.get(p).unwrap().is_water() | |
| 157 | + && [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y] | |
| 158 | + .iter() | |
| 159 | + .any(|&d| map.get(p + d).is_some_and(Terrain::is_rock)) | |
| 160 | + }) | |
| 161 | + .collect() | |
| 162 | +} | |
| 163 | + | |
| 164 | +pub fn pick_rivers(map: &WorldMap, seed: u32) -> Vec<Vec<UVec2>> { | |
| 165 | + let feet = mountain_feet(map); | |
| 166 | + if feet.is_empty() { | |
| 167 | + return Vec::new(); | |
| 168 | + } | |
| 169 | + let flow = FlowField::new(map); | |
| 170 | + let mut rng = SimRng::new(u64::from(seed) ^ 0x5249_5645); | |
| 171 | + let mut rivers: Vec<Vec<UVec2>> = (0..RIVER_CANDIDATES) | |
| 172 | + .map(|_| feet[rng.range_i32(0..feet.len() as i32) as usize]) | |
| 173 | + .map(|source| flow.trace(source)) | |
| 174 | + .collect(); | |
| 175 | + rivers.sort_by_key(|river| Reverse(river.len())); | |
| 176 | + let mut picked: Vec<Vec<UVec2>> = Vec::new(); | |
| 177 | + for river in rivers { | |
| 178 | + if picked.len() == RIVER_COUNT { | |
| 179 | + break; | |
| 180 | + } | |
| 181 | + let spaced = picked | |
| 182 | + .iter() | |
| 183 | + .all(|other| other[0].as_vec2().distance(river[0].as_vec2()) >= RIVER_SOURCE_SPACING); | |
| 184 | + if spaced { | |
| 185 | + picked.push(river); | |
| 186 | + } | |
| 187 | + } | |
| 188 | + picked | |
| 189 | +} | |
| 190 | + | |
| 191 | +pub struct FlowField { | |
| 192 | + size: UVec2, | |
| 193 | + downstream: Vec<Option<UVec2>>, | |
| 194 | +} | |
| 195 | + | |
| 196 | +impl FlowField { | |
| 197 | + pub fn new(map: &WorldMap) -> Self { | |
| 198 | + let n = map.size.element_product() as usize; | |
| 199 | + let index = |p: UVec2| (p.y * map.size.x + p.x) as usize; | |
| 200 | + let mut downstream = vec![None; n]; | |
| 201 | + let mut done = vec![false; n]; | |
| 202 | + let mut open = BinaryHeap::new(); | |
| 203 | + for pos in map.positions() { | |
| 204 | + let p = pos.as_ivec2(); | |
| 205 | + let terrain = map.get(p).unwrap(); | |
| 206 | + let at_edge = | |
| 207 | + pos.x == 0 || pos.y == 0 || pos.x == map.size.x - 1 || pos.y == map.size.y - 1; | |
| 208 | + if terrain.is_water() || (at_edge && !terrain.is_rock()) { | |
| 209 | + done[index(pos)] = true; | |
| 210 | + open.push(Reverse((map.height(p).unwrap().to_bits(), pos.x, pos.y))); | |
| 211 | + } | |
| 212 | + } | |
| 213 | + while let Some(Reverse((level, x, y))) = open.pop() { | |
| 214 | + let current = UVec2::new(x, y); | |
| 215 | + for step in [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y] { | |
| 216 | + let next = current.as_ivec2() + step; | |
| 217 | + if !map.get(next).is_some_and(|t| !t.is_rock()) || done[index(next.as_uvec2())] { | |
| 218 | + continue; | |
| 219 | + } | |
| 220 | + let next = next.as_uvec2(); | |
| 221 | + done[index(next)] = true; | |
| 222 | + downstream[index(next)] = Some(current); | |
| 223 | + let filled = map | |
| 224 | + .height(next.as_ivec2()) | |
| 225 | + .unwrap() | |
| 226 | + .max(f32::from_bits(level)); | |
| 227 | + open.push(Reverse((filled.to_bits(), next.x, next.y))); | |
| 228 | + } | |
| 229 | + } | |
| 230 | + Self { | |
| 231 | + size: map.size, | |
| 232 | + downstream, | |
| 233 | + } | |
| 234 | + } | |
| 235 | + | |
| 236 | + fn downstream(&self, pos: UVec2) -> Option<UVec2> { | |
| 237 | + self.downstream[(pos.y * self.size.x + pos.x) as usize] | |
| 238 | + } | |
| 239 | + | |
| 240 | + pub fn trace(&self, source: UVec2) -> Vec<UVec2> { | |
| 241 | + let mut path = vec![source]; | |
| 242 | + while let Some(next) = self.downstream(*path.last().unwrap()) { | |
| 243 | + path.push(next); | |
| 244 | + } | |
| 245 | + path | |
| 246 | + } | |
| 247 | +} | |
| 248 | + | |
| 249 | +fn carve_river(map: &mut WorldMap, path: &[UVec2]) { | |
| 250 | + for (i, &pos) in path.iter().enumerate() { | |
| 251 | + let mut tiles = vec![pos.as_ivec2()]; | |
| 252 | + if i >= path.len() / 3 { | |
| 253 | + tiles.push(pos.as_ivec2() + IVec2::X); | |
| 254 | + } | |
| 255 | + if i >= path.len() * 2 / 3 { | |
| 256 | + tiles.push(pos.as_ivec2() + IVec2::Y); | |
| 257 | + } | |
| 258 | + for tile in tiles { | |
| 259 | + if map | |
| 260 | + .get(tile) | |
| 261 | + .is_some_and(|t| !t.is_rock() && t != Terrain::DeepWater) | |
| 262 | + { | |
| 263 | + map.set(tile.as_uvec2(), Terrain::ShallowWater); | |
| 264 | + } | |
| 265 | + } | |
| 266 | + } | |
| 267 | +} | |
| 268 | + | |
| 96 | 269 | pub fn quantile(values: &[f32], share: f32) -> f32 { |
| 97 | 270 | let mut sorted = values.to_vec(); |
| 98 | 271 | sorted.sort_by(f32::total_cmp); |
| @@ -173,7 +346,10 @@ mod tests { | ||
| 173 | 346 | let (a, b, c) = (generate(SIZE, 7), generate(SIZE, 7), generate(SIZE, 8)); |
| 174 | 347 | let tiles = |m: &WorldMap| { |
| 175 | 348 | m.positions() |
| 176 | - .map(|p| m.get(p.as_ivec2())) | |
| 349 | + .map(|p| { | |
| 350 | + let p = p.as_ivec2(); | |
| 351 | + (m.get(p), m.plant(p), m.height(p).map(f32::to_bits)) | |
| 352 | + }) | |
| 177 | 353 | .collect::<Vec<_>>() |
| 178 | 354 | }; |
| 179 | 355 | assert_eq!(tiles(&a), tiles(&b)); |
| @@ -187,7 +363,11 @@ mod tests { | ||
| 187 | 363 | for seed in 0..5 { |
| 188 | 364 | let map = generate(SIZE, seed); |
| 189 | 365 | assert!((share(&map, Terrain::DeepWater) - DEEP_WATER_SHARE).abs() < 0.02); |
| 190 | - assert!((share(&map, Terrain::Stone) - STONE_SHARE).abs() < 0.02); | |
| 366 | + let rock: f32 = [Terrain::Stone, Terrain::IronOre, Terrain::GoldOre] | |
| 367 | + .iter() | |
| 368 | + .map(|&t| share(&map, t)) | |
| 369 | + .sum(); | |
| 370 | + assert!((rock - STONE_SHARE).abs() < 0.02); | |
| 191 | 371 | for terrain in Terrain::ALL { |
| 192 | 372 | assert!(share(&map, terrain) > 0.0, "seed {seed} lacks {terrain:?}"); |
| 193 | 373 | } |
| @@ -195,6 +375,116 @@ mod tests { | ||
| 195 | 375 | } |
| 196 | 376 | |
| 197 | 377 | #[test] |
| 378 | + fn gold_is_rarer_than_iron() { | |
| 379 | + let map = generate(SIZE, 3); | |
| 380 | + assert!(share(&map, Terrain::GoldOre) < share(&map, Terrain::IronOre)); | |
| 381 | + } | |
| 382 | + | |
| 383 | + #[test] | |
| 384 | + fn mountains_are_the_highest_ground() { | |
| 385 | + let map = generate(SIZE, 4); | |
| 386 | + let mean_height = |pred: fn(Terrain) -> bool| { | |
| 387 | + let hs: Vec<f32> = map | |
| 388 | + .positions() | |
| 389 | + .map(|p| p.as_ivec2()) | |
| 390 | + .filter(|&p| pred(map.get(p).unwrap())) | |
| 391 | + .map(|p| map.height(p).unwrap()) | |
| 392 | + .collect(); | |
| 393 | + hs.iter().sum::<f32>() / hs.len() as f32 | |
| 394 | + }; | |
| 395 | + assert!(mean_height(Terrain::is_rock) > mean_height(|t| t == Terrain::Grass)); | |
| 396 | + assert!(mean_height(|t| t == Terrain::Grass) > mean_height(|t| t == Terrain::DeepWater)); | |
| 397 | + assert!( | |
| 398 | + map.positions() | |
| 399 | + .all(|p| (0.0..=MAX_HEIGHT).contains(&map.height(p.as_ivec2()).unwrap())) | |
| 400 | + ); | |
| 401 | + } | |
| 402 | + | |
| 403 | + #[test] | |
| 404 | + fn trees_only_grow_on_grass_and_soil() { | |
| 405 | + let map = generate(SIZE, 2); | |
| 406 | + let trees: Vec<_> = map | |
| 407 | + .positions() | |
| 408 | + .map(|p| p.as_ivec2()) | |
| 409 | + .filter(|&p| map.plant(p) == Some(Plant::Tree)) | |
| 410 | + .collect(); | |
| 411 | + assert!(!trees.is_empty()); | |
| 412 | + assert!( | |
| 413 | + trees | |
| 414 | + .iter() | |
| 415 | + .all(|&p| matches!(map.get(p), Some(Terrain::Grass | Terrain::Soil))) | |
| 416 | + ); | |
| 417 | + } | |
| 418 | + | |
| 419 | + fn slope_map() -> WorldMap { | |
| 420 | + let mut map = WorldMap::filled(UVec2::new(10, 5), Terrain::Grass); | |
| 421 | + for pos in map.positions() { | |
| 422 | + map.set_height(pos, pos.x as f32); | |
| 423 | + if pos.x == 0 { | |
| 424 | + map.set(pos, Terrain::DeepWater); | |
| 425 | + } | |
| 426 | + } | |
| 427 | + map | |
| 428 | + } | |
| 429 | + | |
| 430 | + #[test] | |
| 431 | + fn rivers_flow_downhill_into_water() { | |
| 432 | + let map = slope_map(); | |
| 433 | + let path = FlowField::new(&map).trace(UVec2::new(8, 2)); | |
| 434 | + assert_eq!( | |
| 435 | + map.get(path.last().unwrap().as_ivec2()), | |
| 436 | + Some(Terrain::DeepWater) | |
| 437 | + ); | |
| 438 | + assert!(path.windows(2).all(|w| w[1].x < w[0].x)); | |
| 439 | + } | |
| 440 | + | |
| 441 | + // Rivers are carved into the terrain after rock is placed; flowing through mountains would | |
| 442 | + // leave walkable water channels cutting through what should be solid stone. | |
| 443 | + #[test] | |
| 444 | + fn rivers_route_around_rock() { | |
| 445 | + let mut map = slope_map(); | |
| 446 | + map.set(UVec2::new(5, 2), Terrain::Stone); | |
| 447 | + let path = FlowField::new(&map).trace(UVec2::new(8, 2)); | |
| 448 | + assert!( | |
| 449 | + path.iter() | |
| 450 | + .all(|p| !map.get(p.as_ivec2()).unwrap().is_rock()) | |
| 451 | + ); | |
| 452 | + assert_eq!( | |
| 453 | + map.get(path.last().unwrap().as_ivec2()), | |
| 454 | + Some(Terrain::DeepWater) | |
| 455 | + ); | |
| 456 | + } | |
| 457 | + | |
| 458 | + // Noise terrain is full of small dips. Flow directions come from a priority flood that | |
| 459 | + // spills out of dips at their lowest rim, so rivers cross them instead of ending in (or | |
| 460 | + // meandering around inside) every depression. | |
| 461 | + #[test] | |
| 462 | + fn rivers_cross_depressions_to_reach_water() { | |
| 463 | + let mut map = slope_map(); | |
| 464 | + map.set_height(UVec2::new(5, 2), 0.5); | |
| 465 | + let path = FlowField::new(&map).trace(UVec2::new(8, 2)); | |
| 466 | + assert_eq!( | |
| 467 | + map.get(path.last().unwrap().as_ivec2()), | |
| 468 | + Some(Terrain::DeepWater) | |
| 469 | + ); | |
| 470 | + assert!(path.len() <= 9); | |
| 471 | + } | |
| 472 | + | |
| 473 | + #[test] | |
| 474 | + fn generated_worlds_have_rivers() { | |
| 475 | + let map = generate(WORLD_SIZE, 1); | |
| 476 | + assert!(share(&map, Terrain::ShallowWater) > SHALLOW_WATER_SHARE + 0.005); | |
| 477 | + } | |
| 478 | + | |
| 479 | + #[test] | |
| 480 | + fn longest_candidate_rivers_are_picked() { | |
| 481 | + let map = generate(WORLD_SIZE, 1); | |
| 482 | + let rivers = pick_rivers(&map, 1); | |
| 483 | + assert!(!rivers.is_empty() && rivers.len() <= RIVER_COUNT); | |
| 484 | + assert!(rivers.windows(2).all(|w| w[0].len() >= w[1].len())); | |
| 485 | + } | |
| 486 | + | |
| 487 | + #[test] | |
| 198 | 488 | fn quantile_picks_value_at_share() { |
| 199 | 489 | let values: Vec<f32> = (0..10).rev().map(|v| v as f32).collect(); |
| 200 | 490 | assert_eq!(quantile(&values, 0.0), 0.0); |
tasks.md +4 -2
| @@ -10,10 +10,12 @@ | ||
| 10 | 10 | - [x] Colonists: wander, select, right-click move orders, path display |
| 11 | 11 | - [x] Simulation tick (60 Hz fixed timestep), game clock, speeds 1x/3x/6x (keys 1-3), pause (Space), render interpolation |
| 12 | 12 | - [x] Procedural world generation (noise elevation/fertility, seeds, R to regenerate, spawn in largest walkable region) |
| 13 | +- [x] Heightmap: hill shading, uphill movement cost, height in tile info | |
| 14 | +- [x] Rivers (flow field), iron/gold ore veins, trees | |
| 13 | 15 | |
| 14 | 16 | ## Planned |
| 15 | -- [ ] Worldgen extras: rivers, ore/resources, trees/plants | |
| 16 | 17 | - [ ] Speed/pause buttons in UI |
| 17 | 18 | - [ ] Ignore map clicks over UI |
| 18 | -- [ ] Jobs: mining, building, hauling | |
| 19 | +- [ ] Jobs: mining (stone/ore), woodcutting, building, hauling | |
| 20 | +- [ ] Smoother hill shading at close zoom (per-tile shading looks blocky) | |
| 19 | 21 | - [ ] Basic UI (tile info, colonist panel) |