diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 2467d4dc..32d6729c 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -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`) diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 709f6d55..387d0da5 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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` 生成,在**源码仓库**里运行(整合包里没有这个工具): diff --git a/docs/design/client.md b/docs/design/client.md index 4d2a59ff..f3a6a7e8 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -73,7 +73,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer - `scene//` 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`, and the materials' albedo textures shared by the scenes. Read from the scene bundles `scenes/activities//level_/…`, which the client carries only while the season runs: `extract.py --only scene` takes them from the client or from `--scenes ` / `.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//.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//` — 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//` — 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"). diff --git a/public/js/ui/guide.js b/public/js/ui/guide.js index 907abd0e..46cb9626 100644 --- a/public/js/ui/guide.js +++ b/public/js/ui/guide.js @@ -3,7 +3,8 @@ // downloads from the public mirror (data/assets.json → ui['guide/']; 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} [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; diff --git a/test/local-extract.test.js b/test/local-extract.test.js index 86577f1c..e22ca151 100644 --- a/test/local-extract.test.js +++ b/test/local-extract.test.js @@ -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' }, () => { diff --git a/test/ui/localArt.test.js b/test/ui/localArt.test.js index 956f6cba..13ffa2a5 100644 --- a/test/ui/localArt.test.js +++ b/test/ui/localArt.test.js @@ -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))); diff --git a/tools/local-extract/extract.py b/tools/local-extract/extract.py index 129d18e4..2531b645 100644 --- a/tools/local-extract/extract.py +++ b/tools/local-extract/extract.py @@ -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 _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// 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 (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 ] [--out public/assets/local] [--only ] 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 ] Writes /**.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,