feat(art): 《山海行纪》资产一期批产(锚 r1)——112 镜头 2.72MB 图集入库

矿彩国风锚 r1 落地:P1 80 镜头(图 1 tileset+三常规兽各 6 帧+词缀精英/光环/双 Boss 22 帧含立绘+UI 13+底图)
+P2 44 镜头(主角 13 帧定妆帧参考图法跨帧身份一致+法器图标 12+弹道命中特效 15+拾取 4);
5 图集 xingji-{map1,beasts,boss,ui,actors}+tiles+bg 共 2,911,369B≈2.72MB(首屏切面≈2.03MB,boss 页 1.03MB=
懒加载分包面,分包决策归开发席 M4);机检 C1–C5 全绿。累计 127 呼 ¥4.19、首过 92%,远离升档 B 判据;
clean-room 五条全程(兽名不入 prompt/伪字物件化改词)。
主会话修机检门两态口径:C2/C3 keyed 中间件只活在批产工作区(ASSET_WORK 不入仓),整目录缺席=仓库态
复跑降级警示跳过、部分缺席仍判错(消除席位环境绿/CI 必红假门,仓库态复跑 rc=0);
顺手修 asset-atlas 局部重打包整写 manifest 丢条目缺陷。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-07 04:27:00 -07:00
parent 05fcc7f94a
commit c1d1e2b400
21 changed files with 4752 additions and 0 deletions

View File

