Add heightmap, rivers, ore veins and trees to worldgen

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

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

@@ -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)