Skip to main content

game_toolkit_platformer/sim/
level.rs

1//! [`Level`]: a ready-made [`Terrain`]: a grid of tiles (solid, one-way platforms), dash
2//! refills and a spawn point. Parse it from ASCII rows, build it from your own tilemap
3//! with [`Level::from_fn`], or implement [`Terrain`] for your own world instead.
4//!
5//! Tiles of your own ([`Tile::Custom`]: spikes, water, checkpoints) are air to the
6//! movement unless [`Level::define`] makes them solid ground or one-way platforms of some
7//! [`Surface`] (ice, a conveyor, a wall that can't be climbed, a bouncy ledge); [`Level::parse_with`] reads them from
8//! characters of your choosing and [`Level::tiles_touching`] finds the ones under the
9//! character.
10//!
11//! Tiles are [`TILE`] units unless [`Level::with_tile_size`] says otherwise; the tuning is
12//! made for 8-unit tiles, so bigger tiles make jumps relatively lower. For more (doors, moving
13//! platforms, boxes: state of your own beside the level), wrap it in your own terrain with
14//! the level as its [`Terrain::base`], and write only what changes (see
15//! [`super::terrain`]).
16
17use std::collections::BTreeMap;
18
19use super::grid::LevelGrid;
20use super::{Crystal, CustomTile, Grid, Hold, Rect, Spot, Surface, Terrain};
21
22/// The default tile size in units (see [`Level::tile_size`]).
23pub const TILE: i32 = 8;
24
25/// Seconds before a used dash refill comes back, unless the level says otherwise.
26const REFILL_BACK: f32 = 2.5;
27
28/// The demo level, one string per tile row: `#` solid, `=` one-way platform (stand on it,
29/// jump up through it, down + jump drops through), `*` a dash crystal, `.` empty, `P` the
30/// spawn (the hero stands on the tile below it). 48x27 tiles, exactly one screen.
31///
32/// Laid out around the debug overlay's corners, the play happens in the middle:
33///
34/// - left: stairs of 3-tile (24 px) steps, each within a plain jump, up to a tower 96 px
35///   over the floor
36/// - middle: two chimneys for wall-jump bouncing, a narrow one (24 px inside) and a wide
37///   one (40 px), open below, lidded with one-way platforms (drops of 136 and 160 px:
38///   rolls, hard landings, dives)
39/// - right: a low ceiling for corner corrections, a step up to a ledge over the pit, a
40///   free pillar to climb, dash crystals between
41/// - `B`: places for boxes (the demo's own; [`Level::parse`] reads them as air): one on
42///   the floor between the chimneys, one on the ledge
43pub const DEMO: [&str; 27] = [
44    "#..............................................#",
45    "#..............................................#",
46    "#..............................................#",
47    "#..........................*...................#",
48    "#..............................................#",
49    "#.......................=======................#",
50    "#.......................#.....#................#",
51    "#.......................#.....#....##..........#",
52    "#.............=====.....#.....#....##..........#",
53    "#.............#...#.....#.....#....##.......*..#",
54    "#.............#...#.....#.....#....##..........#",
55    "#.............#...#.....#.....#....##..........#",
56    "#.............#...#.....#.....#....##..........#",
57    "#####.........#...#..*..#.....#....##..........#",
58    "#.............#...#.....#.....#...........B....#",
59    "#.............#...#.....#.....#.........########",
60    "#....====.....#...#.....#.....#................#",
61    "#.............#...#.....#.....#................#",
62    "#.............#...#.....#.....#........===.....#",
63    "#####.........#...#.....#.....#................#",
64    "#.............#...#.....#.....#................#",
65    "#.............#...#..............######........#",
66    "#....====......................................#",
67    "#..............................................#",
68    "#..........P.........B.........................#",
69    "##########################################...###",
70    "##########################################...###",
71];
72
73/// What fills one tile. New kinds may come, so matches need a `_` arm.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum Tile {
77    /// Air.
78    Empty,
79    /// Blocks from every side; plain ground ([`Surface::DEFAULT`]), climbable.
80    Solid,
81    /// Blocks only from above, and only while not dropping through.
82    Platform,
83    /// Your game's own tile, by a number of your choosing: spikes, water, a checkpoint, the
84    /// goal, ice. The movement treats it as air unless [`Level::define`] says it is solid
85    /// or a one-way platform; find the ones the character touches with
86    /// [`Level::tiles_touching`].
87    ///
88    /// Unlike a [`CustomId`](super::CustomId), the number is not a name hash, on purpose:
89    /// one byte per tile keeps the grid small, and the numbers belong to the level, which
90    /// one game builds. A crate that ships a tile kind (spikes, water) takes its number as
91    /// a parameter, so the game hands out the numbers and two crates never collide.
92    Custom(u8),
93}
94
95/// Tiles on a grid, with refills. Outside the grid, the sides and the top are
96/// open air, and below the bottom the character falls out. Its parts are read and changed
97/// through methods, which keep them consistent (the grid with its size, the refills with
98/// the tile size).
99#[derive(Clone, Debug, PartialEq)]
100pub struct Level {
101    /// Width in tiles.
102    w: i32,
103    /// Height in tiles.
104    h: i32,
105    /// Row-major tiles, top row first.
106    tiles: Vec<Tile>,
107    /// The spawn tile.
108    spawn: (i32, i32),
109    /// The dash refills.
110    crystals: Vec<Crystal>,
111    /// Seconds before a used refill comes back.
112    refill_back: f32,
113    /// The size of a tile in units ([`TILE`] unless [`Level::with_tile_size`] changed it).
114    tile_size: i32,
115    /// What custom tiles are to the movement, by id (missing: air).
116    custom: BTreeMap<u8, CustomTile>,
117}
118
119impl Level {
120    /// Parse a level from tile rows (see [`DEMO`]): `#` solid, `=` one-way platform, `*`
121    /// dash refill, `P` spawn, anything else air. Panics on ragged rows: levels
122    /// written in code should fail loudly on a typo.
123    pub fn parse(rows: &[&str]) -> Self {
124        Self::parse_with(rows, |_| None)
125    }
126
127    /// Like [`Level::parse`], with characters of your own: `tile(c)` is asked first for
128    /// each one, and a `Some` wins over the built-in meaning (so `Some(Tile::Custom(0))`
129    /// for `^` makes spikes, and `Some(Tile::Empty)` for `*` leaves the refills out). A
130    /// spawn marker of your own: [`Level::find`] it and [`Level::set_spawn_tile`] there.
131    ///
132    /// ```
133    /// use game_toolkit_platformer::sim::{Level, Rect, Tile};
134    ///
135    /// const SPIKES: u8 = 0;
136    /// let level = Level::parse_with(&["....", ".^^.", "####"], |c| match c {
137    ///     '^' => Some(Tile::Custom(SPIKES)),
138    ///     _ => None,
139    /// });
140    /// let feet = Rect { x: 10, y: 4, w: 6, h: 12 };
141    /// assert!(level.tiles_touching(feet).any(|(_, _, t)| t == Tile::Custom(SPIKES)));
142    ///
143    /// // `S`, a start pad of your own, is the spawn.
144    /// const PAD: u8 = 1;
145    /// let mut level = Level::parse_with(&["..S.", "####"], |c| (c == 'S').then_some(Tile::Custom(PAD)));
146    /// let (tx, ty) = level.find(Tile::Custom(PAD)).unwrap();
147    /// level.set_spawn_tile(tx, ty);
148    /// assert_eq!(level.spawn_tile(), (2, 0));
149    /// ```
150    pub fn parse_with(rows: &[&str], tile: impl Fn(char) -> Option<Tile>) -> Self {
151        let h = rows.len() as i32;
152        let w = rows.first().map_or(0, |r| r.chars().count()) as i32;
153        for (y, row) in rows.iter().enumerate() {
154            let n = row.chars().count() as i32;
155            assert_eq!(n, w, "level row {y} is {n} wide, not {w}");
156        }
157        let grid: Vec<Vec<char>> = rows.iter().map(|r| r.chars().collect()).collect();
158        let mut level = Self::from_fn(w, h, |x, y| {
159            let c = grid[y as usize][x as usize];
160            tile(c).unwrap_or(match c {
161                '#' => Tile::Solid,
162                '=' => Tile::Platform,
163                _ => Tile::Empty,
164            })
165        });
166        for (y, row) in grid.iter().enumerate() {
167            for (x, &c) in row.iter().enumerate() {
168                if tile(c).is_some() {
169                    continue;
170                }
171                let at = (x as i32, y as i32);
172                match c {
173                    'P' => level.spawn = at,
174                    '*' => level.add_refill(at.0, at.1),
175                    _ => {}
176                }
177            }
178        }
179        level
180    }
181
182    /// A `w` x `h` level whose tile at `x, y` is `tile(x, y)`: the way in from your own
183    /// tilemap. No refills, the spawn at the top-left; add them as you like.
184    pub fn from_fn(w: i32, h: i32, tile: impl Fn(i32, i32) -> Tile) -> Self {
185        let tiles = (0..h)
186            .flat_map(|y| (0..w).map(move |x| (x, y)))
187            .map(|(x, y)| tile(x, y))
188            .collect();
189        Self {
190            w,
191            h,
192            tiles,
193            spawn: (1, 1),
194            crystals: Vec::new(),
195            refill_back: REFILL_BACK,
196            tile_size: TILE,
197            custom: BTreeMap::new(),
198        }
199    }
200
201    /// This level with tiles of `size` units (refills scaled along). For a tilemap whose
202    /// tiles are bigger than the feel's 8 units; to keep the feel at any tile size, keep
203    /// 8-unit tiles and draw them scaled, or use a `Builder::scale` with your own terrain.
204    pub fn with_tile_size(mut self, size: i32) -> Self {
205        assert!(size >= 1, "a tile of {size} units");
206        let old = self.tile_size;
207        for c in &mut self.crystals {
208            let a = &mut c.area;
209            (a.x, a.y) = (a.x.div_euclid(old) * size, a.y.div_euclid(old) * size);
210            (a.w, a.h) = (size, size);
211        }
212        self.tile_size = size;
213        self
214    }
215
216    /// Make custom tile `id` `tile` to the movement: solid ground of some [`Surface`] (ice,
217    /// a conveyor, a wall that can't be climbed), a one-way platform
218    /// ([`CustomTile::platform`]: a bouncy ledge), or air again with
219    /// `CustomTile::default()`.
220    ///
221    /// ```
222    /// use game_toolkit_platformer::sim::{CustomTile, Level, Rect, Surface, Tile};
223    ///
224    /// const ICE: u8 = 0;
225    /// let mut level = Level::parse_with(&["....", "~~~~"], |c| (c == '~').then_some(Tile::Custom(ICE)));
226    /// level.define(ICE, CustomTile::solid(Surface { friction: 0.1, ..Surface::DEFAULT }));
227    /// let feet = Rect { x: 8, y: 8, w: 6, h: 1 };
228    /// assert!(level.solid_tile(1, 1));
229    /// assert_eq!(level.surface_at(feet).friction, 0.1);
230    /// ```
231    pub fn define(&mut self, id: u8, tile: CustomTile) {
232        self.custom.insert(id, tile);
233    }
234
235    /// What custom tile `id` is to the movement (air unless [`Level::define`]d).
236    pub fn custom_tile(&self, id: u8) -> CustomTile {
237        self.custom.get(&id).copied().unwrap_or_default()
238    }
239
240    /// Width in tiles.
241    pub fn width(&self) -> i32 {
242        self.w
243    }
244
245    /// Height in tiles.
246    pub fn height(&self) -> i32 {
247        self.h
248    }
249
250    /// The size of a tile in units: [`TILE`] unless [`Level::with_tile_size`] changed it.
251    pub fn tile_size(&self) -> i32 {
252        self.tile_size
253    }
254
255    /// The spawn tile (the character stands on the tile below it; see [`Level::start`]).
256    pub fn spawn_tile(&self) -> (i32, i32) {
257        self.spawn
258    }
259
260    /// Move the spawn to tile `tx, ty`: a marker of your own (see [`Level::parse_with`]).
261    pub fn set_spawn_tile(&mut self, tx: i32, ty: i32) {
262        self.spawn = (tx, ty);
263    }
264
265    /// The dash refills, with whether each is there ([`Crystal::ready`]): for drawing.
266    pub fn refills(&self) -> &[Crystal] {
267        &self.crystals
268    }
269
270    /// Add a dash refill filling tile `tx, ty` (of this level's tile size).
271    pub fn add_refill(&mut self, tx: i32, ty: i32) {
272        self.crystals.push(Crystal::at((tx, ty), self.tile_size));
273    }
274
275    /// Seconds before a used refill comes back.
276    pub fn refill_back(&self) -> f32 {
277        self.refill_back
278    }
279
280    /// Make used refills come back after `seconds`.
281    pub fn set_refill_back(&mut self, seconds: f32) {
282        self.refill_back = seconds;
283    }
284
285    /// The first tile that is `tile`, row by row: where a marker of your own is.
286    pub fn find(&self, tile: Tile) -> Option<(i32, i32)> {
287        let i = self.tiles.iter().position(|&t| t == tile)? as i32;
288        Some((i % self.w, i / self.w))
289    }
290
291    /// The demo level.
292    pub fn demo() -> Self {
293        Self::parse(&DEMO)
294    }
295
296    /// Where a character starts: standing on the bottom of the spawn tile, centered on it
297    /// (units).
298    pub fn start(&self) -> Spot {
299        let (sx, sy) = self.spawn;
300        let t = self.tile_size;
301        Spot::Feet(sx * t + t / 2, (sy + 1) * t)
302    }
303
304    /// The tile range `r` overlaps: `(tx0, ty0, tx1, ty1)`, inclusive.
305    fn tile_span(&self, r: Rect) -> (i32, i32, i32, i32) {
306        let t = self.tile_size;
307        (
308            r.x.div_euclid(t),
309            r.y.div_euclid(t),
310            (r.right() - 1).div_euclid(t),
311            (r.bottom() - 1).div_euclid(t),
312        )
313    }
314
315    /// The tile at `tx, ty`; outside the grid, air.
316    pub fn tile(&self, tx: i32, ty: i32) -> Tile {
317        if (0..self.w).contains(&tx) && (0..self.h).contains(&ty) {
318            self.tiles[(ty * self.w + tx) as usize]
319        } else {
320            Tile::Empty
321        }
322    }
323
324    /// Change the tile at `tx, ty` (outside the grid: nothing happens).
325    pub fn set(&mut self, tx: i32, ty: i32, tile: Tile) {
326        if (0..self.w).contains(&tx) && (0..self.h).contains(&ty) {
327            self.tiles[(ty * self.w + tx) as usize] = tile;
328        }
329    }
330
331    /// The tiles `r` overlaps, as `(tx, ty, tile)`, row by row: what the character's
332    /// hitbox touches (spikes, water, the goal), with `Tile::Custom` tiles of your own.
333    pub fn tiles_touching(&self, r: Rect) -> impl Iterator<Item = (i32, i32, Tile)> + '_ {
334        let (tx0, ty0, tx1, ty1) = self.tile_span(r);
335        (ty0..=ty1).flat_map(move |ty| (tx0..=tx1).map(move |tx| (tx, ty, self.tile(tx, ty))))
336    }
337
338    /// The first of `hitboxes` that touches a `tile`, if any: pass
339    /// [`Platformer::hitboxes`](crate::Platformer::hitboxes) to test every step of the last
340    /// frame. The result is a copy, so you can act on the character right away:
341    /// `if level.touches(hero.hitboxes(), SPIKES).is_some() { hero.respawn(); }`.
342    pub fn touches(&self, hitboxes: &[Rect], tile: Tile) -> Option<Rect> {
343        hitboxes
344            .iter()
345            .copied()
346            .find(|&r| self.tiles_touching(r).any(|(_, _, t)| t == tile))
347    }
348
349    /// What tile `tx, ty` is to the movement: [`Tile::Solid`] and [`Tile::Platform`] as
350    /// plain ground, a custom tile as [`Level::define`]d, the rest air.
351    pub(crate) fn tile_kind(&self, tx: i32, ty: i32) -> CustomTile {
352        match self.tile(tx, ty) {
353            Tile::Solid => CustomTile::solid(Surface::DEFAULT),
354            Tile::Platform => CustomTile::platform(Surface::DEFAULT),
355            Tile::Custom(id) => self.custom_tile(id),
356            Tile::Empty => CustomTile::default(),
357        }
358    }
359
360    /// Whether tile `tx, ty` is solid: [`Tile::Solid`], or a custom tile
361    /// [`Level::define`]d solid.
362    pub fn solid_tile(&self, tx: i32, ty: i32) -> bool {
363        self.tile_kind(tx, ty).hold == Hold::Solid
364    }
365
366    /// Whether tile `tx, ty` is a one-way platform: [`Tile::Platform`], or a custom tile
367    /// [`Level::define`]d one.
368    pub fn platform_tile(&self, tx: i32, ty: i32) -> bool {
369        self.tile_kind(tx, ty).hold == Hold::Platform
370    }
371
372    /// The surface `r` touches (the row under the feet, the column beside a wall): of the
373    /// holding tile under `r`'s center if there is one, else of the first holding tile it
374    /// overlaps, else [`Surface::DEFAULT`]. The same rule as [`Grid::surface_of`].
375    pub fn surface_at(&self, r: Rect) -> Surface {
376        LevelGrid(self).surface_of(r)
377    }
378
379    /// Whether `r` overlaps any solid tile (platforms don't count).
380    pub fn overlaps(&self, r: Rect) -> bool {
381        LevelGrid(self).overlaps_solid(r)
382    }
383
384    /// Whether `r` stands exactly on top of a one-way platform.
385    pub fn on_platform(&self, r: Rect) -> bool {
386        LevelGrid(self).on_platform(r)
387    }
388
389    /// The bottom of the level in units.
390    pub fn floor_y(&self) -> i32 {
391        self.h * self.tile_size
392    }
393}
394
395impl Terrain for Level {
396    fn solid(&self, r: Rect) -> bool {
397        self.overlaps(r)
398    }
399
400    fn platform(&self, r: Rect) -> bool {
401        self.on_platform(r)
402    }
403
404    fn refill(&mut self, r: Rect) -> bool {
405        let back = self.refill_back;
406        match self
407            .crystals
408            .iter_mut()
409            .find(|c| c.ready() && c.area.intersects(r))
410        {
411            Some(c) => {
412                c.cooldown = back;
413                true
414            }
415            None => false,
416        }
417    }
418
419    fn fell_out(&self, r: Rect) -> bool {
420        r.y > self.floor_y() + 4 * self.tile_size
421    }
422
423    fn surface(&self, r: Rect) -> Surface {
424        self.surface_at(r)
425    }
426
427    fn tick(&mut self, dt: f32, _bodies: &[Rect]) {
428        for c in &mut self.crystals {
429            c.cooldown = (c.cooldown - dt).max(0.0);
430        }
431    }
432
433    crate::terrain_layer!();
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn demo_parses_with_spawn_on_open_ground() {
442        let l = Level::demo();
443        assert_eq!((l.w, l.h), (48, 27));
444        let (sx, sy) = l.spawn;
445        assert_eq!(l.tile(sx, sy), Tile::Empty);
446        assert!(l.solid_tile(sx, sy + 1));
447        assert_eq!(l.crystals.len(), 3);
448        assert_eq!(l.spawn, (11, 24));
449        assert_eq!(l.tile(21, 24), Tile::Empty, "box places are air");
450    }
451
452    #[test]
453    fn overlaps_rounds_negative_coordinates_down() {
454        let l = Level::parse(&["#.", ".."]);
455        assert!(l.overlaps(Rect {
456            x: -3,
457            y: 0,
458            w: 4,
459            h: 4
460        }));
461        assert!(!l.overlaps(Rect {
462            x: 8,
463            y: 0,
464            w: 8,
465            h: 8
466        }));
467        assert!(!l.overlaps(Rect {
468            x: -9,
469            y: 0,
470            w: 4,
471            h: 4
472        }));
473    }
474
475    #[test]
476    fn custom_tiles_are_air_and_can_be_found() {
477        let l = Level::parse_with(&["~~P.", "~##*"], |c| (c == '~').then_some(Tile::Custom(7)));
478        assert_eq!(l.tile(0, 0), Tile::Custom(7));
479        assert_eq!(l.spawn, (2, 0), "built-in characters still work");
480        assert_eq!(l.crystals.len(), 1);
481        let r = Rect {
482            x: 0,
483            y: 0,
484            w: 8,
485            h: 16,
486        };
487        assert!(!l.overlaps(r), "custom tiles are air");
488        let found: Vec<_> = l.tiles_touching(r).collect();
489        assert_eq!(
490            found,
491            vec![(0, 0, Tile::Custom(7)), (0, 1, Tile::Custom(7))]
492        );
493        // A character can be overridden too.
494        let l = Level::parse_with(&["*"], |c| (c == '*').then_some(Tile::Empty));
495        assert!(l.crystals.is_empty());
496    }
497
498    #[test]
499    fn refills_added_after_a_new_tile_size_fill_their_tile() {
500        let mut l = Level::parse(&["*...", "...."]).with_tile_size(16);
501        l.add_refill(2, 1);
502        let areas: Vec<Rect> = l.refills().iter().map(|c| c.area).collect();
503        let tile = |x, y| Rect { x, y, w: 16, h: 16 };
504        assert_eq!(areas, vec![tile(0, 0), tile(32, 16)]);
505        assert_eq!((l.width(), l.height(), l.tile_size()), (4, 2, 16));
506    }
507
508    #[test]
509    fn a_custom_tile_can_be_a_one_way_platform_and_the_spawn() {
510        let mut l = Level::parse_with(&["..S.", "~~~~"], |c| match c {
511            '~' => Some(Tile::Custom(3)),
512            'S' => Some(Tile::Custom(4)),
513            _ => None,
514        });
515        let bouncy = Surface {
516            bounce: 0.5,
517            ..Surface::DEFAULT
518        };
519        l.define(3, CustomTile::platform(bouncy));
520        let feet = Rect {
521            x: 2,
522            y: -4,
523            w: 4,
524            h: 12,
525        };
526        assert!(l.on_platform(feet) && l.platform_tile(0, 1));
527        assert!(!l.overlaps(feet.shifted(0, 4)), "not solid");
528        assert_eq!(l.surface_at(feet.shifted(0, 8)).bounce, 0.5);
529        assert_eq!(l.find(Tile::Custom(4)), Some((2, 0)));
530        assert_eq!(l.find(Tile::Solid), None);
531        l.set_spawn_tile(2, 0);
532        assert_eq!(l.start(), Spot::Feet(20, 8));
533    }
534
535    #[test]
536    fn platforms_hold_only_from_exactly_above() {
537        let l = Level::parse(&["..", "=="]);
538        let feet_on = Rect {
539            x: 2,
540            y: -4,
541            w: 4,
542            h: 12,
543        };
544        assert!(l.on_platform(feet_on));
545        assert!(!l.on_platform(feet_on.shifted(0, 1)));
546        assert!(!l.overlaps(feet_on.shifted(0, 4)));
547    }
548}