#!/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 608x32 (19 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. P39 (indices 12-18) appends more self-drawn Jiangnan elements after the P34 block, keeping every older index byte-for-byte backwards compatible: 12 bridge centre / 13 bridge end / 14 boat left / 15 boat right 16 stone lantern / 17 market stall / 18 reeds Bridge/boat/lantern/stall/reeds are all self-drawn (Kenney Tiny Farm has none of them), same palette, same 32px grid, NEAREST-safe (no resampling: drawn at final 1x resolution, so pixel edges are exact). 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)) # --- P39 self-drawn Jiangnan elements (indices 12-18) ------------------------- STONE = (150, 150, 142, 255) STONE_DARK = (104, 106, 102, 255) STONE_HI = (196, 196, 188, 255) BRIDGE_DECK = (128, 128, 122, 255) BOAT_HULL = (58, 48, 40, 255) BOAT_HULL_DARK = (38, 32, 27, 255) BOAT_ROOF = (46, 54, 42, 255) BOAT_ROOF_HI = (76, 88, 66, 255) LANTERN_PAPER = (228, 196, 128, 255) LANTERN_GLOW = (255, 208, 150, 255) LANTERN_GLOW_HI = (255, 232, 190, 255) LANTERN_STONE = (168, 166, 156, 255) REED_STEM = (74, 104, 60, 255) REED_LEAF = (92, 130, 72, 255) REED_TIP = (146, 158, 96, 255) STALL_AWNING = (176, 74, 58, 255) STALL_AWNING_2 = (196, 176, 132, 255) CARROT = (216, 126, 54, 255) CABBAGE = (120, 168, 84, 255) CLOTH = (208, 198, 172, 255) def draw_bridge_center(c, ox): """Arch bridge span: stone deck with a railing and an arched underside. The lower third is transparent so the water ripple still shows through the arch; a warm railing sits on top of the deck. """ c.fill(ox, 0, 32, 32, CLEAR) # Deck (single stone span, bright top edge / dark underside). c.fill(ox, 10, 32, 9, BRIDGE_DECK) c.fill(ox, 10, 32, 2, STONE_HI) c.fill(ox, 17, 32, 2, STONE_DARK) # Arch cut-outs under the deck (transparent so water shows through). c.fill(ox + 6, 19, 8, 13, CLEAR) c.fill(ox + 18, 19, 8, 13, CLEAR) c.fill(ox + 10, 19, 12, 5, BRIDGE_DECK) # Wood railing above the deck. c.fill(ox, 4, 32, 2, WOOD_DARK) c.fill(ox, 7, 32, 2, WOOD) for px in (3, 14, 25): c.fill(ox + px, 4, 2, 6, WOOD) c.set(ox + px, 9, WOOD_DARK) def draw_bridge_end(c, ox): """Bridge end: stone abutment at the bank plus the last railing posts.""" c.fill(ox, 0, 32, 32, CLEAR) # Stepped abutment: two stone steps widening down to the bank. c.fill(ox + 4, 12, 24, 6, STONE) c.fill(ox + 4, 12, 24, 2, STONE_HI) c.fill(ox, 18, 32, 14, STONE) c.fill(ox, 18, 32, 2, STONE_HI) c.fill(ox, 29, 32, 3, STONE_DARK) # End posts of the railing. c.fill(ox + 2, 3, 3, 11, WOOD_DARK) c.fill(ox + 2, 3, 3, 2, WOOD) c.fill(ox + 27, 3, 3, 11, WOOD_DARK) c.fill(ox + 27, 3, 3, 2, WOOD) c.fill(ox + 2, 6, 28, 2, WOOD) def draw_boat(c, ox, right_half): """Wu Peng boat half (2 tiles wide): dark hull + curved bamboo awning. Drawn as two halves so a 2-tile boat reads as one continuous silhouette. The hull floats on the water: its top edge (gunwale) is 4px down, so open water ripple stays visible above the boat on the tile. """ c.fill(ox, 0, 32, 32, CLEAR) if not right_half: # Left half: bow rising to the left, hull tapering. for i in range(26): half = 3 + i // 3 c.fill(ox + 0, 10 + i, half, 1, BOAT_HULL) c.fill(ox, 10, 3, 1, BOAT_HULL_DARK) c.fill(ox + 12, 9, 20, 2, BOAT_HULL_DARK) c.fill(ox + 12, 11, 20, 13, BOAT_HULL) # Awning (wu peng) left edge, arch top. c.fill(ox + 12, 1, 20, 8, BOAT_ROOF) c.fill(ox + 12, 1, 20, 2, BOAT_ROOF_HI) c.fill(ox + 12, 1, 2, 8, BOAT_ROOF_HI) c.fill(ox + 12, 8, 20, 2, BOAT_HULL_DARK) # Stern post at the extreme left of the visible half. c.fill(ox, 6, 2, 6, BOAT_HULL_DARK) else: # Right half: stern rising to the right, mirrored continuous hull. for i in range(26): half = 3 + i // 3 c.fill(ox + 32 - half, 10 + i, half, 1, BOAT_HULL) c.fill(ox + 29, 10, 3, 1, BOAT_HULL_DARK) c.fill(ox, 9, 20, 2, BOAT_HULL_DARK) c.fill(ox, 11, 20, 13, BOAT_HULL) c.fill(ox, 1, 20, 8, BOAT_ROOF) c.fill(ox, 1, 20, 2, BOAT_ROOF_HI) c.fill(ox + 18, 1, 2, 8, BOAT_ROOF_HI) c.fill(ox, 8, 20, 2, BOAT_HULL_DARK) c.fill(ox + 30, 6, 2, 6, BOAT_HULL_DARK) def draw_stone_lantern(c, ox): """Ishidoro-style stone lantern with a warm lit window (night glow anchor). The lit pane is a bright warm core (alpha opaque) sitting inside a stone housing, so the renderer's radial glow layer adds a real halo on top. """ c.fill(ox, 0, 32, 32, CLEAR) # Base plinth. c.fill(ox + 8, 27, 16, 5, LANTERN_STONE) c.fill(ox + 8, 27, 16, 1, STONE_HI) c.fill(ox + 11, 24, 10, 4, LANTERN_STONE) # Shaft. c.fill(ox + 14, 18, 4, 7, LANTERN_STONE) # Light housing with a bright warm pane. c.fill(ox + 6, 9, 20, 10, LANTERN_STONE) c.fill(ox + 6, 9, 20, 1, STONE_HI) c.fill(ox + 9, 11, 14, 7, LANTERN_GLOW) c.fill(ox + 11, 12, 4, 5, LANTERN_GLOW_HI) # Dark frame over the pane. c.vline(ox + 9, 11, 7, STONE_DARK) c.vline(ox + 22, 11, 7, STONE_DARK) c.vline(ox + 15, 11, 7, STONE_DARK) # Cap + finial. c.fill(ox + 4, 6, 24, 4, STONE_DARK) c.fill(ox + 6, 5, 20, 2, LANTERN_STONE) c.fill(ox + 15, 1, 3, 4, LANTERN_STONE) def draw_stall(c, ox): """Roadside market stall: striped awning + counter + produce baskets.""" c.fill(ox, 0, 32, 32, CLEAR) # Awning: red/cream stripes with a scalloped lower edge. for x in range(0, 32, 4): stripe = STALL_AWNING if (x // 4) % 2 == 0 else STALL_AWNING_2 c.fill(ox + x, 6, 4, 8, stripe) c.fill(ox, 13, 32, 2, WOOD_DARK) for x in range(0, 32, 8): c.fill(ox + x, 15, 8, 2, STALL_AWNING_2) c.set(ox + x + 4, 17, WOOD_DARK) # Support poles. c.vline(ox + 2, 15, 13, WOOD_DARK) c.vline(ox + 28, 15, 13, WOOD_DARK) # Counter. c.fill(ox + 2, 20, 28, 4, WOOD) c.fill(ox + 2, 20, 28, 1, WOOD_DARK) # Produce on the counter. c.fill(ox + 6, 17, 4, 3, CARROT) c.fill(ox + 7, 16, 2, 1, REED_LEAF) c.fill(ox + 14, 16, 6, 4, CABBAGE) c.fill(ox + 15, 15, 4, 1, REED_LEAF) c.fill(ox + 24, 17, 4, 3, CLOTH) # Base. c.fill(ox + 2, 24, 28, 4, WOOD_DARK) def draw_reeds(c, ox): """Waterside reeds: a few stems with leaf blades and seed tips.""" c.fill(ox, 0, 32, 32, CLEAR) stems = [(6, 6, 24), (13, 10, 22), (21, 7, 23), (26, 13, 20)] for (x, top, bottom) in stems: c.fill(ox + x, top, 2, bottom - top, REED_STEM) c.fill(ox + x, top, 2, 3, REED_TIP) # Leaf blades angling off the stem. c.fill(ox + x - 4, top + 5, 5, 1, REED_LEAF) c.fill(ox + x - 3, top + 6, 4, 1, REED_LEAF) c.fill(ox + x + 2, top + 9, 4, 1, REED_LEAF) # Dense root cluster at the waterline (bottom of the tile). c.fill(ox + 3, 28, 26, 4, REED_LEAF) c.fill(ox + 8, 30, 16, 2, REED_STEM) def build_cc0_tile_atlas(): """Nineteen 32x32 tiles: 4 Kenney ground + 15 self-drawn Jiangnan 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 P39 appends indices 12-18 (bridge / boat / lantern / stall / reeds) after the P34 block so every older index stays unchanged and backwards compatible: 12 bridge centre / 13 bridge end / 14 boat left / 15 boat right 16 stone lantern / 17 market stall / 18 reeds """ c = Canvas(608, 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) # P39 appended block. draw_bridge_center(c, 12 * 32) draw_bridge_end(c, 13 * 32) draw_boat(c, 14 * 32, right_half=False) draw_boat(c, 15 * 32, right_half=True) draw_stone_lantern(c, 16 * 32) draw_stall(c, 17 * 32) draw_reeds(c, 18 * 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(), 608, 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()