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