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            )