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.
This commit is contained in:
2026-08-14 00:04:45 -07:00
parent 825d1ce9ec
commit 651193674c
9 changed files with 238 additions and 0 deletions
+3
View File
@@ -4,3 +4,6 @@
# OS noise
.DS_Store
# Generated placeholder sprites (regenerate via tools/aseprite/)
assets/sprites_ph/
+73
View File
@@ -0,0 +1,73 @@
-- Headless Aseprite sprite generator for U R M03 placeholder sprites.
--
-- Run from the repo root:
-- aseprite -b --script tools/aseprite/gen_sprites.lua
-- Optional params:
-- aseprite -b --script-param out=assets/sprites_ph \
-- --script-param maps=tools/aseprite/maps \
-- --script tools/aseprite/gen_sprites.lua
--
-- Source of truth is the text pixel maps in tools/aseprite/maps/
-- (shared with tools/aseprite/preview.py, which renders identical
-- PNGs without Aseprite). Outputs .aseprite + .png per map.
-- All output is _ph placeholder-grade per the asset policy.
local params = app.params or {}
local MAPS_DIR = params["maps"] or "tools/aseprite/maps"
local OUT_DIR = params["out"] or "assets/sprites_ph"
local SPRITES = { "m03_ph", "grunt_ph", "burster_ph", "slug_ph", "scrap_ph" }
local function parse_palette(path)
local pal = {}
for line in io.lines(path) do
local ch, hex = line:match("^(%S)%s+(%x%x%x%x%x%x)%s*$")
if ch and ch ~= "#" then
local v = tonumber(hex, 16)
pal[ch] = {
r = math.floor(v / 65536) % 256,
g = math.floor(v / 256) % 256,
b = v % 256,
}
end
end
return pal
end
local function parse_map(path)
local rows = {}
for line in io.lines(path) do
if #line > 0 then
rows[#rows + 1] = line
assert(#line == #rows[1],
path .. ": row " .. #rows .. " width " .. #line .. " != " .. #rows[1])
end
end
return rows
end
if app.fs and app.fs.makeAllDirectories then
app.fs.makeAllDirectories(OUT_DIR)
end
local pal = parse_palette(MAPS_DIR .. "/palette.txt")
for _, name in ipairs(SPRITES) do
local rows = parse_map(MAPS_DIR .. "/" .. name .. ".txt")
local w, h = #rows[1], #rows
local spr = Sprite(w, h, ColorMode.RGB)
local img = spr.cels[1].image
for y, row in ipairs(rows) do
for x = 1, w do
local ch = row:sub(x, x)
if ch ~= "." then
local c = assert(pal[ch], name .. ": unknown palette char '" .. ch .. "'")
img:putPixel(x - 1, y - 1, app.pixelColor.rgba(c.r, c.g, c.b, 255))
end
end
end
spr:saveAs(OUT_DIR .. "/" .. name .. ".aseprite")
spr:saveCopyAs(OUT_DIR .. "/" .. name .. ".png")
spr:close()
print("wrote " .. OUT_DIR .. "/" .. name .. ".aseprite (+.png) " .. w .. "x" .. h)
end
+20
View File
@@ -0,0 +1,20 @@
....................
.....KKKKKKKKKK.....
....KRRRRRRRRRRK....
...KRRRRRRRRRRRRK...
..KRRRRRRRRRRRRRRK..
..KRRERRRRRRRRERRK..
..KRRRRRRRRRRRRRRK..
..KRRRGGGGGGGGRRRK..
..KRRGGGGGGGGGGRRK..
..KRRRGGGGGGGGRRRK..
..KRRRRRRRRRRRRRRK..
..KrRRRRRRRRRRRRrK..
...KrRRRRRRRRRRrK...
....KrrRRRRRRrrK....
.....KrrrrrrrrK.....
......KKKKKKKK......
......KrK..KrK......
......KKK..KKK......
....................
....................
+16
View File
@@ -0,0 +1,16 @@
................
....KKKKKKKK....
...KOOOOOOOOK...
..KOOOOOOOOOOK..
..KOOEOOOOEOOK..
..KOOOOOOOOOOK..
..KOOOOOOOOOOK..
..KoOOOOOOOOoK..
..KooOOOOOOooK..
...KooooooooK...
....KKKKKKKK....
....KoK..KoK....
....KKK..KKK....
................
................
................
+32
View File
@@ -0,0 +1,32 @@
................................
................................
................................
............KKKKKKKK............
..........KKSSSSSSSSKK..........
.........KSSSSSSSSSSSSK.........
........KSSSSSSSSSSSSSSK........
.......KSSSSSSSSSSSSSSSSK.......
.......KSSSSSSSSSSSSSSSSK.......
.......KSSsEsSSSSSSWWYSSK.......
.......KSSsssSSSSSSWWYSSK.......
.......KSSSSSSSSSSSYYYSSK.......
.......KSSSSSSSSSSSSSSSSKKKKKK..
.......KSSSSSSSSSSSSSSSSKRRRRK..
.......KSSSSSSSSSSSSSSSSKRRRBK..
.......KSSSSSSSSSSSSSSSSKKKKKK..
.......KsSSSSSSSSSSSSSSsK.......
.......KsSSSSSSSSSSSSSSsK.......
........KsSSSSSSSSSSSSsK........
........KssSSSSSSSSSSssK........
.........KssSSSSSSSSssK.........
..........KssssssssssK..........
...........KKKKKKKKKK...........
........KKKKKK....KKKKKK........
........KssssK....KssssK........
........KssssK....KssssK........
........KssssK....KssssK........
........KooooK....KooooK........
.......KKooooKK..KKooooKK.......
.......KKKKKKKK..KKKKKKKK.......
................................
................................
+13
View File
@@ -0,0 +1,13 @@
# Shared placeholder palette — char -> RRGGBB hex. '.' = transparent.
K 1a1e22
S d2d6e0
s 8a93a6
O 73775f
o 4a4d3f
R c9743a
r 8f4a22
G f2a24a
Y f2cc59
B c0d9f2
W f5f7fa
E 2e3440
+8
View File
@@ -0,0 +1,8 @@
...KK...
..KYYK..
.KYYYYK.
KYWYYYYK
.KYYYYK.
..KYYK..
...KK...
........
+3
View File
@@ -0,0 +1,3 @@
.KKKKKK.
KWWWWBBK
.KKKKKK.
+70
View File
@@ -0,0 +1,70 @@
"""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()