nodes/snake_game.py¶

Part of Snake (raylib classic).

  1"""Snake: gameplay node.
  2
  3A re-implementation of raylib classic ``snake.c`` as a single SimVX Node2D.
  4Grid-based movement, body grows on fruit pickup, game over on wall or self
  5collision.
  6
  7Steering is one typed ``move`` action, so the arrow keys, WASD, a controller's
  8D-pad and its left stick all feed the same reading; the game takes the dominant
  9axis of it and stays four-way. A tap ahead of the head steers too, and on a
 10touch screen the node puts a ``TouchController`` on the glass, which publishes a
 11real pad and therefore drives those same bindings.
 12"""
 13
 14from __future__ import annotations
 15
 16import random
 17
 18from simvx.core import Axis2D, Controller, Input, InputMethod, Key, Mouse, Node2D, Property, Signal, on_input
 19from simvx.core.ui import TouchController, TouchControllerLayout
 20
 21# Logical grid; the play area scales to fill the window each frame.
 22COLS = 31
 23ROWS = 24
 24
 25# Tick every N physics frames (raylib used framesCounter%5 at 60 fps)
 26STEP_FRAMES = 5
 27
 28# Menu / play / game-over states
 29STATE_MENU = "menu"
 30STATE_PLAY = "play"
 31STATE_OVER = "over"
 32
 33START_PROMPT = "PRESS [SPACE], TAP, OR (A) TO START"
 34RESTART_PROMPT = "PRESS [SPACE] / [ENTER], TAP, OR (A) TO PLAY AGAIN"
 35MENU_HINT = "STEER: ARROWS / WASD / D-PAD / STICK     PAUSE: P OR (B)     QUIT: ESC"
 36PLAY_HINT = "STEER: ARROWS / WASD / D-PAD / TAP     PAUSE: P / (B)"
 37
 38HEAD_COLOUR = (0.0, 0.32, 0.67)
 39BODY_COLOUR = (0.0, 0.47, 0.95)
 40FRUIT_COLOUR = (0.40, 0.75, 1.0)
 41GRID_COLOUR = (0.78, 0.78, 0.78)
 42BG_COLOUR = (0.96, 0.96, 0.96)
 43TEXT_COLOUR = (0.51, 0.51, 0.51)
 44HINT_COLOUR = (0.70, 0.70, 0.70)
 45
 46
 47def _sign(value: float) -> int:
 48    """-1, 0 or 1. An action already applies its deadzone, so 0.0 means at rest."""
 49    number = float(value)
 50    if number == 0.0:
 51        return 0
 52    return 1 if number > 0.0 else -1
 53
 54
 55class SnakeGame(Node2D):
 56    """Single-screen Snake. Grid-based, emits ``died`` and ``ate`` signals."""
 57
 58    #: What the player can do. Declared on the scene root, so it is in force
 59    #: whenever this scene is mounted: ``InputMap`` has no tree in ``main()``,
 60    #: and a test that loads this node alone never reaches ``main()`` at all.
 61    input_context = {
 62        # One typed action rather than four opposed ones, so the arrow keys,
 63        # WASD, a pad's D-pad and its left stick all feed the same reading and
 64        # whichever is furthest from rest wins. The snake still turns four
 65        # ways: the game takes the dominant axis of the vector.
 66        "move": [
 67            Axis2D(up=Key.UP, down=Key.DOWN, left=Key.LEFT, right=Key.RIGHT),
 68            Axis2D(up=Key.W, down=Key.S, left=Key.A, right=Key.D),
 69            Axis2D(
 70                up=Controller.DPAD_UP,
 71                down=Controller.DPAD_DOWN,
 72                left=Controller.DPAD_LEFT,
 73                right=Controller.DPAD_RIGHT,
 74            ),
 75            Controller.LEFT_STICK,
 76        ],
 77        # A tap counts as start, because the web runtime delivers touch as a
 78        # left click, and so does a face button, so a pad player can get in.
 79        "start": [Key.SPACE, Key.ENTER, Mouse.LEFT, Controller.A, Controller.START],
 80        # Start is on both: the two handlers are exclusive by state, so the
 81        # button the pad always draws starts a run and then pauses it.
 82        "pause": [Key.P, Controller.B, Controller.START],
 83        "quit": [Key.ESCAPE, Controller.BACK],
 84        # In play, a tap on the board steers and a tap on the score strip's
 85        # middle third pauses. The position comes off the pointer.
 86        "tap": [Mouse.LEFT],
 87    }
 88
 89    step_frames = Property(STEP_FRAMES, range=(1, 30), hint="Physics frames per move")
 90
 91    died = Signal(int)  # final score
 92    ate = Signal(int)  # current length
 93
 94    def __init__(self, **kw):
 95        super().__init__(name="SnakeGame", **kw)
 96        # on_draw reads plain (non-Property) state: the snake body, the score and
 97        # the layout cache. Nothing marks this node dirty when those change, so a
 98        # normal node would draw once and freeze. `dynamic` re-captures the draw
 99        # every frame instead.
