diff --git a/.gitignore b/.gitignore index 5e1d621..94bccfd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ bench/report.json bench/hex-bench/ bench/hex-bench.zip bench/pack/ + +# Phone bundle: regenerated by scripts/build-phone.mjs +/build/ diff --git a/scripts/build-phone.mjs b/scripts/build-phone.mjs new file mode 100644 index 0000000..1fd3357 --- /dev/null +++ b/scripts/build-phone.mjs @@ -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 , ,
and +// 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 `; +// 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 + ? `\n\n\n\n` + + `\n` + + `\n${head}\n\n\n${body}\n\n\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(' · ')}`);