Files
HexTrails/scripts/build-phone.mjs
T
cabinwandClaude Opus 5 2c01b613bd 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>
2026-09-14 18:32:03 +08:00

114 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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(' · ')}`);