Skip to main content

game_toolkit_gfx/
tilemap.rs

1use crate::painter::Painter;
2use crate::sprite::{BlendMode, SpriteInstance};
3use crate::texture::TextureId;
4
5/// Grid tilemap backed by an atlas. `tiles` indexes into the atlas left-to-right,
6/// top-to-bottom; index `0` is treated as empty and skipped.
7///
8/// The map is split into square chunks (default 32x32 tiles). Each chunk caches its sprite
9/// instances and only rebuilds them when a tile in it changes (or the origin moves). On
10/// `draw`, chunks whose world-space AABB does not intersect the camera's visible rectangle
11/// are skipped, so a large map only touches the tiles actually on screen.
12pub struct Tilemap {
13    pub atlas: TextureId,
14    pub atlas_size: [u32; 2],
15    pub tile_size: [u32; 2],
16    pub width: u32,
17    pub height: u32,
18    /// Origin in world space (top-left corner of cell (0,0)).
19    pub origin: [f32; 2],
20    pub tiles: Vec<u16>,
21    pub layer: i16,
22    chunk_size: u32,
23    chunk_cols: u32,
24    chunks: Vec<Chunk>,
25    last_origin: [f32; 2],
26}
27
28/// Cached sprite instances for one chunk, rebuilt only when `dirty`.
29struct Chunk {
30    instances: Vec<SpriteInstance>,
31    dirty: bool,
32}
33
34impl Tilemap {
35    pub fn new(
36        atlas: TextureId,
37        atlas_size: [u32; 2],
38        tile_size: [u32; 2],
39        width: u32,
40        height: u32,
41    ) -> Self {
42        Self::with_chunk_size(atlas, atlas_size, tile_size, width, height, 32)
43    }
44
45    /// Like [`Tilemap::new`] but with an explicit chunk size (in tiles).
46    pub fn with_chunk_size(
47        atlas: TextureId,
48        atlas_size: [u32; 2],
49        tile_size: [u32; 2],
50        width: u32,
51        height: u32,
52        chunk_size: u32,
53    ) -> Self {
54        let chunk_size = chunk_size.max(1);
55        let chunk_cols = width.div_ceil(chunk_size);
56        let chunk_rows = height.div_ceil(chunk_size);
57        let chunks = (0..chunk_cols * chunk_rows)
58            .map(|_| Chunk {
59                instances: Vec::new(),
60                dirty: true,
61            })
62            .collect();
63        Self {
64            atlas,
65            atlas_size,
66            tile_size,
67            width,
68            height,
69            origin: [0.0, 0.0],
70            tiles: vec![0; (width * height) as usize],
71            layer: 0,
72            chunk_size,
73            chunk_cols,
74            chunks,
75            last_origin: [0.0, 0.0],
76        }
77    }
78
79    pub fn set(&mut self, x: u32, y: u32, tile: u16) {
80        if x < self.width && y < self.height {
81            self.tiles[(y * self.width + x) as usize] = tile;
82            let ci = (y / self.chunk_size) * self.chunk_cols + (x / self.chunk_size);
83            self.chunks[ci as usize].dirty = true;
84        }
85    }
86
87    pub fn get(&self, x: u32, y: u32) -> u16 {
88        if x < self.width && y < self.height {
89            self.tiles[(y * self.width + x) as usize]
90        } else {
91            0
92        }
93    }
94
95    /// Mark every chunk dirty so the next [`Tilemap::draw`] rebuilds all instances. Call this
96    /// after mutating `tiles`, `atlas_size`, or `tile_size` directly (bypassing [`set`]).
97    ///
98    /// [`set`]: Tilemap::set
99    pub fn mark_all_dirty(&mut self) {
100        for c in &mut self.chunks {
101            c.dirty = true;
102        }
103    }
104
105    fn tiles_per_row(&self) -> u32 {
106        (self.atlas_size[0] / self.tile_size[0]).max(1)
107    }
108
109    /// Atlas UV rect for a 1-based tile index.
110    fn uv_for(&self, tile: u16) -> ([f32; 2], [f32; 2]) {
111        let idx = (tile - 1) as u32;
112        let cols = self.tiles_per_row();
113        let cx = idx % cols;
114        let cy = idx / cols;
115        let u0 = (cx * self.tile_size[0]) as f32 / self.atlas_size[0] as f32;
116        let v0 = (cy * self.tile_size[1]) as f32 / self.atlas_size[1] as f32;
117        let u1 = ((cx + 1) * self.tile_size[0]) as f32 / self.atlas_size[0] as f32;
118        let v1 = ((cy + 1) * self.tile_size[1]) as f32 / self.atlas_size[1] as f32;
119        ([u0, v0], [u1, v1])
120    }
121
122    /// Tile cell range `(x0, y0, x1, y1)` (half-open) covered by chunk `ci`.
123    fn chunk_cells(&self, ci: usize) -> (u32, u32, u32, u32) {
124        let ccx = ci as u32 % self.chunk_cols;
125        let ccy = ci as u32 / self.chunk_cols;
126        let x0 = ccx * self.chunk_size;
127        let y0 = ccy * self.chunk_size;
128        (
129            x0,
130            y0,
131            (x0 + self.chunk_size).min(self.width),
132            (y0 + self.chunk_size).min(self.height),
133        )
134    }
135
136    /// World-space AABB `(min, max)` of chunk `ci`.
137    fn chunk_aabb(&self, ci: usize) -> ([f32; 2], [f32; 2]) {
138        let (x0, y0, x1, y1) = self.chunk_cells(ci);
139        let tw = self.tile_size[0] as f32;
140        let th = self.tile_size[1] as f32;
141        (
142            [
143                self.origin[0] + x0 as f32 * tw,
144                self.origin[1] + y0 as f32 * th,
145            ],
146            [
147                self.origin[0] + x1 as f32 * tw,
148                self.origin[1] + y1 as f32 * th,
149            ],
150        )
151    }
152
153    fn build_chunk(&self, ci: usize) -> Vec<SpriteInstance> {
154        let (x0, y0, x1, y1) = self.chunk_cells(ci);
155        let tw = self.tile_size[0] as f32;
156        let th = self.tile_size[1] as f32;
157        let mut instances = Vec::new();
158        for y in y0..y1 {
159            for x in x0..x1 {
160                let t = self.tiles[(y * self.width + x) as usize];
161                if t == 0 {
162                    continue;
163                }
164                let (uv_min, uv_max) = self.uv_for(t);
165                let pos = [
166                    self.origin[0] + x as f32 * tw,
167                    self.origin[1] + y as f32 * th,
168                ];
169                instances.push(SpriteInstance::at(pos, [tw, th]).with_uv(uv_min, uv_max));
170            }
171        }
172        instances
173    }
174
175    /// Rebuild dirty chunks, then emit the instances of every chunk that overlaps the
176    /// camera's visible rectangle into the sprite batcher.
177    pub fn draw(&mut self, painter: &mut Painter) {
178        // Moving the origin shifts every tile's world position, so all caches go stale.
179        if self.origin != self.last_origin {
180            self.mark_all_dirty();
181            self.last_origin = self.origin;
182        }
183        for ci in 0..self.chunks.len() {
184            if self.chunks[ci].dirty {
185                self.chunks[ci].instances = self.build_chunk(ci);
186                self.chunks[ci].dirty = false;
187            }
188        }
189
190        let (vmin, vmax) = painter.visible_rect();
191        for ci in 0..self.chunks.len() {
192            let (cmin, cmax) = self.chunk_aabb(ci);
193            if !rects_intersect(cmin, cmax, vmin, vmax) {
194                continue;
195            }
196            for inst in &self.chunks[ci].instances {
197                painter.sprite_ex(self.atlas, *inst, self.layer, BlendMode::Alpha);
198            }
199        }
200    }
201}
202
203/// True if the axis-aligned rectangles `a` and `b` overlap.
204fn rects_intersect(amin: [f32; 2], amax: [f32; 2], bmin: [f32; 2], bmax: [f32; 2]) -> bool {
205    amin[0] < bmax[0] && amax[0] > bmin[0] && amin[1] < bmax[1] && amax[1] > bmin[1]
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn map() -> Tilemap {
213        // 256x256 tiles, 16px each, default 32-tile chunks -> 8x8 = 64 chunks.
214        Tilemap::new(TextureId(0), [64, 64], [16, 16], 256, 256)
215    }
216
217    #[test]
218    fn chunk_grid_dimensions() {
219        let m = map();
220        assert_eq!(m.chunk_cols, 8);
221        assert_eq!(m.chunks.len(), 64); // 8x8 chunks
222    }
223
224    #[test]
225    fn set_marks_only_the_owning_chunk() {
226        let mut m = map();
227        m.mark_all_dirty();
228        for c in &mut m.chunks {
229            c.dirty = false;
230        }
231        m.set(40, 70, 1); // chunk col 1 (40/32), row 2 (70/32) -> index 2*8 + 1 = 17
232        assert!(m.chunks[17].dirty);
233        assert_eq!(m.chunks.iter().filter(|c| c.dirty).count(), 1);
234    }
235
236    #[test]
237    fn culling_skips_offscreen_chunks() {
238        let m = map();
239        // A viewport covering only the top-left ~2 tiles intersects exactly one chunk.
240        let (vmin, vmax) = ([0.0, 0.0], [32.0, 32.0]);
241        let visible = (0..m.chunks.len())
242            .filter(|&ci| {
243                let (cmin, cmax) = m.chunk_aabb(ci);
244                rects_intersect(cmin, cmax, vmin, vmax)
245            })
246            .count();
247        assert_eq!(visible, 1);
248    }
249
250    #[test]
251    fn empty_tiles_emit_no_instances() {
252        let mut m = map();
253        m.set(3, 3, 5);
254        let insts = m.build_chunk(0);
255        assert_eq!(insts.len(), 1);
256    }
257}