gamekit777 0.1.6 → 0.1.8

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 (53) hide show
  1. package/README.md +1 -0
  2. package/create-game/scaffold.ts +72 -15
  3. package/create-game/template/base/game.meta.json +9 -0
  4. package/create-game/template/{game.ts.tmpl → base/game.ts} +15 -11
  5. package/create-game/template/{package.json.tmpl → base/package.json} +7 -3
  6. package/create-game/template/{src/components/App.svelte.tmpl → base/src/components/App.svelte} +8 -3
  7. package/create-game/template/{src/context.svelte.ts.tmpl → base/src/context.svelte.ts} +5 -5
  8. package/create-game/template/{src → base/src}/rules/const.ts +2 -1
  9. package/create-game/template/{src/rules/restore.ts.tmpl → base/src/rules/restore.ts} +7 -5
  10. package/create-game/template/{src/rules/schema.ts.tmpl → base/src/rules/schema.ts} +2 -2
  11. package/create-game/template/{src/rules/simulate.ts.tmpl → base/src/rules/simulate.ts} +2 -2
  12. package/create-game/template/{src/rules/types.ts.tmpl → base/src/rules/types.ts} +2 -2
  13. package/create-game/template/{src/state/game.svelte.ts.tmpl → base/src/state/game.svelte.ts} +14 -7
  14. package/create-game/template/{src/view/mount.svelte.ts.tmpl → base/src/view/mount.svelte.ts} +6 -4
  15. package/create-game/template/{src/view/present.ts.tmpl → base/src/view/present.ts} +2 -2
  16. package/create-game/template/{test/e2e.test.ts.tmpl → base/test/e2e.test.ts} +4 -1
  17. package/create-game/template/base/tsconfig.json +8 -0
  18. package/create-game/template/{CLAUDE.md.tmpl → overlay/CLAUDE.md.tmpl} +13 -3
  19. package/dev-host/DevShell.svelte +240 -75
  20. package/dev-host/index.ts +1 -0
  21. package/dev-host/layout.ts +80 -0
  22. package/dev-host/platform.svelte.ts +23 -4
  23. package/dev-host/run.ts +8 -1
  24. package/dev-host/theme.css +142 -11
  25. package/package.json +1 -1
  26. package/runtime/index.ts +1 -0
  27. package/runtime/meter.ts +72 -0
  28. package/runtime/registry.ts +5 -1
  29. package/sdk/host.ts +8 -0
  30. package/sdk/stage.ts +97 -11
  31. package/studio/app/App.svelte +92 -10
  32. package/studio/app/panels/CasePanel.svelte +1 -1
  33. package/studio/app/panels/PreviewPanel.svelte +4 -0
  34. package/studio/bin.ts +8 -0
  35. package/studio/cli.ts +20 -7
  36. package/studio/preview/entry.ts +10 -1
  37. package/studio/src/engine.ts +5 -1
  38. package/studio/src/mcp.ts +11 -6
  39. package/studio/src/scaffold.ts +74 -31
  40. package/studio/src/studio-plugin.ts +29 -13
  41. package/studio/src/version.ts +62 -0
  42. package/create-game/template/game.meta.json.tmpl +0 -8
  43. package/create-game/template/tsconfig.json +0 -22
  44. /package/create-game/template/{scripts/verify.ts.tmpl → base/scripts/verify.ts} +0 -0
  45. /package/create-game/template/{src → base/src}/assets/critical/.gitkeep +0 -0
  46. /package/create-game/template/{src → base/src}/assets/lazy/.gitkeep +0 -0
  47. /package/create-game/template/{src/index.ts.tmpl → base/src/index.ts} +0 -0
  48. /package/create-game/template/{src → base/src}/styles/animations.css +0 -0
  49. /package/create-game/template/{src → base/src}/styles/global.css +0 -0
  50. /package/create-game/template/{src → base/src}/view/bridge.svelte.ts +0 -0
  51. /package/create-game/template/{vite.config.ts → base/vite.config.ts} +0 -0
  52. /package/create-game/template/{vitest.config.ts → base/vitest.config.ts} +0 -0
  53. /package/create-game/template/{_gitignore → overlay/_gitignore} +0 -0
package/studio/cli.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  /* `gamekit777` 这个命令的入口:按子命令分派。
3
3
  *
4
4
  * gamekit777 studio [dir] [--mcp|--no-mcp] [--game-port N] [--backend URL] 起工作台(MCP 走 stdio)
5
- * gamekit777 new [名字] 新建游戏并装依赖:给名字 → 子目录 ./名字;不给 → 就地用当前(空)目录
5
+ * gamekit777 new [名字] [--no-update] 新建游戏并装依赖:给名字 → 子目录 ./名字;不给 → 就地用当前(空)目录;比 npm 旧就换最新版重跑
6
6
  *
7
7
  * 只是薄薄一层分派,逻辑各在 bin.ts 和 src/scaffold.ts。 */
