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