nodes/sprites.pyΒΆ

Part of GDQuest Open RPG.

  1"""Procedural pixel-art sprite generation (no PNG assets).
  2
  3Every sprite returned is an HxWx4 uint8 RGBA ndarray. Sized for 32-px tiles.
  4Caller composites into Sprite2D via `texture=ndarray` (engine accepts ndarrays).
  5"""
  6
  7from __future__ import annotations
  8
  9import numpy as np
 10
 11PX = 32
 12
 13# ---- helpers -------------------------------------------------------------
 14
 15
 16def _rgba(r: float, g: float, b: float, a: float = 1.0) -> tuple[int, int, int, int]:
 17    return (int(r * 255), int(g * 255), int(b * 255), int(a * 255))
 18
 19
 20def _blank(w: int, h: int, rgba=(0, 0, 0, 0)) -> np.ndarray:
 21    arr = np.zeros((h, w, 4), dtype=np.uint8)
 22    arr[:] = rgba
 23    return arr
 24
 25
 26def _fill_rect(arr: np.ndarray, x: int, y: int, w: int, h: int, rgba) -> None:
 27    arr[y : y + h, x : x + w] = rgba
 28
 29
 30def _circle(arr: np.ndarray, cx: int, cy: int, r: int, rgba) -> None:
 31    h, w = arr.shape[:2]
 32    yy, xx = np.ogrid[:h, :w]
 33    mask = (xx - cx) ** 2 + (yy - cy) ** 2 <= r * r
 34    arr[mask] = rgba
 35
 36
 37def _outline(arr: np.ndarray, rgba=(0, 0, 0, 255)) -> None:
 38    """Add a 1-px outline where alpha goes from 0 to non-zero."""
 39    h, w, _ = arr.shape
 40    alpha = arr[..., 3]
 41    out = arr.copy()
 42    for dy in (-1, 0, 1):
 43        for dx in (-1, 0, 1):
 44            if dy == 0 and dx == 0:
 45                continue
 46            ys = slice(max(0, dy), h + min(0, dy))
 47            xs = slice(max(0, dx), w + min(0, dx))
 48            ydst = slice(max(0, -dy), h + min(0, -dy))
 49            xdst = slice(max(0, -dx), w + min(0, -dx))
 50            mask = (alpha[ys, xs] > 0) & (out[ydst, xdst, 3] == 0)
 51            out[ydst, xdst][mask] = rgba
 52    arr[:] = out
 53
 54
 55# ---- tiles ---------------------------------------------------------------
 56
 57
 58def grass_tile(rng: np.random.Generator | None = None) -> np.ndarray:
 59    arr = _blank(PX, PX, _rgba(0.30, 0.62, 0.30))
 60    rng = rng or np.random.default_rng(0)
 61    for _ in range(8):
 62        x, y = rng.integers(0, PX, 2)
 63        arr[y, x] = _rgba(0.22, 0.52, 0.24)
 64    for _ in range(3):
 65        x, y = rng.integers(2, PX - 2, 2)
 66        # tiny 2x2 darker patch
 67        arr[y : y + 2, x : x + 2] = _rgba(0.18, 0.45, 0.22)
 68    return arr
 69
 70
 71def path_tile(rng: np.random.Generator | None = None) -> np.ndarray:
 72    arr = _blank(PX, PX, _rgba(0.70, 0.60, 0.40))
 73    rng = rng or np.random.default_rng(0)
 74    for _ in range(6):
 75        x, y = rng.integers(0, PX, 2)
 76        arr[y, x] = _rgba(0.55, 0.45, 0.30)
 77    return arr
 78
 79
 80def water_tile() -> np.ndarray:
 81    arr = _blank(PX, PX, _rgba(0.18, 0.40, 0.66))
 82    for y in range(0, PX, 6):
 83        for x in range(0, PX, 8):
 84            arr[y, x : x + 4] = _rgba(0.40, 0.60, 0.85)
 85    return arr
 86
 87
 88def wall_tile() -> np.ndarray:
 89    arr = _blank(PX, PX, _rgba(0.36, 0.30, 0.22))
 90    for y in range(0, PX, 8):
 91        arr[y, :] = _rgba(0.20, 0.16, 0.12)
 92    for x in (0, 16):
 93        for y in range(0, PX, 16):
 94            arr[y : y + 8, x : x + 1] = _rgba(0.20, 0.16, 0.12)
 95    return arr
 96
 97
 98def tree_tile() -> np.ndarray:
 99    arr = _blank(PX, PX, _rgba(0.30, 0.62, 0.30))
