"""Procedural NumPy textures for game ports: checkered, grids, sprite atlases, glyphs.
All functions return RGBA uint8 ndarrays of shape ``(H, W, 4)`` that can be
passed directly to ``Sprite2D.texture`` (or any source ``TextureManager.resolve``
accepts). Pure NumPy, no GPU, no PIL: handy for prototyping ports before art
lands, or for procedural game art.
The colour palette format is ``(R, G, B, A)`` uint8 tuples or 0..255 ints.
"""
from __future__ import annotations
from collections.abc import Iterable, Sequence
import numpy as np
RGBA = tuple[int, int, int, int]
def _norm_rgba(c: RGBA | tuple[int, int, int]) -> RGBA:
"""Coerce an RGB or RGBA tuple to RGBA uint8."""
if len(c) == 3:
return (c[0], c[1], c[2], 255)
return c # type: ignore[return-value]
[docs]
def solid(size: int | tuple[int, int], colour: RGBA | tuple[int, int, int]) -> np.ndarray:
"""``size × size`` (or ``(W, H)``) RGBA texture filled with one colour."""
if isinstance(size, int):
w = h = size
else:
w, h = size
img = np.zeros((h, w, 4), dtype=np.uint8)
img[:] = _norm_rgba(colour)
return img
[docs]
def checkerboard(
size: int | tuple[int, int] = 64,
cell: int = 8,
light: RGBA | tuple[int, int, int] = (220, 220, 220),
dark: RGBA | tuple[int, int, int] = (60, 60, 60),
) -> np.ndarray:
"""Classic two-tone checkerboard. Vectorised: no Python-level loops."""
if isinstance(size, int):
w = h = size
else:
w, h = size
ys = np.arange(h)[:, None] // cell
xs = np.arange(w)[None, :] // cell
mask = ((xs + ys) % 2).astype(bool) # True for "light"
img = np.zeros((h, w, 4), dtype=np.uint8)
img[~mask] = _norm_rgba(dark)
img[mask] = _norm_rgba(light)
return img
[docs]
def packed_grid(
cell_size: int,
columns: int,
rows: int,
colours: Sequence[RGBA | tuple[int, int, int]],
*,
line_colour: RGBA | tuple[int, int, int] | None = None,
line_width: int = 0,
) -> np.ndarray:
"""A grid of ``columns × rows`` solid cells, each ``cell_size`` square.
``colours`` cycles when shorter than ``columns * rows``. ``line_colour``
optionally draws separator lines of ``line_width`` pixels between cells.
Useful for ad-hoc tile atlases, palette swatches, or pip displays.
"""
if not colours:
raise ValueError("packed_grid: colours must not be empty")
w, h = cell_size * columns, cell_size * rows
img = np.zeros((h, w, 4), dtype=np.uint8)
palette = [_norm_rgba(c) for c in colours]
for r in range(rows):
for c in range(columns):
idx = r * columns + c
colour = palette[idx % len(palette)]
y0, y1 = r * cell_size, (r + 1) * cell_size
x0, x1 = c * cell_size, (c + 1) * cell_size
img[y0:y1, x0:x1] = colour
if line_colour is not None and line_width > 0:
lc = _norm_rgba(line_colour)
# Vertical grid lines.
for c in range(columns + 1):
x0 = max(0, c * cell_size - line_width // 2)
x1 = min(w, x0 + line_width)
img[:, x0:x1] = lc
# Horizontal grid lines.
for r in range(rows + 1):
y0 = max(0, r * cell_size - line_width // 2)
y1 = min(h, y0 + line_width)
img[y0:y1, :] = lc
return img
[docs]
def sprite_atlas(
frames: Iterable[np.ndarray],
*,
columns: int | None = None,
) -> tuple[np.ndarray, int, int]:
"""Stitch per-frame RGBA ndarrays into an atlas.
Returns ``(atlas, columns, rows)``: the atlas is shaped
``(rows * fh, columns * fw, 4)``. Frames must share dimensions. If
``columns`` is ``None``, a near-square grid is chosen
(``columns = ceil(sqrt(N))``).
"""
frame_list = list(frames)
if not frame_list:
raise ValueError("sprite_atlas: at least one frame required")
fh, fw = frame_list[0].shape[:2]
for i, f in enumerate(frame_list):
if f.shape[:2] != (fh, fw):
raise ValueError(f"sprite_atlas: frame {i} size {f.shape[:2]} != frame 0 {(fh, fw)}")
if f.shape != (fh, fw, 4) or f.dtype != np.uint8:
raise ValueError("sprite_atlas: frames must be RGBA uint8 (H, W, 4)")
n = len(frame_list)
if columns is None:
columns = int(np.ceil(np.sqrt(n)))
rows = int(np.ceil(n / columns))
atlas = np.zeros((rows * fh, columns * fw, 4), dtype=np.uint8)
for i, f in enumerate(frame_list):
r, c = divmod(i, columns)
atlas[r * fh:(r + 1) * fh, c * fw:(c + 1) * fw] = f
return atlas, columns, rows
# ----------------------------------------------------------------------------
# Glyph rasteriser
# ----------------------------------------------------------------------------
# 5x7 monospace bitmap font covering ASCII 0x20..0x7E. Each glyph is encoded
# as 7 row-bytes; bit 4 is the leftmost pixel of the 5-wide row. Empty rows
# encode as 0. The font is *intentionally minimal*: port helpers need
# legible debug text, not aesthetics.
_FONT_5x7: dict[str, tuple[int, ...]] = {
" ": (0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00),
"0": (0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E),
"1": (0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E),
"2": (0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F),
"3": (0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E),
"4": (0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02),
"5": (0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E),
"6": (0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E),
"7": (0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08),
"8": (0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E),
"9": (0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C),
"A": (0x0E, 0x11, 0x11, 0x11, 0x1F, 0x11, 0x11),
"B": (0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E),
"C": (0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E),
"D": (0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E),
"E": (0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F),
"F": (0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10),
"G": (0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E),
"H": (0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11),
"I": (0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E),
"J": (0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C),
"K": (0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11),
"L": (0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F),
"M": (0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11),
"N": (0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11),
"O": (0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E),
"P": (0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10),
"Q": (0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D),
"R": (0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11),
"S": (0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E),
"T": (0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04),
"U": (0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E),
"V": (0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04),
"W": (0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11),
"X": (0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11),
"Y": (0x11, 0x11, 0x11, 0x0A, 0x04, 0x04, 0x04),
"Z": (0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F),
"-": (0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00),
"_": (0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F),
"+": (0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00),
".": (0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C),
",": (0x00, 0x00, 0x00, 0x00, 0x0C, 0x04, 0x08),
"!": (0x04, 0x04, 0x04, 0x04, 0x00, 0x04, 0x00),
"?": (0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04),
":": (0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x00),
}
[docs]
def glyph(
ch: str,
*,
scale: int = 1,
colour: RGBA | tuple[int, int, int] = (255, 255, 255),
background: RGBA = (0, 0, 0, 0),
) -> np.ndarray:
"""Rasterise a single character to a ``(7·scale, 5·scale, 4)`` RGBA bitmap.
Unsupported characters return a blank glyph (background-filled). Lower-case
letters fall through to upper-case.
"""
key = ch.upper() if ch.isalpha() else ch
bits = _FONT_5x7.get(key)
fg = _norm_rgba(colour)
bg = background if len(background) == 4 else (*background, 0)
if bits is None:
return solid((5 * scale, 7 * scale), bg)
h, w = 7, 5
grid = np.zeros((h, w, 4), dtype=np.uint8)
grid[:] = bg
for y, row in enumerate(bits):
for x in range(w):
if (row >> (w - 1 - x)) & 1:
grid[y, x] = fg
if scale == 1:
return grid
return np.repeat(np.repeat(grid, scale, axis=0), scale, axis=1)
[docs]
def text_strip(
text: str,
*,
scale: int = 2,
colour: RGBA | tuple[int, int, int] = (255, 255, 255),
spacing: int = 1,
background: RGBA = (0, 0, 0, 0),
) -> np.ndarray:
"""Rasterise a short string as a horizontal strip of glyphs.
Useful for debug HUDs / number displays where pulling in the engine's
MSDF text pass is overkill (e.g. a `Sprite2D` carrying the player's score).
"""
if not text:
return solid((1, 7 * scale), background)
glyphs = [glyph(ch, scale=scale, colour=colour, background=background) for ch in text]
gh, gw = glyphs[0].shape[:2]
sep_w = spacing * scale
total_w = sum(g.shape[1] for g in glyphs) + sep_w * (len(glyphs) - 1)
strip = np.zeros((gh, total_w, 4), dtype=np.uint8)
bg = background if len(background) == 4 else (*background, 0)
strip[:] = bg
x = 0
for i, g in enumerate(glyphs):
strip[:, x:x + gw] = g
x += gw
if i != len(glyphs) - 1:
x += sep_w
return strip