gamekit777 0.1.9 → 0.1.12

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 (184) hide show
  1. package/README.md +2 -2
  2. package/client/index.ts +14 -0
  3. package/create-game/scaffold.ts +4 -5
  4. package/create-game/template/base/game.meta.json +2 -2
  5. package/create-game/template/base/game.ts +22 -33
  6. package/create-game/template/base/package.json +1 -4
  7. package/create-game/template/base/scripts/verify.ts +7 -1
  8. package/create-game/template/base/src/components/App.svelte +19 -42
  9. package/create-game/template/base/src/effects.ts +16 -0
  10. package/create-game/template/base/src/game.svelte.ts +24 -0
  11. package/create-game/template/base/src/index.ts +4 -18
  12. package/create-game/template/base/src/locales/en.json +10 -0
  13. package/create-game/template/base/src/locales/zh-CN.json +10 -0
  14. package/create-game/template/base/src/present.ts +41 -0
  15. package/create-game/template/base/src/rules/restore.ts +11 -27
  16. package/create-game/template/base/src/rules/schema.ts +9 -10
  17. package/create-game/template/base/src/rules/settings.ts +6 -0
  18. package/create-game/template/base/src/rules/types.ts +20 -6
  19. package/create-game/template/base/src/styles/global.css +2 -0
  20. package/create-game/template/base/src/use-game.ts +5 -0
  21. package/create-game/template/base/test/conformance.test.ts +11 -0
  22. package/create-game/template/base/test/e2e.test.ts +61 -15
  23. package/create-game/template/base/test/present.test.ts +44 -0
  24. package/create-game/template/base/vitest.config.ts +1 -1
  25. package/create-game/template/overlay/AGENTS.md +156 -0
  26. package/create-game/template/overlay/CLAUDE.md +1 -0
  27. package/dev-host/DevShell.svelte +92 -221
  28. package/dev-host/SchemaFields.svelte +4 -4
  29. package/dev-host/SchemaFieldsPure.svelte +36 -10
  30. package/dev-host/board-server.ts +58 -0
  31. package/dev-host/context.svelte.ts +3 -2
  32. package/dev-host/controls/AutoFields.svelte +25 -0
  33. package/dev-host/controls/Balance.svelte +66 -0
  34. package/dev-host/controls/BetField.svelte +30 -0
  35. package/dev-host/controls/BottomSheet.svelte +166 -0
  36. package/dev-host/controls/FloatText.svelte +23 -0
  37. package/dev-host/controls/Hud.svelte +196 -0
  38. package/dev-host/controls/Panel.svelte +78 -0
  39. package/dev-host/controls/StageActions.svelte +53 -0
  40. package/dev-host/controls/index.ts +8 -0
  41. package/dev-host/controls/model.ts +69 -0
  42. package/dev-host/controls/motion.ts +12 -0
  43. package/dev-host/index.ts +3 -0
  44. package/dev-host/layout.ts +1 -1
  45. package/dev-host/local.ts +8 -7
  46. package/dev-host/platform-server.ts +3 -4
  47. package/dev-host/platform.svelte.ts +333 -65
  48. package/dev-host/remote.ts +8 -8
  49. package/dev-host/run.ts +9 -9
  50. package/dev-host/settings.ts +52 -0
  51. package/dev-host/sim.ts +4 -0
  52. package/dev-host/staged-server.ts +125 -0
  53. package/dev-host/table-server.ts +8 -9
  54. package/dev-host/theme.css +2 -24
  55. package/docs/assets.md +64 -0
  56. package/docs/contract.md +69 -0
  57. package/docs/tables.md +34 -0
  58. package/docs/view.md +627 -0
  59. package/docs/workflow.md +22 -0
  60. package/package.json +9 -3
  61. package/protocol/bundles.ts +13 -1
  62. package/protocol/errors.ts +2 -0
  63. package/protocol/games.ts +3 -0
  64. package/protocol/index.ts +1 -0
  65. package/protocol/runtime.ts +58 -0
  66. package/publish/build.ts +6 -11
  67. package/publish/index.ts +1 -0
  68. package/publish/runtime-check.ts +57 -0
  69. package/publish/sample-worker.ts +3 -3
  70. package/publish/sample.ts +6 -40
  71. package/publish/spec.ts +5 -4
  72. package/publish/upload-core.ts +96 -27
  73. package/publish/upload.ts +26 -8
  74. package/runtime/assets.ts +18 -22
  75. package/runtime/audio.ts +9 -0
  76. package/runtime/bundle-loader.ts +213 -0
  77. package/runtime/dom.ts +27 -0
  78. package/runtime/fonts.ts +3 -5
  79. package/runtime/i18n.ts +99 -0
  80. package/runtime/index.ts +8 -6
  81. package/runtime/loader.ts +6 -0
  82. package/runtime/locales.ts +13 -0
  83. package/runtime/meter.ts +5 -13
  84. package/runtime/preload.ts +5 -11
  85. package/runtime/registry.ts +41 -71
  86. package/runtime/types.ts +4 -4
  87. package/sdk/assets.ts +5 -8
  88. package/sdk/board.ts +156 -0
  89. package/sdk/bundle.ts +81 -0
  90. package/sdk/clone.ts +36 -0
  91. package/sdk/contract.ts +45 -29
  92. package/sdk/ease.ts +129 -0
  93. package/sdk/effect.ts +283 -0
  94. package/sdk/env.ts +5 -0
  95. package/sdk/fx.ts +71 -0
  96. package/sdk/host.ts +97 -24
  97. package/sdk/index.ts +31 -10
  98. package/sdk/platform.ts +23 -0
  99. package/sdk/pool.ts +57 -0
  100. package/sdk/rng.ts +2 -6
  101. package/sdk/sample.ts +2 -10
  102. package/sdk/schema.ts +51 -27
  103. package/sdk/sound.ts +49 -0
  104. package/sdk/spec.ts +250 -88
  105. package/sdk/stage.ts +18 -39
  106. package/sdk/staged.ts +93 -0
  107. package/sdk/store.ts +0 -16
  108. package/sdk/tempo.ts +9 -0
  109. package/sdk/types.ts +21 -0
  110. package/studio/app/App.svelte +17 -5
  111. package/studio/app/lib/api.ts +3 -1
  112. package/studio/app/lib/bus.svelte.ts +3 -1
  113. package/studio/app/panels/PublishPanel.svelte +2 -2
  114. package/studio/app/virtual.d.ts +2 -2
  115. package/studio/preview/bridge.ts +5 -2
  116. package/studio/preview/entry.ts +92 -10
  117. package/studio/preview/virtual.d.ts +3 -3
  118. package/studio/src/api.ts +7 -3
  119. package/studio/src/bus.ts +43 -14
  120. package/studio/src/cdp.ts +71 -0
  121. package/studio/src/engine.ts +107 -30
  122. package/studio/src/game-vite.ts +5 -8
  123. package/studio/src/game.ts +9 -10
  124. package/studio/src/headless.ts +130 -0
  125. package/studio/src/mcp.ts +62 -22
  126. package/studio/src/perf.ts +214 -0
  127. package/studio/src/probe.ts +1 -7
  128. package/studio/src/scaffold.ts +2 -2
  129. package/studio/src/studio-plugin.ts +10 -3
  130. package/studio/src/worker/rpc.ts +7 -11
  131. package/svelte/AnimLayer.svelte +65 -0
  132. package/svelte/Queue.svelte +222 -0
  133. package/svelte/Root.svelte +37 -0
  134. package/svelte/audio.ts +251 -0
  135. package/svelte/bridge.svelte.ts +29 -0
  136. package/svelte/clock.ts +136 -0
  137. package/svelte/co.ts +194 -0
  138. package/svelte/compose.ts +57 -0
  139. package/svelte/conformance.ts +741 -0
  140. package/svelte/context.svelte.ts +41 -0
  141. package/svelte/diff.ts +29 -0
  142. package/svelte/dom.ts +39 -0
  143. package/svelte/effects.svelte.ts +537 -0
  144. package/svelte/fx/BigWin.svelte +57 -0
  145. package/svelte/fx/FloatText.svelte +34 -0
  146. package/svelte/fx/restart.ts +11 -0
  147. package/svelte/game.svelte.ts +294 -0
  148. package/svelte/idle.ts +89 -0
  149. package/svelte/index.ts +16 -0
  150. package/svelte/keyframes.ts +71 -0
  151. package/svelte/mount.svelte.ts +66 -0
  152. package/svelte/particle-gl.ts +240 -0
  153. package/svelte/particle.ts +243 -0
  154. package/svelte/particles.svelte.ts +71 -0
  155. package/svelte/present.svelte.ts +263 -0
  156. package/svelte/render.svelte.ts +71 -0
  157. package/svelte/round-game.svelte.ts +36 -0
  158. package/svelte/rounds.svelte.ts +102 -0
  159. package/svelte/scope.ts +55 -0
  160. package/svelte/stage.svelte.ts +108 -0
  161. package/svelte/staged-game.svelte.ts +84 -0
  162. package/svelte/staged.ts +133 -0
  163. package/svelte/tape.ts +86 -0
  164. package/svelte/testing.ts +81 -0
  165. package/svelte/timeline.ts +125 -0
  166. package/svelte/view.ts +57 -0
  167. package/vite-config/entry.ts +81 -0
  168. package/vite-config/index.js +840 -68
  169. package/vite-config/index.ts +78 -37
  170. package/vite-config/manifest.ts +122 -85
  171. package/vite-config/no-globals.ts +60 -0
  172. package/vite-config/no-random.ts +71 -0
  173. package/vite-config/svelte-pin.ts +27 -0
  174. package/vite-config/svelte-subset.ts +55 -0
  175. package/create-game/template/base/src/assets/critical/.gitkeep +0 -0
  176. package/create-game/template/base/src/assets/lazy/.gitkeep +0 -0
  177. package/create-game/template/base/src/context.svelte.ts +0 -22
  178. package/create-game/template/base/src/rules/simulate.ts +0 -9
  179. package/create-game/template/base/src/state/game.svelte.ts +0 -54
  180. package/create-game/template/base/src/styles/animations.css +0 -13
  181. package/create-game/template/base/src/view/bridge.svelte.ts +0 -33
  182. package/create-game/template/base/src/view/mount.svelte.ts +0 -67
  183. package/create-game/template/base/src/view/present.ts +0 -45
  184. package/create-game/template/overlay/CLAUDE.md.tmpl +0 -180
