0.2.6-dev: the 19 local 玩法说明 pages get a lossless WebP copy in tools/local-extract (25.6 → 17.4 MB; lossy waits for the owner's comparison) and the guide preloads only the next page (host bandwidth layer 0.3); golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 16:00:08 +08:00
1 parent 8edda589f3
commit 05b0dba8b5
7 files changed
+112 -16

No files matched your search

+1 -1
View File
@@ -211,7 +211,7 @@ The `stem` of a Spine model is the upstream file name. Two examples: `char_107_l
- **Fonts:** OTF/TTF files are converted to WOFF2 by a built-in encoder (`tools/assets/woff2.mjs`: Brotli with null transforms).
- Its output was verified lossless against Google's reference `woff2` decoder.
- `fonts.css` lists WOFF2 first and falls back to the original file.
- Images stay PNG. WebP conversion is not done: it would need a native dependency. The local-client board textures are the exception: `tools/local-extract/extract.py` (Python, where Pillow is already a dependency) writes WebP copies of the 12 textures the 3D board downloads (`WEBP`: colour maps lossy at quality 95 with the alpha and the RGB under transparent texels kept, normal and data maps lossless) and `data/local-assets.json` lists the copies; the PNGs stay beside them for `tools/crop-board-atlas.mjs`. `extract.py --webp` adds the copies to an existing extraction without the client.
- Images stay PNG. WebP conversion is not done: it would need a native dependency. The local-client board textures are the exception: `tools/local-extract/extract.py` (Python, where Pillow is already a dependency) writes WebP copies of the 12 textures the 3D board downloads (`WEBP`: colour maps lossy at quality 95 with the alpha and the RGB under transparent texels kept, normal and data maps lossless) and `data/local-assets.json` lists the copies; the PNGs stay beside them for `tools/crop-board-atlas.mjs`. The 19 local 玩法说明 pages get a WebP copy too (`GUIDE_WEBP_MODE`, lossless: 25.6 MB → 17.4 MB on the CN client's pages; lossy quality 90 would be 4.2 MB and waits for the maintainer's side-by-side look), listed in place of the PNG, which stays beside it; the mirror copies under `ui/guide/` stay PNG. `extract.py --webp` adds the copies to an existing extraction without the client.
## Manifest schema (`data/assets.json`)
+2
View File
@@ -315,6 +315,8 @@ services:
**3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。
**「玩法说明」教程图的下载量**(0.2.6 起):本地提取的 19 页教程图每页 1.2–1.5 MB,玩家每翻一页都要从开服的电脑下载。提取时同样给每页写一份无损 WebP,清单列 WebP,PNG 留在旁边;19 页合计从约 25.6 MB 降到约 17.4 MB,画面完全不变。看说明时只预载下一页(以前前后两页都预载)。已经提取过的同样用 `tools/local-extract/extract.py --webp` 补上。
## 7. 打包发布(维护者)
Releases 的 zip(完整包、精简包,0.2.1 起还有更新包)由 `tools/package.mjs` 生成,在**源码仓库**里运行(整合包里没有这个工具):
+1 -1
View File
@@ -73,7 +73,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
- `scene/<stageId>/` and `scene/tex/` — the official scene of a battlefield (§15 "Official battlefield scenes", DESIGN §30.17): `scene.json` (materials, key light, chunk records), `scene.bin` (the meshes in board space), the lightmap pages `lm<i>`, and the materials' albedo textures shared by the scenes. Read from the scene bundles `scenes/activities/<season>/level_<stage>/…`, which the client carries only while the season runs: `extract.py --only scene` takes them from the client or from `--scenes <dir>` / `.cache/official-scenes/*` (`tools/local-extract/fetch_scenes.py` downloads them from the official CDN's in-season version). The 0.2.5 full release zip includes the extracted files when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (`node tools/setup.mjs --scenes`). The repository never stores the bundles or the extracted files.
- `ui/{common,battle,outer}/*` — every sprite of the three official autochess UI bundles (tier chips I–VI, bond disc rings, frames, banners, HUD pieces, badges, backgrounds, mode art).
- `emoticon/<dir>/<picId>.png` — the 36 official battle emotes, 6 themes (`shared/constants.js → EMOTE_THEMES / EMOTES`, `emoteArtPath`; data/emotes.json); also downloaded from the mirror (§22.5).
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5).
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5). `extract.py` writes a lossless WebP copy beside each page and the manifest lists the copy (`GUIDE_WEBP_MODE`; 25.6 MB → 17.4 MB); the viewer preloads only the next page (`ui/guide.js guidePreloadUrl`), not the previous one too.
- `projectiles/*` — battle projectile sprites.
- `spine/enemy/<id>/` — enemy models no web dump carries (灼热 / 炽焰源石虫, `extract.py ENEMY_SPINES`): manifest `enemies[id].spineLocal` (metadata from the committed `tools/assets/local-enemy-spines.json`); drawn instead of the tinted web alias when every file is listed (§21.14).
- `spine/token/<id>/` — token (summon) models no web dump carries (40: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼; `extract.py TOKEN_SPINES`, the Front renderer's skeleton of each battle token prefab): manifest `tokens[id].spineLocal` (metadata from the committed `tools/assets/local-token-spines.json`); drawn instead of the avatar diamond when every file is listed (docs/ASSETS.md "Token models from the local client").
+16 -3
View File
@@ -3,7 +3,8 @@
// downloads from the public mirror (data/assets.json → ui['guide/<key>']; GitHub issue #42) — data.js artUrls; a page
// whose picture fails to load tries its next URL. The pages are stored squashed to 1024² (both copies) and are
// displayed stretched back to 16:9. Three chapters (基础规则 / 调度手册 / 进阶图鉴), ‹ › buttons, ←/→ (A/D) keys, page
// dots, thumbnails; Esc or the backdrop closes. Adjacent pages are preloaded.
// dots, thumbnails; Esc or the backdrop closes. Only the next page is preloaded (a page is 0.9–1.5 MB; the one before
// has usually just been seen, so preloading it too doubled the download — host bandwidth, layer 0).
// With no page listed the viewer shows the official loading-screen tips (config.tips) instead, and so does a page none
// of whose copies loads (`guideStage`: data/assets.json lists the downloaded pages but the files are not on disk — a
// `git pull` and restart without setup, an older asset folder, a failed download the shrink guard kept); the chapter
@@ -66,6 +67,19 @@ export function guideStage(page, failed) {
return src ? { kind: 'image', src } : { kind: 'tips' };
}
/**
* The one page the viewer preloads while page `i` is shown: the next one (wrapping around), its first URL that has not
* failed — null without pages. The previous page is not preloaded (see the header).
* @param {Array<{ urls?: string[] }>} pages guidePages()
* @param {number} i
* @param {Set<string>} [failed]
*/
export function guidePreloadUrl(pages, i, failed) {
const n = Array.isArray(pages) ? pages.length : 0;
if (!n || n < 2) return null;
return nextArtUrl(pages[(((i + 1) % n) + n) % n]?.urls, failed);
}
/** Open/closed + current page. */
export const guideStore = createStore({ open: false, page: 0 });
@@ -136,8 +150,7 @@ export function GuideHost() {
useEffect(() => {
if (!open || !n) return;
preload(nextArtUrl(pages[(i + 1) % n]?.urls, failed));
preload(nextArtUrl(pages[(i + n - 1) % n]?.urls, failed));
preload(guidePreloadUrl(pages, i, failed));
}, [open, i, n, failed]);
if (!open) return null;
+41
View File
@@ -209,6 +209,47 @@ print(json.dumps(e.prefab_node(go)))`);
const raw = spawnSync(PY, ['-c', `import sys; sys.path.insert(0, ${JSON.stringify(TOOL)}); import extract as e, json; print(json.dumps(e.vec(type('V', (), {'x': -0.0, 'y': float('inf'), 'z': 1e-9})())))`], { encoding: 'utf8', env: ENV });
assert.equal(raw.stdout.trim(), '[0.0, 0, 0.0]');
});
test('guide pages (host bandwidth layer 0): --webp writes a lossless WebP copy of every page PNG, the manifest lists it, the PNG stays',
{ skip: spawnSync(PY, ['-c', "import sys; from PIL import features; sys.exit(0 if features.check('webp') else 1)"], { env: ENV }).status !== 0 && 'no Pillow with WebP' }, () => {
const jobs = JSON.parse(spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV }).stdout);
assert.deepEqual(jobs.guideWebp.sub, 'guide');
assert.equal(jobs.guideWebp.mode, 'lossless', 'lossy pages wait for the maintainer\'s comparison');
assert.ok(!jobs.webp.some((w) => w.sub === 'guide'), 'the board texture table is unchanged');
const out = py(`
import tempfile, pathlib
from PIL import Image
root = pathlib.Path(tempfile.mkdtemp())
sub = root / 'guide'
sub.mkdir()
for i, name in enumerate(['autochess_home_1', 'autochess_shop_2']):
img = Image.new('RGBA', (48, 32), (10, 20, 30, 255))
for x in range(48):
for y in range(32): img.putpixel((x, y), ((x * 5 + i * 40) % 256, (y * 7) % 256, (x * y) % 256, 255 if x % 7 else 254))
img.save(sub / f'{name}.png')
groups = {'guide': {n: {'path': f'/assets/local/guide/{n}.png', 'w': 48, 'h': 32, 'kind': 'Sprite'} for n in ['autochess_home_1', 'autochess_shop_2']},
'ui/battle': {'x': {'path': '/assets/local/ui/battle/x.png', 'w': 2, 'h': 2, 'kind': 'Sprite'}}}
groups['guide']['autochess_handbook_9'] = {'path': '/assets/local/guide/autochess_handbook_9.png', 'kind': 'Sprite'} # no PNG on disk
mf = root / 'local-assets.json'
mf.write_text(json.dumps({'version': 1, 'source': 'local-client', 'count': 4, 'groups': groups}))
code = e.webp_only(root, mf, lambda *a: None)
doc = json.loads(mf.read_text())
g = doc['groups']['guide']
px = lambda p: list(Image.open(p).convert('RGBA').getdata())
print(json.dumps({'code': code, 'paths': {k: v['path'] for k, v in g.items()}, 'modes': {k: v.get('webp') for k, v in g.items()},
'other': doc['groups']['ui/battle']['x']['path'],
'same': all(px(sub / f'{n}.png') == px(sub / f'{n}.webp') for n in ['autochess_home_1', 'autochess_shop_2']),
'png': all((sub / f'{n}.png').exists() for n in ['autochess_home_1', 'autochess_shop_2']),
'again': e.run_webp(root, 'guide', doc['groups'], lambda *a: None)}))`);
assert.equal(out.code, 0);
const at = (n, ext) => `/assets/local/guide/${n}.${ext}`;
assert.deepEqual(out.paths, { autochess_home_1: at('autochess_home_1', 'webp'), autochess_shop_2: at('autochess_shop_2', 'webp'), autochess_handbook_9: at('autochess_handbook_9', 'png') });
assert.deepEqual(out.modes, { autochess_home_1: 'lossless', autochess_shop_2: 'lossless', autochess_handbook_9: null });
assert.equal(out.other, '/assets/local/ui/battle/x.png', 'other groups keep their PNGs');
assert.ok(out.same, 'lossless: the same pixels, alpha included');
assert.ok(out.png, 'the PNG stays beside the copy');
assert.equal(out.again, 2, 'a second run (another GUIDE_WEBP_MODE) rewrites the copies the manifest already lists');
});
});
describe('extracted board-scene files (when a local client was extracted)', { skip: !manifest && 'no data/local-assets.json' }, () => {
+13
View File
@@ -163,6 +163,19 @@ describe('emotes and 玩法说明 pages: local art first, then the mirror copy (
assert.ok(!src.includes('该页面暂时无法显示'), 'no blank notice left');
});
test('the guide preloads only the next page (host bandwidth layer 0): wraps around, skips a failed copy, never the previous page', async () => {
const { guidePreloadUrl } = await import('../../public/js/ui/guide.js');
const page = (k) => ({ urls: [`/assets/local/guide/${k}.webp`, `/assets/ui/guide/${k}.png`] });
const pages = ['autochess_home_1', 'autochess_home_2', 'autochess_home_3'].map(page);
assert.equal(guidePreloadUrl(pages, 0), '/assets/local/guide/autochess_home_2.webp');
assert.equal(guidePreloadUrl(pages, 2), '/assets/local/guide/autochess_home_1.webp', 'wraps to the first page');
assert.equal(guidePreloadUrl(pages, 1, new Set(['/assets/local/guide/autochess_home_3.webp'])), '/assets/ui/guide/autochess_home_3.png', 'the copy that has not failed');
assert.equal(guidePreloadUrl([page('autochess_home_1')], 0), null, 'a single page needs no preload');
assert.equal(guidePreloadUrl([], 0), null);
const src = readFileSync(path.join(ROOT, 'public/js/ui/guide.js'), 'utf8');
assert.deepEqual(src.match(/^\s+preload\(.*$/gm).map((l) => l.trim()), ['preload(guidePreloadUrl(pages, i, failed));'], 'one preload call in the viewer');
});
test('tools/assets/plan.mjs GUIDE_PAGES is the viewer\'s page list (GUIDE_CHAPTERS), in reading order', async () => {
const { GUIDE_CHAPTERS } = await import('../../public/js/ui/guide.js');
assert.deepEqual([...GUIDE_PAGES], GUIDE_CHAPTERS.flatMap((c) => c.pages.map(([key]) => key)));
+38 -11
View File
@@ -32,8 +32,8 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
DXT5nm in an older bundle: AG = XY, R = 1) and metallic/gloss with the smoothness in A; the board renderer needs
an RGB normal map (Z rebuilt) and a roughness map in G (1 − smoothness; metalness B = 0), written next to the
source as <name>_rgb.png / _rough.png
- WebP copies of the board textures every player downloads with the 3D board (WEBP), written next to their PNGs;
the manifest lists the copy
- WebP copies of the board textures every player downloads with the 3D board (WEBP) and of the 19 guidebook pages
(GUIDE_WEBP_*), written next to their PNGs; the manifest lists the copy
- the official battlefield scenes (scenes.py SCENES: one prebuilt scene per 卫戍协议 battlefield) → scene/<stageId>/
scene.json + scene.bin + lightmap pages and scene/tex/ (the materials' albedo textures). The scene bundles ship
only while the season runs: they are read from the client when it has them, else from --scenes <dir> (default
@@ -43,8 +43,9 @@ Usage:
python3 -m venv .venv && .venv/bin/pip install -r tools/local-extract/requirements.txt
.venv/bin/python tools/local-extract/extract.py [--game <AB root>] [--out public/assets/local] [--only <subdir prefix>]
python3 tools/local-extract/extract.py --print-jobs (the job table as JSON; needs no dependencies)
python3 tools/local-extract/extract.py --webp (only the WebP copies, from the PNGs already extracted —
e.g. the local art copied from a release bundle; needs Pillow)
python3 tools/local-extract/extract.py --webp (only the WebP copies of the board textures and the guide
pages, from the PNGs already extracted — e.g. the local art
copied from a release bundle; needs Pillow)
.venv/bin/python tools/local-extract/extract.py --only scene [--scenes <dir of in-season bundles>]
Writes <out>/**.png|.webp|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just
@@ -188,6 +189,19 @@ WEBP_OPTIONS = {
'lossless': {'lossless': True, 'quality': 100, 'method': 6, 'exact': True},
}
# The 19 玩法说明 pages (output subdir guide: 1024², opaque) get a WebP copy too — every page PNG of the group, each a
# 1.2–1.5 MB download whenever a player opens the guide (public/js/ui/guide.js shows the local page first and preloads
# the next one). Lossless by default: 25.6 MB → 17.4 MB on the CN client's pages. Lossy quality 90 would make them
# 4.2 MB; the maintainer decides on that after a side-by-side comparison (docs/history/0.2.5.md, the 0.2.6-dev note),
# and then only GUIDE_WEBP_MODE changes (`extract.py --webp` rewrites the copies). Method 4: method 6 spends about 8 s
# a page for 0.4 % less.
GUIDE_WEBP_SUB = 'guide'
GUIDE_WEBP_MODE = 'lossless'
GUIDE_WEBP_OPTIONS = {
'lossless': {'lossless': True, 'quality': 100, 'method': 4, 'exact': True},
'lossy': {'quality': 90, 'method': 4, 'alpha_quality': 100},
}
# Shader bundles loaded beside every job that exports Materials, only so that the materials' shader references
# (external CABs) resolve to a name in materials.json (e.g. Torappu/Scene/StandardDirectional); nothing is exported
# from them.
@@ -454,11 +468,22 @@ def run_derived(out_root, sub, manifest, log):
return n
def webp_targets(sub, manifest):
"""(name, mode, Pillow options) of every WebP copy output subdir `sub` gets: its WEBP rows and, for the guide, each
page of the group (an image entry: its path ends in .png, or in .webp after an earlier run)."""
out = [(name, mode, WEBP_OPTIONS[mode]) for wsub, name, mode in WEBP if wsub == sub]
if sub == GUIDE_WEBP_SUB:
out += [(name, GUIDE_WEBP_MODE, GUIDE_WEBP_OPTIONS[GUIDE_WEBP_MODE])
for name, entry in sorted(manifest.get(sub, {}).items())
if isinstance(entry, dict) and str(entry.get('path', '')).endswith(('.png', '.webp'))]
return out
def run_webp(out_root, sub, manifest, log):
"""Write the WEBP copies of output subdir `sub` from its PNGs and point their manifest entries at them."""
"""Write the WebP copies of output subdir `sub` from its PNGs and point their manifest entries at them."""
n = 0
for wsub, name, mode in WEBP:
entry = manifest.get(sub, {}).get(name) if wsub == sub else None
for name, mode, options in webp_targets(sub, manifest):
entry = manifest.get(sub, {}).get(name)
png = Path(out_root) / sub / f'{name}.png'
if not entry or not png.exists():
continue
@@ -466,7 +491,7 @@ def run_webp(out_root, sub, manifest, log):
try:
from PIL import Image
with Image.open(png) as img:
img.save(dst, 'WEBP', **WEBP_OPTIONS[mode])
img.save(dst, 'WEBP', **options)
except Exception as e: # no WebP support in this Pillow, or a broken PNG: the entry keeps the PNG
log(f' warn webp {name}: {e}')
continue
@@ -486,7 +511,7 @@ def webp_only(out_root, manifest_path, log):
if not isinstance(groups, dict):
print(f'{manifest_path} has no groups. Extract the local art first.', file=sys.stderr)
return 2
n = sum(run_webp(out_root, sub, groups, log) for sub in dict.fromkeys(s for s, _, _ in WEBP))
n = sum(run_webp(out_root, sub, groups, log) for sub in dict.fromkeys([*(s for s, _, _ in WEBP), GUIDE_WEBP_SUB]))
Path(manifest_path).write_text(json.dumps(doc, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
log(f'done: {n} WebP copies, manifest {manifest_path}')
return 0 if n else 1
@@ -846,8 +871,8 @@ def main():
'searched after --game and before .cache/official-scenes/*)')
ap.add_argument('--print-jobs', action='store_true', help='print the job table as JSON and exit')
ap.add_argument('--webp', action='store_true',
help='only write the WebP copies of the board textures from the PNGs already under --out and list '
'them in the manifest (needs Pillow, not the client)')
help='only write the WebP copies of the board textures and the guide pages from the PNGs already '
'under --out and list them in the manifest (needs Pillow, not the client)')
args = ap.parse_args()
if args.print_jobs:
@@ -857,6 +882,8 @@ def main():
webp = [{'sub': sub, 'name': name, 'mode': mode} for sub, name, mode in WEBP]
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
'derived': derived, 'webp': webp,
'guideWebp': {'sub': GUIDE_WEBP_SUB, 'mode': GUIDE_WEBP_MODE,
'options': GUIDE_WEBP_OPTIONS[GUIDE_WEBP_MODE]},
'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB, 'ids': ENEMY_SPINES},
'tokenSpines': {'bundles': TOKEN_PACKS, 'sub': TOKEN_SPINE_SUB, 'ids': TOKEN_SPINES},
'scenes': {'sub': scenes.SCENE_SUB, 'tex': scenes.TEX_SUB,