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. Arrow keys or WASD steer, P pauses, SPACE or ENTER restarts after
  6game over, ESC quits; by touch, tap ahead of the head to steer and tap the
  7pause target in the score strip to pause.
  8"""
  9
 10from __future__ import annotations
 11
 12import random
 13
 14from simvx.core import Input, InputMap, Key, MouseButton, Node2D, Property, Signal
 15
 16# Logical grid; the play area scales to fill the window each frame.
 17COLS = 31
 18ROWS = 24
 19
 20# Tick every N physics frames (raylib used framesCounter%5 at 60 fps)
 21STEP_FRAMES = 5
 22
 23# Menu / play / game-over states
 24STATE_MENU = "menu"
 25STATE_PLAY = "play"
 26STATE_OVER = "over"
 27
 28START_PROMPT = "PRESS [SPACE], AN ARROW KEY OR TAP TO START"
 29RESTART_PROMPT = "PRESS [SPACE] / [ENTER] OR TAP TO PLAY AGAIN"
 30MENU_HINT = "ARROWS / WASD OR TAP : MOVE     P : PAUSE     ESC : QUIT"
 31
 32HEAD_COLOUR = (0.0, 0.32, 0.67)
 33BODY_COLOUR = (0.0, 0.47, 0.95)
 34FRUIT_COLOUR = (0.40, 0.75, 1.0)
 35GRID_COLOUR = (0.78, 0.78, 0.78)
 36BG_COLOUR = (0.96, 0.96, 0.96)
 37TEXT_COLOUR = (0.51, 0.51, 0.51)
 38HINT_COLOUR = (0.70, 0.70, 0.70)
 39
 40
 41class SnakeGame(Node2D):
 42    """Single-screen Snake. Grid-based, emits ``died`` and ``ate`` signals."""
 43
 44    step_frames = Property(STEP_FRAMES, range=(1, 30), hint="Physics frames per move")
 45
 46    died = Signal[int]  # final score
 47    ate = Signal[int]  # current length
 48
 49    def __init__(self, **kw):
 50        super().__init__(name="SnakeGame", **kw)
 51        # on_draw reads plain (non-Property) state: the snake body, the score and
 52        # the layout cache. Nothing marks this node dirty when those change, so a
 53        # normal node would draw once and freeze. `dynamic` re-captures the draw
 54        # every frame instead.
 55        self.dynamic = True
 56        self._frames = 0
 57        self._state = STATE_MENU
 58        self._paused = False
 59        self._allow_move = False
 60        self._body: list[tuple[int, int]] = []
 61        self._dir: tuple[int, int] = (1, 0)
 62        self._fruit: tuple[int, int] | None = None
 63        self._score = 0
 64        # Cached layout (recomputed each frame from current window size)
 65        self._cell = 20
 66        self._origin_x = 0
 67        self._origin_y = 0
 68        self._screen_w = 620
 69        self._screen_h = 480
 70        self._hud_h = 40
 71        # Touch pause target inside the score strip, as (x, y, w, h).
 72        self._pause_rect = (0.0, 0.0, 0.0, 0.0)
 73
 74    # ------------------------------------------------------------------
 75    # Lifecycle
 76    # ------------------------------------------------------------------
 77    def on_ready(self):
 78        # InputMap actions live here, never module scope (web exporter rule)
 79        InputMap.add_action("move_up", [Key.UP, Key.W])
 80        InputMap.add_action("move_down", [Key.DOWN, Key.S])
 81        InputMap.add_action("move_left", [Key.LEFT, Key.A])
 82        InputMap.add_action("move_right", [Key.RIGHT, Key.D])
 83        InputMap.add_action("pause", [Key.P])
 84        InputMap.add_action("restart", [Key.ENTER, Key.SPACE])
 85        InputMap.add_action("quit", [Key.ESCAPE])
 86        # Mobile / touch: left-click anywhere counts as start; in-game we
 87        # convert tap position to a quadrant-based direction in on_fixed_update.
 88        InputMap.add_action("tap", [MouseButton.LEFT])
 89        self._new_game()
 90
 91    def _new_game(self):
 92        """Reset board state but keep state machine on the menu."""
 93        self._frames = 0
 94        self._paused = False
 95        self._allow_move = False
 96        self._dir = (1, 0)
 97        cx, cy = COLS // 2, ROWS // 2
 98        self._body = [(cx, cy)]
 99        self._score = 0
100        self._spawn_fruit()
101
102    def _recompute_layout(self):
103        """Fit the COLS×ROWS grid into the current window with letterboxing."""
104        if self.tree:
105            self._screen_w, self._screen_h = self.tree.screen_size
106        cell = max(1, min(self._screen_w // COLS, self._screen_h // (ROWS + 2)))
107        self._cell = int(cell)
108        play_w = self._cell * COLS
109        play_h = self._cell * ROWS
110        self._origin_x = (self._screen_w - play_w) // 2
111        # Reserve a HUD strip two cells tall above the board. The cell size is
112        # budgeted for ROWS + 2 rows, so the strip always fits on screen.
113        self._hud_h = self._cell * 2
114        self._origin_y = max(self._hud_h, (self._screen_h - play_h) // 2)
115        # Score left, length right, pause target in the middle third.
116        self._pause_rect = (self._origin_x + play_w / 3, self._origin_y - self._hud_h, play_w / 3, self._hud_h)
117
118    # ------------------------------------------------------------------
119    # Frame update
120    # ------------------------------------------------------------------
121    def on_fixed_update(self, dt):
122        if Input.is_action_just_pressed("quit"):
123            self.app.quit()
124            return
125
126        if self._state == STATE_MENU:
127            if (
128                Input.is_action_just_pressed("restart")
129                or Input.is_action_just_pressed("tap")
130                or any(Input.is_action_just_pressed(a) for a in ("move_up", "move_down", "move_left", "move_right"))
131            ):
132                self._new_game()
133                self._state = STATE_PLAY
134            return
135
136        if self._state == STATE_OVER:
137            if Input.is_action_just_pressed("restart") or Input.is_action_just_pressed("tap"):
138                self._new_game()
139                self._state = STATE_PLAY
140            return
141
142        # A tap is either the pause button or a steering gesture, never both.
143        tap = Input.mouse_position if Input.is_action_just_pressed("tap") else None
144        if tap is not None and self._in_pause_button(tap):
145            self._paused = not self._paused
146            tap = None
147        if Input.is_action_just_pressed("pause"):
148            self._paused = not self._paused
149        if self._paused:
150            return
151
152        # Direction input: block 180-degree reverse via allow_move latch
153        dx, dy = self._dir
154        if self._allow_move:
155            if Input.is_action_just_pressed("move_right") and dx == 0:
156                self._dir, self._allow_move = (1, 0), False
157            elif Input.is_action_just_pressed("move_left") and dx == 0:
158                self._dir, self._allow_move = (-1, 0), False
159            elif Input.is_action_just_pressed("move_up") and dy == 0:
160                self._dir, self._allow_move = (0, -1), False
161            elif Input.is_action_just_pressed("move_down") and dy == 0:
162                self._dir, self._allow_move = (0, 1), False
163            # Mobile / touch: tap to turn. Direction is the dominant axis from
164            # the snake's head to the tap. Same 180-degree reversal block.
165            elif tap is not None and self._body:
166                hx, hy = self._body[0]
167                head_x = self._origin_x + (hx + 0.5) * self._cell
168                head_y = self._origin_y + (hy + 0.5) * self._cell
169                ddx = float(tap.x) - head_x
170                ddy = float(tap.y) - head_y
171                if abs(ddx) > abs(ddy):
172                    if ddx > 0 and dx == 0:
173                        self._dir, self._allow_move = (1, 0), False
174                    elif ddx < 0 and dx == 0:
175                        self._dir, self._allow_move = (-1, 0), False
176                else:
177                    if ddy > 0 and dy == 0:
178                        self._dir, self._allow_move = (0, 1), False
179                    elif ddy < 0 and dy == 0:
180                        self._dir, self._allow_move = (0, -1), False
181
182        self._frames += 1
183        if self._frames % self.step_frames != 0:
184            return
185
186        self._step()
187
188    def _step(self):
189        head_x, head_y = self._body[0]
190        nx, ny = head_x + self._dir[0], head_y + self._dir[1]
191        self._allow_move = True
192
193        # Wall collision
194        if nx < 0 or ny < 0 or nx >= COLS or ny >= ROWS:
195            self._state = STATE_OVER
196            self.died(self._score)
197            return
198
199        # Self collision (against current body, not yet shifted)
200        if (nx, ny) in self._body:
201            self._state = STATE_OVER
202            self.died(self._score)
203            return
204
205        # Move: prepend new head
206        self._body.insert(0, (nx, ny))
207
208        # Eat?
209        if self._fruit and (nx, ny) == self._fruit:
210            self._score += 1
211            self.ate(len(self._body))
212            self._spawn_fruit()
213        else:
214            self._body.pop()
215
216    def _in_pause_button(self, point) -> bool:
217        px, py, pw, ph = self._pause_rect
218        return px <= float(point.x) <= px + pw and py <= float(point.y) <= py + ph
219
220    def _spawn_fruit(self):
221        free = [(x, y) for x in range(COLS) for y in range(ROWS) if (x, y) not in self._body]
222        self._fruit = random.choice(free) if free else None
223
224    # ------------------------------------------------------------------
225    # Drawing
226    # ------------------------------------------------------------------
227    def on_draw(self, renderer):
228        # Recompute layout each frame so the game scales with window resize.
229        self._recompute_layout()
230        sw, sh = self._screen_w, self._screen_h
231        cell, ox, oy = self._cell, self._origin_x, self._origin_y
232
233        renderer.draw_rect((0, 0), (sw, sh), colour=BG_COLOUR, filled=True)
234
235        if self._state == STATE_MENU:
236            self._draw_text_stack(
237                renderer,
238                [
239                    ("SNAKE", 8, 0.4, HEAD_COLOUR),
240                    (START_PROMPT, 2, 0.85, TEXT_COLOUR),
241                    (MENU_HINT, 2, 0.9, HINT_COLOUR),
242                ],
243                gap=14,
244            )
245            return
246
247        if self._state == STATE_OVER:
248            self._draw_text_stack(
249                renderer,
250                [
251                    ("GAME OVER", 6, 0.7, (0.78, 0.20, 0.20)),
252                    (f"SCORE  {self._score:03d}", 4, 0.7, TEXT_COLOUR),
253                    (RESTART_PROMPT, 2, 0.85, HINT_COLOUR),
254                ],
255                gap=12,
256            )
257            return
258
259        # Grid lines
260        play_w = cell * COLS
261        play_h = cell * ROWS
262        for i in range(COLS + 1):
263            x = ox + i * cell
264            renderer.draw_line((x, oy), (x, oy + play_h), colour=GRID_COLOUR)
265        for j in range(ROWS + 1):
266            y = oy + j * cell
267            renderer.draw_line((ox, y), (ox + play_w, y), colour=GRID_COLOUR)
268
269        # Snake body
270        for i, (cx, cy) in enumerate(self._body):
271            colour = HEAD_COLOUR if i == 0 else BODY_COLOUR
272            renderer.draw_rect(
273                (ox + cx * cell, oy + cy * cell),
274                (cell, cell),
275                colour=colour,
276                filled=True,
277            )
278
279        # Fruit
280        if self._fruit:
281            fx, fy = self._fruit
282            renderer.draw_rect(
283                (ox + fx * cell, oy + fy * cell),
284                (cell, cell),
285                colour=FRUIT_COLOUR,
286                filled=True,
287            )
288
289        # HUD: three rects across the strip above the board (score, touch pause
290        # target, length). A rect draw fits and aligns each label in one call, so
291        # nothing clips or collides as the window resizes.
292        hud_scale = max(1, min(3, cell // 8))
293        strip_y = oy - self._hud_h
294        third = play_w / 3
295        for text, rect, align in (
296            (f"SCORE {self._score:03d}", (ox, strip_y, third, self._hud_h), "left"),
297            ("RESUME" if self._paused else "PAUSE", self._pause_rect, "centre"),
298            (f"LEN {len(self._body):03d}", (ox + 2 * third, strip_y, third, self._hud_h), "right"),
299        ):
300            renderer.draw_text(
301                text,
302                rect=rect,
303                scale=hud_scale,
304                colour=HINT_COLOUR if align == "centre" else TEXT_COLOUR,
305                alignment=align,
306                vertical_alignment="centre",
307                fit_to_width=True,
308            )
309
310        if self._paused:
311            renderer.draw_text(
312                "PAUSED",
313                rect=(0, 0, sw, sh),
314                scale=4,
315                colour=TEXT_COLOUR,
316                alignment="centre",
317                vertical_alignment="centre",
318            )
319
320        # In-game controls: vertical stack, bottom-right, light grey.
321        self._draw_controls_panel(
322            renderer,
323            [
324                "ARROWS/WASD OR TAP: MOVE",
325                "P OR TAP PAUSE: PAUSE",
326                "ESC: QUIT",
327            ],
328        )
329
330    def _draw_controls_panel(self, renderer, lines: list[str]) -> None:
331        """Bottom-right anchored, left-justified controls hint, drawn as one block."""
332        sw, sh = self._screen_w, self._screen_h
333        text = "\n".join(lines)
334        widest = max(lines, key=len)
335        scale = renderer.fit_scale(widest, sw * 0.30, base_scale=2)
336        margin = 8
337        panel_x = sw - renderer.text_width(widest, scale) - margin
338        panel_y = sh - renderer.text_height(text, scale) - margin
339        renderer.draw_text(text, (panel_x, panel_y), scale=scale, colour=HINT_COLOUR)
340
341    def _draw_text_stack(self, renderer, lines, *, gap: float) -> None:
342        """Centre a stack of ``(text, scale, width_fraction, colour)`` lines in the window.
343
344        ``renderer.fit_scale`` shrinks each line until it fits its share of the
345        window width and ``renderer.text_height`` reports the line box that scale
346        really occupies, so a big title never overlaps the prompt below it.
347        """
348        sw, sh = self._screen_w, self._screen_h
349        boxes = []
350        for text, scale, width_fraction, colour in lines:
351            fitted = renderer.fit_scale(text, sw * width_fraction, base_scale=scale)
352            boxes.append((text, fitted, colour, renderer.text_height(text, fitted)))
353        y = sh / 2 - (sum(box[3] for box in boxes) + gap * (len(boxes) - 1)) / 2
354        for text, scale, colour, height in boxes:
355            renderer.draw_text(
356                text,
357                rect=(0, y, sw, height),
358                scale=scale,
359                colour=colour,
360                alignment="centre",
361                vertical_alignment="centre",
362            )
363            y += height + gap