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                    rotation: 0.0,
116                    origin_x: 0.5,
117                    origin_y: 0.5,
118                    flip_x: false,
119                    flip_y: false,
120                    opacity: 1.0,
121                    blend_mode: 0,
122                    shader_id: 0,
123                });
124            }
125        }
126
127        commands
128    }
129}
130
131/// Manages tilemap instances by ID.
132#[derive(Clone)]
133pub struct TilemapStore {
134    tilemaps: std::collections::HashMap<u32, Tilemap>,
135    next_id: u32,
136}
137
138impl TilemapStore {
139    pub fn new() -> Self {
140        Self {
141            tilemaps: std::collections::HashMap::new(),
142            next_id: 1,
143        }
144    }
145
146    pub fn create(
147        &mut self,
148        texture_id: u32,
149        width: u32,
150        height: u32,
151        tile_size: f32,
152        atlas_columns: u32,
153        atlas_rows: u32,
154    ) -> u32 {
155        let id = self.next_id;
156        self.next_id += 1;
157        self.tilemaps.insert(
158            id,
159            Tilemap::new(texture_id, width, height, tile_size, atlas_columns, atlas_rows),
160        );
161        id
162    }
163
164    pub fn get(&self, id: u32) -> Option<&Tilemap> {
165        self.tilemaps.get(&id)
166    }
167
168    pub fn get_mut(&mut self, id: u32) -> Option<&mut Tilemap> {
169        self.tilemaps.get_mut(&id)
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_set_get_tile() {
179        let mut tm = Tilemap::new(1, 4, 4, 16.0, 4, 4);
180        assert_eq!(tm.get_tile(0, 0), 0);
181        tm.set_tile(1, 2, 5);
182        assert_eq!(tm.get_tile(1, 2), 5);
183        tm.set_tile(3, 3, 10);
184        assert_eq!(tm.get_tile(3, 3), 10);
185    }
186
187    #[test]
188    fn test_out_of_bounds() {
189        let mut tm = Tilemap::new(1, 4, 4, 16.0, 4, 4);
190        // Out-of-bounds set is silently ignored
191        tm.set_tile(10, 10, 5);
192        // Out-of-bounds get returns 0
193        assert_eq!(tm.get_tile(10, 10), 0);
194        assert_eq!(tm.get_tile(4, 0), 0);
195        assert_eq!(tm.get_tile(0, 4), 0);
196    }
197
198    #[test]
199    fn test_uv_computation() {
200        // 4x2 atlas: tile 1 -> (0,0), tile 2 -> (1,0), tile 5 -> (0,1)
201        let mut tm = Tilemap::new(1, 2, 2, 32.0, 4, 2);
202        tm.set_tile(0, 0, 1); // atlas pos (0,0)
203        tm.set_tile(1, 0, 2); // atlas pos (1,0)
204        tm.set_tile(0, 1, 5); // atlas pos (0,1)
205
206        // Camera sees the whole map
207        let cmds = tm.bake_visible(0.0, 0.0, 0, 32.0, 32.0, 1.0, 200.0, 200.0);
208        assert_eq!(cmds.len(), 3);
209
210        let uv_w = 1.0 / 4.0; // 0.25
211        let uv_h = 1.0 / 2.0; // 0.5
212
213        // Tile 1 at (0,0) -> atlas (0,0) -> uv (0, 0)
214        let c0 = &cmds[0];
215        assert!((c0.uv_x - 0.0).abs() < 1e-5);
216        assert!((c0.uv_y - 0.0).abs() < 1e-5);
217        assert!((c0.uv_w - uv_w).abs() < 1e-5);
218        assert!((c0.uv_h - uv_h).abs() < 1e-5);
219
220        // Tile 2 at (1,0) -> atlas (1,0) -> uv (0.25, 0)
221        let c1 = &cmds[1];
222        assert!((c1.uv_x - uv_w).abs() < 1e-5);
223        assert!((c1.uv_y - 0.0).abs() < 1e-5);
224
225        // Tile 5 at (0,1) -> atlas (0,1) -> uv (0, 0.5)
226        let c2 = &cmds[2];
227        assert!((c2.uv_x - 0.0).abs() < 1e-5);
228        assert!((c2.uv_y - uv_h).abs() < 1e-5);
229    }
230
231    #[test]
232    fn test_camera_culling() {
233        // 10x10 tilemap, 16px tiles. Fill entire map with tile 1.
234        let mut tm = Tilemap::new(1, 10, 10, 16.0, 4, 4);
235        for gy in 0..10 {
236            for gx in 0..10 {
237                tm.set_tile(gx, gy, 1);
238            }
239        }
240
241        // Camera at (80, 80) zoom=1, viewport 64x64 -> sees 32px radius
242        // Visible: world x=[48..112], y=[48..112] -> tiles [3..7] in each axis
243        let cmds = tm.bake_visible(0.0, 0.0, 0, 80.0, 80.0, 1.0, 64.0, 64.0);
244
245        // Should NOT emit all 100 tiles
246        assert!(cmds.len() < 100);
247        // Should emit roughly 4-5 tiles in each direction (ceil/floor rounding)
248        assert!(cmds.len() >= 9); // at least 3x3
249        assert!(cmds.len() <= 25); // at most 5x5
250
251        // All emitted tiles should be within visible range
252        for cmd in &cmds {
253            assert!(cmd.x >= 32.0); // tile 2 at x=32
254            assert!(cmd.x <= 112.0);
255            assert!(cmd.y >= 32.0);
256            assert!(cmd.y <= 112.0);
257        }
258    }
259
260    #[test]
261    fn test_tile_zero_skipped() {
262        let mut tm = Tilemap::new(1, 3, 3, 16.0, 4, 4);
263        // Only set one tile; rest are 0 (empty)
264        tm.set_tile(1, 1, 3);
265
266        let cmds = tm.bake_visible(0.0, 0.0, 0, 24.0, 24.0, 1.0, 200.0, 200.0);
267        assert_eq!(cmds.len(), 1);
268        assert!((cmds[0].x - 16.0).abs() < 1e-5);
269        assert!((cmds[0].y - 16.0).abs() < 1e-5);
270    }
271
272    #[test]
273    fn test_tilemap_store() {
274        let mut store = TilemapStore::new();
275        let id1 = store.create(1, 4, 4, 16.0, 4, 4);
276        let id2 = store.create(2, 8, 8, 32.0, 8, 8);
277
278        assert_eq!(id1, 1);
279        assert_eq!(id2, 2);
280
281        // Can access and mutate via store
282        store.get_mut(id1).unwrap().set_tile(0, 0, 5);
283        assert_eq!(store.get(id1).unwrap().get_tile(0, 0), 5);
284
285        // Non-existent ID returns None
286        assert!(store.get(99).is_none());
287        assert!(store.get_mut(99).is_none());
288    }
289
290    #[test]
291    fn test_world_offset() {
292        let mut tm = Tilemap::new(1, 2, 2, 16.0, 4, 4);
293        tm.set_tile(0, 0, 1);
294        tm.set_tile(1, 1, 2);
295
296        // Offset the tilemap by (100, 200)
297        let cmds = tm.bake_visible(100.0, 200.0, 5, 116.0, 216.0, 1.0, 200.0, 200.0);
298        assert_eq!(cmds.len(), 2);
299
300        assert!((cmds[0].x - 100.0).abs() < 1e-5);
301        assert!((cmds[0].y - 200.0).abs() < 1e-5);
302        assert_eq!(cmds[0].layer, 5);
303
304        assert!((cmds[1].x - 116.0).abs() < 1e-5);
305        assert!((cmds[1].y - 216.0).abs() < 1e-5);
306    }
307
308    #[test]
309    fn test_bake_produces_correct_sprite_fields() {
310        let mut tm = Tilemap::new(42, 1, 1, 24.0, 2, 2);
311        tm.set_tile(0, 0, 3); // atlas pos (0,1) for 2-column atlas
312
313        let cmds = tm.bake_visible(10.0, 20.0, 7, 22.0, 32.0, 1.0, 100.0, 100.0);
314        assert_eq!(cmds.len(), 1);
315
316        let c = &cmds[0];
317        assert_eq!(c.texture_id, 42);
318        assert!((c.x - 10.0).abs() < 1e-5);
319        assert!((c.y - 20.0).abs() < 1e-5);
320        assert!((c.w - 24.0).abs() < 1e-5);
321        assert!((c.h - 24.0).abs() < 1e-5);
322        assert_eq!(c.layer, 7);
323        // tile 3, 1-indexed -> index 2 -> col=0, row=1 in 2x2 atlas
324        assert!((c.uv_x - 0.0).abs() < 1e-5);
325        assert!((c.uv_y - 0.5).abs() < 1e-5);
326        assert!((c.uv_w - 0.5).abs() < 1e-5);
327        assert!((c.uv_h - 0.5).abs() < 1e-5);
328        assert!((c.tint_r - 1.0).abs() < 1e-5);
329        assert!((c.tint_g - 1.0).abs() < 1e-5);
330        assert!((c.tint_b - 1.0).abs() < 1e-5);
331        assert!((c.tint_a - 1.0).abs() < 1e-5);
332    }
333}