nodes/textures.py¶

Part of Tanks of Freedom.

  1"""Procedural numpy textures for Tanks of Freedom.
  2
  3Every tile, unit, building and overlay is generated from a few primitive
  4shapes, so the port depends on no image files at all (and sidesteps the
  5upstream sprite-sheet quirks: a 96x63 unit sheet and a P-mode building
  6palette). Pixel-art silhouettes are painted as RGBA uint8 arrays and handed
  7to ``Sprite2D(texture=ndarray, filter="nearest")``, which uploads them.
  8"""
  9
 10from __future__ import annotations
 11
 12import numpy as np
 13
 14from .data import (
 15    BLDG_AIRPORT,
 16    BLDG_BARRACKS,
 17    BLDG_FACTORY,
 18    BLDG_HQ,
 19    PLAYER_BLUE,
 20    PLAYER_RED,
 21    TERR_GRASS,
 22    TERR_MOUNTAIN,
 23    TERR_TREE,
 24    TERR_WATER,
 25    TILE_H,
 26    TILE_W,
 27    UNIT_HELICOPTER,
 28    UNIT_SOLDIER,
 29    UNIT_TANK,
 30)
 31
 32# ----------------------------------------------------------------------------
 33# Helpers
 34# ----------------------------------------------------------------------------
 35
 36
 37def _rgba(r: int, g: int, b: int, a: int = 255) -> np.ndarray:
 38    return np.array([r, g, b, a], dtype=np.uint8)
 39
 40
 41def _empty(w: int, h: int) -> np.ndarray:
 42    return np.zeros((h, w, 4), dtype=np.uint8)
 43
 44
 45def _diamond_mask(w: int, h: int) -> np.ndarray:
 46    """Boolean mask for an isometric diamond."""
 47    cx = (w - 1) * 0.5
 48    cy = (h - 1) * 0.5
 49    yy, xx = np.indices((h, w), dtype=np.float32)
 50    # |x - cx|/(w/2) + |y - cy|/(h/2) <= 1
 51    return (np.abs(xx - cx) / (w * 0.5) + np.abs(yy - cy) / (h * 0.5)) <= 1.0
 52
 53
 54def _diamond_outline_mask(w: int, h: int, inset: int = 1) -> np.ndarray:
 55    big = _diamond_mask(w, h)
 56    small = _diamond_mask(w - inset * 2, h - inset * 2)
 57    canvas = np.zeros_like(big)
 58    if small.shape[0] > 0 and small.shape[1] > 0:
 59        canvas[inset : inset + small.shape[0], inset : inset + small.shape[1]] = small
 60    return big & ~canvas
 61
 62
 63# ----------------------------------------------------------------------------
 64# Terrain tiles (TILE_W x TILE_H diamond)
 65# ----------------------------------------------------------------------------
 66
 67_TERR_COLOUR = {
 68    # (fill, dark edge, soft highlight): kept close in luminance so
 69    # adjacent tiles tessellate without a "white grid" effect.
 70    TERR_GRASS: (_rgba(110, 168, 82), _rgba(82, 130, 60), _rgba(128, 184, 96)),
 71    TERR_WATER: (_rgba(60, 110, 180), _rgba(40, 90, 160), _rgba(80, 140, 200)),
 72    TERR_TREE: (_rgba(46, 100, 52), _rgba(28, 70, 38), _rgba(60, 120, 64)),
 73    TERR_MOUNTAIN: (_rgba(120, 110, 100), _rgba(85, 75, 70), _rgba(140, 130, 120)),
 74}
 75
 76
 77def make_tile(terrain: int) -> np.ndarray:
 78    """Return a TILE_W x TILE_H RGBA tile sprite for ``terrain``."""
 79    img = _empty(TILE_W, TILE_H)
 80    fill, edge, hi = _TERR_COLOUR[terrain]
 81    mask = _diamond_mask(TILE_W, TILE_H)
 82    img[mask] = fill
 83    # Single dark outline (no separate highlight band, it created a "grid"
 84    # effect when adjacent tiles tessellated).
 85    img[_diamond_outline_mask(TILE_W, TILE_H, inset=1)] = edge
 86
 87    cx = (TILE_W - 1) * 0.5
 88    cy = (TILE_H - 1) * 0.5
 89    yy, xx = np.indices((TILE_H, TILE_W), dtype=np.float32)
 90    on_diamond = np.abs(xx - cx) / (TILE_W * 0.5) + np.abs(yy - cy) / (TILE_H * 0.5)
 91
 92    if terrain == TERR_TREE:
 93        # Crown blob in centre
 94        rr2 = (xx - cx) ** 2 / 60.0 + (yy - cy) ** 2 / 12.0
 95        crown = (rr2 < 1.0) & (yy < cy + 2)
 96        img[crown] = _rgba(50, 110, 60)
 97        img[(rr2 < 0.4) & (yy < cy)] = _rgba(150, 200, 130)
 98    elif terrain == TERR_MOUNTAIN:
 99        # Triangular peak
