Files
vh 651193674c feat(tools): Aseprite sprite pipeline — shared pixel maps, Lua generator, headless previewer
tools/aseprite/maps/ holds the source of truth: char-grid pixel maps +
shared palette for five _ph placeholder sprites (M03 32px per MOE.md
design law cues — round silhouette, mismatched eyes, bolted hardpoint,
pillar feet; grunt 16px untagged olive; burster 20px HEAT glow band;
KINETIC slug; scrap diamond). Two interchangeable renderers:
gen_sprites.lua (aseprite -b, emits .aseprite + .png — run on any
machine with Aseprite) and preview.py (Pillow, identical PNGs headless
+ nearest-neighbor zoom for review). Outputs gitignored; greybox
scenes untouched — art stays out of the game per the greybox rule.
2026-08-14 00:04:45 -07:00

71 lines
2.3 KiB
Python

"""Render the shared pixel maps to PNG without Aseprite.
Same source of truth as gen_sprites.lua (tools/aseprite/maps/); use
this for headless preview on boxes with no Aseprite. Output is
_ph placeholder-grade per the asset policy.
uv run --with pillow python tools/aseprite/preview.py \
[--maps tools/aseprite/maps] [--out assets/sprites_ph] [--zoom 8]
Writes <name>.png (1x) and, with --zoom N > 1, <name>@Nx.png
(nearest-neighbor upscale).
"""
import argparse
from pathlib import Path
from PIL import Image
SPRITES = ["m03_ph", "grunt_ph", "burster_ph", "slug_ph", "scrap_ph"]
def parse_palette(path: Path) -> dict[str, tuple[int, int, int, int]]:
pal = {}
for line in path.read_text().splitlines():
parts = line.split()
if len(parts) == 2 and len(parts[0]) == 1 and parts[0] != "#":
v = int(parts[1], 16)
pal[parts[0]] = ((v >> 16) & 255, (v >> 8) & 255, v & 255, 255)
return pal
def parse_map(path: Path) -> list[str]:
rows = [line for line in path.read_text().splitlines() if line]
for i, row in enumerate(rows):
if len(row) != len(rows[0]):
raise ValueError(f"{path}: row {i + 1} width {len(row)} != {len(rows[0])}")
return rows
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--maps", default="tools/aseprite/maps")
ap.add_argument("--out", default="assets/sprites_ph")
ap.add_argument("--zoom", type=int, default=8)
args = ap.parse_args()
maps_dir = Path(args.maps)
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
pal = parse_palette(maps_dir / "palette.txt")
for name in SPRITES:
rows = parse_map(maps_dir / f"{name}.txt")
w, h = len(rows[0]), len(rows)
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
for y, row in enumerate(rows):
for x, ch in enumerate(row):
if ch != ".":
if ch not in pal:
raise ValueError(f"{name}: unknown palette char {ch!r}")
img.putpixel((x, y), pal[ch])
img.save(out_dir / f"{name}.png")
if args.zoom > 1:
big = img.resize((w * args.zoom, h * args.zoom), Image.Resampling.NEAREST)
big.save(out_dir / f"{name}@{args.zoom}x.png")
print(f"wrote {out_dir}/{name}.png {w}x{h}")
if __name__ == "__main__":
main()