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