Skip to main content

shuyi_assets/
grid_map.rs

1//! The `.map` text format of a grid map: a RON header, a `---` line, then
2//! one character per tile, top row first.
3//!
4//! ```text
5//! (
6//!     name: "Crossing",
7//!     legend: { '.': "plain", 'T': "forest", '~': "water" },
8//!     features: { (2, 0): Sign("Turn back") },
9//!     look: (tiles: "indoor", light: "dusk"),
10//! )
11//! ---
12//! ..T
13//! ~~.
14//! ```
15//!
16//! The header has the map's `name`, its `legend` (tile character →
17//! terrain id), its `features` and its `look`; the last two may be left
18//! out. A game says what a feature is (the [`MapFeature`] hook: its own
19//! serde type, written after the tile's `(x, y)`), what a terrain id
20//! resolves to, and which tile looks there are ([`MapFormat`]). Every
21//! problem is reported together, each with its line and column.
22//!
23//! The look is kept beside the tiles, for a skin to paint them by; the
24//! rules never need it. As a format (ADR-0007): [`MapText`].
25
26use crate::prelude::*;
27
28use alloc::collections::BTreeMap;
29use core::fmt;
30use core::marker::PhantomData;
31
32use serde::de::DeserializeOwned;
33use serde::{Deserialize, Serialize};
34use shuyi_core::{Grid, Pos};
35
36use crate::error::{ContentError, codes};
37use crate::format::{AssetKind, AssetPath, DecodeCx, Format, TEXT_MAX_BYTES, text};
38use crate::ron::parse_ron;
39
40/// The line separating the RON header from the tile rows.
41pub const SEPARATOR: &str = "---";
42/// The tile look of a map that names none.
43pub const DEFAULT_TILES: &str = "outdoor";
44/// The light of a map that names none.
45pub const DEFAULT_LIGHT: &str = "noon";
46
47/// What a game's maps may be: the tile looks there are, and how big a map
48/// may be. Build it with [`MapFormat::new`] (ADR-0039).
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50#[non_exhaustive]
51pub struct MapFormat<'a> {
52    /// The sets of terrain pictures a map may name in its `look`.
53    /// [`DEFAULT_TILES`] should be one of them.
54    pub looks: &'a [&'a str],
55    /// Largest width and height, in tiles.
56    pub max_size: usize,
57}
58
59impl<'a> MapFormat<'a> {
60    /// Maps that may name the tile looks `looks`, at most `max_size` tiles
61    /// a side.
62    #[must_use]
63    pub const fn new(looks: &'a [&'a str], max_size: usize) -> Self {
64        Self { looks, max_size }
65    }
66}
67
68/// A game's tile feature, as written in a map header after the tile's
69/// `(x, y)` (a shop, a chest, a sign): the hook through which a game adds
70/// its own things to the format.
71pub trait MapFeature: Serialize + DeserializeOwned {
72    /// A problem visible in the file alone, if the feature has one.
73    fn problem(&self) -> Option<String> {
74        None
75    }
76}
77
78/// The feature of a game whose maps have none.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80pub enum NoFeature {}
81
82impl MapFeature for NoFeature {}
83
84/// How a map looks, as far as its file says: which pictures a skin that
85/// has pictures paints its terrain with, and in which light. A skin
86/// without them ignores it. Look data only: the rules never see it.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct MapLook {
90    /// Which set of terrain pictures: one of the game's
91    /// [`looks`](MapFormat::looks).
92    #[serde(default = "default_tiles")]
93    pub tiles: String,
94    /// Which light the map is in. [`DEFAULT_LIGHT`] unless the file says.
95    #[serde(default = "default_light", skip_serializing_if = "is_default_light")]
96    pub light: String,
97}
98
99fn default_tiles() -> String {
100    DEFAULT_TILES.to_owned()
101}
102
103fn default_light() -> String {
104    DEFAULT_LIGHT.to_owned()
105}
106
107#[allow(clippy::ptr_arg)] // serde gives the field as it is
108fn is_default_light(light: &String) -> bool {
109    light == DEFAULT_LIGHT
110}
111
112impl Default for MapLook {
113    fn default() -> Self {
114        Self {
115            tiles: default_tiles(),
116            light: default_light(),
117        }
118    }
119}
120
121impl MapLook {
122    /// Whether it is the look of a map that names none: not written when
123    /// a map is printed.
124    pub fn is_default(&self) -> bool {
125        *self == Self::default()
126    }
127
128    /// This look in the light `light`, if one is given: a battle's own
129    /// light instead of its map's.
130    #[must_use]
131    pub fn lit(mut self, light: Option<&str>) -> Self {
132        if let Some(light) = light {
133            light.clone_into(&mut self.light);
134        }
135        self
136    }
137}
138
139/// A map's legend: tile character → terrain id as written, and what each
140/// resolved to.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct Legend<T> {
143    /// Character → terrain id, as written in the file.
144    pub names: BTreeMap<char, String>,
145    /// Character → resolved terrain.
146    pub terrains: BTreeMap<char, T>,
147}
148
149impl<T> Default for Legend<T> {
150    fn default() -> Self {
151        Self {
152            names: BTreeMap::new(),
153            terrains: BTreeMap::new(),
154        }
155    }
156}
157
158impl<T: Copy + PartialEq> Legend<T> {
159    /// Builds a legend from `char → terrain id` pairs, resolving each id
160    /// with `terrain`; `None` if any id is unknown.
161    pub fn resolve(
162        names: BTreeMap<char, String>,
163        terrain: impl Fn(&str) -> Option<T>,
164    ) -> Option<Self> {
165        let terrains = names
166            .iter()
167            .map(|(&c, id)| Some((c, terrain(id)?)))
168            .collect::<Option<_>>()?;
169        Some(Self { names, terrains })
170    }
171
172    /// The first (lowest) character standing for terrain `t`.
173    pub fn char_for(&self, t: T) -> Option<char> {
174        self.terrains
175            .iter()
176            .find(|&(_, &u)| u == t)
177            .map(|(&c, _)| c)
178    }
179}
180
181/// A map read from a `.map` file: its tiles and features, the legend it
182/// was written with (for printing it back) and its look.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub struct GridMap<T, F> {
185    /// The map's display name.
186    pub name: String,
187    /// The terrain of each tile.
188    pub tiles: Grid<T>,
189    /// The game's features, by tile.
190    pub features: BTreeMap<Pos, F>,
191    /// The legend from the file.
192    pub legend: Legend<T>,
193    /// How the map looks.
194    #[serde(with = "look_in_full")]
195    pub look: MapLook,
196}
197
198/// A map's look in a map's serde form (a baked map, `pack`): both fields,
199/// always. The look's own form leaves the default light out, which only a
200/// format that names its fields (RON) reads back.
201mod look_in_full {
202    use alloc::string::String;
203
204    use serde::{Deserialize, Deserializer, Serialize, Serializer};
205
206    use super::MapLook;
207
208    pub(super) fn serialize<S: Serializer>(
209        look: &MapLook,
210        serializer: S,
211    ) -> Result<S::Ok, S::Error> {
212        (&look.tiles, &look.light).serialize(serializer)
213    }
214
215    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
216        deserializer: D,
217    ) -> Result<MapLook, D::Error> {
218        let (tiles, light) = <(String, String)>::deserialize(deserializer)?;
219        Ok(MapLook { tiles, light })
220    }
221}
222
223impl<T: 'static, F: 'static> AssetKind for GridMap<T, F> {
224    const NAME: &'static str = "map";
225}
226
227/// What a terrain id resolves to, in a [`MapText`].
228type Terrain<T> = Box<dyn Fn(&str) -> Option<T> + Send + Sync>;
229
230/// The `.map` format (`map`, `.map`) of a game's maps, read as [`parse`]
231/// says: the game's [`MapFormat`], what a legend's terrain id resolves to,
232/// and its features `F`.
233pub struct MapText<T, F = NoFeature> {
234    format: MapFormat<'static>,
235    terrain: Terrain<T>,
236    feature: PhantomData<fn() -> F>,
237}
238
239impl<T, F> MapText<T, F> {
240    /// Maps of `format`, their legends' terrain ids resolved by `terrain`.
241    pub fn new(
242        format: MapFormat<'static>,
243        terrain: impl Fn(&str) -> Option<T> + Send + Sync + 'static,
244    ) -> Self {
245        Self {
246            format,
247            terrain: Box::new(terrain),
248            feature: PhantomData,
249        }
250    }
251}
252
253impl<T, F> fmt::Debug for MapText<T, F> {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        f.debug_struct("MapText")
256            .field("format", &self.format)
257            .finish_non_exhaustive()
258    }
259}
260
261impl<T: Copy + Default + 'static, F: MapFeature + 'static> Format for MapText<T, F> {
262    type Asset = GridMap<T, F>;
263
264    fn id(&self) -> &'static str {
265        "map"
266    }
267
268    fn extensions(&self) -> &'static [&'static str] {
269        &["map"]
270    }
271
272    fn max_bytes(&self) -> usize {
273        TEXT_MAX_BYTES
274    }
275
276    fn decode(
277        &self,
278        file: &AssetPath,
279        bytes: &[u8],
280        _cx: &DecodeCx<'_>,
281    ) -> Result<GridMap<T, F>, Vec<ContentError>> {
282        parse(
283            file.shown(),
284            text(file, bytes)?,
285            &self.format,
286            &self.terrain,
287        )
288    }
289}
290
291#[derive(Serialize, Deserialize)]
292#[serde(deny_unknown_fields)]
293#[serde(bound(serialize = "F: Serialize", deserialize = "F: DeserializeOwned"))]
294struct Header<F> {
295    name: String,
296    legend: BTreeMap<char, String>,
297    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
298    features: BTreeMap<(i32, i32), F>,
299    #[serde(default, skip_serializing_if = "MapLook::is_default")]
300    look: MapLook,
301}
302
303/// Parses map `source` (errors attributed to `file`), resolving legend
304/// terrain ids with `terrain`. Reports every problem with its line and
305/// column.
306pub fn parse<T, F>(
307    file: &str,
308    source: &str,
309    format: &MapFormat<'_>,
310    terrain: impl Fn(&str) -> Option<T>,
311) -> Result<GridMap<T, F>, Vec<ContentError>>
312where
313    T: Copy + Default,
314    F: MapFeature,
315{
316    let lines: Vec<&str> = source
317        .split('\n')
318        .map(|l| l.strip_suffix('\r').unwrap_or(l))
319        .collect();
320    let Some(sep) = lines.iter().position(|&l| l == SEPARATOR) else {
321        return Err(vec![ContentError::new(
322            file,
323            format!("missing the \"{SEPARATOR}\" line between the header and the tiles"),
324        )]);
325    };
326    let header_text = lines[..sep].join("\n");
327    let header: Header<F> = parse_ron(file, &header_text).map_err(|e| vec![e])?;
328
329    let mut errors = Vec::new();
330    let mut terrains = BTreeMap::new();
331    for (&c, id) in &header.legend {
332        if let Some(t) = terrain(id) {
333            terrains.insert(c, t);
334        } else {
335            let (line, col) = legend_position(&lines[..sep], c, id);
336            errors.push(
337                ContentError::new(file, format!("legend '{c}': unknown terrain \"{id}\""))
338                    .at(line, Some(col))
339                    .with_code(codes::UNKNOWN_NAME),
340            );
341        }
342    }
343
344    let tiles_look = header.look.tiles.as_str();
345    if !format.looks.contains(&tiles_look) {
346        let known: Vec<String> = format.looks.iter().map(|l| format!("\"{l}\"")).collect();
347        let message = format!(
348            "look: tiles \"{tiles_look}\" is not a look; the looks are {}",
349            known.join(", ")
350        );
351        let (line, col) =
352            header_position(&lines[..sep], &format!("\"{tiles_look}\"")).unwrap_or((1, 1));
353        errors.push(ContentError::new(file, message).at(line, Some(col)));
354    }
355
356    let rows = Rows {
357        file,
358        legend: &header.legend,
359        terrains: &terrains,
360        max_size: format.max_size,
361    };
362    let tiles = rows.parse(&lines[sep + 1..], sep + 1, &mut errors);
363    let mut features = BTreeMap::new();
364    for ((x, y), feature) in header.features {
365        let pos = Pos::new(x, y);
366        let at = || format!("feature at ({x}, {y})");
367        if tiles.as_ref().is_some_and(|t| t.get(pos).is_none()) {
368            errors.push(ContentError::new(
369                file,
370                format!("{}: outside the map", at()),
371            ));
372        }
373        if let Some(problem) = feature.problem() {
374            errors.push(ContentError::new(file, format!("{}: {problem}", at())));
375        }
376        features.insert(pos, feature);
377    }
378    match tiles {
379        Some(tiles) if errors.is_empty() => Ok(GridMap {
380            name: header.name,
381            tiles,
382            features,
383            legend: Legend {
384                names: header.legend,
385                terrains,
386            },
387            look: header.look,
388        }),
389        _ => Err(errors),
390    }
391}
392
393/// What the tile rows are read against.
394struct Rows<'a, T> {
395    file: &'a str,
396    legend: &'a BTreeMap<char, String>,
397    terrains: &'a BTreeMap<char, T>,
398    max_size: usize,
399}
400
401impl<T: Copy + Default> Rows<'_, T> {
402    /// Parses the tile `rows` (the first is on 0-based line index `first`)
403    /// into a grid, pushing every problem onto `errors`. `None` if the
404    /// tiles can't form a grid; a returned grid is only valid if no error
405    /// was pushed.
406    fn parse(
407        &self,
408        rows: &[&str],
409        first: usize,
410        errors: &mut Vec<ContentError>,
411    ) -> Option<Grid<T>> {
412        let file = self.file;
413        let max = self.max_size;
414        let mut rows: Vec<(u32, Vec<char>)> = rows
415            .iter()
416            .enumerate()
417            .map(|(i, l)| (line_number(first + i), l.chars().collect()))
418            .collect();
419        // Blank lines at the end are ignored.
420        while rows.last().is_some_and(|(_, r)| r.is_empty()) {
421            rows.pop();
422        }
423        let first_line = line_number(first);
424        let Some((_, first_row)) = rows.first() else {
425            errors.push(ContentError::new(file, "map has no tiles").at(first_line, Some(1)));
426            return None;
427        };
428        let width = first_row.len();
429        if width == 0 {
430            errors.push(ContentError::new(file, "first tile row is empty").at(first_line, Some(1)));
431            return None;
432        }
433        if width > max {
434            errors.push(
435                ContentError::new(
436                    file,
437                    format!("map is {width} tiles wide; the maximum is {max}"),
438                )
439                .at(first_line, Some(column_number(max)))
440                .with_code(codes::OUT_OF_RANGE),
441            );
442        }
443        if rows.len() > max {
444            errors.push(
445                ContentError::new(
446                    file,
447                    format!("map is {} tiles tall; the maximum is {max}", rows.len()),
448                )
449                .at(rows[max].0, Some(1))
450                .with_code(codes::OUT_OF_RANGE),
451            );
452        }
453        let mut cells = Vec::with_capacity(width * rows.len());
454        for (line, row) in &rows {
455            if row.len() != width {
456                errors.push(
457                    ContentError::new(
458                        file,
459                        format!(
460                            "row is {} tiles wide; expected {width} like the first row",
461                            row.len()
462                        ),
463                    )
464                    .at(*line, Some(column_number(row.len().min(width)))),
465                );
466            }
467            for (i, &c) in row.iter().enumerate() {
468                match self.terrains.get(&c) {
469                    Some(&t) => cells.push(t),
470                    // Its unknown terrain id was already reported.
471                    None if self.legend.contains_key(&c) => cells.push(T::default()),
472                    None => errors.push(
473                        ContentError::new(file, format!("'{c}' is not in the legend"))
474                            .at(*line, Some(column_number(i)))
475                            .with_code(codes::UNKNOWN_NAME),
476                    ),
477                }
478            }
479        }
480        let w = u16::try_from(width).ok()?;
481        let h = u16::try_from(rows.len()).ok()?;
482        Grid::from_cells(w, h, cells)
483    }
484}
485
486/// Prints `map` in `.map` format: its look only if it isn't the look of a
487/// map that names none. `None` if a tile's terrain has no character in the
488/// legend. Parsing the text gives `map` back.
489pub fn print<T, F>(map: &GridMap<T, F>) -> Option<String>
490where
491    T: Copy + PartialEq,
492    F: MapFeature + Clone,
493{
494    let header = Header {
495        name: map.name.clone(),
496        legend: map.legend.names.clone(),
497        features: map
498            .features
499            .iter()
500            .map(|(p, f)| ((p.x, p.y), f.clone()))
501            .collect(),
502        look: map.look.clone(),
503    };
504    let mut out = ::ron::to_string(&header).ok()?;
505    out.push('\n');
506    out.push_str(SEPARATOR);
507    out.push('\n');
508    let width = usize::from(map.tiles.width());
509    for row in map.tiles.cells().chunks(width.max(1)) {
510        for &t in row {
511            out.push(map.legend.char_for(t)?);
512        }
513        out.push('\n');
514    }
515    Some(out)
516}
517
518/// The header lines of map `source`: every line before the separator (all
519/// of them if it has none).
520pub fn header_lines(source: &str) -> Vec<&str> {
521    let lines: Vec<&str> = source.lines().collect();
522    let header = lines.iter().position(|&l| l == SEPARATOR);
523    lines[..header.unwrap_or(lines.len())].to_vec()
524}
525
526/// 1-based line and column of legend entry `c` in the header lines: the
527/// quoted character if found, else the terrain id string, else the start.
528pub fn legend_position(header: &[&str], c: char, id: &str) -> (u32, u32) {
529    let needles = [format!("'{c}'"), format!("\"{id}\"")];
530    let found = needles.iter().find_map(|n| header_position(header, n));
531    found.unwrap_or((1, 1))
532}
533
534/// 1-based line and column of the first `needle` in the header lines.
535pub fn header_position(header: &[&str], needle: &str) -> Option<(u32, u32)> {
536    header.iter().enumerate().find_map(|(i, line)| {
537        let byte = line.find(needle)?;
538        Some((line_number(i), column_number(line[..byte].chars().count())))
539    })
540}
541
542/// 1-based line number of 0-based line index `i`.
543pub fn line_number(i: usize) -> u32 {
544    u32::try_from(i + 1).unwrap_or(u32::MAX)
545}
546
547/// 1-based column number of 0-based char index `i`.
548pub fn column_number(i: usize) -> u32 {
549    u32::try_from(i + 1).unwrap_or(u32::MAX)
550}
551
552#[cfg(test)]
553mod tests;