diff options
| -rw-r--r-- | docs/assets-and-licenses.md | 3 | ||||
| -rwxr-xr-x | scripts/m6c-package.sh | 4 | ||||
| -rw-r--r-- | scripts/make-jiangnan-art.py | 460 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 13 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/jiangnan-characters.png | bin | 0 -> 1892 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/jiangnan-interior.png | bin | 0 -> 1613 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/jiangnan-world.png | bin | 0 -> 1098 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 9 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj | 3 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/VillageArt.fs | 305 |
10 files changed, 628 insertions, 169 deletions
diff --git a/docs/assets-and-licenses.md b/docs/assets-and-licenses.md index f2dc1bc..10bd69e 100644 --- a/docs/assets-and-licenses.md +++ b/docs/assets-and-licenses.md @@ -2,7 +2,8 @@ ## 仓库和交付包中的实际资产 -- **原创代码生成图形与 procedural data**:`src/LivingVillage.Desktop/Game.fs` 中的 `PixelText`、色块和 `Atlas` 绘制逻辑,以及 `VillageArt.fs` 和 `ChineseText.fs` 中的江南场景数据、NPC 视觉元数据和中文排版策略,均由仓库代码在运行时或纯函数中生成,不主张外部素材来源。 +- **原创代码生成图形与 procedural data**:`src/LivingVillage.Desktop/Game.fs` 中的 `PixelText`、色块和 `Atlas` 绘制逻辑,以及 `VillageArt.fs` 和 `ChineseText.fs` 中的图集采样坐标、NPC 视觉元数据和中文排版策略,均由仓库代码在运行时或纯函数中生成,不主张外部素材来源。 +- **江南美术原创图集**:`src/LivingVillage.Desktop/Assets/jiangnan-world.png`(256x32)、`jiangnan-characters.png`(1024x48)与 `jiangnan-interior.png`(392x96)是仓库实际分发的透明 PNG 图集,全部由 `scripts/make-jiangnan-art.py` 以原创调色板和逐像素几何生成,不使用任何外部素材;三者随 Linux/macOS 发布包复制到 `Assets/` 目录。`VillageArt.fs` 运行时只加载并采样这些图集,不再在进程内烘焙像素。 - **Noto Sans CJK 派生字形图集**:`src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png` 是仓库实际分发的 `384x288` 透明 PNG,使用 `src/LivingVillage.Desktop/Assets/cjk-glyphs.txt` 中的 188 个字符、16 列 24px 单元生成;两者会复制到 Linux/macOS 发布包的 `Assets/` 目录。原始字体文件不随仓库或包分发。 - 来源:`/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc`,字体族包含 Noto Sans CJK SC。 - 许可:SIL Open Font License 1.1;本地版权文件 SHA-256:`849f4ea9c214fa4ac3593b770c699f387534b11ce671264c1b10d85bdcb5997b`。 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() diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 842ab4a..c5b127f 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -416,3 +416,16 @@ type PrototypeTests () = Assert.IsTrue(manifestLines |> Array.forall (fun line -> line.Length = 1), "The CJK manifest must contain exactly one character per line.") CollectionAssert.AreEqual(characters, manifestLines |> Array.map (fun line -> line.[0])) Assert.IsTrue(File.Exists(runtimeAssetPath ()), sprintf "Missing atlas PNG: %s" (runtimeAssetPath ())) + + [<TestMethod>] + member _.JiangnanArtAssetsArePackagedAsRealPngFiles () = + let requiredAssets = + [ "Assets/jiangnan-world.png" + "Assets/jiangnan-characters.png" + "Assets/jiangnan-interior.png" ] + + let missing = + requiredAssets + |> List.filter (fun relativePath -> not (File.Exists(Path.Combine(AppContext.BaseDirectory, relativePath)))) + + Assert.AreEqual<string list>([], missing, sprintf "Missing Jiangnan PNG assets: %s" (String.concat ", " missing)) diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-characters.png b/src/LivingVillage.Desktop/Assets/jiangnan-characters.png Binary files differnew file mode 100644 index 0000000..ffe6f66 --- /dev/null +++ b/src/LivingVillage.Desktop/Assets/jiangnan-characters.png diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-interior.png b/src/LivingVillage.Desktop/Assets/jiangnan-interior.png Binary files differnew file mode 100644 index 0000000..d23f6b7 --- /dev/null +++ b/src/LivingVillage.Desktop/Assets/jiangnan-interior.png diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png Binary files differnew file mode 100644 index 0000000..75dc906 --- /dev/null +++ b/src/LivingVillage.Desktop/Assets/jiangnan-world.png diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index dd04e26..514928f 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -387,7 +387,7 @@ type LivingVillageGame() as this = override _.LoadContent() = spriteBatch <- new SpriteBatch(this.GraphicsDevice) - artTextures <- VillageArt.buildTextures this.GraphicsDevice + artTextures <- VillageArt.loadTextures this.GraphicsDevice cjkAtlas <- CjkGlyphAtlas.load this.GraphicsDevice pixel <- new Texture2D(this.GraphicsDevice, 1, 1) pixel.SetData([| Color.White |]) @@ -395,6 +395,7 @@ type LivingVillageGame() as this = override _.UnloadContent() = if not (isNull artTextures.TileAtlas) then artTextures.TileAtlas.Dispose() if not (isNull artTextures.CharacterAtlas) then artTextures.CharacterAtlas.Dispose() + if not (isNull artTextures.InteriorAtlas) then artTextures.InteriorAtlas.Dispose() if not (isNull cjkAtlas) then cjkAtlas.Dispose() if not (isNull pixel) then pixel.Dispose() if not (isNull spriteBatch) then spriteBatch.Dispose() @@ -527,7 +528,9 @@ type LivingVillageGame() as this = spriteBatch.Begin() match m5View.HomeMode with | Inside _ -> - VillageArt.drawInterior spriteBatch pixel viewport.Width viewport.Height renderPlan.Interior + VillageArt.drawInterior spriteBatch pixel artTextures viewport.Width viewport.Height renderPlan.Interior Color.White + let interiorAvatarSpec = VillageArt.avatarSpriteSpec avatarFacing world.Tick + VillageArt.drawInteriorCharacter spriteBatch artTextures viewport.Width viewport.Height world.Avatar.Pos interiorAvatarSpec Color.White | Outside -> VillageArt.drawWorld spriteBatch @@ -538,7 +541,7 @@ type LivingVillageGame() as this = renderPlan profile.WorldTint - let avatarSpec = VillageArt.npcSpriteSpec (NpcId 0) avatarFacing world.Tick + let avatarSpec = VillageArt.avatarSpriteSpec avatarFacing world.Tick VillageArt.drawCharacter spriteBatch artTextures camera world.Avatar.Pos avatarSpec profile.WorldTint for npc in world.Npcs do diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj index 4a1b483..b6b619f 100644 --- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -28,6 +28,9 @@ <ItemGroup> <None Include="Assets\cjk-glyph-atlas.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> <None Include="Assets\cjk-glyphs.txt" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + <None Include="Assets\jiangnan-world.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + <None Include="Assets\jiangnan-characters.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + <None Include="Assets\jiangnan-interior.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> </ItemGroup> </Project> diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index e46d85c..03cd22e 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -66,6 +66,7 @@ module VillageArt = | IndigoRobe | OchreVest | JadeSash + | StrawHat type FacingDirection = | NorthFacing @@ -84,7 +85,8 @@ module VillageArt = type ArtTextures = { TileAtlas: Texture2D - CharacterAtlas: Texture2D } + CharacterAtlas: Texture2D + InteriorAtlas: Texture2D } type private TileSprite = | GrassSprite @@ -222,6 +224,7 @@ module VillageArt = | IndigoRobe -> 0 | OchreVest -> 1 | JadeSash -> 2 + | StrawHat -> 3 let directionIndex = match spec.Direction with | NorthFacing -> 0 @@ -235,151 +238,41 @@ module VillageArt = let cellIndex = (variantIndex * 4 + directionIndex) * 2 + frameIndex Microsoft.Xna.Framework.Rectangle(cellIndex * 32, 0, 32, 48) - let private setPixel (pixels: XnaColor[]) (width: int) (x: int) (y: int) (color: XnaColor) = - let height = pixels.Length / width - if x >= 0 && x < width && y >= 0 && y < height then - pixels.[y * width + x] <- color + let private rgba (r: int) (g: int) (b: int) (a: int) : XnaColor = XnaColor(r, g, b, a) - let private fillRect (pixels: XnaColor[]) (width: int) (x: int) (y: int) (rectWidth: int) (rectHeight: int) (color: XnaColor) = - for row in y .. y + rectHeight - 1 do - for column in x .. x + rectWidth - 1 do - setPixel pixels width column row color + let avatarSpriteSpec (direction: FacingDirection) (tick: int64) : NpcSpriteSpec = + { Variant = StrawHat + Direction = direction + Frame = animationFrame tick } - let private rgba (r: int) (g: int) (b: int) (a: int) : XnaColor = XnaColor(r, g, b, a) + let worldAtlasRelativePath = "Assets/jiangnan-world.png" + let characterAtlasRelativePath = "Assets/jiangnan-characters.png" + let interiorAtlasRelativePath = "Assets/jiangnan-interior.png" + + let worldAtlasWidth = Sim.tilePixels * 8 + let worldAtlasHeight = Sim.tilePixels + let characterAtlasWidth = 32 * 4 * 4 * 2 + let characterAtlasHeight = 48 + let interiorAtlasWidth = 392 + let interiorAtlasHeight = 96 + + let private loadTexture (device: GraphicsDevice) (relativePath: string) (expectedWidth: int) (expectedHeight: int) : Texture2D = + let fullPath = System.IO.Path.Combine(System.AppContext.BaseDirectory, relativePath) + if not (System.IO.File.Exists fullPath) then + raise (System.IO.FileNotFoundException(sprintf "Jiangnan art atlas is missing: %s" fullPath, fullPath)) + + use stream = System.IO.File.OpenRead fullPath + let texture = Texture2D.FromStream(device, stream) + if texture.Width <> expectedWidth || texture.Height <> expectedHeight then + texture.Dispose() + invalidOp (sprintf "Jiangnan art atlas %s has dimensions %dx%d; expected %dx%d." fullPath texture.Width texture.Height expectedWidth expectedHeight) + else + texture - let private buildTileAtlas (device: GraphicsDevice) : Texture2D = - let atlasWidth = Sim.tilePixels * 8 - let atlasHeight = Sim.tilePixels - let pixels = Array.create (atlasWidth * atlasHeight) (rgba 0 0 0 0) - let fillSlot slot color = fillRect pixels atlasWidth (slot * Sim.tilePixels) 0 Sim.tilePixels Sim.tilePixels color - let setSlotPixel slot x y color = setPixel pixels atlasWidth (slot * Sim.tilePixels + x) y color - let grass = rgba 66 112 70 255 - let grassLight = rgba 92 139 82 255 - let water = rgba 54 116 145 255 - let waterLight = rgba 105 174 184 255 - let path = rgba 170 153 123 255 - let pathLight = rgba 201 183 145 255 - let roof = rgba 45 49 57 255 - let roofLight = rgba 76 80 85 255 - let wall = rgba 225 216 195 255 - let wallShadow = rgba 166 147 119 255 - let wood = rgba 119 76 47 255 - let woodLight = rgba 164 111 61 255 - let bamboo = rgba 51 101 61 255 - let bambooLight = rgba 107 153 81 255 - let soil = rgba 122 78 49 255 - let sprout = rgba 86 139 68 255 - - fillSlot 0 grass - for y in 0 .. Sim.tilePixels - 1 do - for x in 0 .. Sim.tilePixels - 1 do - if (x * 13 + y * 7) % 29 = 0 then - setSlotPixel 0 x y grassLight - - fillSlot 1 water - for y in [ 5; 16; 27 ] do - for x in 3 .. Sim.tilePixels - 4 do - if (x + y) % 5 <> 0 then - setSlotPixel 1 x y waterLight - for x in [ 8; 23 ] do - fillRect pixels atlasWidth (x + Sim.tilePixels) 2 2 26 (rgba 44 93 124 255) - - fillSlot 2 path - for y in [ 0; 31 ] do - for x in 0 .. Sim.tilePixels - 1 do - setSlotPixel 2 x y pathLight - for x in [ 7; 24 ] do - for y in 4 .. 27 do - if y % 3 <> 0 then - setSlotPixel 2 x y pathLight - - fillSlot 3 roof - for y in [ 5; 13; 21; 29 ] do - for x in 0 .. Sim.tilePixels - 1 do - if (x + y) % 6 < 5 then - setSlotPixel 3 x y roofLight - - fillSlot 4 wall - fillRect pixels atlasWidth (4 * Sim.tilePixels) 0 2 Sim.tilePixels wallShadow - fillRect pixels atlasWidth (4 * Sim.tilePixels + 15) 0 2 Sim.tilePixels wallShadow - fillRect pixels atlasWidth (4 * Sim.tilePixels + 30) 0 2 Sim.tilePixels wallShadow - fillRect pixels atlasWidth (4 * Sim.tilePixels) 27 Sim.tilePixels 3 wallShadow - - fillSlot 5 wood - for y in [ 6; 15; 24 ] do - fillRect pixels atlasWidth (5 * Sim.tilePixels) y Sim.tilePixels 2 woodLight - - fillSlot 6 (rgba 0 0 0 0) - for stemX in [ 6; 16; 26 ] do - fillRect pixels atlasWidth (6 * Sim.tilePixels + stemX) 4 3 25 bamboo - for leafY in [ 8; 17; 25 ] do - fillRect pixels atlasWidth (6 * Sim.tilePixels + stemX - 4) leafY 5 2 bambooLight - fillRect pixels atlasWidth (6 * Sim.tilePixels + stemX + 2) (leafY + 2) 5 2 bambooLight - - fillSlot 7 (rgba 0 0 0 0) - for row in [ 6; 15; 24 ] do - fillRect pixels atlasWidth (7 * Sim.tilePixels + 2) row 28 3 soil - for sproutX in [ 6; 15; 24 ] do - fillRect pixels atlasWidth (7 * Sim.tilePixels + sproutX) (row - 3) 2 3 sprout - - let texture = new Texture2D(device, atlasWidth, atlasHeight, false, SurfaceFormat.Color) - texture.SetData pixels - texture - - let private buildCharacterAtlas (device: GraphicsDevice) : Texture2D = - let cellWidth = 32 - let cellHeight = 48 - let columns = 3 * 4 * 2 - let atlasWidth = columns * cellWidth - let pixels = Array.create (atlasWidth * cellHeight) (rgba 0 0 0 0) - let variants = [| IndigoRobe; OchreVest; JadeSash |] - let directions = [| NorthFacing; SouthFacing; WestFacing; EastFacing |] - let frames = [| FrameOne; FrameTwo |] - - for variantIndex in 0 .. variants.Length - 1 do - for directionIndex in 0 .. directions.Length - 1 do - for frameIndex in 0 .. frames.Length - 1 do - let variant = variants.[variantIndex] - let direction = directions.[directionIndex] - let frame = frames.[frameIndex] - let spec = { Variant = variant; Direction = direction; Frame = frame } - let source = characterSourceRectangle spec - let offsetX = source.X - let robe, sash = - match variant with - | IndigoRobe -> rgba 46 57 108 255, rgba 104 145 183 255 - | OchreVest -> rgba 141 91 45 255, rgba 205 163 76 255 - | JadeSash -> rgba 46 113 91 255, rgba 121 185 139 255 - let skin = rgba 232 180 139 255 - let hair = rgba 42 36 37 255 - let shadow = rgba 23 31 34 120 - let legShift = if frame = FrameOne then 0 else 2 - fillRect pixels atlasWidth (offsetX + 7) 42 18 3 shadow - fillRect pixels atlasWidth (offsetX + 8 + legShift) 35 6 10 robe - fillRect pixels atlasWidth (offsetX + 18 - legShift) 35 6 10 robe - fillRect pixels atlasWidth (offsetX + 7) 22 18 16 robe - fillRect pixels atlasWidth (offsetX + 9) 32 14 4 sash - fillRect pixels atlasWidth (offsetX + 10) 12 12 10 skin - fillRect pixels atlasWidth (offsetX + 7) 8 18 4 hair - fillRect pixels atlasWidth (offsetX + 5) 7 22 3 sash - match direction with - | NorthFacing -> - fillRect pixels atlasWidth (offsetX + 9) 13 14 8 hair - | SouthFacing -> - fillRect pixels atlasWidth (offsetX + 12) 17 2 2 hair - fillRect pixels atlasWidth (offsetX + 18) 17 2 2 hair - | WestFacing -> - fillRect pixels atlasWidth (offsetX + 9) 16 3 5 hair - | EastFacing -> - fillRect pixels atlasWidth (offsetX + 20) 16 3 5 hair - - let texture = new Texture2D(device, atlasWidth, cellHeight, false, SurfaceFormat.Color) - texture.SetData pixels - texture - - let buildTextures (device: GraphicsDevice) : ArtTextures = - { TileAtlas = buildTileAtlas device - CharacterAtlas = buildCharacterAtlas device } + let loadTextures (device: GraphicsDevice) : ArtTextures = + { TileAtlas = loadTexture device worldAtlasRelativePath worldAtlasWidth worldAtlasHeight + CharacterAtlas = loadTexture device characterAtlasRelativePath characterAtlasWidth characterAtlasHeight + InteriorAtlas = loadTexture device interiorAtlasRelativePath interiorAtlasWidth interiorAtlasHeight } let private tileSourceRectangle sprite : XnaRectangle = let index = @@ -494,30 +387,116 @@ module VillageArt = 48) spriteBatch.Draw(textures.CharacterAtlas, destination, characterSourceRectangle spec, tint) - let drawInterior (spriteBatch: SpriteBatch) (pixel: Texture2D) (viewportWidth: int) (viewportHeight: int) (plan: InteriorPlan) = - let roomX = 40 - let roomY = 40 - let roomWidth = max 320 (viewportWidth - 80) - let roomHeight = max 240 (viewportHeight - 80) + type InteriorLayout = + { RoomX: int + RoomY: int + RoomWidth: int + RoomHeight: int } + + let private interiorWallBandHeight = 64 + + let private floorCellSource = XnaRectangle(0, 64, Sim.tilePixels, Sim.tilePixels) + let private wallCellSource = XnaRectangle(32, 64, Sim.tilePixels, Sim.tilePixels) + let private rugSource = XnaRectangle(256, 32, 96, 64) + let private chestSource = XnaRectangle(352, 60, 40, 36) + + let interiorSourceRectangle (kind: InteriorElementKind) : Microsoft.Xna.Framework.Rectangle = + match kind with + | Window -> XnaRectangle(64, 56, 48, 40) + | Table -> XnaRectangle(112, 48, 64, 48) + | Bed -> XnaRectangle(176, 0, 48, 96) + | Screen -> XnaRectangle(224, 0, 32, 96) + + let interiorLayout (viewportWidth: int) (viewportHeight: int) : InteriorLayout = + { RoomX = 40 + RoomY = 40 + RoomWidth = max 320 (viewportWidth - 80) + RoomHeight = max 240 (viewportHeight - 80) } + + let interiorColumns (layout: InteriorLayout) : int = + max 1 (layout.RoomWidth / Sim.tilePixels) + + let interiorRows (layout: InteriorLayout) : int = + max 1 ((layout.RoomHeight - interiorWallBandHeight) / Sim.tilePixels) + + let interiorFloorY (layout: InteriorLayout) : int = + layout.RoomY + interiorWallBandHeight + + let private clampInt (minimum: int) (maximum: int) (value: int) : int = + if value < minimum then minimum + elif value > maximum then maximum + else value + + let interiorCharacterScreenPosition (layout: InteriorLayout) (worldPosition: Vec2) : PixelPosition = + let sceneTile = sceneTileFromPixel { PixelPosition.X = worldPosition.X; Y = worldPosition.Y } + let tileX = clampInt 1 (interiorColumns layout - 2) sceneTile.X + let tileY = clampInt 1 (interiorRows layout - 2) sceneTile.Y + let floorY = interiorFloorY layout + { PixelPosition.X = float32 (layout.RoomX + tileX * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (floorY + (tileY + 1) * Sim.tilePixels) } + + let drawInterior + (spriteBatch: SpriteBatch) + (pixel: Texture2D) + (textures: ArtTextures) + (viewportWidth: int) + (viewportHeight: int) + (plan: InteriorPlan) + (tint: XnaColor) = + let layout = interiorLayout viewportWidth viewportHeight + let floorY = interiorFloorY layout + let columns = interiorColumns layout + let rows = interiorRows layout + let partialRows = (layout.RoomHeight - interiorWallBandHeight) % Sim.tilePixels + let totalRows = 2 + rows + (if partialRows > 0 then 1 else 0) + spriteBatch.Draw(pixel, XnaRectangle(0, 0, viewportWidth, viewportHeight), rgba 18 25 42 255) - spriteBatch.Draw(pixel, XnaRectangle(roomX, roomY, roomWidth, roomHeight), rgba 188 163 126 255) - spriteBatch.Draw(pixel, XnaRectangle(roomX, roomY, roomWidth, 18), rgba 65 57 59 255) - spriteBatch.Draw(pixel, XnaRectangle(roomX, roomY + roomHeight - 18, roomWidth, 18), rgba 99 70 50 255) - for element in plan.Elements do - let x = roomX + element.Position.X * Sim.tilePixels - let y = roomY + element.Position.Y * Sim.tilePixels + let drawCell (source: XnaRectangle) (screenX: int) (screenY: int) = + let width = min Sim.tilePixels (layout.RoomX + layout.RoomWidth - screenX) + let height = min Sim.tilePixels (layout.RoomY + layout.RoomHeight - screenY) + if width > 0 && height > 0 then + spriteBatch.Draw(textures.InteriorAtlas, XnaRectangle(screenX, screenY, width, height), source, tint) + + for row in 0 .. totalRows - 1 do + for column in 0 .. columns - 1 do + let screenX = layout.RoomX + column * Sim.tilePixels + let screenY = layout.RoomY + row * Sim.tilePixels + let source = if row < 2 then wallCellSource else floorCellSource + drawCell source screenX screenY + + let rugX = layout.RoomX + (layout.RoomWidth - 96) / 2 + let rugY = floorY + 96 + spriteBatch.Draw(textures.InteriorAtlas, XnaRectangle(rugX, rugY, 96, 64), rugSource, tint) + + let elementDestination (element: InteriorElement) : XnaRectangle = + let baseY = floorY + (element.Position.Y + 1) * Sim.tilePixels match element.Kind with | Window -> - spriteBatch.Draw(pixel, XnaRectangle(x, y, 48, 30), rgba 86 143 170 255) - spriteBatch.Draw(pixel, XnaRectangle(x + 22, y, 4, 30), rgba 225 216 195 255) + XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 8, layout.RoomY + 24, 48, 40) | Table -> - spriteBatch.Draw(pixel, XnaRectangle(x, y, 64, 32), rgba 119 76 47 255) - spriteBatch.Draw(pixel, XnaRectangle(x + 8, y + 28, 8, 20), rgba 83 52 39 255) - spriteBatch.Draw(pixel, XnaRectangle(x + 48, y + 28, 8, 20), rgba 83 52 39 255) + XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 16, baseY - 48, 64, 48) | Bed -> - spriteBatch.Draw(pixel, XnaRectangle(x, y, 48, 80), rgba 114 62 67 255) - spriteBatch.Draw(pixel, XnaRectangle(x + 4, y + 4, 40, 24), rgba 224 211 185 255) + XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 8, baseY - 96, 48, 96) | Screen -> - spriteBatch.Draw(pixel, XnaRectangle(x, y, 16, 80), rgba 61 45 49 255) - spriteBatch.Draw(pixel, XnaRectangle(x + 12, y + 4, 8, 72), rgba 142 102 72 255) + XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels, baseY - 96, 32, 96) + + for element in plan.Elements |> List.sortBy (fun element -> element.Position.Y, element.Position.X) do + spriteBatch.Draw(textures.InteriorAtlas, elementDestination element, interiorSourceRectangle element.Kind, tint) + + let chestX = layout.RoomX + layout.RoomWidth - 44 + let chestY = layout.RoomY + layout.RoomHeight - 36 + spriteBatch.Draw(textures.InteriorAtlas, XnaRectangle(chestX, chestY, 40, 36), chestSource, tint) + + let drawInteriorCharacter + (spriteBatch: SpriteBatch) + (textures: ArtTextures) + (viewportWidth: int) + (viewportHeight: int) + (position: Vec2) + (spec: NpcSpriteSpec) + (tint: XnaColor) = + let layout = interiorLayout viewportWidth viewportHeight + let screenPosition = interiorCharacterScreenPosition layout position + let destination = XnaRectangle(int screenPosition.X - 16, int screenPosition.Y - 48, 32, 48) + spriteBatch.Draw(textures.CharacterAtlas, destination, characterSourceRectangle spec, tint) |
