build: a phone-sized bundle carrying one campaign
The static build ships every edition, and v0.5 alone is a 9.2 MB module holding a thousand levels — fine over localhost, not over a phone connection. This rewrites the campaign registry to name a single edition, then follows the import graph from src/app.js, so the editions nobody asked for are never reached. 22 modules, 645 KB of code. Art is the part an import graph cannot find: the tile sheet and icon sheet are loaded by code, the background, tray and fonts by stylesheets. Both sources are scanned and anything named but missing aborts the build, because a missing tile sheet degrades to the fallback renderer and looks like a design choice rather than a broken build. That is how the first bundle shipped: playable, with every icon gone and "棋子图片加载失败" in the toast. Stylesheets under design/ reach the art with ../assets/, which climbs out of a bundle once inlined into a page at the root, so they are rewritten before they are scanned — what gets collected is what the page will actually request. --standalone emits a complete HTML document for an ordinary static host; the default emits body content only, for hosts that supply the skeleton. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,3 +11,6 @@ bench/report.json
|
|||||||
bench/hex-bench/
|
bench/hex-bench/
|
||||||
bench/hex-bench.zip
|
bench/hex-bench.zip
|
||||||
bench/pack/
|
bench/pack/
|
||||||
|
|
||||||
|
# Phone bundle: regenerated by scripts/build-phone.mjs
|
||||||
|
/build/
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Build a phone-sized standalone copy of the game carrying one campaign.
|
||||||
|
//
|
||||||
|
// node scripts/build-phone.mjs [--campaign=c50] [--out=build/phone]
|
||||||
|
//
|
||||||
|
// The repository's own static build ships every edition, and v0.5 alone is a
|
||||||
|
// 9.2 MB module holding a thousand levels — fine over localhost, not over a
|
||||||
|
// phone connection. This swaps the campaign registry for one that names a
|
||||||
|
// single edition, then follows the import graph from the entry module, so the
|
||||||
|
// editions nobody asked for are never reached and never copied.
|
||||||
|
//
|
||||||
|
// Two shapes of page, because hosts differ. --standalone emits a complete
|
||||||
|
// document for an ordinary static host. The default emits body content only,
|
||||||
|
// for Claude Artifacts, whose publisher supplies <!doctype>, <html>, <head> and
|
||||||
|
// <body> and rejects a page that brings its own. Either way the stylesheets are
|
||||||
|
// inlined rather than linked, so the page has no render-blocking round trips.
|
||||||
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join, relative, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
|
||||||
|
const CAMPAIGN = String(args.campaign ?? 'c50');
|
||||||
|
const STANDALONE = Boolean(args.standalone);
|
||||||
|
const OUT = resolve(args.out ?? 'build/phone');
|
||||||
|
const ROOT = resolve('.');
|
||||||
|
|
||||||
|
// The registry, rewritten to one edition. Same exports, so nothing downstream
|
||||||
|
// knows the difference.
|
||||||
|
const registry = `// Generated by scripts/build-phone.mjs — one edition only.
|
||||||
|
import { C50_CAMPAIGN, C50_LEVELS } from './levels-c50.js';
|
||||||
|
export const CAMPAIGN_VERSIONS = [{
|
||||||
|
id: C50_CAMPAIGN.id, name: C50_CAMPAIGN.name, description: C50_CAMPAIGN.description,
|
||||||
|
chapters: C50_CAMPAIGN.chapters, chapterSize: C50_CAMPAIGN.chapterSize,
|
||||||
|
levels: C50_LEVELS, legacyStorageKeys: [],
|
||||||
|
}];
|
||||||
|
export const DEFAULT_CAMPAIGN_VERSION = '${CAMPAIGN}';
|
||||||
|
export function campaignVersion(id) {
|
||||||
|
return CAMPAIGN_VERSIONS.find(c => c.id === id) || CAMPAIGN_VERSIONS[0];
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const OVERRIDES = new Map([[resolve('src/campaign-versions.js'), registry]]);
|
||||||
|
|
||||||
|
// Follow every static import from the entry. Dynamic imports are not followed;
|
||||||
|
// the game has none, and a miss would show up as a 404 rather than silently.
|
||||||
|
const IMPORT = /(?:^|\n)\s*(?:import|export)\s[^'"]*from\s*['"]([^'"]+)['"]|(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g;
|
||||||
|
const seen = new Map();
|
||||||
|
function walk(file) {
|
||||||
|
if (seen.has(file)) return;
|
||||||
|
const source = OVERRIDES.get(file) ?? readFileSync(file, 'utf8');
|
||||||
|
seen.set(file, source);
|
||||||
|
for (const m of source.matchAll(IMPORT)) {
|
||||||
|
const spec = m[1] ?? m[2];
|
||||||
|
if (!spec.startsWith('.')) continue; // bare specifiers: none in this project
|
||||||
|
walk(resolve(dirname(file), spec));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(resolve('src/app.js'));
|
||||||
|
|
||||||
|
// One <style> block, in the order index.html linked them.
|
||||||
|
const html = readFileSync(resolve('index.html'), 'utf8');
|
||||||
|
const sheets = [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"/g)].map(m => m[1]);
|
||||||
|
const css = sheets.map(href => `/* ${href} */\n${readFileSync(resolve(href), 'utf8')}`).join('\n');
|
||||||
|
|
||||||
|
// Stylesheets under design/ reach the art with ../assets/; inlined into a page
|
||||||
|
// at the root, that path climbs out of the bundle. Rewrite before collecting,
|
||||||
|
// so what is collected is what the page will actually ask for.
|
||||||
|
const cssFixed = css.replace(/\.\.\/assets\//g, 'assets/');
|
||||||
|
|
||||||
|
// Art is referenced from code and from stylesheets, never from index.html, so
|
||||||
|
// the import graph cannot find it. Both sources are scanned, and anything named
|
||||||
|
// but missing is reported rather than shipped as a broken image — a missing
|
||||||
|
// tile sheet degrades to the fallback renderer and looks like a design choice.
|
||||||
|
const assets = new Set();
|
||||||
|
for (const source of [...seen.values(), cssFixed]) {
|
||||||
|
for (const m of source.matchAll(/assets\/[A-Za-z0-9_.\/-]+\.(?:png|webp|jpg|svg|woff2?)/g)) assets.add(m[0]);
|
||||||
|
}
|
||||||
|
const missing = [...assets].filter(a => !existsSync(resolve(a)));
|
||||||
|
if (missing.length) { console.error(`引用了但找不到:${missing.join(' ')}`); process.exit(1); }
|
||||||
|
|
||||||
|
const title = (html.match(/<title>([^<]*)<\/title>/) ?? [, 'HEX TRAILS'])[1];
|
||||||
|
const body = html
|
||||||
|
.replace(/^[\s\S]*?<body>/, '')
|
||||||
|
.replace(/<\/body>\s*<\/html>\s*$/, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
rmSync(OUT, { recursive: true, force: true });
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
const head = `<title>${title}</title>\n<style>\n${cssFixed}\n</style>`;
|
||||||
|
// viewport-fit=cover lets the board sit under a phone's notch; the Artifact
|
||||||
|
// skeleton supplies its own viewport meta, so only the standalone page needs it.
|
||||||
|
writeFileSync(join(OUT, 'index.html'), STANDALONE
|
||||||
|
? `<!doctype html>\n<html lang="zh-CN">\n<head>\n<meta charset="utf-8">\n`
|
||||||
|
+ `<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">\n`
|
||||||
|
+ `<meta name="theme-color" content="#A8BECF">\n${head}\n</head>\n<body>\n${body}\n</body>\n</html>\n`
|
||||||
|
: `${head}\n${body}\n`);
|
||||||
|
for (const asset of assets) {
|
||||||
|
mkdirSync(join(OUT, dirname(asset)), { recursive: true });
|
||||||
|
copyFileSync(resolve(asset), join(OUT, asset));
|
||||||
|
}
|
||||||
|
for (const [file, source] of seen) {
|
||||||
|
const rel = relative(ROOT, file);
|
||||||
|
mkdirSync(join(OUT, dirname(rel)), { recursive: true });
|
||||||
|
writeFileSync(join(OUT, rel), source);
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = dir => readdirSync(dir, { withFileTypes: true })
|
||||||
|
.reduce((n, e) => n + (e.isDirectory() ? size(join(dir, e.name)) : readFileSync(join(dir, e.name)).length), 0);
|
||||||
|
const kb = n => (n / 1024).toFixed(0) + ' KB';
|
||||||
|
console.log(`入口 src/app.js → ${seen.size} 个模块`);
|
||||||
|
console.log(`样式 ${sheets.length} 个文件已内联(${kb(cssFixed.length)})`);
|
||||||
|
console.log(`资源 ${assets.size} 个:${[...assets].map(a => `${a.replace('assets/', '')} ${kb(readFileSync(resolve(a)).length)}`).join(' · ')}`);
|
||||||
|
console.log(`产物 ${OUT}:${kb(size(OUT))}(${STANDALONE ? '完整 HTML 文档,适合普通静态托管' : '仅 body 内容,适合 Claude Artifact'})`);
|
||||||
|
const heavy = [...seen].map(([f, s]) => [relative(ROOT, f), s.length]).sort((a, b) => b[1] - a[1]).slice(0, 3);
|
||||||
|
console.log(`最大的模块 ${heavy.map(([f, n]) => `${f} ${kb(n)}`).join(' · ')}`);
|
||||||
Reference in New Issue
Block a user