summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/assets-and-licenses.md2
-rw-r--r--docs/evidence/p34-baseline-day.pngbin0 -> 58773 bytes
-rw-r--r--docs/evidence/p34-realassets-closeup-2x.pngbin0 -> 20899 bytes
-rw-r--r--docs/evidence/p34-realassets-day.pngbin0 -> 47319 bytes
-rw-r--r--docs/evidence/p34-realassets-night.pngbin0 -> 57561 bytes
-rw-r--r--docs/evidence/p34-water-2x.pngbin0 -> 8014 bytes
-rw-r--r--docs/素材来源.md45
-rw-r--r--docs/维护说明.md36
-rw-r--r--scripts/make-cc0-art.py141
-rw-r--r--src/LivingVillage.Desktop.Tests/AssetPackTests.fs32
-rw-r--r--src/LivingVillage.Desktop/Assets/cc0-tiles.pngbin388 -> 816 bytes
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs95
12 files changed, 310 insertions, 41 deletions
diff --git a/docs/assets-and-licenses.md b/docs/assets-and-licenses.md
index 1d691a7..5b13f49 100644
--- a/docs/assets-and-licenses.md
+++ b/docs/assets-and-licenses.md
@@ -8,7 +8,7 @@
- 来源:`/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc`,字体族包含 Noto Sans CJK SC。
- 许可:SIL Open Font License 1.1;本地版权文件 SHA-256:`849f4ea9c214fa4ac3593b770c699f387534b11ce671264c1b10d85bdcb5997b`。
- 参考:<https://raw.githubusercontent.com/notofonts/noto-cjk/main/README.md>。
-- **第三方 CC0 真实像素素材图集(P33 起)**:`src/LivingVillage.Desktop/Assets/cc0-tiles.png`(128x32,地块)与 `cc0-characters.png`(3840x48,角色)由 `scripts/make-cc0-art.py` 从两个 CC0 素材包确定性再生成(整数 2x NEAREST):
+- **第三方 CC0 真实像素素材图集(P33 起)**:`src/LivingVillage.Desktop/Assets/cc0-tiles.png`(384x32:前 4 格地块来自 Kenney,后 8 格水面/屋顶/墙体/栏杆/门为 P34 仓库自绘原创)与 `cc0-characters.png`(3840x48,角色)由 `scripts/make-cc0-art.py` 从两个 CC0 素材包确定性再生成(整数 2x NEAREST):
- **Kenney Tiny Farm 1.0**:来源 <https://kenney.nl/assets/tiny-farm>,许可 CC0 1.0(包内 `License.txt`),zip SHA-256 `a06f75f3…decc`。
- **Puny Characters(Shade)**:来源 <https://opengameart.org/content/puny-characters>,许可 CC0 1.0(发布页),zip SHA-256 `971d7b07…320e`。
- 原始 zip 记录于 `third_party/`;用途/构建/开关详见 `docs/素材来源.md`。运行时仅在 `LV_ASSET_PACK=cc0` 时启用,默认仍走程序绘制江南图集。
diff --git a/docs/evidence/p34-baseline-day.png b/docs/evidence/p34-baseline-day.png
new file mode 100644
index 0000000..7e3057b
--- /dev/null
+++ b/docs/evidence/p34-baseline-day.png
Binary files differ
diff --git a/docs/evidence/p34-realassets-closeup-2x.png b/docs/evidence/p34-realassets-closeup-2x.png
new file mode 100644
index 0000000..b9cde48
--- /dev/null
+++ b/docs/evidence/p34-realassets-closeup-2x.png
Binary files differ
diff --git a/docs/evidence/p34-realassets-day.png b/docs/evidence/p34-realassets-day.png
new file mode 100644
index 0000000..e447502
--- /dev/null
+++ b/docs/evidence/p34-realassets-day.png
Binary files differ
diff --git a/docs/evidence/p34-realassets-night.png b/docs/evidence/p34-realassets-night.png
new file mode 100644
index 0000000..72a4083
--- /dev/null
+++ b/docs/evidence/p34-realassets-night.png
Binary files differ
diff --git a/docs/evidence/p34-water-2x.png b/docs/evidence/p34-water-2x.png
new file mode 100644
index 0000000..1599a85
--- /dev/null
+++ b/docs/evidence/p34-water-2x.png
Binary files differ
diff --git a/docs/素材来源.md b/docs/素材来源.md
index dd193de..31494e9 100644
--- a/docs/素材来源.md
+++ b/docs/素材来源.md
@@ -1,8 +1,8 @@
# 第三方像素素材来源与许可
-P33 开始把「程序几何绘制」的样例村庄逐步替换为**真实像素素材**(统一像素风格)。
-本单只做第一条竖直切片:样例村庄的一类地面(草地/石板)与一类角色(村民/主角)接入
-CC0 素材,其余仍走原程序绘制江南图集作为 fallback。
+P33 开始把「程序几何绘制」的样例村庄逐步替换为**真实像素素材**(统一像素风格),
+P34 扩展到水面 / 屋顶 / 墙体 / 栏杆等构件。仍是竖直切片,不做全村,其余走原程序绘制
+江南图集作为 fallback。
- **开关**:`LV_ASSET_PACK=cc0` 时启用真实素材包;未设置或取其它值一律回退到程序绘制
图集(`Assets/jiangnan-*.png`)。图集选择入口见 `src/LivingVillage.Desktop/VillageArt.fs`
@@ -11,11 +11,12 @@ CC0 素材,其余仍走原程序绘制江南图集作为 fallback。
```bash
python3 scripts/make-cc0-art.py
- # -> src/LivingVillage.Desktop/Assets/cc0-tiles.png 128x32 (4 个 32x32 地面格)
+ # -> src/LivingVillage.Desktop/Assets/cc0-tiles.png 384x32 (12 个 32x32 格)
# -> src/LivingVillage.Desktop/Assets/cc0-characters.png 3840x48 (5 变体 x 4 向 x 6 帧, 32x48 格)
```
- 源素材 16x16 / 16x24,**整数 2x NEAREST** 放大到 32x32 / 32x48;缩放断言见
+ `cc0-tiles.png` 前 4 格来自 Kenney(源 16x16,**整数 2x NEAREST** 到 32x32),后 8 格为
+ P34 自绘(详见第 3 节);角色图为 Puny 源 16x24 整数 2x 到 32x48。缩放断言见
`src/LivingVillage.Desktop.Tests/AssetPackTests.fs`。
## 1. Kenney Tiny Farm(地块)
@@ -28,8 +29,9 @@ CC0 素材,其余仍走原程序绘制江南图集作为 fallback。
- SHA-256:`a06f75f312c27eff15a2288475612e6f6699411be7259d408323cd15a790decc`(191128 bytes)
- 获取日期:2026-09-21
- 用法:取 `Tilemap/tilemap_packed.png`(192x176,12x11 格,**无间距、16px pitch**;带间距的
- `tilemap.png` 为 203x186/17px)。本单取 (9,8)/(10,8) 两档草 + (4,8)/(4,9) 石板,
- 整数 2x 生成 `Assets/cc0-tiles.png`。
+ `tilemap.png` 为 203x186/17px)。取 (9,8)/(10,8) 两档草 + (4,8)/(4,9) 石板,整数 2x 生成
+ `Assets/cc0-tiles.png` 的 0-3 格。**Tiny Farm 是农场包,无水面 / 屋顶 / 墙体 / 栏杆素材**
+ (已逐格核查:无蓝色水面格),故这些构件自绘,见第 3 节。
## 2. Puny Characters(角色)
@@ -44,10 +46,31 @@ CC0 素材,其余仍走原程序绘制江南图集作为 fallback。
32x48 格。包只提供南向姿态,北向复用南向、东/西为镜像(限制沿用 `scripts/make-cc0-art.py`
注释)。生成 `Assets/cc0-characters.png`。
+## 3. 自绘补绘构件(P34,原创,无第三方来源)
+
+Kenney Tiny Farm 无合适的瓦屋顶 / 白墙 / 木格窗 / 栏杆 / 水波 / 门素材,按任务要求用仓库
+自绘 32px 像素图补入 `cc0-tiles.png` 的 4-11 格(**原创,无外部来源,无需第三方许可**),
+统一江南调色板:青灰瓦、白灰墙、暖木构件、碧水。逐格:
+
+| 索引 | 内容 | 说明 |
+| --- | --- | --- |
+| 4 | 水面 A | 自绘 2 相位水波之一 |
+| 5 | 水面 B | 相位相对 A 位移 4px(两相位差 256px) |
+| 6 | 屋顶正脊 | 青灰瓦脊 + 亮脊线 |
+| 7 | 屋顶檐口 | 青灰瓦 + 檐口高光/滴水 |
+| 8 | 墙体(木构) | 白灰墙 + 上下暖木梁 |
+| 9 | 木格窗 | 白灰墙 + 中心木格窗 |
+| 10 | 栏杆 | 木栏杆(用于桥面) |
+| 11 | 门 | 暖木双扇门 |
+
+- 自绘代码位于 `scripts/make-cc0-art.py`(`draw_water`/`draw_roof_*`/`draw_wall`/`draw_railing`/`draw_door`),
+ 确定性、可复现,无外部素材。
+- 水网/水田(生成器 `code=4`)与样例河道共用同一自绘 2 相位水面。
+
## 说明
-- 两者原始 zip 均随仓库记录于 `third_party/`(`PROVENANCE.md` 为总来源记录),仓库分发的
- 只是上述再生成图集,不声称拥有未包含素材。
+- Kenney/Puny 原始 zip 均随仓库记录于 `third_party/`(`PROVENANCE.md` 为总来源记录),仓库
+ 分发的只是上述再生成图集,不声称拥有未包含素材;自绘构件为原创。
- 版权/许可复核:见 `third_party/` 内原始文件与发布页;重跑 `make-cc0-art.py` 前后对
- `third_party/*.zip` 重新 `sha256sum` 校验(本单校验一致)。
-- 「中式元素补绘」为后续切片;本单只接入基础地块与一类角色,保证管线跑通。
+ `third_party/*.zip` 重新 `sha256sum` 校验。
+- 范围仍为切片:动画全套 / 全村 / 更多中式元素补绘留待后续。
diff --git a/docs/维护说明.md b/docs/维护说明.md
index 96dcecd..c095914 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -1073,3 +1073,39 @@ late pass),故「灯笼光晕 + 水面」以生成器夜帧为准,「炊�
```
- 范围:本单只接入基础地块与一类角色;动画全套 / 全村 / 中式元素补绘留待后续切片。
+
+## P34 素材扩展切片(水面 / 屋顶 / 墙体 / 栏杆)
+
+在 P33 竖直切片之上,把样例村庄更多表面/构件从程序几何切到真像素素材,延续 `LV_ASSET_PACK=cc0`
+开关与程序绘制 fallback。来源/许可/自绘清单见 `docs/素材来源.md`。
+
+- 图集扩展:`Assets/cc0-tiles.png` 由 128x32(4 格)扩到 **384x32(12 格)**。0-3 格仍为
+ Kenney Tiny Farm 地面(整数 2x);4-11 格为**自绘**构件(Kenney 农场包无水面/屋顶/墙/栏杆):
+ `4 water A / 5 water B / 6 roof ridge / 7 roof eave / 8 wall wood / 9 wall window / 10 railing / 11 door`。
+- 构建:`python3 scripts/make-cc0-art.py`(确定性,二次运行 byte-identical)。
+- 水面:自绘 **2 相位**,`cc0WaterTileIndexAt tick x y = (tick/32 + x + 2y) mod 2`,单帧内沿河
+ 同时出现两相位;河道与水田/水网(生成器 `code=4`)共用。江南 fallback 仍是 3 相位。
+- 屋顶 / 墙体 / 门:样例民居在 cc0 模式下用 6/7/8/9/11 格;桥面用 10 格栏杆;其余道具仍走
+ 江南图集。
+- 断言:`src/LivingVillage.Desktop.Tests/AssetPackTests.fs` 更新图集尺寸(12 格)与整数缩放,
+ 并新增「构件索引都在图集内 + 单帧覆盖两水相位」用例。
+- 证据(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 5 帧 0 命中):
+ - `p34-realassets-day.png` / `p34-realassets-night.png`:样例村庄真实素材昼 / 夜整帧 1280x720。
+ - `p34-realassets-closeup-2x.png`:民居屋顶/白墙/桥栏杆/水面 2x NEAREST(青灰瓦 (52,58,72)、
+ 白墙 (234,228,212)、木栏杆 (112,74,46)、水面 (54,110,170) 均在场)。
+ - `p34-water-2x.png`:河面 2x 特写(同帧可见两相位波纹)。
+ - `p34-baseline-day.png`:同场景程序绘制 fallback 对照。
+- 复现:
+
+ ```bash
+ DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
+ for h in 12.0 23.5; do
+ tag=day; [ "$h" = 23.5 ] && tag=night
+ timeout 20 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
+ LV_AUTOPLAY_RECORD=1 LV_AUTOPLAY_START_HOUR=$h LV_RECORD_EVERY=5 \
+ LV_RECORD_NAME=p34-$tag- LV_ASSET_PACK=cc0 \
+ LV_RECORD_DIR=/tmp/opencode/lv-p34-evidence/$tag dotnet "$DLL"
+ done
+ ```
+
+- 范围:仍为切片,不做全村;动画全套与更多中式元素补绘留待后续。
diff --git a/scripts/make-cc0-art.py b/scripts/make-cc0-art.py
index b26cba1..44bd013 100644
--- a/scripts/make-cc0-art.py
+++ b/scripts/make-cc0-art.py
@@ -1,12 +1,18 @@
#!/usr/bin/env python3
"""CC0 asset pack integration (third_party -> game atlases).
-Reads the two provenanced CC0 packs in third_party/ (Kenney Tiny Farm tiles,
-Puny Characters) and emits Deterministic CC0 Atlases for the prototype:
+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 128x32 (4 ground tiles, 32px each)
+ 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.
@@ -40,6 +46,19 @@ class Canvas:
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):
@@ -95,16 +114,102 @@ def verify_png(path, expected_w, expected_h):
print(f"ok {path.name} {width}x{height} bytes={len(data)}")
-def build_cc0_tile_atlas():
- """Four 32x32 ground tiles from Kenney Tiny Farm, upscaled 2x.
+# --- 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))
+
- 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). Tile selection is
- fixed by grid coordinates: two full grass shades (9,8)/(10,8) for the checker
- ground, then two grey stone slabs (4,8)/(4,9) for the paved path.
+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(128, 32)
+ c = Canvas(384, 32)
packed = Image.open(THIRD / "kenney_tiny-farm" / "Tilemap" / "tilemap_packed.png").convert("RGBA")
def tile(grid_x, grid_y):
@@ -112,8 +217,16 @@ def build_cc0_tile_atlas():
choices = [(9, 8), (10, 8), (4, 8), (4, 9)]
for index, (gx, gy) in enumerate(choices):
- t = tile(gx, gy)
- c.blit(t, index * 32, 0, scale=2)
+ 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
@@ -168,7 +281,7 @@ def build_cc0_character_atlas():
def main():
ASSETS.mkdir(parents=True, exist_ok=True)
targets = [
- ("cc0-tiles.png", build_cc0_tile_atlas(), 128, 32),
+ ("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:
diff --git a/src/LivingVillage.Desktop.Tests/AssetPackTests.fs b/src/LivingVillage.Desktop.Tests/AssetPackTests.fs
index fc235d0..46513f9 100644
--- a/src/LivingVillage.Desktop.Tests/AssetPackTests.fs
+++ b/src/LivingVillage.Desktop.Tests/AssetPackTests.fs
@@ -36,11 +36,41 @@ type AssetPackTests () =
Assert.AreEqual<int>(cc0TileAtlasHeight, height)
Assert.AreEqual<int>(0, width % Sim.tilePixels)
Assert.AreEqual<int>(0, height % Sim.tilePixels)
- Assert.AreEqual<int>(4, width / Sim.tilePixels)
+ Assert.AreEqual<int>(cc0TileCount, width / Sim.tilePixels)
+ Assert.AreEqual<int>(cc0TileAtlasWidth, width)
Assert.AreEqual<int>(2, Sim.tilePixels / cc0TileSourcePixels)
Assert.AreEqual<int>(0, Sim.tilePixels % cc0TileSourcePixels)
[<TestMethod>]
+ member _.Cc0StructureTileIndicesFitTheAtlasAndWaterHasTwoPhases () =
+ // Every P34 self-drawn structure cell must sit inside the 12-tile atlas band.
+ let indices =
+ [ cc0TileIndexWaterA
+ cc0TileIndexWaterB
+ cc0TileIndexRoofRidge
+ cc0TileIndexRoofEave
+ cc0TileIndexWallWood
+ cc0TileIndexWallWindow
+ cc0TileIndexRailing
+ cc0TileIndexDoor ]
+ for index in indices do
+ Assert.IsTrue(index >= 0 && index < cc0TileCount, sprintf "cc0 tile index %d out of atlas" index)
+ let width, _ = pngDimensions (assetPath cc0TileAtlasRelativePath)
+ Assert.IsTrue(width >= (List.max indices + 1) * Sim.tilePixels, "atlas must cover every structure index")
+ // Two-phase water: phase depends only on tick/tile, always in {A, B}, and both
+ // phases appear across a row of tiles in one frame.
+ Assert.AreEqual<int>(2, cc0WaterFrameCount)
+ let phases =
+ [ for tileX in 0 .. 15 -> cc0WaterTileIndexAt 0L tileX 0 ]
+ |> Set.ofList
+ Assert.IsTrue((phases = Set.ofList [ cc0TileIndexWaterA; cc0TileIndexWaterB ]),
+ "one frame must show both water phases across the river")
+ for tileX in 0 .. 15 do
+ for tileY in 0 .. 15 do
+ let index = cc0WaterTileIndexAt 12345L tileX tileY
+ Assert.IsTrue(index = cc0TileIndexWaterA || index = cc0TileIndexWaterB)
+
+ [<TestMethod>]
member _.Cc0CharacterAtlasIsIntegerMultipleOfTheCellGrid () =
let width, height = pngDimensions (assetPath cc0CharacterAtlasRelativePath)
Assert.AreEqual<int>(cc0CharacterAtlasWidth, width)
diff --git a/src/LivingVillage.Desktop/Assets/cc0-tiles.png b/src/LivingVillage.Desktop/Assets/cc0-tiles.png
index 204eba2..afa46a4 100644
--- a/src/LivingVillage.Desktop/Assets/cc0-tiles.png
+++ b/src/LivingVillage.Desktop/Assets/cc0-tiles.png
Binary files differ
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index 9a034aa..80eed07 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -553,7 +553,20 @@ module VillageArt =
let cc0TileAtlasRelativePath = "Assets/cc0-tiles.png"
// Source ground tiles are 16x16, upscaled exactly 2x to one Sim.tilePixels cell.
let cc0TileSourcePixels = 16
- let cc0TileAtlasWidth = Sim.tilePixels * 4
+ // P34 structure slice: indices into cc0-tiles.png. Kenney Tiny Farm ships no
+ // water / roof / wall / railing / door art, so indices 4-11 are self-drawn
+ // (provenance in docs/素材来源.md), laid out by this fixed contract.
+ let cc0TileCount = 12
+ let cc0TileIndexWaterA = 4
+ let cc0TileIndexWaterB = 5
+ let cc0TileIndexRoofRidge = 6
+ let cc0TileIndexRoofEave = 7
+ let cc0TileIndexWallWood = 8
+ let cc0TileIndexWallWindow = 9
+ let cc0TileIndexRailing = 10
+ let cc0TileIndexDoor = 11
+ let cc0WaterFrameCount = 2
+ let cc0TileAtlasWidth = Sim.tilePixels * cc0TileCount
let cc0TileAtlasHeight = Sim.tilePixels
let cc0CharacterAtlasRelativePath = "Assets/cc0-characters.png"
// Source Puny band is 16x24, upscaled exactly 2x to a 32x48 cell; atlas packs
@@ -568,6 +581,16 @@ module VillageArt =
let cc0CharacterAtlasWidth =
cc0CharacterCellWidth * cc0CharacterVariantCount * cc0CharacterDirectionCount * cc0CharacterFrameCount
+ /// Deterministic 2-phase water per tile: `(tick/32 + x + 2y) mod 2`, so one frame
+ /// shows both ripple phases across the river. Mirrors the 3-phase Jiangnan
+ /// `waterFrameTickAt`, but only two self-drawn CC0 frames exist.
+ let cc0WaterTileIndexAt (tick: int64) (tileX: int) (tileY: int) : int =
+ let phase = int ((tick / 32L + int64 (tileX + 2 * tileY)) % 2L)
+ if phase = 0 then cc0TileIndexWaterA else cc0TileIndexWaterB
+
+ let cc0TileRect (index: int) : XnaRectangle =
+ XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels)
+
/// Ground atlas selection: `Some cc0-tiles.png` when the pack is on, else `None`
/// so the renderer keeps its procedural Jiangnan ground as the fallback.
let selectedGroundAtlasRelativePath () : string option =
@@ -792,6 +815,20 @@ module VillageArt =
let draw position sprite =
drawWorldTile spriteBatch textures camera viewportWidth viewportHeight position sprite tint
+ // P34: with the CC0 pack on, individual sample structures draw from the
+ // real/self-drawn cc0-tiles atlas; otherwise every draw falls back to jiangnan.
+ let drawCc0At (position: TilePosition) (index: int) =
+ match textures.Cc0TileAtlas with
+ | Some cc0 ->
+ drawCc0Tile
+ spriteBatch
+ cc0
+ tint
+ (cc0TileRect index)
+ (position.X * Sim.tilePixels - int camera.X)
+ (position.Y * Sim.tilePixels - int camera.Y)
+ | None -> ()
+
// Each water tile picks its phase from its own world coordinates, so a single
// frame shows the three ripple phases across the river instead of one global frame.
let waterSpriteAt (position: TilePosition) =
@@ -800,15 +837,41 @@ module VillageArt =
| 1 -> WaterSpriteB
| _ -> WaterSpriteC
+ // P34 water: the self-drawn CC0 atlas only has two frames, so it picks the
+ // 2-phase index; the jiangnan atlas keeps its 3-phase ripple.
+ let drawWater (position: TilePosition) =
+ match textures.Cc0TileAtlas with
+ | Some _ -> drawCc0At position (cc0WaterTileIndexAt tick position.X position.Y)
+ | None -> draw position (waterSpriteAt position)
+
+ // P34 structures: real/self-drawn roof, wall, window and door cells replace
+ // the jiangnan house tiles only when the CC0 pack is on.
+ let houseSpriteAt (position: TilePosition) (sprite: TileSprite) =
+ let cc0Index =
+ match textures.Cc0TileAtlas with
+ | Some _ ->
+ match sprite with
+ | RoofRidgeSprite -> Some cc0TileIndexRoofRidge
+ | RoofEaveSprite -> Some cc0TileIndexRoofEave
+ | WallWoodSprite -> Some cc0TileIndexWallWood
+ | WallWindowSprite -> Some cc0TileIndexWallWindow
+ | DoorSprite -> Some cc0TileIndexDoor
+ | _ -> None
+ | None -> None
+ match cc0Index with
+ | Some index -> drawCc0At position index
+ | None -> draw position sprite
+
// Procedural terrain for the 512x384 large world: water/pebble/peat codes map
// onto the jiangnan atlas sprites with deterministic ripple continuity.
// 生成器世界:1 水 / 2 石板路 / 3 泥炭 / 0 草地。泥炭按草地渲染,路走石板贴图。
let drawTerrainTile (position: TilePosition) (code: int) =
match code with
- | 1 -> draw position (waterSpriteAt position)
+ | 1 -> drawWater position
| 2 -> draw position StonePathSprite
- // P30 乡村空区:水田(4)走静态水面素材、菜畦(5)走菜地素材;均为成熟 tile。
- | 4 -> draw position WaterSprite
+ // P30 乡村空区:水田(4)走水波动画、菜畦(5)走菜地素材;均为成熟 tile。
+ // P34:水田/水网在 CC0 包下同样走自绘 2 相位水面。
+ | 4 -> drawWater position
| 5 -> draw position GardenSprite
| _ -> draw position GrassSprite
@@ -851,7 +914,7 @@ module VillageArt =
for localPosition, kind in planGrounds do
let position = worldTile plan localPosition
match kind with
- | Water -> draw position (waterSpriteAt position)
+ | Water -> drawWater position
| StonePath ->
match textures.Cc0TileAtlas with
| Some cc0 -> drawCc0Tile spriteBatch cc0 tint cc0PathRect (position.X * Sim.tilePixels - int camera.X) (position.Y * Sim.tilePixels - int camera.Y)
@@ -860,18 +923,22 @@ module VillageArt =
let drawHouse doorTile =
let position = worldTile plan doorTile
for dx in -2 .. 1 do
- draw (offsetTile position dx -2) RoofRidgeSprite
- draw (offsetTile position dx -1) RoofEaveSprite
- draw (offsetTile position -2 0) WallWoodSprite
- draw (offsetTile position -1 0) WallWindowSprite
- draw (offsetTile position 0 0) DoorSprite
- draw (offsetTile position 1 0) WallWoodSprite
+ houseSpriteAt (offsetTile position dx -2) RoofRidgeSprite
+ houseSpriteAt (offsetTile position dx -1) RoofEaveSprite
+ houseSpriteAt (offsetTile position -2 0) WallWoodSprite
+ houseSpriteAt (offsetTile position -1 0) WallWindowSprite
+ houseSpriteAt (offsetTile position 0 0) DoorSprite
+ houseSpriteAt (offsetTile position 1 0) WallWoodSprite
let drawBridge localPosition =
let position = worldTile plan localPosition
- draw position BridgeSprite
- draw (offsetTile position 0 1) BridgeSprite
- draw (offsetTile position 0 2) BridgeSprite
+ let bridgeTile p =
+ match textures.Cc0TileAtlas with
+ | Some _ -> drawCc0At p cc0TileIndexRailing
+ | None -> draw p BridgeSprite
+ bridgeTile position
+ bridgeTile (offsetTile position 0 1)
+ bridgeTile (offsetTile position 0 2)
let drawBamboo localPosition =
let position = worldTile plan localPosition