@ -0,0 +1,943 @@
{
"textures": [
{
"image": "xingji-actors.webp",
"format": "RGBA8888",
"size": {
"w": 1104,
"h": 1130
},
"scale": 1,
"frames": [
{
"filename": "hero-attack-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 224,
"h": 221
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 224,
"h": 219
},
"frame": {
"x": 3,
"y": 3,
"w": 224,
"h": 219
}
},
{
"filename": "fx-zhendi-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 192
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 190,
"h": 192
},
"frame": {
"x": 3,
"y": 228,
"w": 190,
"h": 192
}
},
{
"filename": "hero-hurt-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 187,
"h": 192
},
"spriteSourceSize": {
"x": 1,
"y": 0,
"w": 186,
"h": 192
},
"frame": {
"x": 199,
"y": 228,
"w": 186,
"h": 192
}
},
{
"filename": "hero-idle-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 140,
"h": 192
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 140,
"h": 192
},
"frame": {
"x": 233,
"y": 3,
"w": 140,
"h": 192
}
},
{
"filename": "hero-idle-03",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 192,
"h": 180
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 192,
"h": 180
},
"frame": {
"x": 391,
"y": 3,
"w": 192,
"h": 180
}
},
{
"filename": "hero-idle-04",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 167,
"h": 192
},
"spriteSourceSize": {
"x": 1,
"y": 0,
"w": 166,
"h": 192
},
"frame": {
"x": 391,
"y": 189,
"w": 166,
"h": 192
}
},
{
"filename": "hero-run-05",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 154,
"h": 192
},
"spriteSourceSize": {
"x": 1,
"y": 0,
"w": 153,
"h": 192
},
"frame": {
"x": 3,
"y": 426,
"w": 153,
"h": 192
}
},
{
"filename": "hero-run-06",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 187
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 192,
"h": 186
},
"frame": {
"x": 162,
"y": 426,
"w": 192,
"h": 186
}
},
{
"filename": "hero-hurt-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 190,
"h": 192
},
"spriteSourceSize": {
"x": 1,
"y": 0,
"w": 188,
"h": 191
},
"frame": {
"x": 360,
"y": 426,
"w": 188,
"h": 191
}
},
{
"filename": "hero-run-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 171
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 191,
"h": 170
},
"frame": {
"x": 589,
"y": 3,
"w": 191,
"h": 170
}
},
{
"filename": "hero-run-04",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 191
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 191,
"h": 190
},
"frame": {
"x": 589,
"y": 179,
"w": 191,
"h": 190
}
},
{
"filename": "hero-idle-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 190
},
"spriteSourceSize": {
"x": 1,
"y": 0,
"w": 190,
"h": 190
},
"frame": {
"x": 554,
"y": 387,
"w": 190,
"h": 190
}
},
{
"filename": "hero-run-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 175
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 190,
"h": 173
},
"frame": {
"x": 3,
"y": 624,
"w": 190,
"h": 173
}
},
{
"filename": "hero-run-03",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 171
},
"spriteSourceSize": {
"x": 2,
"y": 0,
"w": 190,
"h": 171
},
"frame": {
"x": 199,
"y": 623,
"w": 190,
"h": 171
}
},
{
"filename": "fx-tianlei-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 117,
"h": 176
},
"spriteSourceSize": {
"x": 3,
"y": 0,
"w": 111,
"h": 174
},
"frame": {
"x": 395,
"y": 623,
"w": 111,
"h": 174
}
},
{
"filename": "fx-hit-fire-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 152,
"h": 160
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 152,
"h": 160
},
"frame": {
"x": 512,
"y": 623,
"w": 152,
"h": 160
}
},
{
"filename": "fx-hit-ice-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 160,
"h": 160
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 160,
"h": 160
},
"frame": {
"x": 3,
"y": 803,
"w": 160,
"h": 160
}
},
{
"filename": "fx-hit-thunder-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 160,
"h": 160
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 160,
"h": 160
},
"frame": {
"x": 169,
"y": 803,
"w": 160,
"h": 160
}
},
{
"filename": "fx-icon-fuling-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 36,
"h": 160
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 36,
"h": 160
},
"frame": {
"x": 335,
"y": 803,
"w": 36,
"h": 160
}
},
{
"filename": "fx-icon-hanyuan-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 154,
"h": 160
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 154,
"h": 160
},
"frame": {
"x": 377,
"y": 803,
"w": 154,
"h": 160
}
},
{
"filename": "fx-icon-jinge-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 34,
"h": 160
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 33,
"h": 160
},
"frame": {
"x": 537,
"y": 789,
"w": 33,
"h": 160
}
},
{
"filename": "fx-icon-shanzi-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 160,
"h": 160
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 160,
"h": 160
},
"frame": {
"x": 576,
"y": 789,
"w": 160,
"h": 160
}
},
{
"filename": "fx-icon-zhendi-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 160,
"h": 145
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 160,
"h": 144
},
"frame": {
"x": 786,
"y": 3,
"w": 160,
"h": 144
}
},
{
"filename": "fx-icon-poyue-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 155,
"h": 160
},
"spriteSourceSize": {
"x": 1,
"y": 0,
"w": 153,
"h": 159
},
"frame": {
"x": 670,
"y": 583,
"w": 153,
"h": 159
}
},
{
"filename": "fx-icon-tianlei-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 53,
"h": 160
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 52,
"h": 159
},
"frame": {
"x": 750,
"y": 375,
"w": 52,
"h": 159
}
},
{
"filename": "fx-icon-xinghuo-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 140,
"h": 160
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 138,
"h": 159
},
"frame": {
"x": 829,
"y": 153,
"w": 138,
"h": 159
}
},
{
"filename": "fx-fuling-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 134,
"h": 160
},
"spriteSourceSize": {
"x": 1,
"y": 2,
"w": 133,
"h": 158
},
"frame": {
"x": 829,
"y": 318,
"w": 133,
"h": 158
}
},
{
"filename": "fx-icon-fengren-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 153,
"h": 160
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 152,
"h": 158
},
"frame": {
"x": 742,
"y": 748,
"w": 152,
"h": 158
}
},
{
"filename": "fx-icon-fentian-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 160,
"h": 159
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 156,
"h": 158
},
"frame": {
"x": 3,
"y": 969,
"w": 156,
"h": 158
}
},
{
"filename": "fx-icon-leifu-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 159,
"h": 160
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 158,
"h": 158
},
"frame": {
"x": 165,
"y": 969,
"w": 158,
"h": 158
}
},
{
"filename": "fx-icon-xuanming-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 160,
"h": 157
},
"spriteSourceSize": {
"x": 5,
"y": 0,
"w": 154,
"h": 157
},
"frame": {
"x": 329,
"y": 969,
"w": 154,
"h": 157
}
},
{
"filename": "fx-fengren-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 115,
"h": 128
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 115,
"h": 128
},
"frame": {
"x": 489,
"y": 969,
"w": 115,
"h": 128
}
},
{
"filename": "fx-hanyuan-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 128,
"h": 120
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 128,
"h": 120
},
"frame": {
"x": 829,
"y": 482,
"w": 128,
"h": 120
}
},
{
"filename": "fx-jinge-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 128,
"h": 128
},
"spriteSourceSize": {
"x": 1,
"y": 0,
"w": 127,
"h": 128
},
"frame": {
"x": 829,
"y": 608,
"w": 127,
"h": 128
}
},
{
"filename": "fx-poyue-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 128,
"h": 56
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 128,
"h": 54
},
"frame": {
"x": 742,
"y": 912,
"w": 128,
"h": 54
}
},
{
"filename": "fx-shanzi-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 128,
"h": 128
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 128,
"h": 128
},
"frame": {
"x": 610,
"y": 972,
"w": 128,
"h": 128
}
},
{
"filename": "fx-xuanming-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 128,
"h": 128
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 128,
"h": 128
},
"frame": {
"x": 744,
"y": 972,
"w": 128,
"h": 128
}
},
{
"filename": "pickup-gem-02",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 128,
"h": 128
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 128,
"h": 128
},
"frame": {
"x": 973,
"y": 3,
"w": 128,
"h": 128
}
},
{
"filename": "fx-fentian-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 128,
"h": 121
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 127,
"h": 120
},
"frame": {
"x": 973,
"y": 137,
"w": 127,
"h": 120
}
},
{
"filename": "fx-leifu-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 128,
"h": 85
},
"spriteSourceSize": {
"x": 2,
"y": 2,
"w": 126,
"h": 81
},
"frame": {
"x": 973,
"y": 263,
"w": 126,
"h": 81
}
},
{
"filename": "pickup-orb-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 100,
"h": 112
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 99,
"h": 112
},
"frame": {
"x": 968,
"y": 350,
"w": 99,
"h": 112
}
},
{
"filename": "pickup-coin-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 84,
"h": 96
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 83,
"h": 96
},
"frame": {
"x": 968,
"y": 468,
"w": 83,
"h": 96
}
},
{
"filename": "pickup-gem-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 95,
"h": 96
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 95,
"h": 96
},
"frame": {
"x": 963,
"y": 570,
"w": 95,
"h": 96
}
},
{
"filename": "fx-xinghuo-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 96,
"h": 94
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 95,
"h": 93
},
"frame": {
"x": 962,
"y": 672,
"w": 95,
"h": 93
}
}
]
}
],
"meta": {
"app": "http://github.com/odrick/free-tex-packer-cli",
"version": "0.3.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

View File

@ -0,0 +1,628 @@
{
"textures": [
{
"image": "xingji-beasts.webp",
"format": "RGBA8888",
"size": {
"w": 1051,
"h": 916
},
"scale": 1,
"frames": [
{
"filename": "elite-taowu-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 224,
"h": 155
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 224,
"h": 155
},
"frame": {
"x": 3,
"y": 3,
"w": 224,
"h": 155
}
},
{
"filename": "elite-zheng-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 224,
"h": 185
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 224,
"h": 185
},
"frame": {
"x": 3,
"y": 164,
"w": 224,
"h": 185
}
},
{
"filename": "elite-qiongqi-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 224,
"h": 151
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 223,
"h": 150
},
"frame": {
"x": 233,
"y": 3,
"w": 223,
"h": 150
}
},
{
"filename": "qiongqi-attack-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 168
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 192,
"h": 167
},
"frame": {
"x": 233,
"y": 159,
"w": 192,
"h": 167
}
},
{
"filename": "qiongqi-move-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 192,
"h": 178
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 192,
"h": 178
},
"frame": {
"x": 3,
"y": 355,
"w": 192,
"h": 178
}
},
{
"filename": "qiongqi-move-02",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 191,
"h": 192
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 191,
"h": 192
},
"frame": {
"x": 233,
"y": 332,
"w": 191,
"h": 192
}
},
{
"filename": "taowu-attack-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 192,
"h": 192
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 192,
"h": 192
},
"frame": {
"x": 462,
"y": 3,
"w": 192,
"h": 192
}
},
{
"filename": "taowu-death-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 192,
"h": 165
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 192,
"h": 165
},
"frame": {
"x": 431,
"y": 201,
"w": 192,
"h": 165
}
},
{
"filename": "taowu-hurt-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 115
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 192,
"h": 113
},
"frame": {
"x": 430,
"y": 372,
"w": 192,
"h": 113
}
},
{
"filename": "taowu-idle-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 178
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 192,
"h": 177
},
"frame": {
"x": 3,
"y": 539,
"w": 192,
"h": 177
}
},
{
"filename": "taowu-move-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 186,
"h": 192
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 186,
"h": 192
},
"frame": {
"x": 430,
"y": 491,
"w": 186,
"h": 192
}
},
{
"filename": "taowu-move-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 156
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 192,
"h": 155
},
"frame": {
"x": 201,
"y": 530,
"w": 192,
"h": 155
}
},
{
"filename": "zheng-death-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 130
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 192,
"h": 128
},
"frame": {
"x": 660,
"y": 3,
"w": 192,
"h": 128
}
},
{
"filename": "elite-aura-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 182,
"h": 192
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 182,
"h": 191
},
"frame": {
"x": 660,
"y": 137,
"w": 182,
"h": 191
}
},
{
"filename": "elite-aura-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 188
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 191,
"h": 186
},
"frame": {
"x": 629,
"y": 334,
"w": 191,
"h": 186
}
},
{
"filename": "qiongqi-hurt-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 181,
"h": 192
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 181,
"h": 191
},
"frame": {
"x": 3,
"y": 722,
"w": 181,
"h": 191
}
},
{
"filename": "qiongqi-idle-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 182,
"h": 192
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 180,
"h": 191
},
"frame": {
"x": 190,
"y": 722,
"w": 180,
"h": 191
}
},
{
"filename": "zheng-hurt-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 164
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 191,
"h": 163
},
"frame": {
"x": 622,
"y": 526,
"w": 191,
"h": 163
}
},
{
"filename": "zheng-idle-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 176,
"h": 192
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 176,
"h": 191
},
"frame": {
"x": 376,
"y": 695,
"w": 176,
"h": 191
}
},
{
"filename": "zheng-move-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 183,
"h": 192
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 183,
"h": 191
},
"frame": {
"x": 558,
"y": 695,
"w": 183,
"h": 191
}
},
{
"filename": "qiongqi-death-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 125
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 190,
"h": 123
},
"frame": {
"x": 858,
"y": 3,
"w": 190,
"h": 123
}
},
{
"filename": "zheng-attack-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 147
},
"spriteSourceSize": {
"x": 1,
"y": 0,
"w": 190,
"h": 146
},
"frame": {
"x": 858,
"y": 132,
"w": 190,
"h": 146
}
},
{
"filename": "zheng-move-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 192,
"h": 163
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 187,
"h": 162
},
"frame": {
"x": 848,
"y": 284,
"w": 187,
"h": 162
}
},
{
"filename": "affix-flame-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 91,
"h": 96
},
"spriteSourceSize": {
"x": 1,
"y": 0,
"w": 89,
"h": 96
},
"frame": {
"x": 747,
"y": 695,
"w": 89,
"h": 96
}
},
{
"filename": "affix-frost-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 89,
"h": 96
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 89,
"h": 96
},
"frame": {
"x": 747,
"y": 797,
"w": 89,
"h": 96
}
},
{
"filename": "affix-swift-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 95,
"h": 96
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 95,
"h": 96
},
"frame": {
"x": 819,
"y": 526,
"w": 95,
"h": 96
}
},
{
"filename": "affix-thunder-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 96,
"h": 96
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 96,
"h": 96
},
"frame": {
"x": 920,
"y": 452,
"w": 96,
"h": 96
}
},
{
"filename": "affix-venom-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 95,
"h": 96
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 95,
"h": 96
},
"frame": {
"x": 920,
"y": 554,
"w": 95,
"h": 96
}
},
{
"filename": "affix-armor-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 96,
"h": 94
},
"spriteSourceSize": {
"x": 6,
"y": 3,
"w": 90,
"h": 91
},
"frame": {
"x": 842,
"y": 656,
"w": 90,
"h": 91
}
}
]
}
],
"meta": {
"app": "http://github.com/odrick/free-tex-packer-cli",
"version": "0.3.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

View File

@ -0,0 +1,481 @@
{
"textures": [
{
"image": "xingji-boss.webp",
"format": "RGBA8888",
"size": {
"w": 1692,
"h": 1557
},
"scale": 1,
"frames": [
{
"filename": "luwu-portrait-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 384,
"h": 375
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 384,
"h": 373
},
"frame": {
"x": 3,
"y": 3,
"w": 384,
"h": 373
}
},
{
"filename": "paoxiao-portrait-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 384,
"h": 340
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 382,
"h": 338
},
"frame": {
"x": 3,
"y": 382,
"w": 382,
"h": 338
}
},
{
"filename": "luwu-attack-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 320,
"h": 320
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 320,
"h": 320
},
"frame": {
"x": 393,
"y": 3,
"w": 320,
"h": 320
}
},
{
"filename": "luwu-attack-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 320,
"h": 320
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 320,
"h": 319
},
"frame": {
"x": 393,
"y": 329,
"w": 320,
"h": 319
}
},
{
"filename": "luwu-attack-03",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 320,
"h": 292
},
"spriteSourceSize": {
"x": 0,
"y": 2,
"w": 320,
"h": 290
},
"frame": {
"x": 719,
"y": 3,
"w": 320,
"h": 290
}
},
{
"filename": "luwu-hurt-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 320,
"h": 320
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 320,
"h": 320
},
"frame": {
"x": 719,
"y": 299,
"w": 320,
"h": 320
}
},
{
"filename": "luwu-move-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 320,
"h": 301
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 320,
"h": 300
},
"frame": {
"x": 719,
"y": 625,
"w": 320,
"h": 300
}
},
{
"filename": "luwu-move-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 320,
"h": 311
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 320,
"h": 309
},
"frame": {
"x": 391,
"y": 654,
"w": 320,
"h": 309
}
},
{
"filename": "luwu-roar-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 313,
"h": 320
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 313,
"h": 320
},
"frame": {
"x": 717,
"y": 931,
"w": 313,
"h": 320
}
},
{
"filename": "paoxiao-attack-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 320,
"h": 316
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 320,
"h": 316
},
"frame": {
"x": 3,
"y": 726,
"w": 320,
"h": 316
}
},
{
"filename": "paoxiao-attack-03",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 320,
"h": 308
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 320,
"h": 308
},
"frame": {
"x": 329,
"y": 969,
"w": 320,
"h": 308
}
},
{
"filename": "paoxiao-death-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 320,
"h": 227
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 320,
"h": 224
},
"frame": {
"x": 3,
"y": 1048,
"w": 320,
"h": 224
}
},
{
"filename": "paoxiao-hurt-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 320,
"h": 267
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 320,
"h": 267
},
"frame": {
"x": 1045,
"y": 3,
"w": 320,
"h": 267
}
},
{
"filename": "paoxiao-move-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 320,
"h": 316
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 320,
"h": 316
},
"frame": {
"x": 1045,
"y": 276,
"w": 320,
"h": 316
}
},
{
"filename": "paoxiao-move-02",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 320,
"h": 312
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 320,
"h": 312
},
"frame": {
"x": 1045,
"y": 598,
"w": 320,
"h": 312
}
},
{
"filename": "paoxiao-roar-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 320,
"h": 301
},
"spriteSourceSize": {
"x": 0,
"y": 2,
"w": 320,
"h": 299
},
"frame": {
"x": 1045,
"y": 916,
"w": 320,
"h": 299
}
},
{
"filename": "paoxiao-death-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 320,
"h": 278
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 319,
"h": 276
},
"frame": {
"x": 3,
"y": 1278,
"w": 319,
"h": 276
}
},
{
"filename": "luwu-death-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 320,
"h": 307
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 318,
"h": 307
},
"frame": {
"x": 1036,
"y": 1221,
"w": 318,
"h": 307
}
},
{
"filename": "luwu-death-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 320,
"h": 273
},
"spriteSourceSize": {
"x": 2,
"y": 0,
"w": 318,
"h": 271
},
"frame": {
"x": 655,
"y": 1257,
"w": 318,
"h": 271
}
},
{
"filename": "luwu-idle-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 320,
"h": 254
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 318,
"h": 254
},
"frame": {
"x": 328,
"y": 1283,
"w": 318,
"h": 254
}
},
{
"filename": "paoxiao-attack-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 320,
"h": 300
},
"spriteSourceSize": {
"x": 2,
"y": 0,
"w": 318,
"h": 300
},
"frame": {
"x": 1371,
"y": 3,
"w": 318,
"h": 300
}
},
{
"filename": "paoxiao-idle-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 320,
"h": 289
},
"spriteSourceSize": {
"x": 2,
"y": 2,
"w": 318,
"h": 286
},
"frame": {
"x": 1371,
"y": 309,
"w": 318,
"h": 286
}
}
]
}
],
"meta": {
"app": "http://github.com/odrick/free-tex-packer-cli",
"version": "0.3.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -0,0 +1,271 @@
{
"textures": [
{
"image": "xingji-map1.webp",
"format": "RGBA8888",
"size": {
"w": 719,
"h": 738
},
"scale": 1,
"frames": [
{
"filename": "event-rift-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 208,
"h": 204
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 208,
"h": 202
},
"frame": {
"x": 3,
"y": 3,
"w": 208,
"h": 202
}
},
{
"filename": "deco-bone-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 176,
"h": 167
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 176,
"h": 165
},
"frame": {
"x": 3,
"y": 211,
"w": 176,
"h": 165
}
},
{
"filename": "deco-rock-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 174,
"h": 176
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 174,
"h": 176
},
"frame": {
"x": 217,
"y": 3,
"w": 174,
"h": 176
}
},
{
"filename": "deco-totem-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 129,
"h": 176
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 128,
"h": 176
},
"frame": {
"x": 217,
"y": 185,
"w": 128,
"h": 176
}
},
{
"filename": "deco-totem-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 171,
"h": 176
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 170,
"h": 176
},
"frame": {
"x": 3,
"y": 382,
"w": 170,
"h": 176
}
},
{
"filename": "event-chest-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 157,
"h": 176
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 157,
"h": 176
},
"frame": {
"x": 179,
"y": 382,
"w": 157,
"h": 176
}
},
{
"filename": "deco-reed-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 166,
"h": 176
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 165,
"h": 175
},
"frame": {
"x": 397,
"y": 3,
"w": 165,
"h": 175
}
},
{
"filename": "deco-rock-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 174,
"h": 176
},
"spriteSourceSize": {
"x": 1,
"y": 0,
"w": 172,
"h": 175
},
"frame": {
"x": 342,
"y": 367,
"w": 172,
"h": 175
}
},
{
"filename": "deco-runestone-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 176,
"h": 169
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 175,
"h": 167
},
"frame": {
"x": 351,
"y": 185,
"w": 175,
"h": 167
}
},
{
"filename": "event-spring-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 176,
"h": 171
},
"spriteSourceSize": {
"x": 1,
"y": 0,
"w": 175,
"h": 171
},
"frame": {
"x": 3,
"y": 564,
"w": 175,
"h": 171
}
},
{
"filename": "deco-moss-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 171,
"h": 176
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 169,
"h": 174
},
"frame": {
"x": 342,
"y": 548,
"w": 169,
"h": 174
}
},
{
"filename": "event-shrine-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 148,
"h": 176
},
"spriteSourceSize": {
"x": 0,
"y": 1,
"w": 148,
"h": 174
},
"frame": {
"x": 568,
"y": 3,
"w": 148,
"h": 174
}
}
]
}
],
"meta": {
"app": "http://github.com/odrick/free-tex-packer-cli",
"version": "0.3.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@ -0,0 +1,271 @@
{
"textures": [
{
"image": "xingji-ui.webp",
"format": "RGBA8888",
"size": {
"w": 911,
"h": 846
},
"scale": 1,
"frames": [
{
"filename": "ui-bar-boss-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 512,
"h": 164
},
"spriteSourceSize": {
"x": 1,
"y": 2,
"w": 510,
"h": 161
},
"frame": {
"x": 3,
"y": 3,
"w": 510,
"h": 161
}
},
{
"filename": "ui-bar-frame-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 512,
"h": 105
},
"spriteSourceSize": {
"x": 2,
"y": 2,
"w": 508,
"h": 101
},
"frame": {
"x": 3,
"y": 170,
"w": 508,
"h": 101
}
},
{
"filename": "ui-card-frame-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 277,
"h": 384
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 277,
"h": 384
},
"frame": {
"x": 3,
"y": 277,
"w": 277,
"h": 384
}
},
{
"filename": "ui-card-frame-02",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 277,
"h": 384
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 277,
"h": 384
},
"frame": {
"x": 286,
"y": 277,
"w": 277,
"h": 384
}
},
{
"filename": "ui-btn-02",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 384,
"h": 177
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 383,
"h": 176
},
"frame": {
"x": 3,
"y": 667,
"w": 383,
"h": 176
}
},
{
"filename": "ui-btn-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 384,
"h": 173
},
"spriteSourceSize": {
"x": 1,
"y": 1,
"w": 382,
"h": 171
},
"frame": {
"x": 392,
"y": 667,
"w": 382,
"h": 171
}
},
{
"filename": "ui-joystick-base-01",
"rotated": false,
"trimmed": true,
"sourceSize": {
"w": 184,
"h": 192
},
"spriteSourceSize": {
"x": 2,
"y": 0,
"w": 180,
"h": 192
},
"frame": {
"x": 569,
"y": 3,
"w": 180,
"h": 192
}
},
{
"filename": "ui-badge-tide-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 128,
"h": 128
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 128,
"h": 128
},
"frame": {
"x": 569,
"y": 201,
"w": 128,
"h": 128
}
},
{
"filename": "ui-badge-wave-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 128,
"h": 128
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 128,
"h": 128
},
"frame": {
"x": 569,
"y": 335,
"w": 128,
"h": 128
}
},
{
"filename": "ui-orb-hp-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 128,
"h": 128
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 128,
"h": 128
},
"frame": {
"x": 569,
"y": 469,
"w": 128,
"h": 128
}
},
{
"filename": "ui-slot-item-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 128,
"h": 128
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 128,
"h": 128
},
"frame": {
"x": 780,
"y": 3,
"w": 128,
"h": 128
}
},
{
"filename": "ui-joystick-knob-01",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 96,
"h": 96
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 96,
"h": 96
},
"frame": {
"x": 780,
"y": 137,
"w": 96,
"h": 96
}
}
]
}
],
"meta": {
"app": "http://github.com/odrick/free-tex-packer-cli",
"version": "0.3.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

@ -0,0 +1,77 @@
{
"batch": "xingji-a1",
"note": "《山海行纪》资产一期 manifest:图集/底图/tile 表与字节账。命名 role-state-frame;帧坐标在各图集同名 json(Phaser3 版式,LittleJS 按 frames 换算)。",
"anchor": "../../../docs-design/山海风格版-批次一/anchor-pack.json",
"styleDiscipline": "styleWords 单源=锚包 r1;生图台账(逐呼 prompt/seed/成败)=同目录 manifest.jsonl,batch=xingji-a1",
"generatedAt": "2026-07-07T07:31:00.125Z",
"atlases": {
"xingji-map1": [
{
"page": "xingji-map1.webp",
"json": "xingji-map1.json",
"frames": 12,
"webpBytes": 142472
}
],
"xingji-beasts": [
{
"page": "xingji-beasts.webp",
"json": "xingji-beasts.json",
"frames": 29,
"webpBytes": 443840
}
],
"xingji-boss": [
{
"page": "xingji-boss.webp",
"json": "xingji-boss.json",
"frames": 22,
"webpBytes": 1057434
}
],
"xingji-ui": [
{
"page": "xingji-ui.webp",
"json": "xingji-ui.json",
"frames": 12,
"webpBytes": 202748
}
],
"xingji-actors": [
{
"page": "xingji-actors.webp",
"json": "xingji-actors.json",
"frames": 44,
"webpBytes": 470310
}
]
},
"tiles": {
"file": "tiles/tiles-map1.webp",
"tileSize": 128,
"cols": 8,
"rows": 6,
"order": "a=0-15 基底 / b=16-31 暗变奏 / c=32-47 刻痕变奏(索引=行×8+列)",
"bytes": 240842
},
"bg": [
{
"id": "ui-panel-sortie-01",
"file": "bg/ui-panel-sortie-01.webp",
"bytes": 135152,
"adopted": false
},
{
"id": "bg-map1-vista-01",
"file": "bg/bg-map1-vista-01.webp",
"bytes": 164100,
"adopted": true
}
],
"bytes": {
"atlasWebp": 2316804,
"tilesWebp": 240842,
"bgWebp": 299252,
"total": 2856898
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

View File

@ -0,0 +1,149 @@
#!/usr/bin/env node
// 《山海行纪》资产批产 · 图集打包 + 底图转码 + 资产 manifest(迁移自批次一 atlas-pack.mjs)
// 职责:
// ① keyed/{group}/*.png 按消费分组打 2048² 图集(free-tex-packer,MaxRects,trim),
// png 中转 → webp q80 落 assets/atlas/xingji-{group}*.webp + 同名坐标 json(image 指 webp);
// ② bg 组镜头(整幅底图)不打包,直接转码 webp q80 落 assets/bg/{id}.webp;
// ③ 汇总写 assets/manifest.json:图集分页/帧数/字节 + tiles 几何 + bg 清单 + 锚包指针
// ——图集字节数即两本账(契约包内账/首屏传输账)的实测输入。
// 用法: node scripts/asset-atlas.mjs [--group beasts] (环境变量 ASSET_WORK 同 asset-gen.mjs)
import { readFileSync, writeFileSync, readdirSync, mkdirSync, statSync, existsSync, rmSync, copyFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const GAME = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const BATCH1REL = '../../../docs-design/山海风格版-批次一'; // 锚包单源(相对本游戏根,仅指针不复制)
const WORK = process.env.ASSET_WORK || join(tmpdir(), 'xingji-a1-work');
const KEYED = join(WORK, 'keyed');
const ATLAS_DIR = join(GAME, 'assets', 'atlas');
const BG_DIR = join(GAME, 'assets', 'bg');
const onlyGroup = process.argv.includes('--group') ? process.argv[process.argv.indexOf('--group') + 1] : null;
const { shots, batch } = JSON.parse(readFileSync(join(GAME, 'scripts', 'asset-shots.json'), 'utf8'));
// boss 独立成组:双 Boss 10/20 分钟才登场,是天然的懒加载分包面(首屏账不背它)
const GROUPS = ['map1', 'beasts', 'boss', 'ui', 'actors'];
mkdirSync(ATLAS_DIR, { recursive: true });
mkdirSync(BG_DIR, { recursive: true });
// PIL 转码 webp q80(与批次一同法:free-tex-packer 不直出 webp)
function toWebp(src, dest, quality = 80) {
execFileSync('python3', ['-c',
`from PIL import Image\nImage.open(${JSON.stringify(src)}).save(${JSON.stringify(dest)}, quality=${quality}, method=6)`,
], { stdio: 'inherit', timeout: 60_000 });
}
const manifest = {
batch,
note: '《山海行纪》资产一期 manifest:图集/底图/tile 表与字节账。命名 role-state-frame;帧坐标在各图集同名 json(Phaser3 版式,LittleJS 按 frames 换算)。',
anchor: `${BATCH1REL}/anchor-pack.json`,
styleDiscipline: 'styleWords 单源=锚包 r1;生图台账(逐呼 prompt/seed/成败)=同目录 manifest.jsonl,batch=xingji-a1',
generatedAt: new Date().toISOString(),
atlases: {}, tiles: null, bg: [], bytes: { atlasWebp: 0, tilesWebp: 0, bgWebp: 0, total: 0 },
};
// 断点续跑语义:局部重打包(--group / 工作区缺该组 keyed)时,未重打的组与缺源 bg 从既有
// manifest 继承盘上产物,避免局部跑整写 manifest 时丢掉其余图集条目(工作区是临时目录,
// 断点恢复后常常只需重打单组;继承字节以实盘 statSync 为准,防账实漂移)。
const mfPath = join(GAME, 'assets', 'manifest.json');
const prevManifest = existsSync(mfPath) ? JSON.parse(readFileSync(mfPath, 'utf8')) : null;
function carryGroup(group, why) {
const name = `xingji-${group}`;
const prev = prevManifest?.atlases?.[name];
if (!prev) { console.log(`[${group}] ${why},无既有 manifest 条目可继承,跳过`); return; }
const pages = prev.filter(p => existsSync(join(ATLAS_DIR, p.page)) && existsSync(join(ATLAS_DIR, p.json)));
if (pages.length < prev.length) console.warn(`[${group}] 继承时 ${prev.length - pages.length} 页盘上缺失,已丢弃(机检 C4 会兜)`);
if (!pages.length) return;
for (const p of pages) { p.webpBytes = statSync(join(ATLAS_DIR, p.page)).size; manifest.bytes.atlasWebp += p.webpBytes; }
manifest.atlases[name] = pages;
console.log(`[${group}] ${why},继承既有产物(${pages.length} 页,不重编码)`);
}
// ---- ① 分组图集 ----
for (const group of GROUPS) {
if (onlyGroup && group !== onlyGroup) { carryGroup(group, `--group=${onlyGroup} 局部跑`); continue; }
const inDir = join(KEYED, group);
if (!existsSync(inDir)) { carryGroup(group, '无 keyed 输入'); continue; }
const pngs = readdirSync(inDir).filter(f => f.endsWith('.png'));
if (!pngs.length) { carryGroup(group, 'keyed 目录为空'); continue; }
const name = `xingji-${group}`;
const packTmp = join(WORK, 'atlas-tmp', group);
rmSync(packTmp, { recursive: true, force: true });
mkdirSync(packTmp, { recursive: true });
// free-tex-packer 工程文件(schema 核自 free-tex-packer-cli@0.3.0,承批次一)
const project = {
savePath: packTmp,
images: pngs.map(f => ({ name: f, path: join(inDir, f) })),
folders: [],
packOptions: {
textureName: name, width: 2048, height: 2048, fixedSize: false, powerOfTwo: false,
padding: 2, extrude: 1, allowRotation: false, detectIdentical: true,
allowTrim: true, trimMode: 'trim', alphaThreshold: 0,
exporter: 'Phaser3', removeFileExtension: true, prependFolderName: false,
textureFormat: 'png', base64Export: false, scale: 1, packer: 'MaxRectsPacker',
},
};
const proj = join(packTmp, `${name}.ftpp`);
writeFileSync(proj, JSON.stringify(project, null, 2));
execFileSync('free-tex-packer-cli', ['--project', proj, '--output', packTmp], { stdio: 'inherit', timeout: 120_000 });
const pages = readdirSync(packTmp).filter(f => f.startsWith(name) && f.endsWith('.png'));
const entry = [];
for (const png of pages.sort()) {
const base = png.replace(/\.png$/, '');
const webp = join(ATLAS_DIR, `${base}.webp`);
toWebp(join(packTmp, png), webp);
const jsonSrc = join(packTmp, `${base}.json`);
const j = JSON.parse(readFileSync(jsonSrc, 'utf8'));
for (const t of j.textures || []) if (t.image) t.image = t.image.replace(/\.png$/, '.webp');
const jsonDest = join(ATLAS_DIR, `${base}.json`);
writeFileSync(jsonDest, JSON.stringify(j, null, 2));
const frames = (j.textures || []).reduce((s, t) => s + (t.frames?.length || 0), 0);
const bytes = statSync(webp).size;
manifest.bytes.atlasWebp += bytes;
entry.push({ page: `${base}.webp`, json: `${base}.json`, frames, webpBytes: bytes });
console.log(`[${group}] 页 ${base}:${frames} 帧 | webp ${(bytes / 1024).toFixed(0)}KB`);
}
manifest.atlases[name] = entry;
}
// ---- ② bg 底图直转码 ----
const RAW = join(WORK, 'raw');
const BATCH1 = resolve(GAME, BATCH1REL);
for (const s of shots.filter(x => x.group === 'bg')) {
let src = null;
if (s.adopt) src = join(BATCH1, s.adopt);
else for (const ext of ['.jpg', '.jpeg', '.png']) { const p = join(RAW, s.id + ext); if (existsSync(p)) { src = p; break; } }
if (!src || !existsSync(src)) {
// 缺源(gen 原片在已清工作区)时继承盘上既有 bg 产物,不整写丢账
const prev = prevManifest?.bg?.find(b => b.id === s.id);
const kept = prev && existsSync(join(GAME, prev.file));
if (kept) {
prev.bytes = statSync(join(GAME, prev.file)).size;
manifest.bytes.bgWebp += prev.bytes;
manifest.bg.push(prev);
console.log(`[bg] ${s.id} 缺源,继承既有产物(${(prev.bytes / 1024).toFixed(0)}KB)`);
} else console.log(`[bg] ${s.id} 缺源,跳过`);
continue;
}
const dest = join(BG_DIR, `${s.id}.webp`);
toWebp(src, dest, 72); // 整幅底图走 q72:全屏铺底件,细节容忍度高于精灵
const bytes = statSync(dest).size;
manifest.bytes.bgWebp += bytes;
manifest.bg.push({ id: s.id, file: `bg/${s.id}.webp`, bytes, adopted: !!s.adopt });
console.log(`[bg] ${s.id} → webp ${(bytes / 1024).toFixed(0)}KB${s.adopt ? '(收编批次一)' : ''}`);
}
// ---- ③ tiles 几何登记(产物由 asset-tiles.py 生成)----
const tilesWebp = join(GAME, 'assets', 'tiles', 'tiles-map1.webp');
if (existsSync(tilesWebp)) {
const bytes = statSync(tilesWebp).size;
manifest.tiles = { file: 'tiles/tiles-map1.webp', tileSize: 128, cols: 8, rows: 6,
order: 'a=0-15 基底 / b=16-31 暗变奏 / c=32-47 刻痕变奏(索引=行×8+列)', bytes };
manifest.bytes.tilesWebp = bytes;
}
manifest.bytes.total = manifest.bytes.atlasWebp + manifest.bytes.tilesWebp + manifest.bytes.bgWebp;
writeFileSync(join(GAME, 'assets', 'manifest.json'), JSON.stringify(manifest, null, 2));
console.log(`\nassets/manifest.json 已写:图集 ${(manifest.bytes.atlasWebp / 1024).toFixed(0)}KB + tiles ${(manifest.bytes.tilesWebp / 1024).toFixed(0)}KB + bg ${(manifest.bytes.bgWebp / 1024).toFixed(0)}KB = 合计 ${(manifest.bytes.total / 1024).toFixed(0)}KB(webp,两本账实测输入)`);

View File

@ -0,0 +1,109 @@
#!/usr/bin/env node
// 《山海行纪》资产批产 · 机检门(纪律:每批机检 尺寸/透明底/命名,不过门不入图集账)
// 检查面:
// C1 命名:全部镜头 id 符合 role-state-frame(^[a-z0-9]+(-[a-z0-9]+)*-\d{2}$);
// C2 keyed 覆盖:sprite 镜头逐一有 keyed png,尺寸 ≤ pack 上限;
// C3 透明底:keyed png 为 RGBA 且边框 alpha≈0、内容非空(python/PIL 批查,单进程);
// C4 图集对账:每个 sprite 镜头的 id 在其分组图集 json 里恰有一帧;页尺寸 ≤2048²;
// C5 产物在位:tiles 表几何正确(1024×768)、bg webp 在位、assets/manifest.json 字节账与实盘一致。
// 用法: node scripts/asset-check.mjs [--stage keyed|full](默认 full;keyed=生图后图集前的中检)
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const GAME = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const WORK = process.env.ASSET_WORK || join(tmpdir(), 'xingji-a1-work');
const KEYED = join(WORK, 'keyed');
const stage = process.argv.includes('--stage') ? process.argv[process.argv.indexOf('--stage') + 1] : 'full';
const { shots } = JSON.parse(readFileSync(join(GAME, 'scripts', 'asset-shots.json'), 'utf8'));
const errs = [], warns = [];
const sprites = shots.filter(s => s.type === 'sprite');
// C1 命名纪律
const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*-\d{2}$/;
for (const s of shots) if (!NAME_RE.test(s.id)) errs.push(`C1 命名违例:${s.id}`);
// C2 keyed 覆盖 + 尺寸上限(PNG IHDR 直读宽高,无依赖)
function pngInfo(p) {
const b = readFileSync(p);
return { w: b.readUInt32BE(16), h: b.readUInt32BE(20), colorType: b[25] };
}
// keyed 中间件只活在批产工作区(ASSET_WORK,不入仓):整目录缺席=从仓库态复跑(CI/验收),
// C2/C3 是生产期中检、无对象可查,降级为警示跳过;部分缺席才是真不一致,仍判错。
const keyedPaths = {};
const keyedAbsent = !existsSync(KEYED);
if (keyedAbsent) warns.push('C2/C3 跳过:keyed 工作区不在(生产期中检项;仓库态审计走 C1/C4/C5)');
for (const s of keyedAbsent ? [] : sprites) {
const p = join(KEYED, s.group, `${s.id}.png`);
if (!existsSync(p)) { errs.push(`C2 缺 keyed:${s.group}/${s.id}.png`); continue; }
keyedPaths[s.id] = p;
const { w, h, colorType } = pngInfo(p);
if (s.pack && Math.max(w, h) > s.pack) errs.push(`C2 超 pack 上限:${s.id} ${w}x${h} > ${s.pack}`);
if (colorType !== 6) errs.push(`C3 非 RGBA(colorType=${colorType}):${s.id}`);
}
// C3 透明底与内容非空(单个 python 进程批查全部 keyed)
if (Object.keys(keyedPaths).length) {
const py = `
import sys, json
from PIL import Image
import numpy as np
out = {}
for sid, p in json.load(open(sys.argv[1])).items():
a = np.asarray(Image.open(p).convert('RGBA'))[:, :, 3]
border = np.concatenate([a[0, :], a[-1, :], a[:, 0], a[:, -1]]).astype(float)
out[sid] = { 'borderMax': int(border.max()), 'opaquePct': float((a > 16).mean()) }
print(json.dumps(out))`;
const tmpJson = join(WORK, 'check-keyed.json');
writeFileSync(tmpJson, JSON.stringify(keyedPaths));
const res = JSON.parse(execFileSync('python3', ['-c', py, tmpJson], { encoding: 'utf8', timeout: 120_000 }));
for (const [sid, r] of Object.entries(res)) {
if (r.borderMax > 48) warns.push(`C3 边框 alpha 偏高(${r.borderMax}):${sid}(主体可能贴边/裁切)`);
if (r.opaquePct < 0.02) errs.push(`C3 内容近空(不透明占比 ${(r.opaquePct * 100).toFixed(1)}%):${sid}`);
if (r.opaquePct > 0.98) warns.push(`C3 几乎无透明(占比 ${(r.opaquePct * 100).toFixed(1)}%):${sid}(疑似抠图失败整幅保留)`);
}
}
if (stage === 'full') {
// C4 图集对账
const ATLAS_DIR = join(GAME, 'assets', 'atlas');
const frameOwner = {};
if (existsSync(ATLAS_DIR)) {
for (const f of readdirSync(ATLAS_DIR).filter(f => f.endsWith('.json'))) {
const j = JSON.parse(readFileSync(join(ATLAS_DIR, f), 'utf8'));
for (const t of j.textures || []) {
if (t.size && (t.size.w > 2048 || t.size.h > 2048)) errs.push(`C4 页超 2048²:${f} ${t.size.w}x${t.size.h}`);
for (const fr of t.frames || []) frameOwner[fr.filename] = (frameOwner[fr.filename] || 0) + 1;
}
}
}
for (const s of sprites) {
const n = frameOwner[s.id] || 0;
if (n === 0) errs.push(`C4 图集缺帧:${s.id}`);
else if (n > 1) errs.push(`C4 帧重复 ${n} 处:${s.id}`);
}
// C5 产物在位 + 字节账对账
const tiles = join(GAME, 'assets', 'tiles', 'tiles-map1.webp');
if (!existsSync(tiles)) errs.push('C5 缺 tiles-map1.webp');
for (const s of shots.filter(x => x.group === 'bg')) {
if (!existsSync(join(GAME, 'assets', 'bg', `${s.id}.webp`))) errs.push(`C5 缺 bg:${s.id}.webp`);
}
const mfPath = join(GAME, 'assets', 'manifest.json');
if (!existsSync(mfPath)) errs.push('C5 缺 assets/manifest.json');
else {
const mf = JSON.parse(readFileSync(mfPath, 'utf8'));
let actual = 0;
for (const pages of Object.values(mf.atlases || {})) for (const p of pages) actual += statSync(join(ATLAS_DIR, p.page)).size;
if (mf.tiles && existsSync(tiles)) actual += statSync(tiles).size;
for (const b of mf.bg || []) actual += statSync(join(GAME, 'assets', b.file)).size;
if (actual !== mf.bytes.total) errs.push(`C5 字节账不符:manifest ${mf.bytes.total} vs 实盘 ${actual}`);
}
}
for (const w of warns) console.log(`${w}`);
for (const e of errs) console.error(`${e}`);
console.log(`\n机检(${stage}):${errs.length === 0 ? '全绿' : errs.length + ' 项不过'} / 警示 ${warns.length} 项 / sprite ${sprites.length} 镜头`);
process.exit(errs.length ? 1 : 0);

View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
# 《山海行纪》资产批产 · 拼板图(迁移自批次一 contact-sheet.py;眼验入口)
# 用法: python3 scripts/asset-contact-sheet.py <输出.png> [--prio P1] [--group beasts] [--cols 8] [--title 标题] [--raw]
# python3 scripts/asset-contact-sheet.py --files <输出.png> <标题> <标签=路径>...
# 默认取 keyed 后精灵(所见即入图集帧);--raw 取生图原片;bleed 镜头(tiles/bg)始终取原片。
# 中文标题字体:环境变量 FONT_TTF 或批次一同款霞鹜文楷(OFL)。
import sys, os, json, tempfile
from PIL import Image, ImageDraw, ImageFont
GAME = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WORK = os.environ.get('ASSET_WORK') or os.path.join(tempfile.gettempdir(), 'xingji-a1-work')
BATCH1 = os.path.normpath(os.path.join(GAME, '..', '..', '..', 'docs-design', '山海风格版-批次一'))
FONT_TTF = os.environ.get('FONT_TTF') or '/private/tmp/claude-501/-Users-lili-Project-games-development-ai/69248bd4-16ac-4906-aea1-4157edf7b10f/scratchpad/deps/LXGWWenKai-Regular.ttf'
def load_font(size):
try: return ImageFont.truetype(FONT_TTF, size)
except Exception: return ImageFont.load_default()
def make_sheet(items, out_path, cols, title, tile_w=300, tile_h=330):
label_h, title_h, gap = 26, 52, 8
rows = (len(items) + cols - 1) // cols
W = cols * (tile_w + gap) + gap
H = title_h + rows * (tile_h + label_h + gap) + gap
sheet = Image.new('RGB', (W, H), (30, 34, 32))
d = ImageDraw.Draw(sheet)
d.text((gap + 4, 10), title, fill=(232, 197, 106), font=load_font(28))
f_label = load_font(17)
for i, (label, path) in enumerate(items):
r, c = divmod(i, cols)
x = gap + c * (tile_w + gap)
y = title_h + r * (tile_h + label_h + gap)
try:
im = Image.open(path)
if im.mode == 'RGBA': # 透明底铺浅格,眼验抠图质量
bgc = Image.new('RGB', im.size, (58, 62, 58))
bgc.paste(im, (0, 0), im)
im = bgc
else:
im = im.convert('RGB')
except Exception as e:
d.text((x, y), f'{label}: 打开失败 {e}', fill=(200, 80, 80), font=f_label); continue
im.thumbnail((tile_w, tile_h))
sheet.paste(im, (x + (tile_w - im.width) // 2, y + (tile_h - im.height) // 2))
d.rectangle([x, y + tile_h, x + tile_w, y + tile_h + label_h], fill=(20, 23, 21))
d.text((x + 5, y + tile_h + 3), label, fill=(220, 220, 210), font=f_label)
sheet.save(out_path)
print(f'拼板完成:{out_path} ({W}x{H}, {len(items)} 格, {os.path.getsize(out_path)//1024}KB)')
def locate(shot, prefer_raw):
"""镜头 → 现存图:keyed 优先(sprite),bleed/raw 回原片,adopt 回批次一源。"""
cands = []
if shot['type'] == 'sprite' and not prefer_raw:
cands.append(os.path.join(WORK, 'keyed', shot['group'], shot['id'] + '.png'))
for ext in ('.jpg', '.jpeg', '.png', '.webp'):
cands.append(os.path.join(WORK, 'raw', shot['id'] + ext))
if shot.get('adopt'):
cands.append(os.path.join(BATCH1, shot['adopt']))
for p in cands:
if os.path.exists(p): return p
return None
if __name__ == '__main__':
if len(sys.argv) > 1 and sys.argv[1] == '--files':
out, title = sys.argv[2], sys.argv[3]
items = [(kv.split('=', 1)[0], kv.split('=', 1)[1]) for kv in sys.argv[4:]]
make_sheet(items, out, cols=min(6, max(1, len(items))), title=title)
sys.exit(0)
out = sys.argv[1]
argv = sys.argv[2:]
prio = group = None; cols = 8; title = '山海行纪 · 资产一期'; raw = False
i = 0
while i < len(argv):
if argv[i] == '--prio': prio = argv[i+1]; i += 2
elif argv[i] == '--group': group = argv[i+1]; i += 2
elif argv[i] == '--cols': cols = int(argv[i+1]); i += 2
elif argv[i] == '--title': title = argv[i+1]; i += 2
elif argv[i] == '--raw': raw = True; i += 1
else: sys.exit(f'未知参数 {argv[i]}')
shots = json.load(open(os.path.join(GAME, 'scripts', 'asset-shots.json'), encoding='utf-8'))['shots']
items, missing = [], []
for s in shots:
if prio and s['prio'] != prio: continue
if group and s['group'] != group: continue
p = locate(s, raw)
if p: items.append((s['id'], p))
else: missing.append(s['id'])
if missing: print(f"缺图 {len(missing)}:{','.join(missing)}")
make_sheet(items, out, cols=cols, title=title)

View File

@ -0,0 +1,115 @@
#!/usr/bin/env node
// 《山海行纪》资产批产 · 生图批跑(迁移自 docs-design/山海风格版-批次一/scripts/gen-batch.mjs)
// 用法:
// node scripts/asset-gen.mjs --prio P1 # 按优先级批跑(跳过已存在,可断点续跑)
// node scripts/asset-gen.mjs --group beasts # 只跑一个消费分组
// node scripts/asset-gen.mjs --only zheng-move-01 --seed-bump 100000 --force # 单镜头重摇
// node scripts/asset-gen.mjs --only tile-a-00 --subject-extra ",定向补词" --force
// 环境变量 ASSET_WORK=工作目录(默认系统临时目录 xingji-a1-work;raw/ 放生图原片)
// 纪律(承批次一,逐条生效):
// - styleWords 单源 = 批次一 anchor-pack.json(r1),运行时拼接,绝不在本仓复制一份锚;
// - prompt = promptPrefix + 镜头 subject + styleWordsShared + perGame.xingji(sprite|bleed) + 后缀,原样入账;
// - 每次外呼 150s 超时,失败退避重试 ≤3(内容过滤 exit=10 不重试,配额 exit=4 全批中止);
// - 每次调用成败都追加批次一 manifest.jsonl 一行(batch=xingji-a1),逐张 prompt/参数/耗时可审计;
// - adopt 收编镜头(批次一定稿样张直接入产线)不外呼,由 asset-key.py 直接取源;
// - 串行 + 1s 间隔防限流;--subject-ref 仅当镜头带 ref 字段(创始人拍的定妆帧参考图工艺)。
import { readFileSync, existsSync, mkdirSync, renameSync, appendFileSync, readdirSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { dirname, join, resolve, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
const GAME = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const BATCH1 = resolve(GAME, '..', '..', '..', 'docs-design', '山海风格版-批次一'); // 锚包与台账单源
const WORK = process.env.ASSET_WORK || join(tmpdir(), 'xingji-a1-work');
const RAW = join(WORK, 'raw');
const MANIFEST = join(BATCH1, 'manifest.jsonl');
const TMP = join(WORK, 'tmp-gen');
mkdirSync(RAW, { recursive: true });
mkdirSync(TMP, { recursive: true });
// ---- 参数解析 ----
const args = process.argv.slice(2);
const getArg = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
const onlyIds = getArg('--only')?.split(',').map(s => s.trim()).filter(Boolean);
const onlyGroup = getArg('--group');
const onlyPrio = getArg('--prio');
const seedBump = Number(getArg('--seed-bump') || 0);
const subjectExtra = getArg('--subject-extra') || '';
const force = args.includes('--force');
// ---- 读锚包(单源)与镜头单 ----
const anchor = JSON.parse(readFileSync(join(BATCH1, 'anchor-pack.json'), 'utf8'));
const { shots, batch } = JSON.parse(readFileSync(join(GAME, 'scripts', 'asset-shots.json'), 'utf8'));
// prompt 拼装(锚包 r1 口径,与批次一 gen-batch 完全同构;行纪固定取 perGame.xingji)
function buildPrompt(shot) {
const kind = shot.type === 'sprite' ? 'sprite' : 'bleed';
const per = anchor.perGame.xingji[kind];
const suffix = shot.type === 'sprite' ? anchor.spriteSuffix : anchor.fullBleedSuffix;
return `${anchor.promptPrefix}${shot.subject}${subjectExtra}${anchor.styleWordsShared}${per}${suffix}`;
}
// 追加台账一行(逐次调用,成败都记;续写批次一 manifest.jsonl)
function ledger(entry) {
appendFileSync(MANIFEST, JSON.stringify({ ts: new Date().toISOString(), batch, ...entry }) + '\n');
}
// 调 mmx 一次(150s 超时);返回 { ok, file?, error?, ms, exit }
function callMmx(shot, prompt, seedUsed) {
for (const f of readdirSync(TMP)) if (f.startsWith(shot.id)) rmSync(join(TMP, f)); // 防串档
const cli = ['image', 'generate', '--prompt', prompt,
'--width', String(shot.w), '--height', String(shot.h),
'--seed', String(seedUsed), '--out-dir', TMP, '--out-prefix', shot.id,
'--quiet', '--non-interactive'];
if (shot.ref) cli.push('--subject-ref', `type=character,image=${resolve(BATCH1, shot.ref)}`);
const t0 = Date.now();
const r = spawnSync('mmx', cli, { timeout: 150_000, encoding: 'utf8' });
const ms = Date.now() - t0;
if (r.error?.code === 'ETIMEDOUT' || r.signal) return { ok: false, error: 'timeout-150s', ms, exit: 5 };
if (r.status !== 0) return { ok: false, error: (r.stderr || r.stdout || '').trim().slice(0, 300) || `exit=${r.status}`, ms, exit: r.status ?? 1 };
const saved = (r.stdout || '').split('\n').map(s => s.trim()).find(s => s && existsSync(s))
|| readdirSync(TMP).filter(f => f.startsWith(shot.id)).map(f => join(TMP, f))[0];
if (!saved) return { ok: false, error: 'exit=0 但未找到产物文件', ms, exit: 1 };
return { ok: true, file: saved, ms, exit: 0 };
}
// ---- 主循环:串行 ----
const todo = shots.filter(s => !s.adopt
&& (!onlyIds || onlyIds.includes(s.id))
&& (!onlyGroup || s.group === onlyGroup)
&& (!onlyPrio || s.prio === onlyPrio));
let done = 0, skipped = 0, failed = 0, calls = 0;
for (const shot of todo) {
const existing = readdirSync(RAW).find(f => f.startsWith(shot.id + '.'));
if (existing && !force) { skipped++; continue; }
const prompt = buildPrompt(shot);
const seedUsed = (shot.seed || 0) + seedBump;
let result = null;
for (let attempt = 1; attempt <= 3; attempt++) {
result = callMmx(shot, prompt, seedUsed);
calls++;
ledger({ kind: 'gen-call', id: shot.id, game: 'xingji', prio: shot.prio, group: shot.group,
type: shot.type, w: shot.w, h: shot.h, seed: seedUsed, seedBump, attempt,
model: 'image-01', ref: shot.ref || undefined, prompt,
status: result.ok ? 'ok' : 'fail', error: result.error, durationMs: result.ms });
if (result.ok) break;
if (result.exit === 10) { console.error(`[${shot.id}] 内容过滤(exit=10),不重试`); break; }
if (result.exit === 4) { console.error(`[${shot.id}] 配额超限(exit=4),全批中止`); process.exit(4); }
if (attempt < 3) { const wait = attempt * 5000; console.error(`[${shot.id}] 第${attempt}次失败(${result.error}),${wait / 1000}s 后重试`); spawnSync('sleep', [String(wait / 1000)]); }
}
if (result.ok) {
const ext = extname(result.file) || '.png';
const dest = join(RAW, shot.id + ext.toLowerCase());
renameSync(result.file, dest);
done++;
console.log(`[${shot.id}] OK ${Math.round(result.ms / 1000)}s -> ${dest}`);
} else {
failed++;
console.error(`[${shot.id}] FAILED: ${result.error}`);
}
spawnSync('sleep', ['1']); // 限流间隔
}
console.log(`\n批跑完成:成 ${done} / 跳过 ${skipped} / 败 ${failed};本次外呼 ${calls} 次(≈¥${(calls * 0.033).toFixed(2)} 仓内实测口径)`);
process.exit(failed > 0 ? 1 : 0);

View File

@ -0,0 +1,165 @@
#!/usr/bin/env python3
# 《山海行纪》资产批产 · 色键抠图批处理(迁移自批次一 key-out.py,算法同源)
# 职责:镜头单里全部 sprite 镜头 → 透明底精灵 png,按消费分组落 keyed/{group}/{id}.png。
# - gen 镜头取 ASSET_WORK/raw/{id}.jpg;adopt 收编镜头直接取批次一定稿样张(不外呼);
# - 每张:四角采样定底色 → 双阈值 alpha → 连通域除杂(印章/落款/尘点)→ 裁切留边
# → 可选镜像归一(flip:统一面朝右)→ 内容盒缩到 pack 上限(入图集前定尺);
# - bleed 类(tiles/bg)不在本stage处理(不抠图),归 asset-tiles.py / asset-atlas.mjs。
# 用法: python3 scripts/asset-key.py [--only id1,id2] [--prio P1] [--group beasts] [--force]
# [--tol 28] [--soft 52] [--pad 8] (环境变量 ASSET_WORK 同 asset-gen.mjs)
import sys, os, json, tempfile
from collections import deque
import numpy as np
from PIL import Image
GAME = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BATCH1 = os.path.normpath(os.path.join(GAME, '..', '..', '..', 'docs-design', '山海风格版-批次一'))
WORK = os.environ.get('ASSET_WORK') or os.path.join(tempfile.gettempdir(), 'xingji-a1-work')
RAW, KEYED = os.path.join(WORK, 'raw'), os.path.join(WORK, 'keyed')
def parse_args(argv):
opt = {'only': None, 'prio': None, 'group': None, 'force': False, 'tol': 28.0, 'soft': 52.0, 'pad': 8}
i = 0
while i < len(argv):
a = argv[i]
if a == '--only': opt['only'] = set(argv[i+1].split(',')); i += 2
elif a == '--prio': opt['prio'] = argv[i+1]; i += 2
elif a == '--group': opt['group'] = argv[i+1]; i += 2
elif a == '--force': opt['force'] = True; i += 1
elif a in ('--tol', '--soft'): opt[a[2:]] = float(argv[i+1]); i += 2
elif a == '--pad': opt['pad'] = int(argv[i+1]); i += 2
else: sys.exit(f'未知参数 {a}')
return opt
def dilate(mask, it):
# 简易方块膨胀:把被丢弃域向外扩几圈,连羽化边一起清
m = mask.copy()
for _ in range(it):
m = m | np.roll(m, 1, 0) | np.roll(m, -1, 0) | np.roll(m, 1, 1) | np.roll(m, -1, 1)
return m
def key_one(src, tol, soft, pad):
"""色键抠图核(与批次一 key-out.py 同源):返回 RGBA ndarray 或 None。"""
im = Image.open(src).convert('RGB')
a = np.asarray(im, dtype=np.float32)
h, w, _ = a.shape
c = 12 # ① 四角 12px 块中位数 = 背景色
corners = np.concatenate([a[:c, :c].reshape(-1, 3), a[:c, -c:].reshape(-1, 3),
a[-c:, :c].reshape(-1, 3), a[-c:, -c:].reshape(-1, 3)])
bg = np.median(corners, axis=0)
dist = np.sqrt(((a - bg) ** 2).sum(axis=2))
alpha = np.clip((dist - tol) / max(soft - tol, 1e-6), 0.0, 1.0) # ② 双阈值羽化
s = 4 # ③ 1/4 尺度连通域除杂
fg = (dist > (tol + soft) / 2)[::s, ::s]
H, W = fg.shape
lab = np.zeros(fg.shape, dtype=np.int32)
sizes, boxes, nid = {}, {}, 0
for y in range(H):
for x in range(W):
if fg[y, x] and lab[y, x] == 0:
nid += 1
q = deque([(y, x)]); lab[y, x] = nid
n, y0, y1, x0, x1 = 0, y, y, x, x
while q:
cy, cx = q.popleft(); n += 1
y0, y1, x0, x1 = min(y0, cy), max(y1, cy), min(x0, cx), max(x1, cx)
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
ny, nx = cy + dy, cx + dx
if 0 <= ny < H and 0 <= nx < W and fg[ny, nx] and lab[ny, nx] == 0:
lab[ny, nx] = nid; q.append((ny, nx))
sizes[nid], boxes[nid] = n, (y0, y1, x0, x1)
if not sizes:
return None, bg, 0
main = max(sizes, key=sizes.get)
my0, my1, mx0, mx1 = boxes[main]
grow = max(H, W) // 20 # 主体框外扩 5%:贴身小件(飘带/火星/链梢)保留
keep = set()
for i_, n in sizes.items():
by0, by1, bx0, bx1 = boxes[i_]
near = not (bx1 < mx0 - grow or bx0 > mx1 + grow or by1 < my0 - grow or by0 > my1 + grow)
if i_ == main or (near and n >= 0.03 * sizes[main]) or n >= 0.20 * sizes[main]:
keep.add(i_)
dropped = len(sizes) - len(keep)
drop_small = fg & ~np.isin(lab, list(keep))
drop_big = np.kron(dilate(drop_small, 2), np.ones((s, s), dtype=bool))[:h, :w]
alpha[drop_big] = 0.0
ys, xs = np.nonzero(alpha > 0.02) # ④ 内容裁切 + 留边
if len(ys) == 0:
return None, bg, dropped
y0, y1 = max(int(ys.min()) - pad, 0), min(int(ys.max()) + pad + 1, h)
x0, x1 = max(int(xs.min()) - pad, 0), min(int(xs.max()) + pad + 1, w)
rgba = np.dstack([a.astype(np.uint8), (alpha * 255).astype(np.uint8)])[y0:y1, x0:x1]
return rgba, bg, dropped
def solid_interior(rgba):
"""封闭内域回填:UI 框件的中央空白区在色键下会被啃成噪点。
从边界对透明域洪泛,凡不与边界连通的透明像素判为框内封闭区,回填原色为实心
(卡面/槽面得到可承程序排字的实底)"""
a = rgba[:, :, 3]
h, w = a.shape
outside = np.zeros((h, w), dtype=bool)
trans = a < 250 # 半透噪点一并纳入;外缘羽化与外界连通、不受回填影响
q = deque()
for x in range(w):
for y in (0, h - 1):
if trans[y, x] and not outside[y, x]: outside[y, x] = True; q.append((y, x))
for y in range(h):
for x in (0, w - 1):
if trans[y, x] and not outside[y, x]: outside[y, x] = True; q.append((y, x))
while q:
cy, cx = q.popleft()
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
ny, nx = cy + dy, cx + dx
if 0 <= ny < h and 0 <= nx < w and trans[ny, nx] and not outside[ny, nx]:
outside[ny, nx] = True; q.append((ny, nx))
interior = trans & ~outside
rgba[:, :, 3] = np.where(interior, 255, a)
return rgba, int(interior.sum())
def find_src(shot):
"""定位镜头源图:adopt→批次一定稿;gen→工作区 raw/。"""
if shot.get('adopt'):
p = os.path.join(BATCH1, shot['adopt'])
return p if os.path.exists(p) else None
for ext in ('.jpg', '.jpeg', '.png', '.webp'):
p = os.path.join(RAW, shot['id'] + ext)
if os.path.exists(p): return p
return None
if __name__ == '__main__':
opt = parse_args(sys.argv[1:])
shots = json.load(open(os.path.join(GAME, 'scripts', 'asset-shots.json'), encoding='utf-8'))['shots']
todo = [s for s in shots if s['type'] == 'sprite'
and (not opt['only'] or s['id'] in opt['only'])
and (not opt['prio'] or s['prio'] == opt['prio'])
and (not opt['group'] or s['group'] == opt['group'])]
ok, miss, skip = 0, [], 0
for s in todo:
out_dir = os.path.join(KEYED, s['group'])
out = os.path.join(out_dir, f"{s['id']}.png")
if os.path.exists(out) and not opt['force']:
skip += 1; continue
src = find_src(s)
if not src:
miss.append(s['id']); print(f"[{s['id']}] 缺源图(未生成或收编源不存在),跳过"); continue
rgba, bg, dropped = key_one(src, opt['tol'], opt['soft'], opt['pad'])
if rgba is None:
miss.append(s['id']); print(f"[{s['id']}] 警告:未检出前景,跳过"); continue
filled = 0
if s.get('solidInterior'): # UI 框件:封闭内域回填为实心
rgba, filled = solid_interior(rgba)
im = Image.fromarray(rgba, 'RGBA')
if s.get('flip'): # 镜像归一:帧集统一面朝画面右方
im = im.transpose(Image.FLIP_LEFT_RIGHT)
cap = s.get('pack')
if cap and max(im.size) > cap: # 内容盒定尺:入图集前缩到消费尺寸
r = cap / max(im.size)
im = im.resize((max(1, round(im.width * r)), max(1, round(im.height * r))), Image.LANCZOS)
os.makedirs(out_dir, exist_ok=True)
im.save(out)
ok += 1
print(f"[{s['id']}] 底色 rgb({int(bg[0])},{int(bg[1])},{int(bg[2])}) 丢杂 {dropped}"
+ (f" 内域回填 {filled}px" if filled else '')
+ f"{im.width}x{im.height}{'(flip)' if s.get('flip') else ''} {os.path.getsize(out)//1024}KB")
print(f"\n抠图完成:成 {ok} / 跳过 {skip} / 缺源 {len(miss)}" + (f":{','.join(miss)}" if miss else ''))
sys.exit(1 if miss else 0)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,93 @@
#!/usr/bin/env python3
# 《山海行纪》资产批产 · 图1 tileset 合成(生图满幅地表 → 定格网格 tile 表)
# 思路:三张 1024² 满幅地表纹理(a 基底/b 暗变奏/c 刻痕变奏)各缩到 512² 后切 4×4=16 块
# 128px tile,按 a→b→c 顺序排进 8 列 × 6 行的单张 1024×768 tile 表——同表切块天然可
# 互拼(纹理连续来自同一幅画),tile 索引 = 行×8+列,LittleJS tile(index,128) 直接消费。
# 用法: python3 scripts/asset-tiles.py (环境变量 ASSET_WORK 同 asset-gen.mjs)
# 产物: assets/tiles/tiles-map1.webp(q80,进包体账)+ 工作区 png 开发查看副本。
import os, sys, tempfile
import numpy as np
from PIL import Image
GAME = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WORK = os.environ.get('ASSET_WORK') or os.path.join(tempfile.gettempdir(), 'xingji-a1-work')
RAW = os.path.join(WORK, 'raw')
OUT_DIR = os.path.join(GAME, 'assets', 'tiles')
TILE, COLS = 128, 8
SHEETS = ['tile-a-00', 'tile-b-00', 'tile-c-00'] # 与 asset-shots.json 的 tiles 组对齐
INSET = 24 # 内缩裁切:去掉生图四周细框线,再整幅重铺
def patch_seals(im):
"""印章修补:mmx 国风生图系统性伴生红印(批次一工艺发现3)。
tiles 不走连通域除杂,这里按朱砂色域逐连通域检出印章块(一图可能多枚),
每枚用画面中心区域同尺寸纹理覆盖;大面积红(画面本身偏红)不动"""
a = np.asarray(im, dtype=np.int16)
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
seal = (r > 110) & (r - g > 60) & (r - b > 55) & (g < 90) # 朱砂印泥红:R 显著高、G 压低(区别于赭金斑)
if seal.mean() > 0.05:
return im, 0
# 1/4 尺度 BFS 分岛:多枚印章逐一修,避免并框误判
s = 4
m4 = seal[::s, ::s].copy()
H, W = m4.shape
patched = 0
from collections import deque
for y in range(H):
for x in range(W):
if not m4[y, x]:
continue
q = deque([(y, x)]); m4[y, x] = False
n, y0, y1, x0, x1 = 0, y, y, x, x
while q:
cy, cx = q.popleft(); n += 1
y0, y1, x0, x1 = min(y0, cy), max(y1, cy), min(x0, cx), max(x1, cx)
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)):
ny, nx = cy + dy, cx + dx
if 0 <= ny < H and 0 <= nx < W and m4[ny, nx]:
m4[ny, nx] = False; q.append((ny, nx))
if n < 12: # 岛太小(<~200 全尺寸像素)= 画面固有红点,不动
continue
gy0, gy1 = max(0, y0 * s - 12), min(im.height, (y1 + 1) * s + 12)
gx0, gx1 = max(0, x0 * s - 12), min(im.width, (x1 + 1) * s + 12)
if (gy1 - gy0) > im.height // 3 or (gx1 - gx0) > im.width // 3:
continue # 异常大块不自动动,留人检
ph, pw = gy1 - gy0, gx1 - gx0
cy0, cx0 = im.height // 2 - ph // 2, im.width // 2 - pw // 2
im.paste(im.crop((cx0, cy0, cx0 + pw, cy0 + ph)), (gx0, gy0))
patched += 1
return im, patched
def find_raw(sid):
for ext in ('.jpg', '.jpeg', '.png', '.webp'):
p = os.path.join(RAW, sid + ext)
if os.path.exists(p): return p
return None
if __name__ == '__main__':
srcs = []
for sid in SHEETS:
p = find_raw(sid)
if not p: sys.exit(f'缺 tile 源图 {sid}(先跑 asset-gen.mjs --group tiles)')
srcs.append(p)
rows_per_sheet = 16 // COLS # 每张源图贡献 16 块 → 2 行
sheet = Image.new('RGB', (COLS * TILE, len(SHEETS) * rows_per_sheet * TILE))
for si, p in enumerate(srcs):
im = Image.open(p).convert('RGB')
im = im.crop((INSET, INSET, im.width - INSET, im.height - INSET)) # 去边框线
im, patched = patch_seals(im)
if patched: print(f'[{SHEETS[si]}] 检出印章 {patched} 枚,已用中心纹理修补')
im = im.resize((512, 512), Image.LANCZOS)
for i in range(16): # 源图 4×4 切块,按序排入 8 列网格
sx, sy = (i % 4) * TILE, (i // 4) * TILE
gi = si * 16 + i
dx, dy = (gi % COLS) * TILE, (gi // COLS) * TILE
sheet.paste(im.crop((sx, sy, sx + TILE, sy + TILE)), (dx, dy))
os.makedirs(OUT_DIR, exist_ok=True)
webp = os.path.join(OUT_DIR, 'tiles-map1.webp')
png_dev = os.path.join(WORK, 'tiles-map1.png')
sheet.save(webp, quality=80, method=6)
sheet.save(png_dev)
kb = os.path.getsize(webp) / 1024
print(f'tile 表完成:{sheet.width}x{sheet.height},{len(SHEETS) * 16} tiles @{TILE}px,'
f'索引=行×{COLS}+列(a=0-15/b=16-31/c=32-47) → {webp}({kb:.0f}KB webp)')