tetris-terminal 0.0.1a3__tar.gz → 0.0.2a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tetris-terminal
3
- Version: 0.0.1a3
3
+ Version: 0.0.2a1
4
4
  Summary: A tetris game runs in the terminal
5
5
  Author-email: jayzhu <jay.l.zhu@foxmail.com>
6
- Project-URL: homepage, https://github.com/zlh124/pytetris
6
+ Project-URL: homepage, https://github.com/zlh124/tetris-terminal
7
7
  Classifier: Development Status :: 3 - Alpha
8
8
  Classifier: Environment :: Console :: Curses
9
9
  Classifier: Intended Audience :: End Users/Desktop
@@ -23,6 +23,7 @@ Classifier: Topic :: Terminals
23
23
  Requires-Python: >=3.8
24
24
  Description-Content-Type: text/markdown
25
25
  License-File: LICENSE
26
+ Requires-Dist: windows-curses; sys_platform == "win32"
26
27
  Dynamic: license-file
27
28
 
28
29
  ![gameplay](./gameplay.gif)
@@ -33,14 +34,18 @@ A terminal-based Tetris game written in Python using the `curses` library.
33
34
  [![Python 3.8+](https://img.shields.io/badge/Python-3.8%2B-blue)]()
34
35
 
35
36
  ### Features
36
- - Classic Tetris gameplay with 7 standard tetrominoes
37
- - Real-time score
38
- - Next piece preview
37
+ - Modern Tetris design following the [Tetris Design Guideline](https://dn720004.ca.archive.org/0/items/2009-tetris-variant-concepts_202201/2009%20Tetris%20Design%20Guideline.pdf)
38
+ - [x] Extended Placement
39
+ - [x] Next Piece Preview
40
+ - [x] SRS System
41
+ - [x] Piece Holding
42
+ - [ ] Scoring System
43
+ - [ ] Level System
39
44
 
40
45
  ### Platform Support
41
46
  Based on Python's [`curses`](https://docs.python.org/3/library/curses.html) module:
42
47
  - ✅ **Linux/macOS**: Works out of the box
43
- - ⚠️ **Windows**: Not supported yet
48
+ - ✅️ **Windows**: With [`windows-curses`](https://github.com/zephyrproject-rtos/windows-curses)
44
49
 
45
50
  ### Installation & Usage
46
51
  ```bash
@@ -49,16 +54,19 @@ tetris
49
54
  ```
50
55
 
51
56
  ### Controls
52
- | Key | Action |
53
- |-----------|-----------------|
54
- | `a` | Move left |
55
- | `d` | Move right |
56
- | `w` | Rotate piece |
57
- | `s` | Hard drop |
58
- | `q` | Quit game |
57
+ | Key | Action |
58
+ |------------|------------|
59
+ | `a`, `←` | Move left |
60
+ | `d`, `→` | Move right |
61
+ |`w`, `↑`,`x`| Rotate cw |
62
+ | `z` | Rotate ccw |
63
+ | `s`, `↓` | Soft drop |
64
+ | `space` | Hard drop |
65
+ | `c` | Hold |
66
+ | `q` | Quit game |
59
67
 
60
68
  ### License
61
69
  MIT License - see [LICENSE](LICENSE) for details.
62
70
 
63
71
  ### Acknowledgements
64
- Game logic adapted from [tinytetris](https://github.com/taylorconor/tinytetris) (a C implementation).
72
+ Idea from [tinytetris](https://github.com/taylorconor/tinytetris) (a C implementation).
@@ -0,0 +1,44 @@
1
+ ![gameplay](./gameplay.gif)
2
+ # Tetris Terminal🎮
3
+ A terminal-based Tetris game written in Python using the `curses` library.
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
6
+ [![Python 3.8+](https://img.shields.io/badge/Python-3.8%2B-blue)]()
7
+
8
+ ### Features
9
+ - Modern Tetris design following the [Tetris Design Guideline](https://dn720004.ca.archive.org/0/items/2009-tetris-variant-concepts_202201/2009%20Tetris%20Design%20Guideline.pdf)
10
+ - [x] Extended Placement
11
+ - [x] Next Piece Preview
12
+ - [x] SRS System
13
+ - [x] Piece Holding
14
+ - [ ] Scoring System
15
+ - [ ] Level System
16
+
17
+ ### Platform Support
18
+ Based on Python's [`curses`](https://docs.python.org/3/library/curses.html) module:
19
+ - ✅ **Linux/macOS**: Works out of the box
20
+ - ✅️ **Windows**: With [`windows-curses`](https://github.com/zephyrproject-rtos/windows-curses)
21
+
22
+ ### Installation & Usage
23
+ ```bash
24
+ pip install tetris-terminal
25
+ tetris
26
+ ```
27
+
28
+ ### Controls
29
+ | Key | Action |
30
+ |------------|------------|
31
+ | `a`, `←` | Move left |
32
+ | `d`, `→` | Move right |
33
+ |`w`, `↑`,`x`| Rotate cw |
34
+ | `z` | Rotate ccw |
35
+ | `s`, `↓` | Soft drop |
36
+ | `space` | Hard drop |
37
+ | `c` | Hold |
38
+ | `q` | Quit game |
39
+
40
+ ### License
41
+ MIT License - see [LICENSE](LICENSE) for details.
42
+
43
+ ### Acknowledgements
44
+ Idea from [tinytetris](https://github.com/taylorconor/tinytetris) (a C implementation).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tetris-terminal"
7
- version = "0.0.1-alpha3"
7
+ version = "0.0.2-alpha1"
8
8
  description = "A tetris game runs in the terminal"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -12,7 +12,9 @@ license = { file = "MIT" }
12
12
 
13
13
  authors = [{ name = "jayzhu", email = "jay.l.zhu@foxmail.com" }]
14
14
 
15
- dependencies = []
15
+ dependencies = [
16
+ "windows-curses; sys_platform == 'win32'",
17
+ ]
16
18
 
17
19
  classifiers = [
18
20
  "Development Status :: 3 - Alpha",
@@ -21,7 +23,7 @@ classifiers = [
21
23
  "License :: OSI Approved :: MIT License",
22
24
  "Operating System :: POSIX :: Linux",
23
25
  "Operating System :: MacOS :: MacOS X",
24
- "Operating System :: Microsoft :: Windows", # curses 在 Windows 需额外处理
26
+ "Operating System :: Microsoft :: Windows",
25
27
  "Programming Language :: Python :: 3",
26
28
  "Programming Language :: Python :: 3.8",
27
29
  "Programming Language :: Python :: 3.9",
@@ -34,7 +36,7 @@ classifiers = [
34
36
  ]
35
37
 
36
38
  [project.urls]
37
- homepage = "https://github.com/zlh124/pytetris"
39
+ homepage = "https://github.com/zlh124/tetris-terminal"
38
40
 
39
41
  [project.scripts]
40
42
  tetris = "tetris.cli:main"
@@ -0,0 +1,3 @@
1
+ from .tetris import Tetris, GAME_WINDOW_SIZE_HEIGHT, GAME_WINDOW_SIZE_WIDTH
2
+
3
+ __all__ = ["Tetris", "GAME_WINDOW_SIZE_HEIGHT", "GAME_WINDOW_SIZE_WIDTH"]
@@ -0,0 +1,25 @@
1
+ import curses
2
+ import sys
3
+
4
+ from tetris import GAME_WINDOW_SIZE_HEIGHT, GAME_WINDOW_SIZE_WIDTH, Tetris
5
+
6
+
7
+ def wrapper(stdscr: curses.window) -> int:
8
+ if curses.COLS < GAME_WINDOW_SIZE_WIDTH or curses.LINES < GAME_WINDOW_SIZE_HEIGHT:
9
+ return 1
10
+ Tetris(stdscr).main()
11
+ return 0
12
+
13
+
14
+ def main() -> int:
15
+ if curses.wrapper(wrapper) == 1:
16
+ print(
17
+ f"ensure your terminal has at least {GAME_WINDOW_SIZE_HEIGHT} rows and {GAME_WINDOW_SIZE_WIDTH} columns."
18
+ )
19
+ print("To ensure the game runs smoothly.")
20
+ return 1
21
+ return 0
22
+
23
+
24
+ if __name__ == "__main__":
25
+ sys.exit(main())
@@ -0,0 +1,609 @@
1
+ import curses
2
+ import random
3
+ import sys
4
+ import time
5
+
6
+ from collections import defaultdict, deque
7
+ from enum import Enum
8
+
9
+ EMPTY = 0
10
+
11
+ GAME_WINDOW_SIZE_HEIGHT = 22
12
+ GAME_WINDOW_SIZE_WIDTH = 50
13
+
14
+ # keymap
15
+ MOVE_LEFT = [curses.KEY_LEFT, ord("A"), ord("a")]
16
+ MOVE_RIGHT = [curses.KEY_RIGHT, ord("D"), ord("d")]
17
+ SOFT_DROP = [curses.KEY_DOWN, ord("s"), ord("S")]
18
+ ROTATE_CW = [curses.KEY_UP, ord("x"), ord("X"), ord("w"), ord("W")]
19
+ ROTATE_CCW = [ord("z"), ord("Z")]
20
+ HOLD = [ord("c"), ord("C")]
21
+ HARD_DROP = [ord(" ")]
22
+ EXIT = [ord("q"), ord("Q")]
23
+
24
+
25
+ def rotate_points(
26
+ points: list[tuple[int, int]],
27
+ center: list[int | tuple[int, int]],
28
+ ccw: bool = False,
29
+ ) -> list[tuple[int, int]]:
30
+ """rotate the point 90 degree"""
31
+ if isinstance(center[0], (list, tuple)):
32
+ cr = (center[0][0] + center[0][1]) / 2.0
33
+ cc = (center[1][0] + center[1][1]) / 2.0 # type: ignore
34
+ else:
35
+ cr, cc = float(center[0]), float(center[1]) # type: ignore
36
+
37
+ rotated_points = []
38
+
39
+ for r, c in points:
40
+ rel_r = r - cr
41
+ rel_c = c - cc
42
+ new_rel_r = -rel_c if ccw else rel_c
43
+ new_rel_c = rel_r if ccw else -rel_r
44
+ new_r = int(new_rel_r + cr)
45
+ new_c = int(new_rel_c + cc)
46
+
47
+ rotated_points.append((new_r, new_c))
48
+
49
+ return rotated_points
50
+
51
+
52
+ class TetriminoShape(Enum):
53
+ Z = 1
54
+ S = 2
55
+ O = 3
56
+ J = 4
57
+ T = 5
58
+ I = 6
59
+ L = 7
60
+
61
+ def __repr__(self) -> str:
62
+ return f"TetriminoShape.{self.name}"
63
+
64
+
65
+ class Direction(Enum):
66
+ NORTH = 0
67
+ EAST = 1
68
+ SOUTH = 2
69
+ WEST = 3
70
+
71
+ def __repr__(self) -> str:
72
+ return f"Direction.{self.name}"
73
+
74
+
75
+ SHAPE_TABLE = {
76
+ TetriminoShape.I: [(0, 0), (0, 1), (0, 2), (0, 3)],
77
+ TetriminoShape.J: [(0, 0), (1, 0), (1, 1), (1, 2)],
78
+ TetriminoShape.L: [(0, 0), (0, 1), (0, 2), (-1, 2)],
79
+ TetriminoShape.O: [(0, 0), (0, 1), (1, 0), (1, 1)],
80
+ TetriminoShape.S: [(0, 0), (0, 1), (-1, 1), (-1, 2)],
81
+ TetriminoShape.T: [(0, 0), (0, 1), (-1, 1), (0, 2)],
82
+ TetriminoShape.Z: [(0, 0), (0, 1), (1, 1), (1, 2)],
83
+ }
84
+
85
+ ROTATE_AXIS = {
86
+ TetriminoShape.I: [(0, 1), (1, 2)],
87
+ TetriminoShape.J: [1, 1],
88
+ TetriminoShape.L: [0, 1],
89
+ TetriminoShape.O: [(0, 1), (0, 1)],
90
+ TetriminoShape.S: [0, 1],
91
+ TetriminoShape.T: [0, 1],
92
+ TetriminoShape.Z: [1, 1],
93
+ }
94
+
95
+ GENERATE_POSITION = {
96
+ TetriminoShape.I: (19, 3),
97
+ TetriminoShape.J: (18, 3),
98
+ TetriminoShape.L: (19, 3),
99
+ TetriminoShape.O: (18, 4),
100
+ TetriminoShape.S: (19, 3),
101
+ TetriminoShape.T: (19, 3),
102
+ TetriminoShape.Z: (18, 3),
103
+ }
104
+
105
+
106
+ # SRS system
107
+ ROTATE_TABLE = defaultdict(lambda: defaultdict(dict))
108
+
109
+
110
+ JLSTZ_WALL_KICK_OFFSET = {
111
+ (Direction.NORTH, Direction.EAST): [(0, 0), (0, -1), (-1, -1), (2, 0), (2, -1)],
112
+ (Direction.EAST, Direction.NORTH): [(0, 0), (0, 1), (1, 1), (-2, 0), (-2, 1)],
113
+ (Direction.EAST, Direction.SOUTH): [(0, 0), (0, 1), (1, 1), (-2, 0), (-2, 1)],
114
+ (Direction.SOUTH, Direction.EAST): [(0, 0), (0, -1), (-1, -1), (2, 0), (2, -1)],
115
+ (Direction.SOUTH, Direction.WEST): [(0, 0), (0, 1), (-1, 1), (2, 0), (2, 1)],
116
+ (Direction.WEST, Direction.SOUTH): [(0, 0), (0, -1), (1, -1), (-2, 0), (-2, -1)],
117
+ (Direction.WEST, Direction.NORTH): [(0, 0), (0, -1), (1, -1), (-2, 0), (-2, -1)],
118
+ (Direction.NORTH, Direction.WEST): [(0, 0), (0, 1), (-1, 1), (2, 0), (2, 1)],
119
+ }
120
+
121
+ O_WALL_KICK_OFFSET = {
122
+ (Direction.NORTH, Direction.EAST): [(0, 0)],
123
+ (Direction.EAST, Direction.NORTH): [(0, 0)],
124
+ (Direction.EAST, Direction.SOUTH): [(0, 0)],
125
+ (Direction.SOUTH, Direction.EAST): [(0, 0)],
126
+ (Direction.SOUTH, Direction.WEST): [(0, 0)],
127
+ (Direction.WEST, Direction.SOUTH): [(0, 0)],
128
+ (Direction.WEST, Direction.NORTH): [(0, 0)],
129
+ (Direction.NORTH, Direction.WEST): [(0, 0)],
130
+ }
131
+
132
+ I_WALL_KICK_OFFSET = {
133
+ (Direction.NORTH, Direction.EAST): [(0, 0), (0, -2), (0, 1), (1, -2), (-2, 1)],
134
+ (Direction.EAST, Direction.NORTH): [(0, 0), (0, 2), (0, -1), (-1, 2), (2, -1)],
135
+ (Direction.EAST, Direction.SOUTH): [(0, 0), (0, -1), (0, 2), (-2, -1), (1, 2)],
136
+ (Direction.SOUTH, Direction.EAST): [(0, 0), (0, 1), (0, -2), (2, 1), (-1, -2)],
137
+ (Direction.SOUTH, Direction.WEST): [(0, 0), (0, 2), (0, -1), (-1, 2), (2, -1)],
138
+ (Direction.WEST, Direction.SOUTH): [(0, 0), (0, -2), (0, 1), (1, -2), (-2, 1)],
139
+ (Direction.WEST, Direction.NORTH): [(0, 0), (0, 1), (0, -2), (2, 1), (-1, -2)],
140
+ (Direction.NORTH, Direction.WEST): [(0, 0), (0, -1), (0, 2), (-2, -1), (1, 2)],
141
+ }
142
+
143
+ # build the ROTATE_TABLE
144
+ for shape in list(TetriminoShape):
145
+ directions = list(Direction)
146
+ _cw = [
147
+ (directions[i], directions[(i + 1) % len(directions)], False)
148
+ for i in range(len(directions))
149
+ ]
150
+ _ccw = [
151
+ (
152
+ directions[i],
153
+ directions[(len(directions) + (i - 1)) % len(directions)],
154
+ True,
155
+ )
156
+ for i in range(0, -len(directions), -1)
157
+ ]
158
+
159
+ cur_pos = SHAPE_TABLE[shape][::]
160
+ for start, end, ccw in _cw + _ccw:
161
+ rotated = rotate_points(cur_pos, ROTATE_AXIS[shape], ccw)
162
+ diff = [(rx - x, ry - y) for (rx, ry), (x, y) in list(zip(rotated, cur_pos))]
163
+ cur_pos = rotated
164
+
165
+ ROTATE_TABLE[shape][(start, end)]["standard_rotate_diff"] = diff
166
+
167
+ if shape == TetriminoShape.I:
168
+ ROTATE_TABLE[shape][(start, end)]["offsets"] = I_WALL_KICK_OFFSET[
169
+ (start, end)
170
+ ]
171
+ elif shape == TetriminoShape.O:
172
+ ROTATE_TABLE[shape][(start, end)]["offsets"] = O_WALL_KICK_OFFSET[
173
+ (start, end)
174
+ ]
175
+ else:
176
+ ROTATE_TABLE[shape][(start, end)]["offsets"] = JLSTZ_WALL_KICK_OFFSET[
177
+ (start, end)
178
+ ]
179
+
180
+
181
+ class Tetrimino:
182
+
183
+ ## line0 0000000000 -
184
+ ## ... |> buffer zone
185
+ ## line19 0000000000 -
186
+ ## line20 0000000000 -
187
+ ## ... |> game zone
188
+ ## line39 0000000000 -
189
+ ## all the tetriminos are generated in the 18th and 19th line(buffer zone)
190
+
191
+ def __init__(self, shape: TetriminoShape) -> None:
192
+ self.shape = shape
193
+ self.no = shape.value
194
+ dx, dy = GENERATE_POSITION[shape]
195
+ self.bodies = [(x + dx, y + dy) for (x, y) in SHAPE_TABLE[shape]]
196
+ self.direction = Direction.NORTH
197
+
198
+ def __iter__(self):
199
+ for x, y in self.bodies:
200
+ yield x, y
201
+
202
+ def __getitem__(self, index: int) -> tuple[int, int]:
203
+ return self.bodies[index]
204
+
205
+ def __setitem__(self, index: int, value: tuple[int, int]) -> None:
206
+ self.bodies[index] = value
207
+
208
+
209
+ class Tetris:
210
+ score = 0
211
+ lines = 0
212
+ level = 1
213
+
214
+ fps = 60 # 1 / 60 s per frame
215
+ tick = 0.001 # calculate tick 1 ms
216
+
217
+ failed = False
218
+
219
+ cur_tetrimino = None
220
+ hold = None
221
+
222
+ frame_timer = 0
223
+ normal_fall_timer = 0
224
+ soft_drop_timer = 0
225
+
226
+ lock_down_timer = 0
227
+ lock_down_rotate_counter = 0
228
+
229
+ hold_once = False
230
+ reach_bottom = False
231
+ lowest = 0
232
+
233
+ board = [[0] * 10 for _ in range(40)]
234
+ bag: deque[Tetrimino] = deque(maxlen=14)
235
+
236
+ @property
237
+ def fall_speed(self) -> float:
238
+ return (0.8 - ((self.level - 1) * 0.007)) ** (self.level - 1)
239
+
240
+ @property
241
+ def soft_drop_speed(self) -> float:
242
+ return self.fall_speed / 20
243
+
244
+ def __init__(self, stdscr: curses.window) -> None:
245
+ self.stdscr = stdscr
246
+
247
+ def replenish_bag(self) -> None:
248
+ """replenish the bag with 7 random tetriminos"""
249
+ tmp = [Tetrimino(shape) for shape in list(TetriminoShape)]
250
+ random.shuffle(tmp)
251
+ self.bag.extend(tmp)
252
+
253
+ def init_bag(self) -> None:
254
+ """fill the bag"""
255
+ for _ in range(2):
256
+ self.replenish_bag()
257
+
258
+ def get_tetrimino(self) -> Tetrimino:
259
+ """get a tetrimino from the bag"""
260
+ tetrimino = self.bag.popleft()
261
+ if len(self.bag) == 7:
262
+ self.replenish_bag()
263
+ # move down one cell immediate
264
+ return tetrimino
265
+
266
+ def get_current_lowest(self) -> int:
267
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
268
+ return max(x for x, _ in self.cur_tetrimino)
269
+
270
+ def generate_new_tetrimino(self) -> None:
271
+ self.cur_tetrimino = self.get_tetrimino()
272
+ if any(self.board[x][y] != EMPTY for x, y in self.cur_tetrimino):
273
+ self.failed = True
274
+ self.do_fall_immediate()
275
+
276
+ def line_clear(self) -> None:
277
+ for row in range(len(self.board) - 1, -1, -1):
278
+ while all(v != EMPTY for v in self.board[row]):
279
+
280
+ self.score += 1
281
+ self.lines += 1
282
+
283
+ for i in range(row - 1, -1, -1):
284
+ self.board[i + 1] = self.board[i]
285
+ self.board[0] = [0] * 10
286
+
287
+ def check_can_move_down(self) -> bool:
288
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
289
+ for x, y in self.cur_tetrimino:
290
+ if x + 1 >= 40:
291
+ return False
292
+ if (x + 1, y) in self.cur_tetrimino:
293
+ continue
294
+ if self.board[x + 1][y] != EMPTY:
295
+ return False
296
+ return True
297
+
298
+ def check_can_move_left(self) -> bool:
299
+ if self.lock_down_rotate_counter >= 15:
300
+ return False
301
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
302
+ for x, y in self.cur_tetrimino:
303
+ if y - 1 < 0:
304
+ return False
305
+ if (x, y - 1) in self.cur_tetrimino:
306
+ continue
307
+ if self.board[x][y - 1] != EMPTY:
308
+ return False
309
+ return True
310
+
311
+ def check_can_move_right(self) -> bool:
312
+ if self.lock_down_rotate_counter >= 15:
313
+ return False
314
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
315
+ for x, y in self.cur_tetrimino:
316
+ if y + 1 >= 10:
317
+ return False
318
+ if (x, y + 1) in self.cur_tetrimino:
319
+ continue
320
+ if self.board[x][y + 1] != EMPTY:
321
+ return False
322
+ return True
323
+
324
+ def do_fall_immediate(self) -> bool:
325
+ if not self.check_can_move_down():
326
+ return False
327
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
328
+ # clean old pos
329
+ for x, y in self.cur_tetrimino:
330
+ self.board[x][y] = EMPTY
331
+ # move down
332
+ for i, (x, y) in enumerate(self.cur_tetrimino):
333
+ self.cur_tetrimino[i] = (x + 1, y)
334
+ # draw new pos
335
+ for x, y in self.cur_tetrimino:
336
+ self.board[x][y] = self.cur_tetrimino.no
337
+ return True
338
+
339
+ def do_move_left(self) -> bool:
340
+ if not self.check_can_move_left():
341
+ return False
342
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
343
+ for x, y in self.cur_tetrimino:
344
+ self.board[x][y] = EMPTY
345
+ for i, (x, y) in enumerate(self.cur_tetrimino):
346
+ self.cur_tetrimino[i] = (x, y - 1)
347
+ for x, y in self.cur_tetrimino:
348
+ self.board[x][y] = self.cur_tetrimino.no
349
+ return True
350
+
351
+ def do_move_right(self) -> bool:
352
+ if not self.check_can_move_right():
353
+ return False
354
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
355
+ for x, y in self.cur_tetrimino:
356
+ self.board[x][y] = EMPTY
357
+ for i, (x, y) in enumerate(self.cur_tetrimino):
358
+ self.cur_tetrimino[i] = (x, y + 1)
359
+ for x, y in self.cur_tetrimino:
360
+ self.board[x][y] = self.cur_tetrimino.no
361
+ return True
362
+
363
+ def check_empty(self, points: list[tuple[int, int]]) -> bool:
364
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
365
+ m, n = len(self.board), len(self.board[0])
366
+ for x, y in points:
367
+ if (x, y) in self.cur_tetrimino.bodies:
368
+ continue
369
+ if not (0 <= x < m and 0 <= y < n) or self.board[x][y] != EMPTY:
370
+ return False
371
+ return True
372
+
373
+ def do_rotate(self, cur_direction: Direction, next_direction: Direction):
374
+ if (
375
+ self.lock_down_rotate_counter >= 15
376
+ ): # can only rotate 15 times when reach bottom
377
+ return
378
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
379
+ standard_rotate_diff, offsets = ROTATE_TABLE[self.cur_tetrimino.shape][
380
+ (cur_direction), (next_direction)
381
+ ].values()
382
+
383
+ rotated = [
384
+ (x + dx, y + dy)
385
+ for (x, y), (dx, dy) in list(
386
+ zip(self.cur_tetrimino.bodies, standard_rotate_diff)
387
+ )
388
+ ]
389
+
390
+ for dx, dy in offsets:
391
+ tmp = rotated[::]
392
+ for i, (x, y) in enumerate(rotated):
393
+ tmp[i] = x + dx, y + dy
394
+
395
+ if self.check_empty(tmp):
396
+ for x, y in self.cur_tetrimino.bodies:
397
+ self.board[x][y] = EMPTY
398
+ for x, y in tmp:
399
+ self.board[x][y] = self.cur_tetrimino.shape.value
400
+ self.cur_tetrimino.bodies = tmp
401
+ self.cur_tetrimino.direction = next_direction
402
+ return
403
+
404
+ def do_rotate_cw(self) -> None:
405
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
406
+ cur_direction = self.cur_tetrimino.direction
407
+ directions = list(Direction)
408
+ next_direction = directions[
409
+ (directions.index(cur_direction) + 1) % len(directions)
410
+ ]
411
+ self.do_rotate(cur_direction, next_direction)
412
+
413
+ def do_rotate_ccw(self) -> None:
414
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
415
+ cur_direction = self.cur_tetrimino.direction
416
+ directions = list(Direction)
417
+ next_direction = directions[
418
+ (len(directions) + (directions.index(cur_direction) - 1)) % len(directions)
419
+ ]
420
+ self.do_rotate(cur_direction, next_direction)
421
+
422
+ def normal_fall(self) -> None:
423
+ self.normal_fall_timer += self.tick
424
+ if self.normal_fall_timer < self.fall_speed:
425
+ return
426
+ self.normal_fall_timer = 0
427
+ if not self.do_fall_immediate():
428
+ self.lowest = self.get_current_lowest()
429
+ self.reach_bottom = True
430
+
431
+ def do_soft_drop(self) -> None:
432
+ # cancel normal fall
433
+ self.normal_fall_timer = 0
434
+ if not self.do_fall_immediate():
435
+ self.lowest = self.get_current_lowest()
436
+ self.reach_bottom = True
437
+
438
+ def do_hard_drop(self) -> None:
439
+ while self.do_fall_immediate():
440
+ pass
441
+ self.lock_down()
442
+
443
+ def do_hold(self) -> None:
444
+ if self.hold_once:
445
+ return
446
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
447
+ for x, y in self.cur_tetrimino:
448
+ self.board[x][y] = EMPTY
449
+ if self.hold is None:
450
+ self.hold = self.cur_tetrimino
451
+ self.generate_new_tetrimino()
452
+ else:
453
+ self.bag.appendleft(Tetrimino(self.hold.shape))
454
+ self.hold = self.cur_tetrimino
455
+ self.generate_new_tetrimino()
456
+
457
+ def draw_board(self) -> None:
458
+ self.frame_timer += self.tick
459
+ if self.frame_timer < 1 / self.fps:
460
+ return
461
+ self.frame_timer = 0
462
+
463
+ # draw border
464
+ self.stdscr.move(0, 0)
465
+ self.stdscr.addstr("┏")
466
+ self.stdscr.move(0, GAME_WINDOW_SIZE_WIDTH - 1)
467
+ self.stdscr.addstr("┓")
468
+ self.stdscr.move(GAME_WINDOW_SIZE_HEIGHT - 1, 0)
469
+ self.stdscr.addstr("┗")
470
+ self.stdscr.move(GAME_WINDOW_SIZE_HEIGHT - 1, GAME_WINDOW_SIZE_WIDTH - 1)
471
+ self.stdscr.addstr("┛")
472
+
473
+ for i in range(1, GAME_WINDOW_SIZE_WIDTH - 1):
474
+ self.stdscr.move(0, i)
475
+ self.stdscr.addstr("━")
476
+ self.stdscr.move(GAME_WINDOW_SIZE_HEIGHT - 1, i)
477
+ self.stdscr.addstr("━")
478
+ for i in range(1, GAME_WINDOW_SIZE_HEIGHT - 1):
479
+ self.stdscr.move(i, 0)
480
+ self.stdscr.addstr("┃")
481
+ self.stdscr.move(i, GAME_WINDOW_SIZE_WIDTH - 1)
482
+ self.stdscr.addstr("┃")
483
+
484
+ self.stdscr.move(0, 21)
485
+ self.stdscr.addstr("┳")
486
+ for i in range(1, 21):
487
+ self.stdscr.move(i, 21)
488
+ self.stdscr.addstr("┃")
489
+ self.stdscr.move(GAME_WINDOW_SIZE_HEIGHT - 1, 21)
490
+ self.stdscr.addstr("┻")
491
+
492
+ # title
493
+ self.stdscr.move(3, 28)
494
+ self.stdscr.addstr("━┳━┏━━━┳━┏━┓┳┏━╸")
495
+ self.stdscr.move(4, 28)
496
+ self.stdscr.addstr(" ┃ ┣━━ ┃ ┣┳┛┃┗━┓")
497
+ self.stdscr.move(5, 28)
498
+ self.stdscr.addstr(" ╹ ┗━━ ╹ ╹┗━┻━━┛")
499
+
500
+ # game info
501
+ self.stdscr.move(9, 27)
502
+ self.stdscr.addstr("Next : ")
503
+ for i in range(5):
504
+ self.stdscr.addstr(f"{self.bag[i].shape.name} ")
505
+
506
+ self.stdscr.move(11, 27)
507
+ self.stdscr.addstr(f"Score : {self.score}")
508
+ self.stdscr.move(13, 27)
509
+ self.stdscr.addstr(f"Lines : {self.lines}")
510
+ self.stdscr.move(15, 27)
511
+ self.stdscr.addstr(f"Level : {self.level}")
512
+ self.stdscr.move(17, 27)
513
+ self.stdscr.addstr(f"Hold : {self.hold.shape.name if self.hold else ""}")
514
+ # board
515
+ for i in range(20, 40):
516
+ self.stdscr.move(i - 19, 1)
517
+ for j in range(10):
518
+ self.stdscr.addstr(" ", curses.color_pair(self.board[i][j]))
519
+
520
+ self.stdscr.refresh()
521
+
522
+ def handle_input(self) -> None:
523
+ """handle the input
524
+ Terminal input relies on the operating system's control
525
+ over the rate at which keyboard characters are entered.
526
+ it't hard to ctrl the long press and normal press
527
+ """
528
+ c = self.stdscr.getch()
529
+ if c in EXIT:
530
+ self.failed = True
531
+ if c in MOVE_LEFT:
532
+ self.do_move_left()
533
+ if c in MOVE_RIGHT:
534
+ self.do_move_right()
535
+ if c in SOFT_DROP:
536
+ self.do_soft_drop()
537
+ if c in ROTATE_CW:
538
+ self.do_rotate_cw()
539
+ if c in ROTATE_CCW:
540
+ self.do_rotate_ccw()
541
+ if c in HARD_DROP:
542
+ self.do_hard_drop()
543
+ if c in HOLD:
544
+ self.do_hold()
545
+
546
+ def lock_down(self) -> None:
547
+ assert self.cur_tetrimino is not None, "cur_tetrimino is None"
548
+ # all cells in buff zone when lock down
549
+ if all(x < 20 for x, _ in self.cur_tetrimino):
550
+ self.failed = True
551
+
552
+ self.line_clear()
553
+
554
+ if self.lines >= self.level * (self.level + 1) / 2 * 10:
555
+ self.level += 1
556
+
557
+ self.generate_new_tetrimino()
558
+
559
+ self.reach_bottom = False
560
+ self.lock_down_timer = 0
561
+ self.lock_down_rotate_counter = 0
562
+
563
+ def handle_lock_down(self) -> None:
564
+ if not self.reach_bottom:
565
+ return
566
+ if self.lock_down_timer >= 0.5:
567
+ self.lock_down()
568
+ return
569
+ # no longer move down and has cells below, continue timer
570
+ if self.get_current_lowest() == self.lowest and not self.check_can_move_down():
571
+ self.lock_down_timer += self.tick
572
+ # reach new lowest, reset timer and counter
573
+ elif self.get_current_lowest() > self.lowest:
574
+ self.reach_button = False
575
+ self.lock_down_timer = 0
576
+ self.lock_down_rotate_counter = 0
577
+
578
+ def game_loop(self) -> None:
579
+ while not self.failed:
580
+ self.normal_fall()
581
+ self.draw_board()
582
+ self.handle_input()
583
+ self.handle_lock_down()
584
+ time.sleep(self.tick)
585
+
586
+ def init_color(self) -> None:
587
+ if curses.can_change_color():
588
+ curses.init_color(TetriminoShape.I.value, 0, 941, 941)
589
+ curses.init_color(TetriminoShape.O.value, 941, 941, 0)
590
+ curses.init_color(TetriminoShape.T.value, 627, 0, 941)
591
+ curses.init_color(TetriminoShape.L.value, 941, 627, 0)
592
+ curses.init_color(TetriminoShape.J.value, 0, 0, 941)
593
+ curses.init_color(TetriminoShape.S.value, 0, 941, 0)
594
+ curses.init_color(TetriminoShape.Z.value, 941, 0, 0)
595
+ curses.use_default_colors()
596
+ for tetrimino in list(TetriminoShape):
597
+ curses.init_pair(tetrimino.value, tetrimino.value, tetrimino.value)
598
+
599
+ def init_game(self) -> None:
600
+ self.init_bag()
601
+ self.generate_new_tetrimino()
602
+ self.init_color()
603
+
604
+ curses.curs_set(0)
605
+ self.stdscr.timeout(0)
606
+
607
+ def main(self) -> None:
608
+ self.init_game()
609
+ self.game_loop()
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tetris-terminal
3
- Version: 0.0.1a3
3
+ Version: 0.0.2a1
4
4
  Summary: A tetris game runs in the terminal
5
5
  Author-email: jayzhu <jay.l.zhu@foxmail.com>
6
- Project-URL: homepage, https://github.com/zlh124/pytetris
6
+ Project-URL: homepage, https://github.com/zlh124/tetris-terminal
7
7
  Classifier: Development Status :: 3 - Alpha
8
8
  Classifier: Environment :: Console :: Curses
9
9
  Classifier: Intended Audience :: End Users/Desktop
@@ -23,6 +23,7 @@ Classifier: Topic :: Terminals
23
23
  Requires-Python: >=3.8
24
24
  Description-Content-Type: text/markdown
25
25
  License-File: LICENSE
26
+ Requires-Dist: windows-curses; sys_platform == "win32"
26
27
  Dynamic: license-file
27
28
 
28
29
  ![gameplay](./gameplay.gif)
@@ -33,14 +34,18 @@ A terminal-based Tetris game written in Python using the `curses` library.
33
34
  [![Python 3.8+](https://img.shields.io/badge/Python-3.8%2B-blue)]()
34
35
 
35
36
  ### Features
36
- - Classic Tetris gameplay with 7 standard tetrominoes
37
- - Real-time score
38
- - Next piece preview
37
+ - Modern Tetris design following the [Tetris Design Guideline](https://dn720004.ca.archive.org/0/items/2009-tetris-variant-concepts_202201/2009%20Tetris%20Design%20Guideline.pdf)
38
+ - [x] Extended Placement
39
+ - [x] Next Piece Preview
40
+ - [x] SRS System
41
+ - [x] Piece Holding
42
+ - [ ] Scoring System
43
+ - [ ] Level System
39
44
 
40
45
  ### Platform Support
41
46
  Based on Python's [`curses`](https://docs.python.org/3/library/curses.html) module:
42
47
  - ✅ **Linux/macOS**: Works out of the box
43
- - ⚠️ **Windows**: Not supported yet
48
+ - ✅️ **Windows**: With [`windows-curses`](https://github.com/zephyrproject-rtos/windows-curses)
44
49
 
45
50
  ### Installation & Usage
46
51
  ```bash
@@ -49,16 +54,19 @@ tetris
49
54
  ```
50
55
 
51
56
  ### Controls
52
- | Key | Action |
53
- |-----------|-----------------|
54
- | `a` | Move left |
55
- | `d` | Move right |
56
- | `w` | Rotate piece |
57
- | `s` | Hard drop |
58
- | `q` | Quit game |
57
+ | Key | Action |
58
+ |------------|------------|
59
+ | `a`, `←` | Move left |
60
+ | `d`, `→` | Move right |
61
+ |`w`, `↑`,`x`| Rotate cw |
62
+ | `z` | Rotate ccw |
63
+ | `s`, `↓` | Soft drop |
64
+ | `space` | Hard drop |
65
+ | `c` | Hold |
66
+ | `q` | Quit game |
59
67
 
60
68
  ### License
61
69
  MIT License - see [LICENSE](LICENSE) for details.
62
70
 
63
71
  ### Acknowledgements
64
- Game logic adapted from [tinytetris](https://github.com/taylorconor/tinytetris) (a C implementation).
72
+ Idea from [tinytetris](https://github.com/taylorconor/tinytetris) (a C implementation).
@@ -8,4 +8,5 @@ src/tetris_terminal.egg-info/PKG-INFO
8
8
  src/tetris_terminal.egg-info/SOURCES.txt
9
9
  src/tetris_terminal.egg-info/dependency_links.txt
10
10
  src/tetris_terminal.egg-info/entry_points.txt
11
+ src/tetris_terminal.egg-info/requires.txt
11
12
  src/tetris_terminal.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+
2
+ [:sys_platform == "win32"]
3
+ windows-curses
@@ -1,37 +0,0 @@
1
- ![gameplay](./gameplay.gif)
2
- # Tetris Terminal🎮
3
- A terminal-based Tetris game written in Python using the `curses` library.
4
-
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
6
- [![Python 3.8+](https://img.shields.io/badge/Python-3.8%2B-blue)]()
7
-
8
- ### Features
9
- - Classic Tetris gameplay with 7 standard tetrominoes
10
- - Real-time score
11
- - Next piece preview
12
-
13
- ### Platform Support
14
- Based on Python's [`curses`](https://docs.python.org/3/library/curses.html) module:
15
- - ✅ **Linux/macOS**: Works out of the box
16
- - ⚠️ **Windows**: Not supported yet
17
-
18
- ### Installation & Usage
19
- ```bash
20
- pip install tetris-terminal
21
- tetris
22
- ```
23
-
24
- ### Controls
25
- | Key | Action |
26
- |-----------|-----------------|
27
- | `a` | Move left |
28
- | `d` | Move right |
29
- | `w` | Rotate piece |
30
- | `s` | Hard drop |
31
- | `q` | Quit game |
32
-
33
- ### License
34
- MIT License - see [LICENSE](LICENSE) for details.
35
-
36
- ### Acknowledgements
37
- Game logic adapted from [tinytetris](https://github.com/taylorconor/tinytetris) (a C implementation).
File without changes
@@ -1,18 +0,0 @@
1
- import curses
2
- import sys
3
-
4
- from .tetris import Tetris
5
-
6
-
7
- def wrapper(stdscr: curses.window):
8
- tetris = Tetris(stdscr)
9
- tetris.main()
10
-
11
-
12
- def main() -> int:
13
- curses.wrapper(wrapper)
14
- return 0
15
-
16
-
17
- if __name__ == "__main__":
18
- sys.exit(main())
@@ -1,199 +0,0 @@
1
- #!/usr/bin/python3
2
- import curses
3
- import time
4
-
5
- from collections import deque
6
- from copy import copy
7
- from random import randint, shuffle
8
-
9
-
10
- class Tetris:
11
- x = 431424
12
- y = 598356
13
- r = 427089
14
- c = 348480
15
- p = 615696
16
-
17
- px = 247872
18
- py = 799248
19
- pr = 0
20
-
21
- tick = 0
22
-
23
- board = [[0] * 10 for _ in range(20)]
24
-
25
- piece_chars = ["Z", "S", "O", "J", "T", "I", "L"]
26
-
27
- block = [
28
- [x, y, x, y],
29
- [r, p, r, p],
30
- [c, c, c, c],
31
- [599636, 431376, 598336, 432192],
32
- [411985, 610832, 415808, 595540],
33
- [px, py, px, py],
34
- [614928, 399424, 615744, 428369],
35
- ]
36
-
37
- def __init__(self, stdscr: curses.window):
38
- self.stdscr = stdscr
39
-
40
- queue = deque(maxlen=14)
41
-
42
- score = 0
43
- lock_until = 0
44
-
45
- def now_ms(self) -> int:
46
- return int(time.time() * 1000)
47
-
48
- def reset_lock_delay(self) -> None:
49
- self.lock_until = 0
50
-
51
- def start_lock_delay(self) -> None:
52
- if not self.lock_until:
53
- self.lock_until = self.now_ms() + 500
54
-
55
- def NUM(self, x: int, y: int) -> int:
56
- return 3 & self.block[self.p][x] >> y
57
-
58
- def fill_bag(self) -> None:
59
- bag = list(range(7))
60
- shuffle(bag)
61
- while bag:
62
- self.queue.append(bag.pop())
63
-
64
- def init_queue(self) -> None:
65
- self.fill_bag()
66
- self.fill_bag()
67
-
68
- def next_from_queue(self) -> int:
69
- if len(self.queue) == 7:
70
- self.fill_bag()
71
- return self.queue.popleft()
72
-
73
- def new_piece(self) -> None:
74
- self.y = self.py = 0
75
- self.p = self.next_from_queue()
76
- self.r = self.pr = randint(0, 3)
77
- self.x = self.px = randint(0, 9 - self.NUM(self.r, 16))
78
- self.reset_lock_delay()
79
-
80
- def frame(self, stdscr: curses.window) -> None:
81
- stdscr.move(0, 3)
82
- stdscr.addstr("Next: ")
83
- for i in range(5):
84
- stdscr.addstr(f"{self.piece_chars[self.queue[i]]} ")
85
-
86
- for i in range(20):
87
- stdscr.move(i + 1, 1)
88
- for j in range(10):
89
- if self.board[i][j]:
90
- stdscr.attron(262176 | self.board[i][j] << 8)
91
- stdscr.addstr(" ")
92
- stdscr.attroff(262176 | self.board[i][j] << 8)
93
- stdscr.move(21, 1)
94
- stdscr.addstr(f"Score: {self.score}")
95
- stdscr.refresh()
96
-
97
- def set_piece(self, x: int, y: int, r: int, v: int) -> None:
98
- for i in range(0, 8, 2):
99
- self.board[self.NUM(r, i * 2) + y][self.NUM(r, (i * 2) + 2) + x] = v
100
-
101
- def update_piece(self) -> None:
102
- self.set_piece(self.px, self.py, self.pr, 0)
103
- self.px, self.py, self.pr = self.x, self.y, self.r
104
- self.set_piece(self.x, self.y, self.r, self.p + 1)
105
-
106
- def remove_line(self) -> None:
107
- for row in range(self.y, self.y + self.NUM(self.r, 18) + 1):
108
- self.c = 1
109
- for i in range(10):
110
- self.c *= self.board[row][i]
111
- if not self.c:
112
- continue
113
- for i in range(row - 1, 0, -1):
114
- self.board[i + 1] = copy(self.board[i])
115
- self.board[0] = [0] * 10
116
- self.score += 1
117
-
118
- def check_hit(self, x: int, y: int, r: int) -> int:
119
- if y + self.NUM(r, 18) > 19:
120
- return 1
121
- self.set_piece(self.px, self.py, self.pr, 0)
122
- self.c = 0
123
- for i in range(0, 8, 2):
124
- if self.board[y + self.NUM(r, i * 2)][x + self.NUM(r, (i * 2) + 2)]:
125
- self.c += 1
126
- self.set_piece(self.px, self.py, self.pr, self.p + 1)
127
- return self.c
128
-
129
- def do_tick(self) -> int:
130
- self.tick += 1
131
- if self.tick > 30:
132
- self.tick = 0
133
- if not self.check_hit(self.x, self.y + 1, self.r):
134
- self.y += 1
135
- self.update_piece()
136
- self.reset_lock_delay()
137
- else:
138
- if not self.y:
139
- return 0
140
- self.start_lock_delay()
141
- if self.now_ms() >= self.lock_until:
142
- self.remove_line()
143
- self.new_piece()
144
- if self.lock_until and self.now_ms() >= self.lock_until:
145
- if self.check_hit(self.x, self.y + 1, self.r):
146
- self.remove_line()
147
- self.new_piece()
148
- else:
149
- self.reset_lock_delay()
150
- return 1
151
-
152
- def runloop(self) -> None:
153
- while self.do_tick():
154
- time.sleep(0.01)
155
- c = self.stdscr.getch()
156
- if (
157
- c == ord("a")
158
- and self.x > 0
159
- and not self.check_hit(self.x - 1, self.y, self.r)
160
- ):
161
- self.x -= 1
162
- if (
163
- c == ord("d")
164
- and self.x + self.NUM(self.r, 16) < 9
165
- and not self.check_hit(self.x + 1, self.y, self.r)
166
- ):
167
- self.x += 1
168
- if c == ord("s"):
169
- while not self.check_hit(self.x, self.y + 1, self.r):
170
- self.y += 1
171
- self.update_piece()
172
- self.reset_lock_delay()
173
- self.remove_line()
174
- self.new_piece()
175
- if c == ord("w"):
176
- self.r += 1
177
- self.r %= 4
178
- while self.x + self.NUM(self.r, 16) > 9:
179
- self.x -= 1
180
- if self.check_hit(self.x, self.y, self.r):
181
- self.x = self.px
182
- self.r = self.pr
183
- if c == ord("q"):
184
- return
185
- self.update_piece()
186
- self.frame(self.stdscr)
187
-
188
- def main(self) -> None:
189
- curses.start_color()
190
- self.init_queue()
191
- for i in range(1, 8):
192
- curses.init_pair(i, i, 0)
193
- self.new_piece()
194
- curses.resizeterm(22, 22)
195
- curses.noecho()
196
- curses.curs_set(0)
197
- self.stdscr.timeout(0)
198
- self.stdscr.box()
199
- self.runloop()