├── .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 | 2 | 3 | 4 | 5 | 6 | 0.0▖▞IJOLT 7 | 8 | 0.0▖▞IJOLT 9 | 10 | 11 | 12 | 0.0▖▞J(-I)JOLT 13 | 14 | 0.0▖▞J(-I)JOLT 15 | 16 | 17 | 18 | 0.0▖▞IJOLT->0.0▖▞J(-I)JOLT 19 | 20 | 21 | 22 | 23 | 24 | 1.1▖▞JOLT 25 | 26 | 1.1▖▞JOLT 27 | 28 | 29 | 30 | 0.0▖▞IJOLT->1.1▖▞JOLT 31 | 32 | 33 | 34 | 35 | 36 | 0.1▌▖J(I)OLT 37 | 38 | 0.1▌▖J(I)OLT 39 | 40 | 41 | 42 | 0.0▖▞J(-I)JOLT->0.1▌▖J(I)OLT 43 | 44 | 45 | 46 | 47 | 48 | 1.1▖▞O(-J)OLT 49 | 50 | 1.1▖▞O(-J)OLT 51 | 52 | 53 | 54 | 1.1▖▞JOLT->1.1▖▞O(-J)OLT 55 | 56 | 57 | 58 | 59 | 60 | 1.2▌▖OLT 61 | 62 | 1.2▌▖OLT 63 | 64 | 65 | 66 | 1.1▖▞JOLT->1.2▌▖OLT 67 | 68 | 69 | 70 | 71 | 72 | 0.1▌▖I(-J)OLT 73 | 74 | 0.1▌▖I(-J)OLT 75 | 76 | 77 | 78 | 0.1▌▖J(I)OLT->0.1▌▖I(-J)OLT 79 | 80 | 81 | 82 | 83 | 84 | 0.2▖▄O(I)LT 85 | 86 | 0.2▖▄O(I)LT 87 | 88 | 89 | 90 | 0.1▌▖J(I)OLT->0.2▖▄O(I)LT 91 | 92 | 93 | 94 | 95 | 96 | 1.2▌▖L(-O)LT 97 | 98 | 1.2▌▖L(-O)LT 99 | 100 | 101 | 102 | 1.2▌▖OLT->1.2▌▖L(-O)LT 103 | 104 | 105 | 106 | 107 | 108 | 0.2▌▖O(J)LT 109 | 110 | 0.2▌▖O(J)LT 111 | 112 | 113 | 114 | 0.1▌▖I(-J)OLT->0.2▌▖O(J)LT 115 | 116 | 117 | 118 | 119 | 120 | 0.2▖▄I(-O)LT 121 | 122 | 0.2▖▄I(-O)LT 123 | 124 | 125 | 126 | 0.2▖▄O(I)LT->0.2▖▄I(-O)LT 127 | 128 | 129 | 130 | 131 | 132 | 1.3▄▖L(O)T 133 | 134 | 1.3▄▖L(O)T 135 | 136 | 137 | 138 | 1.2▌▖L(-O)LT->1.3▄▖L(O)T 139 | 140 | 141 | 142 | 143 | 144 | 1.3▖▞L(O)T 145 | 146 | 1.3▖▞L(O)T 147 | 148 | 149 | 150 | 1.2▌▖L(-O)LT->1.3▖▞L(O)T 151 | 152 | 153 | 154 | 155 | 156 | 0.2▌▖J(-O)LT 157 | 158 | 0.2▌▖J(-O)LT 159 | 160 | 161 | 162 | 0.2▌▖O(J)LT->0.2▌▖J(-O)LT 163 | 164 | 165 | 166 | 167 | 168 | 0.3▖▄L(O)T 169 | 170 | 0.3▖▄L(O)T 171 | 172 | 173 | 174 | 0.2▖▄I(-O)LT->0.3▖▄L(O)T 175 | 176 | 177 | 178 | 179 | 180 | 1.3▄▖O(-L)T 181 | 182 | 1.3▄▖O(-L)T 183 | 184 | 185 | 186 | 1.3▄▖L(O)T->1.3▄▖O(-L)T 187 | 188 | 189 | 190 | 191 | 192 | 1.4 ▜T(O) 193 | 194 | 1.4 ▜T(O) 195 | 196 | 197 | 198 | 1.3▄▖L(O)T->1.4 ▜T(O) 199 | 200 | 201 | 202 | 203 | 204 | 1.3▖▞O(-L)T 205 | 206 | 1.3▖▞O(-L)T 207 | 208 | 209 | 210 | 1.3▖▞L(O)T->1.3▖▞O(-L)T 211 | 212 | 213 | 214 | 215 | 216 | 0.2▌▖J(-O)LT->0.3▖▄L(O)T 217 | 218 | 219 | 220 | 221 | 222 | 0.3▖▄O(-L)T 223 | 224 | 0.3▖▄O(-L)T 225 | 226 | 227 | 228 | 0.3▖▄L(O)T->0.3▖▄O(-L)T 229 | 230 | 231 | 232 | 233 | 234 | 0.4▗▄T(O) 235 | 236 | 0.4▗▄T(O) 237 | 238 | 239 | 240 | 0.3▖▄L(O)T->0.4▗▄T(O) 241 | 242 | 243 | 244 | 245 | 246 | 0.4▜ T(O) 247 | 248 | 0.4▜ T(O) 249 | 250 | 251 | 252 | 0.3▖▄L(O)T->0.4▜ T(O) 253 | 254 | 255 | 256 | 257 | 258 | 1.4 ▜O(-T) 259 | 260 | 1.4 ▜O(-T) 261 | 262 | 263 | 264 | 1.4 ▜T(O)->1.4 ▜O(-T) 265 | 266 | 267 | 268 | 269 | 270 | 1.5▌▗(O) 271 | 272 | 1.5▌▗(O) 273 | 274 | 275 | 276 | 1.4 ▜T(O)->1.5▌▗(O) 277 | 278 | 279 | 280 | 281 | 282 | 1.5▗▄(O) 283 | 284 | 1.5▗▄(O) 285 | 286 | 287 | 288 | 1.4 ▜T(O)->1.5▗▄(O) 289 | 290 | 291 | 292 | 293 | 294 | 0.4▗▄O(-T) 295 | 296 | 0.4▗▄O(-T) 297 | 298 | 299 | 300 | 0.4▗▄T(O)->0.4▗▄O(-T) 301 | 302 | 303 | 304 | 305 | 306 | 0.5▙ (O) 307 | 308 | 0.5▙ (O) 309 | 310 | 311 | 312 | 0.4▗▄T(O)->0.5▙ (O) 313 | 314 | 315 | 316 | 317 | 318 | 0.4▜ O(-T) 319 | 320 | 0.4▜ O(-T) 321 | 322 | 323 | 324 | 0.4▜ T(O)->0.4▜ O(-T) 325 | 326 | 327 | 328 | 329 | 330 | 0.5▗▐(O) 331 | 332 | 0.5▗▐(O) 333 | 334 | 335 | 336 | 0.4▜ T(O)->0.5▗▐(O) 337 | 338 | 339 | 340 | 341 | 342 | 1.5▄▗(T) 343 | 344 | 1.5▄▗(T) 345 | 346 | 347 | 348 | 1.4 ▜O(-T)->1.5▄▗(T) 349 | 350 | 351 | 352 | 353 | 354 | 0.5▗▄(T) 355 | 356 | 0.5▗▄(T) 357 | 358 | 359 | 360 | 0.4▜ O(-T)->0.5▗▄(T) 361 | 362 | 363 | 364 | 365 | -------------------------------------------------------------------------------- /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 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | Tetrs Combo (4-wide 3-res.) - Bot run statistics. 62 | samples = 1000, randomizer = 'recency', lookahead = 0; combo_median = 9, combo_average = 12, combo_max = 82, frequency_max = 95. 63 | 64 | 65 | 0 66 | 5 67 | 10 68 | 15 69 | 20 70 | 25 71 | 30 72 | 35 73 | 40 74 | 45 75 | 50 76 | 55 77 | 60 78 | 65 79 | 70 80 | 75 81 | 80 82 | 85 83 | 90 84 | 95 85 | 100 86 | Frequency 87 | Average 88 | Median 89 | 90 | 91 | 92 | 0 93 | 5 94 | 10 95 | 15 96 | 20 97 | 25 98 | 30 99 | 35 100 | 40 101 | 45 102 | 50 103 | 55 104 | 60 105 | 65 106 | 70 107 | 75 108 | 80 109 | 85 110 | Combo Length 111 | 112 | 113 | 114 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 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 | 2 | 3 | 4 | 5 | 6 | Title 7 | 8 | Title 9 | 10 | 11 | 12 | NewGame 13 | 14 | NewGame 15 | 16 | 17 | 18 | Title->NewGame 19 | 20 | 21 | 22 | 23 | 24 | Settings 25 | 26 | Settings 27 | 28 | 29 | 30 | Title->Settings 31 | 32 | 33 | 34 | 35 | 36 | Scoreboard 37 | 38 | Scoreboard 39 | 40 | 41 | 42 | Title->Scoreboard 43 | 44 | 45 | 46 | 47 | 48 | About 49 | 50 | About 51 | 52 | 53 | 54 | Title->About 55 | 56 | 57 | 58 | 59 | 60 | Quit 61 | 62 | Quit 63 | 64 | 65 | 66 | Title->Quit 67 | 68 | 69 | 70 | 71 | 72 | Game 73 | 74 | Game 75 | 76 | 77 | 78 | NewGame->Game 79 | 80 | 81 | 82 | 83 | 84 | GameOver 85 | 86 | GameOver 87 | 88 | 89 | 90 | Game->GameOver 91 | 92 | 93 | 94 | 95 | 96 | GameComplete 97 | 98 | GameComplete 99 | 100 | 101 | 102 | Game->GameComplete 103 | 104 | 105 | 106 | 107 | 108 | Pause 109 | 110 | Pause 111 | 112 | 113 | 114 | Game->Pause 115 | 116 | 117 | 118 | 119 | 120 | GameOver->NewGame 121 | 122 | 123 | 124 | 125 | 126 | GameOver->Settings 127 | 128 | 129 | 130 | 131 | 132 | GameOver->Scoreboard 133 | 134 | 135 | 136 | 137 | 138 | GameOver->Quit 139 | 140 | 141 | 142 | 143 | 144 | GameComplete->NewGame 145 | 146 | 147 | 148 | 149 | 150 | GameComplete->Settings 151 | 152 | 153 | 154 | 155 | 156 | GameComplete->Scoreboard 157 | 158 | 159 | 160 | 161 | 162 | GameComplete->Quit 163 | 164 | 165 | 166 | 167 | 168 | Pause->NewGame 169 | 170 | 171 | 172 | 173 | 174 | Pause->Settings 175 | 176 | 177 | 178 | 179 | 180 | Pause->Scoreboard 181 | 182 | 183 | 184 | 185 | 186 | Pause->About 187 | 188 | 189 | 190 | 191 | 192 | Pause->Quit 193 | 194 | 195 | 196 | 197 | 198 | ChangeControls 199 | 200 | ChangeControls 201 | 202 | 203 | 204 | Settings->ChangeControls 205 | 206 | 207 | 208 | 209 | 210 | ConfigureGame 211 | 212 | ConfigureGame 213 | 214 | 215 | 216 | Settings->ConfigureGame 217 | 218 | 219 | 220 | 221 | 222 | 223 | ENTRY->Title 224 | 225 | 226 | 227 | 228 | -------------------------------------------------------------------------------- /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##" 820 | 821 | "##).as_bytes()).unwrap(); 822 | 823 | file.write(format!( 824 | r##" 825 | 826 | "##).as_bytes()).unwrap(); 827 | 828 | file.write(format!( 829 | r##" 830 | 831 | "##).as_bytes()).unwrap(); 832 | 833 | file.write(format!( 834 | r##" 835 | 836 | "##).as_bytes()).unwrap(); 837 | 838 | for i in 0 ..= chart_max_y/gridgranularity_y { 839 | let y = y_0 - scale_y *(i * gridgranularity_y); 840 | file.write(format!( 841 | r##" 842 | "##, x_0, y, x_0 + scale_x *chart_max_x, y).as_bytes()).unwrap(); 843 | } 844 | 845 | file.write(format!( 846 | r##" 847 | "##).as_bytes()).unwrap(); // 848 | 849 | file.write(format!( 850 | r##" 851 | 852 | "##).as_bytes()).unwrap(); 853 | 854 | for j in 0 ..= chart_max_x/gridgranularity_x { 855 | let x = x_0 + scale_x *(j * gridgranularity_x); 856 | file.write(format!( 857 | r##" 858 | "##, x, y_0, x, y_0 - scale_y *chart_max_y).as_bytes()).unwrap(); 859 | } 860 | 861 | // Combo average indicator. 862 | file.write(format!( 863 | r##" 864 | "##, x_0 + scale_x *combo_average, y_0, x_0 + scale_x *combo_average, y_0 - scale_y *chart_max_y).as_bytes()).unwrap(); 865 | 866 | // Combo median indicator. 867 | file.write(format!( 868 | r##" 869 | "##, x_0 + scale_x *combo_median, y_0, x_0 + scale_x *combo_median, y_0 - scale_y *chart_max_y).as_bytes()).unwrap(); 870 | 871 | file.write(format!( 872 | r##" 873 | "##).as_bytes()).unwrap(); // 874 | 875 | file.write(format!( 876 | r##" 877 | "##).as_bytes()).unwrap(); // 878 | 879 | file.write(format!( 880 | r##" 881 | 882 | "##, font_size).as_bytes()).unwrap(); 883 | 884 | file.write(format!( 885 | r##" Tetrs Combo (4-wide 3-res.) - Bot run statistics. 886 | "##, x_0, y_0 + font_size * 3 + font_size / 2, font_size * 5 / 4).as_bytes()).unwrap(); 887 | 888 | file.write(format!( 889 | r##" {summary}. 890 | "##, x_0, y_0 + font_size * 5).as_bytes()).unwrap(); 891 | 892 | file.write(format!( 893 | r##" 894 | 895 | "##).as_bytes()).unwrap(); 896 | 897 | for i in 0 ..= chart_max_y/gridgranularity_y { 898 | let y = y_0 - scale_y *(i * gridgranularity_y) + font_size / 2; 899 | file.write(format!( 900 | r##" {} 901 | "##, x_0 - font_size / 2, y, i*gridgranularity_y).as_bytes()).unwrap(); 902 | } 903 | 904 | file.write(format!( 905 | r##" Frequency 906 | "##, x_0, margin_y - font_size).as_bytes()).unwrap(); 907 | 908 | file.write(format!( 909 | r##" Average 910 | "##, x_0 + scale_x* combo_average, margin_y - font_size).as_bytes()).unwrap(); 911 | 912 | file.write(format!( 913 | r##" Median 914 | "##, x_0 + scale_x* combo_median, margin_y - font_size).as_bytes()).unwrap(); 915 | 916 | file.write(format!( 917 | r##" 918 | "##).as_bytes()).unwrap(); // 919 | 920 | file.write(format!( 921 | r##" 922 | 923 | "##).as_bytes()).unwrap(); 924 | 925 | for i in 0 ..= chart_max_x/gridgranularity_x { 926 | let x = x_0 + scale_x *(i * gridgranularity_x); 927 | file.write(format!( 928 | r##" {} 929 | "##, x - font_size / 2, y_0 + font_size * 3 / 2, i*gridgranularity_x).as_bytes()).unwrap(); 930 | } 931 | 932 | file.write(format!( 933 | r##" Combo Length 934 | "##, x_0 + scale_x* chart_max_x + font_size, y_0 + font_size / 2).as_bytes()).unwrap(); 935 | 936 | file.write(format!( 937 | r##" 938 | "##).as_bytes()).unwrap(); // */ 939 | 940 | file.write(format!( 941 | r##" 942 | "##).as_bytes()).unwrap(); // 943 | 944 | file.write(format!( 945 | r##" 946 | "##, x_0 + scale_x *combo_max, y_0).as_bytes()).unwrap(); // 966 | 967 | file.write(format!( 968 | r##" 969 | 970 | "##).as_bytes()).unwrap(); 971 | 972 | for (combo, frequency) in frequencies.iter() { 973 | file.write(format!( 974 | r##" 975 | "##, x_0 + scale_x *combo, y_0 - scale_y *frequency, font_size / 5).as_bytes()).unwrap(); 976 | } 977 | 978 | file.write(format!( 979 | r##" 980 | "##).as_bytes()).unwrap(); // 981 | 982 | file.write(format!( 983 | 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 | --------------------------------------------------------------------------------