gamekit777 0.1.10 → 0.1.14

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 (190) hide show
  1. package/README.md +1 -1
  2. package/client/index.ts +14 -0
  3. package/create-game/scaffold.ts +2 -3
  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 +17 -43
  9. package/create-game/template/base/src/effects.ts +21 -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 +37 -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 +4 -2
  20. package/create-game/template/base/src/tempo.ts +4 -0
  21. package/create-game/template/base/src/use-game.ts +5 -0
  22. package/create-game/template/base/test/conformance.test.ts +11 -0
  23. package/create-game/template/base/test/e2e.test.ts +61 -15
  24. package/create-game/template/base/test/present.test.ts +44 -0
  25. package/create-game/template/base/vitest.config.ts +1 -1
  26. package/create-game/template/overlay/AGENTS.md +165 -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 +201 -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 +71 -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 +340 -65
  48. package/dev-host/remote.ts +8 -8
  49. package/dev-host/run.ts +9 -9
  50. package/dev-host/settings.ts +55 -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 +10 -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 +684 -0
  59. package/docs/workflow.md +22 -0
  60. package/package.json +9 -3
  61. package/protocol/assets.ts +4 -2
  62. package/protocol/bundles.ts +13 -1
  63. package/protocol/errors.ts +2 -0
  64. package/protocol/games.ts +3 -0
  65. package/protocol/index.ts +1 -0
  66. package/protocol/runtime.ts +58 -0
  67. package/publish/build.ts +6 -11
  68. package/publish/index.ts +1 -0
  69. package/publish/runtime-check.ts +57 -0
  70. package/publish/sample-worker.ts +3 -3
  71. package/publish/sample.ts +6 -40
  72. package/publish/spec.ts +5 -4
  73. package/publish/upload-core.ts +96 -27
  74. package/publish/upload.ts +26 -8
  75. package/runtime/assets.ts +18 -22
  76. package/runtime/audio.ts +9 -0
  77. package/runtime/bundle-loader.ts +213 -0
  78. package/runtime/dom.ts +27 -0
  79. package/runtime/fonts.ts +3 -5
  80. package/runtime/i18n.ts +99 -0
  81. package/runtime/index.ts +8 -6
  82. package/runtime/loader.ts +6 -0
  83. package/runtime/locales.ts +13 -0
  84. package/runtime/meter.ts +5 -13
  85. package/runtime/preload.ts +5 -11
  86. package/runtime/registry.ts +41 -71
  87. package/runtime/types.ts +4 -4
  88. package/sdk/assets.ts +5 -8
  89. package/sdk/board.ts +156 -0
  90. package/sdk/bundle.ts +81 -0
  91. package/sdk/clone.ts +36 -0
  92. package/sdk/contract.ts +45 -29
  93. package/sdk/ease.ts +163 -0
  94. package/sdk/effect.ts +299 -0
  95. package/sdk/env.ts +5 -0
  96. package/sdk/fx.ts +71 -0
  97. package/sdk/host.ts +106 -25
  98. package/sdk/index.ts +30 -10
  99. package/sdk/platform.ts +22 -0
  100. package/sdk/pool.ts +57 -0
  101. package/sdk/rng.ts +2 -6
  102. package/sdk/sample.ts +2 -10
  103. package/sdk/schema.ts +51 -27
  104. package/sdk/sound.ts +49 -0
  105. package/sdk/spec.ts +250 -88
  106. package/sdk/stage.ts +18 -39
  107. package/sdk/staged.ts +93 -0
  108. package/sdk/store.ts +0 -16
  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/asset-preview/App.svelte +13 -0
  113. package/studio/app/lib/asset-preview/game.svelte.ts +44 -0
  114. package/studio/app/lib/asset-preview/mount.ts +37 -0
  115. package/studio/app/lib/bus.svelte.ts +3 -1
  116. package/studio/app/panels/AssetsPanel.svelte +22 -2
  117. package/studio/app/panels/PublishPanel.svelte +2 -2
  118. package/studio/app/virtual.d.ts +2 -2
  119. package/studio/preview/bridge.ts +5 -2
  120. package/studio/preview/entry.ts +94 -11
  121. package/studio/preview/virtual.d.ts +3 -3
  122. package/studio/src/api.ts +7 -3
  123. package/studio/src/bus.ts +43 -14
  124. package/studio/src/cdp.ts +71 -0
  125. package/studio/src/engine.ts +107 -30
  126. package/studio/src/game-vite.ts +5 -8
  127. package/studio/src/game.ts +9 -10
  128. package/studio/src/headless.ts +130 -0
  129. package/studio/src/mcp.ts +40 -18
  130. package/studio/src/perf.ts +214 -0
  131. package/studio/src/probe.ts +1 -7
  132. package/studio/src/studio-plugin.ts +16 -4
  133. package/studio/src/worker/rpc.ts +7 -11
  134. package/svelte/AnimLayer.svelte +65 -0
  135. package/svelte/Queue.svelte +222 -0
  136. package/svelte/Root.svelte +42 -0
  137. package/svelte/audio.ts +251 -0
  138. package/svelte/bridge.svelte.ts +29 -0
  139. package/svelte/clock.ts +136 -0
  140. package/svelte/co.ts +197 -0
  141. package/svelte/compose.ts +57 -0
  142. package/svelte/conformance.ts +817 -0
  143. package/svelte/context.svelte.ts +41 -0
  144. package/svelte/diff.ts +29 -0
  145. package/svelte/dom.ts +50 -0
  146. package/svelte/effects.svelte.ts +647 -0
  147. package/svelte/fx/BigWin.svelte +57 -0
  148. package/svelte/fx/FloatText.svelte +34 -0
  149. package/svelte/fx/restart.ts +11 -0
  150. package/svelte/game.svelte.ts +305 -0
  151. package/svelte/idle.ts +89 -0
  152. package/svelte/index.ts +16 -0
  153. package/svelte/keyframes.ts +47 -0
  154. package/svelte/loops.ts +74 -0
  155. package/svelte/mount.svelte.ts +66 -0
  156. package/svelte/particle-gl.ts +240 -0
  157. package/svelte/particle.ts +248 -0
  158. package/svelte/particles.svelte.ts +72 -0
  159. package/svelte/present.svelte.ts +271 -0
  160. package/svelte/render.svelte.ts +71 -0
  161. package/svelte/round-game.svelte.ts +36 -0
  162. package/svelte/rounds.svelte.ts +103 -0
  163. package/svelte/scope.ts +55 -0
  164. package/svelte/stage.svelte.ts +108 -0
  165. package/svelte/staged-game.svelte.ts +84 -0
  166. package/svelte/staged.ts +132 -0
  167. package/svelte/tape.ts +86 -0
  168. package/svelte/testing.ts +81 -0
  169. package/svelte/timeline.ts +125 -0
  170. package/svelte/view.ts +57 -0
  171. package/vite-config/entry.ts +81 -0
  172. package/vite-config/index.js +945 -192
  173. package/vite-config/index.ts +79 -37
  174. package/vite-config/manifest.ts +122 -85
  175. package/vite-config/namespace-css.ts +89 -119
  176. package/vite-config/no-globals.ts +60 -0
  177. package/vite-config/no-random.ts +71 -0
  178. package/vite-config/svelte-pin.ts +27 -0
  179. package/vite-config/svelte-subset.ts +55 -0
  180. package/create-game/template/base/src/assets/critical/.gitkeep +0 -0
  181. package/create-game/template/base/src/assets/lazy/.gitkeep +0 -0
  182. package/create-game/template/base/src/context.svelte.ts +0 -22
  183. package/create-game/template/base/src/rules/simulate.ts +0 -9
  184. package/create-game/template/base/src/state/game.svelte.ts +0 -54
  185. package/create-game/template/base/src/styles/animations.css +0 -13
  186. package/create-game/template/base/src/view/bridge.svelte.ts +0 -33
  187. package/create-game/template/base/src/view/mount.svelte.ts +0 -67
  188. package/create-game/template/base/src/view/present.ts +0 -45
  189. package/create-game/template/overlay/AGENTS.md.tmpl +0 -189
  190. /package/create-game/template/overlay/{CLAUDE.md.tmpl → CLAUDE.md} +0 -0
