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