Add static tile world and pan/zoom camera
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 07:55 UTC
- commit
- 7cf33bf6f26f90719f5731afe55049c56f812ee0
- parent
- 621179a747
- tree
- browse at this commit
4 files changed +506 -1
| src/camera.rs | added | +192 -0 |
| src/main.rs | +22 -1 | |
| src/tilemap.rs | added | +134 -0 |
| src/world.rs | added | +158 -0 |
src/camera.rs +192 -0
| @@ -0,0 +1,192 @@ | ||
| 1 | +use bevy::{ | |
| 2 | + input::mouse::{AccumulatedMouseScroll, MouseScrollPixelsPerLine}, | |
| 3 | + prelude::*, | |
| 4 | + window::PrimaryWindow, | |
| 5 | +}; | |
| 6 | + | |
| 7 | +use crate::world::WorldMap; | |
| 8 | + | |
| 9 | +pub struct CameraPlugin; | |
| 10 | + | |
| 11 | +const DRAG_BUTTONS: [MouseButton; 2] = [MouseButton::Middle, MouseButton::Right]; | |
| 12 | + | |
| 13 | +impl Plugin for CameraPlugin { | |
| 14 | + fn build(&self, app: &mut App) { | |
| 15 | + app.add_systems(Startup, spawn_camera).add_systems( | |
| 16 | + Update, | |
| 17 | + (keyboard_pan, drag_pan, scroll_zoom, clamp_to_world).chain(), | |
| 18 | + ); | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +#[derive(Component, Clone, Debug)] | |
| 23 | +pub struct CameraController { | |
| 24 | + pub pan_speed: f32, | |
| 25 | + pub fast_multiplier: f32, | |
| 26 | + pub zoom_per_line: f32, | |
| 27 | + pub min_scale: f32, | |
| 28 | + pub max_scale: f32, | |
| 29 | +} | |
| 30 | + | |
| 31 | +impl Default for CameraController { | |
| 32 | + fn default() -> Self { | |
| 33 | + Self { | |
| 34 | + pan_speed: 600.0, | |
| 35 | + fast_multiplier: 3.0, | |
| 36 | + zoom_per_line: 0.15, | |
| 37 | + min_scale: 0.2, | |
| 38 | + max_scale: 4.0, | |
| 39 | + } | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +fn spawn_camera(mut commands: Commands, map: Res<WorldMap>) { | |
| 44 | + commands.spawn(( | |
| 45 | + Camera2d, | |
| 46 | + CameraController::default(), | |
| 47 | + Transform::from_translation((map.world_size() / 2.0).extend(0.0)), | |
| 48 | + )); | |
| 49 | +} | |
| 50 | + | |
| 51 | +fn ortho(projection: &mut Projection) -> &mut OrthographicProjection { | |
| 52 | + match projection { | |
| 53 | + Projection::Orthographic(o) => o, | |
| 54 | + _ => panic!("camera must use an orthographic projection"), | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +fn keyboard_pan( | |
| 59 | + keys: Res<ButtonInput<KeyCode>>, | |
| 60 | + time: Res<Time>, | |
| 61 | + camera: Single<(&mut Transform, &mut Projection, &CameraController)>, | |
| 62 | +) { | |
| 63 | + let (mut transform, mut projection, controller) = camera.into_inner(); | |
| 64 | + let mut dir = Vec2::ZERO; | |
| 65 | + for (key, d) in [ | |
| 66 | + (KeyCode::KeyW, Vec2::Y), | |
| 67 | + (KeyCode::ArrowUp, Vec2::Y), | |
| 68 | + (KeyCode::KeyS, Vec2::NEG_Y), | |
| 69 | + (KeyCode::ArrowDown, Vec2::NEG_Y), | |
| 70 | + (KeyCode::KeyA, Vec2::NEG_X), | |
| 71 | + (KeyCode::ArrowLeft, Vec2::NEG_X), | |
| 72 | + (KeyCode::KeyD, Vec2::X), | |
| 73 | + (KeyCode::ArrowRight, Vec2::X), | |
| 74 | + ] { | |
| 75 | + if keys.pressed(key) { | |
| 76 | + dir += d; | |
| 77 | + } | |
| 78 | + } | |
| 79 | + if dir == Vec2::ZERO { | |
| 80 | + return; | |
| 81 | + } | |
| 82 | + let fast = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); | |
| 83 | + let speed = controller.pan_speed * if fast { controller.fast_multiplier } else { 1.0 }; | |
| 84 | + let scale = ortho(&mut projection).scale; | |
| 85 | + transform.translation += (dir.normalize() * speed * scale * time.delta_secs()).extend(0.0); | |
| 86 | +} | |
| 87 | + | |
| 88 | +fn drag_pan( | |
| 89 | + buttons: Res<ButtonInput<MouseButton>>, | |
| 90 | + window: Single<&Window, With<PrimaryWindow>>, | |
| 91 | + camera: Single<(&mut Transform, &mut Projection), With<CameraController>>, | |
| 92 | + mut last_cursor: Local<Option<Vec2>>, | |
| 93 | +) { | |
| 94 | + let cursor = window.cursor_position(); | |
| 95 | + let previous = std::mem::replace(&mut *last_cursor, cursor); | |
| 96 | + if !buttons.any_pressed(DRAG_BUTTONS) { | |
| 97 | + return; | |
| 98 | + } | |
| 99 | + let (Some(cursor), Some(previous)) = (cursor, previous) else { | |
| 100 | + return; | |
| 101 | + }; | |
| 102 | + let (mut transform, mut projection) = camera.into_inner(); | |
| 103 | + let scale = ortho(&mut projection).scale; | |
| 104 | + transform.translation += (screen_to_world_delta(cursor - previous) * -scale).extend(0.0); | |
| 105 | +} | |
| 106 | + | |
| 107 | +fn scroll_zoom( | |
| 108 | + scroll: Res<AccumulatedMouseScroll>, | |
| 109 | + pixels_per_line: Res<MouseScrollPixelsPerLine>, | |
| 110 | + window: Single<&Window, With<PrimaryWindow>>, | |
| 111 | + camera: Single<( | |
| 112 | + &Camera, | |
| 113 | + &GlobalTransform, | |
| 114 | + &mut Transform, | |
| 115 | + &mut Projection, | |
| 116 | + &CameraController, | |
| 117 | + )>, | |
| 118 | +) { | |
| 119 | + let lines = scroll.to_lines(&pixels_per_line).delta.y; | |
| 120 | + if lines == 0.0 { | |
| 121 | + return; | |
| 122 | + } | |
| 123 | + let (camera, global, mut transform, mut projection, controller) = camera.into_inner(); | |
| 124 | + let projection = ortho(&mut projection); | |
| 125 | + let old_scale = projection.scale; | |
| 126 | + let new_scale = (old_scale * (1.0 - lines * controller.zoom_per_line)) | |
| 127 | + .clamp(controller.min_scale, controller.max_scale); | |
| 128 | + projection.scale = new_scale; | |
| 129 | + | |
| 130 | + let focus = window | |
| 131 | + .cursor_position() | |
| 132 | + .and_then(|cursor| camera.viewport_to_world_2d(global, cursor).ok()); | |
| 133 | + if let Some(focus) = focus { | |
| 134 | + let pos = zoom_towards(transform.translation.truncate(), focus, old_scale, new_scale); | |
| 135 | + transform.translation = pos.extend(transform.translation.z); | |
| 136 | + } | |
| 137 | +} | |
| 138 | + | |
| 139 | +fn clamp_to_world(map: Res<WorldMap>, mut camera: Single<&mut Transform, With<CameraController>>) { | |
| 140 | + let clamped = clamp_to_rect(camera.translation.truncate(), Vec2::ZERO, map.world_size()); | |
| 141 | + camera.translation = clamped.extend(camera.translation.z); | |
| 142 | +} | |
| 143 | + | |
| 144 | +pub fn screen_to_world_delta(screen: Vec2) -> Vec2 { | |
| 145 | + Vec2::new(screen.x, -screen.y) | |
| 146 | +} | |
| 147 | + | |
| 148 | +pub fn zoom_towards(camera: Vec2, focus: Vec2, old_scale: f32, new_scale: f32) -> Vec2 { | |
| 149 | + focus + (camera - focus) * (new_scale / old_scale) | |
| 150 | +} | |
| 151 | + | |
| 152 | +pub fn clamp_to_rect(pos: Vec2, min: Vec2, max: Vec2) -> Vec2 { | |
| 153 | + pos.clamp(min, max) | |
| 154 | +} | |
| 155 | + | |
| 156 | +#[cfg(test)] | |
| 157 | +mod tests { | |
| 158 | + use super::*; | |
| 159 | + | |
| 160 | + fn world_point_at_offset(camera: Vec2, scale: f32, screen_offset: Vec2) -> Vec2 { | |
| 161 | + camera + screen_offset * scale | |
| 162 | + } | |
| 163 | + | |
| 164 | + #[test] | |
| 165 | + fn zooming_keeps_the_point_under_the_cursor_fixed() { | |
| 166 | + let camera = Vec2::new(100.0, 50.0); | |
| 167 | + let cursor_offset = Vec2::new(200.0, -120.0); | |
| 168 | + let focus = world_point_at_offset(camera, 1.0, cursor_offset); | |
| 169 | + | |
| 170 | + let new_camera = zoom_towards(camera, focus, 1.0, 0.5); | |
| 171 | + | |
| 172 | + assert!(world_point_at_offset(new_camera, 0.5, cursor_offset).abs_diff_eq(focus, 1e-4)); | |
| 173 | + } | |
| 174 | + | |
| 175 | + #[test] | |
| 176 | + fn zooming_on_camera_center_does_not_move_camera() { | |
| 177 | + let camera = Vec2::new(10.0, 20.0); | |
| 178 | + assert_eq!(zoom_towards(camera, camera, 1.0, 3.0), camera); | |
| 179 | + } | |
| 180 | + | |
| 181 | + #[test] | |
| 182 | + fn dragging_moves_world_opposite_to_screen_y_because_screen_y_points_down() { | |
| 183 | + assert_eq!(screen_to_world_delta(Vec2::new(3.0, 4.0)), Vec2::new(3.0, -4.0)); | |
| 184 | + } | |
| 185 | + | |
| 186 | + #[test] | |
| 187 | + fn camera_center_cannot_leave_the_world_so_the_map_stays_on_screen() { | |
| 188 | + let max = Vec2::new(100.0, 50.0); | |
| 189 | + assert_eq!(clamp_to_rect(Vec2::new(-5.0, 60.0), Vec2::ZERO, max), Vec2::new(0.0, 50.0)); | |
| 190 | + assert_eq!(clamp_to_rect(Vec2::new(5.0, 6.0), Vec2::ZERO, max), Vec2::new(5.0, 6.0)); | |
| 191 | + } | |
| 192 | +} | |
src/main.rs +22 -1
| @@ -1,5 +1,26 @@ | ||
| 1 | +mod camera; | |
| 2 | +mod tilemap; | |
| 3 | +mod world; | |
| 4 | + | |
| 1 | 5 | use bevy::prelude::*; |
| 2 | 6 | |
| 3 | 7 | fn main() { |
| 4 | - App::new().run(); | |
| 8 | + App::new() | |
| 9 | + .add_plugins( | |
| 10 | + DefaultPlugins | |
| 11 | + .set(ImagePlugin::default_nearest()) | |
| 12 | + .set(WindowPlugin { | |
| 13 | + primary_window: Some(Window { | |
| 14 | + title: "Simulator".into(), | |
| 15 | + canvas: Some("#bevy".into()), | |
| 16 | + fit_canvas_to_parent: true, | |
| 17 | + ..default() | |
| 18 | + }), | |
| 19 | + ..default() | |
| 20 | + }), | |
| 21 | + ) | |
| 22 | + .insert_resource(ClearColor(Color::srgb(0.08, 0.08, 0.1))) | |
| 23 | + .insert_resource(world::static_world()) | |
| 24 | + .add_plugins((tilemap::TilemapPlugin, camera::CameraPlugin)) | |
| 25 | + .run(); | |
| 5 | 26 | } |
src/tilemap.rs +134 -0
| @@ -0,0 +1,134 @@ | ||
| 1 | +use bevy::{ | |
| 2 | + asset::RenderAssetUsages, | |
| 3 | + prelude::*, | |
| 4 | + render::render_resource::{Extent3d, TextureDimension, TextureFormat}, | |
| 5 | + sprite_render::{TileData, TilemapChunk, TilemapChunkTileData}, | |
| 6 | +}; | |
| 7 | + | |
| 8 | +use crate::world::{hash2, Terrain, WorldMap, TILE_SIZE}; | |
| 9 | + | |
| 10 | +pub const CHUNK_SIZE: u32 = 64; | |
| 11 | +const TEXTURE_SIZE: u32 = 16; | |
| 12 | + | |
| 13 | +pub struct TilemapPlugin; | |
| 14 | + | |
| 15 | +impl Plugin for TilemapPlugin { | |
| 16 | + fn build(&self, app: &mut App) { | |
| 17 | + app.add_systems(Startup, spawn_chunks); | |
| 18 | + } | |
| 19 | +} | |
| 20 | + | |
| 21 | +fn spawn_chunks(mut commands: Commands, map: Res<WorldMap>, mut images: ResMut<Assets<Image>>) { | |
| 22 | + let tileset = images.add(build_tileset()); | |
| 23 | + let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE; | |
| 24 | + | |
| 25 | + for cy in 0..chunks.y { | |
| 26 | + for cx in 0..chunks.x { | |
| 27 | + let origin = UVec2::new(cx, cy) * CHUNK_SIZE; | |
| 28 | + let center = (origin.as_vec2() + CHUNK_SIZE as f32 / 2.0) * TILE_SIZE; | |
| 29 | + commands.spawn(( | |
| 30 | + Name::new(format!("Chunk {cx},{cy}")), | |
| 31 | + TilemapChunk { | |
| 32 | + chunk_size: UVec2::splat(CHUNK_SIZE), | |
| 33 | + tile_display_size: UVec2::splat(TILE_SIZE as u32), | |
| 34 | + tileset: tileset.clone(), | |
| 35 | + ..default() | |
| 36 | + }, | |
| 37 | + TilemapChunkTileData(chunk_tiles(&map, origin)), | |
| 38 | + Transform::from_translation(center.extend(0.0)), | |
| 39 | + )); | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +pub fn chunk_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> { | |
| 45 | + (0..CHUNK_SIZE) | |
| 46 | + .flat_map(|y| (0..CHUNK_SIZE).map(move |x| origin + UVec2::new(x, y))) | |
| 47 | + .map(|pos| { | |
| 48 | + map.get(pos.as_ivec2()).map(|terrain| TileData { | |
| 49 | + tileset_index: terrain.index(), | |
| 50 | + color: tile_tint(pos), | |
| 51 | + ..default() | |
| 52 | + }) | |
| 53 | + }) | |
| 54 | + .collect() | |
| 55 | +} | |
| 56 | + | |
| 57 | +fn tile_tint(pos: UVec2) -> Color { | |
| 58 | + let v = 0.92 + (hash2(pos.x, pos.y) % 9) as f32 * 0.01; | |
| 59 | + Color::srgb(v, v, v) | |
| 60 | +} | |
| 61 | + | |
| 62 | +pub fn tileset_layer_count(terrains: u32) -> u32 { | |
| 63 | + match terrains { | |
| 64 | + 0 | 1 => 2, | |
| 65 | + n if n % 6 == 0 => n + 1, | |
| 66 | + n => n, | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +fn build_tileset() -> Image { | |
| 71 | + let layers = tileset_layer_count(Terrain::ALL.len() as u32); | |
| 72 | + let data = (0..layers) | |
| 73 | + .flat_map(|layer| { | |
| 74 | + let base = Terrain::ALL | |
| 75 | + .get(layer as usize) | |
| 76 | + .map_or(Srgba::NONE, |t| t.color()); | |
| 77 | + (0..TEXTURE_SIZE * TEXTURE_SIZE).flat_map(move |i| { | |
| 78 | + let jitter = (hash2(i, layer) % 16) as f32 / 255.0 - 8.0 / 255.0; | |
| 79 | + let c = Srgba::new(base.red + jitter, base.green + jitter, base.blue + jitter, 1.0); | |
| 80 | + c.to_u8_array() | |
| 81 | + }) | |
| 82 | + }) | |
| 83 | + .collect(); | |
| 84 | + | |
| 85 | + let mut image = Image::new( | |
| 86 | + Extent3d { | |
| 87 | + width: TEXTURE_SIZE, | |
| 88 | + height: TEXTURE_SIZE * layers, | |
| 89 | + depth_or_array_layers: 1, | |
| 90 | + }, | |
| 91 | + TextureDimension::D2, | |
| 92 | + data, | |
| 93 | + TextureFormat::Rgba8UnormSrgb, | |
| 94 | + RenderAssetUsages::RENDER_WORLD, | |
| 95 | + ); | |
| 96 | + image | |
| 97 | + .reinterpret_stacked_2d_as_array(layers) | |
| 98 | + .expect("tileset is a vertical stack of square layers"); | |
| 99 | + image | |
| 100 | +} | |
| 101 | + | |
| 102 | +#[cfg(test)] | |
| 103 | +mod tests { | |
| 104 | + use super::*; | |
| 105 | + | |
| 106 | + #[test] | |
| 107 | + fn chunk_tiles_beyond_map_edge_are_empty() { | |
| 108 | + let map = WorldMap::filled(UVec2::new(CHUNK_SIZE + 2, 3), Terrain::Stone); | |
| 109 | + let tiles = chunk_tiles(&map, UVec2::new(CHUNK_SIZE, 0)); | |
| 110 | + assert_eq!(tiles.len(), (CHUNK_SIZE * CHUNK_SIZE) as usize); | |
| 111 | + assert!(tiles[0].is_some()); | |
| 112 | + assert!(tiles[1].is_some()); | |
| 113 | + assert!(tiles[2].is_none()); | |
| 114 | + assert!(tiles[CHUNK_SIZE as usize * 3].is_none()); | |
| 115 | + } | |
| 116 | + | |
| 117 | + #[test] | |
| 118 | + fn tileset_has_a_layer_for_every_terrain() { | |
| 119 | + let image = build_tileset(); | |
| 120 | + assert!(image.texture_descriptor.size.depth_or_array_layers >= Terrain::ALL.len() as u32); | |
| 121 | + } | |
| 122 | + | |
| 123 | + // wgpu's GLES backend (WebGL2) guesses the texture view dimension from the layer count: | |
| 124 | + // 1 layer => D2, 6 => Cube, multiples of 6 => CubeArray. The tilemap shader needs D2Array, | |
| 125 | + // so the tileset is padded with an unused layer to dodge those counts. | |
| 126 | + #[test] | |
| 127 | + fn tileset_layer_count_avoids_webgl_view_dimension_heuristics() { | |
| 128 | + for n in 0..50 { | |
| 129 | + let layers = tileset_layer_count(n); | |
| 130 | + assert!(layers >= n.max(2)); | |
| 131 | + assert!(layers % 6 != 0, "{n} terrains -> {layers} layers"); | |
| 132 | + } | |
| 133 | + } | |
| 134 | +} | |
src/world.rs +158 -0
| @@ -0,0 +1,158 @@ | ||
| 1 | +use bevy::prelude::*; | |
| 2 | + | |
| 3 | +pub const TILE_SIZE: f32 = 16.0; | |
| 4 | + | |
| 5 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 6 | +pub enum Terrain { | |
| 7 | + Grass, | |
| 8 | + Soil, | |
| 9 | + Sand, | |
| 10 | + ShallowWater, | |
| 11 | + DeepWater, | |
| 12 | + Stone, | |
| 13 | +} | |
| 14 | + | |
| 15 | +impl Terrain { | |
| 16 | + pub const ALL: [Terrain; 6] = [ | |
| 17 | + Terrain::Grass, | |
| 18 | + Terrain::Soil, | |
| 19 | + Terrain::Sand, | |
| 20 | + Terrain::ShallowWater, | |
| 21 | + Terrain::DeepWater, | |
| 22 | + Terrain::Stone, | |
| 23 | + ]; | |
| 24 | + | |
| 25 | + pub fn index(self) -> u16 { | |
| 26 | + self as u16 | |
| 27 | + } | |
| 28 | + | |
| 29 | + pub fn color(self) -> Srgba { | |
| 30 | + match self { | |
| 31 | + Terrain::Grass => Srgba::rgb_u8(92, 140, 64), | |
| 32 | + Terrain::Soil => Srgba::rgb_u8(122, 92, 60), | |
| 33 | + Terrain::Sand => Srgba::rgb_u8(214, 196, 140), | |
| 34 | + Terrain::ShallowWater => Srgba::rgb_u8(72, 132, 184), | |
| 35 | + Terrain::DeepWater => Srgba::rgb_u8(36, 76, 140), | |
| 36 | + Terrain::Stone => Srgba::rgb_u8(118, 118, 124), | |
| 37 | + } | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +#[derive(Resource, Clone, Debug)] | |
| 42 | +pub struct WorldMap { | |
| 43 | + pub size: UVec2, | |
| 44 | + tiles: Vec<Terrain>, | |
| 45 | +} | |
| 46 | + | |
| 47 | +impl WorldMap { | |
| 48 | + pub fn filled(size: UVec2, terrain: Terrain) -> Self { | |
| 49 | + Self { | |
| 50 | + size, | |
| 51 | + tiles: vec![terrain; size.element_product() as usize], | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + pub fn in_bounds(&self, pos: IVec2) -> bool { | |
| 56 | + pos.x >= 0 && pos.y >= 0 && (pos.x as u32) < self.size.x && (pos.y as u32) < self.size.y | |
| 57 | + } | |
| 58 | + | |
| 59 | + fn index(&self, pos: UVec2) -> usize { | |
| 60 | + (pos.y * self.size.x + pos.x) as usize | |
| 61 | + } | |
| 62 | + | |
| 63 | + pub fn get(&self, pos: IVec2) -> Option<Terrain> { | |
| 64 | + self.in_bounds(pos) | |
| 65 | + .then(|| self.tiles[self.index(pos.as_uvec2())]) | |
| 66 | + } | |
| 67 | + | |
| 68 | + pub fn set(&mut self, pos: UVec2, terrain: Terrain) { | |
| 69 | + let i = self.index(pos); | |
| 70 | + self.tiles[i] = terrain; | |
| 71 | + } | |
| 72 | + | |
| 73 | + pub fn positions(&self) -> impl Iterator<Item = UVec2> + use<> { | |
| 74 | + let size = self.size; | |
| 75 | + (0..size.y).flat_map(move |y| (0..size.x).map(move |x| UVec2::new(x, y))) | |
| 76 | + } | |
| 77 | + | |
| 78 | + pub fn world_size(&self) -> Vec2 { | |
| 79 | + self.size.as_vec2() * TILE_SIZE | |
| 80 | + } | |
| 81 | + | |
| 82 | +} | |
| 83 | + | |
| 84 | +pub fn hash2(x: u32, y: u32) -> u32 { | |
| 85 | + let mut h = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77); | |
| 86 | + h ^= h >> 15; | |
| 87 | + h = h.wrapping_mul(0x2C1B_3C6D); | |
| 88 | + h ^= h >> 12; | |
| 89 | + h = h.wrapping_mul(0x297A_2D39); | |
| 90 | + h ^ (h >> 15) | |
| 91 | +} | |
| 92 | + | |
| 93 | +pub fn static_world() -> WorldMap { | |
| 94 | + let size = UVec2::new(192, 128); | |
| 95 | + let mut map = WorldMap::filled(size, Terrain::Grass); | |
| 96 | + let lake = Vec2::new(140.0, 40.0); | |
| 97 | + let mountain = Vec2::new(150.0, 105.0); | |
| 98 | + | |
| 99 | + for pos in map.positions() { | |
| 100 | + let p = pos.as_vec2(); | |
| 101 | + let river_x = 60.0 + 8.0 * (p.y / 14.0).sin(); | |
| 102 | + let river_dist = (p.x - river_x).abs(); | |
| 103 | + let lake_dist = p.distance(lake); | |
| 104 | + let mountain_dist = ((p - mountain) / Vec2::new(36.0, 20.0)).length(); | |
| 105 | + | |
| 106 | + let terrain = if mountain_dist < 1.0 { | |
| 107 | + Terrain::Stone | |
| 108 | + } else if lake_dist < 9.0 { | |
| 109 | + Terrain::DeepWater | |
| 110 | + } else if lake_dist < 14.0 || river_dist < 2.5 { | |
| 111 | + Terrain::ShallowWater | |
| 112 | + } else if lake_dist < 17.0 || river_dist < 4.0 { | |
| 113 | + Terrain::Sand | |
| 114 | + } else if hash2(pos.x / 6, pos.y / 6) % 7 == 0 { | |
| 115 | + Terrain::Soil | |
| 116 | + } else { | |
| 117 | + Terrain::Grass | |
| 118 | + }; | |
| 119 | + map.set(pos, terrain); | |
| 120 | + } | |
| 121 | + map | |
| 122 | +} | |
| 123 | + | |
| 124 | +#[cfg(test)] | |
| 125 | +mod tests { | |
| 126 | + use super::*; | |
| 127 | + | |
| 128 | + #[test] | |
| 129 | + fn get_out_of_bounds_is_none() { | |
| 130 | + let map = WorldMap::filled(UVec2::new(4, 3), Terrain::Grass); | |
| 131 | + assert_eq!(map.get(IVec2::new(-1, 0)), None); | |
| 132 | + assert_eq!(map.get(IVec2::new(4, 0)), None); | |
| 133 | + assert_eq!(map.get(IVec2::new(0, 3)), None); | |
| 134 | + assert_eq!(map.get(IVec2::new(3, 2)), Some(Terrain::Grass)); | |
| 135 | + } | |
| 136 | + | |
| 137 | + #[test] | |
| 138 | + fn tiles_are_stored_row_major_from_bottom_left() { | |
| 139 | + let mut map = WorldMap::filled(UVec2::new(4, 3), Terrain::Grass); | |
| 140 | + map.set(UVec2::new(1, 2), Terrain::Stone); | |
| 141 | + assert_eq!(map.tiles[2 * 4 + 1], Terrain::Stone); | |
| 142 | + } | |
| 143 | + | |
| 144 | + #[test] | |
| 145 | + fn terrain_index_matches_position_in_all() { | |
| 146 | + for (i, t) in Terrain::ALL.iter().enumerate() { | |
| 147 | + assert_eq!(t.index() as usize, i); | |
| 148 | + } | |
| 149 | + } | |
| 150 | + | |
| 151 | + #[test] | |
| 152 | + fn static_world_contains_every_terrain() { | |
| 153 | + let map = static_world(); | |
| 154 | + for t in Terrain::ALL { | |
| 155 | + assert!(map.positions().any(|p| map.get(p.as_ivec2()) == Some(t)), "{t:?}"); | |
| 156 | + } | |
| 157 | + } | |
| 158 | +} | |