Add the entities.png manifest, paste-in template and sprite loader

The sheet becomes a fixed grid (one row per entity, one column slot per
animation state) described by a sidecar entities.toml that only maps
names to rows; the loader reads present states and frame counts off the
pixels, so assembling the sheet by hand needs no bookkeeping.
tools/mktemplate.py renders the labelled template with low-alpha slot
guides. The CGA atlas is kept for larger UI text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 20:25:26 +02:00
co-authored by Claude Opus 5
parent 335962800f
commit 8bf086b06a
13 changed files with 555 additions and 83 deletions
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Draw the paste-in template for entities.png from its manifest.
Reads assets/tilesets/entities.toml and writes entities-template.png: a label
column and header (outside the loaded area), and per-state slot guides tinted at
low alpha — below the loader's opacity threshold, so guides left under a sprite are
harmless. If entities.png already exists in this layout, its sprites are carried
over, so the template can be regenerated after adding rows to the manifest.
python3 tools/mktemplate.py
"""
import tomllib
from PIL import Image
MANIFEST = "assets/tilesets/entities.toml"
SHEET = "assets/tilesets/entities.png"
OUT = "assets/tilesets/entities-template.png"
FONT = "assets/fonts/micro-chat-8x9.png"
GUIDE_ALPHA = 48 # < 128: transparent for the game
GUIDE_TINTS = [(90, 160, 255), (255, 200, 90), (120, 230, 120), (255, 120, 120),
(200, 120, 255), (255, 160, 60), (160, 160, 160)]
LABEL_COLOR = (0, 0, 0, 255)
GRID_COLOR = (0, 0, 0, 90)
class Font:
"""Minimal reader for the CP437 atlas in font.rs's layout (32 columns)."""
def __init__(self, path, gw, gh):
self.img = Image.open(path).convert("L")
self.gw, self.gh = gw, gh
self.left, self.width = {}, {}
for code in range(256):
cx, cy = (code % 32) * gw, (code // 32) * gh
xs = [x for y in range(gh) for x in range(gw) if self.img.getpixel((cx + x, cy + y)) > 128]
if xs:
self.left[code], self.width[code] = min(xs), max(xs) - min(xs) + 1
def draw(self, dst, x, y, text, color):
for ch in text:
code = ord(ch) if ord(ch) < 128 else ord("?")
w = self.width.get(code, 0)
if w:
cx, cy = (code % 32) * self.gw + self.left[code], (code // 32) * self.gh
for gy in range(self.gh):
for gx in range(w):
if self.img.getpixel((cx + gx, cy + gy)) > 128 \
and x + gx < dst.size[0] and y + gy < dst.size[1]:
dst.putpixel((x + gx, y + gy), color)
x += w + 1 if w else self.gw // 2
return x
def main():
m = tomllib.load(open(MANIFEST, "rb"))
cell = m["layout"]["cell"]
ox, oy = m["layout"]["origin"]
states = list(m["layout"]["states"].items())
entities = sorted(m["entities"].items(), key=lambda kv: kv[1])
rows = max(r for _, r in entities) + 1
cols = max(col + n for _, (col, n) in states)
w, h = ox + cols * cell, oy + rows * cell
# A little margin right of the sprite area so the last header label fits.
out = Image.new("RGBA", (w + 8, h), (0, 0, 0, 0))
font = Font(FONT, 8, 9)
# Slot guides: one tint per state, alternating rows slightly for readability.
for i, (name, (col, n)) in enumerate(states):
r, g, b = GUIDE_TINTS[i % len(GUIDE_TINTS)]
for row in range(rows):
a = GUIDE_ALPHA if row % 2 == 0 else GUIDE_ALPHA * 2 // 3
x0, y0 = ox + col * cell, oy + row * cell
for y in range(y0, y0 + cell):
for x in range(x0, x0 + n * cell):
out.putpixel((x, y), (r, g, b, a))
# Header label, staggered over two lines so narrow slots don't collide, and a
# separator line at the slot's left edge.
label = {"action1": "act1", "action2": "act2"}.get(name, name)
font.draw(out, ox + col * cell + 1, 1 if i % 2 == 0 else 8, label, LABEL_COLOR)
for y in range(0, h):
out.putpixel((ox + col * cell, y), GRID_COLOR)
for y in range(0, h):
out.putpixel((w, y), GRID_COLOR)
# Cell grid lines (every cell, faint) inside the sprite area.
for x in range(ox, w, cell):
for y in range(oy, h):
if out.getpixel((x, y))[3] < GRID_COLOR[3]:
out.putpixel((x, y), GRID_COLOR)
for y in range(oy, h, cell):
for x in range(ox, w):
out.putpixel((x, y), GRID_COLOR)
# Row labels.
for name, row in entities:
font.draw(out, 1, oy + row * cell, name.replace("_", " "), LABEL_COLOR)
# Carry over sprites from an existing sheet in this layout.
try:
old = Image.open(SHEET).convert("RGBA")
if old.size[0] >= w and old.size[1] >= oy:
region = old.crop((ox, oy, w, min(h, old.size[1])))
out.paste(region, (ox, oy), region)
print(f"carried over sprites from {SHEET}")
except FileNotFoundError:
pass
out.save(OUT)
print(f"wrote {OUT}: {w}×{h}, {rows} rows, {cols} columns")
if __name__ == "__main__":
main()