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)