gamekit777 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +48 -0
  2. package/client/http.ts +253 -0
  3. package/client/index.ts +211 -0
  4. package/client/node.ts +61 -0
  5. package/create-game/scaffold.ts +73 -0
  6. package/create-game/template/CLAUDE.md.tmpl +167 -0
  7. package/create-game/template/_gitignore +4 -0
  8. package/create-game/template/game.meta.json.tmpl +8 -0
  9. package/create-game/template/game.ts.tmpl +57 -0
  10. package/create-game/template/index.html.tmpl +11 -0
  11. package/create-game/template/package.json.tmpl +26 -0
  12. package/create-game/template/scripts/verify.ts.tmpl +79 -0
  13. package/create-game/template/src/assets/critical/.gitkeep +0 -0
  14. package/create-game/template/src/assets/lazy/.gitkeep +0 -0
  15. package/create-game/template/src/assets/manifest.ts +30 -0
  16. package/create-game/template/src/assets/registry.ts +23 -0
  17. package/create-game/template/src/components/App.svelte.tmpl +54 -0
  18. package/create-game/template/src/context.svelte.ts.tmpl +22 -0
  19. package/create-game/template/src/dev.ts +8 -0
  20. package/create-game/template/src/index.ts.tmpl +17 -0
  21. package/create-game/template/src/rules/const.ts +9 -0
  22. package/create-game/template/src/rules/restore.ts.tmpl +39 -0
  23. package/create-game/template/src/rules/schema.ts.tmpl +19 -0
  24. package/create-game/template/src/rules/simulate.ts.tmpl +9 -0
  25. package/create-game/template/src/rules/types.ts.tmpl +13 -0
  26. package/create-game/template/src/state/game.svelte.ts.tmpl +45 -0
  27. package/create-game/template/src/styles/animations.css +4 -0
  28. package/create-game/template/src/styles/global.css +18 -0
  29. package/create-game/template/src/view/bridge.svelte.ts +33 -0
  30. package/create-game/template/src/view/mount.svelte.ts.tmpl +86 -0
  31. package/create-game/template/src/view/present.ts.tmpl +36 -0
  32. package/create-game/template/test/e2e.test.ts.tmpl +81 -0
  33. package/create-game/template/tsconfig.json +22 -0
  34. package/create-game/template/vite.config.ts +4 -0
  35. package/create-game/template/vitest.config.ts +10 -0
  36. package/dev-host/DevShell.svelte +348 -0
  37. package/dev-host/Field.svelte +25 -0
  38. package/dev-host/SchemaFields.svelte +14 -0
  39. package/dev-host/SchemaFieldsPure.svelte +98 -0
  40. package/dev-host/context.svelte.ts +9 -0
  41. package/dev-host/fonts.ts +16 -0
  42. package/dev-host/index.ts +8 -0
  43. package/dev-host/local.ts +69 -0
  44. package/dev-host/platform-server.ts +78 -0
  45. package/dev-host/platform.svelte.ts +277 -0
  46. package/dev-host/remote.ts +178 -0
  47. package/dev-host/run.ts +46 -0
  48. package/dev-host/table-server.ts +194 -0
  49. package/dev-host/theme.css +344 -0
  50. package/lut/book.ts +97 -0
  51. package/lut/csv.ts +37 -0
  52. package/lut/format.ts +106 -0
  53. package/lut/index.ts +4 -0
  54. package/lut/verify.ts +243 -0
  55. package/package.json +58 -0
  56. package/protocol/bets.ts +74 -0
  57. package/protocol/errors.ts +90 -0
  58. package/protocol/games.ts +158 -0
  59. package/protocol/index.ts +9 -0
  60. package/protocol/money.ts +51 -0
  61. package/protocol/rounds.ts +60 -0
  62. package/protocol/seeds.ts +36 -0
  63. package/protocol/session.ts +18 -0
  64. package/protocol/tables.ts +120 -0
  65. package/protocol/verify.ts +33 -0
  66. package/publish/build.ts +168 -0
  67. package/publish/index.ts +6 -0
  68. package/publish/parallel.ts +56 -0
  69. package/publish/sample-worker.ts +31 -0
  70. package/publish/sample.ts +131 -0
  71. package/publish/spec.ts +8 -0
  72. package/publish/table.ts +244 -0
  73. package/publish/upload-core.ts +176 -0
  74. package/publish/upload.ts +30 -0
  75. package/runtime/assets.ts +122 -0
  76. package/runtime/fonts.ts +21 -0
  77. package/runtime/index.ts +3 -0
  78. package/runtime/types.ts +42 -0
  79. package/sdk/contract.ts +51 -0
  80. package/sdk/hash.ts +184 -0
  81. package/sdk/host.ts +96 -0
  82. package/sdk/index.ts +9 -0
  83. package/sdk/rng.ts +109 -0
  84. package/sdk/sample.ts +68 -0
  85. package/sdk/schema.ts +90 -0
  86. package/sdk/spec.ts +255 -0
  87. package/sdk/stage.ts +20 -0
  88. package/sdk/store.ts +67 -0
  89. package/studio/app/App.svelte +113 -0
  90. package/studio/app/lib/api.ts +59 -0
  91. package/studio/app/lib/bus.svelte.ts +44 -0
  92. package/studio/app/lib/upload.ts +34 -0
  93. package/studio/app/main.ts +5 -0
  94. package/studio/app/panels/CasePanel.svelte +83 -0
  95. package/studio/app/panels/LogPanel.svelte +19 -0
  96. package/studio/app/panels/PreviewPanel.svelte +33 -0
  97. package/studio/app/panels/PublishPanel.svelte +111 -0
  98. package/studio/app/panels/TablePanel.svelte +64 -0
  99. package/studio/app/panels/TuningPanel.svelte +140 -0
  100. package/studio/app/virtual.d.ts +6 -0
  101. package/studio/bin.ts +68 -0
  102. package/studio/cli.ts +43 -0
  103. package/studio/preview/bridge.ts +63 -0
  104. package/studio/preview/entry.ts +89 -0
  105. package/studio/preview/virtual.d.ts +6 -0
  106. package/studio/src/api.ts +157 -0
  107. package/studio/src/build.ts +166 -0
  108. package/studio/src/bus.ts +98 -0
  109. package/studio/src/canon.ts +18 -0
  110. package/studio/src/cases.ts +255 -0
  111. package/studio/src/config.ts +39 -0
  112. package/studio/src/engine.ts +218 -0
  113. package/studio/src/fingerprint.ts +39 -0
  114. package/studio/src/game-vite.ts +91 -0
  115. package/studio/src/game.ts +173 -0
  116. package/studio/src/jobs.ts +59 -0
  117. package/studio/src/mcp.ts +357 -0
  118. package/studio/src/probe.ts +359 -0
  119. package/studio/src/scaffold.ts +85 -0
  120. package/studio/src/solver.ts +139 -0
  121. package/studio/src/stats.ts +134 -0
  122. package/studio/src/studio-plugin.ts +76 -0
  123. package/studio/src/tasks.ts +52 -0
  124. package/studio/src/worker/pool.ts +84 -0
  125. package/studio/src/worker/rpc.ts +178 -0
  126. package/vite-config/index.js +207 -0
  127. package/vite-config/index.ts +87 -0
  128. package/vite-config/manifest.ts +144 -0
  129. package/vite-config/meta.ts +41 -0
  130. package/vite-config/namespace-css.ts +74 -0
  131. package/weights/index.ts +11 -0
  132. package/weights/linalg.ts +118 -0
  133. package/weights/report.ts +78 -0
  134. package/weights/solve.ts +236 -0
  135. package/weights/types.ts +73 -0
  136. package/weights/volatility.ts +40 -0
