nodes/tetris_game.py¶
Part of Tetris (raylib classic).
1"""Tetris: gameplay node.
2
3Port of raylib classic ``tetris.c`` to a single SimVX Node2D. 7 pieces, 10x20
4playfield (with a sentinel border ring like the original), gravity, lateral
5movement with auto-repeat, soft drop, simple 4-corner rotation, line clears
6with a brief fade, level scaling.
7
8Controls: LEFT/RIGHT or A/D move, DOWN or S soft-drop, UP or W rotate, P pause,
9SPACE/ENTER start or restart, ESC quit. On a controller or the on-screen pad the
10D-pad moves and soft-drops, A rotates, B soft-drops and Start pauses; a tap
11anywhere starts a game and resumes a paused one.
12"""
13
14from __future__ import annotations
15
16import random
17
18from simvx.core import Axis1D, Controller, Input, Key, Mouse, Node2D, Property, Signal
19from simvx.core.ui import TouchController
20
21# ---------------------------------------------------------------------------
22# Constants (mirror the C original where reasonable)
23# ---------------------------------------------------------------------------
24
25GRID_W = 12 # includes 1-cell sentinel walls left/right
26GRID_H = 21 # includes 1-cell sentinel floor at the bottom
27
28# State machine
29STATE_MENU = "menu"
30STATE_PLAY = "play"
31STATE_OVER = "over"
32
33HINT_COLOUR = (0.70, 0.70, 0.70)
34
35LATERAL_AUTOREPEAT = 6 # ticks between auto-repeat moves while held
36FAST_FALL_HOLD = 18 # ticks before soft-drop kicks in
37FADING_TIME = 18 # ticks to flash a completed line
38
39EMPTY, MOVING, FULL, BLOCK, FADING = 0, 1, 2, 3, 4
40
41# Cell colours
42COL_BG = (0.96, 0.96, 0.96)
43COL_EMPTY_LINE = (0.78, 0.78, 0.78)
44COL_BLOCK = (0.78, 0.78, 0.78)
45COL_FULL = (0.51, 0.51, 0.51)
46COL_MOVING = (0.31, 0.31, 0.31)
47COL_TEXT = (0.51, 0.51, 0.51)
48COL_FADING_A = (0.74, 0.20, 0.20)
49COL_FADING_B = (0.51, 0.51, 0.51)
50
51# Seven 4x4 pieces from the original (column-major like the C source).
52PIECES: list[list[tuple[int, int]]] = [
53 [(1, 1), (2, 1), (1, 2), (2, 2)], # O
54 [(1, 0), (1, 1), (1, 2), (2, 2)], # L
55 [(1, 2), (2, 0), (2, 1), (2, 2)], # J
56 [(0, 1), (1, 1), (2, 1), (3, 1)], # I
57 [(1, 0), (1, 1), (1, 2), (2, 1)], # T
58 [(1, 1), (2, 1), (2, 2), (3, 2)], # S
59 [(1, 2), (2, 2), (2, 1), (3, 1)], # Z
60]
61
62
63def _new_grid() -> list[list[int]]:
64 g = [[EMPTY for _ in range(GRID_H)] for _ in range(GRID_W)]
65 for i in range(GRID_W):
66 for j in range(GRID_H):
67 if j == GRID_H - 1 or i == 0 or i == GRID_W - 1:
68 g[i][j] = BLOCK
69 return g
70
71
72def _new_piece() -> list[list[int]]:
73 return [[EMPTY for _ in range(4)] for _ in range(4)]
74
75
76def _random_piece() -> list[list[int]]:
77 p = _new_piece()
78 for x, y in random.choice(PIECES):
79 p[x][y] = MOVING
80 return p
81
82
83# ---------------------------------------------------------------------------
84# Game node
85# ---------------------------------------------------------------------------
86
87
88class TetrisGame(Node2D):
89 #: What the player can do, declared on the scene root so the tree registers
90 #: it whenever this scene is loaded. Sideways movement is one signed action
91 #: rather than two opposed ones, so the arrow keys, A/D, a controller's
92 #: D-pad and its left stick all feed the same reading and the one furthest
93 #: from rest wins. Every action a player needs carries a pad binding, and
94 #: starting a game also answers to a left click, which is what the web
95 #: runtime delivers a tap as.
96 input_context = {
97 "move": [
98 Axis1D(low=Key.LEFT, high=Key.RIGHT),
99 Axis1D(low=Key.A, high=Key.D),
100 Axis1D(low=Controller.DPAD_LEFT, high=Controller.DPAD_RIGHT),
101 Controller.LEFT_X,
102 ],
103 "soft_drop": [Key.DOWN, Key.S, Controller.DPAD_DOWN, Controller.B],
104 "rotate": [Key.UP, Key.W, Controller.DPAD_UP, Controller.A],
105 "pause": [Key.P, Controller.START],
106 "start": [Key.ENTER, Key.SPACE, Mouse.LEFT, Controller.A, Controller.START],
107 "quit": [Key.ESCAPE, Controller.BACK],
108 }
109
110 gravity_speed = Property(28, range=(4, 60), hint="Ticks between gravity steps")
111
112 line_cleared = Signal(int) # number of lines this clear
113 died = Signal(int) # final line count
114
115 def __init__(self, **kw):
116 super().__init__(name="TetrisGame", **kw)
117 # The whole playfield is drawn immediate-mode in on_draw from plain (non-
118 # Property) grid/piece state that changes continuously during play (gravity,
119 # moves, fades) and on menu<->play<->over transitions, with nothing to auto-
120 # dirty this node. Mark it `dynamic` so its on_draw re-captures every frame.
121 self.dynamic = True
122 self._state = STATE_MENU
123 # A grid game wants discrete directions, so the drawn pad gets a D-pad
124 # rather than a stick: an analogue reading would only be noise here. It
125 # publishes itself as a real controller, so the Controller bindings above
126 # already read it and there is nothing else to wire up. It leaves the
127 # screen by itself once a real controller is used.
128 self.add_child(TouchController(layout="dpad_and_buttons", buttons=(Controller.A, Controller.B)))
129 self._reset()
130
131 def _reset(self):
132 self.grid = _new_grid()
133 self.piece = _new_piece()
134 self.incoming = _random_piece()
135 self.piece_x = 0
136 self.piece_y = 0
137 self.piece_active = False
138 self.detection = False
139 self.line_to_delete = False
140 self.begin_play = True
141 self.paused = False
142
143 self.gravity_counter = 0
144 self.lateral_counter = 0
145 self.fast_fall_counter = 0
146 self.fade_counter = 0
147 self.lines = 0
148
149 def _layout(self, width: float, height: float) -> tuple[int, int, int]:
150 """The cell size and board origin for a window of this size."""
151 # The playfield is GRID_W × GRID_H cells; reserve room above for the
152 # title strip and below for the controls hint, plus a NEXT-preview
153 # area beside it (4 cells wide + a 1-cell gap).
154 side_cells = 5
155 hud_rows = 3 # top + bottom margins in cell units
156 square = max(6, int(min(width / (GRID_W + side_cells), height / (GRID_H + hud_rows))))
157 # Centre the playfield + side panel block horizontally.
158 block_w = square * (GRID_W + side_cells)
159 origin_x = int((width - block_w) // 2)
160 origin_y = int(max(square, (height - square * GRID_H) // 2))
161 return square, origin_x, origin_y
162
163 # ------------------------------------------------------------------
164 # Update
165 # ------------------------------------------------------------------
166 def on_fixed_update(self, dt):
167 if Input.just_pressed("quit"):
168 self.app.quit()
169 return
170
171 if self._state in (STATE_MENU, STATE_OVER):
172 # A tap, Space, Enter or a pad's A / Start all start a game, so
173 # every screen can be left with a finger or with a controller.
174 if Input.just_pressed("start"):
175 self._reset()
176 self._state = STATE_PLAY
177 return
178
179 # Start on a pad pauses; while paused a tap anywhere resumes as well,
180 # which is the touch player's way back out.
181 if Input.just_pressed("pause") or (self.paused and Input.just_pressed("start")):
182 self.paused = not self.paused
183 if self.paused:
184 return
185
186 if self.line_to_delete:
187 self.fade_counter += 1
188 if self.fade_counter >= FADING_TIME:
189 cleared = self._delete_complete_lines()
190 self.lines += cleared
191 self.line_cleared.emit(cleared)
192 self.fade_counter = 0
193 self.line_to_delete = False
194 return
195
196 if not self.piece_active:
197 self.piece_active = self._create_piece()
198 self.fast_fall_counter = 0
199 else:
200 self.fast_fall_counter += 1
201 self.gravity_counter += 1
202 self.lateral_counter += 1
203
204 # A fresh push moves the piece on this very step; holding it then
205 # repeats at LATERAL_AUTOREPEAT.
206 if Input.just_pressed("move"):
207 self.lateral_counter = LATERAL_AUTOREPEAT
208 # Rotation is one turn per press: moving sideways auto-repeats while
209 # held, but holding the rotate key must not spin the piece.
210 if Input.just_pressed("rotate"):
211 self._resolve_turn_movement()
212
213 if Input.pressed("soft_drop") and self.fast_fall_counter >= FAST_FALL_HOLD:
214 self.gravity_counter += self.gravity_speed
215
216 if self.gravity_counter >= self.gravity_speed:
217 self._check_detection()
218 self._resolve_falling_movement()
219 self._check_completion()
220 self.gravity_counter = 0
221
222 if self.lateral_counter >= LATERAL_AUTOREPEAT:
223 # The action's deadzone has already discarded a stick at rest,
224 # so anything left is a real push in one direction or the other.
225 move = Input.value("move")
226 direction = (move > 0.0) - (move < 0.0)
227 if not self._resolve_lateral_movement(direction):
228 self.lateral_counter = 0
229
230 # Game over: anything FULL in the top two rows
231 for j in range(2):
232 for i in range(1, GRID_W - 1):
233 if self.grid[i][j] == FULL:
234 self._state = STATE_OVER
235 self.died.emit(self.lines)
236 return
237
238 # ------------------------------------------------------------------
239 # Piece handling
240 # ------------------------------------------------------------------
241 def _create_piece(self) -> bool:
242 self.piece_x = (GRID_W - 4) // 2
243 self.piece_y = 0
244
245 if self.begin_play:
246 self.incoming = _random_piece()
247 self.begin_play = False
248
249 # Promote incoming → current
250 for i in range(4):
251 for j in range(4):
252 self.piece[i][j] = self.incoming[i][j]
253 self.incoming = _random_piece()
254
255 for i in range(self.piece_x, self.piece_x + 4):
256 for j in range(4):
257 if self.piece[i - self.piece_x][j] == MOVING:
258 self.grid[i][j] = MOVING
259 return True
260
261 def _check_detection(self):
262 for j in range(GRID_H - 2, -1, -1):
263 for i in range(1, GRID_W - 1):
264 if self.grid[i][j] == MOVING and self.grid[i][j + 1] in (FULL, BLOCK):
265 self.detection = True
266 return
267
268 def _resolve_falling_movement(self):
269 if self.detection:
270 for j in range(GRID_H - 2, -1, -1):
271 for i in range(1, GRID_W - 1):
272 if self.grid[i][j] == MOVING:
273 self.grid[i][j] = FULL
274 self.detection = False
275 self.piece_active = False
276 else:
277 for j in range(GRID_H - 2, -1, -1):
278 for i in range(1, GRID_W - 1):
279 if self.grid[i][j] == MOVING:
280 self.grid[i][j + 1] = MOVING
281 self.grid[i][j] = EMPTY
282 self.piece_y += 1
283
284 def _resolve_lateral_movement(self, direction: int) -> bool:
285 """Move the piece one cell along *direction*, -1 left, 0 nowhere, 1 right.
286
287 Returns True on collision, False on success, matching the C convention.
288 """
289 collision = False
290 if direction < 0:
291 for j in range(GRID_H - 2, -1, -1):
292 for i in range(1, GRID_W - 1):
293 if self.grid[i][j] == MOVING and (i - 1 == 0 or self.grid[i - 1][j] == FULL):
294 collision = True
295 break
296 if collision:
297 break
298 if not collision:
299 for j in range(GRID_H - 2, -1, -1):
300 for i in range(1, GRID_W - 1):
301 if self.grid[i][j] == MOVING:
302 self.grid[i - 1][j] = MOVING
303 self.grid[i][j] = EMPTY
304 self.piece_x -= 1
305 elif direction > 0:
306 for j in range(GRID_H - 2, -1, -1):
307 for i in range(1, GRID_W - 1):
308 if self.grid[i][j] == MOVING and (i + 1 == GRID_W - 1 or self.grid[i + 1][j] == FULL):
309 collision = True
310 break
311 if collision:
312 break
313 if not collision:
314 for j in range(GRID_H - 2, -1, -1):
315 for i in range(GRID_W - 1, 0, -1):
316 if self.grid[i][j] == MOVING:
317 self.grid[i + 1][j] = MOVING
318 self.grid[i][j] = EMPTY
319 self.piece_x += 1
320 return collision
321
322 def _resolve_turn_movement(self) -> bool:
323 # Rotate the local 4x4 piece grid 90 degrees CW. We then check whether
324 # that rotation would intersect any FULL or BLOCK cells; if so, abort.
325 new_piece = _new_piece()
326 for i in range(4):
327 for j in range(4):
328 new_piece[3 - j][i] = self.piece[i][j]
329
330 # Validate: new_piece offsets must land in EMPTY or MOVING cells of grid
331 for i in range(4):
332 for j in range(4):
333 if new_piece[i][j] != MOVING:
334 continue
335 gx = self.piece_x + i
336 gy = self.piece_y + j
337 if not (0 <= gx < GRID_W and 0 <= gy < GRID_H):
338 return False
339 cell = self.grid[gx][gy]
340 if cell == FULL or cell == BLOCK:
341 return False
342
343 # Clear current MOVING cells
344 for j in range(GRID_H - 1):
345 for i in range(1, GRID_W - 1):
346 if self.grid[i][j] == MOVING:
347 self.grid[i][j] = EMPTY
348
349 # Apply rotated piece
350 self.piece = new_piece
351 for i in range(4):
352 for j in range(4):
353 if self.piece[i][j] == MOVING:
354 gx = self.piece_x + i
355 gy = self.piece_y + j
356 if 0 <= gx < GRID_W and 0 <= gy < GRID_H:
357 self.grid[gx][gy] = MOVING
358 return True
359
360 def _check_completion(self):
361 for j in range(GRID_H - 2, -1, -1):
362 count = 0
363 for i in range(1, GRID_W - 1):
364 if self.grid[i][j] == FULL:
365 count += 1
366 if count == GRID_W - 2:
367 self.line_to_delete = True
368 for z in range(1, GRID_W - 1):
369 self.grid[z][j] = FADING
370
371 def _delete_complete_lines(self) -> int:
372 deleted = 0
373 for j in range(GRID_H - 2, -1, -1):
374 while self.grid[1][j] == FADING:
375 for i in range(1, GRID_W - 1):
376 self.grid[i][j] = EMPTY
377 for j2 in range(j - 1, -1, -1):
378 for i2 in range(1, GRID_W - 1):
379 v = self.grid[i2][j2]
380 if v == FULL:
381 self.grid[i2][j2 + 1] = FULL
382 self.grid[i2][j2] = EMPTY
383 elif v == FADING:
384 self.grid[i2][j2 + 1] = FADING
385 self.grid[i2][j2] = EMPTY
386 deleted += 1
387 return deleted
388
389 # ------------------------------------------------------------------
390 # Drawing
391 # ------------------------------------------------------------------
392 def on_draw(self, renderer):
393 # The layout follows the window: every size below comes off the live
394 # size of the target this node draws into, never a baked constant.
395 sw, sh = self.viewport_size
396 sq, ox, oy = self._layout(sw, sh)
397
398 renderer.rect((0, 0), (sw, sh), fill=COL_BG)
399
400 # Pick text scales that fit the current window width. Line heights come
401 # from the renderer's font metrics rather than a hard-coded constant.
402 title = "TETRIS"
403 prompt = "PRESS [SPACE], TAP, OR (A) TO START"
404 hint = "MOVE: ARROWS / WASD / D-PAD ROTATE: UP / (A) DROP: DOWN / (B)"
405 title_scale = self._fit_scale(renderer, title, target_w=sw * 0.5, max_scale=8)
406 prompt_scale = self._fit_scale(renderer, prompt, target_w=sw * 0.85, max_scale=2)
407 hint_scale = self._fit_scale(renderer, hint, target_w=sw * 0.9, max_scale=2)
408 line_h = renderer.text_height
409
410 if self._state == STATE_MENU:
411 gap = 10
412 block_h = line_h(title, title_scale) + gap + line_h(prompt, prompt_scale) + gap + line_h(hint, hint_scale)
413 y = sh // 2 - block_h // 2
414 self._draw_centered(renderer, title, scale=title_scale, y=y, width=sw, colour=COL_MOVING)
415 y += line_h(title, title_scale) + gap
416 self._draw_centered(renderer, prompt, scale=prompt_scale, y=y, width=sw)
417 y += line_h(prompt, prompt_scale) + gap
418 self._draw_centered(renderer, hint, scale=hint_scale, y=y, width=sw, colour=HINT_COLOUR)
419 return
420
421 if self._state == STATE_OVER:
422 over = "GAME OVER"
423 score = f"LINES {self.lines:04d}"
424 again = "PRESS [SPACE], TAP, OR (A) TO PLAY AGAIN"
425 game_over_scale = self._fit_scale(renderer, over, target_w=sw * 0.7, max_scale=6)
426 score_scale = max(2, game_over_scale - 2)
427 again_scale = self._fit_scale(renderer, again, target_w=sw * 0.85, max_scale=2)
428 gap = 12
429 block_h = (
430 line_h(over, game_over_scale) + gap + line_h(score, score_scale) + gap + line_h(again, again_scale)
431 )
432 y = sh // 2 - block_h // 2
433 self._draw_centered(renderer, over, scale=game_over_scale, y=y, width=sw, colour=(0.78, 0.20, 0.20))
434 y += line_h(over, game_over_scale) + gap
435 self._draw_centered(renderer, score, scale=score_scale, y=y, width=sw)
436 y += line_h(score, score_scale) + gap
437 self._draw_centered(renderer, again, scale=again_scale, y=y, width=sw, colour=HINT_COLOUR)
438 return
439
440 # ----- play -----
441 # Fading colour pulse
442 fade_a = (self.fade_counter // 4) % 2 == 0
443 fade_col = COL_FADING_A if fade_a else COL_FADING_B
444
445 for j in range(GRID_H):
446 for i in range(GRID_W):
447 cell = self.grid[i][j]
448 x = ox + i * sq
449 y = oy + j * sq
450 if cell == EMPTY:
451 renderer.rect((x, y), (sq, sq), stroke=COL_EMPTY_LINE)
452 elif cell == BLOCK:
453 renderer.rect((x, y), (sq, sq), fill=COL_BLOCK)
454 elif cell == FULL:
455 renderer.rect((x, y), (sq, sq), fill=COL_FULL)
456 elif cell == MOVING:
457 renderer.rect((x, y), (sq, sq), fill=COL_MOVING)
458 elif cell == FADING:
459 renderer.rect((x, y), (sq, sq), fill=fade_col)
460
461 # Incoming preview, sized to the dynamic square.
462 side_scale = max(1, sq // 12)
463 prev_x = ox + GRID_W * sq + sq
464 prev_y = oy + sq
465 renderer.text("NEXT", (prev_x, prev_y - line_h("NEXT", side_scale) - 4), fill=COL_TEXT, scale=side_scale)
466 for i in range(4):
467 for j in range(4):
468 x = prev_x + i * sq
469 y = prev_y + j * sq
470 if self.incoming[i][j] == MOVING:
471 renderer.rect((x, y), (sq, sq), fill=COL_FULL)
472 else:
473 renderer.rect((x, y), (sq, sq), stroke=COL_EMPTY_LINE)
474
475 score = f"LINES {self.lines:04d}"
476 score_y = prev_y + 4 * sq + line_h(score, side_scale)
477 renderer.text(score, (prev_x, score_y), fill=COL_TEXT, scale=side_scale)
478
479 # In-game controls, stacked down the side column under the score. They
480 # stay out of the bottom corners, which is where the on-screen pad puts
481 # its D-pad and its face buttons.
482 self._draw_controls_panel(
483 renderer,
484 [
485 "MOVE: ARROWS / D-PAD",
486 "ROTATE: UP / (A)",
487 "DROP: DOWN / (B)",
488 "PAUSE: P / START",
489 "QUIT: ESC",
490 ],
491 x=prev_x,
492 y=score_y + line_h(score, side_scale) * 2,
493 max_width=sw - prev_x - sq // 2,
494 )
495
496 if self.paused:
497 resume = "PAUSED - TAP OR PRESS P TO RESUME"
498 scale = self._fit_scale(renderer, resume, target_w=sw * 0.8, max_scale=4)
499 self._draw_centered(
500 renderer, resume, scale=scale, y=sh // 2 - line_h(resume, scale) // 2, width=sw, colour=COL_TEXT
501 )
502
503 def _draw_controls_panel(self, renderer, lines: list[str], *, x: float, y: float, max_width: float) -> None:
504 """Left-justified controls hint, stacked downwards from ``(x, y)``."""
505 widest = max(lines, key=len)
506 scale = self._fit_scale(renderer, widest, target_w=max_width, max_scale=2)
507 line_height = renderer.text_height(widest, scale)
508 for line in lines:
509 renderer.text(line, (x, y), fill=HINT_COLOUR, scale=scale)
510 y += line_height
511
512 def _draw_centered(self, renderer, text, *, scale, y, width, colour=COL_TEXT):
513 renderer.text(text, (width // 2, y), fill=colour, scale=scale, align="centre")
514
515 def _fit_scale(self, renderer, text: str, *, target_w: float, max_scale: int) -> int:
516 for s in range(max_scale, 0, -1):
517 if renderer.text_width(text, s) <= target_w:
518 return s
519 return 1