8
8
  const [sub, ...rest] = process.argv.slice(2);
@@ -10,7 +10,8 @@ const [sub, ...rest] = process.argv.slice(2);
10
10
  const usage = (): never => {
11
11
  process.stderr.write(`用法:
12
12
  gamekit777 studio [dir] [--mcp|--no-mcp] [--game-port N] [--backend URL]
13
- gamekit777 new [名字] 新建游戏并装依赖:给名字 → 生成 ./名字;不给 → 就地用当前目录(必须是空目录,id 取目录名)
13
+ gamekit777 new [名字] [--no-update] 新建游戏并装依赖:给名字 → 生成 ./名字;不给 → 就地用当前目录(必须是空目录,id 取目录名)
14
+ 比 npm 上的最新版旧时自动换最新版重跑;--no-update 用当前这份
14
15
 
15
16
  接 Claude Code:在游戏目录里
16
17
  claude mcp add gamekit -- bunx gamekit777 studio
@@ -26,12 +27,24 @@ switch (sub) {
26
27
  }
27
28
  case 'new': {
28
29
  /* 只收一个可选的名字。不给就用当前目录:AI 多半已经站在要建的目录里,再套一层 my-game 只会让它 cd 错地方。
29
- 标题之类都在生成后的 game.meta.json 里改,少一层「命令行参数 vs 配置文件」的分叉 */
30
- if (rest.length > 1 || rest[0]?.startsWith('-')) usage();
31
- const { scaffold } = await import('./src/scaffold.ts');
30
+ 标题之类都在生成后的 game.meta.json 里改,少一层「命令行参数 vs 配置文件」的分叉。
31
+ 跑之前先看 npm 有没有更新的版本:bunx 把裸 `gamekit777` 缓存成一个临时目录、隔天照用,
32
+ 旧脚手架生成的模板对不上新装的运行时。比 npm 旧就用精确版本号重跑一遍,模板与运行时同时是最新的 */
33
+ const noUpdate = rest.includes('--no-update');
34
+ const args = rest.filter((a) => a !== '--no-update');
35
+ if (args.length > 1 || args[0]?.startsWith('-')) usage();
36
+ const { scaffold, reexecArgs, inWorkspace } = await import('./src/scaffold.ts');
37
+ const { newerOnNpm, ownVersion } = await import('./src/version.ts');
38
+ const latest = noUpdate || inWorkspace(process.cwd()) ? null : await newerOnNpm();
39
+ if (latest) {
40
+ process.stderr.write(`· 这份 gamekit777 是 ${ownVersion()},npm 上最新是 ${latest},改用最新版生成(--no-update 可跳过)\n`);
41
+ const { cmd, args: a, env } = reexecArgs(latest, args[0]);
42
+ const { spawnSync } = await import('node:child_process');
43
+ process.exit(spawnSync(cmd, a, { stdio: 'inherit', env }).status ?? 1);
44
+ }
32
45
  try {
33
- const r = await scaffold({ name: rest[0] }, process.cwd());
34
- process.stdout.write(`✓ ${r.inPlace ? `就地生成在 ${r.dest}` : r.dest}\n\n${r.next.map((n) => ` ${n}`).join('\n')}\n\n ${r.inPlace ? '' : `cd ${r.dest} && `}bunx gamekit777 studio\n`);
46
+ const r = await scaffold({ name: args[0], noUpdate: true }, process.cwd());
47
+ process.stdout.write(`✓ ${r.inPlace ? `就地生成在 ${r.dest}` : r.dest}(gamekit777 ${r.gamekit})\n\n${r.next.map((n) => ` ${n}`).join('\n')}\n\n ${r.inPlace ? '' : `cd ${r.dest} && `}bunx gamekit777 studio\n`);
35
48
  } catch (e) {
36
49
  process.stderr.write(`✗ ${e instanceof Error ? e.message : String(e)}\n`);
37
50
  process.exit(1);
@@ -3,7 +3,7 @@
3
3
  * 壳就是 dev-host 的 DevShell——和 `bun run dev` 看到的是同一个参考平台,同一条本地表模型:
4
4
  * 浏览器里就地建表、按权重抽行、restore 还原。studio 多接的只有三样:
5
5
  * 建好的正式产物(有就优先用)、存为 case、以及 node 侧的总线(MCP 的 preview_* 工具从那儿进来)。 */
6
- import { runDevShell, parseTable, localServer, type LoadedTable, type PlatformOf } from 'gamekit777/dev-host';
6
+ import { runDevShell, parseTable, localServer, layoutFromSearch, safeFromSearch, type LoadedTable, type PlatformOf } from 'gamekit777/dev-host';
7
7
  import type { GameModule } from 'gamekit777/sdk';
8
8
  import game from 'virtual:studio/game';
9
9
  import { api, connectBridge } from './bridge.ts';
@@ -23,6 +23,10 @@ let p: PlatformOf<object, unknown> | null = null;
23
23
  runDevShell(g, {
24
24
  // 嵌入模式:页眉和余额由 studio 页面画,这里只剩机台和机台下面的开发面板
25
25
  embedded: true,
26
+ /* 布局默认桌面,不按窗口判——iframe 有多宽由 studio 页面决定。首载从 ?layout= 读,之后由 set 命令就地切;
27
+ ?safe= 是设备预设模拟的刘海,iframe 里 env() 拿不到 */
28
+ layout: layoutFromSearch(location.search) ?? 'desktop',
29
+ simSafe: safeFromSearch(location.search) ?? undefined,
26
30
  server: localServer({ spec: g, external }),
27
31
  onPlatform: (plat) => { p = plat; },
28
32
  onSaveCase: async (h, title) => {
@@ -39,6 +43,7 @@ const snapshot = (): Record<string, unknown> => {
39
43
  return {
40
44
  config: plain(p.config), mode: p.tableMode, bet: p.bet, balance: p.balance, pnl: p.pnl,
41
45
  status: plain(p.game), lastError: p.err, tableModes: plain(p.tableModes),
46
+ layout: p.layout, stage: plain(p.stage),
42
47
  recent: p.history.slice(0, 10).map((h) => ({ n: h.n, mode: h.mode, multiplier: h.multiplier, payout: h.payout, simId: h.simId, ok: h.checks.every((c) => c.ok) })),
43
48
  };
44
49
  };
@@ -60,6 +65,10 @@ const bridge = connectBridge({
60
65
  if (cmd.clientSeed !== undefined) { plat.clientSeed = String(cmd.clientSeed); applied.push('clientSeed'); }
61
66
  if (cmd.skin !== undefined) { plat.setView({ skin: String(cmd.skin) }); applied.push('skin'); }
62
67
  if (cmd.turbo !== undefined) { plat.setView({ turbo: !!cmd.turbo }); applied.push('turbo'); }
68
+ if (cmd.layout !== undefined) {
69
+ if (cmd.layout === 'desktop' || cmd.layout === 'mobile') { plat.setLayout(cmd.layout); applied.push('layout'); }
70
+ else rejected.push({ key: 'layout', reason: '只能是 desktop / mobile' });
71
+ }
63
72
  return { applied, rejected, state: snapshot() };
64
73
  }
65
74
  case 'play': {
@@ -5,6 +5,7 @@
5
5
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { join, relative } from 'node:path';
7
7
  import { Sha256 } from 'gamekit777/sdk/hash';
8
+ import type { StageInfo, StageLayout } from 'gamekit777/sdk';
8
9
  import type { Segment } from 'gamekit777/weights';
9
10
  import { Bus } from './bus.ts';
10
11
  import { buildMode, groupsSummary, listArtifacts, readArtifactBytes, sampleMode, type Artifact, type BuildModeOptions } from './build.ts';
@@ -31,6 +32,9 @@ export interface PreviewState {
31
32
  lastError: string | null;
32
33
  recent: unknown[];
33
34
  consoleErrors: string[];
35
+ /** 机台布局与交给游戏的舞台(含安全区)。页面的切换按钮和 MCP 的 preview_set 都以这里为准 */
36
+ layout: StageLayout | null;
37
+ stage: StageInfo | null;
34
38
  updatedAt: number;
35
39
  }
36
40
 
@@ -85,7 +89,7 @@ export class Engine {
85
89
  readonly assets: AssetStore;
86
90
  preview: PreviewState = {
87
91
  connected: false, config: null, mode: null, bet: null, balance: null,
88
- status: null, lastError: null, recent: [], consoleErrors: [], updatedAt: 0,
92
+ status: null, lastError: null, recent: [], consoleErrors: [], layout: null, stage: null, updatedAt: 0,
89
93
  };
90
94
  #publish = new Map<string, PublishRequest>();
91
95
  #lastFingerprint: string;
package/studio/src/mcp.ts CHANGED
@@ -145,12 +145,15 @@ export function createMcpServer(engine: Engine): McpServer {
145
145
  });
146
146
 
147
147
  s.registerTool('scaffold', {
148
- description: '新建一个游戏(能跑的抛硬币)并 bun install。不给 name 就在当前目录就地生成(当前目录必须是空的,id 取目录名);给 name 就生成到子目录 ./name。当前目录已经是项目时会拒绝并让你给名字。标题之后改 game.meta.json。生成后要在那个目录里重启 studio。',
149
- inputSchema: { name: z.string().optional().describe('子目录名,同时是 id(小写短横线)。不给 = 用当前目录') },
148
+ description: '新建一个游戏(能跑的抛硬币)并 bun install。不给 name 就在当前目录就地生成(当前目录必须是空的,id 取目录名);给 name 就生成到子目录 ./name。当前目录已经是项目时会拒绝并让你给名字。标题之后改 game.meta.json。模板钉死生成它的 gamekit777 版本;npm 上有更新版本时会用那一版代跑。生成后要在那个目录里重启 studio。',
149
+ inputSchema: {
150
+ name: z.string().optional().describe('子目录名,同时是 id(小写短横线)。不给 = 用当前目录'),
151
+ noUpdate: z.boolean().optional().describe('不查 npm、不用更新的版本代跑'),
152
+ },
150
153
  }, async (a) => {
151
154
  try {
152
- const r = await scaffold({ name: a.name }, process.cwd());
153
- return out(`## ✅ ${r.inPlace ? `已就地生成在 ${r.dest}` : `已生成 ${r.dest}`}\n\n${r.files} 个文件,依赖${r.installed ? '已装' : '未装'}。\n\n${r.next.map((n) => `- ${n}`).join('\n')}`, r);
155
+ const r = await scaffold({ name: a.name, noUpdate: a.noUpdate }, process.cwd());
156
+ return out(`## ✅ ${r.inPlace ? `已就地生成在 ${r.dest}` : `已生成 ${r.dest}`}\n\n${r.files} 个文件,依赖${r.installed ? '已装' : '未装'},gamekit777 ${r.gamekit}${r.upgradedTo ? '(由 npm 最新版代跑)' : ''}。\n\n${r.next.map((n) => `- ${n}`).join('\n')}`, r);
154
157
  } catch (e) { return fail(e instanceof Error ? e.message : String(e)); }
155
158
  });
