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