diff options
Diffstat (limited to 'scripts/make-cc0-art.py')
| -rw-r--r-- | scripts/make-cc0-art.py | 170 |
1 files changed, 170 insertions, 0 deletions
diff --git a/scripts/make-cc0-art.py b/scripts/make-cc0-art.py new file mode 100644 index 0000000..897ec4f --- /dev/null +++ b/scripts/make-cc0-art.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""CC0 asset pack integration (third_party -> game atlases). + +Reads the two provenanced CC0 packs in third_party/ (Kenney Tiny Farm tiles, +Puny Characters) and emits Deterministic CC0 Atlases for the prototype: + + src/LivingVillage.Desktop/Assets/cc0-tiles.png 128x32 (4 ground tiles, 32px each) + src/LivingVillage.Desktop/Assets/cc0-characters.png 2560x48 (5 variants x 4 directions x 4 frames, 32x48 cells) + +Mapping strategy (16 -> 32): integer 2x nearest-neighbour upscale of every source +sprite; ground tiles fill the 32x32 cell exactly; Puny sprites keep their +16x24-ish content band (2x -> 32x48) anchored to the cell bottom. + +Paths anchor to REPO/third_party; all outputs are deterministic (fixed poses, +fixed tiles, NEAREST resampling only). +""" + +from PIL import Image +import struct, zlib, hashlib, subprocess +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +THIRD = REPO / "third_party" +DESKTOP = REPO / "src" / "LivingVillage.Desktop" +ASSETS = DESKTOP / "Assets" + +OUTLINE = (23, 31, 34, 255) + + +class Canvas: + def __init__(self, width, height): + self.width = width + self.height = height + self.pixels = [[(0, 0, 0, 0)] * width for _ in range(height)] + + def set(self, x, y, color): + if 0 <= x < self.width and 0 <= y < self.height: + self.pixels[y][x] = color + + def get(self, x, y): + return self.pixels[y][x] + + def blit(self, img, ox, oy, scale=2): + px = img.load() + for y in range(img.height): + for x in range(img.width): + c = img.getpixel((x, y)) + r, g, b, a = c + for dy in range(scale): + for dx in range(scale): + self.set(ox + x * scale + dx, oy + y * scale + dy, (r, g, b, a)) + + +def write_png(path, canvas): + raw = bytearray() + for y in range(canvas.height): + raw.append(0) + for x in range(canvas.width): + raw.extend(canvas.pixels[y][x]) + + def chunk(tag, payload): + return ( + struct.pack(">I", len(payload)) + + tag + + payload + + struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF) + ) + + header = struct.pack(">IIBBBBB", canvas.width, canvas.height, 8, 6, 0, 0, 0) + data = ( + b'\x89PNG\r\n\x1a\n' + + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + + chunk(b"IEND", b"") + ) + path.write_bytes(data) + + +def verify_png(path, expected_w, expected_h): + data = path.read_bytes() + assert data[:8] == b'\x89PNG\r\n\x1a\n', path + width, height = struct.unpack(">II", data[16:24]) + assert (width, height) == (expected_w, expected_h), (path, width, height) + idat = b"" + offset = 8 + while offset < len(data): + (length,) = struct.unpack(">I", data[offset : offset + 4]) + tag = data[offset + 4 : offset + 8] + if tag == b"IDAT": + idat += data[offset + 8 : offset + 8 + length] + offset += 12 + length + raw = zlib.decompress(idat) + stride = expected_w * 4 + 1 + assert len(raw) == stride * expected_h, (path, len(raw)) + print(f"ok {path.name} {width}x{height} bytes={len(data)}") + + +def build_cc0_tile_atlas(): + """Four 32x32 ground tiles from Kenney Tiny Farm, upscaled 2x. + + Tile selection is fixed by grid coordinates chosen from the pack's + tilemap_packed.png (grass: two greens; stone path: pale slab; dirt: apricot). + """ + c = Canvas(128, 32) + packed = Image.open(THIRD / "kenney_tiny-farm" / "Tilemap" / "tilemap_packed.png").convert("RGBA") + + def tile(grid_x, grid_y): + return packed.crop((grid_x * 17, grid_y * 17, grid_x * 17 + 16, grid_y * 17 + 16)) + + choices = [(10, 9), (9, 7), (6, 7), (6, 9)] + for index, (gx, gy) in enumerate(choices): + t = tile(gx, gy) + c.blit(t, index * 32, 0, scale=2) + return c + + +def build_cc0_character_atlas(): + """5 variants x 4 directions x 4 frames of 32x48 cells from Puny characters. + + Layout: each 32x48 CELL = source 16x24 content band scaled 2x (32x48), anchored + bottom (feet on the ground line). Directions: the pack provides south-facing + art only; south/north use the same band (pack limitation recorded in the + commit), west/east are mirrored copies so walkers always read toward the player. + """ + c = Canvas(2560, 48) + + variant_files = [ + "Soldier-Blue.png", + "Soldier-Yellow.png", + "Warrior-Blue.png", + "Archer-Green.png", + "Character-Base.png", + ] + variants = [] + for name in variant_files: + im = Image.open(THIRD / "puny-characters" / "Puny-Characters" / name).convert("RGBA") + pose_cells = [] + for i in range(4): + left = 8 + i * 32 + pose_cells.append(im.crop((left, 2, left + 16, 26))) + variants.append(pose_cells) + + cell_w = 32 + for vi, pose_cells in enumerate(variants): + for di in range(4): + for frame in range(4): + sprite = pose_cells[frame].resize((32, 48), Image.NEAREST) + if di in (1, 3): + sprite = sprite.transpose(Image.FLIP_LEFT_RIGHT) + ox = cell_w * ((vi * 4 + di) * 4 + frame) + c.blit(sprite, ox, 0, scale=1) + return c + + + + +def main(): + ASSETS.mkdir(parents=True, exist_ok=True) + targets = [ + ("cc0-tiles.png", build_cc0_tile_atlas(), 128, 32), + ("cc0-characters.png", build_cc0_character_atlas(), 2560, 48), + ] + for name, canvas, w, h in targets: + path = ASSETS / name + write_png(path, canvas) + verify_png(path, w, h) + + +if __name__ == "__main__": + main() |