156
159
 
@@ -282,25 +285,27 @@ export function createMcpServer(engine: Engine): McpServer {
282
285
  });
283
286
 
284
287
  s.registerTool('preview_state', {
285
- description: '预览页的当前状态:连没连、当前 config / mode / 注额 / 余额、已加载的表、最近几局、控制台报错。',
288
+ description: '预览页的当前状态:连没连、当前 config / mode / 注额 / 余额、已加载的表、最近几局、控制台报错、机台布局(desktop/mobile)与交给游戏的舞台尺寸和安全区。',
286
289
  inputSchema: {},
287
290
  }, async () => {
288
291
  engine.touch();
289
292
  const p = engine.preview;
290
293
  if (!engine.bus.previewConnected) return out(`预览页没连着。请在浏览器打开 ${engine.urls.studio}`, { connected: false });
291
294
  return out([`## 预览(本地表模型)`, `- mode ${p.mode},注额 ${p.bet},余额 ${p.balance}`, `- config ${JSON.stringify(p.config)}`,
295
+ ...(p.stage ? [`- 布局 ${p.layout},舞台 ${p.stage.width}×${p.stage.height},安全区 上 ${p.stage.safe.top} 下 ${p.stage.safe.bottom}`] : []),
292
296
  `- 最近 ${p.recent.length} 局`, ...(p.lastError ? [`- ⚠️ 最近错误:${p.lastError}`] : []),
293
297
  ...(p.consoleErrors.length ? ['', '### 控制台报错', ...p.consoleErrors.slice(-5).map((e) => `- ${e}`)] : [])].join('\n'), p);
294
298
  });
