#!/usr/bin/env python3 """CC0 asset pack integration (third_party -> game atlases). Reads the provenanced CC0 packs in third_party/ (Kenney Tiny Farm tiles, Puny Characters) and emits deterministic atlases for the prototype: src/LivingVillage.Desktop/Assets/cc0-tiles.png 384x32 (12 tiles, 32px each) src/LivingVillage.Desktop/Assets/cc0-characters.png 3840x48 (5 variants x 4 directions x 6 frames, 32x48 cells) Ground tiles (indices 0-3) come from Kenney Tiny Farm via integer 2x NEAREST of the 16px source. Kenney Tiny Farm ships no water / roof / wall / railing / door art (it is a farm pack), so indices 4-11 are *self-drawn* 32px pixel art in a consistent Jiangnan palette (white plaster, dark slate tile, warm wood). Each tile's provenance is recorded in docs/素材来源.md. 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 fill(self, x, y, w, h, color): for yy in range(y, y + h): for xx in range(x, x + w): self.set(xx, yy, color) def vline(self, x, y, h, color): for yy in range(y, y + h): self.set(x, yy, color) def hline(self, x, y, w, color): for xx in range(x, x + w): self.set(xx, y, color) 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)}") # --- self-drawn Jiangnan structure palette (indices 4-11) --------------------- WATER_DEEP = (36, 84, 138, 255) WATER_MID = (54, 110, 170, 255) WATER_HI = (104, 158, 210, 255) SLATE = (52, 58, 72, 255) SLATE_HI = (96, 104, 122, 255) SLATE_DARK = (33, 37, 47, 255) PLASTER = (234, 228, 212, 255) PLASTER_SH = (204, 197, 181, 255) WOOD = (112, 74, 46, 255) WOOD_DARK = (74, 47, 28, 255) LATTICE = (52, 34, 22, 255) CLEAR = (0, 0, 0, 0) def draw_water(c, ox, phase): """One 32x32 water tile; `phase` in {0,1} shifts the wave crests by 4px.""" c.fill(ox, 0, 32, 32, WATER_MID) shift = phase * 4 for yy in (6, 10, 20, 26): for x in range(32): if ((x + shift) // 4) % 2 == 0: c.set(ox + x, yy, WATER_HI) for yy in (4, 8, 18, 24): for x in range(32): if ((x + shift + 2) // 4) % 2 == 0: c.set(ox + x, yy, WATER_DEEP) def draw_roof_ridge(c, ox): c.fill(ox, 0, 32, 32, SLATE) c.fill(ox, 6, 32, 4, SLATE_HI) for x in range(0, 32, 4): c.vline(ox + x, 0, 6, SLATE_DARK) c.vline(ox + x + 2, 10, 22, SLATE_DARK) c.fill(ox, 0, 32, 2, SLATE_DARK) def draw_roof_eave(c, ox): c.fill(ox, 0, 32, 32, SLATE) for x in range(0, 32, 4): c.vline(ox + x, 0, 22, SLATE_DARK) c.fill(ox, 22, 32, 3, SLATE_HI) c.fill(ox, 25, 32, 3, SLATE_DARK) for x in range(0, 32, 4): c.set(ox + x + 3, 28, SLATE_HI) def draw_wall(c, ox, window): c.fill(ox, 0, 32, 32, PLASTER) c.fill(ox, 0, 32, 4, WOOD) c.fill(ox, 28, 32, 4, WOOD) c.vline(ox, 0, 32, WOOD) c.vline(ox + 31, 0, 32, WOOD) if window: c.fill(ox + 6, 6, 20, 20, WOOD_DARK) c.fill(ox + 8, 8, 16, 16, LATTICE) for i in range(8, 24, 4): c.vline(ox + i, 8, 16, WOOD_DARK) c.hline(ox + 8, i, 16, WOOD_DARK) else: c.fill(ox + 6, 12, 20, 2, PLASTER_SH) c.fill(ox + 6, 18, 20, 2, PLASTER_SH) def draw_railing(c, ox): c.fill(ox, 0, 32, 32, CLEAR) c.fill(ox, 6, 32, 3, WOOD) c.fill(ox, 16, 32, 3, WOOD) c.fill(ox, 2, 32, 2, WOOD_DARK) for px in (2, 15, 28): c.fill(ox + px, 2, 3, 26, WOOD) c.set(ox + px + 1, 28, WOOD_DARK) def draw_door(c, ox): c.fill(ox, 0, 32, 32, CLEAR) c.fill(ox + 4, 2, 24, 30, WOOD_DARK) c.fill(ox + 7, 5, 18, 27, WOOD) c.fill(ox + 14, 5, 4, 27, WOOD_DARK) c.fill(ox + 20, 17, 3, 3, (222, 190, 120, 255)) def build_cc0_tile_atlas(): """Twelve 32x32 tiles: 4 Kenney ground + 8 self-drawn structure tiles. Ground source is the *packed* sheet (tilemap_packed.png) which has NO spacing between tiles: 192x176 = 12x11 tiles at a 16px pitch (the spaced tilemap.png is 203x186 at 17px pitch; mixing the two misaligns every non-zero cell). Grid coordinates: two full grass shades (9,8)/(10,8) then two grey stone slabs (4,8)/(4,9). Indices 4-11 are self-drawn (Kenney Tiny Farm has no water, roof, wall, railing or door tiles), layout fixed by the P34 atlas contract: 4 water A / 5 water B / 6 roof ridge / 7 roof eave 8 wall wood / 9 wall window / 10 railing / 11 door """ c = Canvas(384, 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 * 16, grid_y * 16, grid_x * 16 + 16, grid_y * 16 + 16)) choices = [(9, 8), (10, 8), (4, 8), (4, 9)] for index, (gx, gy) in enumerate(choices): c.blit(tile(gx, gy), index * 32, 0, scale=2) draw_water(c, 4 * 32, 0) draw_water(c, 5 * 32, 1) draw_roof_ridge(c, 6 * 32) draw_roof_eave(c, 7 * 32) draw_wall(c, 8 * 32, window=False) draw_wall(c, 9 * 32, window=True) draw_railing(c, 10 * 32) draw_door(c, 11 * 32) 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(), 384, 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()