Skip to content

Commit 554a979

Browse files
authored
soviet-matrix:0.2.1 (#3002)
1 parent eec2851 commit 554a979

File tree

10 files changed

+640
-0
lines changed

10 files changed

+640
-0
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 YouXam
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.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# soviet-matrix
2+
3+
This is a classic Tetris game implemented using Typst. The goal is to manipulate falling blocks to create and clear horizontal lines without letting the blocks stack up to the top of the playing field.
4+
5+
![](./demo.gif)
6+
7+
## How to Play
8+
9+
You can play the game in two ways:
10+
11+
1. **Online:**
12+
- Visit [https://typst.app/app?template=soviet-matrix&version=latest](https://typst.app/app?template=soviet-matrix&version=latest).
13+
- Enter any title of your choice and click **Create**.
14+
15+
2. **Locally:**
16+
- Open your command line interface.
17+
- Run the following command:
18+
```bash
19+
typst init @preview/soviet-matrix
20+
```
21+
- Typst will create a new directory.
22+
- Open `main.typ` in the created directory.
23+
- Use the [Tinymist Typst VS Code extension](https://marketplace.visualstudio.com/items/?itemName=myriad-dreamin.tinymist) for live preview and gameplay.
24+
25+
Enjoy the game!
26+
27+
28+
## Controls
29+
30+
- Move Left: a
31+
- Move Right: d
32+
- Soft Drop: s
33+
- Hard Drop: f
34+
- Rotate Left: q
35+
- Rotate Right: e
36+
- 180-degree Rotate: w
37+
- Hold Piece: c
38+
39+
## Changing the Game Seed
40+
41+
If you want to play different game scenarios, you can change the game seed using the following method:
42+
43+
```typst
44+
#import "@preview/soviet-matrix:0.2.1": game
45+
#show: game.with(seed: 123) // Change the game seed
46+
```
47+
48+
Replace `123` with any number of your choice.
49+
50+
## Changing Key Bindings
51+
52+
Modify the `actions` parameter in the `game.with` method to change the key bindings. The default key bindings are as follows:
53+
54+
55+
```typst
56+
#show: game.with(seed: 0, actions: (
57+
left: ("a", ),
58+
right: ("d", ),
59+
down: ("s", ),
60+
left-rotate: ("q", ),
61+
right-rotate: ("e", ),
62+
half-turn: ("w", ),
63+
fast-drop: ("f", ),
64+
hold-mino: ("c", ),
65+
))
66+
```
67+
770 KB
Loading
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
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

Comments
 (0)