@@ -4,17 +4,23 @@
4
4
  平台来自 gamekit777/dev-host,是泛型的——所有游戏共用同一份。
5
5
  服务端也是真平台那条路:浏览器里就地建表、按权重抽行、restore 还原。 */
6
6
  import { test, expect, afterEach } from 'vitest';
7
- import { tick } from 'svelte';
8
7
  import { createPlatform, localServer } from 'gamekit777/dev-host';
9
- import { defineGame } from 'gamekit777/sdk';
10
- import game from '../src/index.ts';
11
- import type { CoinFlipConfig as C, CoinFlipOutcome as O } from '../src/rules/types.ts';
8
+ import { composeGame, defineGame } from 'gamekit777/svelte';
9
+ import { EMPTY_ASSETS } from 'gamekit777/svelte/test';
10
+ import def from '../game.ts';
11
+ import zhCN from '../src/locales/zh-CN.json' with { type: 'json' };
12
+ import en from '../src/locales/en.json' with { type: 'json' };
13
+ import type { CoinFlip } from '../src/rules/types.ts';
14
+
15
+ /* 浏览器入口平时由构建预设生成;测试里同样拼一次,走的是同一个 composeGame */
16
+ /* happy-dom 里没有服务器可 fetch 语言包,直接给字典 */
17
+ const game = composeGame(def, { assets: EMPTY_ASSETS.bundle(), locales: { 'zh-CN': zhCN, en } });
12
18
 
