gamekit777 0.1.2 → 0.1.5

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 (55) hide show
  1. package/README.md +1 -1
  2. package/client/http.ts +1 -1
  3. package/client/index.ts +26 -0
  4. package/create-game/template/CLAUDE.md.tmpl +38 -39
  5. package/create-game/template/src/components/App.svelte.tmpl +25 -7
  6. package/create-game/template/src/index.ts.tmpl +5 -3
  7. package/create-game/template/src/state/game.svelte.ts.tmpl +2 -0
  8. package/create-game/template/src/styles/animations.css +10 -1
  9. package/create-game/template/src/view/mount.svelte.ts.tmpl +1 -22
  10. package/create-game/template/src/view/present.ts.tmpl +9 -0
  11. package/create-game/template/vitest.config.ts +1 -1
  12. package/dev-host/DevShell.svelte +23 -2
  13. package/dev-host/platform.svelte.ts +4 -1
  14. package/dev-host/theme.css +5 -3
  15. package/package.json +1 -1
  16. package/protocol/assets.ts +108 -0
  17. package/protocol/bundles.ts +74 -0
  18. package/protocol/errors.ts +6 -0
  19. package/protocol/games.ts +5 -0
  20. package/protocol/index.ts +2 -0
  21. package/publish/upload-core.ts +95 -15
  22. package/publish/upload.ts +17 -1
  23. package/runtime/assets.ts +19 -15
  24. package/runtime/fonts.ts +1 -0
  25. package/runtime/index.ts +2 -0
  26. package/runtime/preload.ts +38 -0
  27. package/runtime/registry.ts +74 -0
  28. package/runtime/types.ts +3 -25
  29. package/sdk/assets.ts +24 -0
  30. package/sdk/host.ts +0 -2
  31. package/sdk/index.ts +1 -0
  32. package/sdk/spec.ts +3 -0
  33. package/studio/app/App.svelte +43 -14
  34. package/studio/app/lib/api.ts +11 -3
  35. package/studio/app/lib/bus.svelte.ts +2 -0
  36. package/studio/app/lib/identity.svelte.ts +4 -1
  37. package/studio/app/panels/AssetsPanel.svelte +239 -0
  38. package/studio/app/panels/PublishPanel.svelte +44 -51
  39. package/studio/bin.ts +2 -0
  40. package/studio/src/api.ts +20 -7
  41. package/studio/src/assets.ts +160 -0
  42. package/studio/src/bus.ts +2 -1
  43. package/studio/src/engine.ts +237 -18
  44. package/studio/src/game-vite.ts +11 -2
  45. package/studio/src/mcp.ts +68 -28
  46. package/studio/src/session.ts +37 -0
  47. package/studio/src/studio-plugin.ts +16 -0
  48. package/studio/src/tasks.ts +4 -1
  49. package/vite-config/index.js +160 -35
  50. package/vite-config/index.ts +5 -1
  51. package/vite-config/manifest.ts +16 -2
  52. package/vite-config/namespace-css.ts +130 -43
  53. package/create-game/template/src/assets/manifest.ts +0 -30
  54. package/create-game/template/src/assets/registry.ts +0 -23
  55. package/studio/app/lib/upload.ts +0 -34
@@ -1,5 +1,5 @@
1
1
  // dist-npm/vite-config/index.ts
2
- import path from "node:path";
2
+ import path2 from "node:path";
3
3
  import { defineConfig } from "vite";
4
4
  import { svelte } from "@sveltejs/vite-plugin-svelte";
5
5
 
@@ -43,6 +43,13 @@ var TYPE_BY_EXT = {
43
43
  webm: "video"
44
44
  };
45
45
  var CRITICAL_BUDGET = 1500000;
