Skip to main content

arcane_engine/renderer/
tilemap.rs

1use super::SpriteCommand;
2
3/// A tile-based map that references a texture atlas.
4/// Tile ID 0 = empty (not drawn). IDs 1+ map to atlas positions (1-indexed).
5#[derive(Clone)]
6pub struct Tilemap {
7    pub width: u32,
8    pub height: u32,
9    pub tile_size: f32,
10    pub texture_id: u32,
11    pub atlas_columns: u32,
12    pub atlas_rows: u32,
13    tiles: Vec<u16>, // width * height, row-major
14}
15
16impl Tilemap {
17    pub fn new(
18        texture_id: u32,
19        width: u32,
20        height: u32,
21        tile_size: f32,
22        atlas_columns: u32,
23        atlas_rows: u32,
24    ) -> Self {
25        Self {
26            width,
27            height,
28            tile_size,
29            texture_id,
30            atlas_columns,
31            atlas_rows,
32            tiles: vec![0; (width * height) as usize],
33        }
34    }
35
36    pub fn set_tile(&mut self, gx: u32, gy: u32, tile_id: u16) {
37        if gx < self.width && gy < self.height {
38            self.tiles[(gy * self.width + gx) as usize] = tile_id;
39        }
40    }
41
42    pub fn get_tile(&self, gx: u32, gy: u32) -> u16 {
43        if gx < self.width && gy < self.height {
44            self.tiles[(gy * self.width + gx) as usize]
45        } else {
46            0
47        }
48    }
49
50    /// Bake visible tiles into sprite commands. Only emits tiles within camera view.
51    pub fn bake_visible(
52        &self,
53        world_offset_x: f32,
54        world_offset_y: f32,
55        layer: i32,
56        camera_x: f32,
57        camera_y: f32,
58        camera_zoom: f32,
59        viewport_w: f32,
60        viewport_h: f32,
61    ) -> Vec<SpriteCommand> {
62        let half_w = viewport_w / (2.0 * camera_zoom);
63        let half_h = viewport_h / (2.0 * camera_zoom);
64
65        // Visible world bounds
66        let view_left = camera_x - half_w;
67        let view_right = camera_x + half_w;
68        let view_top = camera_y - half_h;
69        let view_bottom = camera_y + half_h;
70
71        // Convert to tile grid indices (clamp to tilemap bounds)
72        let min_gx = ((view_left - world_offset_x) / self.tile_size)
73            .floor()
74            .max(0.0) as u32;
75        let max_gx = ((view_right - world_offset_x) / self.tile_size)
76            .ceil()
77            .min(self.width as f32) as u32;
78        let min_gy = ((view_top - world_offset_y) / self.tile_size)
79            .floor()
80            .max(0.0) as u32;
81        let max_gy = ((view_bottom - world_offset_y) / self.tile_size)
82            .ceil()
83            .min(self.height as f32) as u32;
84
85        let uv_tile_w = 1.0 / self.atlas_columns as f32;
86        let uv_tile_h = 1.0 / self.atlas_rows as f32;
87
88        let mut commands = Vec::new();
89
90        for gy in min_gy..max_gy {
91            for gx in min_gx..max_gx {
92                let tile_id = self.tiles[(gy * self.width + gx) as usize];
93                if tile_id == 0 {
94                    continue;
95                }
96
97                let atlas_x = (tile_id as u32 - 1) % self.atlas_columns;
98                let atlas_y = (tile_id as u32 - 1) / self.atlas_columns;
99
100                commands.push(SpriteCommand {
101                    texture_id: self.texture_id,
102                    x: world_offset_x + gx as f32 * self.tile_size,
103                    y: world_offset_y + gy as f32 * self.tile_size,
104                    w: self.tile_size,
105                    h: self.tile_size,
106                    layer,
107                    uv_x: atlas_x as f32 * uv_tile_w,
108                    uv_y: atlas_y as f32 * uv_tile_h,
109                    uv_w: uv_tile_w,
110                    uv_h: uv_tile_h,
111                    tint_r: 1.0,
112                    tint_g: 1.0,
113                    tint_b: 1.0,
114                    tint_a: 1.0,
115                });
116            }
117        }
118
119        commands
120    }
121}
122
123/// Manages tilemap instances by ID.
124#[derive(Clone)]
125pub struct TilemapStore {
126    tilemaps: std::collections::HashMap<u32, Tilemap>,
127    next_id: u32,
128}
129
130impl TilemapStore {
131    pub fn new() -> Self {
132        Self {
133            tilemaps: std::collections::HashMap::new(),
134            next_id: 1,
135        }
136    }
137
138    pub fn create(
139        &mut self,
140        texture_id: u32,
141        width: u32,
142        height: u32,
143        tile_size: f32,
144        atlas_columns: u32,
145        atlas_rows: u32,
146    ) -> u32 {
147        let id = self.next_id;
148        self.next_id += 1;
149        self.tilemaps.insert(
150            id,
151            Tilemap::new(texture_id, width, height, tile_size, atlas_columns, atlas_rows),
152        );
153        id
154    }
155
156    pub fn get(&self, id: u32) -> Option<&Tilemap> {
157        self.tilemaps.get(&id)
158    }
159
160    pub fn get_mut(&mut self, id: u32) -> Option<&mut Tilemap> {
161        self.tilemaps.get_mut(&id)
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_set_get_tile() {
171        let mut tm = Tilemap::new(1, 4, 4, 16.0, 4, 4);
172        assert_eq!(tm.get_tile(0, 0), 0);
173        tm.set_tile(1, 2, 5);
174        assert_eq!(tm.get_tile(1, 2), 5);
175        tm.set_tile(3, 3, 10);
176        assert_eq!(tm.get_tile(3, 3), 10);
177    }
178
179    #[test]
180    fn test_out_of_bounds() {
181        let mut tm = Tilemap::new(1, 4, 4, 16.0, 4, 4);
182        // Out-of-bounds set is silently ignored
183        tm.set_tile(10, 10, 5);
184        // Out-of-bounds get returns 0
185        assert_eq!(tm.get_tile(10, 10), 0);
186        assert_eq!(tm.get_tile(4, 0), 0);
187        assert_eq!(tm.get_tile(0, 4), 0);
188    }
189
190    #[test]
191    fn test_uv_computation() {
192        // 4x2 atlas: tile 1 -> (0,0), tile 2 -> (1,0), tile 5 -> (0,1)
193        let mut tm = Tilemap::new(1, 2, 2, 32.0, 4, 2);
194        tm.set_tile(0, 0, 1); // atlas pos (0,0)
195        tm.set_tile(1, 0, 2); // atlas pos (1,0)
196        tm.set_tile(0, 1, 5); // atlas pos (0,1)
197
198        // Camera sees the whole map
199        let cmds = tm.bake_visible(0.0, 0.0, 0, 32.0, 32.0, 1.0, 200.0, 200.0);
200        assert_eq!(cmds.len(), 3);
201
202        let uv_w = 1.0 / 4.0; // 0.25
203        let uv_h = 1.0 / 2.0; // 0.5
204
205        // Tile 1 at (0,0) -> atlas (0,0) -> uv (0, 0)
206        let c0 = &cmds[0];
207        assert!((c0.uv_x - 0.0).abs() < 1e-5);
208        assert!((c0.uv_y - 0.0).abs() < 1e-5);
209        assert!((c0.uv_w - uv_w).abs() < 1e-5);
210        assert!((c0.uv_h - uv_h).abs() < 1e-5);
211
212        // Tile 2 at (1,0) -> atlas (1,0) -> uv (0.25, 0)
213        let c1 = &cmds[1];
214        assert!((c1.uv_x - uv_w).abs() < 1e-5);
215        assert!((c1.uv_y - 0.0).abs() < 1e-5);
216
217        // Tile 5 at (0,1) -> atlas (0,1) -> uv (0, 0.5)
218        let c2 = &cmds[2];
219        assert!((c2.uv_x - 0.0).abs() < 1e-5);
220        assert!((c2.uv_y - uv_h).abs() < 1e-5);
221    }
222
223    #[test]
224    fn test_camera_culling() {
225        // 10x10 tilemap, 16px tiles. Fill entire map with tile 1.
226        let mut tm = Tilemap::new(1, 10, 10, 16.0, 4, 4);
227        for gy in 0..10 {
228            for gx in 0..10 {
229                tm.set_tile(gx, gy, 1);
230            }
231        }
232
233        // Camera at (80, 80) zoom=1, viewport 64x64 -> sees 32px radius
234        // Visible: world x=[48..112], y=[48..112] -> tiles [3..7] in each axis
235        let cmds = tm.bake_visible(0.0, 0.0, 0, 80.0, 80.0, 1.0, 64.0, 64.0);
236
237        // Should NOT emit all 100 tiles
238        assert!(cmds.len() < 100);
239        // Should emit roughly 4-5 tiles in each direction (ceil/floor rounding)
240        assert!(cmds.len() >= 9); // at least 3x3
241        assert!(cmds.len() <= 25); // at most 5x5
242
243        // All emitted tiles should be within visible range
244        for cmd in &cmds {
245            assert!(cmd.x >= 32.0); // tile 2 at x=32
246            assert!(cmd.x <= 112.0);
247            assert!(cmd.y >= 32.0);
248            assert!(cmd.y <= 112.0);
249        }
250    }
251
252    #[test]
253    fn test_tile_zero_skipped() {
254        let mut tm = Tilemap::new(1, 3, 3, 16.0, 4, 4);
255        // Only set one tile; rest are 0 (empty)
256        tm.set_tile(1, 1, 3);
257
258        let cmds = tm.bake_visible(0.0, 0.0, 0, 24.0, 24.0, 1.0, 200.0, 200.0);
259        assert_eq!(cmds.len(), 1);
260        assert!((cmds[0].x - 16.0).abs() < 1e-5);
261        assert!((cmds[0].y - 16.0).abs() < 1e-5);
262    }
263
264    #[test]
265    fn test_tilemap_store() {
266        let mut store = TilemapStore::new();
267        let id1 = store.create(1, 4, 4, 16.0, 4, 4);
268        let id2 = store.create(2, 8, 8, 32.0, 8, 8);
269
270        assert_eq!(id1, 1);
271        assert_eq!(id2, 2);
272
273        // Can access and mutate via store
274        store.get_mut(id1).unwrap().set_tile(0, 0, 5);
275        assert_eq!(store.get(id1).unwrap().get_tile(0, 0), 5);
276
277        // Non-existent ID returns None
278        assert!(store.get(99).is_none());
279        assert!(store.get_mut(99).is_none());
280    }
281
282    #[test]
283    fn test_world_offset() {
284        let mut tm = Tilemap::new(1, 2, 2, 16.0, 4, 4);
285        tm.set_tile(0, 0, 1);
286        tm.set_tile(1, 1, 2);
287
288        // Offset the tilemap by (100, 200)
289        let cmds = tm.bake_visible(100.0, 200.0, 5, 116.0, 216.0, 1.0, 200.0, 200.0);
290        assert_eq!(cmds.len(), 2);
291
292        assert!((cmds[0].x - 100.0).abs() < 1e-5);
293        assert!((cmds[0].y - 200.0).abs() < 1e-5);
294        assert_eq!(cmds[0].layer, 5);
295
296        assert!((cmds[1].x - 116.0).abs() < 1e-5);
297        assert!((cmds[1].y - 216.0).abs() < 1e-5);
298    }
299
300    #[test]
301    fn test_bake_produces_correct_sprite_fields() {
302        let mut tm = Tilemap::new(42, 1, 1, 24.0, 2, 2);
303        tm.set_tile(0, 0, 3); // atlas pos (0,1) for 2-column atlas
304
305        let cmds = tm.bake_visible(10.0, 20.0, 7, 22.0, 32.0, 1.0, 100.0, 100.0);
306        assert_eq!(cmds.len(), 1);
307
308        let c = &cmds[0];
309        assert_eq!(c.texture_id, 42);
310        assert!((c.x - 10.0).abs() < 1e-5);
311        assert!((c.y - 20.0).abs() < 1e-5);
312        assert!((c.w - 24.0).abs() < 1e-5);
313        assert!((c.h - 24.0).abs() < 1e-5);
314        assert_eq!(c.layer, 7);
315        // tile 3, 1-indexed -> index 2 -> col=0, row=1 in 2x2 atlas
316        assert!((c.uv_x - 0.0).abs() < 1e-5);
317        assert!((c.uv_y - 0.5).abs() < 1e-5);
318        assert!((c.uv_w - 0.5).abs() < 1e-5);
319        assert!((c.uv_h - 0.5).abs() < 1e-5);
320        assert!((c.tint_r - 1.0).abs() < 1e-5);
321        assert!((c.tint_g - 1.0).abs() < 1e-5);
322        assert!((c.tint_b - 1.0).abs() < 1e-5);
323        assert!((c.tint_a - 1.0).abs() < 1e-5);
324    }
325}