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        x, y = self.position.x, self.position.y
 72        if not self.reward:
 73            # Outer dark grass border (rounded rect approximated with two filled rects + circles)
 74            self._fill_rounded(renderer, x - 5, y - 5, TILE_WIDTH + 10, TILE_HEIGHT + 10, 12, GRASS_DARK)
 75            self._fill_rounded(renderer, x - 1, y - 1, TILE_WIDTH + 2, TILE_HEIGHT + 2, 10, GRASS_LIGHT)
 76        else:
 77            # Chest
 78            cx = x + TILE_WIDTH / 2
 79            cy = y + TILE_HEIGHT / 2
 80            renderer.draw_rect((cx - 28, cy - 28), (56, 36), colour=CHEST_BLACK, filled=True)
 81            renderer.draw_rect((cx - 22, cy - 22), (44, 24), colour=CHEST_GOLD, filled=True)
 82            renderer.draw_rect((cx - 18, cy - 22), (36, 10), colour=CHEST_BLACK, filled=True)
 83            if self.looted:
 84                # Lid open: draw lid raised
 85                renderer.draw_rect(
 86                    (cx - 28, cy - 38 - self.lid_open_t * 8),
 87                    (56, 8),
 88                    colour=CHEST_BLACK,
 89                    filled=True,
 90                )
 91        if self.marked or self.hilite:
 92            colour = (1.0, 1.0, 1.0, 1.0) if self.hilite else (1.0, 1.0, 1.0, 0.6)
 93            renderer.draw_rect(
 94                (x + 5, y + 5),
 95                (TILE_WIDTH - 10, TILE_HEIGHT - 10),
 96                colour=colour,
 97                filled=False,
 98            )
 99
100    @staticmethod
101    def _fill_rounded(renderer, x: float, y: float, w: float, h: float, r: float, colour) -> None:
102        """Approximate a rounded rect with a centre rect + 4 side rects + 4 quarter circles."""
103        renderer.draw_rect((x + r, y), (w - 2 * r, h), colour=colour, filled=True)
104        renderer.draw_rect((x, y + r), (w, h - 2 * r), colour=colour, filled=True)
105        for cx, cy in (
106            (x + r, y + r),
107            (x + w - r, y + r),
108            (x + r, y + h - r),
109            (x + w - r, y + h - r),
110        ):
111            renderer.draw_circle((cx, cy), r, colour=colour, filled=True, segments=12)