46
+ var BYTES_PLACEHOLDER = "__GAMEKIT_ASSET_BYTES__";
47
+ function injectAssetBytes(code, bytes) {
48
+ if (!code.includes(BYTES_PLACEHOLDER))
49
+ return code;
50
+ const literal = JSON.stringify(bytes).replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
51
+ return code.split(BYTES_PLACEHOLDER).join(literal);
52
+ }
46
53
  function gameManifest(opts) {
47
54
  const { meta, emitCssFile = false } = opts;
48
55
  return {
@@ -76,6 +83,9 @@ function gameManifest(opts) {
76
83
  const cssAsset = Object.values(bundle).find((f) => f.type === "asset" && f.fileName.endsWith(".css"));
77
84
  const indexJs = bundle["index.js"];
78
85
  let cssInlined = false;
86
+ if (indexJs) {
87
+ indexJs.code = injectAssetBytes(indexJs.code, Object.fromEntries(assets.map((a) => [a.key, a.bytes])));
88
+ }
79
89
  if (cssAsset && indexJs && !emitCssFile) {
80
90
  const css = String(cssAsset.source);
81
91
  if (/url\(\s*['"]?(?!data:|#)/.test(css)) {
@@ -89,7 +99,6 @@ function gameManifest(opts) {
89
99
  const manifest = {
90
100
  id: meta.id,
91
101
  version: meta.version,
92
- generatedAt: new Date().toISOString(),
93
102
  entry: "index.js",
94
103
  css: cssInlined ? null : cssAsset?.fileName ?? null,
95
104
  totalBytes: assets.reduce((s, a) => s + a.bytes, 0),
@@ -116,63 +125,179 @@ function gameManifest(opts) {
116
125
  }
117
126
 
118
127
  // dist-npm/vite-config/namespace-css.ts
128
+ import { readdirSync, readFileSync, statSync } from "node:fs";
129
+ import path from "node:path";
119
130
  import postcss from "postcss";
131
+ var KEYWORDS = new Set([
132
+ "none",
133
+ "initial",
134
+ "inherit",
135
+ "unset",
136
+ "revert",
137
+ "revert-layer",
138
+ "auto",
139
+ "infinite",
140
+ "normal",
141
+ "reverse",
142
+ "alternate",
143
+ "alternate-reverse",
144
+ "forwards",
145
+ "backwards",
146
+ "both",
147
+ "running",
148
+ "paused",
149
+ "ease",
150
+ "ease-in",
151
+ "ease-out",
152
+ "ease-in-out",
153
+ "linear",
154
+ "step-start",
155
+ "step-end"
156
+ ]);
157
+ var IDENT = /^-?[a-zA-Z_][\w-]*$/;
158
+ var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
+ function renameRefs(root, names, rename) {
160
+ if (names.size === 0)
161
+ return;
162
+ root.walkDecls(/^(-\w+-)?animation(-name)?$/, (d) => {
163
+ for (const name of names) {
164
+ d.value = d.value.replace(new RegExp(`(^|[\\s,])${escapeRe(name)}(?=$|[\\s,])`, "g"), (_, pre) => pre + rename(name));
165
+ }
166
+ });
167
+ }
168
+ function refsIn(root) {
169
+ const out = [];
170
+ root.walkDecls(/^(-\w+-)?animation(-name)?$/, (d) => {
171
+ for (const tok of d.value.split(/[\s,]+/)) {
172
+ if (IDENT.test(tok) && !KEYWORDS.has(tok))
173
+ out.push(tok);
174
+ }
175
+ });
176
+ return out;
177
+ }
178
+ function declaredIn(root) {
179
+ const out = [];
180
+ root.walkAtRules(/^(-\w+-)?keyframes$/, (r) => {
181
+ out.push(r.params);
182
+ });
183
+ return out;
184
+ }
120
185
  function namespaceCss(opts) {
121
186
  const ns = opts.gameId.replace(/[^a-z0-9]+/gi, "-");
122
187
  const scope = opts.scope ?? `.gk-${ns}`;
123
188
  const include = opts.include ?? /\/src\/styles\/[^/]*\.css(\?|$)/;
189
+ const components = opts.components ?? /\.svelte\?(?:.*&)?type=style(?:&|$)/;
190
+ const stylesDir = opts.stylesDir ?? "src/styles";
191
+ const rename = (n) => `${n}-${ns}`;
192
+ let root = process.cwd();
193
+ let cache = null;
194
+ const globalNames = () => {
195
+ const dir = path.resolve(root, stylesDir);
196
+ let files = [];
197
+ try {
198
+ files = readdirSync(dir).filter((f) => f.endsWith(".css")).sort();
199
+ } catch {
200
+ return new Set;
201
+ }
202
+ const stamp = files.map((f) => `${f}:${statSync(path.join(dir, f)).mtimeMs}`).join("|");
203
+ if (cache?.stamp === stamp)
204
+ return cache.names;
205
+ const names = new Set;
206
+ for (const f of files) {
207
+ try {
208
+ for (const n of declaredIn(postcss.parse(readFileSync(path.join(dir, f), "utf8"))))
209
+ names.add(n);
210
+ } catch {}
211
+ }
212
+ cache = { stamp, names };
213
+ return names;
214
+ };
215
+ const declared = new Set;
216
+ const referenced = new Map;
124
217
  return {
125
218
  name: "gamekit:namespace-css",
126
219
  enforce: "pre",
220
+ configResolved(config) {
221
+ root = config.root;
222
+ },
223
+ buildStart() {
224
+ declared.clear();
225
+ referenced.clear();
226
+ },
127
227
  transform(code, id) {
128
- if (!include.test(id))
228
+ const isGlobal = include.test(id);
229
+ const isComponent = !isGlobal && components.test(id);
230
+ if (!isGlobal && !isComponent)
129
231
  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
- });
232
+ let css;
233
+ try {
234
+ css = postcss.parse(code, { from: id });
235
+ } catch (e) {
236
+ if (isComponent) {
237
+ this.warn(`namespace-css 跳过 ${id}:内容不是 CSS(${e instanceof Error ? e.message.split(`
238
+ `)[0] : String(e)})`);
239
+ return null;
240
+ }
241
+ throw e;
142
242
  }
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}`;
243
+ if (isGlobal) {
244
+ const local = new Set(declaredIn(css));
245
+ css.walkAtRules(/^(-\w+-)?keyframes$/, (r) => {
246
+ r.params = rename(r.params);
247
+ });
248
+ renameRefs(css, local, rename);
249
+ css.walkRules((rule) => {
250
+ const parent = rule.parent;
251
+ if (parent?.type === "atrule" && /keyframes$/.test(parent.name))
252
+ return;
253
+ rule.selectors = rule.selectors.map((sel) => {
254
+ const s = sel.trim();
255
+ if (s === ":root" || s === "html" || s === "body")
256
+ return scope;
257
+ if (s.startsWith(":root"))
258
+ return scope + s.slice(":root".length);
259
+ if (s.startsWith("html") || s.startsWith("body"))
260
+ return scope + s.slice(4);
261
+ if (s.startsWith(scope))
262
+ return s;
263
+ return `${scope} ${s}`;
264
+ });
158
265
  });
159
- });
160
- return { code: root.toString(), map: null };
266
+ } else {
267
+ const local = new Set(declaredIn(css));
268
+ const targets = new Set([...globalNames()].filter((n) => !local.has(n)));
269
+ renameRefs(css, targets, rename);
270
+ }
271
+ for (const n of declaredIn(css))
272
+ declared.add(n);
273
+ for (const n of refsIn(css))
274
+ if (!referenced.has(n))
275
+ referenced.set(n, id);
276
+ return { code: css.toString(), map: null };
277
+ },
278
+ generateBundle() {
279
+ const missing = [...referenced].filter(([n]) => !declared.has(n));
280
+ if (missing.length === 0)
281
+ return;
282
+ this.error(`有 ${missing.length} 处 animation 引用了不存在的 @keyframes,这些动画会静默不播:
283
+ ` + missing.map(([n, id]) => ` ${n} ← ${path.relative(root, id.split("?")[0])}`).join(`
284
+ `) + `
285
+ 全局 keyframes 要声明在 ${stylesDir}/*.css 里;组件里引用它们用原名,插件会同步改名。`);
161
286
  }
162
287
  };
163
288
  }
164
- var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
165
289
 
166
290
  // dist-npm/vite-config/index.ts
167
291
  function defineGameConfig(opts) {
168
292
  const meta = normalizeMeta(opts.meta);
169
293
  const { shareRuntime = false, emitCssFile = false } = opts;
170
294
  const root = opts.root ?? process.cwd();
171
- const entry = path.resolve(root, opts.entry ?? "src/index.ts");
295
+ const entry = path2.resolve(root, opts.entry ?? "src/index.ts");
172
296
  return defineConfig({
173
297
  root,
174
298
  optimizeDeps: { exclude: ["gamekit777"] },
175
299
  resolve: { dedupe: ["svelte"] },
300
+ define: { __GAMEKIT_FONTS__: JSON.stringify(meta.fonts ?? []) },
176
301
  base: "./",
177
302
  plugins: [
178
303
  svelte(),
@@ -182,7 +307,7 @@ function defineGameConfig(opts) {
182
307
  ],
183
308
  build: {
184
309
  target: "es2022",
185
- outDir: path.resolve(root, "../../dist/games", meta.id),
310
+ outDir: path2.resolve(root, "dist"),
186
311
  emptyOutDir: true,
187
312
  sourcemap: false,
188
313
  lib: {
@@ -47,6 +47,9 @@ export function defineGameConfig(opts: GameConfigOptions): UserConfig {
47
47
  dedupe 是另一半:预览壳和游戏各自 import 的 svelte 必须是同一份实例,否则 runes 静默失灵 */
48
48
  optimizeDeps: { exclude: ['gamekit777'] },
49
49
  resolve: { dedupe: ['svelte'] },
50
+ /* 字体列表交给 runtime 的资源注册表(它随 assets.bundle() 一起交给平台)。
51
+ 用 define 而不是让 runtime 去 import game.meta.json:库文件不该按绝对路径依赖消费方的某个文件 */
52
+ define: { __GAMEKIT_FONTS__: JSON.stringify(meta.fonts ?? []) },
50
53
  /* 这一行就是 import.meta.url 方案本身:Vite 在 es 格式下会把资源引用编译成
51
54
  new URL('./assets/xxx-hash.jpg', import.meta.url).href,
52
55
  于是游戏文件夹放在任意路径(CDN 子目录)都能正确解析。不要手写 new URL */
@@ -59,7 +62,8 @@ export function defineGameConfig(opts: GameConfigOptions): UserConfig {
59
62
  ],
60
63
  build: {
61
64
  target: 'es2022',
62
- outDir: path.resolve(root, '../../dist/games', meta.id),
65
+ // 落在项目自己的 dist/:创作者的目录在任意位置,往上两级写 dist/games 会写到别人家里去
66
+ outDir: path.resolve(root, 'dist'),
63
67
  emptyOutDir: true,
64
68
  sourcemap: false,
65
69
  // cssCodeSplit 不用写:lib 模式会自动关掉
@@ -31,7 +31,6 @@ export interface AssetEntry {
31
31
  export interface GameManifest {
32
32
  id: string;
33
33
  version: string;
34
- generatedAt: string;
35
34
  entry: string;
36
35
  /** 独立产出时是文件名;内联进 index.js 时是 null */
37
36
  css: string | null;
@@ -43,6 +42,17 @@ export interface GameManifest {
43
42
  /** critical 资源的预算。超了就告警——批量做游戏时这条比 manifest 本身还有用 */
44
43
  const CRITICAL_BUDGET = 1_500_000;
45
44
 
45
+ /** runtime 资源注册表里留的字面量。构建期只有这里知道每个文件多大,替换进去后平台的进度条才能按字节走 */
46
+ export const BYTES_PLACEHOLDER = '__GAMEKIT_ASSET_BYTES__';
47
+
48
+ /* 替换的是引号**里面**的内容,引号本身留给压缩器决定(esbuild 可能改成单引号或双引号),
49
+ 所以 JSON 里的引号和反斜杠要按 JS 字符串字面量转义一遍;解析时 JSON.parse 再还原 */
50
+ export function injectAssetBytes(code: string, bytes: Record<string, number>): string {
51
+ if (!code.includes(BYTES_PLACEHOLDER)) return code;
52
+ const literal = JSON.stringify(bytes).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
53
+ return code.split(BYTES_PLACEHOLDER).join(literal);
54
+ }
55
+
46
56
  export function gameManifest(opts: { meta: GameBuildMeta; emitCssFile?: boolean }): Plugin {
47
57
  const { meta, emitCssFile = false } = opts;
48
58
 
@@ -88,6 +98,10 @@ export function gameManifest(opts: { meta: GameBuildMeta; emitCssFile?: boolean
88
98
  const indexJs = bundle['index.js'] as Rollup.OutputChunk | undefined;
89
99
  let cssInlined = false;
90
100
 
101
+ if (indexJs) {
102
+ indexJs.code = injectAssetBytes(indexJs.code, Object.fromEntries(assets.map(a => [a.key, a.bytes])));
103
+ }
104
+
91
105
  if (cssAsset && indexJs && !emitCssFile) {
92
106
  const css = String(cssAsset.source);
93
107
  if (/url\(\s*['"]?(?!data:|#)/.test(css)) {
@@ -106,7 +120,7 @@ export function gameManifest(opts: { meta: GameBuildMeta; emitCssFile?: boolean
106
120
  const manifest: GameManifest = {
107
121
  id: meta.id,
108
122
  version: meta.version,
109
- generatedAt: new Date().toISOString(),
123
+ // 刻意没有时间戳:产物要可复现。游戏包按内容哈希寻址永久缓存,同样的源码两次构建必须是同一份字节
110
124
  entry: 'index.js',
111
125
  css: cssInlined ? null : (cssAsset?.fileName ?? null),
112
126
  totalBytes: assets.reduce((s, a) => s + a.bytes, 0),
@@ -1,74 +1,161 @@
1
1
  /* 给游戏的全局 CSS 加命名空间。
2
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
3
  @keyframes 是全局命名空间、后定义覆盖先定义,所以同页两个游戏会**静默错乱**:
9
4
  不报错,只是动画播成了别人的。批量做游戏时这几乎必然发生。
5
+ Svelte 组件 <style> 里声明的 @keyframes 编译器会自动改名,逃过重命名的是
6
+ src/styles/ 下独立文件里的:coco 的 animations.css 有 14 个,名字是 pop / spin / fly 这一类。
7
+
8
+ 改名要改两头。声明在独立文件里,引用却大多在组件 <style> 里(`.comet line{animation:comet …}`),
9
+ 而 Svelte 只重写它自己声明过的名字——所以组件里的引用必须由这里同步改,否则每一条都静默失效:
10
+ 动画一个不播、控制台一个字不报。曾经只改了声明那一头,coco 的 10 个动画在 dev 和线上一起死了两天。
11
+ build 结束再核一遍「每个被引用的名字都有声明」,让这类漏改在构建期就炸。
10
12
 
11
13
  顺带把 :root / html / body 收敛到游戏根元素,否则游戏的页面底色会涂到整个平台上。 */
12
14
  import type { Plugin } from 'vite';
15
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
16
+ import path from 'node:path';
13
17
  import postcss from 'postcss';
14
18
 
15
19
  export interface NamespaceCssOptions {
16
20
  gameId: string;
17
- /** 命中哪些文件。默认 src/styles/ 下的 .css */
21
+ /** 全局样式文件。默认 src/styles/ 下的 .css */
18
22
  include?: RegExp;
23
+ /** 组件样式模块(vite-plugin-svelte 吐出的 <style>)。默认 *.svelte?…type=style */
24
+ components?: RegExp;
19
25
  /** 游戏根元素的 class。默认 gk-<gameId> */
20
26
  scope?: string;
27
+ /** 全局样式所在目录(相对 root)。默认 src/styles */
28
+ stylesDir?: string;
29
+ }
30
+
31
+ /* animation 简写里会出现的非名字 token:碰到同名的 keyframes 也不改,那本来就是写错了 */
32
+ const KEYWORDS = new Set([
33
+ 'none', 'initial', 'inherit', 'unset', 'revert', 'revert-layer', 'auto',
34
+ 'infinite', 'normal', 'reverse', 'alternate', 'alternate-reverse',
35
+ 'forwards', 'backwards', 'both', 'running', 'paused',
36
+ 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear', 'step-start', 'step-end',
37
+ ]);
38
+ const IDENT = /^-?[a-zA-Z_][\w-]*$/;
39
+
40
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+
42
+ /** 把 animation / animation-name 值里出现的 names 全部改成 rename(name) */
43
+ function renameRefs(root: postcss.Root, names: ReadonlySet<string>, rename: (n: string) => string): void {
44
+ if (names.size === 0) return;
45
+ root.walkDecls(/^(-\w+-)?animation(-name)?$/, d => {
46
+ for (const name of names) {
47
+ d.value = d.value.replace(
48
+ new RegExp(`(^|[\\s,])${escapeRe(name)}(?=$|[\\s,])`, 'g'), (_, pre: string) => pre + rename(name));
49
+ }
50
+ });
51
+ }
52
+
53
+ /** animation 值里所有像 keyframes 名字的 token(去掉关键字、时长、var() 之类) */
54
+ function refsIn(root: postcss.Root): string[] {
55
+ const out: string[] = [];
56
+ root.walkDecls(/^(-\w+-)?animation(-name)?$/, d => {
57
+ for (const tok of d.value.split(/[\s,]+/)) {
58
+ if (IDENT.test(tok) && !KEYWORDS.has(tok)) out.push(tok);
59
+ }
60
+ });
61
+ return out;
62
+ }
63
+
64
+ function declaredIn(root: postcss.Root): string[] {
65
+ const out: string[] = [];
66
+ root.walkAtRules(/^(-\w+-)?keyframes$/, r => { out.push(r.params); });
67
+ return out;
21
68
  }
22
69
 
23
70
  export function namespaceCss(opts: NamespaceCssOptions): Plugin {
24
71
  const ns = opts.gameId.replace(/[^a-z0-9]+/gi, '-');
25
72
  const scope = opts.scope ?? `.gk-${ns}`;
26
73
  const include = opts.include ?? /\/src\/styles\/[^/]*\.css(\?|$)/;
74
+ const components = opts.components ?? /\.svelte\?(?:.*&)?type=style(?:&|$)/;
75
+ const stylesDir = opts.stylesDir ?? 'src/styles';
76
+ const rename = (n: string): string => `${n}-${ns}`;
77
+
78
+ let root = process.cwd();
79
+ /* 全局 keyframes 名字表从磁盘扫,不靠 transform 的先后:dev 下浏览器并行请求各模块,
80
+ 组件样式很可能先于 animations.css 到达,那时它还没被 transform 过 */
81
+ let cache: { stamp: string; names: Set<string> } | null = null;
82
+ const globalNames = (): Set<string> => {
83
+ const dir = path.resolve(root, stylesDir);
84
+ let files: string[] = [];
85
+ try { files = readdirSync(dir).filter(f => f.endsWith('.css')).sort(); } catch { return new Set(); }
86
+ const stamp = files.map(f => `${f}:${statSync(path.join(dir, f)).mtimeMs}`).join('|');
87
+ if (cache?.stamp === stamp) return cache.names;
88
+ const names = new Set<string>();
89
+ for (const f of files) {
90
+ try { for (const n of declaredIn(postcss.parse(readFileSync(path.join(dir, f), 'utf8')))) names.add(n); } catch { /* 语法错误交给 vite 自己报 */ }
91
+ }
92
+ cache = { stamp, names };
93
+ return names;
94
+ };
95
+
96
+ /* build 终验用:改名之后,所有声明过的名字与所有被引用的名字 */
97
+ const declared = new Set<string>();
98
+ const referenced = new Map<string, string>();
27
99
 
28
100
  return {
29
101
  name: 'gamekit:namespace-css',
30
102
  enforce: 'pre',
103
+ configResolved(config) { root = config.root; },
104
+ buildStart() { declared.clear(); referenced.clear(); },
105
+
31
106
  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
- });
107
+ const isGlobal = include.test(id);
108
+ const isComponent = !isGlobal && components.test(id);
109
+ if (!isGlobal && !isComponent) return null;
110
+
111
+ let css: postcss.Root;
112
+ try { css = postcss.parse(code, { from: id }); }
113
+ catch (e) {
114
+ /* 组件样式模块偶尔会拿到整份 .svelte 源码(vite-plugin-svelte 的 CSS 缓存未命中时退回读文件)。
115
+ 那不是我们的错也修不了,别让整页跟着炸——放过去,浏览器会忽略非法 CSS,并留一条 warn */
116
+ if (isComponent) { this.warn(`namespace-css 跳过 ${id}:内容不是 CSS(${e instanceof Error ? e.message.split('\n')[0] : String(e)})`); return null; }
117
+ throw e;
51
118
  }
52
119
 
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}`;
120
+ if (isGlobal) {
121
+ const local = new Set(declaredIn(css));
122
+ css.walkAtRules(/^(-\w+-)?keyframes$/, r => { r.params = rename(r.params); });
123
+ renameRefs(css, local, rename);
124
+
125
+ css.walkRules(rule => {
126
+ const parent = rule.parent;
127
+ // @keyframes 里的 0% / from / to 不是选择器,不要动
128
+ if (parent?.type === 'atrule' && /keyframes$/.test((parent as postcss.AtRule).name)) return;
129
+ rule.selectors = rule.selectors.map(sel => {
130
+ const s = sel.trim();
131
+ if (s === ':root' || s === 'html' || s === 'body') return scope;
132
+ if (s.startsWith(':root')) return scope + s.slice(':root'.length);
133
+ if (s.startsWith('html') || s.startsWith('body')) return scope + s.slice(4);
134
+ if (s.startsWith(scope)) return s; // 已经带了就别叠
135
+ return `${scope} ${s}`;
136
+ });
66
137
  });
67
- });
138
+ } else {
139
+ /* 组件里声明的 keyframes 已被 Svelte 改成 svelte-<hash>-<name>,连同它的引用一起;
140
+ 这里只动那些指向全局名字表的引用 */
141
+ const local = new Set(declaredIn(css));
142
+ const targets = new Set([...globalNames()].filter(n => !local.has(n)));
143
+ renameRefs(css, targets, rename);
144
+ }
68
145
 
69
- return { code: root.toString(), map: null };
70
- }
146
+ for (const n of declaredIn(css)) declared.add(n);
147
+ for (const n of refsIn(css)) if (!referenced.has(n)) referenced.set(n, id);
148
+ return { code: css.toString(), map: null };
149
+ },
150
+
151
+ /* 只在 build 跑:dev 下模块按需到达,凑不齐全集 */
152
+ generateBundle() {
153
+ const missing = [...referenced].filter(([n]) => !declared.has(n));
154
+ if (missing.length === 0) return;
155
+ this.error(
156
+ `有 ${missing.length} 处 animation 引用了不存在的 @keyframes,这些动画会静默不播:\n` +
157
+ missing.map(([n, id]) => ` ${n} ← ${path.relative(root, id.split('?')[0]!)}`).join('\n') +
158
+ `\n全局 keyframes 要声明在 ${stylesDir}/*.css 里;组件里引用它们用原名,插件会同步改名。`);
159
+ },
71
160
  };
72
161
  }
73
-
74
- const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -1,30 +0,0 @@
1
- /* 取资源清单。
2
-
3
- 生产环境下 manifest.json 是构建产物,和 index.js 同目录,用 import.meta.url 定位——
4
- 这样游戏文件夹放到 CDN 任意子目录都找得到。
5
- 开发和测试环境没有这个文件,从注册表现造一份。 */
6
- import type { GameManifest } from 'gamekit777/runtime';
7
- import { assets } from './registry.ts';
8
- import buildMeta from '../../game.meta.json' with { type: 'json' };
9
-
10
- const fallback = (): GameManifest => ({
11
- id: buildMeta.id, version: buildMeta.version,
12
- generatedAt: new Date().toISOString(),
13
- entry: 'index.js', css: null, totalBytes: 0, criticalBytes: 0,
14
- assets: assets.keys.map(key => ({
15
- key, path: assets.url(key),
16
- type: /\.(jpe?g|png|webp|avif|gif|svg)$/i.test(key) ? 'image' as const
17
- : /\.(mp3|ogg|wav|m4a)$/i.test(key) ? 'audio' as const : 'binary' as const,
18
- bytes: 1,
19
- priority: key.startsWith('lazy/') ? 'lazy' as const : 'critical' as const
20
- }))
21
- });
22
-
23
- export async function loadManifest(signal?: AbortSignal): Promise<GameManifest> {
24
- if (!import.meta.url.includes('/dist/')) return fallback();
25
- try {
26
- const r = await fetch(new URL(/* @vite-ignore */ 'manifest.json', import.meta.url), { signal });
27
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
28
- return await r.json() as GameManifest;
29
- } catch { return fallback(); }
30
- }
@@ -1,23 +0,0 @@
1
- /* 资源注册表。目录即优先级。?no-inline 是必须的——
2
- lib 模式会无条件把资源 base64 内联进 JS,这个 query 是唯一逃生口。
3
- 这个游戏暂时没有资源文件,表是空的,但结构保持一致。 */
4
- const critical = import.meta.glob('./critical/**/*', {
5
- eager: true, query: '?no-inline', import: 'default'
6
- }) as Record<string, string>;
7
-
8
- const lazy = import.meta.glob('./lazy/**/*', {
9
- eager: true, query: '?no-inline', import: 'default'
10
- }) as Record<string, string>;
11
-
12
- const table: Record<string, string> = {};
13
- for (const [k, v] of Object.entries({ ...critical, ...lazy })) table[k.replace(/^\.\//, '')] = v;
14
-
15
- export const assets = {
16
- url(key: string): string {
17
- const u = table[key];
18
- if (!u) throw new Error(`[assets] 未知资源: ${key}`);
19
- return u;
20
- },
21
- keys: Object.keys(table),
22
- all: table
23
- };
@@ -1,34 +0,0 @@
1
- /* 浏览器里的发布:身份是页面上登录的那个人。
2
- *
3
- * 表字节从 /studio/api 取(node 侧建好的产物),描述配置由页面里 import 的游戏清单算——
4
- * GameModule 本身就是一份 GameDefinition,上传流程原样复用 publish 的 upload-core:
5
- * CLI 走的也是这一份,只是身份换成了浏览器 cookie。 */
6
- import { createClient, type GameKitClient } from 'gamekit777/client';
7
- import type { GameDefinition } from 'gamekit777/sdk';
8
- import { uploadTables, type UploadResult, type UploadTable } from 'gamekit777/publish/upload-core';
9
- import type { Artifact } from '../../src/build.ts';
10
- import { api } from './api.ts';
11
-
12
- /** 同源,cookie 由浏览器带。/v1 反代到平台后端 */
13
- export const client: GameKitClient = createClient({ baseUrl: '' });
14
-
15
- export interface PublishPlan {
16
- spec: GameDefinition<object, unknown>;
17
- tables: UploadTable[];
18
- }
19
-
20
- /** 把 node 侧的产物 + 页面里的游戏清单拼成 uploadTables 要的形状 */
21
- export async function planFromArtifacts(spec: GameDefinition<object, unknown>, arts: Artifact[]): Promise<PublishPlan> {
22
- const tables: UploadTable[] = await Promise.all(arts.map(async (a) => ({
23
- mode: a.mode,
24
- bytes: await api.artifactTable(a.mode),
25
- contentHash: a.contentHash, rowCount: a.rowCount, betCostCenti: a.betCostCenti,
26
- books: a.hasBooks ? await api.artifactBooks(a.mode) : null,
27
- verdict: { computedRtpPpm: a.computedRtpPpm },
28
- })));
29
- return { spec, tables };
30
- }
31
-
32
- export function runUpload(plan: PublishPlan, o: { publishModes?: string[]; onProgress: (m: string) => void }): Promise<UploadResult> {
33
- return uploadTables(plan.spec, plan.tables, { client, publishModes: o.publishModes, onProgress: o.onProgress });
34
- }