100        peak_top = cy - 6
101        for off in range(8):
102            y = int(peak_top + off)
103            if 0 <= y < TILE_H:
104                wband = off + 2
105                img[y, max(0, int(cx - wband)) : min(TILE_W, int(cx + wband + 1))] = _rgba(150, 140, 130)
106        for off in range(4):
107            y = int(peak_top + off)
108            if 0 <= y < TILE_H:
109                img[y, max(0, int(cx - off // 2)) : min(TILE_W, int(cx + 1))] = _rgba(245, 245, 250)
110    elif terrain == TERR_WATER:
111        # Wave squiggles
112        for stripe_y in (cy - 2, cy + 2):
113            y = int(stripe_y)
114            if 0 <= y < TILE_H:
115                xs = np.arange(int(cx - 14), int(cx + 14), 4)
116                for x in xs:
117                    if 0 <= x < TILE_W and on_diamond[y, x] < 0.95:
118                        img[y, x] = _rgba(200, 220, 250)
119
120    return img
121
122
123# ----------------------------------------------------------------------------
124# Selector / cursor / move-range overlays
125# ----------------------------------------------------------------------------
126
127
128def make_cursor(colour=(255, 255, 255, 230)) -> np.ndarray:
129    img = _empty(TILE_W, TILE_H)
130    rim = _diamond_outline_mask(TILE_W, TILE_H, inset=1)
131    img[rim] = np.array(colour, dtype=np.uint8)
132    rim2 = _diamond_outline_mask(TILE_W, TILE_H, inset=2)
133    img[rim2] = np.array(colour, dtype=np.uint8)
134    return img
135
136
137def make_range_overlay(colour=(255, 255, 100, 110)) -> np.ndarray:
138    img = _empty(TILE_W, TILE_H)
139    mask = _diamond_mask(TILE_W, TILE_H)
140    img[mask] = np.array(colour, dtype=np.uint8)
141    return img
142
143
144def make_attack_overlay() -> np.ndarray:
145    return make_range_overlay((255, 90, 90, 130))
146
147
148def make_path_dot(colour=(255, 255, 0, 240)) -> np.ndarray:
149    """Tiny diamond showing each step on the planned path."""
150    w, h = 14, 8
151    img = _empty(w, h)
152    img[_diamond_mask(w, h)] = np.array(colour, dtype=np.uint8)
153    return img
154
155
156# ----------------------------------------------------------------------------
157# Buildings
158# ----------------------------------------------------------------------------
159
160# Building sprite roughly sits on a TILE_W base with extra height above.
161_BLDG_W = 48
162_BLDG_H = 56
163
164
165def make_building(bldg_type: int, player: int) -> np.ndarray:
166    img = _empty(_BLDG_W, _BLDG_H)
167    body_col, accent, dark = _player_palette(player, base="building")
168
169    cx = _BLDG_W // 2
170    base_y = _BLDG_H - 12  # diamond base sits on ground
171
172    # Diamond base (so it sits flat on the iso tile)
173    base_w, base_h = _BLDG_W - 4, 14
174    base_mask = _diamond_mask(base_w, base_h)
175    by0 = _BLDG_H - base_h - 1
176    img[by0 : by0 + base_h, 2 : 2 + base_w][base_mask] = dark
177
178    if bldg_type == BLDG_HQ:
179        # Bunker: chunky rectangle with star-like shape on top
180        img[base_y - 24 : base_y - 4, cx - 14 : cx + 14] = body_col
181        img[base_y - 24 : base_y - 22, cx - 14 : cx + 14] = dark
182        img[base_y - 4 : base_y - 2, cx - 14 : cx + 14] = dark
183        # Embrasures
184        for ex in (cx - 10, cx - 2, cx + 6):
185            img[base_y - 18 : base_y - 14, ex : ex + 4] = dark
186        # Flag pole + flag at top
187        img[base_y - 36 : base_y - 24, cx - 1 : cx + 1] = _rgba(60, 60, 60)
188        img[base_y - 36 : base_y - 30, cx + 1 : cx + 9] = accent
189    elif bldg_type == BLDG_BARRACKS:
190        # Pitched roof rectangle
191        img[base_y - 18 : base_y - 4, cx - 12 : cx + 12] = body_col
192        img[base_y - 4 : base_y - 2, cx - 12 : cx + 12] = dark
193        # Roof triangle
194        for off in range(8):
195            yt = base_y - 18 - off
196            if 0 <= yt < _BLDG_H:
197                img[yt, cx - 12 + off : cx + 13 - off] = accent
198        # Door
199        img[base_y - 10 : base_y - 4, cx - 2 : cx + 2] = dark
200    elif bldg_type == BLDG_FACTORY:
201        # Wide hall + chimney
202        img[base_y - 16 : base_y - 4, cx - 14 : cx + 14] = body_col
203        img[base_y - 4 : base_y - 2, cx - 14 : cx + 14] = dark
204        # Sawtooth roof
205        for sx in range(cx - 14, cx + 14, 6):
206            for off in range(4):
207                yt = base_y - 16 - off
208                if 0 <= yt < _BLDG_H:
209                    img[yt, sx + off : sx + off + 2] = accent
210        # Chimney
211        img[base_y - 28 : base_y - 16, cx + 8 : cx + 12] = dark
212        img[base_y - 30 : base_y - 28, cx + 7 : cx + 13] = dark
213    elif bldg_type == BLDG_AIRPORT:
214        # Hangar (rounded top)
215        img[base_y - 14 : base_y - 4, cx - 16 : cx + 16] = body_col
216        # Curved roof: half-ellipse
217        for yi in range(10):
218            yt = base_y - 14 - yi
219            if 0 <= yt < _BLDG_H:
220                bw = int((10 - yi) * 1.6)
221                img[yt, cx - bw : cx + bw + 1] = accent
222        # Door
223        img[base_y - 8 : base_y - 4, cx - 4 : cx + 5] = dark
224        # Tower on side
225        img[base_y - 22 : base_y - 4, cx + 12 : cx + 16] = dark
226
227    return img
228
229
230# ----------------------------------------------------------------------------
231# Units
232# ----------------------------------------------------------------------------
233
234_UNIT_W = 36
235_UNIT_H = 40
236
237
238def _player_palette(player: int, base: str = "unit") -> tuple[np.ndarray, np.ndarray, np.ndarray]:
239    if player == PLAYER_BLUE:
240        return (_rgba(80, 110, 200), _rgba(180, 200, 255), _rgba(40, 60, 130))
241    if player == PLAYER_RED:
242        return (_rgba(200, 80, 70), _rgba(255, 180, 170), _rgba(120, 40, 30))
243    return (_rgba(180, 180, 180), _rgba(230, 230, 230), _rgba(110, 110, 110))
244
245
246def make_unit(unit_type: int, player: int) -> np.ndarray:
247    img = _empty(_UNIT_W, _UNIT_H)
248    body, light, dark = _player_palette(player)
249    cx = _UNIT_W // 2
250    base_y = _UNIT_H - 4
251
252    # Shadow
253    sh_mask = _diamond_mask(_UNIT_W - 4, 8)
254    img[base_y - 6 : base_y + 2, 2 : 2 + sh_mask.shape[1]][sh_mask] = _rgba(0, 0, 0, 100)
255
256    if unit_type == UNIT_SOLDIER:
257        # Body
258        img[base_y - 18 : base_y - 6, cx - 4 : cx + 4] = body
259        # Head
260        img[base_y - 24 : base_y - 18, cx - 3 : cx + 4] = light
261        # Helmet
262        img[base_y - 26 : base_y - 24, cx - 4 : cx + 5] = dark
263        # Belt
264        img[base_y - 12 : base_y - 11, cx - 4 : cx + 4] = dark
265        # Legs
266        img[base_y - 6 : base_y - 2, cx - 4 : cx - 1] = dark
267        img[base_y - 6 : base_y - 2, cx + 1 : cx + 4] = dark
268        # Rifle
269        img[base_y - 18 : base_y - 14, cx + 4 : cx + 9] = dark
270    elif unit_type == UNIT_TANK:
271        # Tracks (wide base)
272        img[base_y - 6 : base_y - 2, cx - 14 : cx + 15] = dark
273        # Hull
274        img[base_y - 14 : base_y - 6, cx - 12 : cx + 13] = body
275        # Hull edge highlights
276        img[base_y - 14 : base_y - 13, cx - 12 : cx + 13] = light
277        # Turret
278        img[base_y - 20 : base_y - 14, cx - 6 : cx + 7] = body
279        img[base_y - 20 : base_y - 19, cx - 6 : cx + 7] = light
280        # Cannon
281        img[base_y - 18 : base_y - 17, cx + 7 : cx + 16] = dark
282        img[base_y - 18 : base_y - 16, cx + 6 : cx + 13] = dark
283    elif unit_type == UNIT_HELICOPTER:
284        # Cabin (bulb)
285        img[base_y - 20 : base_y - 12, cx - 8 : cx + 6] = body
286        img[base_y - 20 : base_y - 19, cx - 8 : cx + 6] = light
287        # Tail boom
288        img[base_y - 16 : base_y - 14, cx + 4 : cx + 16] = body
289        # Tail rotor
290        img[base_y - 18 : base_y - 12, cx + 14 : cx + 16] = dark
291        # Skid
292        img[base_y - 4 : base_y - 2, cx - 12 : cx + 13] = dark
293        # Strut
294        for x in (cx - 8, cx + 6):
295            img[base_y - 12 : base_y - 4, x : x + 1] = dark
296        # Rotor
297        img[base_y - 22 : base_y - 21, cx - 14 : cx + 14] = dark
298        # Cockpit window
299        img[base_y - 18 : base_y - 15, cx - 6 : cx - 2] = light
300
301    return img
302
303
304# ----------------------------------------------------------------------------
305# Health bar
306# ----------------------------------------------------------------------------
307
308
309def make_health_bar(fraction: float, w: int = 22, h: int = 4) -> np.ndarray:
310    img = _empty(w, h)
311    img[:] = _rgba(20, 20, 20, 220)
312    img[1:-1, 1:-1] = _rgba(60, 0, 0, 220)
313    fill_w = max(0, int((w - 2) * max(0.0, min(1.0, fraction))))
314    if fill_w > 0:
315        if fraction > 0.5:
316            c = _rgba(70, 200, 70, 240)
317        elif fraction > 0.25:
318            c = _rgba(220, 200, 50, 240)
319        else:
320            c = _rgba(220, 60, 60, 240)
321        img[1:-1, 1 : 1 + fill_w] = c
322    return img
323
324
325def make_flag(player: int) -> np.ndarray:
326    """Small flag overlay drawn above buildings to show ownership."""
327    w, h = 12, 12
328    img = _empty(w, h)
329    body, light, dark = _player_palette(player)
330    img[2:8, 2:10] = body
331    img[2:3, 2:10] = light
332    img[7:8, 2:10] = dark
333    return img
334
335
336# ----------------------------------------------------------------------------
337# Explosion frames
338# ----------------------------------------------------------------------------
339
340
341def make_explosion(t: float) -> np.ndarray:
342    """Return a single explosion frame for normalised time ``t`` in [0, 1]."""
343    w = h = 44
344    img = _empty(w, h)
345    cx = cy = w * 0.5
346    radius = 4 + 16 * t
347    yy, xx = np.indices((h, w), dtype=np.float32)
348    d = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
349    inner = d < radius * 0.55
350    mid = (d >= radius * 0.55) & (d < radius * 0.85)
351    outer = (d >= radius * 0.85) & (d < radius)
352    alpha = int(255 * (1.0 - t))
353    img[inner] = (255, 230, 80, alpha)
354    img[mid] = (240, 130, 40, alpha)
355    img[outer] = (160, 60, 30, max(0, alpha - 60))
356    return img