package/README.md CHANGED
@@ -37,7 +37,7 @@ studio 同时是 MCP server、创作页面(预览 / 调参 / 建表 / Case /
37
37
  | import | 内容 |
38
38
  |---|---|
39
39
  | `gamekit777/sdk` | `defineGame`、契约类型、可证明公平的 RNG、SHA-256 / HMAC、加权抽样原语 |
40
- | `gamekit777/runtime` | 资源注册表(目录即清单)、平台侧预加载器、字体注入 |
40
+ | `gamekit777/runtime` | 资源注册表(目录即清单)、平台侧预加载器、字体注入。游戏不直接 import,从 `gamekit777/svelte` 拿 |
41
41
  | `gamekit777/vite-config` | `defineGameConfig`:一个游戏的 `vite.config.ts` 只要三行 |
42
42
  | `gamekit777/dev-host` | 参考平台与本地表模型服务端;`createRemotePlatform` 接真平台 |
43
43
  | `gamekit777/protocol` · `client` | 平台 REST 契约(zod)与类型化客户端 |
package/client/index.ts CHANGED
@@ -26,6 +26,9 @@ import {
26
26
  type RotateResult as TRotateResult, RotateResult,
27
27
  type RoundDetail as TRoundDetail, RoundDetail,
28
28
  type RoundPage as TRoundPage, RoundPage,
29
+ type RuntimeIndex as TRuntimeIndex, RuntimeIndex,
30
+ type RuntimeListing as TRuntimeListing, RuntimeListing,
31
+ type RuntimePackage, RUNTIME_INDEX_PATH, runtimeListPath,
29
32
  type SeedState as TSeedState, SeedState,
30
33
  type SessionView as TSessionView, SessionView,
31
34
  type UpdateGame,
@@ -182,6 +185,14 @@ export function createClient(opts: ClientOptions) {
182
185
  },
183
186
  },
184
187
 
188
+ /** 平台注入的运行时库(svelte、gamekit777/sdk)。只读:写入是平台方的 `bun run rt:push`,创作者只能查有没有 */
189
+ rt: {
190
+ index: (): Promise<TRuntimeIndex> =>
191
+ http.request({ method: 'GET', path: RUNTIME_INDEX_PATH, schema: RuntimeIndex }),
192
+ list: (pkg: RuntimePackage, version: string): Promise<TRuntimeListing> =>
193
+ http.request({ method: 'GET', path: runtimeListPath(pkg, version), schema: RuntimeListing }),
194
+ },
195
+
185
196
  /** studio 配对:站点签 code,studio 拿 code 换一个同一用户的会话(cookie 落在 studio 的 origin) */
186
197
  pair: {
187
198
  start: (): Promise<TPairStartResult> =>
@@ -197,6 +208,9 @@ export function createClient(opts: ClientOptions) {
197
208
  http.request({ method: 'PATCH', path: `/v1/games/${id}`, json: body, schema: Game }),
198
209
  publish: (id: string, body: PublishGame): Promise<TGame> =>
199
210
  http.request({ method: 'POST', path: `/v1/games/${id}/publish`, json: body, schema: Game }),
211
+ /** 试玩游戏只传包不传表,有包就能发(docs/demo.md) */
212
+ publishDemo: (id: string): Promise<TGame> =>
213
+ http.request({ method: 'POST', path: `/v1/games/${id}/publish-demo`, json: {}, schema: Game }),
200
214
  list: (query: Partial<ListGamesQuery> = {}): Promise<TGamePage> =>
201
215
  http.request({ method: 'GET', path: '/v1/games', query, schema: GamePage }),
202
216
  /** 详情比列表多一份已生效的 mode 清单——对局页要靠它知道有哪几张表可选 */
@@ -75,7 +75,7 @@ const STANDALONE_TSCONFIG = {
75
75
  target: 'ES2022', lib: ['ES2022', 'DOM', 'DOM.Iterable'], module: 'ESNext', moduleResolution: 'bundler',
76
76
  strict: true, noUnusedLocals: true, noUnusedParameters: true, noImplicitOverride: true, noFallthroughCasesInSwitch: true,
77
77
  noEmit: true, isolatedModules: true, verbatimModuleSyntax: true, skipLibCheck: true, forceConsistentCasingInFileNames: true,
78
- allowImportingTsExtensions: true, types: [],
78
+ allowImportingTsExtensions: true, types: ['vite/client'],
79
79
  },
80
80
  include: ['game.ts', 'src', 'scripts', 'test'],
81
81
  };
@@ -127,9 +127,8 @@ export function scaffoldGame(o: ScaffoldOptions): ScaffoldResult {
127
127
  out = `${JSON.stringify(STANDALONE_TSCONFIG, null, 2)}\n`;
128
128
  }
129
129
  writeFileSync(file, out);
130
- // overlay 里带 .tmpl 后缀,免得脚手架自己的 lint / 构建去处理它们;
131
130
  // .gitignore 叫 _gitignore,因为 npm 打包会把包里的 .gitignore 改名或丢掉
132
- let final = file.endsWith('.tmpl') ? file.slice(0, -5) : file;
131
+ let final = file;
133
132
  if (final.endsWith('/_gitignore')) final = `${final.slice(0, -'_gitignore'.length)}.gitignore`;
134
133
  if (final !== file) renameSync(file, final);
135
134
  files.push(final);
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "id": "coin-flip",
3
3
  "title": "抛硬币",
4
- "gameId": "95d28dcf-fe8f-45f0-a03b-f68523efd865",
5
4
  "version": "1.0.0",
6
5
  "aspect": "16:9",
7
6
  "orientation": "any",
8
- "fonts": []
7
+ "fonts": [],
8
+ "gameId": "80c89eb8-d5ad-45b9-be5d-f2696fcf6edb"
9
9
  }
@@ -1,63 +1,52 @@
1
- /* 抛硬币的清单。只依赖 gamekit777/sdk 和自己的 rules/,Node 和浏览器都能 import:
2
- 打包流水线用它采样、studio 用它探测和建表、浏览器入口把它和 mount 拼在一起。
1
+ /* 抛硬币的清单。纯清单对象,只有类型导入,Node 和浏览器都能 import:
2
+ 打包流水线用它采样、studio 用它探测和建表、构建预设把它和 view 拼成浏览器入口。
3
3
 
4
4
  这个游戏同时是脚手架的底:`gamekit777 new` 把整个目录复制出去再换名字。
5
5
  改成你自己的游戏时,先用 studio 的 probe_table 看看:
6
6
  哪些字段改变赔付分布 → 写进 modes(一张表 = 一种分布),只写那几个字段
7
7
  哪些字段只改变这一局 → 保持自由,用 book 编码演出(参考 coco 的 book.ts)
8
- 赔付必须是 centi 精度、必须是注额的线性函数——这两条 probe 会替你查
9
8
  逻辑侧三个必填:schema 说有哪些参数,round 说结果怎么算,paytable 说赔多少。
10
- normalize / validate / modeOf 不用写——它们由 schema 和 modes 推导。
9
+ 演出侧两个:settings 是只影响演出的设置(平台渲染、游戏只读),view 惰性指向演出模块——
10
+ 惰性是为了让 Node 侧 import 这份清单时永远不碰 Svelte 与 DOM。
11
+ normalize / validate / modeOf / 钱 都不用写:加载方 defineGame 时由 schema 和 modes 推导;
12
+ round 收到的 config 已填满、rng 已播好,只回 { outcome, mult },payout = bet/100 × round(mult×100) 由框架算。
11
13
 
12
14
  两面对称,只有一种赔付分布,所以一张表就够。`side` 不改变分布,只改变某一局落在哪面——
13
- 服务端不需要知道它,玩家运行时自由选。book 因此可以完全不传:payout > 0 就说明
15
+ 服务端不需要知道它,玩家运行时自由选。book 因此可以完全不传:赔了钱就说明
14
16
  硬币落在你押的那面(见 rules/restore.ts)。
15
17
 
16
18
  纯度由 scripts/verify.ts 的运行时桩保证:把 Math.random 和 crypto 换成会抛的假实现
17
19
  再调 round(),查的是实际执行路径含全部传递依赖。 */
18
- import { createRng, defineGame, normalizeBySchema, type RoundInput, type RoundResult } from 'gamekit777/sdk';
20
+ import type { GameManifest, RoundInput, RoundReturn } from 'gamekit777/svelte';
19
21
  import meta from './game.meta.json' with { type: 'json' };
20
22
  import { HOUSE_EDGE, MULT } from './src/rules/const.ts';
21
23
  import { restore } from './src/rules/restore.ts';
22
24
  import { schema } from './src/rules/schema.ts';
23
- import { simulate } from './src/rules/simulate.ts';
24
- import type { CoinFlipConfig, CoinFlipOutcome } from './src/rules/types.ts';
25
-
26
- export { schema, simulate, restore };
27
- export * from './src/rules/types.ts';
25
+ import { settings } from './src/rules/settings.ts';
26
+ import type { CoinFlip } from './src/rules/types.ts';
28
27
 
29
28
  /**
30
- * 一局的结果。同步纯函数:同样的 (config, bet, seed) 恒得同样的 payout。
31
- *
29
+ * 一局的判定。同步纯函数:同样的 (config, seed) 恒得同样的结果。
32
30
  * 随机数的抽取顺序是契约的一部分:flip 子流 below(2) 一次。
33
31
  * 已发布 mode 的这段逻辑不能改——历史局的 book 要靠它解;要改就换 slug。
34
32
  */
35
- export function round(input: RoundInput<CoinFlipConfig>): RoundResult<CoinFlipOutcome> {
36
- const cfg = normalizeBySchema(schema, input.config);
37
- const flip = createRng(input.seed).fork('flip').below(2);
38
- const outcome = simulate(flip, cfg);
39
- /* 金额一律是最小货币单位的整数。取整规则是契约的一部分——
40
- 服务端按 bet/100 × payoutCenti 赔付,这里必须得出同一个整数 */
41
- const payout = outcome.win ? Math.floor(input.bet * outcome.mult) : 0;
42
- return {
43
- bet: input.bet,
44
- payout,
45
- multiplier: outcome.mult,
46
- outcome,
47
- pacing: { durationMs: 900, postDelayMs: outcome.win ? 400 : 250 },
48
- };
33
+ function round({ config, rng }: RoundInput<CoinFlip>): RoundReturn<CoinFlip> {
34
+ const landed = rng.fork('flip').below(2) === 0 ? 'heads' : 'tails';
35
+ const win = landed === config.side;
36
+ return { outcome: { side: config.side, landed, win }, mult: win ? MULT : 0 };
49
37
  }
50
38
 
51
- export default defineGame<CoinFlipConfig, CoinFlipOutcome>({
52
- // 名字只在 game.meta.json 里改:slug 同时是大厅里的 id、产物目录名和 CSS 命名空间
53
- slug: meta.id,
54
- title: meta.title,
39
+ export default {
40
+ // 名字只在 game.meta.json 里改:id 同时是大厅里的 slug、产物目录名和 CSS 命名空间
41
+ meta,
55
42
  description: '押正面还是反面。猜中赔 1.96 倍,RTP 98%。',
56
43
  rtp: HOUSE_EDGE,
57
44
  sims: 100_000,
58
45
  schema,
46
+ settings,
59
47
  round,
60
- paytable: [{ label: { 'zh-CN': '猜中' }, mult: MULT }],
48
+ paytable: [{ label: { 'zh-CN': '猜中', en: 'Hit' }, mult: MULT }],
61
49
  book: 'none',
62
50
  restore,
63
- });
51
+ view: () => import('./src/index.ts'),
52
+ } satisfies GameManifest<CoinFlip>;
@@ -3,9 +3,6 @@
3
3
  "private": true,
4
4
  "version": "1.0.0",
5
5
  "type": "module",
6
- "exports": {
7
- ".": "./src/index.ts"
8
- },
9
6
  "scripts": {
10
7
  "dev": "GAMEKIT_BASE_URL=http://127.0.0.1:8787 GAMEKIT_SITE_URL=http://localhost:5180 gamekit777 studio --no-mcp",
11
8
  "build": "svelte-check --tsconfig ./tsconfig.json && vite build",
@@ -21,7 +18,7 @@
21
18
  "@sveltejs/vite-plugin-svelte": "^6.2.1",
22
19
  "@tsconfig/svelte": "^5.0.5",
23
20
  "happy-dom": "^20.14.5",
24
- "svelte": "^5.42.2",
21
+ "svelte": "5.57.1",
25
22
  "svelte-check": "^4.3.3",
26
23
  "typescript": "^5.9.3",
27
24
  "vite": "^7.1.5",
@@ -6,7 +6,13 @@
6
6
  RTP 实际期望要对得上清单的 rtp
7
7
  分布 随机源本身是否无偏 */
8
8
  import { MULT, WIN_CHANCE } from '../src/rules/const.ts';
9
- import game, { round } from '../game.ts';
9
+ import manifest from '../game.ts';
10
+ import { defineGame } from 'gamekit777/sdk';
11
+ /* scripts/ 在 Node 里跑,是唯一直接用 gamekit777/sdk 的地方:清单要经 defineGame 才有包好 rng 与 normalize 的 round */
12
+ const game = defineGame(manifest);
13
+
14
+ /* round 从 definition 上取:包过一层,config 填满、rng 播好、结果带 seed */
15
+ const { round } = game;
10
16
 
11
17
  const ROUNDS = Number(process.argv[2] ?? 200_000);
12
18
  let fails = 0;
@@ -1,51 +1,35 @@
1
1
  <script lang="ts">
2
2
  /* 游戏的根组件。平台化之后它只有画面——
3
3
  注额、押哪一面、音效、极速都是平台该渲染的 UI。
4
+ 抛出中硬币一鼓一缩是循环表的 spin(src/effects.ts),挂在 .coin 上、在演时播;不写 CSS animation,减弱动效时框架自己停。
4
5
 
5
- 实例经 context 往下传,子组件一律 getInstance(),不 import 任何状态对象,
6
- 这样同页挂两个实例才不会互相污染。 */
7
- import { setInstance, type Instance } from '../context.svelte.ts';
6
+ 实例经 context 往下传(gamekit777/svelte 的 composeGame 设好),子组件一律 useGame(),
7
+ 不 import 任何状态对象,这样同页挂两个实例才不会互相污染。
8
+ 赢面牌子这类内置效果画在框架的覆盖层上(容器末尾,盖在画面之上),这里不用放任何东西。 */
9
+ import { useGame } from '../use-game.ts';
8
10
  import { MULT } from '../rules/const.ts';
9
11
 
10
- let { instance }: { instance: Instance } = $props();
12
+ const { game, t } = useGame();
11
13
 
12
- /* 只取初始值是故意的:一个组件树对应一个实例,换实例应当整个重挂 */
13
- // svelte-ignore state_referenced_locally
14
- const inst = instance;
15
- setInstance(inst);
16
- const { game } = inst;
17
-
18
- const label = (s: 'heads' | 'tails') => (s === 'heads' ? '正' : '反');
19
- const money = (minor: number) => (minor / 100).toFixed(2);
14
+ const label = (s: 'heads' | 'tails') => t(`side.${s}`);
20
15
  </script>
21
16
 
22
17
  <div class="game">
23
- <p class="head">押 <b>{label(game.side)}</b> · 猜中赔 {MULT.toFixed(2)}×</p>
18
+ <p class="head">{@html t('head', { side: `<b>${label(game.config.side)}</b>`, mult: MULT.toFixed(2) })}</p>
24
19
 
25
- <div class="coin" class:spin={game.live} aria-live="polite">
26
- <span>{label(game.face)}</span>
20
+ <div class="coin" {@attach game.loops.spin.when(game.rounds.live)} aria-live="polite">
21
+ <span>{label(game.state.face)}</span>
27
22
  </div>
28
23
 
29
- <p class="foot" class:win={game.last?.win} class:lose={game.last && !game.last.win}>
30
- {#if game.phase === 'over' && game.last}
31
- {game.last.win ? '猜中了' : '没猜中'}
32
- {:else if game.live}
33
- 抛出中…
34
- {:else if game.layout === 'mobile'}
35
- 在参数里选一面,下注开始
24
+ <p class="foot" class:win={game.state.last?.win} class:lose={game.state.last && !game.state.last.win}>
25
+ {#if game.rounds.phase === 'settled' && game.state.last}
26
+ {game.state.last.win ? t('hit') : t('miss')}
27
+ {:else if game.rounds.live}
28
+ {t('flipping')}
36
29
  {:else}
37
- 在左边选一面,下注开始
30
+ {t(game.stage.layout === 'mobile' ? 'hint.mobile' : 'hint.desktop')}
38
31
  {/if}
39
32
  </p>
40
-
41
- <!-- 赢了弹一下就走:不遮画面、不用点。key 到局号,连赢两局是两次弹出而不是一次拉长 -->
42
- {#if game.toast}
43
- {#key game.toast.id}
44
- <div class="toast" style:animation-duration="{game.toast.ms}ms" aria-hidden="true">
45
- <b>{game.toast.mult.toFixed(2)}×</b><span>+{money(game.toast.payout)}</span>
46
- </div>
47
- {/key}
48
- {/if}
49
33
  </div>
50
34
 
51
35
  <style>
@@ -57,21 +41,11 @@
57
41
  padding:calc(28px + var(--gk-safe-top,0px)) calc(28px + var(--gk-safe-right,0px)) calc(28px + var(--gk-safe-bottom,0px)) calc(28px + var(--gk-safe-left,0px));
58
42
  background:var(--bg);color:var(--ink);font-family:var(--txt)}
59
43
  .head{margin:0;font-size:13px;color:var(--dim)}
60
- .head b{color:var(--ink);font-size:16px}
44
+ .head :global(b){color:var(--ink);font-size:16px}
61
45
  .coin{display:grid;place-items:center;width:104px;height:104px;border-radius:50%;
62
46
  background:var(--mark);color:#241A00;font-family:var(--num);font-size:38px;font-weight:700;
63
47
  box-shadow:inset 0 -6px 0 rgba(0,0,0,.18)}
64
- .coin.spin{animation:pulse 420ms ease-in-out infinite}
65
48
  .foot{margin:0;min-height:1.5em;font-size:13px;color:var(--dim)}
66
49
  .foot.win{color:var(--win);font-weight:700}
67
50
  .foot.lose{color:var(--lose)}
68
-
69
- .toast{position:absolute;left:50%;top:calc(var(--gk-safe-top,0px) + 22%);transform:translate(-50%,0);pointer-events:none;
70
- display:flex;align-items:baseline;gap:10px;padding:10px 22px 12px;border-radius:18px;
71
- background:var(--panel);color:var(--ink);box-shadow:0 0 0 3px var(--win),0 10px 30px rgba(0,0,0,.45);
72
- animation:toast 1400ms ease-out forwards}
73
- .toast b{font-family:var(--num);font-size:34px;line-height:1;color:var(--win)}
74
- .toast span{font-family:var(--num);font-size:16px;color:var(--ink)}
75
-
76
- @media (prefers-reduced-motion:reduce){.coin.spin{animation:none}.toast{animation-name:toastStill}}
77
51
  </style>
@@ -0,0 +1,21 @@
1
+ /* 效果表与循环表。翻动音是过程效果;输赢音与赢面牌子标 always,快进后也要听到、看到。
2
+ 动画不要用 JS 逐帧驱动(跨源 iframe 里 iOS 会把 rAF 限到 30fps),都交给 WAAPI:
3
+ 发生一次的(弹一下、翻开、飞过去)→ 效果表里的 effect.keyframes,位置演出时才知道就把 frames 写成 payload 的函数;
4
+ 处在某个状态期间一直循环的(呼吸、旋转、闪烁)→ 循环表 defineLoops,模板里 {@attach game.loops.x.when(条件)};
5
+ 只有数字读数、必须进 state 的量才用 ctx.tween。
6
+ 改成你自己的游戏时:哪些东西该进哪张表、always 怎么定,见 node_modules/gamekit777/docs/view.md「先分清三类东西」。 */
7
+ import { sound, effect, defineEffects, defineLoops } from 'gamekit777/svelte';
8
+ import { T } from './tempo.ts';
9
+
10
+ export const effects = defineEffects({
11
+ flip: effect.sound(sound.tone({ f: 880, dur: 0.05, type: 'triangle', gain: 0.08 })),
12
+ win: effect.sound(sound.chord({ notes: [659, 784, 988], step: 0.08, dur: 0.28, gain: 0.12 }), { always: true }),
13
+ lose: effect.sound(sound.tone({ f: 220, dur: 0.18, type: 'sine', gain: 0.1, slide: 140 }), { always: true }),
14
+ /** 赢了弹一下就走。tier 0 只有牌子,不遮画面、不用点;自动模式连赢也只是连弹两次 */
15
+ plate: effect.builtin('big-win', { always: true, ms: T.linger }),
16
+ });
17
+
18
+ export const loops = defineLoops({
19
+ /** 抛出中:硬币一鼓一缩(App 里 {@attach game.loops.spin.when(game.rounds.live)}) */
20
+ spin: { ms: 420, easing: 'ease-in-out', frames: [{ transform: 'scale(1)' }, { transform: 'scale(1.1)', offset: 0.5 }, { transform: 'scale(1)' }] },
21
+ });
@@ -0,0 +1,24 @@
1
+ /* 游戏类。平台的投影(config / settings / run / stage)、phase / rid / skip、status 派生、作废,全在基类里;
2
+ 画面上是什么在 game.state(形状在 rules/types.ts,初值是这里的 initial,编排写、模板直接读)。这里只剩静态声明。
3
+ 改成你自己的游戏时,类上只放三样:声明(静态字段、锚点把手);编排和组件都要用的派生与改 state 的方法;玩家在画布上的选择与交互。
4
+ state / config / settings 的字段不要在这里再包一层 getter(模板直接写 game.state.face、game.config.side);只有组件用的尺寸、颜色、格式化写在组件里。
5
+ 做成可实例化的类而不是模块级单例:同页挂两个实例时各自一套状态。 */
6
+ import { RoundGame } from 'gamekit777/svelte';
7
+ import type { CoinFlip, CoinFlipState } from './rules/types.ts';
8
+ import { effects, loops } from './effects.ts';
9
+ import App from './components/App.svelte';
10
+ import { presenter } from './present.ts';
11
+
12
+ export class Game extends RoundGame<CoinFlip, typeof effects, typeof loops> {
13
+ /* 效果表与循环表交给基类:写成静态字段,不用写构造函数(框架每次 mount new 一个) */
14
+ static override App = App;
15
+ static override presenter = presenter;
16
+ static override effects = effects;
17
+ static override loops = loops;
18
+
19
+ /** 挂载时的画面:正面朝上、没有上一局 */
20
+ static override initial(): CoinFlipState { return { face: 'heads', last: null }; }
21
+
22
+ /* 收掉上一局(换了押的面、作废):结果不作数了,硬币停在哪面还是哪面 */
23
+ protected override onReset(): void { this.state.last = null; }
24
+ }
@@ -1,19 +1,5 @@
1
- /* 游戏包的浏览器入口 = 清单 + view。平台加载这个文件:
2
-
3
- import game from '/g/<gameId>/<bundleHash>/index.js' // 平台从 R2 流出的包
4
- const inst = await game.mount(host, el)
5
- await inst.present(result)
6
-
7
- 清单在根目录的 game.ts,Node 侧(采样、studio)import 的是它;这里只是把 mount 拼上去。
8
- assets 是 src/assets/ 目录的清单(runtime 按目录生成),平台在 mount 之前按它预加载、画进度条——游戏自己不加载。 */
1
+ /* 演出模块:game.ts 的 view 惰性指向这里,浏览器入口由构建预设生成、经 gamekit777/svelte 的 composeGame 挂起来。
2
+ 一个游戏在演出侧只声明一个类(game.svelte.ts):App、演出把手、效果表、循环表都是它的静态字段。
3
+ 这个文件 import 了样式、间接 import 了 Svelte 组件,Node 侧永远不会执行它。 */
9
4
  import './styles/global.css';
10
- import './styles/animations.css';
11
-
12
- import { assets } from 'gamekit777/runtime';
13
- import game from '../game.ts';
14
- import { mountGame } from './view/mount.svelte.ts';
15
-
16
- export * from '../game.ts';
17
- export { mountGame as mount };
18
-
19
- export default { ...game, mount: mountGame, assets: assets.bundle() };
5
+ export { Game as default } from './game.svelte.ts';
@@ -0,0 +1,10 @@
1
+ {
2
+ "side.heads": "Heads",
3
+ "side.tails": "Tails",
4
+ "head": "Bet {side} · pays {mult}×",
5
+ "hit": "You called it",
6
+ "miss": "Missed",
7
+ "flipping": "Flipping…",
8
+ "hint.mobile": "Pick a side in settings, then bet",
9
+ "hint.desktop": "Pick a side on the left, then bet"
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "side.heads": "正",
3
+ "side.tails": "反",
4
+ "head": "押 {side} · 猜中赔 {mult}×",
5
+ "hit": "猜中了",
6
+ "miss": "没猜中",
7
+ "flipping": "抛出中…",
8
+ "hint.mobile": "在参数里选一面,下注开始",
9
+ "hint.desktop": "在左边选一面,下注开始"
10
+ }
@@ -0,0 +1,37 @@
1
+ /* 演出:把已经算好的结果播出来。
2
+
3
+ 判定一行都不在这里——outcome 是传进来的;极速、减弱动效由框架施加在演出上,改不了结果,
4
+ 因为它们根本不在 round() 的入参里。
5
+ run 是生成器:yield 出去的是「要等什么」,什么时候接着跑由框架定——快进、直接看结果、减弱动效时不等,局被作废时不再往下跑。
6
+ 只写 g.state 和发效果。翻动中途的那一面也写在 state 里,演完一定落在开出的那面:正常演、快进、直接看结果,最后的 state 一样(一致性套件验)。
7
+ 节拍是 tempo.ts 里的常数,这里不看 turbo / reducedMotion。
8
+ 装饰性随机只能来自 ctx.rand(按这一局的种子播种,回放一模一样);这个游戏暂时没用到。 */
9
+ import { definePresent } from 'gamekit777/svelte';
10
+ import type { CoinFlip } from './rules/types.ts';
11
+ import { T } from './tempo.ts';
12
+
13
+ const money = (minor: number): string => (minor / 100).toFixed(2);
14
+
15
+ /* 翻动几下再定格。次数只是节奏(总时长 / 翻一下多久),不占用可复现的随机数 */
16
+ const STEPS = Math.max(2, Math.round(T.total / T.flip));
17
+
18
+ export const presenter = definePresent<CoinFlip>({
19
+ *run(ctx, g, r) {
20
+ const o = r.outcome;
21
+ /* 翻面是离散的几下(每 90ms 换一次字),不是逐帧驱动的动画;硬币一鼓一缩是循环表的 spin(WAAPI) */
22
+ for (let i = 0; i < STEPS; i++) {
23
+ g.state.face = i % 2 === 0 ? 'tails' : 'heads';
24
+ ctx.fx.flip();
25
+ yield ctx.delay(T.total / STEPS);
26
+ }
27
+ g.state.face = o.landed;
28
+ g.state.last = o;
29
+ },
30
+ settle(ctx, g, r) {
31
+ const o = r.outcome;
32
+ (o.win ? ctx.fx.win : ctx.fx.lose)();
33
+ /* 牌子是余韵,present 不等它——弹窗不该拖慢下一注(自动模式尤其);停多久写在效果表里,下一局开始时框架会清掉 */
34
+ /* 游戏自己声明的设置在 g.settings 里(和平台内置的 sound / locale 合在一起) */
35
+ if (o.win && g.settings.toast) ctx.fx.plate({ tier: 0, mult: `${r.multiplier.toFixed(2)}×`, amount: `+${money(r.payout)}` });
36
+ },
37
+ });
@@ -1,41 +1,25 @@
1
- /* 表模型下的还原:把平台抽中的那一行变回一局可演出的结果。
1
+ /* 表模型下的还原:把平台抽中的那一行变回一局的结果快照。
2
2
  *
3
3
  * coin-flip 是最省的那一档——**不需要任何演出数据**。
4
- * 猜中了硬币就落在你押的那面,猜错了就落在另一面,payout 已经把这件事说完了。
4
+ * 猜中了硬币就落在你押的那面,猜错了就落在另一面,赔付已经把这件事说完了。
5
5
  * 所以这张表只有两行,book 一个字节都不传。
6
6
  *
7
7
  * 押哪一面(side)因此可以保持玩家自由选:它不改变赔付分布,服务端也不需要知道。
8
8
  *
9
9
  * 改成你自己的游戏时:
10
10
  * 所有影响结果的字段都在 modes 里 → 删掉这个文件,game.ts 里不写 book 和 restore,
11
- * defineGame 会给你 restoreFromSeed(存种子重跑 round(),渲染代码一行不用改)
11
+ * 框架会给你 restoreFromSeed(存种子重跑 round(),渲染代码一行不用改)
12
12
  * 有字段改变这一局却不改变分布(像 coco 的 ranks)→ 自己写 book 编码和这里的解码
13
- * 硬契约只有一条:返回的 payout 必须恰好等于 (bet / 100) × payoutCenti。对不上就抛错、不演出——
14
- * 「玩家看到大奖动画,余额却没动」这类 bug 完全静默,最伤信任。studio 的表模型预览会替你验。 */
15
- import { normalizeBySchema, type Restore } from 'gamekit777/sdk';
16
- import { PAYOUT_SCALE } from './const.ts';
17
- import { schema } from './schema.ts';
18
- import type { CoinFlipConfig, CoinFlipOutcome } from './types.ts';
13
+ * 只返回 { outcome, mult }:钱由框架按 payoutCenti 算,还原出的 mult 和表里那一行对不上框架会抛错不演。 */
14
+ import type { Restore } from 'gamekit777/svelte';
15
+ import { MULT } from './const.ts';
16
+ import type { CoinFlip } from './types.ts';
19
17
 
20
- export const restore: Restore<CoinFlipConfig, CoinFlipOutcome> = (input) => {
21
- const { side } = normalizeBySchema(schema, input.config);
18
+ export const restore: Restore<CoinFlip> = (input) => {
19
+ const { side } = input.config;
22
20
  const win = input.payoutCenti > 0;
23
- const mult = input.payoutCenti / PAYOUT_SCALE;
24
-
25
- const outcome: CoinFlipOutcome = {
26
- side,
27
- landed: win ? side : side === 'heads' ? 'tails' : 'heads',
28
- win,
29
- mult,
30
- };
31
-
32
21
  return {
33
- bet: input.bet,
34
- /* 必须和平台的 winCents 逐分相同。除法在前是故意的:
35
- bet 保证能被 100 整除,先除再乘恒为精确整数 */
36
- payout: (input.bet / PAYOUT_SCALE) * input.payoutCenti,
37
- multiplier: mult,
38
- outcome,
39
- pacing: { durationMs: 900, postDelayMs: win ? 400 : 250 },
22
+ outcome: { side, landed: win ? side : side === 'heads' ? 'tails' : 'heads', win },
23
+ mult: win ? MULT : 0,
40
24
  };
41
25
  };
@@ -1,19 +1,18 @@
1
- import type { ConfigSchema } from 'gamekit777/sdk';
2
- import type { CoinFlipConfig } from './types.ts';
1
+ import type { ConfigSchema } from 'gamekit777/svelte';
3
2
 
4
- /* 平台按这份声明自动渲染设置控件,它不认识这个游戏。
3
+ /* 影响结果的参数。平台按这份声明自动渲染设置控件,它不认识这个游戏;config 的类型由它反推(types.ts 的 InferConfig)。
5
4
  affectsOutcome 影响结果 → 下注时冻结、随 RoundInput 上送、进 proof
6
5
  lock 什么时候不许改
7
6
  ui 谁渲染:'host' 平台的表单,'stage' 游戏画布内(仍然受控) */
8
- export const schema: ConfigSchema<CoinFlipConfig> = {
7
+ export const schema = {
9
8
  side: {
10
9
  kind: 'enum',
11
10
  options: [
12
- {value: 'heads', label: {'zh-CN': '正面'}},
13
- {value: 'tails', label: {'zh-CN': '反面'}}
11
+ { value: 'heads', label: { 'zh-CN': '正面' } },
12
+ { value: 'tails', label: { 'zh-CN': '反面' } },
14
13
  ],
15
14
  default: 'heads',
16
- label: {'zh-CN': '押哪一面'},
17
- affectsOutcome: true, lock: 'inRoundOrAuto', ui: 'host', control: 'select', order: 1
18
- }
19
- };
15
+ label: { 'zh-CN': '押哪一面' },
16
+ affectsOutcome: true, lock: 'inRoundOrAuto', ui: 'host', control: 'select', order: 1,
17
+ },
18
+ } as const satisfies ConfigSchema;
@@ -0,0 +1,6 @@
1
+ import type { SettingsSchema } from 'gamekit777/svelte';
2
+
3
+ /** 只影响演出的设置。平台渲染控件并存值,游戏从 host.settings 读;键不许撞平台内置的 sound / turbo 那些 */
4
+ export const settings = {
5
+ toast: { kind: 'bool', default: true, label: { 'zh-CN': '赢面弹窗', en: 'Win toast' }, control: 'switch', order: 1 },
6
+ } as const satisfies SettingsSchema;
@@ -1,13 +1,27 @@
1
- /** 影响结果的参数。平台在下注这一刻冻结一份传进 round() */
2
- export interface CoinFlipConfig {
3
- /** 押正面还是反面 */
4
- side: 'heads' | 'tails';
5
- }
1
+ /* 一个接口描述整个游戏。config 与 settings 从 schema 声明反推,outcome 是 round 算出来的东西、只能声明,
2
+ state 是画面上是什么(game.state:编排写、模板读),game 是演出侧的游戏类(只是类型引用,Node 里不会加载它)。清单、round、游戏类、视图、实例都只写 CoinFlip 这一个名字。 */
3
+ import type { InferConfig, InferSettings } from 'gamekit777/svelte';
4
+ import type { schema } from './schema.ts';
5
+ import type { settings } from './settings.ts';
6
+ import type { Game } from '../game.svelte.ts';
6
7
 
7
8
  /** 一局的完整结果快照。演出层只读它,不做任何判定 */
8
9
  export interface CoinFlipOutcome {
9
10
  side: 'heads' | 'tails';
10
11
  landed: 'heads' | 'tails';
11
12
  win: boolean;
12
- mult: number;
13
+ }
14
+
15
+ /** 画面上是什么(game.state):硬币朝上的那一面(翻动中也在变)、最近一局的结果 */
16
+ export interface CoinFlipState {
17
+ face: 'heads' | 'tails';
18
+ last: CoinFlipOutcome | null;
19
+ }
20
+
21
+ export interface CoinFlip {
22
+ config: InferConfig<typeof schema>;
23
+ outcome: CoinFlipOutcome;
24
+ settings: InferSettings<typeof settings>;
25
+ state: CoinFlipState;
26
+ game: Game;
13
27
  }
@@ -1,14 +1,16 @@
1
1
  /* 游戏自己的设计变量和 reset。
2
2
 
3
3
  构建时 @gamekit/vite-config 的 namespace-css 插件会把这里的 :root / html / body
4
- 收敛到游戏根元素 .gk-<id>,@keyframes 也会改名。原因是同页可能挂着两个游戏,
5
- 而 @keyframes 是全局命名空间、后定义覆盖先定义——撞名不报错,只是动画播成别人的。
4
+ 收敛到游戏根元素 .gk-<id>:同页可能挂着两个游戏,变量不能互相覆盖。
5
+ 这里不写 @keyframes(构建直接报错):一次性的动画进效果表 effect.keyframes,循环的进 defineLoops。
6
6
 
7
7
  页面外框(body 的底色、外边距)不放这里:那是平台的地盘。 */
8
8
  :root{
9
9
  --bg:#0E1116; --panel:#161B23; --line:#232B36;
10
10
  --ink:#E8EDF4; --dim:#8A97A8;
11
11
  --win:#31D07A; --lose:#F0554A; --mark:#FFC93C;
12
+ /* 内置的大奖牌读这几个:覆盖层在 .game 之外,所以写在这里 */
13
+ --gold:var(--win); --gold-d:#1a6b40; --cream:var(--ink);
12
14
  --num:ui-monospace,SFMono-Regular,Menlo,monospace;
13
15
  --txt:system-ui,-apple-system,"Noto Sans SC",sans-serif;
14
16
  }
@@ -0,0 +1,4 @@
1
+ /* 节拍(毫秒):效果表的时长和演出的时间线都读这一份,两边的数字对得上。
2
+ 只写一套:极速、减弱动效由框架统一处理(极速时这一局的等待与效果按平台给的倍率加速,减弱动效时每一段直接落到终态),游戏不读 turbo、不判 reducedMotion。
3
+ flip:翻一下多久;total:翻动多久再定格;linger:赢面牌子停多久 */
4
+ export const T = { flip: 90, total: 800, linger: 1600 } as const;
@@ -0,0 +1,5 @@
1
+ import { useGame as useGameOf, type Instance } from 'gamekit777/svelte';
2
+ import type { CoinFlip } from './rules/types.ts';
3
+
4
+ /** 组件里一律用它取实例,不 import 任何状态对象:同页两个实例才不会互相污染。框架的 useGame 是泛型的,这里钉上类型 */
5
+ export const useGame: () => Instance<CoinFlip> = useGameOf;
@@ -0,0 +1,11 @@
1
+ /* 演出层的一致性:终态、快进、作废、可复现、无残留、双实例、不碰钱。脚手架必带,bun run test 强制跑 */
2
+ import { test } from 'vitest';
3
+ import { composeGame } from 'gamekit777/svelte';
4
+ import { conformanceCases } from 'gamekit777/svelte/test';
5
+ import { EMPTY_ASSETS } from 'gamekit777/svelte/test';
6
+ import def from '../game.ts';
7
+ import zhCN from '../src/locales/zh-CN.json' with { type: 'json' };
8
+ import en from '../src/locales/en.json' with { type: 'json' };
9
+
10
+ const game = composeGame(def, { assets: EMPTY_ASSETS.bundle(), locales: { 'zh-CN': zhCN, en } });
11
+ for (const c of conformanceCases(game)) test(c.name, c.run);