295
299
 
296
300
  s.registerTool('preview_set', {
297
- description: '改预览的设置:config、注额、客户端种子、造型、极速。局中/自动中会被平台否决,rejected 里会说。',
301
+ description: '改预览的设置:config、注额、客户端种子、造型、极速、机台布局(desktop 桌面 / mobile 手机竖屏全屏)。局中/自动中会被平台否决,rejected 里会说。',
298
302
  inputSchema: {
299
303
  config: z.record(z.string(), z.unknown()).optional(),
300
304
  bet: z.number().int().optional().describe('最小货币单位(分)'),
301
305
  clientSeed: z.string().optional(),
302
306
  skin: z.string().optional(),
303
307
  turbo: z.boolean().optional(),
308
+ layout: z.enum(['desktop', 'mobile']).optional().describe('切机台布局。游戏不重挂,收到 host.stage 后自己重排。两种布局都要验过;具体机型尺寸与刘海只能在 studio 页面机台上方选,MCP 不切'),
304
309
  },
305
310
  }, async (a) => {
306
311
  try {
@@ -1,11 +1,17 @@
1
1
  /* 建新游戏:脚手架 + 写 package.json + bun install。
2
2
  *
3
3
  * 这是 MCP 的 scaffold 工具背后的东西。装依赖这一步只有这里会自动做——
4
- * 别处发现缺 node_modules 一律只提示,不代作者动手。 */
4
+ * 别处发现缺 node_modules 一律只提示,不代作者动手。
5
+ *
6
+ * 一条不变式:**模板里钉的 gamekit777 版本 = 生成它的那份 gamekit777。**
7
+ * 写 `^x.y.z` 违反它——bunx 缓存里的旧脚手架写出 `^0.1.2`,bun install 却装到 0.1.7,
8
+ * 模板和运行时错位,第一条 check 就红。所以版本钉死;而「跑着的是旧版」这件事
9
+ * 由 newerOnNpm 兜:比 npm 旧就换最新版重跑一遍,模板与运行时同时是最新的。 */
5
10
  import { spawn } from 'node:child_process';
6
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
11
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
7
12
  import { basename, join } from 'node:path';
8
13
  import { scaffoldGame, ID_PATTERN } from 'gamekit777/create-game';
14
+ import { REEXEC_ENV, newerOnNpm, ownVersion } from './version.ts';
9
15
 
10
16
  export const DEFAULT_ID = 'my-game';
11
17
  export const DEFAULT_TITLE = '我的游戏';
@@ -42,10 +48,12 @@ export interface ScaffoldRequest {
42
48
  /** 子目录名,同时是 id。不给就在 cwd 里就地生成,id 取自目录名 */
43
49
  name?: string;
44
50
  title?: string;
45
- /** package.json 里 gamekit777 的版本说明符。仓库内是 workspace:*,创作者机器上是版本号 */
51
+ /** package.json 里 gamekit777 的版本说明符。仓库内是 workspace:*,创作者机器上是精确版本号 */
46
52
  gamekitSpec?: string;
47
53
  /** 跳过 bun install */
48
54
  skipInstall?: boolean;
55
+ /** 不查 npm、不自升级:离线或明确要用当前这份 */
56
+ noUpdate?: boolean;
49
57
  }
50
58
 
51
59
  export interface ScaffoldResult {
@@ -56,21 +64,26 @@ export interface ScaffoldResult {
56
64
  files: number;
57
65
  installed: boolean;
58
66
  installOutput: string;
67
+ /** 模板钉的 gamekit777 版本;仓库内是 workspace:* */
68
+ gamekit: string;
69
+ /** 由更新的版本代跑时是那个版本号;自己跑的是 null */
70
+ upgradedTo: string | null;
59
71
  next: string[];
60
72
  }
61
73
 
62
- function bunInstall(cwd: string): Promise<{ ok: boolean; output: string }> {
74
+ function run(cmd: string, args: string[], cwd: string, env?: NodeJS.ProcessEnv): Promise<{ ok: boolean; output: string }> {
63
75
  return new Promise((resolve) => {
64
- const child = spawn('bun', ['install'], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
76
+ const child = spawn(cmd, args, { cwd, env: env ?? process.env, stdio: ['ignore', 'pipe', 'pipe'] });
65
77
  let out = '';
66
78
  child.stdout.on('data', (b: Buffer) => { out += b.toString(); });
67
79
  child.stderr.on('data', (b: Buffer) => { out += b.toString(); });
80
+ child.on('error', (e) => resolve({ ok: false, output: `${out}\n${e.message}` }));
68
81
  child.on('close', (code) => resolve({ ok: code === 0, output: out.slice(-4000) }));
69
82
  });
70
83
  }
71
84
 
72
- /** 仓库内跑就用 workspace:*,否则用 studio 自己这份 gamekit777 的版本号 */
73
- function defaultSpec(dest: string): string {
85
+ /** 往上找 6 层,有 workspaces 的 package.json 就是在仓库内 */
86
+ export function inWorkspace(dest: string): boolean {
74
87
  let dir = dest;
75
88
  for (let i = 0; i < 6; i++) {
76
89
  dir = join(dir, '..');
@@ -78,42 +91,72 @@ function defaultSpec(dest: string): string {
78
91
  if (existsSync(pkg)) {
79
92
  try {
80
93
  const m = JSON.parse(readFileSync(pkg, 'utf8')) as { workspaces?: string[] };
81
- if (m.workspaces) return 'workspace:*';
94
+ if (m.workspaces) return true;
82
95
  } catch { /* 继续往上 */ }
83
96
  }
84
97
  }
85
- /* 仓库内这个文件在 packages/studio/src/,薄壳在 packages/gamekit777/;
86
- 压平进 npm 包后在 gamekit777/studio/src/,包根就是 gamekit777 本身。两种布局都试 */
87
- for (const rel of ['../../package.json', '../../../gamekit777/package.json']) {
88
- try {
89
- const m = JSON.parse(readFileSync(new URL(rel, import.meta.url), 'utf8')) as { name?: string; version?: string };
90
- if (m.name === 'gamekit777' && m.version) return `^${m.version}`;
91
- } catch { /* 下一个 */ }
92
- }
93
- return '*';
98
+ return false;
99
+ }
100
+
101
+ /** 仓库内跑就用 workspace:*,否则钉死成 studio 自己这份 gamekit777 的精确版本 */
102
+ export function defaultSpec(dest: string): string {
103
+ if (inWorkspace(dest)) return 'workspace:*';
104
+ return ownVersion() ?? 'latest';
105
+ }
106
+
107
+ /** 用 npm 上更新的那份代跑 `new`。精确版本的 bunx 目录不会被旧缓存顶掉 */
108
+ export function reexecArgs(latest: string, name?: string): { cmd: string; args: string[]; env: NodeJS.ProcessEnv } {
109
+ return {
110
+ cmd: 'bun',
111
+ args: ['x', `gamekit777@${latest}`, 'new', ...(name ? [name] : [])],
112
+ env: { ...process.env, [REEXEC_ENV]: latest },
113
+ };
114
+ }
115
+
116
+ const countFiles = (dir: string): number =>
117
+ readdirSync(dir).reduce((n, f) => {
118
+ if (f === 'node_modules' || f === '.git') return n;
119
+ const p = join(dir, f);
120
+ return n + (statSync(p).isDirectory() ? countFiles(p) : 1);
121
+ }, 0);
122
+
123
+ export function nextSteps(dest: string, id: string, inPlace: boolean, upgradedTo: string | null): string[] {
124
+ return [
125
+ inPlace
126
+ ? `就地生成在当前目录 ${dest}(id = ${id},取自目录名),不用 cd。是一个能跑的抛硬币;名字和标题在 game.meta.json 里改。`
127
+ : `游戏在 ${dest}(id = ${id}),是一个能跑的抛硬币。从能跑的东西开始改;名字和标题在 game.meta.json 里改。`,
128
+ ...(upgradedTo ? [`模板由 npm 上最新的 gamekit777@${upgradedTo} 生成并钉死在 package.json 里;之后在这个目录里跑的 gamekit777 命令都用项目里装的这份,不再受 bunx 缓存影响。正在运行的 studio 还是旧版,要在这个目录里重启它。`] : []),
129
+ `先读 ${join(dest, 'CLAUDE.md')}:清单字段、round() 的硬约束、modes/book/restore 怎么选、演出侧约定。`,
130
+ '改 src/rules/ 下的纯逻辑(零 DOM、零自取随机),改 src/components/ 下的演出。',
131
+ '已发布过的 mode 解码逻辑不能改,要改就换 slug;结果漂移由 studio/cases.jsonl 兜。',
132
+ '用 run_task 跑 check / test / verify;用 probe_table 看能不能打表;用 preview_play 在预览里演几局。',
133
+ ];
94
134
  }
95
135
 
96
136
  export async function scaffold(req: ScaffoldRequest, cwd: string): Promise<ScaffoldResult> {
97
137
  const { id, dest, inPlace } = resolveTarget(cwd, req.name);
98
- const r = scaffoldGame({ id, title: req.title ?? DEFAULT_TITLE, dest, gamekitSpec: req.gamekitSpec ?? defaultSpec(dest) });
138
+ const spec = req.gamekitSpec ?? defaultSpec(dest);
139
+
140
+ /* 只有「创作者机器 + 默认版本」这条路才查 npm:仓库内是 workspace,自验指定了 tarball,都不用 */
141
+ const latest = !req.gamekitSpec && spec !== 'workspace:*' && !req.noUpdate && !req.skipInstall ? await newerOnNpm() : null;
142
+ if (latest) {
143
+ const { cmd, args, env } = reexecArgs(latest, req.name);
144
+ const r = await run(cmd, args, cwd, env);
145
+ if (!r.ok) throw new Error(`用 gamekit777@${latest} 生成失败(当前这份是 ${spec}):\n${r.output}`);
146
+ return {
147
+ dest, id, inPlace, files: countFiles(dest), installed: true, installOutput: r.output,
148
+ gamekit: latest, upgradedTo: latest, next: nextSteps(dest, id, inPlace, latest),
149
+ };
150
+ }
151
+
152
+ const r = scaffoldGame({ id, title: req.title ?? DEFAULT_TITLE, dest, gamekitSpec: spec });
99
153
 
100
154
  let installed = false, installOutput = '';
101
155
  if (!req.skipInstall) {
102
- const i = await bunInstall(dest);
156
+ const i = await run('bun', ['install'], dest);
103
157
  installed = i.ok; installOutput = i.output;
104
158
  if (!i.ok) throw new Error(`bun install 失败:\n${i.output}`);
105
159
  }
106
160
 
107
- return {
108
- dest, id, inPlace, files: r.files.length, installed, installOutput,
109
- next: [
110
- inPlace
111
- ? `就地生成在当前目录 ${dest}(id = ${id},取自目录名),不用 cd。是一个能跑的抛硬币;名字和标题在 game.meta.json 里改。`
112
- : `游戏在 ${dest}(id = ${id}),是一个能跑的抛硬币。从能跑的东西开始改;名字和标题在 game.meta.json 里改。`,
113
- `先读 ${join(dest, 'CLAUDE.md')}:清单字段、round() 的硬约束、modes/book/restore 怎么选、演出侧约定。`,
114
- '改 src/rules/ 下的纯逻辑(零 DOM、零自取随机),改 src/components/ 下的演出。',
115
- '已发布过的 mode 解码逻辑不能改,要改就换 slug;结果漂移由 studio/cases.jsonl 兜。',
116
- '用 run_task 跑 check / test / verify;用 probe_table 看能不能打表;用 preview_play 在预览里演几局。',
117
- ],
118
- };
161
+ return { dest, id, inPlace, files: r.files.length, installed, installOutput, gamekit: spec, upgradedTo: null, next: nextSteps(dest, id, inPlace, null) };
119
162
  }
@@ -5,9 +5,7 @@
5
5
  *
6
6
  * 不往游戏目录写任何文件:两个 html 从内存吐,入口用 /@fs 指回 studio 包。
7
7
  * 不依赖游戏目录里有 index.html:脚手架出来的项目没有它,`bun run dev` 就是起 studio。 */
8
- import { readFileSync } from 'node:fs';
9
- import type { Plugin } from 'vite';
10
- import { compile } from 'svelte/compiler';
8
+ import type { DevEnvironment, Plugin, Rollup } from 'vite';
11
9
  import { API_PREFIX, createApi } from './api.ts';
12
10
  import type { Engine } from './engine.ts';
13
11
 
@@ -55,18 +53,36 @@ export function studioPlugin(o: StudioPluginOptions): Plugin {
55
53
  return null;
56
54
  },
57
55
 
58
- /* 资源库组件的 <style> 兜底。vite-plugin-svelte 把组件 CSS 存在模块信息里,样式虚拟模块
59
- (…svelte?svelte&type=style&lang.css)从那里取;组件还没编译过就先来要样式(重连、刷新、缓存未命中)时
60
- 它拿不到,退回读文件——整份 .svelte 源码被当 CSS 交给下游,namespace-css 直接炸页。
61
- 库组件在游戏根之外、内容自足,这里自己编一遍只取 CSS:scoped class 的哈希只由样式文本决定,和组件那次编译一致 */
62
- load(id) {
56
+ /* svelte 组件 <style> 的兜底。
57
+ vite-plugin-svelte 把编译出的 CSS 挂在组件模块的 meta 上,样式虚拟模块(…svelte?svelte&type=style&lang.css)
58
+ 用 getModuleInfo(不带 query 的文件名) 去取。两种情况取不到,它就放弃,vite 退回读文件——
59
+ 整份 .svelte 源码被当 CSS 交给下游:模板里的 `{#if}` 变成一条条空规则,`<style>` 里的选择器没有 hash,页面整个走样。
60
+ 1. 装成 npm 包后 gamekit777 在游戏的 node_modules 里,vite 给这类模块 id 加 ?v=<hash>,文件名对不上 id。
61
+ 仓库内是符号链接到 packages/,走 /@fs 路径没有 ?v=,所以只有创作者机器上才坏。
62
+ 2. 组件还没编译过就先来要样式(资源库现场编译、重连、刷新)。
63
+ 这里按文件名在模块图里找那次编译留下的 CSS;没有就先把组件本身 transform 一遍再找——
64
+ 不能自己 compile 一遍只取 CSS:dev 下 vite-plugin-svelte 用文件名算 scope hash(s-xxx),
65
+ 默认的按内容算(svelte-xxx),两边对不上,等于没修。 */
66
+ async load(id) {
63
67
  const m = /^([^?]+\.svelte)\?(?:.*&)?svelte(?:&.*)?&type=style(?:&|$)/.exec(id) ?? /^([^?]+\.svelte)\?(?:.*&)?type=style(?:&|$)/.exec(id);
64
68
  const file = m?.[1];
65
- if (!file || !file.startsWith(o.engine.assets.dir)) return null;
66
- try {
67
- const src = readFileSync(file, 'utf8');
68
- return { code: compile(src, { filename: file, generate: 'client', css: 'external' }).css?.code ?? '' };
69
- } catch { return null; }
69
+ if (!file) return null;
70
+ const env = this.environment;
71
+ if (env.mode !== 'dev') return null;
72
+ const dev = env as DevEnvironment;
73
+ const pick = (): { code: string; map: Rollup.SourceMapInput | undefined; moduleType: 'css' } | null => {
74
+ for (const mod of dev.moduleGraph.getModulesByFile(file) ?? []) {
75
+ // 插件写的 meta 落在 info.meta 上(vite 的 ModuleInfo 代理),mod.meta 是另一份、可能是空的
76
+ const meta = (mod.info?.meta ?? mod.meta) as { svelte?: { css?: { code?: string; map?: Rollup.SourceMapInput } } } | undefined;
77
+ const css = meta?.svelte?.css;
78
+ if (typeof css?.code === 'string') return { code: css.code, map: css.map, moduleType: 'css' };
79
+ }
80
+ return null;
81
+ };
82
+ const hit = pick();
83
+ if (hit) return hit;
84
+ try { await dev.transformRequest(`/@fs${file}`); } catch { return null; }
85
+ return pick();
70
86
  },
71
87
 
72
88
  configureServer(server) {
@@ -0,0 +1,62 @@
1
+ /* gamekit777 的三个版本:跑着的这份、npm 上最新的、创作者项目里装的。
2
+ *
3
+ * 只有 `new` 暴露在 bunx 的缓存下:bun 把裸 `gamekit777` 和 `@latest` 共用一个临时目录,
4
+ * 装过一次就一直用那份(实测隔天仍是旧版)。项目建好之后 `bunx gamekit777 studio` 优先用
5
+ * 项目 node_modules 里的那份,所以后面的命令天然和模板一致——要守的只有生成模板这一步。 */
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ const readVersion = (file: string | URL): string | null => {
10
+ try {
11
+ const m = JSON.parse(readFileSync(file, 'utf8')) as { name?: string; version?: string };
12
+ return m.name === 'gamekit777' && m.version ? m.version : null;
13
+ } catch { return null; }
14
+ };
15
+
16
+ /** 正在运行的这份 gamekit777 的版本。npm 包里 studio/src 往上两层就是包根;仓库内往上两层是 packages/,薄壳在它下面 */
17
+ export function ownVersion(): string | null {
18
+ for (const rel of ['../../package.json', '../../gamekit777/package.json']) {
19
+ const v = readVersion(new URL(rel, import.meta.url));
20
+ if (v) return v;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /** 某个游戏目录里装的 gamekit777 版本;没装或是 workspace 链接回仓库时也照读 */
26
+ export function installedVersion(gameDir: string): string | null {
27
+ const file = join(gameDir, 'node_modules', 'gamekit777', 'package.json');
28
+ return existsSync(file) ? readVersion(file) : null;
29
+ }
30
+
31
+ export const NPM_REGISTRY = process.env.GAMEKIT_NPM_REGISTRY ?? 'https://registry.npmjs.org';
32
+
33
+ /** npm 上的最新版。查不到(离线、超时、registry 挂了)返回 null,调用方按「不升级」处理 */
34
+ export async function latestVersion(timeoutMs = 5000): Promise<string | null> {
35
+ try {
36
+ const res = await fetch(`${NPM_REGISTRY}/gamekit777/latest`, { signal: AbortSignal.timeout(timeoutMs) });
37
+ if (!res.ok) return null;
38
+ const m = await res.json() as { version?: string };
39
+ return typeof m.version === 'string' ? m.version : null;
40
+ } catch { return null; }
41
+ }
42
+
43
+ /** 只比主.次.修订三段数字;预发布后缀忽略。a 比 b 新返回正数 */
44
+ export function compareVersions(a: string, b: string): number {
45
+ const pa = a.split('-')[0]!.split('.').map(Number);
46
+ const pb = b.split('-')[0]!.split('.').map(Number);
47
+ for (let i = 0; i < 3; i++) {
48
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
49
+ if (d !== 0) return d;
50
+ }
51
+ return 0;
52
+ }
53
+
54
+ /** 环境变量:由自升级出来的子进程带着,防止再套一层;也可手动设置跳过检查 */
55
+ export const REEXEC_ENV = 'GAMEKIT_NEW_REEXEC';
56
+
57
+ /** 跑着的比 npm 最新的旧就返回最新版号;查不到、在仓库内、已是子进程都返回 null */
58
+ export async function newerOnNpm(own: string | null = ownVersion()): Promise<string | null> {
59
+ if (!own || process.env[REEXEC_ENV]) return null;
60
+ const latest = await latestVersion();
61
+ return latest && compareVersions(latest, own) > 0 ? latest : null;
62
+ }
@@ -1,8 +0,0 @@
1
- {
2
- "id": "__ID__",
3
- "title": "__TITLE__",
4
- "version": "1.0.0",
5
- "aspect": "16:9",
6
- "orientation": "any",
7
- "fonts": []
8
- }
@@ -1,22 +0,0 @@
1
- {
2
- "extends": "@tsconfig/svelte/tsconfig.json",
3
- "compilerOptions": {
4
- "target": "ES2022",
5
- "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
- "module": "ESNext",
7
- "moduleResolution": "bundler",
8
- "strict": true,
9
- "noUnusedLocals": true,
10
- "noUnusedParameters": true,
11
- "noImplicitOverride": true,
12
- "noFallthroughCasesInSwitch": true,
13
- "noEmit": true,
14
- "isolatedModules": true,
15
- "verbatimModuleSyntax": true,
16
- "skipLibCheck": true,
17
- "forceConsistentCasingInFileNames": true,
18
- "allowImportingTsExtensions": true,
19
- "types": ["vite/client"]
20
- },
21
- "include": ["src", "test", "scripts", "*.ts"]
22
- }