├── .gitignore
├── Cargo.toml
├── Gallery
├── combo
│ ├── combo_12-lookahead-graph.svg
│ ├── combo_4-lookahead-graph.svg
│ ├── combot-2024-09-07_18-09-31_L1_bag.png
│ ├── combot-2024-09-07_18-09-31_L1_uniform.png
│ ├── combot-2024-09-08_19-01-11_L0_recency.svg
│ ├── combot-2024-09-08_19-02-47_L4_recency.svg
│ └── harddrop.com_4-Wide-Combo-Setups.png
├── graphics
│ ├── ASCII-Fullcolor.png
│ ├── ASCII-Monochrome.png
│ ├── Electronika60-Monochrome.png
│ ├── Unicode-Color16.png
│ ├── Unicode-Experimental.png
│ ├── Unicode-Fullcolor.png
│ └── Unicode-Monochrome.png
├── rotation
│ ├── ocular-rotation-system+_16px.png
│ ├── ocular-rotation-system_16px.png
│ └── super-rotation-system_16px.png
├── sample-ascii.gif
├── sample-electronika60.png
├── sample-unicode.gif
├── sample-unicode.png
├── tetrs_logo.png
└── tui-menu-graph.svg
├── LICENSE
├── README.md
├── tetrs_engine
├── Cargo.toml
└── src
│ ├── lib.rs
│ ├── piece_generation.rs
│ └── piece_rotation.rs
└── tetrs_tui
├── Cargo.toml
└── src
├── game_input_handlers
├── combo_bot.rs
├── crossterm.rs
└── mod.rs
├── game_mods
├── cheese_mode.rs
├── combo_mode.rs
├── descent_mode.rs
├── mod.rs
├── puzzle_mode.rs
└── utils.rs
├── game_renderers
├── cached_renderer.rs
├── debug_renderer.rs
└── mod.rs
├── main.rs
└── terminal_app.rs
/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | debug/
4 | target/
5 |
6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
8 | Cargo.lock
9 |
10 | # These are backup files generated by rustfmt
11 | **/*.rs.bk
12 |
13 | # MSVC Windows builds of rustc generate these, which store debugging information
14 | *.pdb
15 |
16 |
17 | # Added by cargo
18 |
19 | /target
20 |
21 | # Custom files possibly generated by tetrs_tui
22 |
23 | tetrs_tui_savefile.json
24 | combot_graphviz_log.txt
25 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [workspace]
2 |
3 | members = [
4 | "tetrs_tui",
5 | "tetrs_engine",
6 | ]
7 | resolver = "2"
8 |
--------------------------------------------------------------------------------
/Gallery/combo/combo_4-lookahead-graph.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Gallery/combo/combot-2024-09-07_18-09-31_L1_bag.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/combo/combot-2024-09-07_18-09-31_L1_bag.png
--------------------------------------------------------------------------------
/Gallery/combo/combot-2024-09-07_18-09-31_L1_uniform.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/combo/combot-2024-09-07_18-09-31_L1_uniform.png
--------------------------------------------------------------------------------
/Gallery/combo/combot-2024-09-08_19-01-11_L0_recency.svg:
--------------------------------------------------------------------------------
1 |
254 |
--------------------------------------------------------------------------------
/Gallery/combo/harddrop.com_4-Wide-Combo-Setups.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/combo/harddrop.com_4-Wide-Combo-Setups.png
--------------------------------------------------------------------------------
/Gallery/graphics/ASCII-Fullcolor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/graphics/ASCII-Fullcolor.png
--------------------------------------------------------------------------------
/Gallery/graphics/ASCII-Monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/graphics/ASCII-Monochrome.png
--------------------------------------------------------------------------------
/Gallery/graphics/Electronika60-Monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/graphics/Electronika60-Monochrome.png
--------------------------------------------------------------------------------
/Gallery/graphics/Unicode-Color16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/graphics/Unicode-Color16.png
--------------------------------------------------------------------------------
/Gallery/graphics/Unicode-Experimental.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/graphics/Unicode-Experimental.png
--------------------------------------------------------------------------------
/Gallery/graphics/Unicode-Fullcolor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/graphics/Unicode-Fullcolor.png
--------------------------------------------------------------------------------
/Gallery/graphics/Unicode-Monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/graphics/Unicode-Monochrome.png
--------------------------------------------------------------------------------
/Gallery/rotation/ocular-rotation-system+_16px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/rotation/ocular-rotation-system+_16px.png
--------------------------------------------------------------------------------
/Gallery/rotation/ocular-rotation-system_16px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/rotation/ocular-rotation-system_16px.png
--------------------------------------------------------------------------------
/Gallery/rotation/super-rotation-system_16px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/rotation/super-rotation-system_16px.png
--------------------------------------------------------------------------------
/Gallery/sample-ascii.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/sample-ascii.gif
--------------------------------------------------------------------------------
/Gallery/sample-electronika60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/sample-electronika60.png
--------------------------------------------------------------------------------
/Gallery/sample-unicode.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/sample-unicode.gif
--------------------------------------------------------------------------------
/Gallery/sample-unicode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/sample-unicode.png
--------------------------------------------------------------------------------
/Gallery/tetrs_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Strophox/tetrs/dba702f4082624b629c259be26c3b750a0b1fedb/Gallery/tetrs_logo.png
--------------------------------------------------------------------------------
/Gallery/tui-menu-graph.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Lucas W
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tetrs_engine/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "tetrs_engine"
3 | version = "0.1.0"
4 | authors = ["L. Werner"]
5 | description = "An implementation of a tetromino game engine, able to handle numerous modern mechanics."
6 | repository = "https://github.com/Strophox/tetrs"
7 | # documentation = "https://docs.rs/..."
8 | license = "MIT"
9 | # keywords = [...]
10 | readme = "README.md"
11 | edition = "2021"
12 | rust-version = "1.79.0"
13 | # categories = [...]
14 |
15 | [lib]
16 | name = "tetrs_engine"
17 | path = "src/lib.rs"
18 |
19 | [features]
20 | default = []
21 | serde = ["dep:serde"]
22 |
23 | [dependencies]
24 | rand = "0.8.5"
25 | serde = { version = "1.0", features = ["derive"], optional = true }
26 |
--------------------------------------------------------------------------------
/tetrs_engine/src/piece_generation.rs:
--------------------------------------------------------------------------------
1 | /*!
2 | This module handles random generation of [`Tetromino`]s.
3 | */
4 |
5 | use std::num::NonZeroU32;
6 |
7 | use rand::{
8 | self,
9 | distributions::{Distribution, WeightedIndex},
10 | //prelude::SliceRandom, // vec.shuffle(rng)...
11 | Rng,
12 | };
13 |
14 | use crate::Tetromino;
15 |
16 | /// Handles the information of which pieces to spawn during a game.
17 | ///
18 | /// To actually generate [`Tetromino`]s, the [`TetrominoSource::with_rng`] method needs to be used to yield a
19 | /// [`TetrominoIterator`] that implements [`Iterator`].
20 | #[derive(Debug)]
21 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22 | pub enum TetrominoSource {
23 | /// Uniformly random piece generator.
24 | Uniform,
25 | /// Standard 'bag' generator.
26 | ///
27 | /// Stock works by picking `n` copies of each [`Tetromino`] type, and then uniformly randomly
28 | /// handing them out until a lower stock threshold is reached and restocked with `n` copies.
29 | /// A multiplicity of `1` and restock threshold of `0` corresponds to the common 7-Bag.
30 | Stock {
31 | /// The number of each piece type left in the bag.
32 | pieces_left: [u32; 7],
33 | /// How many of each piece type to refill with.
34 | multiplicity: NonZeroU32,
35 | /// Bag threshold upon which to restock.
36 | restock_threshold: u32,
37 | },
38 | /// Recency/history-based piece generator.
39 | ///
40 | /// This generator keeps track of the last time each [`Tetromino`] type has been seen.
41 | /// It picks pieces by weighing them by this information as given by the `snap` field, which is
42 | /// used as the exponent of the last time the piece was seen. Note that this makes it impossible
43 | /// for a piece that was just played (index `0`) to be played again.
44 | Recency {
45 | /// The last time a piece was seen.
46 | ///
47 | /// `0` here denotes that it was the most recent piece generated.
48 | last_generated: [u32; 7],
49 | /// Determines how strongly it weighs pieces not generated in a while.
50 | ///
51 | ///
52 | snap: f64,
53 | },
54 | /// Experimental generator based off of how many times each [`Tetromino`] type has been seen
55 | /// *in total so far*.
56 | BalanceRelative {
57 | /// The relative number of times each piece type has been seen more/less than the others.
58 | ///
59 | /// Note that this is normalized, i.e. all entries are decremented simultaneously until
60 | /// at least one is `0`.
61 | relative_counts: [u32; 7],
62 | },
63 | /// Debug generator which repeats a certain pattern of [`Tetromino`]s forever.
64 | Cycle {
65 | /// The sequence of pieces that is repeated.
66 | pattern: Vec,
67 | /// Index to the piece that will be yielded next.
68 | index: usize,
69 | },
70 | }
71 |
72 | impl TetrominoSource {
73 | /// Initialize an instance of the [`TetrominoSource::Uniform`] variant.
74 | pub const fn uniform() -> Self {
75 | Self::Uniform
76 | }
77 |
78 | /// Initialize a 7-Bag instance of the [`TetrominoSource::Stock`] variant.
79 | pub const fn bag() -> Self {
80 | Self::Stock {
81 | pieces_left: [1; 7],
82 | multiplicity: NonZeroU32::MIN,
83 | restock_threshold: 0,
84 | }
85 | }
86 |
87 | /// Initialize a custom instance of the [`TetrominoSource::Stock`] variant.
88 | pub const fn stock(multiplicity: NonZeroU32, refill_threshold: u32) -> Option {
89 | if refill_threshold < multiplicity.get() * 7 {
90 | Some(Self::Stock {
91 | pieces_left: [multiplicity.get(); 7],
92 | multiplicity,
93 | restock_threshold: refill_threshold,
94 | })
95 | } else {
96 | None
97 | }
98 | }
99 |
100 | /// Initialize a default instance of the [`TetrominoSource::Recency`] variant.
101 | pub const fn recency() -> Self {
102 | Self::recency_with(2.5)
103 | }
104 |
105 | /// Initialize a custom instance of the [`TetrominoSource::Recency`] variant.
106 | pub const fn recency_with(snap: f64) -> Self {
107 | Self::Recency {
108 | last_generated: [1; 7],
109 | snap,
110 | }
111 | }
112 |
113 | /// Initialize an instance of the [`TetrominoSource::BalanceRelative`] variant.
114 | pub const fn balance_relative() -> Self {
115 | Self::BalanceRelative {
116 | relative_counts: [0; 7],
117 | }
118 | }
119 |
120 | /// Initialize a custom instance of the [`TetrominoSource::Cycle`] variant.
121 | pub const fn cycle(pattern: Vec) -> Self {
122 | Self::Cycle { pattern, index: 0 }
123 | }
124 |
125 | /// Method that allows `TetrominoSource` to be used as [`Iterator`].
126 | pub fn with_rng<'a, 'b, R: Rng>(&'a mut self, rng: &'b mut R) -> TetrominoIterator<'a, 'b, R> {
127 | TetrominoIterator {
128 | tetromino_generator: self,
129 | rng,
130 | }
131 | }
132 | }
133 |
134 | impl Clone for TetrominoSource {
135 | fn clone(&self) -> Self {
136 | match self {
137 | Self::Uniform => Self::uniform(),
138 | Self::Stock {
139 | pieces_left: _,
140 | multiplicity,
141 | restock_threshold,
142 | } => Self::stock(*multiplicity, *restock_threshold).unwrap(),
143 | Self::Recency {
144 | last_generated: _,
145 | snap,
146 | } => Self::recency_with(*snap),
147 | Self::BalanceRelative { relative_counts: _ } => Self::balance_relative(),
148 | Self::Cycle { pattern, index: _ } => Self::cycle(pattern.clone()),
149 | }
150 | }
151 | }
152 |
153 | /// Struct produced from [`TetrominoSource::with_rng`] which implements [`Iterator`].
154 | pub struct TetrominoIterator<'a, 'b, R: Rng> {
155 | /// Selected tetromino generator to use as information source.
156 | pub tetromino_generator: &'a mut TetrominoSource,
157 | /// Thread random number generator for raw soure of randomness.
158 | pub rng: &'b mut R,
159 | }
160 |
161 | impl<'a, 'b, R: Rng> Iterator for TetrominoIterator<'a, 'b, R> {
162 | type Item = Tetromino;
163 |
164 | fn next(&mut self) -> Option {
165 | match &mut self.tetromino_generator {
166 | TetrominoSource::Uniform => Some(Tetromino::VARIANTS[self.rng.gen_range(0..=6)]),
167 | TetrominoSource::Stock {
168 | pieces_left,
169 | multiplicity,
170 | restock_threshold: refill_threshold,
171 | } => {
172 | let weights = pieces_left.iter();
173 | // SAFETY: Struct invariant.
174 | let idx = WeightedIndex::new(weights).unwrap().sample(&mut self.rng);
175 | // Update individual tetromino number and maybe replenish bag (ensuring invariant).
176 | pieces_left[idx] -= 1;
177 | if pieces_left.iter().sum::() == *refill_threshold {
178 | for cnt in pieces_left {
179 | *cnt += multiplicity.get();
180 | }
181 | }
182 | // SAFETY: 0 <= idx <= 6.
183 | Some(Tetromino::VARIANTS[idx])
184 | }
185 | TetrominoSource::BalanceRelative { relative_counts } => {
186 | let weighing = |&x| 1.0 / f64::from(x).exp(); // Alternative weighing function: `1.0 / (f64::from(x) + 1.0);`
187 | let weights = relative_counts.iter().map(weighing);
188 | // SAFETY: `weights` will always be non-zero due to `weighing`.
189 | let idx = WeightedIndex::new(weights).unwrap().sample(&mut self.rng);
190 | // Update individual tetromino counter and maybe rebalance all relative counts
191 | relative_counts[idx] += 1;
192 | // SAFETY: `self.relative_counts` always has a minimum.
193 | let min = *relative_counts.iter().min().unwrap();
194 | if min > 0 {
195 | for x in relative_counts.iter_mut() {
196 | *x -= min;
197 | }
198 | }
199 | // SAFETY: 0 <= idx <= 6.
200 | Some(Tetromino::VARIANTS[idx])
201 | }
202 | TetrominoSource::Recency {
203 | last_generated,
204 | snap,
205 | } => {
206 | let weighing = |&x| f64::from(x).powf(*snap);
207 | let weights = last_generated.iter().map(weighing);
208 | // SAFETY: `weights` will always be non-zero due to struct invarian.
209 | let idx = WeightedIndex::new(weights).unwrap().sample(&mut self.rng);
210 | // Update all tetromino last_played values and maybe rebalance all relative counts..
211 | for x in last_generated.iter_mut() {
212 | *x += 1;
213 | }
214 | last_generated[idx] = 0;
215 | // SAFETY: 0 <= idx <= 6.
216 | Some(Tetromino::VARIANTS[idx])
217 | }
218 | TetrominoSource::Cycle { pattern, index } => {
219 | let tetromino = pattern[*index];
220 | *index += 1;
221 | if *index == pattern.len() {
222 | *index = 0;
223 | }
224 | Some(tetromino)
225 | }
226 | }
227 | }
228 | }
229 |
--------------------------------------------------------------------------------
/tetrs_engine/src/piece_rotation.rs:
--------------------------------------------------------------------------------
1 | /*!
2 | This module handles rotation of [`ActivePiece`]s.
3 | */
4 |
5 | use crate::{ActivePiece, Board, Orientation, Tetromino};
6 |
7 | /// Handles the logic of how to rotate a tetromino in play.
8 | #[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy, Hash, Debug)]
9 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10 | pub enum RotationSystem {
11 | /// The self-developed 'Ocular' rotation system.
12 | Ocular,
13 | /// The right-handed variant of the classic, kick-less rotation system used in NES Tetris.
14 | Classic,
15 | /// The Super Rotation System as used in the modern standard.
16 | Super,
17 | }
18 |
19 | impl RotationSystem {
20 | /// Tries to rotate a piece with the chosen `RotationSystem`.
21 | ///
22 | /// This will return `None` if the rotation is not possible, and `Some(p)` if the rotation
23 | /// succeeded with `p` as the new state of the piece.
24 | ///
25 | /// # Examples
26 | ///
27 | /// ```
28 | /// # use tetrs_engine::*;
29 | /// # let game = Game::new(GameMode::marathon());
30 | /// # let empty_board = &game.state().board;
31 | /// let i_piece = ActivePiece { shape: Tetromino::I, orientation: Orientation::N, position: (0, 0) };
32 | ///
33 | /// // Rotate left once.
34 | /// let i_rotated = RotationSystem::Ocular.rotate(&i_piece, empty_board, -1);
35 | ///
36 | /// let i_expected = ActivePiece { shape: Tetromino::I, orientation: Orientation::W, position: (1, 0) };
37 | /// assert_eq!(i_rotated, Some(i_expected));
38 | /// ```
39 | pub fn rotate(
40 | &self,
41 | piece: &ActivePiece,
42 | board: &Board,
43 | right_turns: i32,
44 | ) -> Option {
45 | match self {
46 | RotationSystem::Ocular => ocular_rotate(piece, board, right_turns),
47 | RotationSystem::Classic => classic_rotate(piece, board, right_turns),
48 | RotationSystem::Super => super_rotate(piece, board, right_turns),
49 | }
50 | }
51 | }
52 |
53 | #[rustfmt::skip]
54 | fn ocular_rotate(piece: &ActivePiece, board: &Board, right_turns: i32) -> Option {
55 | /*
56 | Symmetry notation : "OISZTLJ NESW ↺↻", and "-" means "mirror".
57 | [O N ↺ ] is given:
58 | O N ↻ = -O N ↺
59 | [I NE ↺ ] is given:
60 | I NE ↻ = -I NE ↺
61 | [S NE ↺↻] is given:
62 | Z NE ↺↻ = -S NE ↻↺
63 | [T NESW ↺ ] is given:
64 | T NS ↻ = -T NS ↺
65 | T EW ↻ = -T WE ↺
66 | [L NESW ↺↻] is given:
67 | J NS ↺↻ = -L NS ↻↺
68 | J EW ↺↻ = -L WE ↻↺
69 | */
70 | use Orientation::*;
71 | match right_turns.rem_euclid(4) {
72 | // No rotation.
73 | 0 => Some(*piece),
74 | // 180° rotation.
75 | 2 => {
76 | let mut mirror = false;
77 | let mut shape = piece.shape;
78 | let mut orientation = piece.orientation;
79 | let mirrored_orientation = match orientation {
80 | N => N, E => W, S => S, W => E,
81 | };
82 | let kick_table = 'lookup_kicks: loop {
83 | break match shape {
84 | Tetromino::O | Tetromino::I => &[( 0, 0)][..],
85 | Tetromino::S => match orientation {
86 | N | S => &[(-1,-1), ( 0, 0)][..],
87 | E | W => &[( 1,-1), ( 0, 0)][..],
88 | },
89 | Tetromino::Z => {
90 | shape = Tetromino::S;
91 | mirror = true;
92 | continue 'lookup_kicks
93 | },
94 | Tetromino::T => match orientation {
95 | N => &[( 0,-1), ( 0, 0)][..],
96 | E => &[(-1, 0), ( 0, 0), (-1,-1)][..],
97 | S => &[( 0, 1), ( 0, 0), ( 0,-1)][..],
98 | W => {
99 | orientation = mirrored_orientation;
100 | mirror = true;
101 | continue 'lookup_kicks
102 | },
103 | },
104 | Tetromino::L => match orientation {
105 | N => &[( 0,-1), ( 1,-1), (-1,-1), ( 0, 0), ( 1, 0)][..],
106 | E => &[(-1, 0), (-1,-1), ( 0, 0), ( 0,-1)][..],
107 | S => &[( 0, 1), ( 0, 0), (-1, 1), (-1, 0)][..],
108 | W => &[( 1, 0), ( 0, 0), ( 1,-1), ( 1, 1), ( 0, 1)][..],
109 | },
110 | Tetromino::J => {
111 | shape = Tetromino::L;
112 | orientation = mirrored_orientation;
113 | mirror = true;
114 | continue 'lookup_kicks
115 | }
116 | }
117 | };
118 | piece.first_fit(board, kick_table.iter().copied().map(|(x, y)| if mirror { (-x, y) } else { (x, y) }), right_turns)
119 | }
120 | // 90° right/left rotation.
121 | rot => {
122 | let mut mirror = None;
123 | let mut shape = piece.shape;
124 | let mut orientation = piece.orientation;
125 | let mut left = rot == 3;
126 | let mirrored_orientation = match orientation {
127 | N => N, E => W, S => S, W => E,
128 | };
129 | let kick_table = 'lookup_kicks: loop {
130 | match shape {
131 | Tetromino::O => {
132 | if !left {
133 | let mx = 0;
134 | (mirror, left) = (Some(mx), !left);
135 | continue 'lookup_kicks;
136 | } else {
137 | break &[(-1, 0), (-1,-1), (-1, 1), ( 0, 0)][..];
138 | }
139 | },
140 | Tetromino::I => {
141 | if !left {
142 | let mx = match orientation {
143 | N | S => 3, E | W => -3,
144 | };
145 | (mirror, left) = (Some(mx), !left);
146 | continue 'lookup_kicks;
147 | } else {
148 | break match orientation {
149 | N | S => &[( 1,-1), ( 1,-2), ( 1,-3), ( 0,-1), ( 0,-2), ( 0,-3), ( 1, 0), ( 0, 0), ( 2,-1), ( 2,-2)][..],
150 | E | W => &[(-2, 1), (-3, 1), (-2, 0), (-3, 0), (-1, 1), (-1, 0), ( 0, 1), ( 0, 0)][..],
151 | };
152 | }
153 | },
154 | Tetromino::S => break match orientation {
155 | N | S => if left { &[( 0, 0), ( 0,-1), ( 1, 0), (-1,-1)][..] }
156 | else { &[( 1, 0), ( 1,-1), ( 1, 1), ( 0, 0), ( 0,-1)][..] },
157 | E | W => if left { &[(-1, 0), ( 0, 0), (-1,-1), (-1, 1), ( 0, 1)][..] }
158 | else { &[( 0, 0), (-1, 0), ( 0,-1), ( 1, 0), ( 0, 1), (-1, 1)][..] },
159 | },
160 | Tetromino::Z => {
161 | let mx = match orientation {
162 | N | S => 1, E | W => -1,
163 | };
164 | (mirror, shape, left) = (Some(mx), Tetromino::S, !left);
165 | continue 'lookup_kicks;
166 | },
167 | Tetromino::T => {
168 | if !left {
169 | let mx = match orientation {
170 | N | S => 1, E | W => -1,
171 | };
172 | (mirror, orientation, left) = (Some(mx), mirrored_orientation, !left);
173 | continue 'lookup_kicks;
174 | } else {
175 | break match orientation {
176 | N => &[( 0,-1), ( 0, 0), (-1,-1), ( 1,-1), (-1,-2), ( 1, 0)][..],
177 | E => &[(-1, 1), (-1, 0), ( 0, 1), ( 0, 0), (-1,-1), (-1, 2)][..],
178 | S => &[( 1, 0), ( 0, 0), ( 1,-1), ( 0,-1), ( 1,-2), ( 2, 0)][..],
179 | W => &[( 0, 0), (-1, 0), ( 0,-1), (-1,-1), ( 1,-1), ( 0, 1), (-1, 1)][..],
180 | };
181 | }
182 | },
183 | Tetromino::L => break match orientation {
184 | N => if left { &[( 0,-1), ( 1,-1), ( 0,-2), ( 1,-2), ( 0, 0), ( 1, 0)][..] }
185 | else { &[( 1,-1), ( 1, 0), ( 1,-1), ( 2, 0), ( 0,-1), ( 0, 0)][..] },
186 | E => if left { &[(-1, 1), (-1, 0), (-2, 1), (-2, 0), ( 0, 0), ( 0, 1)][..] }
187 | else { &[(-1, 0), ( 0, 0), ( 0,-1), (-1,-1), ( 0, 1), (-1, 1)][..] },
188 | S => if left { &[( 1, 0), ( 0, 0), ( 1,-1), ( 0,-1), ( 0, 1), ( 1, 1)][..] }
189 | else { &[( 0, 0), ( 0,-1), ( 1,-1), (-1,-1), ( 1, 0), (-1, 0), ( 0, 1)][..] },
190 | W => if left { &[( 0, 0), (-1, 0), ( 0, 1), ( 1, 0), (-1, 1), ( 1, 1), ( 0,-1), (-1,-1)][..] }
191 | else { &[( 0, 1), (-1, 1), ( 0, 0), (-1, 0), ( 0, 2), (-1, 2)][..] },
192 | },
193 | Tetromino::J => {
194 | let mx = match orientation {
195 | N | S => 1, E | W => -1,
196 | };
197 | (mirror, shape, orientation, left) = (Some(mx), Tetromino::L, mirrored_orientation, !left);
198 | continue 'lookup_kicks;
199 | }
200 | }
201 | };
202 | let kicks = kick_table.iter().copied().map(|(x, y)| if let Some(mx) = mirror { (mx - x, y) } else { (x, y) });
203 | piece.first_fit(board, kicks, right_turns)
204 | },
205 | }
206 | }
207 |
208 | fn super_rotate(piece: &ActivePiece, board: &Board, right_turns: i32) -> Option {
209 | let left = match right_turns.rem_euclid(4) {
210 | // No rotation occurred.
211 | 0 => return Some(*piece),
212 | // One right rotation.
213 | 1 => false,
214 | // Some 180 rotation I came up with.
215 | 2 => {
216 | #[rustfmt::skip]
217 | let kick_table = match piece.shape {
218 | Tetromino::O | Tetromino::I | Tetromino::S | Tetromino::Z => &[(0, 0)][..],
219 | Tetromino::T | Tetromino::L | Tetromino::J => match piece.orientation {
220 | N => &[( 0,-1), ( 0, 0)][..],
221 | E => &[(-1, 0), ( 0, 0)][..],
222 | S => &[( 0, 1), ( 0, 0)][..],
223 | W => &[( 1, 0), ( 0, 0)][..],
224 | },
225 | };
226 | return piece.first_fit(board, kick_table.iter().copied(), 2);
227 | }
228 | // One left rotation.
229 | 3 => true,
230 | _ => unreachable!(),
231 | };
232 | use Orientation::*;
233 | #[rustfmt::skip]
234 | let kick_table = match piece.shape {
235 | Tetromino::O => &[(0, 0)][..], // ⠶
236 | Tetromino::I => match piece.orientation {
237 | N => if left { &[( 1,-2), ( 0,-2), ( 3,-2), ( 0, 0), ( 3,-3)][..] }
238 | else { &[( 2,-2), ( 0,-2), ( 3,-2), ( 0,-3), ( 3, 0)][..] },
239 | E => if left { &[(-2, 2), ( 0, 2), (-3, 2), ( 0, 3), (-3, 0)][..] }
240 | else { &[( 2,-1), (-3, 1), ( 0, 1), (-3, 3), ( 0, 0)][..] },
241 | S => if left { &[( 2,-1), ( 3,-1), ( 0,-1), ( 3,-3), ( 0, 0)][..] }
242 | else { &[( 1,-1), ( 3,-1), ( 0,-1), ( 3, 0), ( 0,-3)][..] },
243 | W => if left { &[(-1, 1), (-3, 1), ( 0, 1), (-3, 0), ( 0, 3)][..] }
244 | else { &[(-1, 2), ( 0, 2), (-3, 2), ( 0, 0), (-3, 3)][..] },
245 | },
246 | Tetromino::S | Tetromino::Z | Tetromino::T | Tetromino::L | Tetromino::J => match piece.orientation {
247 | N => if left { &[( 0,-1), ( 1,-1), ( 1, 0), ( 0,-3), ( 1,-3)][..] }
248 | else { &[( 1,-1), ( 0,-1), ( 0, 0), ( 1,-3), ( 0,-3)][..] },
249 | E => if left { &[(-1, 1), ( 0, 1), ( 0, 0), (-1, 3), ( 0, 3)][..] }
250 | else { &[(-1, 0), ( 0, 0), ( 0,-1), (-1, 2), ( 0, 2)][..] },
251 | S => if left { &[( 1, 0), ( 0, 0), (-1, 1), ( 1,-2), ( 0,-2)][..] }
252 | else { &[( 0, 0), ( 1, 0), ( 1, 1), ( 0,-2), ( 1,-2)][..] },
253 | W => if left { &[( 0, 0), (-1, 0), (-1,-1), ( 0, 2), (-1, 2)][..] }
254 | else { &[( 0, 1), (-1, 1), (-1, 0), ( 0, 3), (-1, 3)][..] },
255 | },
256 | };
257 | piece.first_fit(board, kick_table.iter().copied(), right_turns)
258 | }
259 |
260 | fn classic_rotate(piece: &ActivePiece, board: &Board, right_turns: i32) -> Option {
261 | let left_rotation = match right_turns.rem_euclid(4) {
262 | // No rotation occurred.
263 | 0 => return Some(*piece),
264 | // One right rotation.
265 | 1 => false,
266 | // Classic didn't define 180 rotation, just check if the "default" 180 rotation fits.
267 | 2 => {
268 | return piece.fits_at_rotated(board, (0, 0), 2);
269 | }
270 | // One left rotation.
271 | 3 => true,
272 | _ => unreachable!(),
273 | };
274 | use Orientation::*;
275 | #[rustfmt::skip]
276 | let kick = match piece.shape {
277 | Tetromino::O => (0, 0), // ⠶
278 | Tetromino::I => match piece.orientation {
279 | N | S => (2, -1), // ⠤⠤ -> ⡇
280 | E | W => (-2, 1), // ⡇ -> ⠤⠤
281 | },
282 | Tetromino::S | Tetromino::Z => match piece.orientation {
283 | N | S => (1, 0), // ⠴⠂ -> ⠳ // ⠲⠄ -> ⠞
284 | E | W => (-1, 0), // ⠳ -> ⠴⠂ // ⠞ -> ⠲⠄
285 | },
286 | Tetromino::T | Tetromino::L | Tetromino::J => match piece.orientation {
287 | N => if left_rotation { ( 0,-1) } else { ( 1,-1) }, // ⠺ <- ⠴⠄ -> ⠗ // ⠹ <- ⠤⠆ -> ⠧ // ⠼ <- ⠦⠄ -> ⠏
288 | E => if left_rotation { (-1, 1) } else { (-1, 0) }, // ⠴⠄ <- ⠗ -> ⠲⠂ // ⠤⠆ <- ⠧ -> ⠖⠂ // ⠦⠄ <- ⠏ -> ⠒⠆
289 | S => if left_rotation { ( 1, 0) } else { ( 0, 0) }, // ⠗ <- ⠲⠂ -> ⠺ // ⠧ <- ⠖⠂ -> ⠹ // ⠏ <- ⠒⠆ -> ⠼
290 | W => if left_rotation { ( 0, 0) } else { ( 0, 1) }, // ⠲⠂ <- ⠺ -> ⠴⠄ // ⠖⠂ <- ⠹ -> ⠤⠆ // ⠒⠆ <- ⠼ -> ⠦⠄
291 | },
292 | };
293 | piece.fits_at_rotated(board, kick, right_turns)
294 | }
295 |
--------------------------------------------------------------------------------
/tetrs_tui/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "tetrs_tui"
3 | version = "0.2.5"
4 | edition = "2021"
5 |
6 | [dependencies]
7 | chrono = "0.4.38"
8 | clap = { version = "4.5.9", features = ["derive"] }
9 | crossterm = { version = "0.27.0", features = ["serde"] }
10 | rand = "0.8.5"
11 | serde = { version = "1.0.204", features = ["derive"] }
12 | serde_json = "1.0.120"
13 | serde_with = { version = "3.9.0", features = ["json"] }
14 | tetrs_engine = { path = "../tetrs_engine", features = ["serde"] }
15 |
16 | [features]
17 | graphviz = []
18 |
--------------------------------------------------------------------------------
/tetrs_tui/src/game_input_handlers/combo_bot.rs:
--------------------------------------------------------------------------------
1 | #![allow(clippy::just_underscores_and_digits)]
2 |
3 | use std::{
4 | collections::{HashSet, VecDeque},
5 | fmt::Debug,
6 | fs::File,
7 | io::Write,
8 | sync::mpsc::{self, Receiver, RecvError, Sender},
9 | thread::{self, JoinHandle},
10 | time::{Duration, Instant},
11 | vec,
12 | };
13 |
14 | use tetrs_engine::{Button, Game, Tetromino};
15 |
16 | use super::InputSignal;
17 |
18 | type ButtonInstructions = &'static [Button];
19 | type Layout = (Pat, bool);
20 |
21 | const GRAPHVIZ: bool = cfg!(feature = "graphviz");
22 | const GRAPHVIZ_FILENAME: &str = "combot_graphviz_log.txt";
23 |
24 | #[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy, Hash, Debug)]
25 | enum Pat {
26 | /// `█▀ `
27 | _200,
28 | /// `█ ▄`
29 | _137,
30 | /// `█▄ `
31 | _140,
32 | /// `▄▄▄ `
33 | _14,
34 | /// `▄ `++`█ `
35 | _2184,
36 | /// `▄▄ ▄`
37 | _13,
38 | /// `▄▄ ▀`
39 | _28,
40 | /// `▀█ `
41 | _196,
42 | /// `█ ▄ `
43 | _138,
44 | /// `▄█ `
45 | _76,
46 | /// `▀▄▄ `
47 | _134,
48 | /// `▀▄ ▄`
49 | _133,
50 | /// `▄▀ ▄`
51 | _73,
52 | /// `▄▀▀ `
53 | _104,
54 | }
55 |
56 | #[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Copy, Hash, Debug)]
57 | pub struct ComboState {
58 | layout: Layout,
59 | active: Option,
60 | hold: Option<(Tetromino, bool)>,
61 | next_pieces: u128,
62 | depth: usize,
63 | }
64 |
65 | #[derive(Debug)]
66 | pub struct ComboBotHandler {
67 | _handle: JoinHandle<()>,
68 | }
69 |
70 | impl ComboBotHandler {
71 | pub fn new(
72 | button_sender: &Sender,
73 | action_idle_time: Duration,
74 | ) -> (Self, Sender) {
75 | let (state_sender, state_receiver) = mpsc::channel();
76 | let join_handle = Self::spawn(state_receiver, button_sender.clone(), action_idle_time);
77 | let combo_bot_handler = ComboBotHandler {
78 | _handle: join_handle,
79 | };
80 | (combo_bot_handler, state_sender)
81 | }
82 |
83 | pub fn encode(game: &Game) -> Result {
84 | let row0 = &game.state().board[0][3..=6];
85 | let row1 = &game.state().board[1][3..=6];
86 | let row2 = &game.state().board[2][3..=6];
87 | let pattern_bits = row2
88 | .iter()
89 | .chain(row1.iter())
90 | .chain(row0.iter())
91 | .fold(0, |bits, cell| bits << 1 | i32::from(cell.is_some()));
92 | let pattern = match pattern_bits {
93 | 200 | 49 => Pat::_200,
94 | 137 | 25 => Pat::_137,
95 | 140 | 19 => Pat::_140,
96 | 14 | 7 => Pat::_14,
97 | 2184 | 273 => Pat::_2184,
98 | 13 | 11 => Pat::_13,
99 | 28 | 131 => Pat::_28,
100 | 196 | 50 => Pat::_196,
101 | 138 | 21 => Pat::_138,
102 | 76 | 35 => Pat::_76,
103 | 134 | 22 => Pat::_134,
104 | 133 | 26 => Pat::_133,
105 | 73 | 41 => Pat::_73,
106 | 104 | 97 => Pat::_104,
107 | _ => return Err(format!("row0 = {row0:?}, row1 = {row1:?}, row2 = {row2:?}, pattern_bits = {pattern_bits:?}")),
108 | };
109 | let flipped = ![
110 | 200, 137, 140, 14, 2184, 13, 28, 196, 138, 76, 134, 133, 73, 104,
111 | ]
112 | .contains(&pattern_bits);
113 | const MAX_LOOKAHEAD: usize = 42;
114 | if game.state().next_pieces.len() > MAX_LOOKAHEAD {
115 | return Err(format!(
116 | "game.state().next_pieces.len()={} > MAX_LOOKAHEAD={}",
117 | game.state().next_pieces.len(),
118 | MAX_LOOKAHEAD
119 | ));
120 | }
121 | Ok(ComboState {
122 | layout: (pattern, flipped),
123 | active: Some(game.state().active_piece_data.unwrap().0.shape),
124 | hold: game.state().hold_piece,
125 | next_pieces: Self::encode_next_queue(
126 | game.state().next_pieces.iter().take(MAX_LOOKAHEAD),
127 | ),
128 | depth: 0,
129 | })
130 | }
131 |
132 | fn encode_next_queue<'a>(tetrominos: impl DoubleEndedIterator- ) -> u128 {
133 | use Tetromino::*;
134 | tetrominos.into_iter().rev().fold(0, |bits, tet| {
135 | bits << 3
136 | | (match tet {
137 | O => 0,
138 | I => 1,
139 | S => 2,
140 | Z => 3,
141 | T => 4,
142 | L => 5,
143 | J => 6,
144 | } + 1)
145 | })
146 | }
147 |
148 | fn spawn(
149 | state_receiver: Receiver,
150 | button_sender: Sender,
151 | idle_time: Duration,
152 | ) -> JoinHandle<()> {
153 | thread::spawn(move || {
154 | 'react_to_game: loop {
155 | match state_receiver.recv() {
156 | Ok(state_lvl0) => {
157 | /*TBD: Remove debug: let s=format!("[ main1 REVOYYY zeroth_state = {state_lvl0:?} ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
158 | let (states_lvl1, states_lvl1_buttons): (
159 | Vec,
160 | Vec,
161 | ) = neighbors(state_lvl0).into_iter().unzip();
162 | /*TBD: Remove debug: let s=format!("[ main2 states_lvl1 = {:?} = {states_lvl1:?} ]\n", states_lvl1.iter().map(|state| fmt_statenode(&(0, *state))).collect::>());let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
163 | // No more options to continue.
164 | let Some(branch_choice) =
165 | choose_branch(states_lvl1, GRAPHVIZ.then_some(state_lvl0))
166 | else {
167 | /*TBD: Remove debug: let s=format!("[ main3 uhhhhhh ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
168 | let _ = button_sender.send(InputSignal::Pause);
169 | break 'react_to_game;
170 | };
171 | for mut button in states_lvl1_buttons[branch_choice].iter().copied() {
172 | // Need to manually flip instructions if original position was a flipped one.
173 | if state_lvl0.layout.1 {
174 | button = match button {
175 | Button::MoveLeft => Button::MoveRight,
176 | Button::MoveRight => Button::MoveLeft,
177 | Button::RotateLeft => Button::RotateRight,
178 | Button::RotateRight => Button::RotateLeft,
179 | Button::RotateAround
180 | | Button::DropSoft
181 | | Button::DropHard
182 | | Button::DropSonic
183 | | Button::HoldPiece => button,
184 | };
185 | }
186 | let now = Instant::now();
187 | let _ = button_sender.send(InputSignal::ButtonInput(button, true, now));
188 | let _ =
189 | button_sender.send(InputSignal::ButtonInput(button, false, now));
190 | /*TBD: Remove debug: let s=format!("[ main4 SENT button = {button:?} ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
191 | thread::sleep(idle_time);
192 | }
193 | }
194 | // No more state updates will be received, stop thread.
195 | Err(RecvError) => break 'react_to_game,
196 | }
197 | }
198 | })
199 | }
200 | }
201 |
202 | fn choose_branch(
203 | states_lvl1: Vec,
204 | debug_state_lvl0: Option,
205 | ) -> Option {
206 | /*TBD: Remove debug: let s=format!("[ chbr1 examine states = {states_lvl1:?} ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
207 | if states_lvl1.is_empty() {
208 | /*TBD: Remove debug: let s=format!("[ chbr2 empty ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
209 | None
210 | // One option to continue, do not do further analysis.
211 | } else if states_lvl1.len() == 1 {
212 | /*TBD: Remove debug: let s=format!("[ chbr single ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
213 | Some(0)
214 | // Several options to evaluate, do graph algorithm.
215 | } else {
216 | /*TBD: Remove debug: let s=format!("[ chbr multianalyze ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
217 | let num_states = states_lvl1.len();
218 | let mut queue: VecDeque<(usize, ComboState)> =
219 | states_lvl1.into_iter().enumerate().collect();
220 | let mut graphviz_str = String::new();
221 | if let Some(state_lvl0) = debug_state_lvl0 {
222 | graphviz_str.push_str("strict digraph {\n");
223 | graphviz_str.push_str(&format!("\"{}\"\n", fmt_statenode(&(0, state_lvl0))));
224 | for statenode in queue.iter() {
225 | graphviz_str.push_str(&format!(
226 | "\"{}\" -> \"{}\"\n",
227 | fmt_statenode(&(0, state_lvl0)),
228 | fmt_statenode(statenode)
229 | ));
230 | }
231 | }
232 | let mut depth_best = queue.iter().map(|(_, state)| state.depth).max().unwrap();
233 | let mut states_best = queue
234 | .iter()
235 | .filter(|(_, state)| state.depth == depth_best)
236 | .copied()
237 | .collect::>();
238 | /*TBD: Remove debug: let s=format!("[ chbr before-while ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
239 | while let Some(statenode @ (branch, state)) = queue.pop_front() {
240 | let neighbors: Vec<_> = neighbors(state)
241 | .into_iter()
242 | .map(|(state, _)| (branch, state))
243 | .collect();
244 | if debug_state_lvl0.is_some() {
245 | for state in neighbors.iter() {
246 | graphviz_str.push_str(&format!(
247 | "\"{}\" -> \"{}\"\n",
248 | fmt_statenode(&statenode),
249 | fmt_statenode(state)
250 | ));
251 | }
252 | }
253 | for neighbor in neighbors.iter() {
254 | let depth = neighbor.1.depth;
255 | use std::cmp::Ordering::*;
256 | match depth_best.cmp(&depth) {
257 | Less => {
258 | depth_best = depth;
259 | states_best.clear();
260 | states_best.push(*neighbor);
261 | }
262 | Equal => {
263 | states_best.push(*neighbor);
264 | }
265 | Greater => {}
266 | }
267 | }
268 | queue.extend(neighbors);
269 | }
270 | /*TBD: Remove debug: let s=format!("[ chbr depth_best = {depth_best} ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
271 | if debug_state_lvl0.is_some() {
272 | graphviz_str.push_str("\n}");
273 |
274 | let _ = File::options()
275 | .create(true)
276 | .append(true)
277 | .open(GRAPHVIZ_FILENAME)
278 | .unwrap()
279 | .write(format!("graphviz: \"\"\"\n{graphviz_str}\n\"\"\"\n").as_bytes());
280 | }
281 | /*TBD: Remove debug: let s=format!("[ chbr states_best = {states_best:?} ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
282 | //states_lvlx.sort_by_key(|(_, ComboState { layout, .. })| layout.0);
283 | //let best = states_lvlx.first().unwrap().0;
284 | let mut sets = vec![HashSet::::new(); num_states];
285 | for (branch, state) in states_best {
286 | sets[branch].insert(state.layout);
287 | if let Some((held, true)) = state.hold {
288 | sets[branch].extend(
289 | reachable_with(state.layout, held)
290 | .iter()
291 | .map(|(layout, _)| layout),
292 | );
293 | }
294 | }
295 | // let best = (0..num_states).max_by_key(|branch| sets[*branch].len()).unwrap();
296 | let layout_heuristic = |(pat, _): &Layout| {
297 | use Pat::*;
298 | match pat {
299 | _200 => 8,
300 | _137 => 8,
301 | _140 => 7,
302 | _14 => 6,
303 | _2184 => 4,
304 | _13 => 6,
305 | _28 => 6,
306 | _196 => 4,
307 | _138 => 4,
308 | _76 => 3,
309 | _134 => 3,
310 | _133 => 3,
311 | _73 => 2,
312 | _104 => 2,
313 | }
314 | };
315 | let best = (0..num_states)
316 | .max_by_key(|branch| {
317 | let val = sets[*branch].iter().map(layout_heuristic).sum::();
318 | /*TBD: Remove debug: let s=format!("[ chbr branch = {branch}, val = {val}\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
319 | val
320 | })
321 | .unwrap();
322 | /*NOTE: Old, maybe one should benchmark this again, but seems worse.
323 | #[rustfmt::skip]
324 | let layout_heuristic = |(pat, flipped): &Layout| -> (u8, u32) {
325 | let flip = *flipped;
326 | use Pat::*;
327 | match pat {
328 | _200 => (if flip { 0b011_1111 } else { 0b101_1111 }, 8),
329 | _137 => (if flip { 0b111_0111 } else { 0b111_1011 }, 8),
330 | _140 => (if flip { 0b111_1011 } else { 0b111_0111 }, 7),
331 | _14 => (if flip { 0b111_0110 } else { 0b111_1010 }, 6),
332 | _2184 => (if flip { 0b111_0010 } else { 0b111_0010 }, 4),
333 | _13 => (if flip { 0b101_1010 } else { 0b011_0110 }, 6),
334 | _28 => (if flip { 0b111_1010 } else { 0b111_0110 }, 6),
335 | _196 => (if flip { 0b001_1011 } else { 0b001_0111 }, 4),
336 | _138 => (if flip { 0b110_0010 } else { 0b110_0010 }, 4),
337 | _76 => (if flip { 0b100_0011 } else { 0b010_0011 }, 3),
338 | _134 => (if flip { 0b110_0010 } else { 0b110_0010 }, 3),
339 | _133 => (if flip { 0b011_0010 } else { 0b101_0010 }, 3),
340 | _73 => (if flip { 0b000_0110 } else { 0b000_1010 }, 2),
341 | _104 => (if flip { 0b010_0010 } else { 0b100_0010 }, 2),
342 | }
343 | };
344 | let best = (0..num_states)
345 | .max_by_key(|branch| {
346 | let val = sets[*branch].iter().map(layout_heuristic).reduce(|(piecety0, cont0), (piecety1, cont1)| (piecety0 | piecety1, cont0 + cont1));
347 | /*TBD: Remove debug: let s=format!("[ chbr branch = {branch}, val = {val}\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
348 | val
349 | })
350 | .unwrap();
351 | */
352 | /*TBD: Remove debug: let s=format!("[ chbr best = {best:?} ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
353 | Some(best)
354 | }
355 | }
356 |
357 | fn neighbors(
358 | ComboState {
359 | depth,
360 | layout,
361 | active,
362 | hold,
363 | next_pieces,
364 | }: ComboState,
365 | ) -> Vec<(ComboState, ButtonInstructions)> {
366 | /*TBD: Remove debug: let s=format!("[ nbrs1 entered ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
367 | let mut neighbors = Vec::new();
368 | let Some(active) = active else {
369 | /*TBD: Remove debug: let s=format!("[ nbrs2 early-ret ]\n");let _=std::io::Write::write(&mut std::fs::OpenOptions::new().append(true).open("tetrs_tui_error_message_COMBO.txt").unwrap(), s.as_bytes());*/
370 | return neighbors;
371 | };
372 | let new_active = (next_pieces != 0)
373 | .then(|| Tetromino::VARIANTS[usize::try_from(next_pieces & 0b111).unwrap() - 1]);
374 | let new_next_pieces = next_pieces >> 3;
375 | // Add neighbors reachable with just holding / swapping with the active piece.
376 | if let Some((held, swap_allowed)) = hold {
377 | if swap_allowed {
378 | neighbors.push((
379 | ComboState {
380 | layout,
381 | active: Some(held),
382 | hold: Some((active, false)),
383 | next_pieces,
384 | depth,
385 | },
386 | &[Button::HoldPiece][..],
387 | ));
388 | }
389 | } else {
390 | neighbors.push((
391 | ComboState {
392 | layout,
393 | active: new_active,
394 | hold: Some((active, false)),
395 | next_pieces,
396 | depth,
397 | },
398 | &[Button::HoldPiece][..],
399 | ));
400 | }
401 | neighbors.extend(
402 | reachable_with(layout, active)
403 | .into_iter()
404 | .map(|(next_layout, buttons)| {
405 | (
406 | ComboState {
407 | layout: next_layout,
408 | active: new_active,
409 | hold: hold.map(|(held, _swap_allowed)| (held, true)),
410 | next_pieces: new_next_pieces,
411 | depth: depth + 1,
412 | },
413 | buttons,
414 | )
415 | }),
416 | );
417 | neighbors
418 | }
419 |
420 | #[rustfmt::skip]
421 | fn reachable_with((pattern, flip): Layout, mut shape: Tetromino) -> Vec<(Layout, ButtonInstructions)> {
422 | use Tetromino::*;
423 | if flip {
424 | shape = match shape {
425 | O => O,
426 | I => I,
427 | S => Z,
428 | Z => S,
429 | T => T,
430 | L => J,
431 | J => L,
432 | };
433 | }
434 | use Button::*;
435 | match pattern {
436 | // "█▀ "
437 | Pat::_200 => match shape {
438 | T => vec![((Pat::_137, !flip), &[RotateLeft, MoveRight, MoveRight, DropHard][..]),
439 | ((Pat::_14, flip), &[RotateLeft, MoveRight, MoveRight, DropSonic, RotateRight, DropSoft][..])],
440 | L => vec![((Pat::_13, flip), &[RotateLeft, MoveRight, MoveRight, DropSonic, RotateRight, DropSoft][..])],
441 | S => vec![((Pat::_14, flip), &[RotateRight, MoveRight, DropSonic, RotateRight, DropSoft][..]),
442 | ((Pat::_73, !flip), &[RotateRight, MoveRight, DropHard][..])],
443 | Z => vec![((Pat::_133, !flip), &[RotateRight, MoveRight, DropHard][..]),
444 | ((Pat::_104, flip), &[MoveRight, DropHard][..])],
445 | O => vec![((Pat::_13, !flip), &[MoveRight, MoveRight, DropHard][..])],
446 | I => vec![((Pat::_200, flip), &[DropHard][..])],
447 | _ => vec![],
448 | },
449 | // "█ ▄"
450 | Pat::_137 => match shape {
451 | T => vec![((Pat::_13, !flip), &[RotateRight, RotateRight, MoveRight, DropHard][..]),
452 | ((Pat::_73, !flip), &[MoveRight, DropHard][..])],
453 | L => vec![((Pat::_137, !flip), &[MoveRight, DropHard][..]),
454 | ((Pat::_13, flip), &[RotateRight, RotateRight, MoveRight, DropHard][..]),
455 | ((Pat::_76, flip), &[RotateRight, MoveRight, MoveLeft, DropHard][..])],
456 | J => vec![((Pat::_73, flip), &[MoveRight, DropHard][..])],
457 | S => vec![((Pat::_13, !flip), &[MoveRight, DropHard][..])],
458 | O => vec![((Pat::_14, flip), &[DropHard][..])],
459 | I => vec![((Pat::_137, flip), &[DropHard][..])],
460 | _ => vec![],
461 | },
462 | // "█▄ "
463 | Pat::_140 => match shape {
464 | T => vec![((Pat::_14, flip), &[RotateRight, RotateRight, MoveRight, DropHard][..])],
465 | L => vec![((Pat::_28, flip), &[MoveRight, DropHard][..])],
466 | J => vec![((Pat::_137, !flip), &[RotateLeft, MoveRight, MoveRight, DropHard][..]),
467 | ((Pat::_13, flip), &[RotateRight, RotateRight, MoveRight, DropHard][..]),
468 | ((Pat::_76, flip), &[MoveRight, DropHard][..])],
469 | Z => vec![((Pat::_14, flip), &[MoveRight, DropHard][..])],
470 | O => vec![((Pat::_13, !flip), &[MoveRight, DropHard][..])],
471 | I => vec![((Pat::_140, flip), &[DropHard][..])],
472 | _ => vec![],
473 | },
474 | // "▄▄▄ "
475 | Pat::_14 => match shape {
476 | T => vec![((Pat::_140, !flip), &[RotateLeft, MoveRight, MoveRight, DropHard][..])],
477 | L => vec![((Pat::_200, !flip), &[RotateLeft, MoveRight, MoveRight, DropHard][..])],
478 | J => vec![((Pat::_14, !flip), &[RotateRight, RotateRight, MoveRight, DropHard][..])],
479 | S => vec![((Pat::_76, !flip), &[RotateRight, MoveRight, DropHard][..])],
480 | I => vec![((Pat::_2184, !flip), &[RotateRight, MoveRight, DropHard][..]),
481 | ((Pat::_14, flip), &[DropHard][..])],
482 | _ => vec![],
483 | },
484 | // "▄ "++"█ "
485 | Pat::_2184 => match shape {
486 | T => vec![((Pat::_138, flip), &[MoveRight, DropHard][..])],
487 | L => vec![((Pat::_137, flip), &[MoveRight, DropHard][..]),
488 | ((Pat::_140, flip), &[RotateRight, RotateRight, MoveRight, DropHard][..])],
489 | J => vec![((Pat::_137, flip), &[RotateRight, RotateRight, MoveRight, DropHard][..]),
490 | ((Pat::_140, flip), &[MoveRight, DropHard][..])],
491 | I => vec![((Pat::_2184, flip), &[DropHard][..])],
492 | _ => vec![],
493 | },
494 | // "▄▄ ▄"
495 | Pat::_13 => match shape {
496 | T => vec![((Pat::_14, !flip), &[RotateRight, RotateRight, MoveRight, DropHard][..]),
497 | ((Pat::_76, !flip), &[RotateRight, MoveRight, DropHard][..])],
498 | J => vec![((Pat::_14, flip), &[RotateRight, RotateRight, MoveLeft/***/, DropHard][..]),
499 | ((Pat::_196, !flip), &[RotateRight, MoveRight, DropHard][..])],
500 | Z => vec![((Pat::_140, !flip), &[RotateRight, MoveRight, DropHard][..])],
501 | I => vec![((Pat::_13, flip), &[DropHard][..])],
502 | _ => vec![],
503 | },
504 | // "▄▄ ▀"
505 | Pat::_28 => match shape {
506 | T => vec![((Pat::_76, flip), &[MoveLeft/***/, DropHard][..])],
507 | L => vec![((Pat::_76, !flip), &[RotateLeft, MoveRight, MoveRight, MoveLeft, DropSonic, RotateAround, DropSoft][..])], // SPECIAL: 180°
508 | J => vec![((Pat::_140, flip), &[MoveLeft/***/, DropHard][..]),
509 | ((Pat::_14, flip), &[RotateRight, RotateRight, MoveLeft/***/, DropHard][..])],
510 | Z => vec![((Pat::_14, !flip), &[RotateRight, MoveRight, DropSonic, RotateLeft, DropSoft][..])],
511 | I => vec![((Pat::_28, flip), &[DropHard][..])],
512 | _ => vec![],
513 | },
514 | // "▀█ "
515 | Pat::_196 => match shape {
516 | T => vec![((Pat::_138, !flip), &[RotateLeft, MoveRight, MoveRight, DropHard][..])],
517 | Z => vec![((Pat::_134, !flip), &[RotateRight, MoveRight, DropHard][..])],
518 | O => vec![((Pat::_14, !flip), &[MoveRight, DropHard][..])],
519 | I => vec![((Pat::_196, flip), &[DropHard][..])],
520 | _ => vec![],
521 | },
522 | // "█ ▄ "
523 | Pat::_138 => match shape {
524 | L => vec![((Pat::_14, flip), &[RotateRight, RotateRight, MoveRight, DropHard][..]),
525 | ((Pat::_133, !flip), &[MoveRight, DropHard][..])],
526 | J => vec![((Pat::_13, !flip), &[RotateRight, RotateRight, MoveRight, DropHard][..])],
527 | I => vec![((Pat::_138, flip), &[DropHard][..])],
528 | _ => vec![],
529 | },
530 | // "▄█ "
531 | Pat::_76 => match shape {
532 | J => vec![((Pat::_138, !flip), &[RotateLeft, MoveRight, MoveRight, DropHard][..])],
533 | O => vec![((Pat::_14, !flip), &[MoveRight, DropHard][..])],
534 | I => vec![((Pat::_76, flip), &[DropHard][..])],
535 | _ => vec![],
536 | },
537 | // "▀▄▄ "
538 | Pat::_134 => match shape {
539 | L => vec![((Pat::_134, !flip), &[MoveRight, DropHard][..])],
540 | J => vec![((Pat::_14, !flip), &[RotateRight, RotateRight, MoveRight, DropHard][..])],
541 | I => vec![((Pat::_134, flip), &[DropHard][..])],
542 | _ => vec![],
543 | },
544 | // "▀▄ ▄"
545 | Pat::_133 => match shape {
546 | T => vec![((Pat::_14, !flip), &[RotateRight, RotateRight, MoveRight, DropHard][..])],
547 | L => vec![((Pat::_138, !flip), &[MoveRight, DropHard][..])],
548 | I => vec![((Pat::_133, flip), &[DropHard][..])],
549 | _ => vec![],
550 | },
551 | // "▄▀ ▄"
552 | Pat::_73 => match shape {
553 | S => vec![((Pat::_14, !flip), &[RotateRight, MoveRight, MoveLeft, DropSonic, RotateRight, DropSoft][..])],
554 | I => vec![((Pat::_73, flip), &[DropHard][..])],
555 | _ => vec![],
556 | },
557 | // "▄▀▀ "
558 | Pat::_104 => match shape {
559 | L => vec![((Pat::_14, !flip), &[RotateLeft, MoveRight, MoveRight, DropSonic, RotateRight, DropHard][..])],
560 | I => vec![((Pat::_104, flip), &[DropHard][..])],
561 | _ => vec![],
562 | },
563 | }
564 | }
565 |
566 | pub fn fmt_statenode(
567 | (
568 | id,
569 | ComboState {
570 | layout,
571 | active,
572 | hold,
573 | next_pieces,
574 | depth,
575 | },
576 | ): &(usize, ComboState),
577 | ) -> String {
578 | let layout = match layout {
579 | (Pat::_200, false) => "▛ ",
580 | (Pat::_200, true) => " ▜",
581 | (Pat::_137, false) => "▌▗",
582 | (Pat::_137, true) => "▖▐",
583 | (Pat::_140, false) => "▙ ",
584 | (Pat::_140, true) => " ▟",
585 | (Pat::_14, false) => "▄▖",
586 | (Pat::_14, true) => "▗▄",
587 | (Pat::_2184, false) => "▌ ",
588 | (Pat::_2184, true) => " ▐",
589 | (Pat::_13, false) => "▄▗",
590 | (Pat::_13, true) => "▖▄",
591 | (Pat::_28, false) => "▄▝",
592 | (Pat::_28, true) => "▘▄",
593 | (Pat::_196, false) => "▜ ",
594 | (Pat::_196, true) => " ▛",
595 | (Pat::_138, false) => "▌▖",
596 | (Pat::_138, true) => "▗▐",
597 | (Pat::_76, false) => "▟ ",
598 | (Pat::_76, true) => " ▙",
599 | (Pat::_134, false) => "▚▖",
600 | (Pat::_134, true) => "▗▞",
601 | (Pat::_133, false) => "▚▗",
602 | (Pat::_133, true) => "▖▞",
603 | (Pat::_73, false) => "▞▗",
604 | (Pat::_73, true) => "▖▚",
605 | (Pat::_104, false) => "▞▘",
606 | (Pat::_104, true) => "▝▚",
607 | };
608 | let mut next_pieces_str = String::new();
609 | let mut next_pieces = *next_pieces;
610 | while next_pieces != 0 {
611 | next_pieces_str.push_str(&format!(
612 | "{:?}",
613 | Tetromino::VARIANTS[usize::try_from(next_pieces & 0b111).unwrap() - 1]
614 | ));
615 | next_pieces >>= 3;
616 | }
617 | let active_str = if let Some(tet) = active {
618 | format!("{tet:?}")
619 | } else {
620 | "".to_string()
621 | };
622 | let hold_str = if let Some((tet, swap_allowed)) = hold {
623 | format!("({}{tet:?})", if *swap_allowed { "" } else { "-" })
624 | } else {
625 | "".to_string()
626 | };
627 | format!("{id}.{depth}{layout}{active_str}{hold_str}{next_pieces_str}")
628 | }
629 |
630 | #[cfg(test)]
631 | mod tests {
632 | use std::{collections::HashMap, num::NonZeroU32};
633 |
634 | use super::*;
635 | use tetrs_engine::piece_generation::TetrominoSource;
636 |
637 | const COMBO_MAX: usize = 1_000_000;
638 |
639 | #[test]
640 | fn benchmark_demo() {
641 | let sample_count = 2_500;
642 | let lookahead = 4;
643 | let randomizer = (TetrominoSource::recency(), "recency");
644 | run_analyses_on(sample_count, std::iter::once((lookahead, randomizer)));
645 | }
646 |
647 | #[test]
648 | fn benchmark_simple() {
649 | let sample_count = 1_000;
650 | let lookahead = 8;
651 | let randomizer = (TetrominoSource::bag(), "bag");
652 | run_analyses_on(sample_count, std::iter::once((lookahead, randomizer)));
653 | }
654 |
655 | #[test]
656 | fn benchmark_lookaheads() {
657 | let sample_count = 10_000;
658 | let lookaheads = 0..=9;
659 | let randomizer = (TetrominoSource::bag(), "bag");
660 | run_analyses_on(sample_count, lookaheads.zip(std::iter::repeat(randomizer)));
661 | }
662 |
663 | #[test]
664 | fn benchmark_randomizers() {
665 | let sample_count = 100_000;
666 | let lookahead = 3;
667 | #[rustfmt::skip]
668 | let randomizers = [
669 | (TetrominoSource::uniform(), "uniform"),
670 | (TetrominoSource::balance_relative(), "balance-relative"),
671 | (TetrominoSource::bag(), "bag"),
672 | (TetrominoSource::stock(NonZeroU32::MIN.saturating_add(1), 0).unwrap(), "bag-2"),
673 | (TetrominoSource::stock(NonZeroU32::MIN.saturating_add(2), 0).unwrap(), "bag-3"),
674 | (TetrominoSource::stock(NonZeroU32::MIN.saturating_add(1), 7).unwrap(), "bag-2_restock-on-7"),
675 | (TetrominoSource::stock(NonZeroU32::MIN.saturating_add(1), 7).unwrap(), "bag-3_restock-on-7"),
676 | (TetrominoSource::recency_with(0.0), "recency-0.0"),
677 | (TetrominoSource::recency_with(0.5), "recency-0.5"),
678 | (TetrominoSource::recency_with(1.0), "recency-1.0"),
679 | (TetrominoSource::recency_with(1.5), "recency-1.5"),
680 | (TetrominoSource::recency_with(2.0), "recency-2.0"),
681 | (TetrominoSource::recency(), "recency"),
682 | (TetrominoSource::recency_with(3.0), "recency-3.0"),
683 | (TetrominoSource::recency_with(8.0), "recency-7.0"),
684 | (TetrominoSource::recency_with(16.0), "recency-16.0"),
685 | (TetrominoSource::recency_with(32.0), "recency-32.0"),
686 | ];
687 | run_analyses_on(sample_count, std::iter::repeat(lookahead).zip(randomizers));
688 | }
689 |
690 | fn run_analyses_on<'a>(
691 | sample_count: usize,
692 | configurations: impl IntoIterator
- ,
693 | ) {
694 | let timestamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%S").to_string();
695 | let summaries_filename = format!("combot-{timestamp}_SUMMARY.md");
696 | let mut file = File::options()
697 | .create(true)
698 | .append(true)
699 | .open(summaries_filename)
700 | .unwrap();
701 | file.write(
702 | format!("# Tetrs Combo (4-wide 3-res.) - Bot Statistics Summary\n\n").as_bytes(),
703 | )
704 | .unwrap();
705 | let mut rng = rand::thread_rng();
706 | for (lookahead, (randomizer, randomizer_name)) in configurations {
707 | let combos = std::iter::repeat_with(|| {
708 | run_bot(lookahead, &mut randomizer.clone().with_rng(&mut rng))
709 | })
710 | .take(sample_count);
711 | let filename_svg = format!("combot-{timestamp}_L{lookahead}_{randomizer_name}.svg");
712 | let summary = run_analysis(combos, lookahead, randomizer_name, &filename_svg);
713 | file.write(format!("- {summary}\n").as_bytes()).unwrap();
714 | }
715 | }
716 |
717 | fn run_bot(lookahead: usize, iter: &mut impl Iterator
- ) -> usize {
718 | let mut next_pieces: VecDeque<_> = iter.take(lookahead).collect();
719 | let mut state = ComboState {
720 | layout: (Pat::_200, false),
721 | active: Some(iter.next().unwrap()),
722 | hold: None,
723 | next_pieces: ComboBotHandler::encode_next_queue(next_pieces.iter()),
724 | depth: 0,
725 | };
726 | let mut it: usize = 0;
727 | loop {
728 | let states_lvl1 = neighbors(state);
729 | // No more options to continue.
730 | let Some(branch) = choose_branch(
731 | states_lvl1
732 | .iter()
733 | .map(|(state_lvl1, _)| *state_lvl1)
734 | .collect(),
735 | None,
736 | ) else {
737 | break;
738 | };
739 | let did_hold = states_lvl1[branch].1.contains(&Button::HoldPiece);
740 | let mut new_state = states_lvl1[branch].0;
741 | if new_state.active.is_none() {
742 | new_state.active = Some(iter.next().unwrap());
743 | } else if !did_hold || (did_hold && state.hold.is_none()) {
744 | next_pieces.push_back(iter.next().unwrap());
745 | next_pieces.pop_front();
746 | }
747 | new_state.next_pieces = ComboBotHandler::encode_next_queue(next_pieces.iter());
748 | state = new_state;
749 | // Only count if piece was not dropped i.e. used.
750 | if !did_hold {
751 | it += 1;
752 | }
753 | if it == COMBO_MAX {
754 | break;
755 | }
756 | }
757 | it
758 | }
759 |
760 | fn run_analysis(
761 | combos: impl IntoIterator
- ,
762 | lookahead: usize,
763 | randomizer_name: &str,
764 | filename_svg: &str,
765 | ) -> String {
766 | let mut frequencies = HashMap::::new();
767 | let mut sum = 0;
768 | let mut len = 0;
769 | for combo in combos {
770 | *frequencies.entry(combo).or_default() += 1;
771 | sum += combo;
772 | len += 1;
773 | }
774 | let mut frequencies = frequencies.into_iter().collect::>();
775 | frequencies.sort_unstable();
776 | 0;
777 | let mut tmp = 0;
778 | let combo_median = 'calc: {
779 | for (combo, frequency) in frequencies.iter() {
780 | if tmp > len / 2 {
781 | break 'calc combo;
782 | }
783 | tmp += frequency;
784 | }
785 | unreachable!()
786 | };
787 | let combo_max = frequencies.last().unwrap().0;
788 | let combo_average = sum / len;
789 | let frequency_max = *frequencies.iter().map(|(_k, v)| v).max().unwrap();
790 | let summary = format!("samples = {len}, randomizer = '{randomizer_name}', lookahead = {lookahead}; combo_median = {combo_median}, combo_average = {combo_average}, combo_max = {combo_max}, frequency_max = {frequency_max}");
791 |
792 | let font_size = 15;
793 | let margin_x = 20 * font_size;
794 | let margin_y = 20 * font_size;
795 | let gridgranularity_x = 5;
796 | let gridgranularity_y = 5;
797 | let chart_max_x = combo_max + (gridgranularity_x - combo_max % gridgranularity_x);
798 | let chart_max_y = frequency_max + (gridgranularity_y - frequency_max % gridgranularity_y);
799 | let scale_y = 10;
800 | let y_0 = margin_y + scale_y * chart_max_y;
801 | let scale_x = (5).max(scale_y * chart_max_y / chart_max_x);
802 | let x_0 = margin_x;
803 | let w_svg = scale_x * chart_max_x + 2 * margin_x;
804 | let h_svg = scale_y * chart_max_y + 2 * margin_y;
805 |
806 | let file = File::options()
807 | .create(true)
808 | .append(true)
809 | .open(filename_svg)
810 | .unwrap();
811 | let mut file = std::io::BufWriter::new(file);
812 |
813 | #[rustfmt::skip] {
814 | file.write(format!(
815 | r##"
984 | "##).as_bytes()).unwrap();
985 | };
986 |
987 | summary
988 | }
989 | }
990 |
--------------------------------------------------------------------------------
/tetrs_tui/src/game_input_handlers/crossterm.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | collections::HashMap,
3 | sync::{
4 | atomic::{AtomicBool, Ordering},
5 | mpsc::Sender,
6 | Arc,
7 | },
8 | thread::{self, JoinHandle},
9 | time::Instant,
10 | };
11 |
12 | use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
13 |
14 | use tetrs_engine::Button;
15 |
16 | use super::InputSignal;
17 |
18 | #[derive(Debug)]
19 | pub struct CrosstermHandler {
20 | handle: Option<(Arc, JoinHandle<()>)>,
21 | }
22 |
23 | impl Drop for CrosstermHandler {
24 | fn drop(&mut self) {
25 | if let Some((run_thread_flag, _)) = self.handle.take() {
26 | run_thread_flag.store(false, Ordering::Release);
27 | }
28 | }
29 | }
30 |
31 | impl CrosstermHandler {
32 | pub fn new(
33 | input_sender: &Sender,
34 | keybinds: &HashMap,
35 | kitty_enabled: bool,
36 | ) -> Self {
37 | let run_thread_flag = Arc::new(AtomicBool::new(true));
38 | let join_handle = if kitty_enabled {
39 | Self::spawn_kitty
40 | } else {
41 | Self::spawn_standard
42 | }(
43 | run_thread_flag.clone(),
44 | input_sender.clone(),
45 | keybinds.clone(),
46 | );
47 | CrosstermHandler {
48 | handle: Some((run_thread_flag, join_handle)),
49 | }
50 | }
51 |
52 | pub fn default_keybinds() -> HashMap {
53 | HashMap::from([
54 | (KeyCode::Left, Button::MoveLeft),
55 | (KeyCode::Right, Button::MoveRight),
56 | (KeyCode::Char('a'), Button::RotateLeft),
57 | (KeyCode::Char('d'), Button::RotateRight),
58 | //(KeyCode::Char('s'), Button::RotateAround),
59 | (KeyCode::Down, Button::DropSoft),
60 | (KeyCode::Up, Button::DropHard),
61 | //(KeyCode::Char('w'), Button::DropSonic),
62 | (KeyCode::Char(' '), Button::HoldPiece),
63 | ])
64 | }
65 |
66 | fn spawn_standard(
67 | run_thread_flag: Arc,
68 | input_sender: Sender,
69 | keybinds: HashMap,
70 | ) -> JoinHandle<()> {
71 | thread::spawn(move || {
72 | 'react_to_event: loop {
73 | // Maybe stop thread.
74 | let run_thread = run_thread_flag.load(Ordering::Acquire);
75 | if !run_thread {
76 | break 'react_to_event;
77 | };
78 | match event::read() {
79 | Ok(Event::Key(KeyEvent {
80 | code: KeyCode::Char('c'),
81 | modifiers: KeyModifiers::CONTROL,
82 | kind: KeyEventKind::Press | KeyEventKind::Repeat,
83 | ..
84 | })) => {
85 | let _ = input_sender.send(InputSignal::AbortProgram);
86 | break 'react_to_event;
87 | }
88 | Ok(Event::Key(KeyEvent {
89 | code: KeyCode::Char('d'),
90 | modifiers: KeyModifiers::CONTROL,
91 | kind: KeyEventKind::Press,
92 | ..
93 | })) => {
94 | let _ = input_sender.send(InputSignal::ForfeitGame);
95 | break 'react_to_event;
96 | }
97 | Ok(Event::Key(KeyEvent {
98 | code: KeyCode::Char('s'),
99 | modifiers: KeyModifiers::CONTROL,
100 | kind: KeyEventKind::Press,
101 | ..
102 | })) => {
103 | let _ = input_sender.send(InputSignal::TakeSnapshot);
104 | }
105 | // Escape pressed: send pause.
106 | Ok(Event::Key(KeyEvent {
107 | code: KeyCode::Esc,
108 | kind: KeyEventKind::Press,
109 | ..
110 | })) => {
111 | let _ = input_sender.send(InputSignal::Pause);
112 | break 'react_to_event;
113 | }
114 | Ok(Event::Resize(..)) => {
115 | let _ = input_sender.send(InputSignal::WindowResize);
116 | }
117 | // Candidate key pressed.
118 | Ok(Event::Key(KeyEvent {
119 | code: key,
120 | kind: KeyEventKind::Press | KeyEventKind::Repeat,
121 | ..
122 | })) => {
123 | if let Some(&button) = keybinds.get(&key) {
124 | // Binding found: send button press.
125 | let now = Instant::now();
126 | let _ = input_sender.send(InputSignal::ButtonInput(button, true, now));
127 | let _ = input_sender.send(InputSignal::ButtonInput(button, false, now));
128 | }
129 | }
130 | // Don't care about other events: ignore.
131 | _ => {}
132 | };
133 | }
134 | })
135 | }
136 |
137 | fn spawn_kitty(
138 | run_thread_flag: Arc,
139 | input_sender: Sender,
140 | keybinds: HashMap,
141 | ) -> JoinHandle<()> {
142 | thread::spawn(move || {
143 | 'react_to_event: loop {
144 | // Maybe stop thread.
145 | let run_thread = run_thread_flag.load(Ordering::Acquire);
146 | if !run_thread {
147 | break 'react_to_event;
148 | };
149 | match event::poll(std::time::Duration::from_secs(1)) {
150 | Ok(true) => {}
151 | Ok(false) | Err(_) => continue 'react_to_event,
152 | }
153 | match event::read() {
154 | // Direct interrupt.
155 | Ok(Event::Key(KeyEvent {
156 | code: KeyCode::Char('c'),
157 | modifiers: KeyModifiers::CONTROL,
158 | kind: KeyEventKind::Press | KeyEventKind::Repeat,
159 | ..
160 | })) => {
161 | let _ = input_sender.send(InputSignal::AbortProgram);
162 | break 'react_to_event;
163 | }
164 | Ok(Event::Key(KeyEvent {
165 | code: KeyCode::Char('d'),
166 | modifiers: KeyModifiers::CONTROL,
167 | kind: KeyEventKind::Press,
168 | ..
169 | })) => {
170 | let _ = input_sender.send(InputSignal::ForfeitGame);
171 | break 'react_to_event;
172 | }
173 | Ok(Event::Key(KeyEvent {
174 | code: KeyCode::Char('s'),
175 | modifiers: KeyModifiers::CONTROL,
176 | kind: KeyEventKind::Press,
177 | ..
178 | })) => {
179 | let _ = input_sender.send(InputSignal::TakeSnapshot);
180 | }
181 | // Escape pressed: send pause.
182 | Ok(Event::Key(KeyEvent {
183 | code: KeyCode::Esc,
184 | kind: KeyEventKind::Press,
185 | ..
186 | })) => {
187 | let _ = input_sender.send(InputSignal::Pause);
188 | break 'react_to_event;
189 | }
190 | Ok(Event::Resize(..)) => {
191 | let _ = input_sender.send(InputSignal::WindowResize);
192 | }
193 | // TTY simulated press repeat: ignore.
194 | Ok(Event::Key(KeyEvent {
195 | kind: KeyEventKind::Repeat,
196 | ..
197 | })) => {}
198 | // Candidate key actually changed.
199 | Ok(Event::Key(KeyEvent { code, kind, .. })) => match keybinds.get(&code) {
200 | // No binding: ignore.
201 | None => {}
202 | // Binding found: send button un-/press.
203 | Some(&button) => {
204 | let _ = input_sender.send(InputSignal::ButtonInput(
205 | button,
206 | kind == KeyEventKind::Press,
207 | Instant::now(),
208 | ));
209 | }
210 | },
211 | // Don't care about other events: ignore.
212 | _ => {}
213 | };
214 | }
215 | })
216 | }
217 | }
218 |
--------------------------------------------------------------------------------
/tetrs_tui/src/game_input_handlers/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod combo_bot;
2 | pub mod crossterm;
3 |
4 | pub enum InputSignal {
5 | AbortProgram,
6 | ForfeitGame,
7 | Pause,
8 | WindowResize,
9 | TakeSnapshot,
10 | ButtonInput(tetrs_engine::Button, bool, std::time::Instant),
11 | }
12 |
--------------------------------------------------------------------------------
/tetrs_tui/src/game_mods/cheese_mode.rs:
--------------------------------------------------------------------------------
1 | use std::num::{NonZeroU8, NonZeroUsize};
2 |
3 | use rand::Rng;
4 |
5 | use tetrs_engine::{FnGameMod, Game, GameEvent, GameMode, Limits, Line, ModifierPoint};
6 |
7 | fn random_gap_lines(gap_size: usize) -> impl Iterator
- {
8 | let gap_size = gap_size.min(Game::WIDTH);
9 | let grey_tile = Some(NonZeroU8::try_from(254).unwrap());
10 | let mut rng = rand::thread_rng();
11 | std::iter::from_fn(move || {
12 | let mut line = [grey_tile; Game::WIDTH];
13 | let gap_idx = rng.gen_range(0..=line.len() - gap_size);
14 | for i in 0..gap_size {
15 | line[gap_idx + i] = None;
16 | }
17 | Some(line)
18 | })
19 | }
20 |
21 | fn is_cheese_line(line: &Line) -> bool {
22 | line.iter()
23 | .any(|cell| *cell == Some(NonZeroU8::try_from(254).unwrap()))
24 | }
25 |
26 | pub fn new_game(cheese_limit: Option, gap_size: usize, gravity: u32) -> Game {
27 | let mut line_source =
28 | random_gap_lines(gap_size).take(cheese_limit.unwrap_or(NonZeroUsize::MAX).get());
29 | let mut temp_cheese_tally = 0;
30 | let mut temp_normal_tally = 0;
31 | let mut init = false;
32 | let cheese_mode: FnGameMod = Box::new(
33 | move |_config, _mode, state, _rng, _feedback_events, modifier_point| {
34 | if !init {
35 | for (line, cheese) in state.board.iter_mut().take(10).rev().zip(&mut line_source) {
36 | *line = cheese;
37 | }
38 | init = true;
39 | } else if matches!(
40 | modifier_point,
41 | ModifierPoint::BeforeEvent(GameEvent::LineClear)
42 | ) {
43 | for line in state.board.iter() {
44 | if line.iter().all(|mino| mino.is_some()) {
45 | if is_cheese_line(line) {
46 | temp_cheese_tally += 1;
47 | } else {
48 | temp_normal_tally += 1;
49 | }
50 | }
51 | }
52 | }
53 | if matches!(
54 | modifier_point,
55 | ModifierPoint::AfterEvent(GameEvent::LineClear)
56 | ) {
57 | state.lines_cleared -= temp_normal_tally;
58 | for cheese in line_source.by_ref().take(temp_cheese_tally) {
59 | state.board.insert(0, cheese);
60 | }
61 | temp_cheese_tally = 0;
62 | temp_normal_tally = 0;
63 | }
64 | },
65 | );
66 | let mut game = Game::new(GameMode {
67 | name: "Cheese".to_string(),
68 | initial_gravity: gravity,
69 | increase_gravity: false,
70 | limits: Limits {
71 | lines: cheese_limit.map(|line_count| (true, line_count.get())),
72 | ..Default::default()
73 | },
74 | });
75 | game.add_modifier(cheese_mode);
76 | game
77 | }
78 |
--------------------------------------------------------------------------------
/tetrs_tui/src/game_mods/combo_mode.rs:
--------------------------------------------------------------------------------
1 | use std::num::NonZeroU8;
2 |
3 | use tetrs_engine::{
4 | Board, FnGameMod, Game, GameEvent, GameMode, Limits, Line, ModifierPoint, Tetromino,
5 | };
6 |
7 | pub const LAYOUTS: [u16; 5] = [
8 | 0b0000_0000_1100_1000, // "r"
9 | 0b0000_0000_0000_1110, // "_"
10 | 0b0000_1100_1000_1011, // "f _"
11 | 0b0000_1100_1000_1101, // "k ."
12 | 0b1000_1000_1000_1101, // "L ."
13 | /*0b0000_1001_1001_1001, // "I I"
14 | 0b0001_0001_1001_1100, // "l i"
15 | 0b1000_1000_1100_1100, // "b"
16 | 0b0000_0000_1110_1011, // "rl"*/
17 | ];
18 |
19 | fn four_wide_lines() -> impl Iterator
- {
20 | let color_tiles = [
21 | Tetromino::Z,
22 | Tetromino::L,
23 | Tetromino::O,
24 | Tetromino::S,
25 | Tetromino::I,
26 | Tetromino::J,
27 | Tetromino::T,
28 | ]
29 | .map(|tet| Some(tet.tiletypeid()));
30 | let grey_tile = Some(NonZeroU8::try_from(254).unwrap());
31 | let indices_0 = (0..).map(|i| i % 7);
32 | let indices_1 = indices_0.clone().skip(1);
33 | indices_0.zip(indices_1).map(move |(i_0, i_1)| {
34 | let mut line = [None; Game::WIDTH];
35 | line[0] = color_tiles[i_0];
36 | line[1] = color_tiles[i_1];
37 | line[2] = grey_tile;
38 | line[7] = grey_tile;
39 | line[8] = color_tiles[i_1];
40 | line[9] = color_tiles[i_0];
41 | line
42 | })
43 | }
44 |
45 | pub fn new_game(gravity: u32, initial_layout: u16) -> Game {
46 | let mut line_source = four_wide_lines();
47 | let mut init = false;
48 | let combo_mode: FnGameMod = Box::new(
49 | move |_config, _mode, state, _rng, _feedback_events, modifier_point| {
50 | if !init {
51 | for (line, four_well) in state
52 | .board
53 | .iter_mut()
54 | .take(Game::HEIGHT)
55 | .zip(&mut line_source)
56 | {
57 | *line = four_well;
58 | }
59 | init_board(&mut state.board, initial_layout);
60 | init = true;
61 | } else if matches!(modifier_point, ModifierPoint::AfterEvent(GameEvent::Lock)) {
62 | // No lineclear, game over.
63 | if !state.events.contains_key(&GameEvent::LineClear) {
64 | state.end = Some(Err(tetrs_engine::GameOver::ModeLimit));
65 | // Combo continues, prepare new line.
66 | } else {
67 | state.board.push(line_source.next().unwrap());
68 | }
69 | }
70 | },
71 | );
72 | let mut game = Game::new(GameMode {
73 | name: "Combo".to_string(),
74 | initial_gravity: gravity,
75 | increase_gravity: false,
76 | limits: Limits::default(),
77 | });
78 | game.add_modifier(combo_mode);
79 | game
80 | }
81 |
82 | fn init_board(board: &mut Board, mut init_layout: u16) {
83 | let grey_tile = Some(NonZeroU8::try_from(254).unwrap());
84 | let mut y = 0;
85 | while init_layout != 0 {
86 | if init_layout & 0b1000 != 0 {
87 | board[y][3] = grey_tile;
88 | }
89 | if init_layout & 0b0100 != 0 {
90 | board[y][4] = grey_tile;
91 | }
92 | if init_layout & 0b0010 != 0 {
93 | board[y][5] = grey_tile;
94 | }
95 | if init_layout & 0b0001 != 0 {
96 | board[y][6] = grey_tile;
97 | }
98 | init_layout /= 0b1_0000;
99 | y += 1;
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/tetrs_tui/src/game_mods/descent_mode.rs:
--------------------------------------------------------------------------------
1 | use std::{num::NonZeroU8, time::Duration};
2 |
3 | use rand::{self, Rng};
4 |
5 | use tetrs_engine::{
6 | FnGameMod, Game, GameEvent, GameMode, GameTime, Limits, Line, ModifierPoint, Tetromino,
7 | };
8 |
9 | pub fn random_descent_lines() -> impl Iterator
- {
10 | /*
11 | We generate quadruple sets of lines like this:
12 | X
13 | 0O0O O0O0X
14 | */
15 | let color_tiles = [
16 | Tetromino::Z,
17 | Tetromino::L,
18 | Tetromino::O,
19 | Tetromino::S,
20 | Tetromino::I,
21 | Tetromino::J,
22 | Tetromino::T,
23 | ]
24 | .map(|tet| Some(tet.tiletypeid()));
25 | let grey_tile = Some(NonZeroU8::try_from(254).unwrap());
26 | let playing_width = Game::WIDTH - (1 - Game::WIDTH % 2);
27 | let mut rng = rand::thread_rng();
28 | (0..).map(move |i| {
29 | let mut line = [None; Game::WIDTH];
30 | match i % 4 {
31 | 0 | 2 => {}
32 | r => {
33 | for (j, cell) in line.iter_mut().enumerate() {
34 | if j % 2 == 1 || (r == 1 && rng.gen_bool(0.5)) {
35 | *cell = grey_tile;
36 | }
37 | }
38 | // Make hole if row became completely closed off through rng.
39 | if line.iter().all(|c| c.is_some()) {
40 | let hole_idx = 2 * rng.gen_range(0..playing_width / 2);
41 | line[hole_idx] = None;
42 | }
43 | let gem_idx = rng.gen_range(0..playing_width);
44 | if line[gem_idx].is_some() {
45 | line[gem_idx] = Some(NonZeroU8::try_from(rng.gen_range(1..=7)).unwrap());
46 | }
47 | }
48 | };
49 | if playing_width < line.len() {
50 | line[playing_width] = color_tiles[(i / 10) % 7];
51 | }
52 | line
53 | })
54 | }
55 |
56 | pub fn new_game() -> Game {
57 | let mut line_source = random_descent_lines();
58 | let descent_tetromino = if rand::thread_rng().gen_bool(0.5) {
59 | Tetromino::L
60 | } else {
61 | Tetromino::J
62 | };
63 | let mut instant_last_descent = GameTime::ZERO;
64 | let base_descent_period = Duration::from_secs(2_000_000);
65 | let mut instant_camera_adjusted = instant_last_descent;
66 | let camera_adjust_period = Duration::from_millis(125);
67 | let mut depth = 1u32;
68 | let mut init = false;
69 | let descent_mode: FnGameMod = Box::new(
70 | move |config, _mode, state, _rng, _feedback_events, modifier_point| {
71 | if !init {
72 | for (line, worm_line) in state
73 | .board
74 | .iter_mut()
75 | .take(Game::SKYLINE)
76 | .rev()
77 | .zip(&mut line_source)
78 | {
79 | *line = worm_line;
80 | }
81 | init = true;
82 | }
83 | let Some((active_piece, _)) = &mut state.active_piece_data else {
84 | return;
85 | };
86 | let descent_period_elapsed = state.time.saturating_sub(instant_last_descent)
87 | >= base_descent_period.div_f64(f64::from(depth).powf(1.0 / 2.5));
88 | let camera_adjust_elapsed =
89 | state.time.saturating_sub(instant_camera_adjusted) >= camera_adjust_period;
90 | let camera_hit_bottom = active_piece.position.1 <= 1;
91 | if descent_period_elapsed || (camera_hit_bottom && camera_adjust_elapsed) {
92 | if descent_period_elapsed {
93 | instant_last_descent = state.time;
94 | }
95 | instant_camera_adjusted = state.time;
96 | depth += 1;
97 | active_piece.position.1 += 1;
98 | state.board.insert(0, line_source.next().unwrap());
99 | state.board.pop();
100 | if active_piece.position.1 >= Game::SKYLINE {
101 | state.end = Some(Err(tetrs_engine::GameOver::ModeLimit));
102 | }
103 | }
104 | if matches!(
105 | modifier_point,
106 | ModifierPoint::AfterEvent(GameEvent::Rotate(_))
107 | ) {
108 | let piece_tiles_coords = active_piece.tiles().map(|(coord, _)| coord);
109 | for (y, line) in state.board.iter_mut().enumerate() {
110 | for (x, tile) in line.iter_mut().take(9).enumerate() {
111 | if let Some(tiletypeid) = tile {
112 | let i = tiletypeid.get();
113 | if i <= 7 {
114 | let j = if piece_tiles_coords
115 | .iter()
116 | .any(|(x_p, y_p)| x_p.abs_diff(x) + y_p.abs_diff(y) <= 1)
117 | {
118 | state.score += 1;
119 | 253
120 | } else {
121 | match i {
122 | 4 => 6,
123 | 6 => 1,
124 | 1 => 3,
125 | 3 => 2,
126 | 2 => 7,
127 | 7 => 5,
128 | 5 => 4,
129 | _ => unreachable!(),
130 | }
131 | };
132 | *tiletypeid = NonZeroU8::try_from(j).unwrap();
133 | }
134 | }
135 | }
136 | }
137 | }
138 | // Keep custom game state that's also visible to player, but hide it from the game engine that handles gameplay.
139 | if matches!(
140 | modifier_point,
141 | ModifierPoint::BeforeEvent(_) | ModifierPoint::BeforeButtonChange
142 | ) {
143 | state.lines_cleared = 0;
144 | state.next_pieces.clear();
145 | config.preview_count = 0;
146 | // state.level = NonZeroU32::try_from(SPEED_LEVEL).unwrap();
147 | } else {
148 | state.lines_cleared = usize::try_from(depth).unwrap();
149 | // state.level =
150 | // NonZeroU32::try_from(u32::try_from(current_puzzle_idx + 1).unwrap()).unwrap();
151 | }
152 | // Remove ability to hold.
153 | if matches!(modifier_point, ModifierPoint::AfterButtonChange) {
154 | state.events.remove(&GameEvent::Hold);
155 | }
156 | // FIXME: Remove jank.
157 | active_piece.shape = descent_tetromino;
158 | },
159 | );
160 | let mut game = Game::new(GameMode {
161 | name: "Descent".to_string(),
162 | initial_gravity: 0,
163 | increase_gravity: false,
164 | limits: Limits {
165 | time: Some((true, Duration::from_secs(180))),
166 | ..Default::default()
167 | },
168 | });
169 | game.config_mut().preview_count = 0;
170 | game.add_modifier(descent_mode);
171 | game
172 | }
173 |
--------------------------------------------------------------------------------
/tetrs_tui/src/game_mods/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod cheese_mode;
2 | pub mod combo_mode;
3 | pub mod descent_mode;
4 | pub mod puzzle_mode;
5 | pub mod utils;
6 |
--------------------------------------------------------------------------------
/tetrs_tui/src/game_mods/puzzle_mode.rs:
--------------------------------------------------------------------------------
1 | use std::{collections::VecDeque, num::NonZeroU8};
2 |
3 | use tetrs_engine::{
4 | Feedback, FeedbackEvents, FnGameMod, Game, GameEvent, GameMode, GameOver, GameState, Limits,
5 | ModifierPoint, Tetromino,
6 | };
7 |
8 | const MAX_STAGE_ATTEMPTS: usize = 5;
9 | const PUZZLE_GRAVITY: u32 = 1;
10 |
11 | pub fn new_game() -> Game {
12 | let puzzles = puzzle_list();
13 | let puzzles_len = puzzles.len();
14 | let load_puzzle = move |state: &mut GameState,
15 | attempt: usize,
16 | current_puzzle_idx: usize,
17 | feedback_events: &mut FeedbackEvents|
18 | -> usize {
19 | let (puzzle_name, puzzle_lines, puzzle_pieces) = &puzzles[current_puzzle_idx];
20 | // Game message.
21 | feedback_events.push((
22 | state.time,
23 | Feedback::Message(if attempt == 1 {
24 | format!(
25 | "Stage {}: {}",
26 | current_puzzle_idx + 1,
27 | puzzle_name.to_ascii_uppercase()
28 | )
29 | } else {
30 | format!(
31 | "{} ATT. LEFT ({})",
32 | MAX_STAGE_ATTEMPTS + 1 - attempt,
33 | puzzle_name.to_ascii_uppercase()
34 | )
35 | }),
36 | ));
37 | state.next_pieces.clone_from(puzzle_pieces);
38 | for (load_line, board_line) in puzzle_lines
39 | .iter()
40 | .rev()
41 | .chain(std::iter::repeat(&&[b' '; 10]))
42 | .zip(state.board.iter_mut())
43 | {
44 | let grey_tile = Some(NonZeroU8::try_from(254).unwrap());
45 | *board_line = tetrs_engine::Line::default();
46 | if load_line.iter().any(|c| c != &b' ') {
47 | for (board_cell, puzzle_tile) in board_line
48 | .iter_mut()
49 | .zip(load_line.iter().chain(std::iter::repeat(&b'O')))
50 | {
51 | if puzzle_tile != &b' ' {
52 | *board_cell = grey_tile;
53 | }
54 | }
55 | }
56 | }
57 | puzzle_pieces.len()
58 | };
59 | let mut init = false;
60 | let mut current_puzzle_idx = 0;
61 | let mut current_puzzle_attempt = 1;
62 | let mut current_puzzle_piececnt_limit = 0;
63 | let puzzle_mode: FnGameMod = Box::new(
64 | move |config, _mode, state, _rng, feedback_events, modifier_point| {
65 | let game_piececnt = usize::try_from(state.pieces_played.iter().sum::()).unwrap();
66 | if !init {
67 | let piececnt = load_puzzle(
68 | state,
69 | current_puzzle_attempt,
70 | current_puzzle_idx,
71 | feedback_events,
72 | );
73 | current_puzzle_piececnt_limit = game_piececnt + piececnt;
74 | init = true;
75 | } else if matches!(modifier_point, ModifierPoint::BeforeEvent(GameEvent::Spawn))
76 | && game_piececnt == current_puzzle_piececnt_limit
77 | {
78 | let puzzle_done = state
79 | .board
80 | .iter()
81 | .all(|line| line.iter().all(|cell| cell.is_none()));
82 | // Run out of attempts, game over.
83 | if !puzzle_done && current_puzzle_attempt == MAX_STAGE_ATTEMPTS {
84 | state.end = Some(Err(GameOver::ModeLimit));
85 | } else {
86 | if puzzle_done {
87 | current_puzzle_idx += 1;
88 | current_puzzle_attempt = 1;
89 | } else {
90 | current_puzzle_attempt += 1;
91 | }
92 | if current_puzzle_idx == puzzles_len {
93 | // Done with all puzzles, game completed.
94 | state.end = Some(Ok(()));
95 | } else {
96 | // Load in new puzzle.
97 | let piececnt = load_puzzle(
98 | state,
99 | current_puzzle_attempt,
100 | current_puzzle_idx,
101 | feedback_events,
102 | );
103 | current_puzzle_piececnt_limit = game_piececnt + piececnt;
104 | }
105 | }
106 | }
107 | // FIXME: handle displaying the level to the user better.
108 | // Keep custom game state that's also visible to player, but hide it from the game engine that handles gameplay.
109 | if matches!(
110 | modifier_point,
111 | ModifierPoint::BeforeEvent(_) | ModifierPoint::BeforeButtonChange
112 | ) {
113 | config.preview_count = 0;
114 | state.gravity = PUZZLE_GRAVITY;
115 | } else {
116 | config.preview_count = state.next_pieces.len();
117 | state.gravity = u32::try_from(current_puzzle_idx + 1).unwrap();
118 | // Delete accolades.
119 | feedback_events.retain(|evt| !matches!(evt, (_, Feedback::Accolade { .. })));
120 | }
121 | // Remove spurious spawn.
122 | if matches!(modifier_point, ModifierPoint::AfterEvent(GameEvent::Spawn))
123 | && state.end.is_some()
124 | {
125 | state.active_piece_data = None;
126 | }
127 | // Remove ability to hold.
128 | if matches!(modifier_point, ModifierPoint::AfterButtonChange) {
129 | state.events.remove(&GameEvent::Hold);
130 | }
131 | },
132 | );
133 | let mut game = Game::new(GameMode {
134 | name: "Puzzle".to_string(),
135 | initial_gravity: 2,
136 | increase_gravity: false,
137 | limits: Limits::default(),
138 | });
139 | game.config_mut().preview_count = 0;
140 | game.add_modifier(puzzle_mode);
141 | game
142 | }
143 |
144 | #[allow(clippy::type_complexity)]
145 | #[rustfmt::skip]
146 | fn puzzle_list() -> [(&'static str, Vec<&'static [u8; 10]>, VecDeque); 24] {
147 | [
148 | /* Puzzle template.
149 | ("puzzlename", vec![
150 | b"OOOOOOOOOO",
151 | b"OOOOOOOOOO",
152 | b"OOOOOOOOOO",
153 | b"OOOOOOOOOO",
154 | ], VecDeque::from([Tetromino::I,])),
155 | */
156 | /*("DEBUG L/J", vec![
157 | b" O O O O O",
158 | b" O",
159 | b" O O O O O",
160 | b" O",
161 | b" O O O O O",
162 | b" O",
163 | b" O O O O O",
164 | b" O",
165 | ], VecDeque::from([Tetromino::L,Tetromino::J])),*/
166 | // 4 I-spins.
167 | ("I-spin", vec![
168 | b"OOOOO OOOO",
169 | b"OOOOO OOOO",
170 | b"OOOOO OOOO",
171 | b"OOOOO OOOO",
172 | b"OOOO OO",
173 | ], VecDeque::from([Tetromino::I,Tetromino::I])),
174 | ("I-spin", vec![
175 | b"OOOOO OOO",
176 | b"OOOOO OOOO",
177 | b"OOOOO OOOO",
178 | b"OO OOOO",
179 | ], VecDeque::from([Tetromino::I,Tetromino::J])),
180 | ("I-spin Triple", vec![
181 | b"OO O OO",
182 | b"OO OOOO",
183 | b"OOOO OOOOO",
184 | b"OOOO OOOOO",
185 | b"OOOO OOOOO",
186 | ], VecDeque::from([Tetromino::I,Tetromino::L,Tetromino::O,])),
187 | ("I-spin trial", vec![
188 | b"OOOOO OOO",
189 | b"OOO OO OOO",
190 | b"OOO OO OOO",
191 | b"OOO OO",
192 | b"OOO OOOOOO",
193 | ], VecDeque::from([Tetromino::I,Tetromino::I,Tetromino::L,])),
194 | // 4 S/Z-spins.
195 | ("S-spin", vec![
196 | b"OOOO OOOO",
197 | b"OOO OOOOO",
198 | ], VecDeque::from([Tetromino::S,])),
199 | ("S-spins", vec![
200 | b"OOOO OO",
201 | b"OOO OOO",
202 | b"OOOOO OOO",
203 | b"OOOO OOOO",
204 | ], VecDeque::from([Tetromino::S,Tetromino::S,Tetromino::S,])),
205 | ("Z-spin galore", vec![
206 | b"O OOOOOOO",
207 | b"OO OOOOOO",
208 | b"OOO OOOOO",
209 | b"OOOO OOOO",
210 | b"OOOOO OOO",
211 | b"OOOOOO OO",
212 | b"OOOOOOO O",
213 | b"OOOOOOOO ",
214 | ], VecDeque::from([Tetromino::Z,Tetromino::Z,Tetromino::Z,Tetromino::Z,])),
215 | ("SuZ-spins", vec![
216 | b"OOOO OOOO",
217 | b"OOO OOOOO",
218 | b"OO OOOO",
219 | b"OO OOOO",
220 | b"OOO OOO",
221 | b"OO OO OO",
222 | ], VecDeque::from([Tetromino::S,Tetromino::S,Tetromino::I,Tetromino::I,Tetromino::Z,])),
223 | // 4 L/J-spins.
224 | ("J-spin", vec![
225 | b"OO OOO",
226 | b"OOOOOO OOO",
227 | b"OOOOO OOO",
228 | ], VecDeque::from([Tetromino::J,Tetromino::I,])),
229 | ("L_J-spin", vec![
230 | b"OO OO",
231 | b"OO OOOO OO",
232 | b"OO OO OO",
233 | ], VecDeque::from([Tetromino::J,Tetromino::L,Tetromino::I])),
234 | ("L-spin", vec![
235 | b"OOOOO OOOO",
236 | b"OOO OOOO",
237 | ], VecDeque::from([Tetromino::L,])),
238 | ("L/J-spins", vec![
239 | b"O OO O",
240 | b"O O OO O O",
241 | b"O OO O",
242 | ], VecDeque::from([Tetromino::J,Tetromino::L,Tetromino::J,Tetromino::L,])),
243 | // 4 L/J-turns.
244 | ("77", vec![
245 | b"OOOO OOOO",
246 | b"OOOOO OOOO",
247 | b"OOO OOOO",
248 | b"OOOO OOOOO",
249 | b"OOOO OOOOO",
250 | ], VecDeque::from([Tetromino::L,Tetromino::L,])),
251 | ("7-turn", vec![
252 | b"OOOOO OOO",
253 | b"OOO OOO",
254 | b"OOOO OOOOO",
255 | b"OOOO OOOOO",
256 | ], VecDeque::from([Tetromino::L,Tetromino::O,])),
257 | ("L-turn", vec![
258 | b"OOOO OOOO",
259 | b"OOOO OOOO",
260 | b"OOOO OOO",
261 | b"OOOO OOOOO",
262 | ], VecDeque::from([Tetromino::L,Tetromino::O,])),
263 | ("L-turn trial", vec![
264 | b"OOOO OOOO",
265 | b"OOOO OOOO",
266 | b"OO OOO",
267 | b"OOO OOOOO",
268 | b"OOO OOOOOO",
269 | ], VecDeque::from([Tetromino::L,Tetromino::L,Tetromino::O,])),
270 | // 7 T-spins.
271 | ("T-spin", vec![
272 | b"OOOO OO",
273 | b"OOO OOOO",
274 | b"OOOO OOOOO",
275 | ], VecDeque::from([Tetromino::T,Tetromino::I])),
276 | ("T-spin pt.2", vec![
277 | b"OOOO OO",
278 | b"OOO OOOO",
279 | b"OOOO OOOOO",
280 | ], VecDeque::from([Tetromino::T,Tetromino::L])),
281 | ("T-tuck", vec![
282 | b"OO OOOOO",
283 | b"OOO OOOOO",
284 | b"OOO OOOO",
285 | ], VecDeque::from([Tetromino::T,Tetromino::T])),
286 | ("T-insert", vec![
287 | b"OOOO OOOO",
288 | b"OOOO OOOO",
289 | b"OOOOO OOOO",
290 | b"OOOO OOO",
291 | ], VecDeque::from([Tetromino::T,Tetromino::O])),
292 | ("T-go-round", vec![
293 | b"OOO OOOOO",
294 | b"OOO OOOO",
295 | b"OOOOO OOO",
296 | b"OOOOO OOOO",
297 | ], VecDeque::from([Tetromino::T,Tetromino::O])),
298 | ("T T-spin Setup", vec![
299 | b"OOOOO OOO",
300 | b"OOOOO OOO",
301 | b"OOO OOOO",
302 | b"OOOO OOOOO",
303 | ], VecDeque::from([Tetromino::T,Tetromino::O])),
304 | ("T T-spin Triple", vec![
305 | b"OOOO OOO",
306 | b"OOOOO OOO",
307 | b"OOO OOOO",
308 | b"OOOO OOOOO",
309 | b"OOO OOOOO",
310 | b"OOOO OOOOO",
311 | ], VecDeque::from([Tetromino::T,Tetromino::L,Tetromino::J])),
312 | ("~ Finale ~", vec![ // v2.2.1
313 | b"OOOO OOOO",
314 | b"O O OOOO",
315 | b" OOO OOOO",
316 | b"OOO OOO",
317 | b"OOOOOO O",
318 | b" O OOO",
319 | b"OOOOO OOOO",
320 | b"O O OOOO",
321 | b"OOOOO OOOO",
322 | ], VecDeque::from([Tetromino::T,Tetromino::L,Tetromino::O,Tetromino::S,Tetromino::I,Tetromino::J,Tetromino::Z])),
323 | // ("T-spin FINALE v2.3", vec![
324 | // b"OOOO OOOO",
325 | // b"OOOO O O",
326 | // b"OOOO OOO ",
327 | // b"OOO OOO",
328 | // b"O OOOOOO",
329 | // b"OOO OOO",
330 | // b"OOOO OOO ",
331 | // b"OOOO O O",
332 | // b"OOOO OOOOO",
333 | // ], VecDeque::from([Tetromino::T,Tetromino::J,Tetromino::O,Tetromino::Z,Tetromino::I,Tetromino::L,Tetromino::S])),
334 | // ("T-spin FINALE v2.2", vec![
335 | // b"OOOO OOOO",
336 | // b"O O OOOO",
337 | // b" OOO OOOO",
338 | // b"OOO OOO",
339 | // b"OOOOOO O",
340 | // b"OOO OOO",
341 | // b" OOO OOOO",
342 | // b"O O OOOO",
343 | // b"OOOOO OOOO",
344 | // ], VecDeque::from([Tetromino::T,Tetromino::L,Tetromino::O,Tetromino::S,Tetromino::I,Tetromino::J,Tetromino::Z])),
345 | // ("T-spin FINALE v2.1", vec![
346 | // b"OOOO OOOO",
347 | // b"OOOO OOOO",
348 | // b"OOOOO OOOO",
349 | // b"OOO OOO",
350 | // b"OOOOOO O",
351 | // b"OOO OOO",
352 | // b" OOO OO ",
353 | // b"O O OOOO",
354 | // b"OOOOO O O",
355 | // ], VecDeque::from([Tetromino::T,Tetromino::L,Tetromino::O,Tetromino::I,Tetromino::J,Tetromino::Z,Tetromino::S])),
356 | // ("T-spin FINALE v3", vec![
357 | // b"OOOO OOOO",
358 | // b"OOOO OOOO",
359 | // b"OOOOO OOOO",
360 | // b"OOO OOO",
361 | // b"OOOOOO O",
362 | // b"OOO OOO",
363 | // b"OOOOO OOOO",
364 | // b"O O O O",
365 | // b"O OO OO ",
366 | // ], VecDeque::from([Tetromino::T,Tetromino::L,Tetromino::S,Tetromino::I,Tetromino::J,Tetromino::O,Tetromino::Z])),
367 | // ("T-spin FINALE v2", vec![
368 | // b"OOOO OOOO",
369 | // b"OOOO OOOO",
370 | // b"OOOOO OOOO",
371 | // b"OOO OOO",
372 | // b"OOOOOO O",
373 | // b"OOO OOO",
374 | // b"OOOOO OOOO",
375 | // b"O O O O",
376 | // b" OOO OO ",
377 | // ], VecDeque::from([Tetromino::T,Tetromino::L,Tetromino::O,Tetromino::I,Tetromino::J,Tetromino::Z,Tetromino::S])),
378 | // ("T-spin FINALE v1", vec![
379 | // b"OOOO OOOO",
380 | // b"OOOO OOOO",
381 | // b"OOOOO OOOO",
382 | // b"OOO OO",
383 | // b"OOOOOO O",
384 | // b"OO O ",
385 | // b"OOOOO OOOO",
386 | // b"O O OOOO",
387 | // b" OOO OOOO",
388 | // ], VecDeque::from([Tetromino::T,Tetromino::O,Tetromino::L,Tetromino::I,Tetromino::J,Tetromino::Z,Tetromino::S])),
389 | ]
390 | }
391 |
--------------------------------------------------------------------------------
/tetrs_tui/src/game_mods/utils.rs:
--------------------------------------------------------------------------------
1 | use tetrs_engine::{
2 | piece_generation::TetrominoSource, Feedback, FnGameMod, GameEvent, ModifierPoint, Tetromino,
3 | };
4 |
5 | #[allow(dead_code)]
6 | pub fn custom_start_board(board_str: &str) -> FnGameMod {
7 | let grey_tile = Some(std::num::NonZeroU8::try_from(254).unwrap());
8 | let mut init = false;
9 | let board_str = board_str.to_owned();
10 | Box::new(
11 | move |_config, _mode, state, _rng, _feedback_events, _modifier_point| {
12 | if !init {
13 | let mut chars = board_str.chars().rev();
14 | 'init: for row in state.board.iter_mut() {
15 | for cell in row.iter_mut().rev() {
16 | let Some(char) = chars.next() else {
17 | break 'init;
18 | };
19 | *cell = if char != ' ' { grey_tile } else { None };
20 | }
21 | }
22 | init = true;
23 | }
24 | },
25 | )
26 | }
27 |
28 | #[allow(dead_code)]
29 | pub fn custom_start_offset(offset: u32) -> FnGameMod {
30 | let mut init = false;
31 | Box::new(
32 | move |config, _mode, state, rng, _feedback_events, _modifier_point| {
33 | if !init {
34 | // feedback_events.push((state.time, Feedback::Message(format!("tet gen.: {:?}", config.tetromino_generator))));
35 | for tet in config
36 | .tetromino_generator
37 | .with_rng(rng)
38 | .take(usize::try_from(offset).unwrap())
39 | {
40 | state.pieces_played[tet] += 1;
41 | }
42 | if state.hold_piece.is_some() {
43 | let _tet = config.tetromino_generator.with_rng(rng).next();
44 | }
45 | init = true;
46 | }
47 | },
48 | )
49 | }
50 |
51 | #[allow(dead_code)]
52 | pub fn display_tetromino_likelihood() -> FnGameMod {
53 | Box::new(
54 | |config, _mode, state, _rng, feedback_events, modifier_point| {
55 | if !matches!(modifier_point, ModifierPoint::AfterEvent(GameEvent::Spawn)) {
56 | return;
57 | }
58 | let TetrominoSource::Recency {
59 | last_generated,
60 | snap: _,
61 | } = config.tetromino_generator
62 | else {
63 | return;
64 | };
65 | let mut pieces_played_strs = [
66 | Tetromino::O,
67 | Tetromino::I,
68 | Tetromino::S,
69 | Tetromino::Z,
70 | Tetromino::T,
71 | Tetromino::L,
72 | Tetromino::J,
73 | ];
74 | pieces_played_strs.sort_by_key(|&t| last_generated[t]);
75 | feedback_events.push((
76 | state.time,
77 | Feedback::Message(
78 | pieces_played_strs
79 | .map(|tet| {
80 | format!(
81 | "{tet:?}{}{}{}",
82 | last_generated[tet],
83 | // "█".repeat(lg[t] as usize),
84 | "█".repeat(
85 | (last_generated[tet] * last_generated[tet]) as usize / 8
86 | ),
87 | [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]
88 | [(last_generated[tet] * last_generated[tet]) as usize % 8]
89 | )
90 | .to_ascii_lowercase()
91 | })
92 | .join("")
93 | .to_string(),
94 | ),
95 | ));
96 | // config.line_clear_delay = Duration::ZERO;
97 | // config.appearance_delay = Duration::ZERO;
98 | // state.board.remove(0);
99 | // state.board.push(Default::default());
100 | // state.board.remove(0);
101 | // state.board.push(Default::default());
102 | },
103 | )
104 | }
105 |
--------------------------------------------------------------------------------
/tetrs_tui/src/game_renderers/cached_renderer.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | cmp::Ordering,
3 | fmt::{Debug, Display},
4 | io::{self, Write},
5 | num::NonZeroU8,
6 | time::Duration,
7 | };
8 |
9 | use crossterm::{
10 | cursor,
11 | event::KeyCode,
12 | style::{self, Color, Print, PrintStyledContent, Stylize},
13 | terminal, QueueableCommand,
14 | };
15 | use tetrs_engine::{
16 | Button, Coord, Feedback, FeedbackEvents, Game, GameState, GameTime, Orientation, Tetromino,
17 | TileTypeID,
18 | };
19 |
20 | use crate::{
21 | game_renderers::Renderer,
22 | terminal_app::{
23 | fmt_duration, fmt_key, fmt_keybinds, GraphicsColor, GraphicsStyle, RunningGameStats,
24 | TerminalApp,
25 | },
26 | };
27 |
28 | use super::{tet_str_minuscule, tet_str_small, tile_to_color};
29 |
30 | #[derive(Clone, Default, Debug)]
31 | struct ScreenBuf {
32 | prev: Vec)>>,
33 | next: Vec)>>,
34 | x_draw: usize,
35 | y_draw: usize,
36 | }
37 |
38 | impl ScreenBuf {
39 | fn buffer_reset(&mut self, (x, y): (usize, usize)) {
40 | self.prev.clear();
41 | (self.x_draw, self.y_draw) = (x, y);
42 | }
43 |
44 | fn buffer_from(&mut self, base_screen: Vec) {
45 | self.next = base_screen
46 | .iter()
47 | .map(|str| str.chars().zip(std::iter::repeat(None)).collect())
48 | .collect();
49 | }
50 |
51 | fn buffer_str(&mut self, str: &str, fg_color: Option, (x, y): (usize, usize)) {
52 | for (x_c, c) in str.chars().enumerate() {
53 | // Lazy: just fill up until desired starting row and column exist.
54 | while y >= self.next.len() {
55 | self.next.push(Vec::new());
56 | }
57 | let row = &mut self.next[y];
58 | while x + x_c >= row.len() {
59 | row.push((' ', None));
60 | }
61 | row[x + x_c] = (c, fg_color);
62 | }
63 | }
64 |
65 | fn put(&self, term: &mut impl Write, c: char, x: usize, y: usize) -> io::Result<()> {
66 | term.queue(cursor::MoveTo(
67 | u16::try_from(self.x_draw + x).unwrap(),
68 | u16::try_from(self.y_draw + y).unwrap(),
69 | ))?
70 | .queue(Print(c))?;
71 | Ok(())
72 | }
73 |
74 | fn put_styled(
75 | &self,
76 | term: &mut impl Write,
77 | content: style::StyledContent,
78 | x: usize,
79 | y: usize,
80 | ) -> io::Result<()> {
81 | term.queue(cursor::MoveTo(
82 | u16::try_from(self.x_draw + x).unwrap(),
83 | u16::try_from(self.y_draw + y).unwrap(),
84 | ))?
85 | .queue(PrintStyledContent(content))?;
86 | Ok(())
87 | }
88 |
89 | fn flush(&mut self, term: &mut impl Write) -> io::Result<()> {
90 | // Begin frame update.
91 | term.queue(terminal::BeginSynchronizedUpdate)?;
92 | if self.prev.is_empty() {
93 | // Redraw entire screen.
94 | term.queue(terminal::Clear(terminal::ClearType::All))?;
95 | for (y, line) in self.next.iter().enumerate() {
96 | for (x, (c, col)) in line.iter().enumerate() {
97 | if let Some(col) = col {
98 | self.put_styled(term, c.with(*col), x, y)?;
99 | } else {
100 | self.put(term, *c, x, y)?;
101 | }
102 | }
103 | }
104 | } else {
105 | // Compare next to previous frames and only write differences.
106 | for (y, (line_prev, line_next)) in self.prev.iter().zip(self.next.iter()).enumerate() {
107 | // Overwrite common line characters.
108 | for (x, (cell_prev @ (_c_prev, col_prev), cell_next @ (c_next, col_next))) in
109 | line_prev.iter().zip(line_next.iter()).enumerate()
110 | {
111 | // Relevant change occurred.
112 | if cell_prev != cell_next {
113 | // New color.
114 | if let Some(col) = col_next {
115 | self.put_styled(term, c_next.with(*col), x, y)?;
116 | // Previously colored but not anymore, explicit reset.
117 | } else if col_prev.is_some() && col_next.is_none() {
118 | self.put_styled(term, c_next.reset(), x, y)?;
119 | // Uncolored before and after, simple reprint.
120 | } else {
121 | self.put(term, *c_next, x, y)?;
122 | }
123 | }
124 | }
125 | // Handle differences in line length.
126 | match line_prev.len().cmp(&line_next.len()) {
127 | // Previously shorter, just write out new characters now.
128 | Ordering::Less => {
129 | for (x, (c_next, col_next)) in
130 | line_next.iter().enumerate().skip(line_prev.len())
131 | {
132 | // Write new colored char.
133 | if let Some(col) = col_next {
134 | self.put_styled(term, c_next.with(*col), x, y)?;
135 | // Write new uncolored char.
136 | } else {
137 | self.put(term, *c_next, x, y)?;
138 | }
139 | }
140 | }
141 | Ordering::Equal => {}
142 | // Previously longer, delete new characters.
143 | Ordering::Greater => {
144 | for (x, (_c_prev, col_prev)) in
145 | line_prev.iter().enumerate().skip(line_next.len())
146 | {
147 | // Previously colored but now erased, explicit reset.
148 | if col_prev.is_some() {
149 | self.put_styled(term, ' '.reset(), x, y)?;
150 | // Otherwise simply erase previous character.
151 | } else {
152 | self.put(term, ' ', x, y)?;
153 | }
154 | }
155 | }
156 | }
157 | }
158 | // Handle differences in text height.
159 | match self.prev.len().cmp(&self.next.len()) {
160 | // Previously shorter in height.
161 | Ordering::Less => {
162 | for (y, next_line) in self.next.iter().enumerate().skip(self.prev.len()) {
163 | // Write entire line.
164 | for (x, (c_next, col_next)) in next_line.iter().enumerate() {
165 | // Write new colored char.
166 | if let Some(col) = col_next {
167 | self.put_styled(term, c_next.with(*col), x, y)?;
168 | // Write new uncolored char.
169 | } else {
170 | self.put(term, *c_next, x, y)?;
171 | }
172 | }
173 | }
174 | }
175 | Ordering::Equal => {}
176 | // Previously taller, delete excess lines.
177 | Ordering::Greater => {
178 | for (y, prev_line) in self.prev.iter().enumerate().skip(self.next.len()) {
179 | // Erase entire line.
180 | for (x, (_c_prev, col_prev)) in prev_line.iter().enumerate() {
181 | // Previously colored but now erased, explicit reset.
182 | if col_prev.is_some() {
183 | self.put_styled(term, ' '.reset(), x, y)?;
184 | // Otherwise simply erase previous character.
185 | } else {
186 | self.put(term, ' ', x, y)?;
187 | }
188 | }
189 | }
190 | }
191 | }
192 | }
193 | // End frame update and flush.
194 | term.queue(cursor::MoveTo(0, 0))?;
195 | term.queue(terminal::EndSynchronizedUpdate)?;
196 | term.flush()?;
197 | // Clear old.
198 | self.prev.clear();
199 | // Swap buffers.
200 | std::mem::swap(&mut self.prev, &mut self.next);
201 | Ok(())
202 | }
203 | }
204 |
205 | #[derive(Clone, Default, Debug)]
206 | pub struct CachedRenderer {
207 | screen: ScreenBuf,
208 | visual_events: Vec<(GameTime, Feedback, bool)>,
209 | messages: Vec<(GameTime, String)>,
210 | hard_drop_tiles: Vec<(GameTime, Coord, usize, TileTypeID, bool)>,
211 | }
212 |
213 | impl Renderer for CachedRenderer {
214 | // NOTE self: what is the concept of having an ADT but some functions are only defined on some variants (that may contain record data)?
215 | fn render(
216 | &mut self,
217 | app: &mut TerminalApp,
218 | running_game_stats: &mut RunningGameStats,
219 | game: &Game,
220 | new_feedback_events: FeedbackEvents,
221 | screen_resized: bool,
222 | ) -> io::Result<()>
223 | where
224 | T: Write,
225 | {
226 | if screen_resized {
227 | let (x_main, y_main) = TerminalApp::::fetch_main_xy();
228 | self.screen
229 | .buffer_reset((usize::from(x_main), usize::from(y_main)));
230 | }
231 | let GameState {
232 | seed: _,
233 | end: _,
234 | time: game_time,
235 | events: _,
236 | buttons_pressed: _,
237 | board,
238 | active_piece_data,
239 | hold_piece,
240 | next_pieces,
241 | pieces_played,
242 | lines_cleared,
243 | gravity,
244 | score,
245 | consecutive_line_clears: _,
246 | back_to_back_special_clears: _,
247 | } = game.state();
248 | // Screen: some titles.
249 | let mode_name = game.mode().name.to_ascii_uppercase();
250 | let mode_name_space = mode_name.len().max(14);
251 | let (goal_name, goal_value) = [
252 | game.mode().limits.time.map(|(_, max_dur)| {
253 | (
254 | "Time left:",
255 | fmt_duration(max_dur.saturating_sub(*game_time)),
256 | )
257 | }),
258 | game.mode().limits.pieces.map(|(_, max_pcs)| {
259 | (
260 | "Pieces remaining:",
261 | max_pcs
262 | .saturating_sub(pieces_played.iter().sum::())
263 | .to_string(),
264 | )
265 | }),
266 | game.mode().limits.lines.map(|(_, max_lns)| {
267 | (
268 | "Lines left to clear:",
269 | max_lns.saturating_sub(*lines_cleared).to_string(),
270 | )
271 | }),
272 | game.mode().limits.gravity.map(|(_, max_lvl)| {
273 | (
274 | "Gravity levels to advance:",
275 | max_lvl.saturating_sub(*gravity).to_string(),
276 | )
277 | }),
278 | game.mode().limits.score.map(|(_, max_pts)| {
279 | (
280 | "Points to score:",
281 | max_pts.saturating_sub(*score).to_string(),
282 | )
283 | }),
284 | ]
285 | .into_iter()
286 | .find_map(|limit_text| limit_text)
287 | .unwrap_or_default();
288 | let (focus_name, focus_value) = match game.mode().name.as_str() {
289 | "Marathon" => ("Score:", score.to_string()),
290 | "40-Lines" => ("Time taken:", fmt_duration(*game_time)),
291 | "Time Trial" => ("Score:", score.to_string()),
292 | "Master" => ("", "".to_string()),
293 | "Puzzle" => ("", "".to_string()),
294 | _ => ("Lines cleared:", lines_cleared.to_string()),
295 | };
296 | let key_icons_moveleft = fmt_keybinds(Button::MoveLeft, &app.settings().keybinds);
297 | let key_icons_moveright = fmt_keybinds(Button::MoveRight, &app.settings().keybinds);
298 | let mut key_icons_move = format!("{key_icons_moveleft}{key_icons_moveright}");
299 | let key_icons_rotateleft = fmt_keybinds(Button::RotateLeft, &app.settings().keybinds);
300 | let key_icons_rotatearound = fmt_keybinds(Button::RotateAround, &app.settings().keybinds);
301 | let key_icons_rotateright = fmt_keybinds(Button::RotateRight, &app.settings().keybinds);
302 | let mut key_icons_rotate =
303 | format!("{key_icons_rotateleft}{key_icons_rotatearound}{key_icons_rotateright}");
304 | let key_icons_dropsoft = fmt_keybinds(Button::DropSoft, &app.settings().keybinds);
305 | let key_icons_dropsonic = fmt_keybinds(Button::DropSonic, &app.settings().keybinds);
306 | let key_icons_drophard = fmt_keybinds(Button::DropHard, &app.settings().keybinds);
307 | let mut key_icons_drop =
308 | format!("{key_icons_dropsoft}{key_icons_dropsonic}{key_icons_drophard}");
309 | let key_icon_pause = fmt_key(KeyCode::Esc);
310 | // FAIR enough https://users.rust-lang.org/t/truncating-a-string/77903/9 :
311 | let eleven = key_icons_move
312 | .char_indices()
313 | .map(|(i, _)| i)
314 | .nth(11)
315 | .unwrap_or(key_icons_move.len());
316 | key_icons_move.truncate(eleven);
317 | let eleven = key_icons_rotate
318 | .char_indices()
319 | .map(|(i, _)| i)
320 | .nth(11)
321 | .unwrap_or(key_icons_rotate.len());
322 | key_icons_rotate.truncate(eleven);
323 | let eleven = key_icons_drop
324 | .char_indices()
325 | .map(|(i, _)| i)
326 | .nth(11)
327 | .unwrap_or(key_icons_drop.len());
328 | key_icons_drop.truncate(eleven);
329 | let piececnts_o_i_s_z = [
330 | format!("{}o", pieces_played[Tetromino::O]),
331 | format!("{}i", pieces_played[Tetromino::I]),
332 | format!("{}s", pieces_played[Tetromino::S]),
333 | format!("{}z", pieces_played[Tetromino::Z]),
334 | ]
335 | .join(" ");
336 | let piececnts_t_l_j_sum = [
337 | format!("{}t", pieces_played[Tetromino::T]),
338 | format!("{}l", pieces_played[Tetromino::L]),
339 | format!("{}j", pieces_played[Tetromino::J]),
340 | format!("={}", pieces_played.iter().sum::()),
341 | ]
342 | .join(" ");
343 | // Screen: draw.
344 | #[allow(clippy::useless_format)]
345 | #[rustfmt::skip]
346 | let base_screen = match app.settings().graphics_style {
347 | GraphicsStyle::Electronika60 => vec![
348 | format!(" ", ),
349 | format!(" {: ^w$ } ", "mode:", w=mode_name_space),
350 | format!(" ALL STATS {: ^w$ } ", mode_name, w=mode_name_space),
351 | format!(" ---------- {: ^w$ } ", "", w=mode_name_space),
352 | format!(" Gravity: {:<10 } { }", gravity, goal_name),
353 | format!(" Lines: {:<12 }{:^14 }", lines_cleared, goal_value),
354 | format!(" Score: {:<12 } ", score),
355 | format!(" { }", focus_name),
356 | format!(" Time elapsed {:^14 }", focus_value),
357 | format!(" {:<18 } ", fmt_duration(*game_time)),
358 | format!(" ", ),
359 | format!(" Pieces played ", ),
360 | format!(" {:<18 } ", piececnts_o_i_s_z),
361 | format!(" {:<18 } ", piececnts_t_l_j_sum),
362 | format!(" ", ),
363 | format!(" ", ),
364 | format!(" CONTROLS ", ),
365 | format!(" --------- ", ),
366 | format!(" Move {:<11 } ", key_icons_move),
367 | format!(" Rotate {:<11 } ", key_icons_rotate),
368 | format!(" Drop {:<11 } ", key_icons_drop),
369 | format!(" Pause {:<11 } ", key_icon_pause),
370 | format!(" ", ),
371 | format!(r" \/\/\/\/\/\/\/\/\/\/ ", ),
372 | ],
373 | GraphicsStyle::ASCII => vec![
374 | format!(" ", ),
375 | format!(" { }|- - - - - - - - - - +{:-^w$ }+", if hold_piece.is_some() { "+-hold-" } else {" "}, "mode", w=mode_name_space),
376 | format!(" ALL STATS {} | |{: ^w$ }|", if hold_piece.is_some() { "| " } else {" "}, mode_name, w=mode_name_space),
377 | format!(" ---------- { }| +{:-^w$ }+", if hold_piece.is_some() { "+------" } else {" "}, "", w=mode_name_space),
378 | format!(" Gravity: {:<11 }| | { }", gravity, goal_name),
379 | format!(" Lines: {:<13 }| |{:^15 }", lines_cleared, goal_value),
380 | format!(" Score: {:<13 }| | ", score),
381 | format!(" | | { }", focus_name),
382 | format!(" Time elapsed | |{:^15 }", focus_value),
383 | format!(" {:<19 }| | ", fmt_duration(*game_time)),
384 | format!(" | |{ }", if !next_pieces.is_empty() { "-----next-----+" } else {" "}),
385 | format!(" Pieces played | | {}", if !next_pieces.is_empty() { " |" } else {" "}),
386 | format!(" {:<19 }| | {}", piececnts_o_i_s_z, if !next_pieces.is_empty() { " |" } else {" "}),
387 | format!(" {:<19 }| |{ }", piececnts_t_l_j_sum, if !next_pieces.is_empty() { "--------------+" } else {" "}),
388 | format!(" | | ", ),
389 | format!(" | | ", ),
390 | format!(" CONTROLS | | ", ),
391 | format!(" --------- | | ", ),
392 | format!(" Move {:<12 }| | ", key_icons_move),
393 | format!(" Rotate {:<12 }| | ", key_icons_rotate),
394 | format!(" Drop {:<12 }| | ", key_icons_drop),
395 | format!(" Pause {:<12 }| | ", key_icon_pause),
396 | format!(" ~#====================#~ ", ),
397 | format!(" ", ),
398 | ],
399 | GraphicsStyle::Unicode => vec![
400 | format!(" ", ),
401 | format!(" { }╓╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╥{:─^w$ }┐", if hold_piece.is_some() { "┌─hold─" } else {" "}, "mode", w=mode_name_space),
402 | format!(" ALL STATS {} ║ ║{: ^w$ }│", if hold_piece.is_some() { "│ " } else {" "}, mode_name, w=mode_name_space),
403 | format!(" ─────────╴ { }║ ╟{:─^w$ }┘", if hold_piece.is_some() { "└──────" } else {" "}, "", w=mode_name_space),
404 | format!(" Gravity: {:<11 }║ ║ { }", gravity, goal_name),
405 | format!(" Lines: {:<13 }║ ║{:^15 }", lines_cleared, goal_value),
406 | format!(" Score: {:<13 }║ ║ ", score),
407 | format!(" ║ ║ { }", focus_name),
408 | format!(" Time elapsed ║ ║{:^15 }", focus_value),
409 | format!(" {:<19 }║ ║ ", fmt_duration(*game_time)),
410 | format!(" ║ ║{ }", if !next_pieces.is_empty() { "─────next─────┐" } else {" "}),
411 | format!(" Pieces played ║ ║ {}", if !next_pieces.is_empty() { " │" } else {" "}),
412 | format!(" {:<19 }║ ║ {}", piececnts_o_i_s_z, if !next_pieces.is_empty() { " │" } else {" "}),
413 | format!(" {:<19 }║ ║{ }", piececnts_t_l_j_sum, if !next_pieces.is_empty() { "──────────────┘" } else {" "}),
414 | format!(" ║ ║ ", ),
415 | format!(" ║ ║ ", ),
416 | format!(" CONTROLS ║ ║ ", ),
417 | format!(" ────────╴ ║ ║ ", ),
418 | format!(" Move {:<12 }║ ║ ", key_icons_move),
419 | format!(" Rotate {:<12 }║ ║ ", key_icons_rotate),
420 | format!(" Drop {:<12 }║ ║ ", key_icons_drop),
421 | format!(" Pause {:<12 }║ ║ ", key_icon_pause),
422 | format!(" ░▒▓█▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█▓▒░ ", ),
423 | format!(" ", ),
424 | ],
425 | };
426 | self.screen.buffer_from(base_screen);
427 | let (x_board, y_board) = (24, 1);
428 | let (x_hold, y_hold) = (18, 2);
429 | let (x_preview, y_preview) = (48, 12);
430 | let (x_preview_small, y_preview_small) = (48, 14);
431 | let (x_preview_minuscule, y_preview_minuscule) = (50, 16);
432 | let (x_messages, y_messages) = (47, 18);
433 | let pos_board = |(x, y)| (x_board + 2 * x, y_board + Game::SKYLINE - y);
434 | // Board: helpers.
435 | let color = tile_to_color(app.settings().graphics_color);
436 | let color_locked = tile_to_color(app.settings().graphics_color_locked);
437 | // Board: draw hard drop trail.
438 | for (event_time, pos, h, tile_type_id, relevant) in self.hard_drop_tiles.iter_mut() {
439 | let elapsed = game_time.saturating_sub(*event_time);
440 | let luminance_map = match app.settings().graphics_style {
441 | GraphicsStyle::Electronika60 => [" .", " .", " .", " .", " .", " .", " .", " ."],
442 | GraphicsStyle::ASCII | GraphicsStyle::Unicode => {
443 | ["@@", "$$", "##", "%%", "**", "++", "~~", ".."]
444 | }
445 | };
446 | // let Some(&char) = [50, 60, 70, 80, 90, 110, 140, 180]
447 | let Some(tile) = [50, 70, 90, 110, 130, 150, 180, 240]
448 | .iter()
449 | .enumerate()
450 | .find_map(|(idx, ms)| (elapsed < Duration::from_millis(*ms)).then_some(idx))
451 | .and_then(|dt| luminance_map.get(*h * 4 / 7 + dt))
452 | else {
453 | *relevant = false;
454 | continue;
455 | };
456 | self.screen
457 | .buffer_str(tile, color(*tile_type_id), pos_board(*pos));
458 | }
459 | self.hard_drop_tiles.retain(|elt| elt.4);
460 | // Board: draw fixed tiles.
461 | let (tile_ground, tile_ghost, tile_active, tile_preview) =
462 | match app.settings().graphics_style {
463 | GraphicsStyle::Electronika60 => ("▮▮", " .", "▮▮", "▮▮"),
464 | GraphicsStyle::ASCII => ("##", "::", "[]", "[]"),
465 | GraphicsStyle::Unicode => ("██", "░░", "▓▓", "▒▒"),
466 | };
467 | for (y, line) in board.iter().enumerate().take(21).rev() {
468 | for (x, cell) in line.iter().enumerate() {
469 | if let Some(tile_type_id) = cell {
470 | self.screen.buffer_str(
471 | tile_ground,
472 | color_locked(*tile_type_id),
473 | pos_board((x, y)),
474 | );
475 | }
476 | }
477 | }
478 | // If a piece is in play.
479 | if let Some((active_piece, _)) = active_piece_data {
480 | // Draw ghost piece.
481 | for (tile_pos, tile_type_id) in active_piece.well_piece(board).tiles() {
482 | if tile_pos.1 <= Game::SKYLINE {
483 | self.screen
484 | .buffer_str(tile_ghost, color(tile_type_id), pos_board(tile_pos));
485 | }
486 | }
487 | // Draw active piece.
488 | for (tile_pos, tile_type_id) in active_piece.tiles() {
489 | if tile_pos.1 <= Game::SKYLINE {
490 | self.screen
491 | .buffer_str(tile_active, color(tile_type_id), pos_board(tile_pos));
492 | }
493 | }
494 | }
495 | // Draw preview.
496 | if let Some(next_piece) = next_pieces.front() {
497 | let color = color(next_piece.tiletypeid());
498 | for (x, y) in next_piece.minos(Orientation::N) {
499 | let pos = (x_preview + 2 * x, y_preview - y);
500 | self.screen.buffer_str(tile_preview, color, pos);
501 | }
502 | }
503 | // Draw small preview pieces 2,3,4.
504 | let mut x_offset_small = 0;
505 | for tet in next_pieces.iter().skip(1).take(3) {
506 | let str = tet_str_small(tet);
507 | self.screen.buffer_str(
508 | str,
509 | color(tet.tiletypeid()),
510 | (x_preview_small + x_offset_small, y_preview_small),
511 | );
512 | x_offset_small += str.chars().count() + 1;
513 | }
514 | // Draw minuscule preview pieces 5,6,7,8...
515 | let mut x_offset_minuscule = 0;
516 | for tet in next_pieces.iter().skip(4) {
517 | //.take(5) {
518 | let str = tet_str_minuscule(tet);
519 | self.screen.buffer_str(
520 | str,
521 | color(tet.tiletypeid()),
522 | (
523 | x_preview_minuscule + x_offset_minuscule,
524 | y_preview_minuscule,
525 | ),
526 | );
527 | x_offset_minuscule += str.chars().count() + 1;
528 | }
529 | // Draw held piece.
530 | if let Some((tet, swap_allowed)) = hold_piece {
531 | let str = tet_str_small(tet);
532 | let color = color(if *swap_allowed {
533 | tet.tiletypeid()
534 | } else {
535 | NonZeroU8::try_from(254).unwrap()
536 | });
537 | self.screen.buffer_str(str, color, (x_hold, y_hold));
538 | }
539 | // Update stored events.
540 | self.visual_events.extend(
541 | new_feedback_events
542 | .into_iter()
543 | .map(|(time, event)| (time, event, true)),
544 | );
545 | // Draw events.
546 | for (event_time, event, relevant) in self.visual_events.iter_mut() {
547 | let elapsed = game_time.saturating_sub(*event_time);
548 | match event {
549 | Feedback::PieceSpawned(_piece) => {
550 | *relevant = false;
551 | }
552 | Feedback::PieceLocked(piece) => {
553 | #[rustfmt::skip]
554 | let animation_locking = match app.settings().graphics_style {
555 | GraphicsStyle::Electronika60 => [
556 | ( 50, "▮▮"),
557 | ( 75, "▮▮"),
558 | (100, "▮▮"),
559 | (125, "▮▮"),
560 | (150, "▮▮"),
561 | (175, "▮▮"),
562 | ],
563 | GraphicsStyle::ASCII => [
564 | ( 50, "()"),
565 | ( 75, "()"),
566 | (100, "{}"),
567 | (125, "{}"),
568 | (150, "<>"),
569 | (175, "<>"),
570 | ],
571 | GraphicsStyle::Unicode => [
572 | ( 50, "██"),
573 | ( 75, "▓▓"),
574 | (100, "▒▒"),
575 | (125, "░░"),
576 | (150, "▒▒"),
577 | (175, "▓▓"),
578 | ],
579 | };
580 | let color_locking = match app.settings().graphics_color {
581 | GraphicsColor::Monochrome => None,
582 | GraphicsColor::Color16 | GraphicsColor::Fullcolor => Some(Color::White),
583 | GraphicsColor::Experimental => Some(Color::Rgb {
584 | r: 207,
585 | g: 207,
586 | b: 207,
587 | }),
588 | };
589 | let Some(tile) = animation_locking.iter().find_map(|(ms, tile)| {
590 | (elapsed < Duration::from_millis(*ms)).then_some(tile)
591 | }) else {
592 | *relevant = false;
593 | continue;
594 | };
595 | for (tile_pos, _tile_type_id) in piece.tiles() {
596 | if tile_pos.1 <= Game::SKYLINE {
597 | self.screen
598 | .buffer_str(tile, color_locking, pos_board(tile_pos));
599 | }
600 | }
601 | }
602 | Feedback::LineClears(lines_cleared, line_clear_delay) => {
603 | if line_clear_delay.is_zero() {
604 | *relevant = false;
605 | continue;
606 | }
607 | let animation_lineclear = match app.settings().graphics_style {
608 | GraphicsStyle::Electronika60 => [
609 | "▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮",
610 | " ▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮",
611 | " ▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮",
612 | " ▮▮▮▮▮▮▮▮▮▮▮▮▮▮",
613 | " ▮▮▮▮▮▮▮▮▮▮▮▮",
614 | " ▮▮▮▮▮▮▮▮▮▮",
615 | " ▮▮▮▮▮▮▮▮",
616 | " ▮▮▮▮▮▮",
617 | " ▮▮▮▮",
618 | " ▮▮",
619 | ],
620 | GraphicsStyle::ASCII => [
621 | "$$$$$$$$$$$$$$$$$$$$",
622 | "$$$$$$$$$$$$$$$$$$$$",
623 | " ",
624 | " ",
625 | "$$$$$$$$$$$$$$$$$$$$",
626 | "$$$$$$$$$$$$$$$$$$$$",
627 | " ",
628 | " ",
629 | "$$$$$$$$$$$$$$$$$$$$",
630 | "$$$$$$$$$$$$$$$$$$$$",
631 | ],
632 | GraphicsStyle::Unicode => [
633 | "████████████████████",
634 | " ██████████████████ ",
635 | " ████████████████ ",
636 | " ██████████████ ",
637 | " ████████████ ",
638 | " ██████████ ",
639 | " ████████ ",
640 | " ██████ ",
641 | " ████ ",
642 | " ██ ",
643 | ],
644 | };
645 | let color_lineclear = match app.settings().graphics_color {
646 | GraphicsColor::Monochrome => None,
647 | GraphicsColor::Color16
648 | | GraphicsColor::Fullcolor
649 | | GraphicsColor::Experimental => Some(Color::White),
650 | };
651 | let percent = elapsed.as_secs_f64() / line_clear_delay.as_secs_f64();
652 | // SAFETY: `0.0 <= percent && percent <= 1.0`.
653 | let idx = if percent < 1.0 {
654 | unsafe { (10.0 * percent).to_int_unchecked::() }
655 | } else {
656 | *relevant = false;
657 | continue;
658 | };
659 | for y_line in lines_cleared {
660 | let pos = (x_board, y_board + Game::SKYLINE - *y_line);
661 | self.screen
662 | .buffer_str(animation_lineclear[idx], color_lineclear, pos);
663 | }
664 | }
665 | Feedback::HardDrop(_top_piece, bottom_piece) => {
666 | for ((x_tile, y_tile), tile_type_id) in bottom_piece.tiles() {
667 | for y in y_tile..Game::SKYLINE {
668 | self.hard_drop_tiles.push((
669 | *event_time,
670 | (x_tile, y),
671 | y - y_tile,
672 | tile_type_id,
673 | true,
674 | ));
675 | }
676 | }
677 | *relevant = false;
678 | }
679 | Feedback::Accolade {
680 | score_bonus,
681 | shape,
682 | spin,
683 | lineclears,
684 | perfect_clear,
685 | combo,
686 | back_to_back,
687 | } => {
688 | running_game_stats.1.push(*score_bonus);
689 | let mut strs = Vec::new();
690 | strs.push(format!("+{score_bonus}"));
691 | if *perfect_clear {
692 | strs.push("Perfect".to_string());
693 | }
694 | if *spin {
695 | strs.push(format!("{shape:?}-Spin"));
696 | running_game_stats.0[0] += 1;
697 | }
698 | let clear_action = match lineclears {
699 | 1 => "Single",
700 | 2 => "Double",
701 | 3 => "Triple",
702 | 4 => "Quadruple",
703 | 5 => "Quintuple",
704 | 6 => "Sextuple",
705 | 7 => "Septuple",
706 | 8 => "Octuple",
707 | 9 => "Nonuple",
708 | 10 => "Decuple",
709 | 11 => "Undecuple",
710 | 12 => "Duodecuple",
711 | 13 => "Tredecuple",
712 | 14 => "Quattuordecuple",
713 | 15 => "Quindecuple",
714 | 16 => "Sexdecuple",
715 | 17 => "Septendecuple",
716 | 18 => "Octodecuple",
717 | 19 => "Novemdecuple",
718 | 20 => "Vigintuple",
719 | 21 => "Kirbtris",
720 | _ => "Unreachable",
721 | }
722 | .to_string();
723 | if *lineclears <= 4 {
724 | running_game_stats.0[usize::try_from(*lineclears).unwrap()] += 1;
725 | } else {
726 | // FIXME: Record higher lineclears, if even possible.
727 | }
728 | strs.push(clear_action);
729 | if *combo > 1 {
730 | strs.push(format!("({combo}.combo)"));
731 | }
732 | if *back_to_back > 1 {
733 | strs.push(format!("({back_to_back}.B2B)"));
734 | }
735 | self.messages.push((*event_time, strs.join(" ")));
736 | *relevant = false;
737 | }
738 | Feedback::Message(msg) => {
739 | self.messages.push((*event_time, msg.clone()));
740 | *relevant = false;
741 | }
742 | }
743 | }
744 | self.visual_events.retain(|elt| elt.2);
745 | // Draw messages.
746 | for (y, (_event_time, message)) in self.messages.iter().rev().enumerate() {
747 | let pos = (x_messages, y_messages + y);
748 | self.screen.buffer_str(message, None, pos);
749 | }
750 | self.messages.retain(|(timestamp, _message)| {
751 | game_time.saturating_sub(*timestamp) < Duration::from_millis(7000)
752 | });
753 | self.screen.flush(&mut app.term)
754 | }
755 | }
756 |
--------------------------------------------------------------------------------
/tetrs_tui/src/game_renderers/debug_renderer.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | collections::VecDeque,
3 | io::{self, Write},
4 | };
5 |
6 | use crossterm::{
7 | cursor::{self, MoveToNextLine},
8 | style::{self, Print},
9 | terminal, QueueableCommand,
10 | };
11 | use tetrs_engine::{Feedback, FeedbackEvents, Game, GameState, GameTime};
12 |
13 | use crate::{
14 | game_renderers::Renderer,
15 | terminal_app::{RunningGameStats, TerminalApp},
16 | };
17 |
18 | #[derive(Clone, Default, Debug)]
19 | pub struct DebugRenderer {
20 | feedback_event_buffer: VecDeque<(GameTime, Feedback)>,
21 | }
22 |
23 | impl Renderer for DebugRenderer {
24 | fn render(
25 | &mut self,
26 | app: &mut TerminalApp,
27 | _running_game_stats: &mut RunningGameStats,
28 | game: &Game,
29 | new_feedback_events: FeedbackEvents,
30 | _screen_resized: bool,
31 | ) -> io::Result<()>
32 | where
33 | T: Write,
34 | {
35 | // Draw game stuf
36 | let GameState {
37 | time: game_time,
38 | board,
39 | active_piece_data,
40 | ..
41 | } = game.state();
42 | let mut temp_board = board.clone();
43 | if let Some((active_piece, _)) = active_piece_data {
44 | for ((x, y), tile_type_id) in active_piece.tiles() {
45 | temp_board[y][x] = Some(tile_type_id);
46 | }
47 | }
48 | app.term
49 | .queue(cursor::MoveTo(0, 0))?
50 | .queue(terminal::Clear(terminal::ClearType::FromCursorDown))?;
51 | app.term
52 | .queue(Print(" +--------------------+"))?
53 | .queue(MoveToNextLine(1))?;
54 | for (idx, line) in temp_board.iter().take(20).enumerate().rev() {
55 | let txt_line = format!(
56 | "{idx:02} |{}|",
57 | line.iter()
58 | .map(|cell| {
59 | cell.map_or(" .", |tile| match tile.get() {
60 | 1 => "OO",
61 | 2 => "II",
62 | 3 => "SS",
63 | 4 => "ZZ",
64 | 5 => "TT",
65 | 6 => "LL",
66 | 7 => "JJ",
67 | 253 => "WW",
68 | 254 => "WW",
69 | 255 => "WW",
70 | t => unimplemented!("formatting unknown tile id {t}"),
71 | })
72 | })
73 | .collect::>()
74 | .join("")
75 | );
76 | app.term.queue(Print(txt_line))?.queue(MoveToNextLine(1))?;
77 | }
78 | app.term
79 | .queue(Print(" +--------------------+"))?
80 | .queue(MoveToNextLine(1))?;
81 | app.term
82 | .queue(style::Print(format!(" {:?}", game_time)))?
83 | .queue(MoveToNextLine(1))?;
84 | // Draw feedback stuf
85 | for evt in new_feedback_events {
86 | self.feedback_event_buffer.push_front(evt);
87 | }
88 | let mut feed_evt_msgs = Vec::new();
89 | for (_, feedback_event) in self.feedback_event_buffer.iter() {
90 | feed_evt_msgs.push(match feedback_event {
91 | Feedback::Accolade {
92 | score_bonus,
93 | shape,
94 | spin,
95 | lineclears,
96 | perfect_clear,
97 | combo,
98 | back_to_back,
99 | } => {
100 | let mut strs = Vec::new();
101 | if *spin {
102 | strs.push(format!("{shape:?}-Spin"));
103 | }
104 | let clear_action = match lineclears {
105 | 1 => "Single",
106 | 2 => "Double",
107 | 3 => "Triple",
108 | 4 => "Quadruple",
109 | x => unreachable!("unexpected line clear count {x}"),
110 | }
111 | .to_string();
112 | if *back_to_back > 1 {
113 | strs.push(format!("{back_to_back}-B2B"));
114 | }
115 | strs.push(clear_action);
116 | if *combo > 1 {
117 | strs.push(format!("[{combo}.combo]"));
118 | }
119 | if *perfect_clear {
120 | strs.push("PERFECT!".to_string());
121 | }
122 | strs.push(format!("+{score_bonus}"));
123 | strs.join(" ")
124 | }
125 | Feedback::PieceSpawned(_) => continue,
126 | Feedback::PieceLocked(_) => continue,
127 | Feedback::LineClears(..) => continue,
128 | Feedback::HardDrop(_, _) => continue,
129 | Feedback::Message(s) => s.clone(),
130 | });
131 | }
132 | for str in feed_evt_msgs.iter().take(16) {
133 | app.term.queue(Print(str))?.queue(MoveToNextLine(1))?;
134 | }
135 | // Execute draw.
136 | app.term.flush()?;
137 | Ok(())
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/tetrs_tui/src/game_renderers/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod cached_renderer;
2 | pub mod debug_renderer;
3 |
4 | use std::io::{self, Write};
5 |
6 | use crossterm::style::Color;
7 | use tetrs_engine::{FeedbackEvents, Game, Tetromino, TileTypeID};
8 |
9 | use crate::terminal_app::{GraphicsColor, RunningGameStats, TerminalApp};
10 |
11 | pub trait Renderer {
12 | fn render(
13 | &mut self,
14 | app: &mut TerminalApp,
15 | running_game_stats: &mut RunningGameStats,
16 | game: &Game,
17 | new_feedback_events: FeedbackEvents,
18 | screen_resized: bool,
19 | ) -> io::Result<()>
20 | where
21 | T: Write;
22 | }
23 |
24 | pub fn tet_str_small(t: &Tetromino) -> &'static str {
25 | match t {
26 | Tetromino::O => "██",
27 | Tetromino::I => "▄▄▄▄",
28 | Tetromino::S => "▄█▀",
29 | Tetromino::Z => "▀█▄",
30 | Tetromino::T => "▄█▄",
31 | Tetromino::L => "▄▄█",
32 | Tetromino::J => "█▄▄",
33 | }
34 | }
35 |
36 | pub fn tet_str_minuscule(t: &Tetromino) -> &'static str {
37 | match t {
38 | Tetromino::O => "⠶", //"⠶",
39 | Tetromino::I => "⡇", //"⠤⠤",
40 | Tetromino::S => "⠳", //"⠴⠂",
41 | Tetromino::Z => "⠞", //"⠲⠄",
42 | Tetromino::T => "⠗", //"⠴⠄",
43 | Tetromino::L => "⠧", //"⠤⠆",
44 | Tetromino::J => "⠼", //"⠦⠄",
45 | }
46 | }
47 |
48 | pub fn tile_to_color(mode: GraphicsColor) -> fn(TileTypeID) -> Option {
49 | match mode {
50 | GraphicsColor::Monochrome => |_tile: TileTypeID| None,
51 | GraphicsColor::Color16 => |tile: TileTypeID| {
52 | Some(match tile.get() {
53 | 1 => Color::Yellow,
54 | 2 => Color::DarkCyan,
55 | 3 => Color::Green,
56 | 4 => Color::DarkRed,
57 | 5 => Color::DarkMagenta,
58 | 6 => Color::Red,
59 | 7 => Color::Blue,
60 | 253 => Color::Black,
61 | 254 => Color::DarkGrey,
62 | 255 => Color::White,
63 | t => unimplemented!("formatting unknown tile id {t}"),
64 | })
65 | },
66 | GraphicsColor::Fullcolor => |tile: TileTypeID| {
67 | Some(match tile.get() {
68 | 1 => Color::Rgb {
69 | r: 254,
70 | g: 203,
71 | b: 0,
72 | },
73 | 2 => Color::Rgb {
74 | r: 0,
75 | g: 159,
76 | b: 218,
77 | },
78 | 3 => Color::Rgb {
79 | r: 105,
80 | g: 190,
81 | b: 40,
82 | },
83 | 4 => Color::Rgb {
84 | r: 237,
85 | g: 41,
86 | b: 57,
87 | },
88 | 5 => Color::Rgb {
89 | r: 149,
90 | g: 45,
91 | b: 152,
92 | },
93 | 6 => Color::Rgb {
94 | r: 255,
95 | g: 121,
96 | b: 0,
97 | },
98 | 7 => Color::Rgb {
99 | r: 0,
100 | g: 101,
101 | b: 189,
102 | },
103 | 253 => Color::Rgb { r: 0, g: 0, b: 0 },
104 | 254 => Color::Rgb {
105 | r: 127,
106 | g: 127,
107 | b: 127,
108 | },
109 | 255 => Color::Rgb {
110 | r: 255,
111 | g: 255,
112 | b: 255,
113 | },
114 | t => unimplemented!("formatting unknown tile id {t}"),
115 | })
116 | },
117 | GraphicsColor::Experimental => |tile: TileTypeID| {
118 | Some(match tile.get() {
119 | 1 => Color::Rgb {
120 | r: 14,
121 | g: 198,
122 | b: 244,
123 | },
124 | 2 => Color::Rgb {
125 | r: 242,
126 | g: 192,
127 | b: 29,
128 | },
129 | 3 => Color::Rgb {
130 | r: 70,
131 | g: 201,
132 | b: 50,
133 | },
134 | 4 => Color::Rgb {
135 | r: 230,
136 | g: 53,
137 | b: 197,
138 | },
139 | 5 => Color::Rgb {
140 | r: 147,
141 | g: 41,
142 | b: 229,
143 | },
144 | 6 => Color::Rgb {
145 | r: 36,
146 | g: 118,
147 | b: 242,
148 | },
149 | 7 => Color::Rgb {
150 | r: 244,
151 | g: 50,
152 | b: 48,
153 | },
154 | 253 => Color::Rgb { r: 0, g: 0, b: 0 },
155 | 254 => Color::Rgb {
156 | r: 127,
157 | g: 127,
158 | b: 127,
159 | },
160 | 255 => Color::Rgb {
161 | r: 255,
162 | g: 255,
163 | b: 255,
164 | },
165 | t => unimplemented!("formatting unknown tile id {t}"),
166 | })
167 | },
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/tetrs_tui/src/main.rs:
--------------------------------------------------------------------------------
1 | mod game_input_handlers;
2 | mod game_mods;
3 | mod game_renderers;
4 | mod terminal_app;
5 |
6 | use std::io::{self, Write};
7 |
8 | use clap::Parser;
9 |
10 | #[derive(Parser, Debug)]
11 | #[command(version, about, long_about = None)]
12 | struct Args {
13 | /// Custom starting seed when playing Custom mode, given as a 64-bit integer.
14 | /// This influences the sequence of pieces used and makes it possible to replay
15 | /// a run with the same pieces if the same seed is entered.
16 | /// Example: `./tetrs_tui --custom-seed=42` or `./tetrs_tui -c 42`.
17 | #[arg(short, long)]
18 | custom_seed: Option,
19 | /// Custom starting board when playing Custom mode (10-wide rows), encoded as string.
20 | /// Spaces indicate empty cells, anything else is a filled cell.
21 | /// The string just represents the row information, starting with the topmost row.
22 | /// Example: '█▀ ▄██▀ ▀█'
23 | /// => `./tetrs_tui --custom-start="XX XXX XXO OOO O"`.
24 | #[arg(long)]
25 | custom_start: Option,
26 | /// Custom starting layout when playing Combo mode (4-wide rows), encoded as binary.
27 | /// Example: '▀▄▄▀' => 0b_1001_0110 = 150
28 | /// => `./tetrs_tui --combo-start=150`.
29 | #[arg(long)]
30 | combo_start: Option,
31 | /// Whether to enable the combo bot in Combo mode: `./tetrs_tui --enable-combo-bot` or `./tetrs_tui -e`
32 | #[arg(short, long)]
33 | enable_combo_bot: bool,
34 | }
35 |
36 | fn main() -> Result<(), Box> {
37 | let args = Args::parse();
38 | let stdout = io::BufWriter::new(io::stdout());
39 | let mut app = terminal_app::TerminalApp::new(
40 | stdout,
41 | args.custom_seed,
42 | args.custom_start,
43 | args.combo_start,
44 | args.enable_combo_bot,
45 | );
46 | std::panic::set_hook(Box::new(|panic_info| {
47 | if let Ok(mut file) = std::fs::File::create("tetrs_tui_error_message.txt") {
48 | let _ = file.write(panic_info.to_string().as_bytes());
49 | // let _ = file.write(std::backtrace::Backtrace::force_capture().to_string().as_bytes());
50 | }
51 | }));
52 | let msg = app.run()?;
53 | println!("{msg}");
54 | Ok(())
55 | }
56 |
--------------------------------------------------------------------------------