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. By touch, tap left or right of the board
10to move, above it to rotate, below it to soft-drop, and on the side panel to
11pause.
12"""
13
14from __future__ import annotations
15
16import random
17
18from simvx.core import Input, InputMap, Key, MouseButton, Node2D, Property, Signal
19
20# ---------------------------------------------------------------------------
21# Constants (mirror the C original where reasonable)
22# ---------------------------------------------------------------------------
23
24GRID_W = 12 # includes 1-cell sentinel walls left/right
25GRID_H = 21 # includes 1-cell sentinel floor at the bottom
26
27# State machine
28STATE_MENU = "menu"
29STATE_PLAY = "play"
30STATE_OVER = "over"
31
32HINT_COLOUR = (0.70, 0.70, 0.70)
33
34LATERAL_AUTOREPEAT = 6 # ticks between auto-repeat moves while held
35FAST_FALL_HOLD = 18 # ticks before soft-drop kicks in
36FADING_TIME = 18 # ticks to flash a completed line
37
38EMPTY, MOVING, FULL, BLOCK, FADING = 0, 1, 2, 3, 4
39
40# One-shot actions: latched once per rendered frame, consumed by one fixed step.
41EDGE_ACTIONS = ("quit", "restart", "pause", "tap", "rotate", "soft_drop", "move_left", "move_right")
42START_ACTIONS = frozenset({"restart", "tap", "rotate", "soft_drop", "move_left", "move_right"})
43
44# Cell colours
45COL_BG = (0.96, 0.96, 0.96)
46COL_EMPTY_LINE = (0.78, 0.78, 0.78)
47COL_BLOCK = (0.78, 0.78, 0.78)
48COL_FULL = (0.51, 0.51, 0.51)
49COL_MOVING = (0.31, 0.31, 0.31)
50COL_TEXT = (0.51, 0.51, 0.51)
51COL_FADING_A = (0.74, 0.20, 0.20)
52COL_FADING_B = (0.51, 0.51, 0.51)
53
54# Seven 4x4 pieces from the original (column-major like the C source).
55PIECES: list[list[tuple[int, int]]] = [
56 [(1, 1), (2, 1), (1, 2), (2, 2)], # O
57 [(1, 0), (1, 1), (1, 2), (2, 2)], # L
58 [(1, 2), (2, 0), (2, 1), (2, 2)], # J
59 [(0, 1), (1, 1), (2, 1), (3, 1)], # I
60 [(1, 0), (1, 1), (1, 2), (2, 1)], # T
61 [(1, 1), (2, 1), (2, 2), (3, 2)], # S
62 [(1, 2), (2, 2), (2, 1), (3, 1)], # Z
63]
64
65
66def _new_grid() -> list[list[int]]:
67 g = [[EMPTY for _ in range(GRID_H)] for _ in range(GRID_W)]
68 for i in range(GRID_W):
69 for j in range(GRID_H):
70 if j == GRID_H - 1 or i == 0 or i == GRID_W - 1:
71 g[i][j] = BLOCK
72 return g
73
74
75def _new_piece() -> list[list[int]]:
76 return [[EMPTY for _ in range(4)] for _ in range(4)]
77
78
79def _random_piece() -> list[list[int]]:
80 p = _new_piece()
81 for x, y in random.choice(PIECES):
82 p[x][y] = MOVING
83 return p
84
85
86# ---------------------------------------------------------------------------
87# Game node
88# ---------------------------------------------------------------------------
89
90
91class TetrisGame(Node2D):
92 gravity_speed = Property(28, range=(4, 60), hint="Ticks between gravity steps")
93
94 line_cleared = Signal[int] # number of lines this clear
95 died = Signal[int] # final line count
96
97 def __init__(self, **kw):
98 super().__init__(name="TetrisGame", **kw)
99 # The whole playfield is drawn immediate-mode in on_draw from plain (non-
100 # Property) grid/piece state that changes continuously during play (gravity,
101 # moves, fades) and on menu<->play<->over transitions, with nothing to auto-
102 # dirty this node. Mark it `dynamic` so its on_draw re-captures every frame.
103 self.dynamic = True
104 self._state = STATE_MENU
105 self._tap_intent: str | None = None
106 # One-shot input latched per rendered frame (see on_update).
107 self._pressed: set[str] = set()
108 self._tap_position: tuple[float, float] | None = None
109 # Cached layout, recomputed each frame.
110 self._square = 24
111 self._origin_x = 0
112 self._origin_y = 0
113 self._screen_w = 600
114 self._screen_h = 600
115 self._reset()
116
117 # ------------------------------------------------------------------
118 def on_ready(self):
119 InputMap.add_action("move_left", [Key.LEFT, Key.A])
120 InputMap.add_action("move_right", [Key.RIGHT, Key.D])
121 InputMap.add_action("soft_drop", [Key.DOWN, Key.S])
122 InputMap.add_action("rotate", [Key.UP, Key.W])
123 InputMap.add_action("pause", [Key.P])
124 InputMap.add_action("restart", [Key.ENTER, Key.SPACE])
125 InputMap.add_action("quit", [Key.ESCAPE])
126 # Mobile / touch: tap zones (left/right of board to move, above it to
127 # rotate, below it to soft-drop, side panel to pause). The action is the
128 # same; the tap position latched in on_update picks the zone.
129 InputMap.add_action("tap", [MouseButton.LEFT])
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 _recompute_layout(self):
150 if self.tree:
151 self._screen_w, self._screen_h = self.tree.screen_size
152 # The playfield is GRID_W × GRID_H cells; reserve room above for the
153 # title strip and below for the controls hint, plus a NEXT-preview
154 # area beside it (4 cells wide + a 1-cell gap).
155 side_cells = 5
156 hud_rows = 3 # top + bottom margins in cell units
157 max_w = self._screen_w / (GRID_W + side_cells)
158 max_h = self._screen_h / (GRID_H + hud_rows)
159 self._square = max(6, int(min(max_w, max_h)))
160 play_w = self._square * GRID_W
161 # Centre the playfield + side panel block horizontally.
162 block_w = play_w + self._square * side_cells
163 self._origin_x = (self._screen_w - block_w) // 2
164 self._origin_y = max(self._square, (self._screen_h - self._square * GRID_H) // 2)
165
166 # ------------------------------------------------------------------
167 # Update
168 # ------------------------------------------------------------------
169 def on_update(self, dt):
170 """Latch one-shot input once per rendered frame.
171
172 A just-pressed edge lives for exactly one rendered frame, while the fixed
173 step runs zero times on a fast frame and several times on a slow one.
174 Polling edges straight from on_fixed_update would therefore swallow a tap
175 above 60fps and fire it twice below, so they are collected here and each
176 one is consumed by a single fixed step.
177 """
178 for action in EDGE_ACTIONS:
179 if Input.is_action_just_pressed(action):
180 self._pressed.add(action)
181 if "tap" in self._pressed and self._tap_position is None:
182 tap = Input.mouse_position
183 self._tap_position = (float(tap.x), float(tap.y))
184
185 def _tap_zone(self, tap: tuple[float, float]) -> str | None:
186 """Map a tap in screen pixels to a play-state intent."""
187 sq = self._square
188 if sq <= 0:
189 return None
190 tx, ty = tap
191 ox, oy = self._origin_x, self._origin_y
192 board_right = ox + GRID_W * sq
193 board_bottom = oy + GRID_H * sq
194 if tx >= board_right and oy <= ty <= board_bottom:
195 return "pause"
196 if ty < oy:
197 return "rotate"
198 if ty > board_bottom:
199 return "soft_drop"
200 return "move_left" if tx < ox + (GRID_W * sq) / 2 else "move_right"
201
202 def on_fixed_update(self, dt):
203 pressed = self._pressed
204 self._pressed = set()
205 tap_pos, self._tap_position = self._tap_position, None
206
207 if "quit" in pressed:
208 self.app.quit()
209 return
210
211 if self._state == STATE_MENU:
212 if pressed & START_ACTIONS:
213 self._reset()
214 self._state = STATE_PLAY
215 return
216
217 if self._state == STATE_OVER:
218 if "restart" in pressed or "tap" in pressed:
219 self._reset()
220 self._state = STATE_PLAY
221 return
222
223 # Mobile / touch: the zone the tap landed in becomes a one-shot "pause",
224 # "rotate", "move_left", "move_right", or "soft_drop" intent. While
225 # paused, any tap resumes.
226 tap_intent = self._tap_zone(tap_pos) if tap_pos is not None else None
227 if "pause" in pressed or tap_intent == "pause" or (self.paused and tap_intent is not None):
228 self.paused = not self.paused
229 tap_intent = None
230 if self.paused:
231 return
232 self._tap_intent = tap_intent
233
234 if self.line_to_delete:
235 self.fade_counter += 1
236 if self.fade_counter >= FADING_TIME:
237 cleared = self._delete_complete_lines()
238 self.lines += cleared
239 self.line_cleared(cleared)
240 self.fade_counter = 0
241 self.line_to_delete = False
242 return
243
244 if not self.piece_active:
245 self.piece_active = self._create_piece()
246 self.fast_fall_counter = 0
247 else:
248 self.fast_fall_counter += 1
249 self.gravity_counter += 1
250 self.lateral_counter += 1
251
252 if "move_left" in pressed or "move_right" in pressed or self._tap_intent in ("move_left", "move_right"):
253 self.lateral_counter = LATERAL_AUTOREPEAT
254 # Rotation is one turn per press: moving sideways auto-repeats while
255 # held, but holding the rotate key must not spin the piece.
256 if "rotate" in pressed or self._tap_intent == "rotate":
257 self._resolve_turn_movement()
258
259 if (Input.is_action_pressed("soft_drop") or self._tap_intent == "soft_drop") and (
260 self._tap_intent == "soft_drop" or self.fast_fall_counter >= FAST_FALL_HOLD
261 ):
262 self.gravity_counter += self.gravity_speed
263
264 if self.gravity_counter >= self.gravity_speed:
265 self._check_detection()
266 self._resolve_falling_movement()
267 self._check_completion()
268 self.gravity_counter = 0
269
270 if self.lateral_counter >= LATERAL_AUTOREPEAT:
271 if not self._resolve_lateral_movement():
272 self.lateral_counter = 0
273
274 # Game over: anything FULL in the top two rows
275 for j in range(2):
276 for i in range(1, GRID_W - 1):
277 if self.grid[i][j] == FULL:
278 self._state = STATE_OVER
279 self.died(self.lines)
280 return
281
282 # ------------------------------------------------------------------
283 # Piece handling
284 # ------------------------------------------------------------------
285 def _create_piece(self) -> bool:
286 self.piece_x = (GRID_W - 4) // 2
287 self.piece_y = 0
288
289 if self.begin_play:
290 self.incoming = _random_piece()
291 self.begin_play = False
292
293 # Promote incoming → current
294 for i in range(4):
295 for j in range(4):
296 self.piece[i][j] = self.incoming[i][j]
297 self.incoming = _random_piece()
298
299 for i in range(self.piece_x, self.piece_x + 4):
300 for j in range(4):
301 if self.piece[i - self.piece_x][j] == MOVING:
302 self.grid[i][j] = MOVING
303 return True
304
305 def _check_detection(self):
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 self.grid[i][j + 1] in (FULL, BLOCK):
309 self.detection = True
310 return
311
312 def _resolve_falling_movement(self):
313 if self.detection:
314 for j in range(GRID_H - 2, -1, -1):
315 for i in range(1, GRID_W - 1):
316 if self.grid[i][j] == MOVING:
317 self.grid[i][j] = FULL
318 self.detection = False
319 self.piece_active = False
320 else:
321 for j in range(GRID_H - 2, -1, -1):
322 for i in range(1, GRID_W - 1):
323 if self.grid[i][j] == MOVING:
324 self.grid[i][j + 1] = MOVING
325 self.grid[i][j] = EMPTY
326 self.piece_y += 1
327
328 def _resolve_lateral_movement(self) -> bool:
329 """Returns True on collision, False on success, matches C convention."""
330 collision = False
331 if Input.is_action_pressed("move_left") or self._tap_intent == "move_left":
332 for j in range(GRID_H - 2, -1, -1):
333 for i in range(1, GRID_W - 1):
334 if self.grid[i][j] == MOVING and (i - 1 == 0 or self.grid[i - 1][j] == FULL):
335 collision = True
336 break
337 if collision:
338 break
339 if not collision:
340 for j in range(GRID_H - 2, -1, -1):
341 for i in range(1, GRID_W - 1):
342 if self.grid[i][j] == MOVING:
343 self.grid[i - 1][j] = MOVING
344 self.grid[i][j] = EMPTY
345 self.piece_x -= 1
346 elif Input.is_action_pressed("move_right") or self._tap_intent == "move_right":
347 for j in range(GRID_H - 2, -1, -1):
348 for i in range(1, GRID_W - 1):
349 if self.grid[i][j] == MOVING and (i + 1 == GRID_W - 1 or self.grid[i + 1][j] == FULL):
350 collision = True
351 break
352 if collision:
353 break
354 if not collision:
355 for j in range(GRID_H - 2, -1, -1):
356 for i in range(GRID_W - 1, 0, -1):
357 if self.grid[i][j] == MOVING:
358 self.grid[i + 1][j] = MOVING
359 self.grid[i][j] = EMPTY
360 self.piece_x += 1
361 return collision
362
363 def _resolve_turn_movement(self) -> bool:
364 # Rotate the local 4x4 piece grid 90 degrees CW. We then check whether
365 # that rotation would intersect any FULL or BLOCK cells; if so, abort.
366 new_piece = _new_piece()
367 for i in range(4):
368 for j in range(4):
369 new_piece[3 - j][i] = self.piece[i][j]
370
371 # Validate: new_piece offsets must land in EMPTY or MOVING cells of grid
372 for i in range(4):
373 for j in range(4):
374 if new_piece[i][j] != MOVING:
375 continue
376 gx = self.piece_x + i
377 gy = self.piece_y + j
378 if not (0 <= gx < GRID_W and 0 <= gy < GRID_H):
379 return False
380 cell = self.grid[gx][gy]
381 if cell == FULL or cell == BLOCK:
382 return False
383
384 # Clear current MOVING cells
385 for j in range(GRID_H - 1):
386 for i in range(1, GRID_W - 1):
387 if self.grid[i][j] == MOVING:
388 self.grid[i][j] = EMPTY
389
390 # Apply rotated piece
391 self.piece = new_piece
392 for i in range(4):
393 for j in range(4):
394 if self.piece[i][j] == MOVING:
395 gx = self.piece_x + i
396 gy = self.piece_y + j
397 if 0 <= gx < GRID_W and 0 <= gy < GRID_H:
398 self.grid[gx][gy] = MOVING
399 return True
400
401 def _check_completion(self):
402 for j in range(GRID_H - 2, -1, -1):
403 count = 0
404 for i in range(1, GRID_W - 1):
405 if self.grid[i][j] == FULL:
406 count += 1
407 if count == GRID_W - 2:
408 self.line_to_delete = True
409 for z in range(1, GRID_W - 1):
410 self.grid[z][j] = FADING
411
412 def _delete_complete_lines(self) -> int:
413 deleted = 0
414 for j in range(GRID_H - 2, -1, -1):
415 while self.grid[1][j] == FADING:
416 for i in range(1, GRID_W - 1):
417 self.grid[i][j] = EMPTY
418 for j2 in range(j - 1, -1, -1):
419 for i2 in range(1, GRID_W - 1):
420 v = self.grid[i2][j2]
421 if v == FULL:
422 self.grid[i2][j2 + 1] = FULL
423 self.grid[i2][j2] = EMPTY
424 elif v == FADING:
425 self.grid[i2][j2 + 1] = FADING
426 self.grid[i2][j2] = EMPTY
427 deleted += 1
428 return deleted
429
430 # ------------------------------------------------------------------
431 # Drawing
432 # ------------------------------------------------------------------
433 def on_draw(self, renderer):
434 self._recompute_layout()
435 sw, sh = self._screen_w, self._screen_h
436 sq = self._square
437 ox, oy = self._origin_x, self._origin_y
438
439 renderer.draw_rect((0, 0), (sw, sh), colour=COL_BG, filled=True)
440
441 # Pick text scales that fit the current window width. Line heights come
442 # from the renderer's font metrics rather than a hard-coded constant.
443 title = "TETRIS"
444 prompt = "PRESS [SPACE] OR [ENTER], OR TAP, TO START"
445 hint = "ARROWS / WASD : MOVE UP / W : ROTATE DOWN : SOFT-DROP"
446 title_scale = self._fit_scale(renderer, title, target_w=sw * 0.5, max_scale=8)
447 prompt_scale = self._fit_scale(renderer, prompt, target_w=sw * 0.85, max_scale=2)
448 hint_scale = self._fit_scale(renderer, hint, target_w=sw * 0.9, max_scale=2)
449 line_h = renderer.text_height
450
451 if self._state == STATE_MENU:
452 gap = 10
453 block_h = line_h(title, title_scale) + gap + line_h(prompt, prompt_scale) + gap + line_h(hint, hint_scale)
454 y = sh // 2 - block_h // 2
455 self._draw_centered(renderer, title, scale=title_scale, y=y, colour=COL_MOVING)
456 y += line_h(title, title_scale) + gap
457 self._draw_centered(renderer, prompt, scale=prompt_scale, y=y)
458 y += line_h(prompt, prompt_scale) + gap
459 self._draw_centered(renderer, hint, scale=hint_scale, y=y, colour=HINT_COLOUR)
460 return
461
462 if self._state == STATE_OVER:
463 over = "GAME OVER"
464 score = f"LINES {self.lines:04d}"
465 again = "PRESS [SPACE] OR [ENTER], OR TAP, TO PLAY AGAIN"
466 game_over_scale = self._fit_scale(renderer, over, target_w=sw * 0.7, max_scale=6)
467 score_scale = max(2, game_over_scale - 2)
468 again_scale = self._fit_scale(renderer, again, target_w=sw * 0.85, max_scale=2)
469 gap = 12
470 block_h = (
471 line_h(over, game_over_scale) + gap + line_h(score, score_scale) + gap + line_h(again, again_scale)
472 )
473 y = sh // 2 - block_h // 2
474 self._draw_centered(renderer, over, scale=game_over_scale, y=y, colour=(0.78, 0.20, 0.20))
475 y += line_h(over, game_over_scale) + gap
476 self._draw_centered(renderer, score, scale=score_scale, y=y)
477 y += line_h(score, score_scale) + gap
478 self._draw_centered(renderer, again, scale=again_scale, y=y, colour=HINT_COLOUR)
479 return
480
481 # ----- play -----
482 # Fading colour pulse
483 fade_a = (self.fade_counter // 4) % 2 == 0
484 fade_col = COL_FADING_A if fade_a else COL_FADING_B
485
486 for j in range(GRID_H):
487 for i in range(GRID_W):
488 cell = self.grid[i][j]
489 x = ox + i * sq
490 y = oy + j * sq
491 if cell == EMPTY:
492 renderer.draw_rect((x, y), (sq, sq), colour=COL_EMPTY_LINE, filled=False)
493 elif cell == BLOCK:
494 renderer.draw_rect((x, y), (sq, sq), colour=COL_BLOCK, filled=True)
495 elif cell == FULL:
496 renderer.draw_rect((x, y), (sq, sq), colour=COL_FULL, filled=True)
497 elif cell == MOVING:
498 renderer.draw_rect((x, y), (sq, sq), colour=COL_MOVING, filled=True)
499 elif cell == FADING:
500 renderer.draw_rect((x, y), (sq, sq), colour=fade_col, filled=True)
501
502 # Incoming preview, sized to the dynamic square.
503 side_scale = max(1, sq // 12)
504 prev_x = ox + GRID_W * sq + sq
505 prev_y = oy + sq
506 renderer.draw_text("NEXT", (prev_x, prev_y - line_h("NEXT", side_scale) - 4), scale=side_scale, colour=COL_TEXT)
507 for i in range(4):
508 for j in range(4):
509 x = prev_x + i * sq
510 y = prev_y + j * sq
511 if self.incoming[i][j] == MOVING:
512 renderer.draw_rect((x, y), (sq, sq), colour=COL_FULL, filled=True)
513 else:
514 renderer.draw_rect((x, y), (sq, sq), colour=COL_EMPTY_LINE, filled=False)
515
516 score = f"LINES {self.lines:04d}"
517 renderer.draw_text(
518 score, (prev_x, prev_y + 4 * sq + line_h(score, side_scale)), scale=side_scale, colour=COL_TEXT
519 )
520
521 if self.paused:
522 self._draw_centered(renderer, "PAUSED", scale=4, y=sh // 2 - line_h("PAUSED", 4) // 2, colour=COL_TEXT)
523
524 # In-game controls: vertical stack, bottom-right, light grey.
525 self._draw_controls_panel(
526 renderer,
527 [
528 "ARROWS/WASD: MOVE",
529 "UP/W: ROTATE",
530 "DOWN: SOFT-DROP",
531 "P / TAP PANEL: PAUSE",
532 "ESC: QUIT",
533 ],
534 )
535
536 def _draw_controls_panel(self, renderer, lines: list[str]) -> None:
537 """Vertical, bottom-right anchored, left-justified controls hint."""
538 sw, sh = self._screen_w, self._screen_h
539 widest = max(lines, key=len)
540 # Pick the largest scale that keeps the widest line under 30% of width.
541 scale = self._fit_scale(renderer, widest, target_w=sw * 0.30, max_scale=2)
542 line_height = renderer.text_height(widest, scale)
543 margin = 8
544 widest_w = renderer.text_width(widest, scale)
545 panel_x = sw - widest_w - margin
546 y = sh - line_height * len(lines) - margin
547 for line in lines:
548 renderer.draw_text(line, (panel_x, y), scale=scale, colour=HINT_COLOUR)
549 y += line_height
550
551 def _draw_centered(self, renderer, text, *, scale, y, colour=COL_TEXT):
552 renderer.draw_text(text, (self._screen_w // 2, y), scale=scale, colour=colour, alignment="centre")
553
554 def _fit_scale(self, renderer, text: str, *, target_w: float, max_scale: int) -> int:
555 for s in range(max_scale, 0, -1):
556 if renderer.text_width(text, s) <= target_w:
557 return s
558 return 1