summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 01:41:05 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 01:41:05 +0800
commitfc77481196aaea817eec991bdcf4dc22bb1d245c (patch)
tree95df2d0bff79a742c7f3637fbec680f2e6945316 /scripts
parentb6b95988e525a68ea7426ecc9690b18a1b3bbfe8 (diff)
downloadliving-village-fc77481196aaea817eec991bdcf4dc22bb1d245c.tar.gz
feat(desktop): 江南美术改为真实 PNG 图集
- scripts/make-jiangnan-art.py 生成三张原创透明 PNG:世界 256x32、角色 1024x48、内景 392x96 - VillageArt.fs 移除进程内像素烘焙,改为 BaseDirectory 加载图集并校验尺寸 - 内景渲染改用图集(地板/墙/窗/桌/床/屏/毯/柜),新增玩家专属 StrawHat 变体 - 内景新增地毯与柜子装饰,角色按 FeetPosition 定位 - fsproj 随包复制三张 PNG;m6c-package.sh 校验三张 PNG 进入交付包
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/m6c-package.sh4
-rw-r--r--scripts/make-jiangnan-art.py460
2 files changed, 462 insertions, 2 deletions
diff --git a/scripts/m6c-package.sh b/scripts/m6c-package.sh
index 89ae6a4..b620427 100755
--- a/scripts/m6c-package.sh
+++ b/scripts/m6c-package.sh
@@ -46,9 +46,9 @@ else
fi
fi
-for asset in Assets/cjk-glyph-atlas.png Assets/cjk-glyphs.txt; do
+for asset in Assets/cjk-glyph-atlas.png Assets/cjk-glyphs.txt Assets/jiangnan-world.png Assets/jiangnan-characters.png Assets/jiangnan-interior.png; do
if [[ ! -s "$APP_DIR/$asset" ]]; then
- printf 'Required CJK asset is missing from the Linux package: %s\n' "$APP_DIR/$asset" >&2
+ printf 'Required asset is missing from the Linux package: %s\n' "$APP_DIR/$asset" >&2
exit 1
fi
done
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()