100        self.dynamic = True
101        self._frames = 0
102        self._state = STATE_MENU
103        self._paused = False
104        self._pending_dir: tuple[int, int] | None = None
105        self._body: list[tuple[int, int]] = []
106        self._dir: tuple[int, int] = (1, 0)
107        self._fruit: tuple[int, int] | None = None
108        self._score = 0
109        # The direction the move action last reported, as a pair of signs. The
110        # game acts on a change rather than on every frame, so a direction still
111        # held when the snake dies does not restart the run under the player.
112        self._held_move: tuple[int, int] = (0, 0)
113        self._pad: TouchController | None = None
114        # Layout, recomputed from the live viewport size every frame.
115        self._cell = 1
116        self._origin_x = 0.0
117        self._origin_y = 0.0
118        self._screen_w = 0.0
119        self._screen_h = 0.0
120        self._content_h = 0.0
121        self._hud_h = 0.0
122        # Rects inside the strip above the board, as (x, y, w, h).
123        self._pause_rect = (0.0, 0.0, 0.0, 0.0)
124        self._help_rect = (0.0, 0.0, 0.0, 0.0)
125
126    # ------------------------------------------------------------------
127    # Lifecycle
128    # ------------------------------------------------------------------
129    def on_ready(self):
130        # The on-screen pad is a controller: it publishes under a pad id, so the
131        # Controller and D-pad bindings above are the whole of the touch
132        # support and nothing here reads it. A grid game asks for the D-pad
133        # layout, because an analogue reading would be noise on a board of
134        # whole cells. It leaves the screen by itself once a real controller
135        # is used.
136        self._pad = self.add_child(
137            TouchController(
138                name="TouchController",
139                layout=TouchControllerLayout.DPAD_AND_BUTTONS,
140                visible=Input.capabilities.coarse_pointer,
141            )
142        )
143        self._recompute_layout()
144        self._new_game()
145
146    def _new_game(self):
147        """Reset board state but keep state machine on the menu."""
148        self._frames = 0
149        self._paused = False
150        self._pending_dir = None
151        self._dir = (1, 0)
152        cx, cy = COLS // 2, ROWS // 2
153        self._body = [(cx, cy)]
154        self._score = 0
155        self._spawn_fruit()
156
157    # ------------------------------------------------------------------
158    # Layout
159    # ------------------------------------------------------------------
160    def _pad_band(self) -> float:
161        """How much of the bottom of the screen the drawn pad is covering.
162
163        Measured off the controls themselves rather than guessed, so the board
164        is laid out above whatever the pad actually occupies at this window
165        size and a thumb never covers the snake.
166        """
167        pad = self._pad
168        if pad is None or not pad.visible:
169            return 0.0
170        tops = [float(control.world_position.y) for control in pad.children]
171        if not tops:
172            return 0.0
173        return max(0.0, self._screen_h - min(tops))
174
175    def _recompute_layout(self):
176        """Fit the COLS by ROWS grid into the part of the window the board owns."""
177        width, height = self.viewport_size
178        self._screen_w, self._screen_h = float(width), float(height)
179        # Everything but the band the drawn pad is sitting in.
180        self._content_h = max(1.0, self._screen_h - self._pad_band())
181        # Four spare rows of height pay for the strip above the board, so the
182        # strip always fits whatever the window does.
183        self._cell = max(1, int(min(self._screen_w // COLS, self._content_h // (ROWS + 4))))
184        play_w = self._cell * COLS
185        play_h = self._cell * ROWS
186        self._hud_h = float(self._cell * 4)
187        self._origin_x = (self._screen_w - play_w) / 2
188        self._origin_y = self._hud_h + max(0.0, (self._content_h - play_h - self._hud_h) / 2)
189        # Strip above the board, four rows tall: score left, pause target
190        # centre and length right across the top half, the controls line
191        # across the bottom half.
192        strip_y = self._origin_y - self._hud_h
193        score_h = float(self._cell * 2)
194        self._pause_rect = (self._origin_x + play_w / 3, strip_y, play_w / 3, score_h)
195        self._help_rect = (self._origin_x, strip_y + score_h, float(play_w), self._hud_h - score_h)
196
197    # ------------------------------------------------------------------
198    # Input: discrete actions are events, steering is a polled direction
199    # ------------------------------------------------------------------
200    # Pausing, quitting and starting are single presses, so each is a handler
201    # the router calls once per event. Steering is the state of one typed
202    # action instead, read each frame and buffered into a one-slot queue the
203    # step drains: a turn has to land on a step boundary, and buffering it is
204    # also what lets a fast player queue the next turn.
205
206    @on_input("quit")
207    def _on_quit(self, event):
208        self.app.quit()
209        return True
210
211    @on_input("pause")
212    def _on_pause(self, event):
213        if self._state != STATE_PLAY:
214            return False
215        self._paused = not self._paused
216        return True
217
218    @on_input("start")
219    def _on_start(self, event):
220        return self._start_if_waiting()
221
222    @on_input("tap")
223    def _on_tap(self, event):
224        # On the title and game-over screens a tap is a start, which the start
225        # action already carries: the left button is bound to both.
226        if self._state != STATE_PLAY:
227            return False
228        pos = Input.mouse_position
229        if self._in_pause_button(pos):
230            self._paused = not self._paused
231            return True
232        self._steer_towards(pos)
233        return True
234
235    def on_update(self, dt):
236        """Read the steering action, acting on a change rather than on a hold.
237
238        A change is the press the player made, whatever device made it, so a
239        direction still held when the snake dies does not restart the run
240        under them, and a held key does not re-buffer the same turn.
241        """
242        self._show_pad_on_a_touch_screen()
243        wanted = Input.value("move")
244        held = (_sign(wanted.x), _sign(wanted.y))
245        if held == self._held_move:
246            return
247        self._held_move = held
248        if held == (0, 0):
249            return
250        if self._state != STATE_PLAY:
251            self._start_if_waiting()
252            return
253        self._steer(wanted)
254
255    def _show_pad_on_a_touch_screen(self):
256        """Put the pad on the glass as soon as the machine says the pointer is a finger.
257
258        A phone reports a coarse pointer before anything has been touched, so
259        the pad is there to be pressed rather than appearing after the first
260        press. The pad takes itself away again for a real controller.
261        """
262        pad = self._pad
263        if pad is None or pad.visible or Input.input_method is InputMethod.CONTROLLER:
264            return
265        if Input.capabilities.coarse_pointer or Input.capabilities.touch:
266            pad.visible = True
267
268    def _start_if_waiting(self) -> bool:
269        """Begin a run from the menu or the game-over screen. True if it did."""
270        if self._state not in (STATE_MENU, STATE_OVER):
271            return False
272        self._new_game()
273        self._state = STATE_PLAY
274        return True
275
276    def _steer(self, vector) -> None:
277        """Turn on the strongest axis of a direction, keeping the game four-way.
278
279        A stick or a diagonal on the D-pad reports both axes at once, so the
280        larger one is the turn the player means; when the snake is already
281        travelling along that axis, the other one is what they can have.
282        """
283        x, y = float(vector.x), float(vector.y)
284        candidates = []
285        if x:
286            candidates.append((abs(x), (_sign(x), 0)))
287        if y:
288            candidates.append((abs(y), (0, _sign(y))))
289        for _magnitude, direction in sorted(candidates, reverse=True):
290            if self._turn(direction):
291                return
292
293    def _turn(self, direction: tuple[int, int]) -> bool:
294        """Buffer a turn for the next step, refusing a 180-degree reversal.
295
296        Buffered rather than applied, because a turn is only legal on a step
297        boundary: applying it the moment the key arrives would let a player
298        double back within one cell by turning twice between steps.
299        """
300        dx, dy = self._dir
301        if (direction[0] and dx) or (direction[1] and dy):
302            return False  # same axis: a reversal or a no-op
303        self._pending_dir = direction
304        return True
305
306    def _steer_towards(self, tap) -> None:
307        """Turn toward a tap, on the dominant axis from the snake's head."""
308        if not self._body:
309            return
310        hx, hy = self._body[0]
311        ddx = float(tap.x) - (self._origin_x + (hx + 0.5) * self._cell)
312        ddy = float(tap.y) - (self._origin_y + (hy + 0.5) * self._cell)
313        if abs(ddx) > abs(ddy):
314            self._turn((1, 0) if ddx > 0 else (-1, 0))
315        else:
316            self._turn((0, 1) if ddy > 0 else (0, -1))
317
318    # ------------------------------------------------------------------
319    # Frame update
320    # ------------------------------------------------------------------
321    def on_fixed_update(self, dt):
322        if self._state != STATE_PLAY or self._paused:
323            return
324        self._frames += 1
325        if self._frames % self.step_frames != 0:
326            return
327        self._step()
328
329    def _step(self):
330        # Drain the buffered turn: one per step, which is what stops a player
331        # doubling back by turning twice between two steps.
332        if self._pending_dir is not None:
333            self._dir, self._pending_dir = self._pending_dir, None
334        head_x, head_y = self._body[0]
335        nx, ny = head_x + self._dir[0], head_y + self._dir[1]
336
337        # Wall collision
338        if nx < 0 or ny < 0 or nx >= COLS or ny >= ROWS:
339            self._state = STATE_OVER
340            self.died.emit(self._score)
341            return
342
343        # Self collision (against current body, not yet shifted)
344        if (nx, ny) in self._body:
345            self._state = STATE_OVER
346            self.died.emit(self._score)
347            return
348
349        # Move: prepend new head
350        self._body.insert(0, (nx, ny))
351
352        # Eat?
353        if self._fruit and (nx, ny) == self._fruit:
354            self._score += 1
355            self.ate.emit(len(self._body))
356            self._spawn_fruit()
357        else:
358            self._body.pop()
359
360    def _in_pause_button(self, point) -> bool:
361        px, py, pw, ph = self._pause_rect
362        return px <= float(point.x) <= px + pw and py <= float(point.y) <= py + ph
363
364    def _spawn_fruit(self):
365        free = [(x, y) for x in range(COLS) for y in range(ROWS) if (x, y) not in self._body]
366        self._fruit = random.choice(free) if free else None
367
368    # ------------------------------------------------------------------
369    # Drawing
370    # ------------------------------------------------------------------
371    def on_draw(self, renderer):
372        # Recompute layout each frame so the game scales with window resize.
373        self._recompute_layout()
374        sw, sh = self._screen_w, self._screen_h
375        cell, ox, oy = self._cell, self._origin_x, self._origin_y
376
377        renderer.rect((0, 0), (sw, sh), fill=BG_COLOUR)
378
379        if self._state == STATE_MENU:
380            self._draw_text_stack(
381                renderer,
382                [
383                    ("SNAKE", 8, 0.4, HEAD_COLOUR),
384                    (START_PROMPT, 2, 0.85, TEXT_COLOUR),
385                    (MENU_HINT, 2, 0.9, HINT_COLOUR),
386                ],
387                gap=14,
388            )
389            return
390
391        if self._state == STATE_OVER:
392            self._draw_text_stack(
393                renderer,
394                [
395                    ("GAME OVER", 6, 0.7, (0.78, 0.20, 0.20)),
396                    (f"SCORE  {self._score:03d}", 4, 0.7, TEXT_COLOUR),
397                    (RESTART_PROMPT, 2, 0.85, HINT_COLOUR),
398                ],
399                gap=12,
400            )
401            return
402
403        # Grid lines
404        play_w = cell * COLS
405        play_h = cell * ROWS
406        for i in range(COLS + 1):
407            x = ox + i * cell
408            renderer.line((x, oy), (x, oy + play_h), stroke=GRID_COLOUR)
409        for j in range(ROWS + 1):
410            y = oy + j * cell
411            renderer.line((ox, y), (ox + play_w, y), stroke=GRID_COLOUR)
412
413        # Snake body
414        for i, (cx, cy) in enumerate(self._body):
415            colour = HEAD_COLOUR if i == 0 else BODY_COLOUR
416            renderer.rect((ox + cx * cell, oy + cy * cell), (cell, cell), fill=colour)
417
418        # Fruit
419        if self._fruit:
420            fx, fy = self._fruit
421            renderer.rect((ox + fx * cell, oy + fy * cell), (cell, cell), fill=FRUIT_COLOUR)
422
423        # Strip above the board: score, the tap-to-pause target and length
424        # across one row, the controls across the row under it. A rect draw
425        # fits and aligns each label in one call, so nothing clips or collides
426        # as the window resizes.
427        hud_scale = max(1, min(3, cell // 8))
428        score_y, score_h = self._pause_rect[1], self._pause_rect[3]
429        third = play_w / 3
430        for text, rect, align in (
431            (f"SCORE {self._score:03d}", (ox, score_y, third, score_h), "left"),
432            ("RESUME" if self._paused else "PAUSE", self._pause_rect, "centre"),
433            (f"LEN {len(self._body):03d}", (ox + 2 * third, score_y, third, score_h), "right"),
434        ):
435            renderer.text(
436                text,
437                fill=TEXT_COLOUR if align != "centre" else HINT_COLOUR,
438                scale=hud_scale,
439                align=align,
440                valign="centre",
441                rect=rect,
442                fit_to_width=True,
443            )
444
445        # The controls line, but only while the row can hold it: a text scale
446        # has a readable floor, so a window too small for the line gets the
447        # board and the score rather than a line of help lying across them.
448        help_w, help_h = self._help_rect[2], self._help_rect[3]
449        help_scale = renderer.fit_scale(PLAY_HINT, help_w, base_scale=hud_scale)
450        fits_width = renderer.text_width(PLAY_HINT, help_scale) <= help_w + 0.5
451        if fits_width and renderer.text_height(PLAY_HINT, help_scale) <= help_h:
452            renderer.text(
453                PLAY_HINT,
454                fill=HINT_COLOUR,
455                scale=help_scale,
456                align="centre",
457                valign="centre",
458                rect=self._help_rect,
459            )
460
461        if self._paused:
462            renderer.text(
463                "PAUSED",
464                fill=TEXT_COLOUR,
465                scale=4,
466                align="centre",
467                valign="centre",
468                rect=(0, 0, sw, self._content_h),
469            )
470
471    def _draw_text_stack(self, renderer, lines, *, gap: float) -> None:
472        """Centre a stack of ``(text, scale, width_fraction, colour)`` lines in the window.
473
474        ``renderer.fit_scale`` shrinks each line until it fits its share of the
475        window width and ``renderer.text_height`` reports the line box that scale
476        really occupies, so a big title never overlaps the prompt below it. The
477        stack is centred in the part of the window the drawn pad is not sitting
478        in, so a prompt is never under a thumb.
479        """
480        sw = self._screen_w
481        boxes = []
482        for text, scale, width_fraction, colour in lines:
483            fitted = renderer.fit_scale(text, sw * width_fraction, base_scale=scale)
484            boxes.append((text, fitted, colour, renderer.text_height(text, fitted)))
485        y = self._content_h / 2 - (sum(box[3] for box in boxes) + gap * (len(boxes) - 1)) / 2
486        for text, scale, colour, height in boxes:
487            renderer.text(text, fill=colour, scale=scale, align="centre", valign="centre", rect=(0, y, sw, height))
488            y += height + gap