nodes/game.py¶
Part of Hextris.
1"""HextrisGame: the whole game in one immediate-mode 2D scene node.
2
3Implements the Hextris loop on top of SimVX:
4 - Central hexagon with 6 coloured triangular slices.
5 - Coloured blocks falling from the 6 outer directions.
6 - Tap left/right (or arrow / A/D) rotates the hex by +/- 60 degrees.
7 - A block sticks to the face it lands on; 3 same-colour blocks in a row on
8 one face clear, and clearing repacks the stack above them.
9 - 8 blocks deep on any face ends the run.
10 - Fall speed and spawn rate scale with the score.
11
12Everything is drawn procedurally in ``on_draw`` against the live viewport size,
13so the board stays centred and correctly scaled in the responsive web export.
14Pointer input covers desktop and mobile with one code path: the web runtime
15reports a touch as a left mouse button press at the touch position.
16"""
17
18import math
19import random
20
21from simvx.core import Input, MouseButton, Node2D, Property
22
23from .hex_math import apothem, block_quad, hex_vertices, slice_triangle
24
25# The layout below is authored for an 800x800 board and scaled uniformly to
26# whatever viewport the game actually runs in.
27DESIGN_SIZE = 800.0
28HEX_SIDE = 80.0
29BLOCK_HEIGHT = 22.0
30SPAWN_DISTANCE = 360.0
31GAMEOVER_STACK = 8 # stack depth that triggers game over
32
33# Seconds the tap-zone hint stays up at the start of a run, and its fade-out.
34HINT_HOLD = 3.0
35HINT_FADE = 1.5
36
37# Tetris-clean Hextris palette (4 colours, repeats round the hex)
38COLOURS = [
39 (0.91, 0.30, 0.24), # red
40 (0.95, 0.77, 0.06), # yellow
41 (0.20, 0.60, 0.86), # blue
42 (0.18, 0.80, 0.44), # green
43]
44HEX_FILL = (0.17, 0.24, 0.31)
45BG = (0.93, 0.94, 0.95)
46TEXT = (0.17, 0.24, 0.31)
47HINT_TINT = (0.17, 0.24, 0.31, 0.08)
48
49CONTROLS_STRIP = "ARROWS / A D or TAP LEFT-RIGHT rotate DOWN or HOLD LOW drop P pause R restart"
50
51
52class FallingBlock:
53 """A single coloured block, falling toward the central hex on `side`."""
54
55 __slots__ = ("side", "colour", "distance", "speed", "settled")
56
57 def __init__(self, side: int, colour_idx: int, speed: float, distance: float = SPAWN_DISTANCE):
58 self.side = side
59 self.colour = colour_idx
60 self.distance = distance # distance of inner edge from centre
61 self.speed = speed
62 self.settled = False
63
64 @property
65 def colour_rgb(self) -> tuple[float, float, float]:
66 return COLOURS[self.colour]
67
68
69class HextrisGame(Node2D):
70 """Root game scene: title screen, play loop and game-over screen."""
71
72 # Continuous animation: falling blocks descend + the hex rotation angle lerps every frame.
73 dynamic = True
74
75 base_speed = Property(80.0, range=(20, 400), hint="Pixels per second initial fall speed.")
76 spawn_interval = Property(1.4, range=(0.4, 4.0), hint="Seconds between block spawns.")
77
78 def __init__(self, **kwargs):
79 super().__init__(**kwargs)
80 self._state = "menu"
81 self._reset_state()
82
83 # ------------------------------------------------------------------
84 # State
85 # ------------------------------------------------------------------
86
87 def _reset_state(self):
88 # Six stacks (one per face), each a list of FallingBlock that have stuck
89 self._stacks: list[list[FallingBlock]] = [[] for _ in range(6)]
90 # Currently falling blocks
91 self._falling: list[FallingBlock] = []
92 # Hex rotation: integer 0..5 representing how many +60deg rotations applied.
93 # When rotation_index == 1, the slice originally facing side 0 now faces side 1.
94 self.rotation_index = 0
95 # Smooth visual angle (radians). Animates toward _target_angle.
96 self._visual_angle = 0.0
97 self._target_angle = 0.0
98 self._spawn_accum = 0.0
99 self._score = 0
100 self._paused = False
101 self._restart_blink = 0.0
102 self._hint_age = 0.0
103 self._speed_mult = 1.0
104
105 def _begin_run(self):
106 self._reset_state()
107 self._state = "playing"
108
109 # ------------------------------------------------------------------
110 # Lifecycle
111 # ------------------------------------------------------------------
112
113 def on_update(self, dt: float):
114 if self._state != "playing":
115 self._restart_blink += dt
116 if Input.is_action_just_pressed("start") or self._just_clicked():
117 self._begin_run()
118 return
119
120 if Input.is_action_just_pressed("pause"):
121 self._paused = not self._paused
122 if self._paused:
123 self._restart_blink += dt
124 return
125
126 self._hint_age += dt
127 self._handle_input()
128 self._animate_rotation(dt)
129 self._update_falling(dt)
130 self._spawn(dt)
131 self._check_game_over()
132
133 # ------------------------------------------------------------------
134 # Input
135 # ------------------------------------------------------------------
136
137 def _just_clicked(self) -> bool:
138 return Input.is_mouse_button_just_pressed(MouseButton.LEFT)
139
140 def _handle_input(self):
141 # Keyboard
142 if Input.is_action_just_pressed("rotate_left"):
143 self.rotate(-1)
144 if Input.is_action_just_pressed("rotate_right"):
145 self.rotate(1)
146
147 # Mouse / touch: rotate based on which side of centre was clicked.
148 if self._just_clicked():
149 width, _height, _scale = self._layout()
150 self.rotate(-1 if Input.mouse_position.x < width / 2 else 1)
151
152 # Speed-up while held (key); also "tap-and-hold near the bottom" on touch.
153 held = Input.is_action_pressed("speed_up") or self._is_pointer_held_below()
154 self._speed_mult = 3.0 if held else 1.0
155
156 def _is_pointer_held_below(self) -> bool:
157 if not Input.is_mouse_button_pressed(MouseButton.LEFT):
158 return False
159 _width, height, _scale = self._layout()
160 return Input.mouse_position.y > height * 0.75
161
162 # ------------------------------------------------------------------
163 # Layout
164 # ------------------------------------------------------------------
165
166 def _layout(self) -> tuple[float, float, float]:
167 """Live viewport `(width, height, scale)`; scale maps design units to pixels."""
168 if self.tree is None:
169 return DESIGN_SIZE, DESIGN_SIZE, 1.0
170 width, height = self.tree.screen_size
171 return float(width), float(height), min(float(width), float(height)) / DESIGN_SIZE
172
173 # ------------------------------------------------------------------
174 # Rotation
175 # ------------------------------------------------------------------
176
177 def rotate(self, steps: int):
178 self.rotation_index = (self.rotation_index + steps) % 6
179 self._target_angle += steps * (math.pi / 3.0)
180
181 def _animate_rotation(self, dt: float):
182 # Critically damped-ish lerp toward target.
183 diff = self._target_angle - self._visual_angle
184 self._visual_angle += diff * min(1.0, dt * 14.0)
185 if abs(diff) < 0.001:
186 self._visual_angle = self._target_angle
187
188 # ------------------------------------------------------------------
189 # Falling / spawning
190 # ------------------------------------------------------------------
191
192 def _spawn(self, dt: float):
193 self._spawn_accum += dt
194 # Speed-scale spawn rate by score
195 interval = max(0.45, self.spawn_interval - self._score * 0.005)
196 if self._spawn_accum >= interval:
197 self._spawn_accum = 0.0
198 self._spawn_block()
199
200 def _spawn_block(self):
201 side = random.randrange(6)
202 colour = random.randrange(len(COLOURS))
203 speed = self.base_speed + self._score * 1.5
204 self._falling.append(FallingBlock(side, colour, speed))
205
206 def _update_falling(self, dt: float):
207 for block in self._falling:
208 if block.settled:
209 continue
210 block.distance -= block.speed * dt * self._speed_mult
211 self._try_settle(block)
212
213 # Move settled-out blocks into the appropriate stack
214 still_falling: list[FallingBlock] = []
215 for block in self._falling:
216 if block.settled:
217 self._attach_to_stack(block)
218 else:
219 still_falling.append(block)
220 self._falling = still_falling
221
222 # Animate clears (handled inline as instant for this port)
223 self._check_clears()
224
225 def _try_settle(self, block: FallingBlock):
226 # Determine the hex slice currently facing this block's incoming side.
227 # The hex has rotated by `rotation_index`, so a block falling on world-side
228 # s lands on slice index (s - rotation_index) mod 6.
229 target_slice = (block.side - self.rotation_index) % 6
230 stack = self._stacks[target_slice]
231 # Inner edge target: top of stack if any, else hex apothem.
232 if stack:
233 inner = stack[-1].distance + BLOCK_HEIGHT
234 else:
235 inner = apothem(HEX_SIDE)
236
237 if block.distance <= inner:
238 block.distance = inner
239 block.settled = True
240 # Re-target side index to the slice it stuck onto (for storage).
241 block.side = target_slice # store in hex-local frame
242
243 def _attach_to_stack(self, block: FallingBlock):
244 self._stacks[block.side].append(block)
245
246 # ------------------------------------------------------------------
247 # Clears (3-in-a-row on same face)
248 # ------------------------------------------------------------------
249
250 def _check_clears(self):
251 cleared_any = True
252 # Repeat: clearing one run can let a stack collapse and re-touch.
253 while cleared_any:
254 cleared_any = False
255 for stack in self._stacks:
256 if len(stack) < 3:
257 continue
258 # Find any run of 3+ same colour
259 run_start = 0
260 run_colour = stack[0].colour
261 for i in range(1, len(stack) + 1):
262 if i < len(stack) and stack[i].colour == run_colour:
263 continue
264 run_len = i - run_start
265 if run_len >= 3:
266 # Clear [run_start:i]
267 cleared = stack[run_start:i]
268 del stack[run_start:i]
269 self._on_cleared(cleared)
270 # Re-pack distances for the remaining blocks above
271 for j in range(run_start, len(stack)):
272 stack[j].distance = apothem(HEX_SIDE) + j * BLOCK_HEIGHT
273 cleared_any = True
274 break
275 if i < len(stack):
276 run_start = i
277 run_colour = stack[i].colour
278 if cleared_any:
279 break
280
281 def _on_cleared(self, cleared: list[FallingBlock]):
282 n = len(cleared)
283 # Score: 10 per block, +5 per block beyond 3 (combo bonus)
284 self._score += n * 10 + max(0, n - 3) * 5
285
286 # ------------------------------------------------------------------
287 # Game over
288 # ------------------------------------------------------------------
289
290 def _check_game_over(self):
291 for stack in self._stacks:
292 if len(stack) >= GAMEOVER_STACK:
293 self._state = "over"
294 self._restart_blink = 0.0
295 return
296
297 # ------------------------------------------------------------------
298 # Drawing
299 # ------------------------------------------------------------------
300
301 def on_draw(self, renderer):
302 width, height, scale = self._layout()
303 cx, cy = width / 2, height / 2
304
305 renderer.draw_rect((0, 0), (width, height), colour=BG, filled=True)
306 self._draw_board(renderer, cx, cy, scale)
307
308 if self._state == "playing":
309 self._draw_tap_hint(renderer, width, height, scale)
310 self._draw_hud(renderer, width, height, scale)
311
312 if self._state == "menu":
313 self._draw_menu(renderer, width, height, scale)
314 elif self._state == "over":
315 self._draw_game_over(renderer, width, height, scale)
316 elif self._paused:
317 self._draw_centred_panel(renderer, width, height, scale, "PAUSED", "P to resume")
318
319 def _draw_board(self, renderer, cx: float, cy: float, scale: float):
320 # 6 coloured slices (rotate with the hex)
321 for i in range(6):
322 tri = slice_triangle(cx, cy, HEX_SIDE * 0.94 * scale, i, self._visual_angle)
323 renderer.draw_polygon(tri, colour=COLOURS[i % len(COLOURS)])
324
325 # Hex outline
326 renderer.draw_lines(hex_vertices(cx, cy, HEX_SIDE * scale, self._visual_angle), closed=True, colour=HEX_FILL)
327
328 # Stacked blocks (rotate with the hex)
329 for slice_idx, stack in enumerate(self._stacks):
330 for block in stack:
331 quad = block_quad(cx, cy, slice_idx, block.distance * scale, BLOCK_HEIGHT * scale, self._visual_angle)
332 renderer.draw_polygon(quad, colour=block.colour_rgb)
333
334 # Falling blocks (do not rotate; they follow their world-space side)
335 for block in self._falling:
336 quad = block_quad(cx, cy, block.side, block.distance * scale, BLOCK_HEIGHT * scale, 0.0)
337 renderer.draw_polygon(quad, colour=block.colour_rgb)
338
339 def _draw_tap_hint(self, renderer, width: float, height: float, scale: float):
340 """Fade the two rotate-here zones in at the start of a run, then out."""
341 fade = min(1.0, max(0.0, (HINT_HOLD + HINT_FADE - self._hint_age) / HINT_FADE))
342 if fade <= 0.0:
343 return
344 tint = (HINT_TINT[0], HINT_TINT[1], HINT_TINT[2], HINT_TINT[3] * fade)
345 renderer.draw_rect((0, 0), (width / 2, height), colour=tint, filled=True)
346 renderer.draw_rect((width / 2, 0), (width / 2, height), colour=tint, filled=True)
347 renderer.draw_rect((width / 2 - scale, 0), (2 * scale, height), colour=tint, filled=True)
348 label = (TEXT[0], TEXT[1], TEXT[2], 0.55 * fade)
349 row_y, row_h = height * 0.5 - 60 * scale, 40 * scale
350 renderer.draw_text(
351 "< ROTATE", rect=(0, row_y, width / 2, row_h), alignment="centre", scale=1.6 * scale, colour=label
352 )
353 renderer.draw_text(
354 "ROTATE >", rect=(width / 2, row_y, width / 2, row_h), alignment="centre", scale=1.6 * scale, colour=label
355 )
356
357 def _draw_hud(self, renderer, width: float, height: float, scale: float):
358 renderer.draw_text(f"SCORE {self._score}", (20 * scale, 20 * scale), scale=2.5 * scale, colour=TEXT)
359 # Bottom controls strip
360 strip_h = 40 * scale
361 renderer.draw_rect((0, height - strip_h), (width, strip_h), colour=(*HEX_FILL, 0.10), filled=True)
362 renderer.draw_text(
363 CONTROLS_STRIP,
364 rect=(12 * scale, height - strip_h, width - 24 * scale, strip_h),
365 alignment="centre",
366 vertical_alignment="centre",
367 scale=1.3 * scale,
368 fit_to_width=True,
369 colour=TEXT,
370 )
371
372 def _draw_menu(self, renderer, width: float, height: float, scale: float):
373 self._draw_centred_panel(
374 renderer,
375 width,
376 height,
377 scale,
378 "HEXTRIS",
379 "TAP or press ENTER to play",
380 subtitle="Rotate the hexagon so three blocks of one colour meet",
381 )
382
383 def _draw_game_over(self, renderer, width: float, height: float, scale: float):
384 self._draw_centred_panel(
385 renderer,
386 width,
387 height,
388 scale,
389 "GAME OVER",
390 "TAP or press ENTER to play again",
391 subtitle=f"SCORE {self._score}",
392 title_colour=(0.95, 0.30, 0.27),
393 )
394
395 def _draw_centred_panel(
396 self,
397 renderer,
398 width: float,
399 height: float,
400 scale: float,
401 title: str,
402 prompt: str,
403 *,
404 subtitle: str = "",
405 title_colour: tuple[float, float, float] = (1.0, 1.0, 1.0),
406 ):
407 renderer.draw_rect((0, 0), (width, height), colour=(0, 0, 0, 0.55), filled=True)
408 renderer.draw_text(
409 title,
410 rect=(0, height / 2 - 110 * scale, width, 80 * scale),
411 alignment="centre",
412 scale=6 * scale,
413 fit_to_width=True,
414 colour=title_colour,
415 )
416 if subtitle:
417 renderer.draw_text(
418 subtitle,
419 rect=(0, height / 2 + 10 * scale, width, 40 * scale),
420 alignment="centre",
421 scale=2.4 * scale,
422 fit_to_width=True,
423 colour=(1.0, 1.0, 1.0),
424 )
425 if int(self._restart_blink * 2) % 2 == 0:
426 renderer.draw_text(
427 prompt,
428 rect=(0, height / 2 + 80 * scale, width, 34 * scale),
429 alignment="centre",
430 scale=2 * scale,
431 fit_to_width=True,
432 colour=(0.85, 0.85, 0.85),
433 )