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>
116 lines
4.6 KiB
Python
116 lines
4.6 KiB
Python
#!/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()
|