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}