falling_tetromino_engine/lib.rs
1/*!
2# Falling Tetromino Engine
3
4`falling_tetromino_engine` is an implementation of a tetromino game engine, able to handle numerous modern
5mechanics.
6
7# Example
8
9```
10use falling_tetromino_engine::*;
11
12// Starting up a game - note that in-game time starts at 0s.
13let mut game = Game::builder()
14 .seed(1234)
15 /* ...Further optional configuration possible... */
16 .build();
17
18// Updating the game with the info that 'left' should be pressed at second 4.2;
19// If a piece is in the game, it will try to move left.
20let input = Input::Activate(Button::MoveLeft);
21game.update(InGameTime::from_secs_f64(4.2), Some(input));
22
23// ...
24
25// Updating the game with the info that no input change has occurred up to second 6.79;
26// This updates the game, e.g., pieces fall and lock.
27game.update(InGameTime::from_secs_f64(13.37), None);
28
29// Read most recent game state;
30// This is how a UI can know how to render the board, etc.
31let State { board, .. } = game.state();
32```
33
34[FIXME: Document *all* features in detail (including IRS, etc., cargo feature `serde` etc.).]
35*/
36
37#![warn(missing_docs)]
38
39mod builder;
40pub mod extduration;
41pub mod extnonnegf64;
42pub mod modding;
43pub mod randomization;
44pub mod rotation;
45mod update;
46
47use std::{collections::VecDeque, fmt, num::NonZeroU8, ops, time::Duration};
48
49use rand_chacha::{rand_core::SeedableRng, ChaCha8Rng};
50
51pub use builder::GameBuilder;
52pub use extduration::ExtDuration;
53pub use extnonnegf64::ExtNonNegF64;
54pub use modding::{GameAccess, GameModifier};
55pub use randomization::TetrominoGenerator;
56pub use rotation::RotationSystem;
57
58/// Abstract identifier for which type of tile occupies a cell in the grid.
59pub type TileTypeID = NonZeroU8;
60/// The type of horizontal lines of the playing grid.
61pub type Line = [Option<TileTypeID>; Game::WIDTH];
62// NOTE: Would've liked to use `impl Game { type Board = ...` (https://github.com/rust-lang/rust/issues/8995)
63/// The type of the entire two-dimensional playing grid.
64pub type Board = [Line; Game::HEIGHT];
65/// Coordinates conventionally used to index into the [`Board`], starting in the bottom left.
66pub type Coord = (isize, isize);
67/// Coordinates offsets that can be [`add`]ed to [`Coord`]inates.
68pub type CoordOffset = (isize, isize);
69/// Type describing the state that is stored about buttons.
70///
71/// Specifically, it stores which buttons are considered active, and if yes, since when.
72pub type ButtonsState = [Option<InGameTime>; Button::VARIANTS.len()];
73/// The type used to identify points in time in a game's internal timeline.
74pub type InGameTime = Duration;
75/// The internal RNG used by a game.
76pub type GameRng = ChaCha8Rng;
77/// Type alias for a stream of notifications with timestamps.
78pub type NotificationFeed = Vec<(Notification, InGameTime)>;
79
80/// Represents one of the seven "Tetrominos";
81///
82/// A *tetromino* is a two-dimensional, geometric shape made by
83/// connecting four squares (orthogonally / at along the edges).
84#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy, Hash, Debug)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub enum Tetromino {
87 /// 'O'-Tetromino.
88 /// Four squares connected as one big square; `⠶`, `██`.
89 ///
90 /// 'O' has 90° rotational symmetry + 2 axes of mirror symmetry.
91 O = 0,
92 /// 'I'-Tetromino.
93 /// Four squares connected as one straight line; `⡇`, `▄▄▄▄`.
94 ///
95 /// 'I' has 180° rotational symmetry + 2 axes of mirror symmetry.
96 I = 1,
97 /// 'S'-Tetromino.
98 /// Four squares connected in an 'S'-snaking manner; `⠳`, `▄█▀`.
99 ///
100 /// 'S' has 180° rotational symmetry + 0 axes of mirror symmetry.
101 S = 2,
102 /// 'Z'-Tetromino:
103 /// Four squares connected in a 'Z'-snaking manner; `⠞`, `▀█▄`.
104 ///
105 /// 'Z' has 180° rotational symmetry + 0 axes of mirror symmetry.
106 Z = 3,
107 /// 'T'-Tetromino:
108 /// Four squares connected in a 'T'-junction shape; `⠗`, `▄█▄`.
109 ///
110 /// 'T' has 360° rotational symmetry + 1 axis of mirror symmetry.
111 T = 4,
112 /// 'L'-Tetromino:
113 /// Four squares connected in an 'L'-shape; `⠧`, `▄▄█`.
114 ///
115 /// 'L' has 360° rotational symmetry + 0 axes of mirror symmetry.
116 L = 5,
117 /// 'J'-Tetromino:
118 /// Four squares connected in a 'J'-shape; `⠼`, `█▄▄`.
119 ///
120 /// 'J' has 360° rotational symmetry + 0 axes of mirror symmetry.
121 J = 6,
122}
123
124/// Represents the orientation an active piece can be in.
125#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy, Hash, Debug)]
126#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
127pub enum Orientation {
128 /// North.
129 N = 0,
130 /// East.
131 E,
132 /// South.
133 S,
134 /// West.
135 W,
136}
137
138/// An active tetromino in play.
139#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy, Hash, Debug)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
141pub struct Piece {
142 /// Type of tetromino the active piece is.
143 pub tetromino: Tetromino,
144 /// In which way the tetromino is re-oriented.
145 pub orientation: Orientation,
146 /// The position of the active piece on a playing grid.
147 pub position: Coord,
148}
149
150/// A struct holding information on how certain time 'delay' values progress during a game's lifetime.
151///
152/// # Example
153/// The formulation used for calculation of fall delay is conceptually:
154/// ```ignore
155/// let fall_delay = |lineclears| {
156/// initial_fall_delay.mul_ennf64(
157/// multiplier.get().powf(lineclears) - subtrahend.get() * lineclears
158/// )
159/// }
160/// ```
161#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy, Hash, Debug)]
162#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
163pub struct DelayParameters {
164 /// The duration at which the delay starts.
165 base_delay: ExtDuration,
166 /// The base factor that gets exponentiated by number of line clears;
167 /// `factor ^ lineclears ...`.
168 ///
169 /// Should be in the range `0.0 ≤ .. ≤ 1.0`, where
170 /// - `0.0` means 'zero-out initial delay at every line clear',
171 /// - `0.5` means 'halve initial delay for every line clear',
172 /// - `1.0` means 'keep initial delay at 100%'.
173 factor: ExtNonNegF64,
174 /// The base subtrahend that gets multiplied by number of line clears;
175 /// `... - subtrahend * lineclears`.
176 ///
177 /// Should be in the range `0.0 ≤ .. ≤ 1.0`, where
178 /// - `0.0` means 'subtract 0% of initial delay for every line clear',
179 /// - `0.5` means 'subtract 50% of initial delay for every line clear',
180 /// - `1.0` means 'subtract 100% of initial delay for every line clear'.
181 subtrahend: ExtDuration,
182 /// The duration below which delay cannot decrease.
183 lowerbound: ExtDuration,
184}
185
186/// Certain statistics for which an instance of [`Game`] can be checked against.
187#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Debug, Default)]
188#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
189pub struct GameLimits {
190 /// A given amount of total time that can elapse in-game.
191 pub time_elapsed: Option<(InGameTime, bool)>,
192 /// A given number of [`Tetromino`]s that can be locked/placed on the game's [`Board`].
193 pub pieces_locked: Option<(u32, bool)>,
194 /// A given number of lines that can be cleared from the [`Board`].
195 pub lines_cleared: Option<(u32, bool)>,
196 /// A given number of points that can be scored.
197 pub points_scored: Option<(u32, bool)>,
198}
199
200/// Certain statistics for which an instance of [`Game`] can be checked against.
201#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Debug)]
202#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
203pub enum Stat {
204 /// A given amount of total time that elapsed in-game.
205 TimeElapsed(InGameTime),
206 /// A given number of [`Tetromino`]s that have been locked/placed on the game's [`Board`].
207 PiecesLocked(u32),
208 /// A given number of lines that have been cleared from the [`Board`].
209 LinesCleared(u32),
210 /// A given number of points that have been scored.
211 PointsScored(u32),
212}
213
214/// The amount of feedback information that is to be generated.
215#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy, Hash, Debug)]
216#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
217pub enum NotificationLevel {
218 /// No feedback generated by base engine.
219 /// Note that game modifiers called may choose to generate feedback messages
220 /// themselves, which will not again be discarded once received by
221 /// the base game engine.
222 Silent,
223 /// Base level of feedback about in-game events.
224 Standard,
225 /// Highest level of feedback, which includes emitting every
226 /// internal game event processed
227 Debug,
228}
229
230/// Configuration options of the game, which can be modified without hurting internal invariants.
231///
232/// # Reproducibility
233/// Modifying a [`Game`]'s configuration after it was created might not make it easily
234/// reproducible anymore.
235#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Hash, Debug)]
236#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
237pub struct Configuration {
238 /// How many pieces should be pre-generated and accessible/visible in the game state.
239 pub piece_preview_count: usize,
240 /// Whether holding a 'rotate' button lets a piece be smoothly spawned in a rotated state,
241 /// or holding the 'hold' button lets a piece be swapped immediately before it evens spawns.
242 pub allow_initial_actions: bool,
243 /// The method of tetromino rotation used.
244 pub rotation_system: RotationSystem,
245 /// How long the game should take to spawn a new piece.
246 pub spawn_delay: Duration,
247 /// How long it takes for the active piece to start automatically shifting more to the side
248 /// after the initial time a 'move' button has been pressed.
249 pub delayed_auto_shift: Duration,
250 /// How long it takes for automatic side movement to repeat once it has started.
251 pub auto_repeat_rate: Duration,
252 /// Specification of how fall delay gets calculated from the rest of the state.
253 pub fall_delay_params: DelayParameters,
254 /// How many times faster than normal drop speed a piece should fall while 'soft drop' is being held.
255 pub soft_drop_factor: ExtNonNegF64,
256 /// Specification of how fall delay gets calculated from the rest of the state.
257 pub lock_delay_params: DelayParameters,
258 /// Whether engine should try to ensure that delays for autonomous moves - which are determined by
259 /// `delayed_auto_shift` and `auto_repeat_rate` - should be less than `lock_delay` runs out.
260 /// This allows DAS and ARR to function at extreme game speeds.
261 pub ensure_move_delay_lt_lock_delay: bool,
262 /// Whether just pressing a rotation- or movement button is enough to refresh lock delay.
263 /// Normally, lock delay only resets if rotation or movement actually succeeds.
264 pub allow_lenient_lock_reset: bool,
265 /// How long each spawned active piece may touch the ground in total until it should lock down
266 /// immediately.
267 pub lock_reset_cap_factor: ExtNonNegF64,
268 /// How long the game should take to clear a line.
269 pub line_clear_duration: Duration,
270 /// When to update the fall and lock delays in [`State`].
271 pub update_delays_every_n_lineclears: u32,
272 /// Stores the ways in which a round of the game should be limited.
273 ///
274 /// Each limitation may be either of positive ('game completed') or negative ('game over'), as
275 /// designated by the `bool` stored with it.
276 ///
277 /// No limitations may allow for endless games.
278 pub game_limits: GameLimits,
279 /// The amount of feedback information that is to be generated.
280 pub notification_level: NotificationLevel,
281}
282
283/// Some values that were used to help initialize the game.
284///
285/// Used for game reproducibility.
286#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy, Hash, Debug)]
287#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
288pub struct StateInitialization {
289 /// The value to seed the game's PRNG with.
290 pub seed: u64,
291 /// The method (and internal state) of tetromino generation used.
292 pub tetromino_generator: TetrominoGenerator,
293}
294
295/// Represents an abstract game input.
296// NOTE: We could consider calling this `Action` judging from its variants, however the Game stores a mapping of whether a given `Button` is active over a period of time. `Intents` could work but `Button` is less abstract and often corresponds directly to IRL player inputs.
297#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy, Hash, Debug)]
298#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
299pub enum Button {
300 /// Moves the piece once to the left.
301 MoveLeft = 0,
302 /// Moves the piece once to the right.
303 MoveRight,
304 /// Rotate the piece by +90° (clockwise).
305 RotateLeft,
306 /// Rotate the piece by -90° (counter-clockwise).
307 RotateRight,
308 /// Rotate the piece by 180° (flip around).
309 Rotate180,
310 /// "Soft" dropping.
311 /// This drops a piece down by one, locking it immediately if it hit a surface,
312 /// Otherwise holding this button decreases fall speed by the game [`Configuration`]'s `soft_drop_factor`.
313 DropSoft,
314 /// "Hard" dropping.
315 /// This immediately drops a piece all the way down until it hits a surface,
316 /// locking it there (almost) instantly, too.
317 DropHard,
318 /// Teleport the piece down, also known as "Sonic" dropping.
319 /// This immediately drops a piece all the way down until it hits a surface,
320 /// but without locking it (unlike [`Button::DropHard`]).
321 TeleDown,
322 /// Instantly 'teleports' (moves) a piece left until it hits a surface.
323 TeleLeft,
324 /// Instantly 'teleports' (moves) a piece right until it hits a surface.
325 TeleRight,
326 /// Holding the current piece; and swapping in a new piece if one was held previously.
327 HoldPiece,
328}
329
330/// A change in button state, between being held down or unpressed.
331#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy, Hash, Debug)]
332#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
333pub enum Input {
334 /// The signal of a button now being activated.
335 Activate(Button),
336 /// The signal of a button now being deactivated.
337 Deactivate(Button),
338}
339
340/// Struct storing internal game state that changes over the course of play.
341#[derive(Eq, PartialEq, Clone, Debug)]
342#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
343pub struct State {
344 /// Current in-game time.
345 pub time: InGameTime,
346 /// The stores which buttons are considered active and since when.
347 pub active_buttons: ButtonsState,
348 /// The internal pseudo random number generator used.
349 pub rng: GameRng,
350 /// The method (and internal state) of tetromino generation used.
351 pub piece_generator: TetrominoGenerator,
352 /// Upcoming pieces to be played.
353 pub piece_preview: VecDeque<Tetromino>,
354 /// Data about the piece being held. `true` denotes that the held piece can be swapped back in.
355 pub piece_held: Option<(Tetromino, bool)>,
356 /// The main playing grid storing empty (`None`) and filled, fixed tiles (`Some(nz_u32)`).
357 pub board: Board,
358 /// The current duration a piece takes to fall one unit.
359 pub fall_delay: ExtDuration,
360 /// The point (number of lines cleared) at which fall delay was updated to zero (possibly capped if formula yielded negative).
361 pub fall_delay_lowerbound_hit_at_n_lineclears: Option<u32>,
362 /// The current duration a piece takes to try and lock down.
363 pub lock_delay: ExtDuration,
364 /// Tallies of how many pieces of each type have been played so far.
365 pub pieces_locked: [u32; Tetromino::VARIANTS.len()],
366 /// The total number of lines that have been cleared.
367 pub lineclears: u32,
368 /// The number of consecutive pieces that have been played and caused a line clear.
369 pub consecutive_line_clears: u32,
370 /// The current total score the player has achieved in this round of play.
371 pub points: u32,
372}
373
374/// Represents how a game can end.
375#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Hash, Debug)]
376#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
377pub enum GameEndCause {
378 /// 'Lock out' denotes the most recent piece would be completely locked down at
379 /// or above [`Game::SKYLINE_HEIGHT`].
380 LockOut {
381 /// The offending piece that does not fit below [`Game::SKYLINE_HEIGHT`].
382 locking_piece: Piece,
383 },
384 /// 'Block out' denotes a new piece being unable to spawn due to existing board tile(s)
385 /// blocking one or several of the cells of a piece to be spawned.
386 BlockOut {
387 /// The offending piece that does not fit onto board.
388 blocked_piece: Piece,
389 },
390 // 'Top out' denotes a number of new lines being unable to enter the existing board.
391 /// This is currently unused in the base engine.
392 TopOut {
393 /// The offending lines that did not fit onto the existing board.
394 blocked_lines: Vec<Line>,
395 },
396 /// Game over by having reached a [`Stat`] limit.
397 Limit(Stat),
398 /// Game ended by player forfeit.
399 Forfeit {
400 /// Piece that was in play at time of forfeit.
401 piece_in_play: Option<Piece>,
402 },
403 /// Custom game over.
404 /// This is unused in the base engine and intended for modding.
405 Custom(String),
406}
407
408/// An event that is scheduled by the game engine to execute some action.
409#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Hash, Debug)]
410#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
411pub enum Phase {
412 /// The state of the game "taking its time" to spawn a piece.
413 /// This is the state the board will have right before attempting to spawn a new piece.
414 Spawning {
415 /// The in-game time at which the game moves on to the next `Phase.`
416 spawn_time: InGameTime,
417 },
418 /// The state of the game having an active piece in-play, which can be controlled by a player.
419 PieceInPlay {
420 /// The tetromino game piece itself.
421 piece: Piece,
422 /// Optional time of the next move event.
423 auto_move_scheduled: Option<InGameTime>,
424 /// The time of the next fall or lock event.
425 fall_or_lock_time: InGameTime,
426 /// The time after which the active piece will immediately lock upon touching ground.
427 lock_time_cap: InGameTime,
428 /// The lowest recorded vertical position of the main piece.
429 lowest_y: isize,
430 },
431 /// The state of the game "taking its time" to clear out lines.
432 /// In this state the board is as it was at the time of the piece locking down,
433 /// i.e. with some horizontally completed lines.
434 /// After exiting this state, the
435 LinesClearing {
436 /// The in-game time at which the game moves on to the next `Phase.`
437 clear_finish_time: InGameTime,
438 /// The score bonus that will be earned once the lines are cleared out.
439 score_bonus: u32,
440 },
441 /// The state of the game being irreversibly over, and not playable anymore.
442 GameEnd {
443 /// The cause of why the game ended.
444 cause: GameEndCause,
445 /// Whether the ending is considered a win.
446 is_win: bool,
447 },
448}
449
450/// Main game struct representing a round of play.
451#[derive(Debug)]
452pub struct Game {
453 /// Some internal configuration options of the `Game`.
454 ///
455 /// # Reproducibility
456 /// Modifying a `Game`'s configuration after it was created might not make it easily
457 /// reproducible anymore.
458 pub config: Configuration,
459 state_init: StateInitialization,
460 state: State,
461 phase: Phase,
462 /// A list of special modifiers that apply to the `Game`.
463 ///
464 /// # Reproducibility
465 /// Modifying a `Game`'s modifiers after it was created might not make it easily
466 /// reproducible anymore.
467 pub modifiers: Vec<Box<dyn GameModifier>>,
468}
469
470/// A number of feedback notifications that can be returned by the game.
471///
472/// These can be used to more easily render visual feedback to the player.
473///
474/// The [`Notification::Debug`] variant is accessible if [`NotificationLevel::Debug`] is toggled.
475/// All other events are generally variants of `Notification::Debug` but provide additional info to possibly
476/// reconstruct visual effects (e.g. location of where a lock actually occurred, or how long a lineclear took at the time it happened).
477#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Hash, Debug)]
478#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
479pub enum Notification {
480 /// A piece was locked down in a certain configuration.
481 PieceLocked {
482 /// Information about the [`Piece`] that was locked.
483 piece: Piece,
484 },
485 /// A number of lines were cleared.
486 ///
487 /// The duration indicates the line clear delay the game was configured with at the time.
488 LinesClearing {
489 /// A list of height coordinates/indices signifying where lines where cleared.
490 y_coords: Vec<usize>,
491 /// Game time where lines started clearing.
492 /// Starts simultaneously to when a piece was locked and successfully completed some horizontal [`Line`]s,
493 /// therefore this will coincide with the time same value in a nearby [`Notification::PieceLocked`].
494 line_clear_duration: InGameTime,
495 },
496 /// A piece was quickly dropped from its original position to a new one.
497 HardDrop {
498 /// Information about the old state of the hard-dropped piece.
499 previous_piece: Piece,
500 /// Information about the new state of the hard-dropped piece.
501 updated_piece: Piece,
502 },
503 /// The player cleared some lines with a number of other stats that might have increased their
504 /// score bonus.
505 Accolade {
506 /// The final computed score bonus caused by the action.
507 score_bonus: u32,
508 /// How many lines were cleared by the piece simultaneously
509 lineclears: u32,
510 /// The number of consecutive pieces played that caused a lineclear.
511 combo: u32,
512 /// Whether the piece was spun into place.
513 is_spin: bool,
514 /// Whether the entire board was cleared empty by this action.
515 is_perfect_clear: bool,
516 /// The tetromino type that was locked.
517 tetromino: Tetromino,
518 },
519 /// Message that the game has ended.
520 GameEnded {
521 /// Whether it was a win or a loss.
522 is_win: bool,
523 },
524 /// A message containing debug information.
525 ///
526 /// This feedback type is only generated on [`NotificationLevel::Debug`]
527 Debug(String),
528 /// Generic text feedback message.
529 ///
530 /// This is currently unused in the base engine.
531 Custom(String),
532}
533
534/// An error that can be thrown by [`Game::update`].
535#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy, Hash, Debug)]
536pub enum UpdateGameError {
537 /// Error variant caused by an attempt to update the game with a requested `update_time` that lies in
538 /// the game's past (` < game.state().time`).
539 TargetTimeInPast,
540 /// Error variant caused by an attempt to update a game that has ended (`game.ended() == true`).
541 AlreadyEnded,
542}
543
544impl Tetromino {
545 /// All `Tetromino` enum variants in order.
546 ///
547 /// Note that `Tetromino::VARIANTS[t as usize] == t` always holds.
548 pub const VARIANTS: [Self; 7] = {
549 use Tetromino::*;
550 [O, I, S, Z, T, L, J]
551 };
552
553 /// Returns the mino offsets of a tetromino shape, given an orientation.
554 pub const fn minos(&self, oriented: Orientation) -> [Coord; 4] {
555 use Orientation::*;
556 match self {
557 Tetromino::O => [(0, 0), (1, 0), (0, 1), (1, 1)], // ⠶
558 Tetromino::I => match oriented {
559 N | S => [(0, 0), (1, 0), (2, 0), (3, 0)], // ⠤⠤
560 E | W => [(0, 0), (0, 1), (0, 2), (0, 3)], // ⡇
561 },
562 Tetromino::S => match oriented {
563 N | S => [(0, 0), (1, 0), (2, 1), (1, 1)], // ⠴⠂
564 E | W => [(1, 0), (0, 1), (1, 1), (0, 2)], // ⠳
565 },
566 Tetromino::Z => match oriented {
567 N | S => [(1, 0), (2, 0), (0, 1), (1, 1)], // ⠲⠄
568 E | W => [(0, 0), (1, 1), (0, 1), (1, 2)], // ⠞
569 },
570 Tetromino::T => match oriented {
571 N => [(0, 0), (1, 0), (2, 0), (1, 1)], // ⠴⠄
572 E => [(0, 0), (1, 1), (0, 1), (0, 2)], // ⠗
573 S => [(1, 0), (0, 1), (2, 1), (1, 1)], // ⠲⠂
574 W => [(1, 0), (0, 1), (1, 1), (1, 2)], // ⠺
575 },
576 Tetromino::L => match oriented {
577 N => [(0, 0), (1, 0), (2, 0), (2, 1)], // ⠤⠆
578 E => [(0, 0), (1, 0), (0, 1), (0, 2)], // ⠧
579 S => [(0, 0), (1, 1), (2, 1), (0, 1)], // ⠖⠂
580 W => [(1, 0), (0, 2), (1, 1), (1, 2)], // ⠹
581 },
582 Tetromino::J => match oriented {
583 N => [(0, 0), (1, 0), (2, 0), (0, 1)], // ⠦⠄
584 E => [(0, 0), (1, 2), (0, 1), (0, 2)], // ⠏
585 S => [(2, 0), (0, 1), (1, 1), (2, 1)], // ⠒⠆
586 W => [(0, 0), (1, 0), (1, 1), (1, 2)], // ⠼
587 },
588 }
589 }
590
591 /// Returns the convened-on standard tile id corresponding to the given tetromino.
592 pub const fn tiletypeid(&self) -> TileTypeID {
593 use Tetromino::*;
594 let u8 = match self {
595 O => 1,
596 I => 2,
597 S => 3,
598 Z => 4,
599 T => 5,
600 L => 6,
601 J => 7,
602 };
603 // SAFETY: Ye, `u8 > 0`;
604 unsafe { NonZeroU8::new_unchecked(u8) }
605 }
606}
607
608impl Orientation {
609 /// All `Orientation` enum variants in order.
610 ///
611 /// Note that `Orientation::VARIANTS[o as usize] == o` always holds.
612 pub const VARIANTS: [Self; 4] = {
613 use Orientation::*;
614 [N, E, S, W]
615 };
616
617 /// Find a new direction by turning right some number of times.
618 ///
619 /// This accepts `i32` to allow for left rotation.
620 pub const fn reorient_right(&self, right_turns: i8) -> Self {
621 Orientation::VARIANTS[((*self as i8 + right_turns) as usize).rem_euclid(4)]
622 }
623}
624
625impl Piece {
626 /// Returns the coordinates and tile types for he piece on the board.
627 pub fn tiles(&self) -> [(Coord, TileTypeID); 4] {
628 let Self {
629 tetromino,
630 orientation,
631 position: (x, y),
632 } = self;
633 let tile_type_id = tetromino.tiletypeid();
634 tetromino
635 .minos(*orientation)
636 .map(|(dx, dy)| ((x + dx, y + dy), tile_type_id))
637 }
638
639 /// Checks whether the piece fits at its current location onto the board.
640 pub fn fits_onto(&self, board: &Board) -> bool {
641 self.tiles().iter().all(|&((x, y), _)| {
642 0 <= x
643 && (x as usize) < Game::WIDTH
644 && 0 <= y
645 && (y as usize) < Game::HEIGHT
646 && board[y as usize][x as usize].is_none()
647 })
648 }
649
650 /// Checks whether the piece fits a given offset from its current location onto the board.
651 pub fn offset_on(&self, board: &Board, offset: CoordOffset) -> Result<Piece, Piece> {
652 let offset_piece = Piece {
653 tetromino: self.tetromino,
654 orientation: self.orientation,
655 position: add(self.position, offset),
656 };
657
658 if offset_piece.fits_onto(board) {
659 Ok(offset_piece)
660 } else {
661 Err(offset_piece)
662 }
663 }
664
665 /// Checks whether the piece fits a given offset from its current location onto the board, with
666 /// its rotation changed by some number of right turns.
667 pub fn reoriented_offset_on(
668 &self,
669 board: &Board,
670 right_turns: i8,
671 offset: CoordOffset,
672 ) -> Result<Piece, Piece> {
673 let reoriented_offset_piece = Piece {
674 tetromino: self.tetromino,
675 orientation: self.orientation.reorient_right(right_turns),
676 position: add(self.position, offset),
677 };
678
679 if reoriented_offset_piece.fits_onto(board) {
680 Ok(reoriented_offset_piece)
681 } else {
682 Err(reoriented_offset_piece)
683 }
684 }
685
686 /// Check whether piece could fall one unit down or not.
687 pub fn is_airborne(&self, board: &Board) -> bool {
688 self.offset_on(board, (0, -1)).is_ok()
689 }
690
691 /// Given an iterator over some offsets, checks whether the rotated piece fits at any offset
692 /// location onto the board.
693 pub fn find_reoriented_offset_on(
694 &self,
695 board: &Board,
696 right_turns: i8,
697 offsets: impl IntoIterator<Item = CoordOffset>,
698 ) -> Option<Piece> {
699 let original_pos = self.position;
700
701 let mut updated_piece = *self;
702 updated_piece.orientation = updated_piece.orientation.reorient_right(right_turns);
703 for offset in offsets {
704 updated_piece.position = add(original_pos, offset);
705 if updated_piece.fits_onto(board) {
706 return Some(updated_piece);
707 }
708 }
709
710 None
711 }
712
713 /// Returns the position the piece would hit if it kept moving at `offset` steps.
714 /// For offset `(0,0)` this function return immediately.
715 pub fn teleported(&self, board: &Board, offset: CoordOffset) -> Piece {
716 let mut updated_piece = *self;
717
718 if offset != (0, 0) {
719 // Move piece as far as possible.
720 while let Ok(offset_updated_piece) = updated_piece.offset_on(board, offset) {
721 if offset_updated_piece == updated_piece {
722 break;
723 }
724 updated_piece = offset_updated_piece;
725 }
726 }
727
728 updated_piece
729 }
730}
731
732impl std::fmt::Display for GameEndCause {
733 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
734 let s = match self {
735 GameEndCause::LockOut { .. } => "Lock out",
736 GameEndCause::BlockOut { .. } => "Block out",
737 GameEndCause::TopOut { .. } => "Top out",
738 GameEndCause::Limit(stat) => match stat {
739 Stat::TimeElapsed(_) => "Time limit reached",
740 Stat::PiecesLocked(_) => "Piece limit reached",
741 Stat::LinesCleared(_) => "Line limit reached",
742 Stat::PointsScored(_) => "Score limit reached",
743 },
744 GameEndCause::Forfeit { .. } => "Forfeited",
745 GameEndCause::Custom(text) => text,
746 };
747 write!(f, "{s}")
748 }
749}
750
751impl DelayParameters {
752 /// The duration at which the delay starts.
753 pub fn base_delay(&self) -> ExtDuration {
754 self.base_delay
755 }
756
757 /// The base factor that gets exponentiated by number of line clears;
758 /// `factor ^ lineclears ...`.
759 ///
760 /// Should be in the range `0.0 ≤ .. ≤ 1.0`, where
761 /// - `0.0` means 'zero-out initial delay at every line clear',
762 /// - `0.5` means 'halve initial delay for every line clear',
763 /// - `1.0` means 'keep initial delay at 100%'.
764 pub fn factor(&self) -> ExtNonNegF64 {
765 self.factor
766 }
767
768 /// The base subtrahend that gets multiplied by number of line clears;
769 /// `... - subtrahend * lineclears`.
770 ///
771 /// Should be in the range `0.0 ≤ .. ≤ 1.0`, where
772 /// - `0.0` means 'subtract 0% of initial delay for every line clear',
773 /// - `0.5` means 'subtract 50% of initial delay for every line clear',
774 /// - `1.0` means 'subtract 100% of initial delay for every line clear'.
775 pub fn subtrahend(&self) -> ExtDuration {
776 self.subtrahend
777 }
778
779 /// The duration below which delay cannot decrease.
780 pub fn lowerbound(&self) -> ExtDuration {
781 self.lowerbound
782 }
783
784 /// Delay equation which decreases/decays exponentially in number of linescleared.
785 pub fn new(
786 base_delay: ExtDuration,
787 lowerbound: ExtDuration,
788 factor: ExtNonNegF64,
789 subtrahend: ExtDuration,
790 ) -> Option<Self> {
791 Self::constant(Default::default())
792 .with_bounds(base_delay, lowerbound)?
793 .with_coefficients(factor, subtrahend)
794 }
795
796 /// Create a modified delay parameters where only the bounds are changed.
797 pub fn with_bounds(&self, base_delay: ExtDuration, lowerbound: ExtDuration) -> Option<Self> {
798 let correct_bounds = lowerbound <= base_delay;
799 correct_bounds.then_some(Self {
800 base_delay,
801 lowerbound,
802 ..*self
803 })
804 }
805
806 /// Create a modified delay parameters where only the coefficients are changed.
807 pub fn with_coefficients(&self, factor: ExtNonNegF64, subtrahend: ExtDuration) -> Option<Self> {
808 let correct_coefficients = factor <= 1.into();
809 correct_coefficients.then_some(Self {
810 factor,
811 subtrahend,
812 ..*self
813 })
814 }
815
816 /// Delay equation which does not change at all with number of linescleared.
817 pub fn constant(delay: ExtDuration) -> Self {
818 Self {
819 base_delay: delay,
820 factor: 1.into(),
821 subtrahend: ExtDuration::ZERO,
822 lowerbound: delay,
823 }
824 }
825
826 /// Whether the delay curve is invariant to number of lineclears.
827 pub fn is_constant(&self) -> bool {
828 self.factor == 1.into() && self.subtrahend.is_zero()
829 }
830
831 /// Delay equation which implements guideline-like fall delays:
832 /// * 0.0 lineclears ~> 20s to fall 20 units (1s/unit).
833 /// * 28.8_ lineclears ~> 10s to fall 20 units.
834 /// * 94.4_ lineclears ~> 2s to fall 20 units.
835 /// * 120.9_ lineclears ~> 1s to fall 20 units.
836 /// * 156.8_ lineclears ~> 1/3s to fall 20 units (NES max; 1 unit/frame).
837 /// * 196.1_ lineclears ~> 1/60s to fall 20 units (1frame/20units).
838 /// * 199.4_ lineclears ~> 0s to fall (instant gravity).
839 pub fn standard_fall() -> Self {
840 Self {
841 base_delay: Duration::from_millis(1000).into(),
842 factor: ExtNonNegF64::new(0.9763).unwrap(),
843 subtrahend: Duration::from_secs_f64(0.000042).into(),
844 lowerbound: Duration::ZERO.into(),
845 }
846 }
847
848 /// Delay equation which implements guideline-like lock delays:
849 /// * 0 lineclears ~> 500ms lock delay.
850 /// * Decrease lock_delay by 10 ms every 10 lineclears (= 1 ms every lineclear).
851 /// * End at 100ms lock delay.
852 pub fn standard_lock() -> Self {
853 Self {
854 base_delay: Duration::from_millis(500).into(),
855 factor: 1.into(),
856 subtrahend: Duration::from_millis(1).into(),
857 lowerbound: Duration::from_millis(100).into(),
858 }
859 }
860
861 /// Calculates an actual delay value given a number of lineclears to determine progression.
862 pub fn calculate(&self, lineclears: u32) -> ExtDuration {
863 // Multiplicative factor computed from lineclears;
864 let raw_mul = self.factor.get().powf(f64::from(lineclears));
865 // Wrap it back in ExtNonNegF64.
866 // SAFETY: ∀e:int, ∀b:f64 ≤ 1, (b^e ≤ 1).
867 let mul = ExtNonNegF64::new(raw_mul).unwrap();
868
869 // Subtractive offset computed from lineclears.
870 let sub = self.subtrahend.mul_ennf64(lineclears.into());
871
872 // Calculate intended delay;
873 let raw_delay = self.base_delay.mul_ennf64(mul).saturating_sub(sub);
874 // Return delay capped by lower bound.
875 self.lowerbound.max(raw_delay)
876 }
877}
878
879impl GameLimits {
880 /// Create a fresh [`GameLimits`] without any limits.
881 pub fn new() -> Self {
882 Self::default()
883 }
884
885 /// Create a new [`GameLimits`] with a single [`Stat`] as the limit.
886 pub fn single(stat: Stat, is_win: bool) -> Self {
887 let mut new = Self::new();
888
889 match stat {
890 Stat::TimeElapsed(t) => new.time_elapsed = Some((t, is_win)),
891 Stat::PiecesLocked(p) => new.pieces_locked = Some((p, is_win)),
892 Stat::LinesCleared(l) => new.lines_cleared = Some((l, is_win)),
893 Stat::PointsScored(s) => new.points_scored = Some((s, is_win)),
894 };
895
896 new
897 }
898
899 /// Iterate over all limiting [`Stat`] contained in a [`GameLimits`] struct.
900 pub fn iter(&self) -> impl Iterator<Item = (Stat, bool)> {
901 [
902 self.time_elapsed
903 .map(|(t, is_win)| (Stat::TimeElapsed(t), is_win)),
904 self.pieces_locked
905 .map(|(p, is_win)| (Stat::PiecesLocked(p), is_win)),
906 self.lines_cleared
907 .map(|(l, is_win)| (Stat::LinesCleared(l), is_win)),
908 self.points_scored
909 .map(|(s, is_win)| (Stat::PointsScored(s), is_win)),
910 ]
911 .into_iter()
912 .flatten()
913 }
914}
915
916impl Button {
917 /// All `Button` enum variants.
918 ///
919 /// Note that `Button::VARIANTS[b as usize] == b` always holds.
920 pub const VARIANTS: [Self; 11] = {
921 use Button as B;
922 [
923 B::MoveLeft,
924 B::MoveRight,
925 B::RotateLeft,
926 B::RotateRight,
927 B::Rotate180,
928 B::DropSoft,
929 B::DropHard,
930 B::TeleDown,
931 B::TeleLeft,
932 B::TeleRight,
933 B::HoldPiece,
934 ]
935 };
936}
937
938impl<T> ops::Index<Button> for [T; Button::VARIANTS.len()] {
939 type Output = T;
940
941 fn index(&self, idx: Button) -> &Self::Output {
942 &self[idx as usize]
943 }
944}
945
946impl<T> ops::IndexMut<Button> for [T; Button::VARIANTS.len()] {
947 fn index_mut(&mut self, idx: Button) -> &mut Self::Output {
948 &mut self[idx as usize]
949 }
950}
951
952impl Default for Configuration {
953 fn default() -> Self {
954 Self {
955 piece_preview_count: 3,
956 allow_initial_actions: true,
957 rotation_system: RotationSystem::default(),
958 spawn_delay: Duration::from_millis(50),
959 delayed_auto_shift: Duration::from_millis(167),
960 auto_repeat_rate: Duration::from_millis(33),
961 fall_delay_params: DelayParameters::constant(Duration::from_millis(1000).into()),
962 soft_drop_factor: ExtNonNegF64::new(15.0).unwrap(),
963 lock_delay_params: DelayParameters::constant(Duration::from_millis(500).into()),
964 allow_lenient_lock_reset: false,
965 ensure_move_delay_lt_lock_delay: false,
966 lock_reset_cap_factor: ExtNonNegF64::new(8.0).unwrap(),
967 line_clear_duration: Duration::from_millis(200),
968 update_delays_every_n_lineclears: 10,
969 game_limits: Default::default(),
970 notification_level: NotificationLevel::Standard,
971 }
972 }
973}
974
975impl Phase {
976 /// Read accessor to a `Phase`'s possible [`Piece`].
977 pub fn piece(&self) -> Option<&Piece> {
978 if let Phase::PieceInPlay { piece, .. } = self {
979 Some(piece)
980 } else {
981 None
982 }
983 }
984
985 /// Mutable accessor to a `Phase`'s possible [`Piece`].
986 pub fn piece_mut(&mut self) -> Option<&mut Piece> {
987 if let Phase::PieceInPlay { piece, .. } = self {
988 Some(piece)
989 } else {
990 None
991 }
992 }
993}
994
995impl Game {
996 /// The maximum height *any* piece tile could reach *before* `GameOver::LockOut` occurs.
997 pub const HEIGHT: usize = Self::LOCK_OUT_HEIGHT + 7;
998 /// The game field width.
999 pub const WIDTH: usize = 10;
1000 /// The height of the (conventionally) visible playing grid that can be played in.
1001 /// No tile piece may have all its tiles locked entirely at or above this index height (see [`GameEndCause::LockOut`]), although it may do so partially.
1002 pub const LOCK_OUT_HEIGHT: usize = 20;
1003
1004 /// Creates a blank new template representing a yet-to-be-started [`Game`] ready for configuration.
1005 pub fn builder() -> GameBuilder {
1006 GameBuilder::default()
1007 }
1008
1009 /// Read accessor for the game's initial values.
1010 pub const fn state_init(&self) -> &StateInitialization {
1011 &self.state_init
1012 }
1013
1014 /// Read accessor for the current game state.
1015 pub const fn state(&self) -> &State {
1016 &self.state
1017 }
1018
1019 /// Read accessor for the current game state.
1020 pub const fn phase(&self) -> &Phase {
1021 &self.phase
1022 }
1023
1024 /// Whether the game has ended, and whether it can continue to update.
1025 pub const fn has_ended(&self) -> bool {
1026 matches!(self.phase, Phase::GameEnd { .. })
1027 }
1028
1029 /// Retrieve the when the next *autonomous* in-game update is scheduled.
1030 /// I.e., compute the next time the game would change state assuming no button updates
1031 ///
1032 /// Returns `None` when game ended.
1033 ///
1034 /// # Modifiers
1035 /// Note that this only predicts what an unmodded game would do;
1036 /// [`Modifier`]s may arbitrarily change game state and change or prevent precise update predictions.
1037 pub fn peek_next_update_time(&self) -> Option<InGameTime> {
1038 // Find the next autonomous game update.
1039 let mut update_time = match self.phase {
1040 Phase::GameEnd { .. } => return None,
1041 Phase::LinesClearing {
1042 clear_finish_time, ..
1043 } => clear_finish_time,
1044 Phase::Spawning { spawn_time } => spawn_time,
1045 Phase::PieceInPlay {
1046 auto_move_scheduled,
1047 fall_or_lock_time,
1048 ..
1049 } => 'exp: {
1050 if let Some(move_time) = auto_move_scheduled {
1051 if move_time < fall_or_lock_time {
1052 break 'exp move_time;
1053 }
1054 }
1055 fall_or_lock_time
1056 }
1057 };
1058
1059 // Check against time-related end conditions.
1060 if let Some((time_limit, _)) = self.config.game_limits.time_elapsed {
1061 if time_limit < update_time {
1062 update_time = time_limit;
1063 }
1064 }
1065
1066 Some(update_time)
1067 }
1068
1069 /// Check whether a certain stat value has been met or exceeded.
1070 pub fn check_stat_met(&self, stat: Stat) -> bool {
1071 match stat {
1072 Stat::TimeElapsed(t) => t <= self.state.time,
1073 Stat::PiecesLocked(p) => p <= self.state.pieces_locked.iter().sum(),
1074 Stat::LinesCleared(l) => l <= self.state.lineclears,
1075 Stat::PointsScored(s) => s <= self.state.points,
1076 }
1077 }
1078
1079 /// Try to create a cloned instance of the game.
1080 pub fn try_clone(&self) -> Result<Self, String> {
1081 let mut modifiers = Vec::new();
1082 for modifier in self.modifiers.iter() {
1083 modifiers.push(modifier.try_clone()?);
1084 }
1085
1086 Ok(Self {
1087 config: self.config.clone(),
1088 state_init: self.state_init,
1089 state: self.state.clone(),
1090 phase: self.phase.clone(),
1091 modifiers,
1092 })
1093 }
1094}
1095
1096impl std::fmt::Display for UpdateGameError {
1097 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1098 let s = match self {
1099 UpdateGameError::TargetTimeInPast => {
1100 "attempt to update game to timestamp it already passed"
1101 }
1102 UpdateGameError::AlreadyEnded => "attempt to update game after it already ended",
1103 };
1104 write!(f, "{s}")
1105 }
1106}
1107
1108impl std::error::Error for UpdateGameError {}
1109
1110/// Adds an offset to a coordinate, failing if the result overflows
1111/// (negative or positive).
1112pub fn add((x, y): Coord, (dx, dy): CoordOffset) -> Coord {
1113 (x + dx, y + dy)
1114}
1115
1116/*#[cfg(test)]
1117mod tests {
1118 use super::*;
1119
1120 #[test]
1121 fn it_works() {
1122 let res = add((1,2),(3,4));
1123 assert_eq!(res, (4,6));
1124 }
1125}*/