nodes/tile.pyΒΆ

Part of Casual Crusade.

  1"""Tile: single board cell. Owns optional Card content; draws grass or chest.
  2
  3Mirrors `src/tile.ts`. Tiles live as children of `Board`; Card is a child of
  4`Hand`/`Board` rather than the tile, so dragging composes cleanly.
  5"""
  6
  7from __future__ import annotations
  8
  9import math
 10import random
 11
 12from simvx.core import Node2D, Property
 13from simvx.core.math.types import Vec2
 14
 15from .constants import (
 16    CHEST_BLACK,
 17    CHEST_GOLD,
 18    GRASS_DARK,
 19    GRASS_LIGHT,
 20    NEIGHBOURS,
 21    TILE_HEIGHT,
 22    TILE_WIDTH,
 23    opposite,
 24)
 25
 26
 27class Tile(Node2D):
 28    """A single grid cell. ``content`` is set by Game when a Card is placed.
 29
 30    Everything ``on_draw`` reads is a ``Property``, so writing one marks the tile
 31    render-dirty: a highlight that appears mid-drag is redrawn without the whole
 32    node opting into per-frame redraws via ``dynamic``.
 33    """
 34
 35    marked = Property(False, hint="Legal drop target for the dragged card")
 36    hilite = Property(False, hint="Currently-hovered drop target")
 37    reward = Property(False, hint="This cell holds a chest instead of grass")
 38    looted = Property(False, hint="Chest already opened")
 39    hidden = Property(False, hint="Destroyed: empty when the level ended")
 40    lid_open_t = Property(0.0, range=(0.0, 1.0), hint="Chest lid raise, 0-1")
 41
 42    def __init__(self, gx: int, gy: int, world_x: float, world_y: float):
 43        super().__init__(name=f"Tile({gx},{gy})")
 44        self.gx = gx
 45        self.gy = gy
 46        self.position = Vec2(world_x, world_y)
 47        self.content = None  # Card placed here (or None)
 48        self._phase = random.random() * math.tau
 49
 50    def world_centre(self) -> Vec2:
 51        return Vec2(self.position.x + TILE_WIDTH / 2, self.position.y + TILE_HEIGHT / 2)
 52
 53    def accepts(self, card_data, board: list[Tile]) -> bool:
 54        """Return True if `card_data` (CardData) can legally be placed here."""
 55        if self.reward or self.hidden or self.content is not None:
 56            return False
 57        for d, dx, dy in NEIGHBOURS:
 58            ndx, ndy = self.gx + dx, self.gy + dy
 59            for t in board:
 60                if t.gx == ndx and t.gy == ndy and t.content is not None:
 61                    if card_data.has(d) and t.content.data.has(opposite(d)):
 62                        return True
 63        return False
 64
 65    # --------------------------------------------------------------
 66    # Rendering
 67    # --------------------------------------------------------------
 68    def on_draw(self, renderer) -> None:
 69        if self.hidden:
 70            return
 71        if not self.reward:
 72            # Outer dark grass border (rounded rect approximated with two filled rects + circles)
 73            self._fill_rounded(renderer, -5, -5, TILE_WIDTH + 10, TILE_HEIGHT + 10, 12, GRASS_DARK)
 74            self._fill_rounded(renderer, -1, -1, TILE_WIDTH + 2, TILE_HEIGHT + 2, 10, GRASS_LIGHT)
 75        else:
 76            # Chest
 77            cx = TILE_WIDTH / 2
 78            cy = TILE_HEIGHT / 2
 79            renderer.rect((cx - 28, cy - 28), (56, 36), fill=CHEST_BLACK)
 80            renderer.rect((cx - 22, cy - 22), (44, 24), fill=CHEST_GOLD)
 81            renderer.rect((cx - 18, cy - 22), (36, 10), fill=CHEST_BLACK)
 82            if self.looted:
 83                # Lid open: draw lid raised
 84                renderer.rect((cx - 28, cy - 38 - self.lid_open_t * 8), (56, 8), fill=CHEST_BLACK)
 85        if self.marked or self.hilite:
 86            colour = (1.0, 1.0, 1.0, 1.0) if self.hilite else (1.0, 1.0, 1.0, 0.6)
 87            renderer.rect((5, 5), (TILE_WIDTH - 10, TILE_HEIGHT - 10), stroke=colour)
 88
 89    @staticmethod
 90    def _fill_rounded(renderer, x: float, y: float, w: float, h: float, r: float, colour) -> None:
 91        """Approximate a rounded rect with a centre rect + 4 side rects + 4 quarter circles."""
 92        renderer.rect((x + r, y), (w - 2 * r, h), fill=colour)
 93        renderer.rect((x, y + r), (w, h - 2 * r), fill=colour)
 94        for cx, cy in (
 95            (x + r, y + r),
 96            (x + w - r, y + r),
 97            (x + r, y + h - r),
 98            (x + w - r, y + h - r),
 99        ):
100            renderer.circle((cx, cy), r, fill=colour, segments=12)