#!/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 3840x48 (5 variants x 4 directions x 6 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 6 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. Frames 0-3 are the walk cycle (source poses 0-3). Frames 4-5 are the idle breath cycle: the neutral pose (source pose 0) relaxed, then raised 1px for the inhale. The pack has no dedicated idle art, so this reuses pose 0. """ c = Canvas(3840, 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): # Walk frames reuse poses 0-3; idle frames 4-5 reuse the neutral pose 0, # with frame 5 raised 1px so the pair reads as a breath. frame_poses = [pose_cells[0], pose_cells[1], pose_cells[2], pose_cells[3], pose_cells[0], pose_cells[0]] frame_offsets = [0, 0, 0, 0, 0, -1] for frame in range(6): sprite = frame_poses[frame].resize((32, 48), Image.NEAREST) if di in (1, 3): sprite = sprite.transpose(Image.FLIP_LEFT_RIGHT) ox = cell_w * ((vi * 4 + di) * 6 + frame) c.blit(sprite, ox, frame_offsets[frame], 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(), 3840, 48), ] for name, canvas, w, h in targets: path = ASSETS / name write_png(path, canvas) verify_png(path, w, h) if __name__ == "__main__": main()