13
19
  const live: { destroy(): void }[] = [];
14
20
  afterEach(() => { while (live.length) { try { live.pop()!.destroy(); } catch { /* 已经拆过了 */ } } });
15
21
 
16
22
  const boot = async () => {
17
- const p = createPlatform<C, O>(game);
23
+ const p = createPlatform<CoinFlip>(game);
18
24
  // 和真实宿主一样先握手:余额、注额区间都在服务端那边
19
25
  await p.init();
20
26
  const el = document.createElement('div');
@@ -22,7 +28,7 @@ const boot = async () => {
22
28
  const inst = await game.mount(p.host(), el);
23
29
  live.push(inst);
24
30
  inst.status.subscribe(s => p.setGameStatus(s));
25
- p.bindGame({ present: r => inst.present(r), status: () => p.game });
31
+ p.bindGame(inst);
26
32
  return { p, el, inst };
27
33
  };
28
34
 
@@ -31,7 +37,7 @@ test('挂载 → 下注 → 演完 → 余额按 payout 变化', async () => {
31
37
  expect(p.game.phase).toBe('idle');
32
38
  expect(el.querySelector('.coin')).toBeTruthy();
33
39
 
34
- p.setView({ reducedMotion: true });
40
+ p.setSettings({ reducedMotion: true });
35
41
  p.setBet(1000);
36
42
 
37
43
  const before = p.balance;
@@ -42,12 +48,11 @@ test('挂载 → 下注 → 演完 → 余额按 payout 变化', async () => {
42
48
  test('平台改配置 → 游戏画布跟着变', async () => {
43
49
  const { p, el } = await boot();
44
50
  p.requestConfig({ side: 'tails' });
45
- await tick();
46
51
  expect(el.querySelector('.head b')?.textContent).toBe('反');
47
52
  });
48
53
 
49
54
  test('表模型:还原出的赔付必须等于平台记账,差一分也要拦住', async () => {
50
- const srv = localServer<C, O>({ spec: game, sims: 500 });
55
+ const srv = localServer<CoinFlip>({ spec: def, sims: 500 });
51
56
  const before = (await srv.init()).balance;
52
57
  const r = await srv.place({ config: { side: 'heads' }, bet: 1000, clientSeed: 'c' });
53
58
  expect(r.ok).toBe(true);
@@ -57,23 +62,22 @@ test('表模型:还原出的赔付必须等于平台记账,差一分也要
57
62
  expect(r.round.checks.every((c) => c.ok)).toBe(true);
58
63
 
59
64
  /* 同一份清单,只把 restore 换成会多赔一分的:钱已经按表记了,演出不能演 */
60
- const tampered = defineGame<C, O>({ ...game, restore: (i) => { const x = game.restore(i); return { ...x, payout: x.payout + 1 }; } });
61
- const bad = localServer<C, O>({ spec: tampered, sims: 500 });
65
+ const tampered = defineGame<CoinFlip>({ ...def, restore: (i) => { const x = def.restore(i); return { ...x, mult: x.mult + 0.01 }; } });
66
+ const bad = localServer<CoinFlip>({ spec: tampered, sims: 500 });
62
67
  await bad.init();
63
68
  const v = await bad.place({ config: { side: 'heads' }, bet: 1000, clientSeed: 'c' });
64
69
  expect(v.ok).toBe(false);
65
- if (!v.ok) expect(v.error).toMatch(/不符/);
70
+ if (!v.ok) expect(v.error).toMatch(/分叉|不符/);
66
71
  });
67
72
 
68
73
  test('同页两个实例互不干扰', async () => {
69
74
  const a = await boot();
70
75
  const b = await boot();
71
- a.p.setView({ reducedMotion: true });
72
- b.p.setView({ reducedMotion: true });
76
+ a.p.setSettings({ reducedMotion: true });
77
+ b.p.setSettings({ reducedMotion: true });
73
78
 
74
79
  a.p.requestConfig({ side: 'heads' });
75
80
  b.p.requestConfig({ side: 'tails' });
76
- await tick();
77
81
  expect(a.el.querySelector('.head b')?.textContent).toBe('正');
78
82
  expect(b.el.querySelector('.head b')?.textContent).toBe('反');
79
83
 
@@ -82,3 +86,45 @@ test('同页两个实例互不干扰', async () => {
82
86
  expect(b.p.balance).toBe(100_000); // b 的钱包没被动过
83
87
  expect(b.p.game.phase).toBe('idle');
84
88
  });
89
+
90
+ test('游戏声明的设置从 host.settings 进来:关掉弹窗就不弹,开着赢了就有牌子', async () => {
91
+ const { p, el } = await boot();
92
+ p.setSettings({ reducedMotion: true, toast: false } as never);
93
+ p.setBet(1000);
94
+ for (let i = 0; i < 8; i++) {
95
+ await p.playOne();
96
+ expect(el.querySelector('.bigwin:not([hidden])')).toBeNull();
97
+ }
98
+ p.setSettings({ toast: true } as never);
99
+ let won = false;
100
+ for (let i = 0; i < 12 && !won; i++) {
101
+ await p.playOne();
102
+ if ((p.game.lastPayout ?? 0) > 0) {
103
+ won = true;
104
+ /* 大奖牌是按需加载的分块(mount 时预取):测试里第一次要现编译,等它到位 */
105
+ for (let t = 0; t < 50 && !el.querySelector('[data-gk-layer] .bigwin.t0'); t++) { await new Promise((r) => setTimeout(r, 20)); }
106
+ expect(el.querySelector('[data-gk-layer] .bigwin.t0')).toBeTruthy();
107
+ }
108
+ }
109
+ expect(won).toBe(true);
110
+ });
111
+
112
+ test('语言跟着 settings.locale 走', async () => {
113
+ const { p, el } = await boot();
114
+ p.setSettings({ locale: 'en' });
115
+ await new Promise((r) => setTimeout(r, 50));
116
+ expect(el.querySelector('.foot')?.textContent).toMatch(/Pick a side/);
117
+ });
118
+
119
+ test('destroy 之后容器干净、没有残留的定时器把状态写回来', async () => {
120
+ const { p, el, inst } = await boot();
121
+ p.setSettings({ reducedMotion: true });
122
+ p.setBet(1000);
123
+ await p.playOne();
124
+ inst.destroy();
125
+ live.pop();
126
+ expect(el.childNodes.length).toBe(0);
127
+ expect(el.className).toBe('');
128
+ await new Promise((r) => setTimeout(r, 1000));
129
+ expect(el.childNodes.length).toBe(0);
130
+ });
@@ -0,0 +1,44 @@
1
+ /* 编排的单测:不挂 App、不等时间。结果用清单的 round 按种子出,和平台给的一样;drain 直接看结果把一局演完,断言画面上是什么 */
2
+ import { test, expect } from 'vitest';
3
+ import { defineGame } from 'gamekit777/sdk';
4
+ import { headless, drain } from 'gamekit777/svelte/test';
5
+ import manifest from '../game.ts';
6
+ import { Game } from '../src/game.svelte.ts';
7
+
8
+ const def = defineGame(manifest);
9
+ const at = (side: 'heads' | 'tails', nonce: number) =>
10
+ def.round({ config: def.normalize({ side }), bet: 1000, seed: { server: 'present', client: 'c', nonce } });
11
+ /** 找一局赢的 / 输的 */
12
+ const find = (win: boolean) => { for (let n = 0; ; n++) { const r = at('heads', n); if (r.outcome.win === win) return r; } };
13
+
14
+ test('挂载时:正面朝上、没有上一局', () => {
15
+ expect(headless(def, Game).state).toEqual({ face: 'heads', last: null });
16
+ });
17
+
18
+ test('一局之后:硬币停在开出的那面,记下这一局', async () => {
19
+ const g = headless(def, Game);
20
+ const r = at('tails', 1);
21
+ await drain(g, r);
22
+ expect(g.state).toEqual({ face: r.outcome.landed, last: r.outcome });
23
+ });
24
+
25
+ test('赢了弹牌子、响赢的音;输了只响输的音', async () => {
26
+ const win = await drain(headless(def, Game), find(true));
27
+ expect(win.map((c) => c.name)).toEqual(expect.arrayContaining(['win', 'plate']));
28
+ const lose = await drain(headless(def, Game), find(false));
29
+ expect(lose.map((c) => c.name)).toContain('lose');
30
+ expect(lose.map((c) => c.name)).not.toContain('plate');
31
+ });
32
+
33
+ test('关掉弹窗设置就不弹牌子', async () => {
34
+ const fx = await drain(headless(def, Game, { settings: { toast: false } }), find(true));
35
+ expect(fx.map((c) => c.name)).not.toContain('plate');
36
+ });
37
+
38
+ test('收掉上一局(作废):结果不作数,硬币不动', async () => {
39
+ const g = headless(def, Game);
40
+ const r = at('tails', 4);
41
+ await drain(g, r);
42
+ g.invalidate();
43
+ expect(g.state).toEqual({ face: r.outcome.landed, last: null });
44
+ });
@@ -4,7 +4,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
4
4
  /* 测试必须走 Vite 管线:gamekit777/runtime 的资源注册表用了 import.meta.glob(Vite 特性,按 root 找 src/assets/),
5
5
  组件和 .svelte.ts 里的 runes 也要 Svelte 编译器处理。 */
6
6
  export default defineConfig({
7
- plugins: [svelte({ hot: false })],
7
+ plugins: [svelte({ hot: false, compilerOptions: { runes: true } })],
8
8
  test: { environment: 'happy-dom', include: ['test/**/*.test.ts'], testTimeout: 30_000 },
9
9
  resolve: { conditions: ['browser'] }
10
10
  });
@@ -0,0 +1,156 @@
1
+ # __TITLE__(`__ID__`)— 写给 AI 的游戏开发指南
2
+
3
+ <!-- 规则只写在这一份里;CLAUDE.md 只是 `@AGENTS.md` 一行引用。细节按需读 node_modules/gamekit777/docs/ 下的文件(随库的版本走,不在项目里) -->
4
+
5
+ 这是一个 GameKit 游戏目录。平台**不执行游戏代码**:打包时用你的 `round()` 采样出一张权重表,
6
+ 运行时平台按权重抽一行、查表赔付,浏览器用你的 `restore()` 把那一行还原成演出。
7
+
8
+ 先读这份文件,再读 `game.ts`。这个目录是平台参考游戏(猜正反面的那个)的**完整拷贝**换了名字——它能跑、能建表、能发布;
9
+ 从能跑的东西开始改,不要从零写。源码里带「改成你自己的游戏时」的注释就是给你的路标。
10
+
11
+ ## 一条原则:演出必须能回放
12
+
13
+ 一局的画面只能是「表里那一行」的函数。同一行,在下注当场、在历史列表里点开、从分享链接进来、换一台设备,
14
+ 演出来的必须是同一局:同样的牌、同样的连线、同样的赔付、同样的粒子和台词。下面所有约束都是这一条推出来的:
15
+
16
+ - **`round()` 是纯函数**,随机只来自注入的 rng。作者机器采样、玩家浏览器还原、审计脚本复算,三处逐位一致。
17
+ - **`restore()` 把一行还原成 `RoundResult`**,返回的 `payout` 必须恰好等于 `(bet / 100) × payoutCenti`,对不上就 `throw`、不演出。
18
+ - **`present(result)` 只看 `result`**,不看上一局、不看余额、不看玩家当下的设置。回放是在一个全新的实例里跑,那里什么都没有。
19
+ - **装饰性随机也不许用 `Math.random`**(粒子角度、台词抽签):编排里 `ctx.rand`——按这一局的种子播种,回放时一模一样;局外(发呆、交互)用 `game.ambient`。
20
+ 构建时扫源码,出现 `Math.random` / `crypto.getRandomValues` 直接构建失败。
21
+
22
+ 「玩家看到连成三条线,余额却没动」「分享出去的一局,别人看到另一副牌」——这类 bug 完全静默、最伤信任,所以每一条都有工具兜着:
23
+ `scripts/verify.ts` 把 `Math.random` / `crypto` 换成会抛的桩,平台在演出前逐分比对 payout,`case_run` 盯任何结果漂移。
24
+
25
+ ## 目录
26
+
27
+ ```
28
+ game.ts 唯一入口:export default { meta, schema, settings, modes, book, paytable, round, restore, view } satisfies GameManifest<CoinFlip>。纯对象、只有类型导入。Node 与浏览器都 import 它,不能碰 DOM / Svelte
29
+ src/rules/ 纯逻辑:schema、settings(as const,类型从它们反推)、restore、常量、types.ts(一个接口 CoinFlip 描述整个游戏)。零 DOM、零自取随机
30
+ src/index.ts 演出模块:import 全局样式、把游戏类 Game 作为默认导出,game.ts 的 view 惰性指向它
31
+ src/present.ts definePresent({ tempo, run, settle, concurrent?, interval? }):一局怎么演。run / settle 是 function*,要等什么就 yield 什么(ctx.delay / tween / 子时间线),只写 game.state 和发效果 ctx.fx.<名字>()
32
+ src/effects.ts 效果表 defineEffects(声音、keyframes、组件、粒子、台词)与关键帧表 defineKeyframes(状态外观):每样演出只在这里声明一次
33
+ src/use-game.ts export const useGame: () => Instance<CoinFlip> = useGame(框架的泛型版钉上类型)
34
+ src/game.svelte.ts 游戏类 extends RoundGame<CoinFlip>(多阶段用 StagedGame、带台面用 BoardGame):App、演出把手、两张表、initial(game.state 的初值)是它的静态字段;类上只放三样:声明(静态字段、锚点把手);编排和组件都要用的派生与改 state 的方法;玩家在画布上的选择与交互。state / config / settings 的字段不再包 getter(模板直接读 game.state.x),只有组件用的尺寸、颜色、格式化写在组件里。平台状态(game.config / settings / run / stage)直接读,要随它变的用 $derived,生命周期钩子 onMount / onReset / onDestroy 是它上面的方法
35
+ src/components/ 画面。子组件一律 useGame()(src/use-game.ts:框架的 useGame 钉上 CoinFlip 类型再导出),不 import 状态对象
36
+ src/locales/ 文案:zh-CN.json、en.json,扁平 key-value,组件用 t(key, params)
37
+ src/assets/ 图片、声音随意放,import 就进清单。加载、进度、预取见 node_modules/gamekit777/docs/assets.md
38
+ scripts/verify.ts 数学回归:纯度、确定性、RTP、分布
39
+ test/present.test.ts 编排的单测:headless + drain 直接看结果演完一局,断言 game.state 与调过的效果。不挂载、不等时间
40
+ test/e2e.test.ts happy-dom 里跑完整一局
41
+ test/conformance.test.ts 演出层一致性套件,一行引入;bun run test 强制跑,不要删
42
+ studio/cases.jsonl case 库(进版本控制),studio 维护
43
+ .studio/ 采样缓存与建好的表,按规则指纹分目录,可随手删
44
+ ```
45
+
46
+ `src/index.ts` 是演出模块,不是浏览器入口——入口由构建预设生成(虚拟模块,静态 import 它)。**改名**只改 `game.meta.json` 的 `id`(小写短横线,同时是平台上的 slug、产物目录名、CSS 命名空间)和 `title`。
47
+ `gameId` 是平台第一次发布时分配并写回的身份,不要手改、不要删。已发布过的 slug 不能改——那是另一个游戏。
48
+
49
+ ## 硬约束
50
+
51
+ - `round({ config, rng })` 同步纯函数、只用注入的 rng、`rng.fork('tag')` 隔开子系统,只回 `{ outcome, mult }`;`mult` 最多两位小数。钱由框架算(`bet / 100 × round(mult × 100)`),游戏不碰。
52
+ - `restore()` 同样只回 `{ outcome, mult }`;框架拿 `mult` 和表里那一行的 `payoutCenti` 核对,对不上抛错不演。
53
+ - 一个接口描述整个游戏:`interface CoinFlip { config; outcome; settings; state; game }`,config / settings 从 `as const satisfies ConfigSchema / SettingsSchema` 的声明用 `InferConfig` / `InferSettings` 反推,不手写。所有泛型只写这一个名字。
54
+ - 影响结果的字段进 `schema`;只影响演出的进 `settings`;影响赔付分布的字段写进 `modes`。三者的键互不重叠。
55
+ - 不写 `normalize` / `validate` / `modeOf`,不加版本号。**已发布过的 mode,发牌逻辑、book 编解码、取整方式都不能再改**,要改就换 slug。
56
+ - **编排是生成器**:`*run(ctx, g, r)`、`*settle`、`*idle`,要等什么就 `yield` 什么——`yield ctx.delay(ms)` / `ctx.tween(…)` / 子时间线;辅助函数也写 `function*`,调用处 `yield* helper(…)`(漏了 `yield*` 它一行都不跑)。不写 `async` / `await` / `setTimeout` / `sleep`:快进要不等、作废要不再往下跑,自己写的等做不到。
57
+ - 装饰随机只用 `rand`(`ctx.rand` / `game.ambient`),`Math.random` 构建期拦。
58
+ - **演出里的每样东西先分类**(跳过它终态会不会错?):画面上是什么写 `game.state`;状态外观进关键帧表、`class:` 打开;一次性事件进效果表、`ctx.fx.<名字>()`。详见 `node_modules/gamekit777/docs/view.md`「先分清三类东西」。
59
+ - **画面上是什么全在 `game.state`**(结果、翻到一半的那面、在飞的牌的坐标、滚到一半的数字、在落的球),**编排只写它和发效果**:直接给游戏类的字段赋值(`g.shown = 1`)开发期当场抛。
60
+ 一致性套件会拿同一局按正常演完、演到一半快进、直接看结果各演一遍,比最后的 `game.state`,不一样就报字段。所以:结果无条件写(不包在 `if (!ctx.quiet())` 里);只在动画中途有意义的字段演完回到固定值;画面上的数字照抄 outcome 里现成的值,不在编排里重算;效果的钩子(`effect.data` 的 `end`)不写 state。
61
+ - **演出里触发效果只用 `ctx.fx`**:它过这一局的闸、按这一局派发随机流。`game.fx` 是局外的把手:模板里 `{@attach game.fx.x}` 绑目标、发呆与交互时触发。
62
+ - **一次演几局由 `definePresent({ concurrent, interval })` 定**:默认 1(新局开始时清场);连着下的游戏(Plinko)写 `concurrent: Infinity, interval: 120`,演出中也能下注。
63
+ 这时每局自己的东西(球、尾迹)放进 `game.state` 里的数组、演完移除,共享的盘面状态(发光的格子)按局合并。详见 `node_modules/gamekit777/docs/view.md`「同时演几局」。
64
+ - **动画先定级别**:有没有别的东西要等它演完?有就是编排,写进演出把手的时间线;没有就是状态外观,演出把手只写状态,过渡交给组件。一排会挪动的格子(开奖历史、历史牌)用框架的 `Queue`,不用 Svelte 的 `animate:` / `transition:`。一个元素的一个属性只有一个主人,两样都要动就拆两层。详见 `node_modules/gamekit777/docs/view.md`「一个动画归哪一级」。
65
+ - 画布里只改演出状态、不经平台的交互(切视图、选面值、翻页)在处理函数里调 `game.touch()`,否则空闲编排可能刚好到点,把玩家刚打开的东西收回去。
66
+ - 声音、动画、粒子、台词只经效果表:不手写 `node.animate`、不自己注 `@keyframes`、不 `new AudioContext`、不碰 `<audio>`。它们因此自动服从快进、追赶、直接看结果、倍率与减弱动效。
67
+ - **效果不用判 `ctx.quiet()`**:快进、从后台回来追赶时框架丢掉非 always 的效果;结算要看到、听到的标 `always`(声明时或调用处)。粒子没有 always。`ctx.quiet()` 只用来跳过游戏自己的装饰(压牌前多停一下),结果不许看它决定写不写。
68
+ - 不为触发动画在状态里养计数器(`shakeToken++`):模板里 `{@attach game.fx.shake}` 绑目标,present 里 `ctx.fx.shake()` 就播。
69
+ - 要让框架知道一个元素,只有一种写法:`{@attach 把手}`。动画目标挂效果把手(`{@attach game.fx.shake}`、`{@attach game.fx.peg.at(k)}`);粒子、飘字的位置挂锚点把手(游戏类上 `readonly ticket = this.stage.anchor()` / `cells = this.stage.anchors()`,模板 `{@attach game.ticket}`,present 里 `at: game.ticket.or('center')`,`or` 必填)。不 `querySelector`、不在模块里存元素。
70
+ - 读 DOM 只经锚点把手:为 state 量坐标在协程里直接 `g.x.box()`(舞台逻辑像素)或 `g.x.el`,写完 state 马上量就是新的(框架先刷新),量不到(单测里没有 DOM)是 null 要容忍;装饰写成 `effect.dom`(框架在这一批刷新之后执行)。量出来的值只管画在哪,不决定 state 里有什么:state 里的列表按固定上限裁,一排摆得下几张是组件按量到的宽度决定的(摆不下的藏起来)。框架保证 keyframes / 粒子这类读 DOM 的效果在刷新之后执行,所以先写状态、再 fire 总是对的。
71
+ - 只写 Svelte 5 runes(`$state` / `$props` / `$derived` / `onclick`):编译器钉了 `runes: true`,`export let`、`$:`、`on:click` 直接编译报错。
72
+ - 所有 import 都写 `gamekit777/svelte`(测试多一个 `gamekit777/svelte/test`)。`game.ts` / `rules/` 在 Node 里跑,那里只能有 `import type`——清单是纯对象,`round` 收到的 `input.config` 已填满、`input.rng` 已播好,不需要任何函数。例外是 `scripts/verify.ts` 与测试(要 `defineGame` / `createRng` 的值),从 `gamekit777/sdk` 拿。`gamekit777/sdk/platform` 与 `gamekit777/runtime` 是平台和工具的面,游戏里不出现。
73
+ - 模块级不留可变状态;**不许直接碰 `window` / `document`,不许裸写 `setTimeout` / `addEventListener` / `new ResizeObserver`**(构建期拦):要重叠就用子时间线 `ctx.timeline()`、局外的延迟用 `g.scheduler.after`(按实例时钟),监听、观察器用 `g.addEventListener / g.observe`(返回清理句柄),作废或 `destroy` 时框架统一清。平台会在同一个沙箱里装下一个游戏,漏一个就累积。
74
+ 框架的部分 `invalidate()` 已清;自己加的定时器从 `onMount` 返回 cleanup。一致性套件会抓。
75
+ - 两块舞台都要支持:桌面 900×771、手机 450×H;安全区外不放交互。
76
+ - reset 之后画面必须是开局前的样子:框架把 `game.state` 放回开局前、再调 `onReset()` 收掉上一局(只清这一局的结果,跨局要留的——战绩、硬币停在哪面——留着;不写 `onReset` 就整个回到 `initial`)。画面上的东西都在 state 里才归得了位。平台切换、回放、离开都会 reset,`preview_perf` 也按 reset 后的画面量残留。本场战绩这类跨局的 UI 要有上限。
77
+ - 少创建销毁 DOM:固定槽位数组代替 push / shift,状态切换用 class 不用 `{#if}`,`{#each}` 必带稳定 key,一次性效果进效果表(组件型效果挂上不卸,槽位复用)。
78
+ - 资源只经 vite import 引用,不在数据里写路径查表;`() => import()` 只给玩家主动操作才出现的 UI(规则弹窗、二级界面、极少触发的大场面),一局主流程里的东西不拆。
79
+
80
+ ## 演出层(最小示例;三类东西、时间线、效果选项、反例清单见 `node_modules/gamekit777/docs/view.md`)
81
+
82
+ ```ts
83
+ // src/effects.ts —— 每样演出只声明一次
84
+ export const effects = defineEffects({
85
+ flip: effect.sound(sound.tone({ f: 880, dur: 0.05 })), // 过程效果:快进、追赶时框架丢掉
86
+ win: effect.sound({ src: winMp3, fallback: sound.chord({ notes: [659, 784, 988], step: 0.08, dur: 0.28 }) }, { always: true }), // 结果效果
87
+ shake: effect.keyframes({ ms: 320, frames: [...] }), // 元素补间:模板 {@attach g.fx.shake} 绑目标,ctx.fx.shake() 播
88
+ linein: effect.keyframes({ ms: 160, frames: [{ opacity: 0 }, { opacity: 1 }] }), // 或 {@attach g.fx.linein.on(token)}:token 变了就播
89
+ plate: effect.builtin('big-win', { always: true }), // 内置牌子,画在框架的覆盖层上
90
+ spark: effect.particle('burst', { count: 16 }), // 粒子:纯装饰,没有 always
91
+ say: effect.data<string>({ slots: 1, hold: 800 }), // 台词:模板读 game.fx.say.current
92
+ });
93
+ export const keyframes = defineKeyframes({
94
+ spin: { ms: 420, frames: [{ transform: 'scale(1)' }, { transform: 'scale(1.1)', offset: 0.5 }, { transform: 'scale(1)' }] }, // 状态外观:class:spin
95
+ });
96
+
97
+ // src/game.svelte.ts —— 画面上是什么在 game.state(形状写在 rules/types.ts 的 state 键),模板直接读;类上只放声明、共用的派生、画布交互
98
+ export class Game extends RoundGame<CoinFlip, typeof effects, typeof keyframes> {
99
+ static override App = App; // 演出侧只声明这一个类:App、演出把手、两张表都是静态字段
100
+ static override presenter = presenter;
101
+ static override effects = effects;
102
+ static override keyframes = keyframes;
103
+ static override initial(): CoinFlip['state'] { return { face: 'heads', last: null }; } // game.state 的初值
104
+ readonly coin = this.stage.anchor(); // 锚点把手:模板 {@attach game.coin}
105
+ protected override onReset(): void { this.state.last = null; } // 收掉上一局:结果不作数了,硬币停在哪面还是哪面
106
+ }
107
+
108
+ // src/present.ts —— 生成器;辅助协程也写 function*,调用处 yield*
109
+ function* celebrate(ctx: PresentCtx<CoinFlip, T>) {
110
+ ctx.fx.say(ctx.rand.pick(['漂亮', '到手'])); // ctx.rand 回放一致
111
+ yield ctx.delay(ctx.T.pause);
112
+ ctx.fx.plate({ tier: 0, mult: '2.00×', amount: '+2.00' }); // 效果表里标了 always:快进也放
113
+ }
114
+ type T = { flip: number; pause: number };
115
+ export const presenter = definePresent<CoinFlip, T>({
116
+ tempo: { normal: { flip: 100, pause: 90 }, turbo: { flip: 40, pause: 50 }, reduced: { flip: 0, pause: 90 } },
117
+ *run(ctx, g, r) {
118
+ for (let i = 0; i < 6; i++) {
119
+ g.state.face = i % 2 ? 'heads' : 'tails'; // 翻动中途的那一面也写 state;最后一定落在开出的那面
120
+ ctx.fx.flip(); // 效果:不判 quiet
121
+ yield ctx.delay(ctx.T.flip); // 要等就 yield:按实例时钟、跟随倍率、后台时停住,快进不等,作废不再往下跑
122
+ }
123
+ g.state.face = r.outcome.landed; // 结果:无条件写
124
+ g.state.last = r.outcome;
125
+ },
126
+ *settle(ctx, g, r) {
127
+ if (r.outcome.win) {
128
+ ctx.fx.win();
129
+ ctx.fx.shake(undefined, { always: true }); // 播到 {@attach g.fx.shake} 绑的元素上;没绑就不播
130
+ ctx.fx.spark({ at: g.coin.offset({ offsetY: -20 }).or('center') }); // 锚点:元素不在时退到哪儿,or 必填
131
+ yield* celebrate(ctx);
132
+ }
133
+ },
134
+ });
135
+ ```
136
+
137
+ ```svelte
138
+ <!-- src/components/App.svelte -->
139
+ <script lang="ts">const { game } = useGame();</script>
140
+ <div class="coin" class:spin={game.rounds.live} {@attach game.coin} {@attach game.fx.shake}>{game.state.face}</div>
141
+ <p class="line" {@attach game.fx.linein.on(game.fx.say.current)}>{game.fx.say.current ?? ''}</p>
142
+ <style>.coin.spin{animation:var(--a-spin) ease-in-out infinite}</style>
143
+ ```
144
+
145
+ `{@attach}` 只接受 keyframes 效果的把手和锚点把手,挂到别的效果上是类型错误;把手是对象引用,名字写错也是类型错误。
146
+ `test/conformance.test.ts` 已带:`for (const c of conformanceCases(game)) test(c.name, c.run)`。挂了就是用了框架之外的等待 / 随机 / 定时器 / 声音动画,或者某条路演出来的 `game.state` 不一样(报错给出字段),每条用例挂了意味着什么见 `node_modules/gamekit777/docs/view.md`「一致性测试」。
147
+ `test/present.test.ts` 已带:`const g = headless(def, Game); await drain(g, r); expect(g.state)…`,改了编排就改它,写法见 `node_modules/gamekit777/docs/view.md`「编排的单测」。
148
+
149
+ ## 创作时的规则
150
+
151
+ - **调试只用预览**:`preview_state` / `preview_set` / `preview_play`。没有预览连着它们会自己拉起无头浏览器,不要去开 studio 页面,也不要自己起浏览器。桌面和手机两种布局都要验过。
152
+ - **演出改完跑 `preview_perf`**:默认 CPU 节流 6 倍压测(中低端手机)。泄漏(reset 后节点、监听、堆在后半程还涨)是硬结论,必须修,`publish` 前还会快进复查一遍,不过就发不了;每局增删节点、重排、掉帧只报数,和上一次比,别为数字牺牲演出。
153
+ - **想让作者看效果就给 studio 页面地址**(`game_info` 里有),不要描述画面。作者打开页面后,预览命令会自动改发到页面里的机台,作者看到的就是你在演的。
154
+ - **case 要早存**:规则能跑起来就用 `case_add` 存几条有代表性的局面;遇到问题先存 case 固定住再改。
155
+ - **不主动发布**。`publish` 不可逆,等作者明确说了再用。
156
+ - 遇到不确定的:清单字段与 round 看 `node_modules/gamekit777/docs/contract.md`,能不能打表看 `node_modules/gamekit777/docs/tables.md`,演出、设置、文案、舞台看 `node_modules/gamekit777/docs/view.md`,资源怎么引、什么该动态 import 看 `node_modules/gamekit777/docs/assets.md`,工具怎么用看 `node_modules/gamekit777/docs/workflow.md`。
@@ -0,0 +1 @@
1
+ @AGENTS.md