#!/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()