@@ -0,0 +1,207 @@
1
+ // dist-npm/vite-config/index.ts
2
+ import path from "node:path";
3
+ import { defineConfig } from "vite";
4
+ import { svelte } from "@sveltejs/vite-plugin-svelte";
5
+
6
+ // dist-npm/vite-config/meta.ts
7
+ var ORIENTATIONS = ["portrait", "landscape", "any"];
8
+ function normalizeMeta(m) {
9
+ for (const k of ["id", "title", "version"]) {
10
+ if (!m[k] || typeof m[k] !== "string")
11
+ throw new Error(`game.meta.json 缺少 ${k}`);
12
+ }
13
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(m.id)) {
14
+ throw new Error(`game.meta.json 的 id "${m.id}" 要是小写短横线形式——它同时是输出目录名`);
15
+ }
16
+ if (m.orientation !== undefined && !ORIENTATIONS.includes(m.orientation)) {
17
+ throw new Error(`game.meta.json 的 orientation "${m.orientation}" 无效,只能是 ${ORIENTATIONS.join(" / ")}`);
18
+ }
19
+ if (m.aspect !== undefined && !/^\d+(\.\d+)?:\d+(\.\d+)?$/.test(m.aspect)) {
20
+ throw new Error(`game.meta.json 的 aspect "${m.aspect}" 无效,形如 "16:10"`);
21
+ }
22
+ return { ...m, orientation: m.orientation };
23
+ }
24
+
25
+ // dist-npm/vite-config/manifest.ts
26
+ var TYPE_BY_EXT = {
27
+ jpg: "image",
28
+ jpeg: "image",
29
+ png: "image",
30
+ webp: "image",
31
+ avif: "image",
32
+ svg: "image",
33
+ gif: "image",
34
+ mp3: "audio",
35
+ ogg: "audio",
36
+ wav: "audio",
37
+ m4a: "audio",
38
+ woff: "font",
39
+ woff2: "font",
40
+ ttf: "font",
41
+ json: "json",
42
+ mp4: "video",
43
+ webm: "video"
44
+ };
45
+ var CRITICAL_BUDGET = 1500000;
46
+ function gameManifest(opts) {
47
+ const { meta, emitCssFile = false } = opts;
48
+ return {
49
+ name: "gamekit:manifest",
50
+ enforce: "post",
51
+ generateBundle: {
52
+ order: "post",
53
+ handler(_o, bundle) {
54
+ const assets = [];
55
+ for (const file of Object.values(bundle)) {
56
+ if (file.type !== "asset")
57
+ continue;
58
+ const a = file;
59
+ if (a.fileName.endsWith(".css"))
60
+ continue;
61
+ const orig = (a.originalFileName ?? "").replace(/\\/g, "/");
62
+ const m = /src\/assets\/(critical|lazy)\/(.+)$/.exec(orig);
63
+ const priority = m?.[1] ?? "critical";
64
+ const key = m ? `${m[1]}/${m[2]}` : a.fileName;
65
+ const ext = a.fileName.split(".").pop().toLowerCase();
66
+ const src = a.source;
67
+ assets.push({
68
+ key,
69
+ path: a.fileName,
70
+ type: TYPE_BY_EXT[ext] ?? "binary",
71
+ bytes: typeof src === "string" ? Buffer.byteLength(src) : src.byteLength,
72
+ priority
73
+ });
74
+ }
75
+ assets.sort((x, y) => x.priority === y.priority ? x.key.localeCompare(y.key) : x.priority === "critical" ? -1 : 1);
76
+ const cssAsset = Object.values(bundle).find((f) => f.type === "asset" && f.fileName.endsWith(".css"));
77
+ const indexJs = bundle["index.js"];
78
+ let cssInlined = false;
79
+ if (cssAsset && indexJs && !emitCssFile) {
80
+ const css = String(cssAsset.source);
81
+ if (/url\(\s*['"]?(?!data:|#)/.test(css)) {
82
+ this.warn(`[${meta.id}] CSS 里有 url(),内联注入后会相对 document 解析而错位。` + `改用 <img> 引用资源,或给 defineGameConfig 传 emitCssFile: true`);
83
+ }
84
+ indexJs.code = `(function(){var s=document.createElement('style');` + `s.setAttribute('data-game',${JSON.stringify(meta.id)});` + `s.textContent=${JSON.stringify(css)};` + `document.head.appendChild(s)})();
85
+ ` + indexJs.code;
86
+ delete bundle[cssAsset.fileName];
87
+ cssInlined = true;
88
+ }
89
+ const manifest = {
90
+ id: meta.id,
91
+ version: meta.version,
92
+ generatedAt: new Date().toISOString(),
93
+ entry: "index.js",
94
+ css: cssInlined ? null : cssAsset?.fileName ?? null,
95
+ totalBytes: assets.reduce((s, a) => s + a.bytes, 0),
96
+ criticalBytes: assets.filter((a) => a.priority === "critical").reduce((s, a) => s + a.bytes, 0),
97
+ assets
98
+ };
99
+ this.emitFile({ type: "asset", fileName: "manifest.json", source: JSON.stringify(manifest, null, 2) });
100
+ this.emitFile({ type: "asset", fileName: "meta.json", source: JSON.stringify(meta, null, 2) });
101
+ if (!cssAsset && !emitCssFile) {
102
+ this.warn(`[${meta.id}] 没找到 CSS 产物。要么游戏真的没样式,` + `要么这个插件跑得太早(应当 enforce:'post' + order:'post')`);
103
+ }
104
+ if (manifest.criticalBytes > CRITICAL_BUDGET) {
105
+ this.warn(`[${meta.id}] critical 资源 ${(manifest.criticalBytes / 1e6).toFixed(2)}MB,` + `超过 ${(CRITICAL_BUDGET / 1e6).toFixed(1)}MB 预算,开场会卡`);
106
+ }
107
+ if (indexJs) {
108
+ const inlined = indexJs.code.match(/data:(?:image|audio|video|font)\/[a-z0-9+.-]+;base64,/gi);
109
+ if (inlined?.length) {
110
+ this.warn(`[${meta.id}] index.js 里有 ${inlined.length} 个 base64 内联资源——` + `八成是某处 import 漏了 ?no-inline(lib 模式下 assetsInlineLimit 不起作用)`);
111
+ }
112
+ }
113
+ }
114
+ }
115
+ };
116
+ }
117
+
118
+ // dist-npm/vite-config/namespace-css.ts
119
+ import postcss from "postcss";
120
+ function namespaceCss(opts) {
121
+ const ns = opts.gameId.replace(/[^a-z0-9]+/gi, "-");
122
+ const scope = opts.scope ?? `.gk-${ns}`;
123
+ const include = opts.include ?? /\/src\/styles\/[^/]*\.css(\?|$)/;
124
+ return {
125
+ name: "gamekit:namespace-css",
126
+ enforce: "pre",
127
+ transform(code, id) {
128
+ if (!include.test(id))
129
+ return null;
130
+ const root = postcss.parse(code, { from: id });
131
+ const renamed = new Set;
132
+ root.walkAtRules(/^(-\w+-)?keyframes$/, (r) => {
133
+ renamed.add(r.params);
134
+ r.params = `${r.params}-${ns}`;
135
+ });
136
+ if (renamed.size) {
137
+ root.walkDecls(/^(-\w+-)?animation(-name)?$/, (d) => {
138
+ for (const name of renamed) {
139
+ d.value = d.value.replace(new RegExp(`(^|[\\s,])${escapeRe(name)}(?=$|[\\s,])`, "g"), `$1${name}-${ns}`);
140
+ }
141
+ });
142
+ }
143
+ root.walkRules((rule) => {
144
+ const parent = rule.parent;
145
+ if (parent?.type === "atrule" && /keyframes$/.test(parent.name))
146
+ return;
147
+ rule.selectors = rule.selectors.map((sel) => {
148
+ const s = sel.trim();
149
+ if (s === ":root" || s === "html" || s === "body")
150
+ return scope;
151
+ if (s.startsWith(":root"))
152
+ return scope + s.slice(":root".length);
153
+ if (s.startsWith("html") || s.startsWith("body"))
154
+ return scope + s.slice(4);
155
+ if (s.startsWith(scope))
156
+ return s;
157
+ return `${scope} ${s}`;
158
+ });
159
+ });
160
+ return { code: root.toString(), map: null };
161
+ }
162
+ };
163
+ }
164
+ var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
165
+
166
+ // dist-npm/vite-config/index.ts
167
+ function defineGameConfig(opts) {
168
+ const meta = normalizeMeta(opts.meta);
169
+ const { shareRuntime = false, emitCssFile = false } = opts;
170
+ const root = opts.root ?? process.cwd();
171
+ const entry = path.resolve(root, opts.entry ?? "src/index.ts");
172
+ return defineConfig({
173
+ root,
174
+ optimizeDeps: { exclude: ["gamekit777"] },
175
+ resolve: { dedupe: ["svelte"] },
176
+ base: "./",
177
+ plugins: [
178
+ svelte(),
179
+ namespaceCss({ gameId: meta.id }),
180
+ gameManifest({ meta, emitCssFile }),
181
+ ...opts.plugins ?? []
182
+ ],
183
+ build: {
184
+ target: "es2022",
185
+ outDir: path.resolve(root, "../../dist/games", meta.id),
186
+ emptyOutDir: true,
187
+ sourcemap: false,
188
+ lib: {
189
+ entry,
190
+ formats: ["es"],
191
+ fileName: () => "index.js",
192
+ cssFileName: "style"
193
+ },
194
+ rollupOptions: {
195
+ external: shareRuntime ? ["svelte", /^svelte\//] : [],
196
+ output: {
197
+ assetFileNames: "assets/[name]-[hash][extname]",
198
+ chunkFileNames: "chunks/[name]-[hash].js",
199
+ inlineDynamicImports: true
200
+ }
201
+ }
202
+ }
203
+ });
204
+ }
205
+ export {
206
+ defineGameConfig
207
+ };
@@ -0,0 +1,87 @@
1
+ /* 游戏包的构建预设。每个游戏的 vite.config.ts 只需要三行:
2
+ import { defineGameConfig } from './index.ts';
3
+ import meta from './game.meta.json' with { type: 'json' };
4
+ export default defineGameConfig({ meta });
5
+
6
+ 只产出 index.js 一个入口——视图和逻辑内联在一起。
7
+ 曾经还单独打过一个零 DOM 的 logic.js 给服务端算 payout,但平台改成查表开奖、
8
+ 服务端零执行之后它就没有消费者了:采样在作者机器上跑源码,渲染在客户端。
9
+ 纯度(不碰 DOM、不自取随机)仍然是硬要求,但改由每个游戏 scripts/verify.ts 的
10
+ 运行时桩来保证——那个查的是实际执行路径含全部传递依赖,比对产物 grep 强。 */
11
+ import path from 'node:path';
12
+ import { defineConfig, type PluginOption, type UserConfig } from 'vite';
13
+ import { svelte } from '@sveltejs/vite-plugin-svelte';
14
+ import { normalizeMeta, type GameBuildMetaInput } from './meta.ts';
15
+ import { gameManifest } from './manifest.ts';
16
+ import { namespaceCss } from './namespace-css.ts';
17
+
18
+ export type { GameBuildMeta, GameBuildMetaInput } from './meta.ts';
19
+
20
+ export interface GameConfigOptions {
21
+ meta: GameBuildMetaInput;
22
+ root?: string;
23
+ /** 入口,默认 src/index.ts */
24
+ entry?: string;
25
+ /**
26
+ * true = svelte runtime 交给平台的 import map 提供。
27
+ * 默认 false:每个游戏各带一份约 15KB gzip,换来「文件夹自包含、
28
+ * 各游戏 Svelte 版本独立升级」。同页要跑二十个小游戏时才值得打开。
29
+ */
30
+ shareRuntime?: boolean;
31
+ /** true = 单独产出 style.css 交给平台加载;默认内联进 index.js */
32
+ emitCssFile?: boolean;
33
+ plugins?: PluginOption[];
34
+ }
35
+
36
+ export function defineGameConfig(opts: GameConfigOptions): UserConfig {
37
+ const meta = normalizeMeta(opts.meta);
38
+ const { shareRuntime = false, emitCssFile = false } = opts;
39
+ const root = opts.root ?? process.cwd();
40
+ const entry = path.resolve(root, opts.entry ?? 'src/index.ts');
41
+
42
+ return defineConfig({
43
+ root,
44
+ /* 创作者机器上 gamekit777 是 node_modules 里的真目录,vite 会用 esbuild 预构建它——
45
+ 而 dev-host 的 *.svelte.ts 是 runes 模块,必须过 svelte 插件,esbuild 一碰就炸。
46
+ 仓库内它是 workspace 符号链接,vite 当源码处理,所以这条从没在仓库里暴露过。
47
+ dedupe 是另一半:预览壳和游戏各自 import 的 svelte 必须是同一份实例,否则 runes 静默失灵 */
48
+ optimizeDeps: { exclude: ['gamekit777'] },
49
+ resolve: { dedupe: ['svelte'] },
50
+ /* 这一行就是 import.meta.url 方案本身:Vite 在 es 格式下会把资源引用编译成
51
+ new URL('./assets/xxx-hash.jpg', import.meta.url).href,
52
+ 于是游戏文件夹放在任意路径(CDN 子目录)都能正确解析。不要手写 new URL */
53
+ base: './',
54
+ plugins: [
55
+ svelte(),
56
+ namespaceCss({ gameId: meta.id }),
57
+ gameManifest({ meta, emitCssFile }),
58
+ ...(opts.plugins ?? [])
59
+ ],
60
+ build: {
61
+ target: 'es2022',
62
+ outDir: path.resolve(root, '../../dist/games', meta.id),
63
+ emptyOutDir: true,
64
+ sourcemap: false,
65
+ // cssCodeSplit 不用写:lib 模式会自动关掉
66
+ lib: {
67
+ entry,
68
+ formats: ['es'],
69
+ fileName: () => 'index.js',
70
+ cssFileName: 'style'
71
+ },
72
+ rollupOptions: {
73
+ external: shareRuntime ? ['svelte', /^svelte\//] : [],
74
+ output: {
75
+ /* 必须覆盖:lib 模式的默认值是 '[name].[ext]',既没有 hash 也没有子目录 */
76
+ assetFileNames: 'assets/[name]-[hash][extname]',
77
+ chunkFileNames: 'chunks/[name]-[hash].js',
78
+ // 只出一个文件,平台不用管 chunk 路径
79
+ inlineDynamicImports: true
80
+ }
81
+ }
82
+ },
83
+ /* 刻意不注入 __GAME_ID__ 之类的全局:那种只在构建下成立的东西
84
+ 会让同一份源码在测试、dev、SSR 里行为不一致。
85
+ 要用元数据就直接 import game.meta.json */
86
+ });
87
+ }
@@ -0,0 +1,144 @@
1
+ /* 构建产物清单。
2
+
3
+ 为什么要插件而不是只用 import.meta.glob:两者的消费者不同。
4
+ glob 给游戏自己的 JS 用,它天然拿到带 hash 的正确 URL;
5
+ manifest.json 给**平台**用——平台要在 import() 游戏之前就知道有哪些资源、
6
+ 多大、什么优先级,才能发 preload 把下载和 JS 解析并行起来。
7
+
8
+ 插件不扫源码,只读已经产好的 bundle:它看到的就是最终真实产物。 */
9
+ import type { Plugin, Rollup } from 'vite';
10
+ import type { GameBuildMeta } from './meta.ts';
11
+
12
+ export type AssetType = 'image' | 'audio' | 'font' | 'json' | 'video' | 'binary';
13
+
14
+ const TYPE_BY_EXT: Record<string, AssetType> = {
15
+ jpg: 'image', jpeg: 'image', png: 'image', webp: 'image', avif: 'image', svg: 'image', gif: 'image',
16
+ mp3: 'audio', ogg: 'audio', wav: 'audio', m4a: 'audio',
17
+ woff: 'font', woff2: 'font', ttf: 'font',
18
+ json: 'json', mp4: 'video', webm: 'video'
19
+ };
20
+
21
+ export interface AssetEntry {
22
+ /** 源码里稳定的名字,如 'critical/coco-classic.jpg' */
23
+ key: string;
24
+ /** 产物相对路径,带 hash */
25
+ path: string;
26
+ type: AssetType;
27
+ bytes: number;
28
+ priority: 'critical' | 'lazy';
29
+ }
30
+
31
+ export interface GameManifest {
32
+ id: string;
33
+ version: string;
34
+ generatedAt: string;
35
+ entry: string;
36
+ /** 独立产出时是文件名;内联进 index.js 时是 null */
37
+ css: string | null;
38
+ totalBytes: number;
39
+ criticalBytes: number;
40
+ assets: AssetEntry[];
41
+ }
42
+
43
+ /** critical 资源的预算。超了就告警——批量做游戏时这条比 manifest 本身还有用 */
44
+ const CRITICAL_BUDGET = 1_500_000;
45
+
46
+ export function gameManifest(opts: { meta: GameBuildMeta; emitCssFile?: boolean }): Plugin {
47
+ const { meta, emitCssFile = false } = opts;
48
+
49
+ return {
50
+ name: 'gamekit:manifest',
51
+ /* 必须最后跑。vite 内部的 css-post 插件也是在 generateBundle 里才把 style.css
52
+ 产出来的,早一步就看不见它——CSS 会静静留在 assets/ 下而不是内联进 index.js,
53
+ 而且不会有任何报错 */
54
+ enforce: 'post',
55
+ generateBundle: {
56
+ order: 'post',
57
+ handler(_o, bundle) {
58
+ const assets: AssetEntry[] = [];
59
+
60
+ for (const file of Object.values(bundle)) {
61
+ if (file.type !== 'asset') continue;
62
+ const a = file as Rollup.OutputAsset;
63
+ if (a.fileName.endsWith('.css')) continue;
64
+
65
+ const orig = (a.originalFileName ?? '').replace(/\\/g, '/');
66
+ const m = /src\/assets\/(critical|lazy)\/(.+)$/.exec(orig);
67
+ const priority = (m?.[1] ?? 'critical') as 'critical' | 'lazy';
68
+ const key = m ? `${m[1]}/${m[2]}` : a.fileName;
69
+
70
+ const ext = a.fileName.split('.').pop()!.toLowerCase();
71
+ const src = a.source;
72
+ assets.push({
73
+ key, path: a.fileName,
74
+ type: TYPE_BY_EXT[ext] ?? 'binary',
75
+ bytes: typeof src === 'string' ? Buffer.byteLength(src) : src.byteLength,
76
+ priority
77
+ });
78
+ }
79
+
80
+ assets.sort((x, y) =>
81
+ x.priority === y.priority ? x.key.localeCompare(y.key) : x.priority === 'critical' ? -1 : 1);
82
+
83
+ /* CSS 内联进 index.js 而不是留给平台加载:少一个网络往返、少一个平台必须
84
+ 遵守的约定,也没有「JS 到了 CSS 没到」的闪屏窗口。
85
+ 代价是 CSS 里的 url() 会相对 document 解析而不是相对 CSS 文件——所以要扫一遍 */
86
+ const cssAsset = Object.values(bundle)
87
+ .find(f => f.type === 'asset' && f.fileName.endsWith('.css')) as Rollup.OutputAsset | undefined;
88
+ const indexJs = bundle['index.js'] as Rollup.OutputChunk | undefined;
89
+ let cssInlined = false;
90
+
91
+ if (cssAsset && indexJs && !emitCssFile) {
92
+ const css = String(cssAsset.source);
93
+ if (/url\(\s*['"]?(?!data:|#)/.test(css)) {
94
+ this.warn(`[${meta.id}] CSS 里有 url(),内联注入后会相对 document 解析而错位。`
95
+ + `改用 <img> 引用资源,或给 defineGameConfig 传 emitCssFile: true`);
96
+ }
97
+ indexJs.code =
98
+ `(function(){var s=document.createElement('style');`
99
+ + `s.setAttribute('data-game',${JSON.stringify(meta.id)});`
100
+ + `s.textContent=${JSON.stringify(css)};`
101
+ + `document.head.appendChild(s)})();\n` + indexJs.code;
102
+ delete bundle[cssAsset.fileName];
103
+ cssInlined = true;
104
+ }
105
+
106
+ const manifest: GameManifest = {
107
+ id: meta.id,
108
+ version: meta.version,
109
+ generatedAt: new Date().toISOString(),
110
+ entry: 'index.js',
111
+ css: cssInlined ? null : (cssAsset?.fileName ?? null),
112
+ totalBytes: assets.reduce((s, a) => s + a.bytes, 0),
113
+ criticalBytes: assets.filter(a => a.priority === 'critical').reduce((s, a) => s + a.bytes, 0),
114
+ assets
115
+ };
116
+
117
+ this.emitFile({ type: 'asset', fileName: 'manifest.json', source: JSON.stringify(manifest, null, 2) });
118
+ this.emitFile({ type: 'asset', fileName: 'meta.json', source: JSON.stringify(meta, null, 2) });
119
+
120
+ /* ---- 构建期体检 ---- */
121
+
122
+ if (!cssAsset && !emitCssFile) {
123
+ this.warn(`[${meta.id}] 没找到 CSS 产物。要么游戏真的没样式,`
124
+ + `要么这个插件跑得太早(应当 enforce:'post' + order:'post')`);
125
+ }
126
+ if (manifest.criticalBytes > CRITICAL_BUDGET) {
127
+ this.warn(`[${meta.id}] critical 资源 ${(manifest.criticalBytes / 1e6).toFixed(2)}MB,`
128
+ + `超过 ${(CRITICAL_BUDGET / 1e6).toFixed(1)}MB 预算,开场会卡`);
129
+ }
130
+ /* lib 模式会无条件把资源 base64 内联进 JS(vite 源码 config.js 的 shouldInline:
131
+ build.lib 为真时直接 return true,assetsInlineLimit 完全失效),
132
+ 唯一逃生口是给 import 加 ?no-inline。漏掉时构建照样成功,资源只是悄悄
133
+ 胖进 index.js、不在 manifest 里、平台预加载不到——必须把静默失败变成响的 */
134
+ if (indexJs) {
135
+ const inlined = indexJs.code.match(/data:(?:image|audio|video|font)\/[a-z0-9+.-]+;base64,/gi);
136
+ if (inlined?.length) {
137
+ this.warn(`[${meta.id}] index.js 里有 ${inlined.length} 个 base64 内联资源——`
138
+ + `八成是某处 import 漏了 ?no-inline(lib 模式下 assetsInlineLimit 不起作用)`);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ };
144
+ }
@@ -0,0 +1,41 @@
1
+ /** game.meta.json 的形状。它是每个游戏的单一真值源,构建时原样抄进 dist/meta.json */
2
+ export interface GameBuildMeta {
3
+ id: string;
4
+ title: string;
5
+ version: string;
6
+ /** 舞台建议宽高比,平台排版用 */
7
+ aspect?: string;
8
+ orientation?: 'portrait' | 'landscape' | 'any';
9
+ /** 需要的文档级字体 URL。平台可以提前 preconnect */
10
+ fonts?: string[];
11
+ }
12
+
13
+ /** 从 JSON import 进来的原始形状——JSON 的字面量类型会被放宽成 string,
14
+ 所以这里接得松一点,由 normalizeMeta 收窄并校验 */
15
+ export interface GameBuildMetaInput {
16
+ id: string;
17
+ title: string;
18
+ version: string;
19
+ aspect?: string;
20
+ orientation?: string;
21
+ fonts?: string[];
22
+ }
23
+
24
+ const ORIENTATIONS = ['portrait', 'landscape', 'any'] as const;
25
+
26
+ /** 校验并收窄。game.meta.json 里写错值会在构建期炸,而不是等平台排版出问题才发现 */
27
+ export function normalizeMeta(m: GameBuildMetaInput): GameBuildMeta {
28
+ for (const k of ['id', 'title', 'version'] as const) {
29
+ if (!m[k] || typeof m[k] !== 'string') throw new Error(`game.meta.json 缺少 ${k}`);
30
+ }
31
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(m.id)) {
32
+ throw new Error(`game.meta.json 的 id "${m.id}" 要是小写短横线形式——它同时是输出目录名`);
33
+ }
34
+ if (m.orientation !== undefined && !ORIENTATIONS.includes(m.orientation as never)) {
35
+ throw new Error(`game.meta.json 的 orientation "${m.orientation}" 无效,只能是 ${ORIENTATIONS.join(' / ')}`);
36
+ }
37
+ if (m.aspect !== undefined && !/^\d+(\.\d+)?:\d+(\.\d+)?$/.test(m.aspect)) {
38
+ throw new Error(`game.meta.json 的 aspect "${m.aspect}" 无效,形如 "16:10"`);
39
+ }
40
+ return { ...m, orientation: m.orientation as GameBuildMeta['orientation'] };
41
+ }
@@ -0,0 +1,74 @@
1
+ /* 给游戏的全局 CSS 加命名空间。
2
+
3
+ 只处理 src/styles/ 下的独立 .css 文件——Svelte 组件的 <style> 已经是
4
+ scoped 的,编译器还会自动重命名它们内部声明的 @keyframes。
5
+ 逃过重命名的正是这些独立文件里的:coco 的 animations.css 有 14 个
6
+ @keyframes,名字是 pop / spin / flash / draw / tick / fly / want 这一类。
7
+
8
+ @keyframes 是全局命名空间、后定义覆盖先定义,所以同页两个游戏会**静默错乱**:
9
+ 不报错,只是动画播成了别人的。批量做游戏时这几乎必然发生。
10
+
11
+ 顺带把 :root / html / body 收敛到游戏根元素,否则游戏的页面底色会涂到整个平台上。 */
12
+ import type { Plugin } from 'vite';
13
+ import postcss from 'postcss';
14
+
15
+ export interface NamespaceCssOptions {
16
+ gameId: string;
17
+ /** 命中哪些文件。默认 src/styles/ 下的 .css */
18
+ include?: RegExp;
19
+ /** 游戏根元素的 class。默认 gk-<gameId> */
20
+ scope?: string;
21
+ }
22
+
23
+ export function namespaceCss(opts: NamespaceCssOptions): Plugin {
24
+ const ns = opts.gameId.replace(/[^a-z0-9]+/gi, '-');
25
+ const scope = opts.scope ?? `.gk-${ns}`;
26
+ const include = opts.include ?? /\/src\/styles\/[^/]*\.css(\?|$)/;
27
+
28
+ return {
29
+ name: 'gamekit:namespace-css',
30
+ enforce: 'pre',
31
+ transform(code, id) {
32
+ if (!include.test(id)) return null;
33
+
34
+ const root = postcss.parse(code, { from: id });
35
+ const renamed = new Set<string>();
36
+
37
+ // 1. @keyframes 改名
38
+ root.walkAtRules(/^(-\w+-)?keyframes$/, r => {
39
+ renamed.add(r.params);
40
+ r.params = `${r.params}-${ns}`;
41
+ });
42
+
43
+ // 2. animation / animation-name 里的引用同步改名
44
+ if (renamed.size) {
45
+ root.walkDecls(/^(-\w+-)?animation(-name)?$/, d => {
46
+ for (const name of renamed) {
47
+ d.value = d.value.replace(
48
+ new RegExp(`(^|[\\s,])${escapeRe(name)}(?=$|[\\s,])`, 'g'), `$1${name}-${ns}`);
49
+ }
50
+ });
51
+ }
52
+
53
+ // 3. 选择器收敛到游戏根元素
54
+ root.walkRules(rule => {
55
+ const parent = rule.parent;
56
+ // @keyframes 里的 0% / from / to 不是选择器,不要动
57
+ if (parent?.type === 'atrule' && /keyframes$/.test((parent as postcss.AtRule).name)) return;
58
+
59
+ rule.selectors = rule.selectors.map(sel => {
60
+ const s = sel.trim();
61
+ if (s === ':root' || s === 'html' || s === 'body') return scope;
62
+ if (s.startsWith(':root')) return scope + s.slice(':root'.length);
63
+ if (s.startsWith('html') || s.startsWith('body')) return scope + s.slice(4);
64
+ if (s.startsWith(scope)) return s; // 已经带了就别叠
65
+ return `${scope} ${s}`;
66
+ });
67
+ });
68
+
69
+ return { code: root.toString(), map: null };
70
+ }
71
+ };
72
+ }
73
+
74
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -0,0 +1,11 @@
1
+ export type {
2
+ Constraint,
3
+ Group,
4
+ Problem,
5
+ Segment,
6
+ SolveOptions,
7
+ SolveResult,
8
+ } from './types.ts';
9
+ export { buildConstraints, solve } from './solve.ts';
10
+ export { report, type Report, type SegmentReport } from './report.ts';
11
+ export { pickMedian } from './volatility.ts';
@@ -0,0 +1,118 @@
1
+ /* 只够用的稠密线性代数。矩阵规模是「约束条数」,通常 5~15,
2
+ 所以怎么写都快,优先要的是数值上稳。 */
3
+
4
+ /** 行主序的 n×n 对称正定矩阵,就地 Cholesky 分解。失败返回 false */
5
+ export function cholesky(a: Float64Array, n: number): boolean {
6
+ for (let i = 0; i < n; i++) {
7
+ for (let j = 0; j <= i; j++) {
8
+ let sum = a[i * n + j]!;
9
+ for (let k = 0; k < j; k++) sum -= a[i * n + k]! * a[j * n + k]!;
10
+ if (i === j) {
11
+ if (!(sum > 0)) return false;
12
+ a[i * n + i] = Math.sqrt(sum);
13
+ } else {
14
+ a[i * n + j] = sum / a[j * n + j]!;
15
+ }
16
+ }
17
+ for (let j = i + 1; j < n; j++) a[i * n + j] = 0;
18
+ }
19
+ return true;
20
+ }
21
+
22
+ /** 用已分解的下三角 L 解 L Lᵀ x = b,就地改写 b */
23
+ export function choleskySolve(l: Float64Array, n: number, b: Float64Array): void {
24
+ for (let i = 0; i < n; i++) {
25
+ let sum = b[i]!;
26
+ for (let k = 0; k < i; k++) sum -= l[i * n + k]! * b[k]!;
27
+ b[i] = sum / l[i * n + i]!;
28
+ }
29
+ for (let i = n - 1; i >= 0; i--) {
30
+ let sum = b[i]!;
31
+ for (let k = i + 1; k < n; k++) sum -= l[k * n + i]! * b[k]!;
32
+ b[i] = sum / l[i * n + i]!;
33
+ }
34
+ }
35
+
36
+ /** 解对称正定系统,必要时加岭正则。返回解,或 null 表示怎么加都解不动 */
37
+ export function solveSpd(h: Float64Array, n: number, rhs: Float64Array): Float64Array | null {
38
+ let scale = 0;
39
+ for (let i = 0; i < n; i++) scale = Math.max(scale, Math.abs(h[i * n + i]!));
40
+ if (scale === 0) scale = 1;
41
+
42
+ for (let attempt = 0; attempt < 12; attempt++) {
43
+ const a = Float64Array.from(h);
44
+ if (attempt > 0) {
45
+ const ridge = scale * 1e-12 * 10 ** attempt;
46
+ for (let i = 0; i < n; i++) a[i * n + i] += ridge;
47
+ }
48
+ if (!cholesky(a, n)) continue;
49
+ const x = Float64Array.from(rhs);
50
+ choleskySolve(a, n, x);
51
+ let finite = true;
52
+ for (let i = 0; i < n; i++) if (!Number.isFinite(x[i]!)) finite = false;
53
+ if (finite) return x;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ export interface RowReduction {
59
+ /** 保留下来的行下标,彼此线性无关 */
60
+ keep: number[];
61
+ /** 与已保留行线性相关、且右端项一致的行 */
62
+ redundant: number[];
63
+ /** 与已保留行线性相关、但右端项对不上的行——这意味着约束互斥 */
64
+ inconsistent: number[];
65
+ }
66
+
67
+ /**
68
+ * 对约束矩阵做带主元的 Gram-Schmidt,挑出一组线性无关的行。
69
+ *
70
+ * 目的不只是让 Hessian 非奇异:一条「线性相关但右端项对不上」的行,
71
+ * 恰好就是无解的证据,且能直接指出是哪两条约束在打架。
72
+ */
73
+ export function reduceRows(
74
+ rows: readonly Float64Array[],
75
+ targets: readonly number[],
76
+ tol = 1e-10,
77
+ ): RowReduction {
78
+ const basis: Float64Array[] = [];
79
+ const basisTarget: number[] = [];
80
+ const keep: number[] = [];
81
+ const redundant: number[] = [];
82
+ const inconsistent: number[] = [];
83
+
84
+ for (let r = 0; r < rows.length; r++) {
85
+ const v = Float64Array.from(rows[r]!);
86
+ let t = targets[r]!;
87
+ let norm0 = 0;
88
+ for (let i = 0; i < v.length; i++) norm0 += v[i]! * v[i]!;
89
+ norm0 = Math.sqrt(norm0);
90
+
91
+ for (let b = 0; b < basis.length; b++) {
92
+ const e = basis[b]!;
93
+ let dot = 0;
94
+ for (let i = 0; i < v.length; i++) dot += v[i]! * e[i]!;
95
+ if (dot === 0) continue;
96
+ for (let i = 0; i < v.length; i++) v[i] -= dot * e[i]!;
97
+ t -= dot * basisTarget[b]!;
98
+ }
99
+
100
+ let norm = 0;
101
+ for (let i = 0; i < v.length; i++) norm += v[i]! * v[i]!;
102
+ norm = Math.sqrt(norm);
103
+
104
+ if (norm <= tol * Math.max(1, norm0)) {
105
+ const slack = Math.abs(t);
106
+ if (slack <= tol * Math.max(1, Math.abs(targets[r]!))) redundant.push(r);
107
+ else inconsistent.push(r);
108
+ continue;
109
+ }
110
+
111
+ for (let i = 0; i < v.length; i++) v[i] /= norm;
112
+ basis.push(v);
113
+ basisTarget.push(t / norm);
114
+ keep.push(r);
115
+ }
116
+
117
+ return { keep, redundant, inconsistent };
118
+ }