100    # trunk
101    _fill_rect(arr, PX // 2 - 2, PX - 8, 4, 8, _rgba(0.30, 0.20, 0.10))
102    # crown
103    _circle(arr, PX // 2, PX // 2 - 2, 11, _rgba(0.16, 0.42, 0.18))
104    _circle(arr, PX // 2 - 5, PX // 2 - 5, 6, _rgba(0.10, 0.32, 0.13))
105    _circle(arr, PX // 2 + 4, PX // 2 - 1, 5, _rgba(0.22, 0.55, 0.22))
106    return arr
107
108
109def shrine_tile() -> np.ndarray:
110    arr = _blank(PX, PX, _rgba(0.30, 0.62, 0.30))
111    # base
112    _fill_rect(arr, 6, 22, 20, 8, _rgba(0.55, 0.55, 0.55))
113    _fill_rect(arr, 8, 8, 16, 16, _rgba(0.85, 0.78, 0.55))
114    # crystal
115    _fill_rect(arr, 13, 4, 6, 14, _rgba(0.75, 0.95, 1.00, 0.86))
116    _fill_rect(arr, 15, 1, 2, 4, _rgba(1.00, 1.00, 1.00, 0.86))
117    return arr
118
119
120# ---- characters: 4-direction, 2-frame walk -------------------------------
121
122
123def _humanoid(body: tuple, head: tuple, hat: tuple | None = None) -> np.ndarray:
124    """Single 32x32 idle-frame humanoid (front-facing)."""
125    arr = _blank(PX, PX)
126    # legs
127    _fill_rect(arr, 11, 22, 4, 8, body)
128    _fill_rect(arr, 17, 22, 4, 8, body)
129    # body
130    _fill_rect(arr, 9, 12, 14, 12, body)
131    # head
132    _circle(arr, 16, 9, 5, head)
133    # hat
134    if hat is not None:
135        _fill_rect(arr, 11, 3, 10, 3, hat)
136        _fill_rect(arr, 13, 6, 6, 1, hat)
137    # eyes
138    arr[8, 14] = _rgba(0, 0, 0)
139    arr[8, 18] = _rgba(0, 0, 0)
140    _outline(arr)
141    return arr
142
143
144def knight_sprite() -> np.ndarray:
145    return _humanoid(_rgba(0.55, 0.55, 0.65), _rgba(0.95, 0.80, 0.62), hat=_rgba(0.75, 0.75, 0.85))
146
147
148def wizard_sprite() -> np.ndarray:
149    return _humanoid(_rgba(0.30, 0.30, 0.65), _rgba(0.95, 0.80, 0.62), hat=_rgba(0.20, 0.20, 0.50))
150
151
152def squirrel_sprite() -> np.ndarray:
153    arr = _blank(PX, PX)
154    # body
155    _circle(arr, 16, 20, 8, _rgba(0.65, 0.40, 0.18))
156    # head
157    _circle(arr, 16, 11, 6, _rgba(0.78, 0.48, 0.20))
158    # tail
159    for r in (8, 6, 4):
160        _circle(arr, 26, 18 - (8 - r), r, _rgba(0.78, 0.48, 0.20))
161    # ears
162    _circle(arr, 13, 6, 2, _rgba(0.45, 0.28, 0.12))
163    _circle(arr, 19, 6, 2, _rgba(0.45, 0.28, 0.12))
164    # eyes
165    arr[10, 13] = _rgba(0, 0, 0)
166    arr[10, 19] = _rgba(0, 0, 0)
167    _outline(arr)
168    return arr
169
170
171def bear_sprite() -> np.ndarray:
172    arr = _blank(PX, PX)
173    _circle(arr, 16, 20, 11, _rgba(0.30, 0.18, 0.10))
174    _circle(arr, 16, 10, 8, _rgba(0.40, 0.24, 0.14))
175    _circle(arr, 10, 5, 3, _rgba(0.30, 0.18, 0.10))
176    _circle(arr, 22, 5, 3, _rgba(0.30, 0.18, 0.10))
177    _circle(arr, 16, 12, 2, _rgba(0.10, 0.06, 0.04))
178    arr[8, 12] = _rgba(1, 1, 1)
179    arr[8, 20] = _rgba(1, 1, 1)
180    _outline(arr)
181    return arr
182
183
184def bugcat_sprite() -> np.ndarray:
185    arr = _blank(PX, PX)
186    _circle(arr, 16, 18, 9, _rgba(0.85, 0.65, 0.30))
187    # ears
188    pts1 = [(11, 7), (13, 12), (10, 13)]
189    pts2 = [(21, 7), (19, 12), (22, 13)]
190    for x, y in pts1 + pts2:
191        _circle(arr, x, y, 1, _rgba(0.70, 0.50, 0.20))
192    # head
193    _circle(arr, 16, 11, 6, _rgba(0.95, 0.78, 0.45))
194    # antennae
195    for x in (12, 20):
196        for y in range(2, 7):
197            arr[y, x] = _rgba(0.30, 0.20, 0.10)
198        _circle(arr, x, 1, 2, _rgba(0.50, 0.30, 0.30))
199    # eyes
200    _circle(arr, 13, 11, 2, _rgba(0.10, 0.06, 0.04))
201    _circle(arr, 19, 11, 2, _rgba(0.10, 0.06, 0.04))
202    arr[10, 13] = _rgba(1, 1, 1)
203    arr[10, 19] = _rgba(1, 1, 1)
204    _outline(arr)
205    return arr
206
207
208def wolf_sprite() -> np.ndarray:
209    arr = _blank(PX, PX)
210    _circle(arr, 16, 19, 10, _rgba(0.42, 0.42, 0.50))
211    _circle(arr, 16, 10, 6, _rgba(0.55, 0.55, 0.65))
212    # ears (triangular-ish)
213    for dy in range(0, 5):
214        arr[3 + dy, 11 - dy : 13] = _rgba(0.30, 0.30, 0.40)
215        arr[3 + dy, 19 : 21 + dy] = _rgba(0.30, 0.30, 0.40)
216    # eyes
217    arr[10, 13] = _rgba(1, 0.85, 0.20)
218    arr[10, 19] = _rgba(1, 0.85, 0.20)
219    _outline(arr)
220    return arr