Skip to main content

pite_scene/
tilemap.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! TileMap document schema, TOML serialization, coordinate math, and layer management.
3//!
4//! Stores sparse tile layers referencing atlas frames. TOML (`.ptmap`) is canonical
5//! and human-readable, consistent with `.ptscn`.
6
7use std::collections::HashSet;
8use std::path::Path;
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12
13fn default_true() -> bool {
14    true
15}
16
17fn default_one() -> u32 {
18    1
19}
20
21/// An individual placed tile entry in a sparse layer: `(column, row, frame_name)`.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct TileCell(pub i32, pub i32, pub String);
24
25impl TileCell {
26    /// Creates a new tile cell placement.
27    pub fn new(col: i32, row: i32, frame: impl Into<String>) -> Self {
28        Self(col, row, frame.into())
29    }
30
31    /// Column index (X grid coordinate).
32    pub fn col(&self) -> i32 {
33        self.0
34    }
35
36    /// Row index (Y grid coordinate).
37    pub fn row(&self) -> i32 {
38        self.1
39    }
40
41    /// Atlas frame name for this tile.
42    pub fn frame(&self) -> &str {
43        &self.2
44    }
45}
46
47/// A named layer of placed tiles within a [`TileMapDoc`].
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct TileMapLayer {
50    /// Layer display name.
51    pub name: String,
52    /// Visibility flag for rendering.
53    #[serde(default = "default_true")]
54    pub visible: bool,
55    /// Whether this layer generates physics collision bodies.
56    #[serde(default)]
57    pub collision_enabled: bool,
58    /// Physics collision layer bitmask.
59    #[serde(default = "default_one")]
60    pub collision_layer: u32,
61    /// Physics collision mask bitmask.
62    #[serde(default = "default_one")]
63    pub collision_mask: u32,
64    /// Optional whitelist of atlas frame names treated as solid for collisions.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub solid_frames: Option<Vec<String>>,
67    /// Sparse array of placed tiles in this layer.
68    #[serde(default)]
69    pub tiles: Vec<TileCell>,
70}
71
72impl TileMapLayer {
73    pub fn new(name: impl Into<String>) -> Self {
74        Self {
75            name: name.into(),
76            visible: true,
77            collision_enabled: false,
78            collision_layer: 1,
79            collision_mask: 1,
80            solid_frames: None,
81            tiles: Vec::new(),
82        }
83    }
84}
85
86/// Canonical `.ptmap` document.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct TileMapDoc {
89    pub atlas: String,
90    pub tile_size: [u32; 2],
91    #[serde(default, rename = "layer")]
92    pub layers: Vec<TileMapLayer>,
93}
94
95impl TileMapDoc {
96    pub fn new(atlas: impl Into<String>, tile_w: u32, tile_h: u32) -> Self {
97        Self {
98            atlas: atlas.into(),
99            tile_size: [tile_w, tile_h],
100            layers: Vec::new(),
101        }
102    }
103
104    pub fn layer(&self, idx: usize) -> Option<&TileMapLayer> {
105        self.layers.get(idx)
106    }
107
108    pub fn layer_mut(&mut self, idx: usize) -> Option<&mut TileMapLayer> {
109        self.layers.get_mut(idx)
110    }
111
112    pub fn layer_names(&self) -> Vec<&str> {
113        self.layers.iter().map(|l| l.name.as_str()).collect()
114    }
115
116    pub fn get_cell(&self, layer_idx: usize, col: i32, row: i32) -> Option<&str> {
117        let layer = self.layers.get(layer_idx)?;
118        layer
119            .tiles
120            .iter()
121            .find(|t| t.0 == col && t.1 == row)
122            .map(|t| t.2.as_str())
123    }
124
125    pub fn set_cell(&mut self, layer_idx: usize, col: i32, row: i32, frame: impl Into<String>) {
126        if let Some(layer) = self.layers.get_mut(layer_idx) {
127            let frame = frame.into();
128            if let Some(cell) = layer.tiles.iter_mut().find(|t| t.0 == col && t.1 == row) {
129                cell.2 = frame;
130            } else {
131                layer.tiles.push(TileCell(col, row, frame));
132            }
133        }
134    }
135
136    pub fn erase_cell(&mut self, layer_idx: usize, col: i32, row: i32) -> bool {
137        if let Some(layer) = self.layers.get_mut(layer_idx) {
138            if let Some(pos) = layer.tiles.iter().position(|t| t.0 == col && t.1 == row) {
139                layer.tiles.remove(pos);
140                return true;
141            }
142        }
143        false
144    }
145
146    pub fn clear_layer(&mut self, layer_idx: usize) {
147        if let Some(layer) = self.layers.get_mut(layer_idx) {
148            layer.tiles.clear();
149        }
150    }
151
152    pub fn used_cells(&self, layer_idx: usize) -> Vec<(i32, i32)> {
153        let Some(layer) = self.layers.get(layer_idx) else {
154            return Vec::new();
155        };
156        layer.tiles.iter().map(|t| (t.0, t.1)).collect()
157    }
158
159    /// Solid cells for a layer taking `collision_enabled` and `solid_frames` into account.
160    pub fn solid_cells(&self, layer_idx: usize) -> Vec<(i32, i32)> {
161        let Some(layer) = self.layers.get(layer_idx) else {
162            return Vec::new();
163        };
164        if !layer.collision_enabled {
165            return Vec::new();
166        }
167        match &layer.solid_frames {
168            Some(solids) => {
169                let solid_set: HashSet<&str> = solids.iter().map(String::as_str).collect();
170                layer
171                    .tiles
172                    .iter()
173                    .filter(|t| solid_set.contains(t.2.as_str()))
174                    .map(|t| (t.0, t.1))
175                    .collect()
176            }
177            None => layer.tiles.iter().map(|t| (t.0, t.1)).collect(),
178        }
179    }
180}
181
182/// Convert world position to tilemap grid coordinate `(col, row)`.
183pub fn world_to_map(world: (f64, f64), origin: (f64, f64), tile_size: (f64, f64)) -> (i32, i32) {
184    let w = if tile_size.0 <= 0.0 {
185        16.0
186    } else {
187        tile_size.0
188    };
189    let h = if tile_size.1 <= 0.0 {
190        16.0
191    } else {
192        tile_size.1
193    };
194    let col = ((world.0 - origin.0) / w).floor() as i32;
195    let row = ((world.1 - origin.1) / h).floor() as i32;
196    (col, row)
197}
198
199/// Convert grid coordinate `(col, row)` to world center position `(x, y)`.
200pub fn map_to_world(cell: (i32, i32), origin: (f64, f64), tile_size: (f64, f64)) -> (f64, f64) {
201    let w = if tile_size.0 <= 0.0 {
202        16.0
203    } else {
204        tile_size.0
205    };
206    let h = if tile_size.1 <= 0.0 {
207        16.0
208    } else {
209        tile_size.1
210    };
211    (
212        origin.0 + (cell.0 as f64 + 0.5) * w,
213        origin.1 + (cell.1 as f64 + 0.5) * h,
214    )
215}
216
217/// Parses a tilemap document from a TOML string.
218pub fn parse_tilemap_str(text: &str) -> Result<TileMapDoc> {
219    toml::from_str(text).context("invalid tilemap TOML")
220}
221
222/// Loads and validates a `.ptmap` file from disk.
223pub fn load_tilemap(path: &Path) -> Result<TileMapDoc> {
224    let text = std::fs::read_to_string(path)
225        .with_context(|| format!("cannot read tilemap {}", path.display()))?;
226    parse_tilemap_str(&text).with_context(|| format!("cannot parse tilemap {}", path.display()))
227}
228
229/// Serializes and writes a [`TileMapDoc`] to disk at the specified path.
230pub fn save_tilemap(path: &Path, doc: &TileMapDoc) -> Result<()> {
231    let text = toml::to_string(doc).context("cannot serialize tilemap")?;
232    std::fs::write(path, text).with_context(|| format!("cannot write tilemap {}", path.display()))
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn roundtrip_tilemap_toml() {
241        let toml_src = r#"atlas = "res://assets/tileset.atlas.json"
242tile_size = [16, 16]
243
244[[layer]]
245name = "ground"
246visible = true
247collision_enabled = false
248collision_layer = 1
249collision_mask = 1
250tiles = [
251  [0, 0, "grass"],
252  [1, 0, "grass"],
253  [2, 0, "path"],
254]
255
256[[layer]]
257name = "walls"
258visible = true
259collision_enabled = true
260collision_layer = 2
261collision_mask = 1
262solid_frames = ["wall_brick"]
263tiles = [
264  [0, 1, "wall_brick"],
265  [1, 1, "wall_window"],
266]
267"#;
268        let doc = parse_tilemap_str(toml_src).unwrap();
269        assert_eq!(doc.atlas, "res://assets/tileset.atlas.json");
270        assert_eq!(doc.tile_size, [16, 16]);
271        assert_eq!(doc.layers.len(), 2);
272        assert_eq!(doc.get_cell(0, 0, 0), Some("grass"));
273        assert_eq!(doc.get_cell(0, 2, 0), Some("path"));
274        assert_eq!(doc.get_cell(0, 3, 0), None);
275
276        // Check collision query: only wall_brick is solid on layer 1
277        let solids = doc.solid_cells(1);
278        assert_eq!(solids, vec![(0, 1)]);
279
280        // Reserialize
281        let serialized = toml::to_string(&doc).unwrap();
282        let doc2 = parse_tilemap_str(&serialized).unwrap();
283        assert_eq!(doc, doc2);
284    }
285
286    #[test]
287    fn coordinate_conversion() {
288        let origin = (100.0, 200.0);
289        let size = (16.0, 16.0);
290
291        let cell = (2, 3);
292        let world = map_to_world(cell, origin, size);
293        assert_eq!(world, (100.0 + 2.5 * 16.0, 200.0 + 3.5 * 16.0));
294
295        let back_cell = world_to_map(world, origin, size);
296        assert_eq!(back_cell, cell);
297
298        // Negative coordinates
299        let neg_cell = (-3, -5);
300        let neg_world = map_to_world(neg_cell, origin, size);
301        let back_neg = world_to_map(neg_world, origin, size);
302        assert_eq!(back_neg, neg_cell);
303    }
304
305    #[test]
306    fn mutations() {
307        let mut doc = TileMapDoc::new("res://tiles.atlas.json", 16, 16);
308        doc.layers.push(TileMapLayer::new("main"));
309        assert_eq!(doc.get_cell(0, 0, 0), None);
310
311        doc.set_cell(0, 0, 0, "stone");
312        assert_eq!(doc.get_cell(0, 0, 0), Some("stone"));
313
314        // Overwrite
315        doc.set_cell(0, 0, 0, "wood");
316        assert_eq!(doc.get_cell(0, 0, 0), Some("wood"));
317
318        // Erase
319        assert!(doc.erase_cell(0, 0, 0));
320        assert_eq!(doc.get_cell(0, 0, 0), None);
321        assert!(!doc.erase_cell(0, 0, 0));
322    }
323}