use std::collections::HashSet; use bevy::prelude::*; pub const MIN_WEAR: f32 = 0.05; pub const TILE_SIZE: f32 = 16.0; pub const MAX_HEIGHT: f32 = 100.0; pub const UPHILL_COST_PER_METER: f32 = 20.0; pub const CHEAPEST_MOVE_COST: u32 = 60; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Terrain { Grass, Soil, Sand, ShallowWater, DeepWater, Stone, IronOre, GoldOre, Gravel, Path, Paved, } impl Terrain { pub const ALL: [Terrain; 11] = [ Terrain::Grass, Terrain::Soil, Terrain::Sand, Terrain::ShallowWater, Terrain::DeepWater, Terrain::Stone, Terrain::IronOre, Terrain::GoldOre, Terrain::Gravel, Terrain::Path, Terrain::Paved, ]; pub fn index(self) -> u16 { self as u16 } pub fn move_cost(self) -> Option { match self { Terrain::Paved => Some(CHEAPEST_MOVE_COST), Terrain::Path => Some(80), Terrain::Grass | Terrain::Soil => Some(100), Terrain::Sand | Terrain::Gravel => Some(130), Terrain::ShallowWater => Some(300), Terrain::DeepWater | Terrain::Stone | Terrain::IronOre | Terrain::GoldOre => None, } } pub fn is_water(self) -> bool { matches!(self, Terrain::ShallowWater | Terrain::DeepWater) } pub fn is_rock(self) -> bool { matches!(self, Terrain::Stone | Terrain::IronOre | Terrain::GoldOre) } pub fn color(self) -> Srgba { match self { Terrain::Grass => Srgba::rgb_u8(92, 140, 64), Terrain::Soil => Srgba::rgb_u8(122, 92, 60), Terrain::Sand => Srgba::rgb_u8(214, 196, 140), Terrain::ShallowWater => Srgba::rgb_u8(72, 132, 184), Terrain::DeepWater => Srgba::rgb_u8(36, 76, 140), Terrain::Stone | Terrain::IronOre | Terrain::GoldOre => Srgba::rgb_u8(118, 118, 124), Terrain::Gravel => Srgba::rgb_u8(150, 146, 138), Terrain::Path => Srgba::rgb_u8(160, 128, 86), Terrain::Paved => Srgba::rgb_u8(168, 164, 156), } } pub fn speck_color(self) -> Option { match self { Terrain::IronOre => Some(Srgba::rgb_u8(178, 104, 72)), Terrain::GoldOre => Some(Srgba::rgb_u8(236, 196, 60)), Terrain::Path => Some(Srgba::rgb_u8(130, 104, 70)), _ => None, } } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Plant { Tree, BerryBush, Crop, } impl Plant { pub fn extra_move_cost(self) -> u32 { match self { Plant::Tree => 100, Plant::BerryBush => 60, Plant::Crop => 30, } } pub fn ripening_ticks(self) -> Option { let days = match self { Plant::Tree => return None, Plant::BerryBush => 2.0, Plant::Crop => 1.5, }; Some(days * 24.0 * crate::sim::TICKS_PER_HOUR as f32) } pub fn is_ripe(self, growth: f32) -> bool { self.ripening_ticks().is_some() && growth >= 1.0 } } #[derive(Component, Default, Debug, Clone, Copy, PartialEq)] pub struct TilePos(pub UVec2); #[derive(Message, Debug, Clone, Copy, PartialEq)] pub struct TileChanged(pub UVec2); #[derive(Resource, Clone, Debug)] pub struct WorldMap { pub size: UVec2, tiles: Vec, plants: Vec>, heights: Vec, walls: Vec, roofs: Vec, growth: Vec, wear: Vec, worn: HashSet, growing: HashSet, } impl WorldMap { pub fn filled(size: UVec2, terrain: Terrain) -> Self { let n = size.element_product() as usize; Self { size, tiles: vec![terrain; n], plants: vec![None; n], heights: vec![0.0; n], walls: vec![false; n], roofs: vec![false; n], growth: vec![0.0; n], wear: vec![0.0; n], worn: HashSet::new(), growing: HashSet::new(), } } pub fn in_bounds(&self, pos: IVec2) -> bool { pos.x >= 0 && pos.y >= 0 && (pos.x as u32) < self.size.x && (pos.y as u32) < self.size.y } fn index(&self, pos: UVec2) -> usize { (pos.y * self.size.x + pos.x) as usize } pub fn get(&self, pos: IVec2) -> Option { self.in_bounds(pos) .then(|| self.tiles[self.index(pos.as_uvec2())]) } pub fn set(&mut self, pos: UVec2, terrain: Terrain) { let i = self.index(pos); self.tiles[i] = terrain; } pub fn plant(&self, pos: IVec2) -> Option { self.in_bounds(pos) .then(|| self.plants[self.index(pos.as_uvec2())]) .flatten() } pub fn set_plant(&mut self, pos: UVec2, plant: Option) { let i = self.index(pos); self.plants[i] = plant; if plant.is_some_and(|p| p.ripening_ticks().is_some()) { self.growing.insert(pos); } else { self.growing.remove(&pos); } } pub fn growing_plants(&self) -> Vec { let mut tiles: Vec = self.growing.iter().copied().collect(); tiles.sort_by_key(|t| (t.y, t.x)); tiles } pub fn height(&self, pos: IVec2) -> Option { self.in_bounds(pos) .then(|| self.heights[self.index(pos.as_uvec2())]) } pub fn set_height(&mut self, pos: UVec2, height: f32) { let i = self.index(pos); self.heights[i] = height; } pub fn is_wall(&self, pos: IVec2) -> bool { self.in_bounds(pos) && self.walls[self.index(pos.as_uvec2())] } pub fn set_wall(&mut self, pos: UVec2, wall: bool) { let i = self.index(pos); self.walls[i] = wall; } pub fn growth(&self, pos: IVec2) -> f32 { if self.in_bounds(pos) { self.growth[self.index(pos.as_uvec2())] } else { 0.0 } } pub fn set_growth(&mut self, pos: UVec2, growth: f32) { let i = self.index(pos); self.growth[i] = growth.clamp(0.0, 1.0); } pub fn wear(&self, pos: IVec2) -> f32 { if self.in_bounds(pos) { self.wear[self.index(pos.as_uvec2())] } else { 0.0 } } pub fn set_wear(&mut self, pos: UVec2, wear: f32) { let i = self.index(pos); let wear = if wear < MIN_WEAR { 0.0 } else { wear }; self.wear[i] = wear; if wear > 0.0 { self.worn.insert(pos); } else { self.worn.remove(&pos); } } pub fn worn_tiles(&self) -> Vec { let mut tiles: Vec = self.worn.iter().copied().collect(); tiles.sort_by_key(|t| (t.y, t.x)); tiles } pub fn ripe_plant(&self, pos: IVec2) -> Option { self.plant(pos).filter(|p| p.is_ripe(self.growth(pos))) } pub fn is_roofed(&self, pos: IVec2) -> bool { self.in_bounds(pos) && self.roofs[self.index(pos.as_uvec2())] } pub fn set_roof(&mut self, pos: UVec2, roof: bool) { let i = self.index(pos); self.roofs[i] = roof; } pub fn move_cost(&self, pos: IVec2) -> Option { if self.is_wall(pos) { return None; } let terrain = self.get(pos)?.move_cost()?; Some(terrain + self.plant(pos).map_or(0, Plant::extra_move_cost)) } pub fn step_cost(&self, from: IVec2, to: IVec2) -> Option { let climb = (self.height(to)? - self.height(from)?).max(0.0); Some(self.move_cost(to)? + (climb * UPHILL_COST_PER_METER).round() as u32) } pub fn positions(&self) -> impl Iterator + use<> { let size = self.size; (0..size.y).flat_map(move |y| (0..size.x).map(move |x| UVec2::new(x, y))) } pub fn world_size(&self) -> Vec2 { self.size.as_vec2() * TILE_SIZE } pub fn tile_center(pos: UVec2) -> Vec2 { (pos.as_vec2() + 0.5) * TILE_SIZE } pub fn tile_at(&self, world: Vec2) -> Option { let pos = (world / TILE_SIZE).floor().as_ivec2(); self.in_bounds(pos).then(|| pos.as_uvec2()) } } pub fn hash2(x: u32, y: u32) -> u32 { let mut h = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77); h ^= h >> 15; h = h.wrapping_mul(0x2C1B_3C6D); h ^= h >> 12; h = h.wrapping_mul(0x297A_2D39); h ^ (h >> 15) } #[cfg(test)] mod tests { use super::*; // Growth runs ten times a game hour; it visits only tracked growing plants because a // full-map pass that often dominated frame time at high speeds. #[test] fn only_ripening_plants_are_tracked_as_growing() { let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass); map.set_plant(UVec2::new(1, 1), Some(Plant::Tree)); map.set_plant(UVec2::new(2, 2), Some(Plant::BerryBush)); map.set_plant(UVec2::new(3, 3), Some(Plant::Crop)); assert_eq!( map.growing_plants(), vec![UVec2::new(2, 2), UVec2::new(3, 3)] ); map.set_plant(UVec2::new(2, 2), None); assert_eq!(map.growing_plants(), vec![UVec2::new(3, 3)]); } #[test] fn get_out_of_bounds_is_none() { let map = WorldMap::filled(UVec2::new(4, 3), Terrain::Grass); assert_eq!(map.get(IVec2::new(-1, 0)), None); assert_eq!(map.get(IVec2::new(4, 0)), None); assert_eq!(map.get(IVec2::new(0, 3)), None); assert_eq!(map.get(IVec2::new(3, 2)), Some(Terrain::Grass)); } #[test] fn tiles_are_stored_row_major_from_bottom_left() { let mut map = WorldMap::filled(UVec2::new(4, 3), Terrain::Grass); map.set(UVec2::new(1, 2), Terrain::Stone); assert_eq!(map.tiles[2 * 4 + 1], Terrain::Stone); } #[test] fn tile_at_inverts_tile_center() { let map = WorldMap::filled(UVec2::new(32, 128), Terrain::Grass); for pos in [UVec2::ZERO, UVec2::new(17, 99), map.size - 1] { assert_eq!(map.tile_at(WorldMap::tile_center(pos)), Some(pos)); } } #[test] fn tile_at_outside_world_is_none() { let map = WorldMap::filled(UVec2::new(32, 128), Terrain::Grass); assert_eq!(map.tile_at(Vec2::new(-0.1, 0.0)), None); assert_eq!(map.tile_at(map.world_size()), None); } #[test] fn trees_slow_movement_but_do_not_block_it() { let mut map = WorldMap::filled(UVec2::new(2, 1), Terrain::Grass); let open = map.move_cost(IVec2::ZERO).unwrap(); map.set_plant(UVec2::ZERO, Some(Plant::Tree)); assert!(map.move_cost(IVec2::ZERO).unwrap() > open); } #[test] fn bushes_and_crops_ripen_but_trees_do_not() { assert!(Plant::BerryBush.is_ripe(1.0)); assert!(!Plant::Crop.is_ripe(0.9)); assert!(!Plant::Tree.is_ripe(1.0)); } // A* uses CHEAPEST_MOVE_COST in its distance estimate; if any terrain were cheaper the // estimate would overshoot and colonists could take needlessly long routes. #[test] fn no_terrain_is_cheaper_than_the_pathfinding_estimate() { for terrain in Terrain::ALL { if let Some(cost) = terrain.move_cost() { assert!(cost >= CHEAPEST_MOVE_COST, "{terrain:?}"); } } } #[test] fn walls_block_movement() { let mut map = WorldMap::filled(UVec2::new(2, 1), Terrain::Grass); map.set_wall(UVec2::ZERO, true); assert_eq!(map.move_cost(IVec2::ZERO), None); assert!(map.move_cost(IVec2::X).is_some()); } #[test] fn walking_uphill_costs_more_than_downhill_or_flat() { let mut map = WorldMap::filled(UVec2::new(3, 1), Terrain::Grass); map.set_height(UVec2::new(1, 0), 5.0); let (low, high, flat) = (IVec2::new(0, 0), IVec2::new(1, 0), IVec2::new(2, 0)); let up = map.step_cost(low, high).unwrap(); let down = map.step_cost(high, low).unwrap(); assert!(up > down); assert_eq!(down, map.step_cost(low, flat).unwrap()); } #[test] fn rock_and_water_classification() { assert!(Terrain::IronOre.is_rock() && Terrain::IronOre.move_cost().is_none()); assert!(Terrain::ShallowWater.is_water() && !Terrain::ShallowWater.is_rock()); } #[test] fn terrain_index_matches_position_in_all() { for (i, t) in Terrain::ALL.iter().enumerate() { assert_eq!(t.index() as usize, i); } } }