|
| 1 | +#import "@preview/suiji:0.4.0": gen-rng, choice, shuffle |
| 2 | +#import "mino/tetris.typ": render-field |
| 3 | + |
| 4 | +#let parse-actions(body) = { |
| 5 | + let extract(it) = { |
| 6 | + "" |
| 7 | + if it == [ ] { |
| 8 | + " " |
| 9 | + } else if it.func() == text { |
| 10 | + it.text |
| 11 | + } else if it.func() == [].func() { |
| 12 | + it.children.map(extract).join() |
| 13 | + } |
| 14 | + } |
| 15 | + extract(body).clusters().map(lower) |
| 16 | +} |
| 17 | + |
| 18 | +#let minoes = (("ZZ_", "_ZZ"), ("OO", "OO"), ("_SS", "SS_"), ("IIII",), ("__L", "LLL"), ("J__", "JJJ"), ("_T_", "TTT")) |
| 19 | + |
| 20 | +#let shuffle-minoes(rng) = { |
| 21 | + let indices = range(minoes.len()) |
| 22 | + let (rng-after-shuffle, shuffled) = shuffle(rng, indices) |
| 23 | + (rng-after-shuffle, shuffled.map(i => minoes.at(i))) |
| 24 | +} |
| 25 | + |
| 26 | +#let create-mino(mino, cols, rows) = { |
| 27 | + let width = calc.max(..mino.map(it => it.len())) |
| 28 | + ( |
| 29 | + mino: mino, |
| 30 | + pos: (x: calc.floor(cols / 2) - calc.floor(width / 2) - 1, y: rows + 1), |
| 31 | + height: mino.len(), |
| 32 | + width: width, |
| 33 | + index: minoes.position((value)=>{value==mino}) |
| 34 | + ) |
| 35 | +} |
| 36 | + |
| 37 | +#let new-mino(rng, bag, cols, rows) = { |
| 38 | + let mino-bag = bag |
| 39 | + let rng-before-draw = rng |
| 40 | + let mino = none |
| 41 | + let bag-before-draw = mino-bag |
| 42 | + let rng-after-bag = rng-before-draw |
| 43 | + let bag-after-bag = bag-before-draw |
| 44 | + if mino-bag.len() == 0 { |
| 45 | + let (rng-after-shuffle, new-bag) = shuffle-minoes(rng-before-draw) |
| 46 | + bag-after-bag = new-bag |
| 47 | + rng-after-bag = rng-after-shuffle |
| 48 | + } |
| 49 | + mino = bag-after-bag.at(0) |
| 50 | + let bag-after-draw = bag-after-bag.slice(1, bag-after-bag.len()) |
| 51 | + (rng-after-bag, bag-after-draw, create-mino(mino, cols, rows)) |
| 52 | +} |
| 53 | + |
| 54 | +#let render-map(map, bg-color: rgb("#f3f3ed")) = { |
| 55 | + let map = map.map(it => it.join("")) |
| 56 | + render-field(map, rows: map.len(), cols: calc.max(..map.map(it => it.len())), bg-color: bg-color, radius: 0pt) |
| 57 | +} |
| 58 | + |
| 59 | +#let check-collision(state, cols: 10, rows: 20) = { |
| 60 | + if state.current.pos.x < 0 or state.current.pos.y - state.current.height + 1 < 0 or state.current.pos.x + state.current.width > cols { |
| 61 | + return true |
| 62 | + } |
| 63 | + |
| 64 | + for y in range(state.current.mino.len()) { |
| 65 | + for x in range(state.current.mino.at(y).len()) { |
| 66 | + if state.current.mino.at(y).at(x) != "_" and state.map.at(state.current.pos.y - y).at(state.current.pos.x + x) != "_" { |
| 67 | + return true |
| 68 | + } |
| 69 | + } |
| 70 | + } |
| 71 | + false |
| 72 | +} |
| 73 | + |
| 74 | +#let try-move(state, cols: 10, rows: 20, dx: 0, dy: 0) = { |
| 75 | + state.current.pos = (x: state.current.pos.x + dx, y: state.current.pos.y + dy) |
| 76 | + not check-collision(state, cols: cols, rows: rows) |
| 77 | +} |
| 78 | + |
| 79 | +#let rotate-clockwise(mino) = { |
| 80 | + let center = (x: mino.pos.x + calc.floor(mino.width / 2), y: mino.pos.y - calc.floor(mino.height / 2)) |
| 81 | + |
| 82 | + let new-mino = range(mino.width).map(_ => "") |
| 83 | + for y in range(mino.mino.len() - 1, -1, step: -1) { |
| 84 | + for x in range(mino.mino.at(y).len()) { |
| 85 | + new-mino.at(x) += mino.mino.at(y).at(x) |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + ( |
| 90 | + mino: new-mino, |
| 91 | + pos: (x: center.x - calc.floor(mino.height / 2), y: center.y + calc.floor(mino.width / 2)), |
| 92 | + height: mino.width, |
| 93 | + width: mino.height, |
| 94 | + index: mino.index, |
| 95 | + ) |
| 96 | +} |
| 97 | + |
| 98 | +#let rotate(state, cols: 10, rows: 20, angle: 0) = { |
| 99 | + let next-state = state |
| 100 | + for _ in range(angle) { |
| 101 | + next-state.current = rotate-clockwise(next-state.current) |
| 102 | + } |
| 103 | + if check-collision(next-state, cols: cols, rows: rows) { |
| 104 | + state |
| 105 | + } else { |
| 106 | + next-state |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +#let move(state, cols: 10, rows: 20, dx: 0, dy: 0) = { |
| 111 | + if try-move(state, cols: cols, rows: rows, dx: dx, dy: dy) { |
| 112 | + state.current.pos.x += dx |
| 113 | + state.current.pos.y += dy |
| 114 | + (state, true) |
| 115 | + } else { |
| 116 | + (state, false) |
| 117 | + } |
| 118 | +} |
| 119 | + |
| 120 | +#let hold(state, cols: 10, rows: 20) = { |
| 121 | + if state.can-hold == false { |
| 122 | + return state |
| 123 | + } |
| 124 | + state.can-hold = false |
| 125 | + if state.hold == none { |
| 126 | + state.hold = create-mino(minoes.at(state.current.index), cols, rows) |
| 127 | + state.current = state.next |
| 128 | + (state.rng, state.mino-bag, state.next) = new-mino(state.rng, state.mino-bag, cols, rows) |
| 129 | + } else { |
| 130 | + let hold-mino = state.hold |
| 131 | + state.hold = create-mino(minoes.at(state.current.index), cols, rows) |
| 132 | + state.current = hold-mino |
| 133 | + } |
| 134 | + return state |
| 135 | +} |
| 136 | + |
| 137 | +#let render(state, cols: 10, rows: 20) = { |
| 138 | + let map = state.map |
| 139 | + |
| 140 | + if not state.end { |
| 141 | + let pos = state.current.pos |
| 142 | + |
| 143 | + while true { |
| 144 | + if try-move(state, cols: cols, rows: rows, dy: -1) { |
| 145 | + state.current.pos.y -= 1 |
| 146 | + } else { |
| 147 | + break |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + for y in range(state.current.mino.len()) { |
| 152 | + for x in range(state.current.mino.at(y).len()) { |
| 153 | + if state.current.mino.at(y).at(x) != "_" { |
| 154 | + map.at(state.current.pos.y - y).at(state.current.pos.x + x) = lower(state.current.mino.at(y).at(x)) |
| 155 | + } |
| 156 | + } |
| 157 | + } |
| 158 | + |
| 159 | + for y in range(state.current.mino.len()) { |
| 160 | + for x in range(state.current.mino.at(y).len()) { |
| 161 | + if state.current.mino.at(y).at(x) != "_" { |
| 162 | + map.at(pos.y - y).at(pos.x + x) = state.current.mino.at(y).at(x) |
| 163 | + } |
| 164 | + } |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + let main = state.map.slice(0, state.map.len() - 2) |
| 169 | + |
| 170 | + grid(columns: 2, gutter: 5pt, block(height: rows * 10pt, width: cols * 10pt, { |
| 171 | + place( |
| 172 | + top + left, |
| 173 | + dy: 40pt, |
| 174 | + dx: 2pt, |
| 175 | + block(stroke: luma(80%) + 0.5pt, radius: 2pt, inset: 0pt, fill: tiling(size: (10pt, 10pt))[ |
| 176 | + #box(stroke: 0.1pt + luma(50%), width: 100%, height: 100%, fill: rgb("#f3f3ed")), |
| 177 | + ], height: rows * 10pt, width: cols * 10pt), |
| 178 | + ) |
| 179 | + place(top + left, render-map(map, bg-color: white.transparentize(100%))) |
| 180 | + }), pad(top: 40pt, [ |
| 181 | + #set block(spacing: 3pt) |
| 182 | + #block(height: 6em, width: 6em, stroke: luma(80%) + 0.5pt, radius: 2pt, [ |
| 183 | + #set block(spacing: 0pt) |
| 184 | + #if state.end [ |
| 185 | + #align(center + horizon)[ |
| 186 | + *Game Over* |
| 187 | + ] |
| 188 | + ] else [ |
| 189 | + #pad(top: 2pt, left: 3pt, bottom: 0pt, [*Next*]) |
| 190 | + #align(center + horizon)[ |
| 191 | + #render-map(state.next.mino.map(it => it.split("")).rev(), bg-color: white.transparentize(100%)) |
| 192 | + ] |
| 193 | + ] |
| 194 | + ]) |
| 195 | + #block(height: 4em, width: 6em, stroke: luma(80%) + 0.5pt, radius: 2pt, [ |
| 196 | + #set block(spacing: 0pt) |
| 197 | + #pad(top: 2pt, left: 3pt, bottom: 0pt, [*Hold*]) |
| 198 | + #if state.hold != none [ |
| 199 | + #align(center + horizon)[ |
| 200 | + #render-map(state.hold.mino.map(it => it.split("")).rev(), bg-color: white.transparentize(100%)) |
| 201 | + ] |
| 202 | + ] else [ |
| 203 | + #align(center + horizon)[ |
| 204 | + None |
| 205 | + ] |
| 206 | + ] |
| 207 | + ]) |
| 208 | + #block(height: 4em, width: 6em, stroke: luma(80%) + 0.5pt, radius: 2pt, [ |
| 209 | + #set block(spacing: 0pt) |
| 210 | + #pad(top: 2pt, left: 3pt, bottom: 0pt, [*Score*]) |
| 211 | + #align(center + horizon)[ |
| 212 | + #state.score |
| 213 | + ] |
| 214 | + ]) |
| 215 | + ])) |
| 216 | +} |
| 217 | + |
| 218 | +#let next-tick(state, cols: 10, rows: 10) = { |
| 219 | + if try-move(state, dy: -1, cols: cols, rows: rows) { |
| 220 | + state.current.pos.y -= 1 |
| 221 | + } else { |
| 222 | + for y in range(state.current.mino.len()) { |
| 223 | + for x in range(state.current.mino.at(y).len()) { |
| 224 | + if state.current.mino.at(y).at(x) != "_" { |
| 225 | + state.map.at(state.current.pos.y - y).at(state.current.pos.x + x) = state.current.mino.at(y).at(x) |
| 226 | + } |
| 227 | + } |
| 228 | + } |
| 229 | + state.can-hold = true |
| 230 | + state.current = state.next |
| 231 | + (state.rng, state.mino-bag, state.next) = new-mino(state.rng, state.mino-bag, cols, rows) |
| 232 | + } |
| 233 | + state |
| 234 | +} |
| 235 | + |
| 236 | +#let eliminate(state, cols: 10, rows: 20) = { |
| 237 | + let new-map = state.map.filter(row => row.filter(it => it != "_").len() != row.len()) |
| 238 | + let eliminated = state.map.len() - new-map.len() |
| 239 | + state.map = new-map + range(eliminated).map(_ => range(cols).map(it => "_")) |
| 240 | + let level = (-2, 40, 100, 300, 1200) |
| 241 | + state.score += level.at(eliminated) |
| 242 | + state |
| 243 | +} |
| 244 | + |
| 245 | +#let game(body, seed: 2, cols: 10, rows: 20, actions: ( |
| 246 | + left: ("a", ), |
| 247 | + right: ("d", ), |
| 248 | + down: ("s", ), |
| 249 | + left-rotate: ("q", ), |
| 250 | + right-rotate: ("e", ), |
| 251 | + half-turn: ("w", ), |
| 252 | + fast-drop: ("f", ), |
| 253 | + hold-mino: ("c", ), |
| 254 | +)) = { |
| 255 | + set page(height: auto, width: auto, margin: (top: 0.5in - 30pt, bottom: 0.5in + 40pt, rest: 0.5in)) |
| 256 | + |
| 257 | + let chars = parse-actions(body) |
| 258 | + |
| 259 | + let rng-initial = gen-rng(seed) |
| 260 | + let (rng-after-initial-bag, bag-after-initial-bag) = shuffle-minoes(rng-initial) |
| 261 | + let state = ( |
| 262 | + rng: rng-after-initial-bag, |
| 263 | + mino-bag: bag-after-initial-bag, |
| 264 | + current: none, |
| 265 | + next: none, |
| 266 | + map: range(rows + 4).map(_ => range(cols).map(it => "_")), |
| 267 | + end: false, |
| 268 | + score: 0, |
| 269 | + hold: none, |
| 270 | + can-hold: true, |
| 271 | + ) |
| 272 | + |
| 273 | + (state.rng, state.mino-bag, state.current) = new-mino(rng-after-initial-bag, bag-after-initial-bag, cols, rows) |
| 274 | + (state.rng, state.mino-bag, state.next) = new-mino(state.rng, state.mino-bag, cols, rows) |
| 275 | + |
| 276 | + for char in chars { |
| 277 | + if actions.left.any(it => it == char) { |
| 278 | + (state, _) = move(state, cols: cols, rows: rows, dx: -1, dy: 0) |
| 279 | + } else if actions.right.any(it => it == char) { |
| 280 | + (state, _) = move(state, cols: cols, rows: rows, dx: 1, dy: 0) |
| 281 | + } else if actions.fast-drop.any(it => it == char) { |
| 282 | + let success = true |
| 283 | + while success { |
| 284 | + (state, success) = move(state, cols: cols, rows: rows, dy: -1) |
| 285 | + } |
| 286 | + } else if actions.right-rotate.any(it => it == char) { |
| 287 | + state = rotate(state, angle: 1, cols: cols, rows: rows) |
| 288 | + } else if actions.left-rotate.any(it => it == char) { |
| 289 | + state = rotate(state, angle: 3, cols: cols, rows: rows) |
| 290 | + } else if actions.half-turn.any(it => it == char) { |
| 291 | + state = rotate(state, angle: 2, cols: cols, rows: rows) |
| 292 | + } else if actions.hold-mino.any(it => it == char) { |
| 293 | + state = hold(state, cols: cols, rows: rows) |
| 294 | + } |
| 295 | + state = next-tick(state, cols: cols, rows: rows) |
| 296 | + state = eliminate(state, cols: cols, rows: rows) |
| 297 | + |
| 298 | + if check-collision(state, cols: cols, rows: rows) { |
| 299 | + state.end = true |
| 300 | + break |
| 301 | + } |
| 302 | + } |
| 303 | + |
| 304 | + render(state, cols: cols, rows: rows) |
| 305 | +} |
0 commit comments