diff options
Diffstat (limited to 'scripts/make-jiangnan-art.py')
| -rw-r--r-- | scripts/make-jiangnan-art.py | 460 |
1 files changed, 460 insertions, 0 deletions
diff --git a/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py new file mode 100644 index 0000000..478e732 --- /dev/null +++ b/scripts/make-jiangnan-art.py @@ -0,0 +1,460 @@ +#!/usr/bin/env python3 +"""Deterministic pixel-art atlases for LivingVillage.Desktop. + +Generates three RGBA PNGs into src/LivingVillage.Desktop/Assets: + jiangnan-world.png 256x32 (8 ground/prop tiles, 32px each) + jiangnan-characters.png 1024x48 (4 variants x 4 directions x 2 frames, 32x48 cells) + jiangnan-interior.png 392x96 (variable-size bottom-aligned interior cells) + +Cell geometry is mirrored in VillageArt.fs; keep both in sync. +""" + +import struct +import zlib +from pathlib import Path + +ASSET_DIR = Path(__file__).resolve().parents[1] / "src" / "LivingVillage.Desktop" / "Assets" + +OUTLINE = (23, 31, 34, 255) +SKIN = (232, 180, 139, 255) +HAIR = (42, 36, 37, 255) +SHOE = (40, 30, 28, 255) +EYE = (43, 32, 30, 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 rect(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 outline(self, x0, y0, w, h, color=OUTLINE): + opaque = { + (x, y) + for y in range(y0, y0 + h) + for x in range(x0, x0 + w) + if self.get(x, y)[3] == 255 + } + for (x, y) in opaque: + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx, ny = x + dx, y + dy + if x0 <= nx < x0 + w and y0 <= ny < y0 + h and self.get(nx, ny)[3] == 0: + self.set(nx, ny, color) + + +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_world_atlas(): + c = Canvas(256, 32) + tile = 32 + + def slot(index): + return index * tile + + grass = (66, 112, 70, 255) + grass_light = (92, 139, 82, 255) + grass_dark = (54, 92, 60, 255) + water = (54, 116, 145, 255) + water_light = (105, 174, 184, 255) + water_deep = (44, 93, 124, 255) + path = (170, 153, 123, 255) + path_light = (201, 183, 145, 255) + path_grout = (140, 124, 96, 255) + roof = (45, 49, 57, 255) + roof_light = (76, 80, 85, 255) + roof_dark = (30, 33, 38, 255) + wall = (225, 216, 195, 255) + wall_light = (238, 232, 214, 255) + wall_shadow = (166, 147, 119, 255) + wall_seam = (196, 180, 152, 255) + wood = (119, 76, 47, 255) + wood_light = (164, 111, 61, 255) + wood_dark = (83, 52, 39, 255) + bamboo = (51, 101, 61, 255) + bamboo_light = (107, 153, 81, 255) + bamboo_node = (38, 76, 46, 255) + soil = (122, 78, 49, 255) + soil_light = (146, 96, 60, 255) + sprout = (86, 139, 68, 255) + sprout_light = (107, 171, 85, 255) + + x0 = slot(0) + c.rect(x0, 0, 32, 32, grass) + for y in range(32): + for x in range(32): + if (x * 13 + y * 7) % 29 == 0: + c.set(x0 + x, y, grass_light) + elif (x * 7 + y * 11) % 43 == 0: + c.set(x0 + x, y, grass_dark) + for bx, by in ((5, 7), (13, 19), (22, 11), (28, 25), (9, 27), (18, 3)): + c.set(x0 + bx, by, grass_light) + c.set(x0 + bx, by + 1, grass_light) + + x0 = slot(1) + c.rect(x0, 0, 32, 32, water) + c.rect(x0 + 8, 2, 2, 26, water_deep) + c.rect(x0 + 23, 2, 2, 26, water_deep) + for y in (5, 16, 27): + for x in range(3, 29): + if (x + y) % 5 != 0: + c.set(x0 + x, y, water_light) + for sx, sy in ((12, 9), (26, 20), (6, 22)): + c.set(x0 + sx, sy, (214, 231, 238, 255)) + + x0 = slot(2) + c.rect(x0, 0, 32, 32, path) + c.rect(x0, 0, 32, 1, path_light) + c.rect(x0, 31, 32, 1, path_light) + for y in (10, 21): + c.rect(x0, y, 32, 1, path_grout) + for x in (11, 22): + c.rect(x0 + x, 0, 1, 10, path_grout) + for x in (5, 17, 28): + c.rect(x0 + x, 11, 1, 10, path_grout) + for x in (12, 24): + c.rect(x0 + x, 22, 1, 10, path_grout) + for y in range(1, 31): + for x in range(1, 31): + if (x * 5 + y * 3) % 23 == 0 and c.get(x0 + x, y) == path: + c.set(x0 + x, y, path_light) + + x0 = slot(3) + c.rect(x0, 0, 32, 32, roof) + for band in range(4): + y = band * 8 + c.rect(x0, y, 32, 1, roof_light) + for x in range((band % 2) * 4, 32, 8): + c.rect(x0 + x, y + 1, 1, 7, roof_dark) + c.rect(x0, 31, 32, 1, roof_dark) + + x0 = slot(4) + c.rect(x0, 0, 32, 32, wall) + c.rect(x0, 0, 32, 1, wall_light) + c.rect(x0 + 15, 0, 2, 32, wall_seam) + c.rect(x0, 9, 32, 1, wall_seam) + c.rect(x0, 18, 32, 1, wall_seam) + c.rect(x0, 27, 32, 5, wall_shadow) + c.rect(x0, 31, 32, 1, (120, 102, 80, 255)) + + x0 = slot(5) + c.rect(x0, 0, 32, 32, wood) + for y in (0, 8, 16, 24): + c.rect(x0, y, 32, 1, wood_light) + for y in (7, 15, 23, 31): + c.rect(x0, y, 32, 1, wood_dark) + for y in (4, 12, 20, 28): + c.set(x0 + 4, y, (61, 45, 49, 255)) + c.set(x0 + 27, y, (61, 45, 49, 255)) + + x0 = slot(6) + for stem in (5, 15, 25): + c.rect(x0 + stem, 2, 3, 30, bamboo) + c.set(x0 + stem, 2, bamboo_light) + c.set(x0 + stem, 3, bamboo_light) + c.set(x0 + stem, 4, bamboo_light) + for y in (8, 16, 24): + c.rect(x0 + stem, y, 3, 1, bamboo_node) + for i, ly in enumerate((6, 14, 22)): + if i % 2 == 0: + c.rect(x0 + stem - 4, ly, 5, 2, bamboo_light) + c.rect(x0 + stem + 3, ly + 3, 4, 2, bamboo) + else: + c.rect(x0 + stem + 3, ly, 5, 2, bamboo_light) + c.rect(x0 + stem - 4, ly + 3, 4, 2, bamboo) + c.outline(x0, 0, 32, 32) + + x0 = slot(7) + for row_y in (5, 14, 23): + c.rect(x0 + 2, row_y, 28, 4, soil) + c.rect(x0 + 2, row_y, 28, 1, soil_light) + for sx in (6, 15, 24): + c.rect(x0 + sx, row_y - 3, 2, 3, sprout) + c.set(x0 + sx, row_y - 3, sprout_light) + c.set(x0 + sx + 1, row_y - 3, sprout_light) + c.outline(x0, 0, 32, 32) + + return c + + +VARIANTS = [ + {"robe": (46, 57, 108, 255), "light": (104, 145, 183, 255), "dark": (32, 41, 80, 255), "hat": False}, + {"robe": (141, 91, 45, 255), "light": (205, 163, 76, 255), "dark": (104, 66, 32, 255), "hat": False}, + {"robe": (46, 113, 91, 255), "light": (121, 185, 139, 255), "dark": (32, 84, 68, 255), "hat": False}, + {"robe": (122, 96, 58, 255), "light": (176, 141, 84, 255), "dark": (92, 71, 42, 255), "hat": True}, +] + +DIRECTIONS = ["north", "south", "west", "east"] + + +def draw_character_cell(c, ox, variant, direction, frame): + robe = variant["robe"] + light = variant["light"] + dark = variant["dark"] + hat = (196, 158, 74, 255) + hat_light = (228, 197, 112, 255) + hat_dark = (168, 132, 58, 255) + + c.rect(ox + 8, 44, 16, 1, (23, 31, 34, 110)) + c.rect(ox + 10, 45, 12, 1, (23, 31, 34, 90)) + + if frame == 0: + c.rect(ox + 10, 36, 4, 7, dark) + c.rect(ox + 9, 42, 6, 3, SHOE) + c.rect(ox + 18, 36, 4, 5, dark) + c.rect(ox + 18, 39, 6, 3, SHOE) + else: + c.rect(ox + 18, 36, 4, 7, dark) + c.rect(ox + 18, 42, 6, 3, SHOE) + c.rect(ox + 10, 36, 4, 5, dark) + c.rect(ox + 10, 39, 6, 3, SHOE) + + c.rect(ox + 7, 22, 18, 14, robe) + c.rect(ox + 23, 22, 2, 14, dark) + c.rect(ox + 7, 31, 18, 3, light) + c.rect(ox + 13, 22, 6, 2, light) + c.rect(ox + 15, 24, 2, 2, light) + + c.rect(ox + 5, 24, 2, 9, robe) + c.rect(ox + 25, 24, 2, 9, robe) + if direction != "north": + c.rect(ox + 5, 32, 2, 2, light) + c.rect(ox + 25, 32, 2, 2, light) + c.rect(ox + 5, 34, 2, 3, SKIN) + c.rect(ox + 25, 34, 2, 3, SKIN) + + c.rect(ox + 10, 12, 12, 10, SKIN) + c.rect(ox + 9, 8, 14, 4, HAIR) + c.rect(ox + 9, 12, 2, 3, HAIR) + c.rect(ox + 21, 12, 2, 3, HAIR) + + if direction == "north": + c.rect(ox + 10, 12, 12, 5, HAIR) + elif direction == "south": + c.rect(ox + 12, 15, 2, 2, EYE) + c.rect(ox + 18, 15, 2, 2, EYE) + c.rect(ox + 15, 19, 2, 1, (196, 120, 104, 255)) + elif direction == "west": + c.rect(ox + 18, 12, 4, 6, HAIR) + c.rect(ox + 11, 15, 2, 2, EYE) + elif direction == "east": + c.rect(ox + 10, 12, 4, 6, HAIR) + c.rect(ox + 19, 15, 2, 2, EYE) + + if variant["hat"]: + c.rect(ox + 11, 3, 10, 7, hat) + c.rect(ox + 12, 4, 8, 1, hat_light) + c.rect(ox + 6, 9, 20, 2, hat) + c.rect(ox + 7, 11, 18, 1, hat_dark) + + c.outline(ox, 0, 32, 48) + + +def build_character_atlas(): + c = Canvas(1024, 48) + cell = 0 + for variant in VARIANTS: + for direction in DIRECTIONS: + for frame in (0, 1): + draw_character_cell(c, cell * 32, variant, direction, frame) + cell += 1 + return c + + +def build_interior_atlas(): + c = Canvas(392, 96) + + floor = (172, 138, 96, 255) + floor_line = (146, 113, 74, 255) + floor_light = (188, 155, 110, 255) + wall = (225, 216, 195, 255) + wall_light = (238, 232, 214, 255) + wall_seam = (196, 180, 152, 255) + wood = (119, 76, 47, 255) + wood_light = (164, 111, 61, 255) + wood_dark = (83, 52, 39, 255) + frame_wood = (94, 66, 40, 255) + ink = (61, 45, 49, 255) + brass = (196, 158, 74, 255) + sky = (86, 143, 170, 255) + cloud = (214, 231, 238, 255) + + x0, y0 = 0, 64 + c.rect(x0, y0, 32, 32, floor) + for y in (7, 15, 23, 31): + c.rect(x0, y0 + y, 32, 1, floor_line) + c.rect(x0 + 20, y0, 1, 7, floor_line) + for x in (6, 26): + c.rect(x0 + x, y0 + 8, 1, 7, floor_line) + c.rect(x0 + 13, y0 + 16, 1, 7, floor_line) + for x in (3, 22): + c.rect(x0 + x, y0 + 24, 1, 7, floor_line) + for y in range(32): + for x in range(32): + if (x * 11 + y * 5) % 37 == 0: + c.set(x0 + x, y0 + y, floor_light) + + x0, y0 = 32, 64 + c.rect(x0, y0, 32, 32, wall) + c.rect(x0, y0, 32, 1, wall_light) + c.rect(x0 + 15, y0, 2, 32, wall_seam) + c.rect(x0, y0 + 28, 32, 4, floor_line) + c.rect(x0, y0 + 31, 32, 1, wood_dark) + + x0, y0 = 64, 56 + c.rect(x0, y0, 48, 40, frame_wood) + c.rect(x0 + 3, y0 + 3, 42, 28, sky) + c.rect(x0 + 6, y0 + 7, 10, 3, cloud) + c.rect(x0 + 20, y0 + 5, 12, 3, cloud) + c.rect(x0 + 14, y0 + 3, 3, 28, frame_wood) + c.rect(x0 + 30, y0 + 3, 3, 28, frame_wood) + c.rect(x0 + 3, y0 + 14, 42, 3, frame_wood) + c.rect(x0, y0 + 33, 48, 7, wood) + c.rect(x0, y0 + 38, 48, 2, wood_dark) + c.outline(x0, y0, 48, 40) + + x0, y0 = 112, 48 + c.rect(x0 + 6, y0 + 16, 8, 28, frame_wood) + c.rect(x0 + 50, y0 + 16, 8, 28, frame_wood) + c.rect(x0 + 12, y0 + 16, 2, 28, wood_dark) + c.rect(x0 + 56, y0 + 16, 2, 28, wood_dark) + c.rect(x0, y0 + 4, 64, 14, wood) + c.rect(x0, y0 + 4, 64, 3, wood_light) + c.rect(x0, y0 + 16, 64, 2, wood_dark) + c.rect(x0 + 12, y0 + 10, 6, 5, wall) + c.rect(x0 + 13, y0 + 11, 4, 2, sky) + c.rect(x0 + 40, y0 + 7, 10, 8, ink) + c.rect(x0 + 43, y0 + 5, 4, 2, ink) + c.rect(x0 + 50, y0 + 9, 3, 3, ink) + c.outline(x0, y0, 64, 48) + + x0, y0 = 176, 0 + c.rect(x0, y0, 48, 96, frame_wood) + c.rect(x0, y0, 5, 96, wood_dark) + c.rect(x0 + 43, y0, 5, 96, wood_dark) + c.rect(x0 + 5, y0 + 4, 38, 88, (224, 211, 185, 255)) + c.rect(x0 + 9, y0 + 8, 30, 14, (238, 232, 214, 255)) + c.rect(x0 + 9, y0 + 22, 30, 2, wall_seam) + quilt = (114, 62, 67, 255) + quilt_line = (94, 48, 52, 255) + c.rect(x0 + 5, y0 + 28, 38, 64, quilt) + c.rect(x0 + 5, y0 + 28, 38, 2, quilt_line) + for y in (38, 48, 58, 68, 78): + c.rect(x0 + 5, y0 + y, 38, 2, quilt_line) + c.rect(x0, y0 + 88, 48, 6, wood_dark) + c.rect(x0, y0 + 92, 48, 2, ink) + c.outline(x0, y0, 48, 96) + + x0, y0 = 224, 0 + c.rect(x0, y0, 32, 96, ink) + c.rect(x0 + 4, y0 + 4, 24, 88, (142, 102, 72, 255)) + c.rect(x0 + 6, y0 + 6, 20, 84, (163, 124, 86, 255)) + c.rect(x0 + 8, y0 + 60, 16, 4, frame_wood) + c.rect(x0 + 10, y0 + 56, 12, 4, frame_wood) + c.rect(x0 + 12, y0 + 52, 8, 4, frame_wood) + c.rect(x0 + 8, y0 + 68, 16, 2, (188, 163, 126, 255)) + c.outline(x0, y0, 32, 96) + + x0, y0 = 256, 32 + rug = (150, 62, 58, 255) + rug_inner = (171, 84, 72, 255) + rug_dark = (134, 52, 50, 255) + c.rect(x0, y0, 96, 64, rug) + c.rect(x0, y0, 96, 5, brass) + c.rect(x0, y0 + 59, 96, 5, brass) + c.rect(x0, y0, 5, 64, brass) + c.rect(x0 + 91, y0, 5, 64, brass) + c.rect(x0 + 9, y0 + 9, 78, 46, rug_inner) + for dy in range(-9, 10): + w = (9 - abs(dy)) * 2 + c.rect(x0 + 48 - w // 2, y0 + 32 + dy, w, 1, brass) + for cx, cy in ((12, 12), (82, 12), (12, 50), (82, 50)): + c.rect(x0 + cx, y0 + cy, 2, 2, brass) + for y in range(9, 55): + for x in range(9, 87): + if (x * 7 + y * 13) % 41 == 0: + c.set(x0 + x, y0 + y, rug_dark) + + x0, y0 = 352, 60 + c.rect(x0, y0 + 8, 40, 28, wood) + c.rect(x0, y0, 40, 12, frame_wood) + c.rect(x0, y0, 40, 2, wood_light) + c.rect(x0, y0 + 11, 40, 2, ink) + c.rect(x0 + 17, y0 + 9, 6, 8, brass) + c.rect(x0 + 19, y0 + 12, 2, 3, ink) + for bx, by in ((0, 8), (37, 8), (0, 31), (37, 31)): + c.rect(x0 + bx, y0 + by, 3, 3, wood_light) + c.rect(x0, y0 + 33, 40, 3, ink) + c.outline(x0, y0, 40, 36) + + return c + + +def main(): + ASSET_DIR.mkdir(parents=True, exist_ok=True) + targets = [ + ("jiangnan-world.png", build_world_atlas(), 256, 32), + ("jiangnan-characters.png", build_character_atlas(), 1024, 48), + ("jiangnan-interior.png", build_interior_atlas(), 392, 96), + ] + for name, canvas, w, h in targets: + path = ASSET_DIR / name + write_png(path, canvas) + verify_png(path, w, h) + + +if __name__ == "__main__": + main() |
