Add static tile world and pan/zoom camera

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

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