gamekit777 0.1.5 → 0.1.7

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 (50) hide show
  1. package/README.md +1 -1
  2. package/create-game/scaffold.ts +81 -16
  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 +237 -73
  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 -10
  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/PreviewPanel.svelte +4 -0
  33. package/studio/cli.ts +7 -6
  34. package/studio/preview/entry.ts +10 -1
  35. package/studio/src/engine.ts +5 -1
  36. package/studio/src/mcp.ts +9 -7
  37. package/studio/src/scaffold.ts +38 -15
  38. package/studio/src/studio-plugin.ts +29 -13
  39. package/create-game/template/game.meta.json.tmpl +0 -8
  40. package/create-game/template/tsconfig.json +0 -22
  41. /package/create-game/template/{scripts/verify.ts.tmpl → base/scripts/verify.ts} +0 -0
  42. /package/create-game/template/{src → base/src}/assets/critical/.gitkeep +0 -0
  43. /package/create-game/template/{src → base/src}/assets/lazy/.gitkeep +0 -0
  44. /package/create-game/template/{src/index.ts.tmpl → base/src/index.ts} +0 -0
  45. /package/create-game/template/{src → base/src}/styles/animations.css +0 -0
  46. /package/create-game/template/{src → base/src}/styles/global.css +0 -0
  47. /package/create-game/template/{src → base/src}/view/bridge.svelte.ts +0 -0
  48. /package/create-game/template/{vite.config.ts → base/vite.config.ts} +0 -0
  49. /package/create-game/template/{vitest.config.ts → base/vitest.config.ts} +0 -0
  50. /package/create-game/template/{_gitignore → overlay/_gitignore} +0 -0
@@ -6,13 +6,17 @@
6
6
  默认服务端是本地表模型:浏览器里就地建表、按权重抽行、restore 还原——
7
7
  和真平台同一条路,restore 契约违反在这里就会被拦下。
8
8
 
9
- 机台的样子和玩家站点的对局页是同一份:同一套类名(.cab / .panel-col / .stage / .bar),
10
- 样式全部来自共享的 theme.css,舞台也是同一块 900×771 的逻辑画面整体缩放。
9
+ 机台的样子和玩家站点的对局页是同一份:同一套类名(.cab / .panel-col / .stage / .bar,
10
+ 手机是 .cab.mobile / .hud-top / .hud-bottom / .sheet),样式全部来自共享的 theme.css,
11
+ 舞台也是同一块逻辑画面整体缩放(桌面 900×771,手机 450×H)。
11
12
  只有游戏的挂载方式不同——那边是 sandbox iframe,这边直接挂进 .gameframe 这个 div。
13
+ .stage > .gameframe 不进任何 {#if}:切布局只换它的兄弟节点,游戏实例不重挂。
12
14
  开发专用的东西(历史、校验、公平证、赔率、定点复现)放在机台下面,不挤进机台里。 */
13
15
  import { onMount } from 'svelte';
14
- import { STAGE_H, STAGE_W, type GameInstance, type GameModule } from '../sdk/index.ts';
15
- import { preloadGame } from '../runtime/index.ts';
16
+ import { NO_INSETS, STAGE_H, STAGE_W, safeInsetsOf, type GameInstance, type GameModule, type StageFit, type StageLayout } from '../sdk/index.ts';
17
+ import { createLoadMeter, preloadGame } from '../runtime/index.ts';
18
+ import { fade } from 'svelte/transition';
19
+ import { observeStage } from './layout.ts';
16
20
  import { createPlatform, type HistoryEntry, type PlatformOf } from './platform.svelte.ts';
17
21
  import type { PlatformServer } from './platform-server.ts';
18
22
  import type { LocalServer } from './local.ts';
@@ -20,7 +24,7 @@
20
24
  import SchemaFields from './SchemaFields.svelte';
21
25
  import Field from './Field.svelte';
22
26
 
23
- let { game, title, note, embedded = false, server, onSaveCase, onPlatform }: {
27
+ let { game, title, note, embedded = false, server, onSaveCase, onPlatform, layout = 'desktop', simSafe }: {
24
28
  game: GameModule<C, O>;
25
29
  title?: string;
26
30
  note?: string;
@@ -32,19 +36,29 @@
32
36
  onSaveCase?: (h: HistoryEntry, title: string) => Promise<string>;
33
37
  /** 平台实例建好了交出去。studio 的总线要拿它驱动预览(MCP 的 preview_* 工具) */
34
38
  onPlatform?: (p: PlatformOf<C, O>) => void;
39
+ /** 初始布局。之后由 p.setLayout 切 */
40
+ layout?: StageLayout;
41
+ /** 模拟真机的刘海 / home 指示条(屏幕像素)。iframe 里 env(safe-area-inset-*) 恒为 0,studio 预览靠这个模拟 */
42
+ simSafe?: { top: number; bottom: number };
35
43
  } = $props();
36
44
 
37
45
  /* 只取初始值是故意的:一个平台实例对应一个游戏,换游戏应当整个重挂
38
46
  (外面用 {#key game} 包住),而不是让平台在运行中换掉脚下的逻辑 */
39
47
  // svelte-ignore state_referenced_locally
40
- const p = createPlatform<C, O>(game, server);
48
+ const p = createPlatform<C, O>(game, server, { layout });
41
49
  setPlatform(p);
42
50
  // svelte-ignore state_referenced_locally
43
51
  onPlatform?.(p);
44
52
 
45
53
  let stage = $state<HTMLElement>();
46
54
  let frame = $state<HTMLElement>();
55
+ let hudTop = $state<HTMLElement>();
56
+ let hudBottom = $state<HTMLElement>();
57
+ let probe = $state<HTMLElement>();
58
+ let betDlg = $state<HTMLDialogElement>();
59
+ let menuDlg = $state<HTMLDialogElement>();
47
60
  let inst = $state<GameInstance<O> | null>(null);
61
+ const mobile = $derived(p.layout === 'mobile');
48
62
  // svelte-ignore state_referenced_locally
49
63
  let betText = $state((p.bet / 100).toFixed(2));
50
64
  let tab = $state<'history' | 'fair' | 'proof' | 'pay' | 'skins' | 'replay'>('history');
@@ -60,33 +74,92 @@
60
74
  const local = $derived(p.server as Partial<LocalServer<C, O>>);
61
75
  const tableStats = $derived(p.tableMode ? local.table?.(p.tableMode)?.stats ?? null : null);
62
76
 
77
+ /* 遮罩比就绪多留一拍,让 100% 真的被看见再淡出;outro 期间块内更新会被暂停,所以不能靠 phase 直接切 */
78
+ let veil = $state(true);
79
+ $effect(() => {
80
+ if (p.game.phase === 'loading') return;
81
+ const t = setTimeout(() => { veil = false; }, 220);
82
+ return () => clearTimeout(t);
83
+ });
84
+
85
+ /* 舞台 + 安全区 → 游戏。fit 由 observeStage 给,HUD 的高度自己量;
86
+ 两者任一变化都重发一次,setStage 去重,真变了才叫游戏 resize() */
87
+ let lastFit: StageFit | null = null;
88
+ const publishStage = (): void => {
89
+ if (!lastFit || !frame) return;
90
+ /* 安全区 = HUD 的遮挡(其 padding 已含系统安全区 / 模拟刘海)+ 左右的系统安全区(探针读 env) */
91
+ const cs = probe ? getComputedStyle(probe) : null;
92
+ const safe = lastFit.layout === 'mobile'
93
+ ? safeInsetsOf(lastFit, {
94
+ top: hudTop?.offsetHeight ?? 0, bottom: hudBottom?.offsetHeight ?? 0,
95
+ left: cs ? parseFloat(cs.paddingLeft) || 0 : 0, right: cs ? parseFloat(cs.paddingRight) || 0 : 0,
96
+ })
97
+ : NO_INSETS;
98
+ const changed = p.setStage({ layout: lastFit.layout, width: lastFit.width, height: lastFit.height, safe });
99
+ frame.dataset.layout = lastFit.layout;
100
+ frame.style.setProperty('--gk-safe-top', `${safe.top}px`);
101
+ frame.style.setProperty('--gk-safe-right', `${safe.right}px`);
102
+ frame.style.setProperty('--gk-safe-bottom', `${safe.bottom}px`);
103
+ frame.style.setProperty('--gk-safe-left', `${safe.left}px`);
104
+ if (changed) inst?.resize();
105
+ };
106
+ /* HUD 只在手机布局里存在,元素换了观察器跟着换 */
107
+ $effect(() => {
108
+ const els = [hudTop, hudBottom].filter((e): e is HTMLElement => !!e);
109
+ const ro = new ResizeObserver(publishStage);
110
+ for (const e of els) ro.observe(e);
111
+ addEventListener('resize', publishStage);
112
+ publishStage();
113
+ return () => { ro.disconnect(); removeEventListener('resize', publishStage); };
114
+ });
115
+ $effect(() => {
116
+ if (!betDlg) return;
117
+ if (betOpen && !betDlg.open) betDlg.showModal();
118
+ else if (!betOpen && betDlg.open) betDlg.close();
119
+ });
120
+ $effect(() => {
121
+ if (!menuDlg) return;
122
+ if (menuOpen && !menuDlg.open) menuDlg.showModal();
123
+ else if (!menuOpen && menuDlg.open) menuDlg.close();
124
+ });
125
+ let betOpen = $state(false);
126
+ let menuOpen = $state(false);
127
+ const toggleAuto = (): void => { menuOpen = false; if (p.autoActive) p.stopAuto(); else void p.startAuto(); };
128
+
63
129
  onMount(() => {
64
130
  let live = true;
65
131
  let off: (() => void) | undefined;
66
132
  let handle: GameInstance<O> | undefined;
67
133
 
68
- /* 和玩家站点的 GameFrame 同一条缩放规则:容器 CSS 尺寸恒为 STAGE_W×STAGE_H,
69
- 按舞台宽度算一个倍率。舞台自己是黄金比例,高度自然对得上。
70
- 观察的是舞台而不是窗口:机台在窄屏下会重排,同样的窗口宽度可能对应两种舞台宽度 */
71
- const fit = (): void => {
72
- const w = stage?.clientWidth ?? 0;
73
- if (frame && w > 0) frame.style.transform = `scale(${w / STAGE_W})`;
74
- };
75
- const ro = new ResizeObserver(fit);
76
- ro.observe(stage!);
77
- fit();
134
+ /* 和玩家站点的 GameFrame 同一条缩放规则(dev-host 的 observeStage):容器 CSS 尺寸恒为逻辑分辨率,
135
+ 桌面按舞台宽算倍率,手机两个方向都看。第一次 fit 同步发生在这里,早于下面的 mount,
136
+ 所以游戏 mount 时 host.stage 已经是对的 */
137
+ const fitter = observeStage({
138
+ stage: stage!, frame: frame!, layout: () => p.layout,
139
+ onFit: (f) => { lastFit = f; publishStage(); },
140
+ });
78
141
 
79
142
  void (async () => {
80
143
  // 先和服务端握手:余额、种子承诺、注额区间都在那边
81
144
  await p.init();
82
145
  /* 资源在平台这一侧预加载:进度画在机台上,游戏的 mount 假定 critical 已到齐。
83
146
  图片句柄挂在 pre 上活到卸载——opaque origin 下没有落盘缓存,这份就是唯一一份 */
84
- const pre = await preloadGame(game.assets, { onProgress: (x) => p.setLoadProgress(x.ratio) });
85
- if (!live) return;
86
- const i = await game.mount(p.host(), frame!);
87
- if (!live) { i.destroy(); return; }
147
+ const meter = createLoadMeter({ onChange: (r) => p.setLoadProgress(r) });
148
+ let i: GameInstance<O>;
149
+ try {
150
+ const pre = await Promise.race([preloadGame(game.assets, { onProgress: (x) => meter.real(x.ratio) }), meter.timeout()]);
151
+ if (!live) return;
152
+ i = await Promise.race([game.mount(p.host(), frame!), meter.timeout()]);
153
+ if (!live) { i.destroy(); return; }
154
+ pre.prefetchLazy();
155
+ } catch (e) {
156
+ p.err = e instanceof Error ? e.message : String(e);
157
+ return;
158
+ } finally {
159
+ meter.stop();
160
+ }
161
+ p.setLoadProgress(1);
88
162
  handle = i; inst = i;
89
- pre.prefetchLazy();
90
163
  off = i.status.subscribe(s => p.setGameStatus(s));
91
164
  p.bindGame({ present: (r, rate) => i.present(r, rate ? { rate } : undefined), status: () => p.game });
92
165
  })();
@@ -105,7 +178,7 @@
105
178
 
106
179
  return () => {
107
180
  live = false;
108
- ro.disconnect();
181
+ fitter.dispose();
109
182
  document.removeEventListener('keydown', onKey);
110
183
  off?.();
111
184
  handle?.destroy();
@@ -133,8 +206,8 @@
133
206
  };
134
207
  </script>
135
208
 
136
- <div class="shell" class:embedded>
137
- {#if !embedded}
209
+ <div class="shell" class:embedded class:mobile>
210
+ {#if !embedded && !mobile}
138
211
  <header class="top">
139
212
  <span class="brand big">GameKit</span>
140
213
  <span class="dim small">参考平台</span>
@@ -148,7 +221,8 @@
148
221
  </div>
149
222
  {/if}
150
223
 
151
- <div class="cab" data-mode={p.mode}>
224
+ <div class="cab" class:mobile data-mode={p.mode}>
225
+ {#if !mobile}
152
226
  <!-- 左:下注与参数 -->
153
227
  <section class="panel-col">
154
228
  <div class="panel-inner">
@@ -179,72 +253,106 @@
179
253
  onchange={e => { p.autoRounds = Math.max(0, parseInt(e.currentTarget.value, 10) || 0);
180
254
  e.currentTarget.value = String(p.autoRounds); }} />
181
255
  </Field>
182
- <button class="cta cash" type="button" disabled={!p.ready0 || !p.spec.autoplay}
183
- onclick={() => p.autoActive ? p.stopAuto() : p.startAuto()}>
184
- {#if p.autoActive}停止<span class="n">({p.autoLeft})</span>{:else if p.spec.autoplay}开始自动{:else}这个游戏不支持自动{/if}
185
- </button>
186
- {:else if p.game.busy}
187
- <button class="cta" type="button" disabled={!p.game.canSkip} onclick={() => inst?.skip()}>
188
- {p.game.canSkip ? '直接看结果' : '进行中'}
189
- </button>
190
- {:else}
191
- <button class="cta" type="button" disabled={!p.canBet} onclick={() => p.playOne()}>
192
- 下注 <span class="n">{money(p.bet)}</span>
193
- </button>
194
256
  {/if}
257
+ {@render cta()}
195
258
 
196
259
  {#if p.err}<p class="err small">{p.err}</p>{/if}
197
260
 
198
261
  <hr />
199
262
  <!-- 只有这一块滚:注额和下注按钮得一直够得着,参数再多也不该把它们顶出视野 -->
200
263
  <div class="params">
201
- <SchemaFields />
202
- {#each p.issues as i (i.field)}
203
- <p class="bad small">{Object.values(i.message)[0]}</p>
204
- {/each}
205
- {#if p.tableMode}
206
- <p class="hint">表 <span class="mono">{p.tableMode}</span>{#if tableStats} · 实算 RTP {(tableStats.computedRtpPpm / 1e4).toFixed(2)}% · 命中 {(tableStats.hitRatePpm / 1e4).toFixed(2)}% · 最大 {(tableStats.maxPayoutCenti / 100).toFixed(2)}x{:else if !p.remote} · 第一注时就地建表{/if}</p>
207
- {/if}
264
+ {@render params()}
208
265
  </div>
209
266
  </div>
210
267
  </section>
268
+ {/if}
211
269
 
212
- <!-- 右:游戏画布。容器尺寸恒为逻辑分辨率,onMount 里按舞台宽度整体缩放 -->
213
- <section class="stage" style="--ratio:{STAGE_W}/{STAGE_H}" bind:this={stage}>
270
+ <!-- 游戏画布。容器尺寸恒为逻辑分辨率,observeStage 按布局整体缩放。不进 {#if}:切布局不重挂游戏 -->
271
+ <section class="stage" class:ready={!veil} style="--ratio:{STAGE_W}/{STAGE_H}" bind:this={stage}>
214
272
  <div class="gameframe" bind:this={frame} style="width:{STAGE_W}px;height:{STAGE_H}px"></div>
215
- {#if p.game.phase === 'loading'}
216
- <div class="loading">
217
- <p>{p.loadProgress === null ? '正在装载游戏…' : `加载资源… ${Math.round(p.loadProgress * 100)}%`}</p>
218
- {#if p.loadProgress !== null}<span class="loadbar"><i style="width:{p.loadProgress * 100}%"></i></span>{/if}
273
+ {#if veil}
274
+ <div class="loading" out:fade={{ duration: 180 }}>
275
+ <p>加载资源… {Math.round(p.loadProgress * 100)}%</p>
276
+ <span class="loadbar"><i style="width:{p.loadProgress * 100}%"></i></span>
219
277
  </div>
220
278
  {/if}
221
279
  </section>
222
280
 
281
+ {#if !mobile}
223
282
  <!-- 底:全机台的设置条 -->
224
283
  <footer class="bar">
225
284
  <div class="tg">
226
- <button type="button" aria-pressed={p.view.turbo}
227
- onclick={() => p.setView({ turbo: !p.view.turbo })}>
228
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7z" /></svg>
229
- 快速模式
230
- </button>
231
- <button type="button" aria-pressed={p.view.sound}
232
- onclick={() => p.setView({ sound: !p.view.sound })}>
233
- {#if p.view.sound}
234
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9zM17 8a5 5 0 0 1 0 8" /></svg>
235
- {:else}
236
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9zM17 10l4 4M21 10l-4 4" /></svg>
237
- {/if}
238
- 音效
239
- </button>
240
- <label class="seed">客户端种子
241
- <input bind:value={p.clientSeed} disabled={p.autoActive} /></label>
242
- {#if p.server.refill}
243
- <button type="button" onclick={() => p.refill()}>重置余额</button>
244
- {/if}
285
+ {@render toggles()}
245
286
  </div>
246
287
  <span class="brand">GameKit</span>
247
288
  </footer>
289
+ {:else}
290
+ <!-- 手机:HUD 叠在画布上下两端,被盖住的部分经 host.stage.safe 告诉游戏。
291
+ 底栏和玩家站点同一副样子:假输入框 / 圆形下注键 / 设置齿轮 -->
292
+ <i class="sysprobe" bind:this={probe} aria-hidden="true"></i>
293
+ <div class="hud hud-top" bind:this={hudTop} style:padding-top={simSafe ? `${simSafe.top + 8}px` : undefined}>
294
+ <span class="grow"></span>
295
+ <span class="bal">{money(p.balance)}</span>
296
+ </div>
297
+ <div class="hud hud-bottom" class:away={p.game.busy && !p.autoActive} bind:this={hudBottom} style:padding-bottom={simSafe ? `${simSafe.bottom + 12}px` : undefined}>
298
+ {#if p.err}<p class="err small">{p.err}</p>{/if}
299
+ <div class="hud-bar">
300
+ <button type="button" class="fakeinp" aria-label="注额" disabled={!p.canBet} onclick={() => betOpen = true}>
301
+ <span class="coin" aria-hidden="true"></span>
302
+ <b>{money(p.bet)}</b>
303
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L18 10l-4-4L4 16z" /></svg>
304
+ </button>
305
+ {#if p.autoActive}
306
+ <button type="button" class="playbtn stop" aria-label="停止自动" onclick={() => p.stopAuto()}><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" /></svg></button>
307
+ {:else if p.game.busy}
308
+ <button type="button" class="playbtn" aria-label="直接看结果" disabled={!p.game.canSkip} onclick={() => inst?.skip()}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5v14l9-7zM16 5h3v14h-3z" /></svg></button>
309
+ {:else}
310
+ <button type="button" class="playbtn" aria-label="下注" disabled={!p.canBet} onclick={() => p.playOne()}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z" /></svg></button>
311
+ {/if}
312
+ <button type="button" class="gearbtn" aria-label="设置" onclick={() => menuOpen = true}>
313
+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1" /></svg>
314
+ </button>
315
+ </div>
316
+ </div>
317
+
318
+ <dialog class="sheet" bind:this={betDlg} onclose={() => betOpen = false}>
319
+ <h3>注额</h3>
320
+ <div class="body">
321
+ <div class="inp">
322
+ <span class="coin" aria-hidden="true"></span>
323
+ <input aria-label="注额" inputmode="decimal" bind:value={betText} onchange={commitBet} disabled={!p.canBet} />
324
+ <button type="button" aria-label="减半" disabled={!p.canBet} onclick={() => scaleBet(0.5)}>½</button>
325
+ <button type="button" aria-label="加倍" disabled={!p.canBet} onclick={() => scaleBet(2)}>2×</button>
326
+ </div>
327
+ <p class="hint">区间 {money(p.limits.min)}–{money(p.limits.max)},步长 {money(p.limits.step)}</p>
328
+ <button type="button" class="ghostbtn" onclick={() => betOpen = false}>完成</button>
329
+ </div>
330
+ </dialog>
331
+ <dialog class="sheet" bind:this={menuDlg} onclose={() => menuOpen = false}>
332
+ <h3>设置</h3>
333
+ <div class="body">
334
+ <p class="small" style="margin:0">本场 <b class="num" class:win={p.pnl > 0} class:bad={p.pnl < 0}>{signed(p.pnl)}</b></p>
335
+ <div class="tg">
336
+ {@render toggles()}
337
+ </div>
338
+ <h3>参数</h3>
339
+ {@render params()}
340
+ <h3>自动</h3>
341
+ <Field>
342
+ {#snippet label()}<label for="rounds-m">自动局数</label>{/snippet}
343
+ {#snippet accent()}<span>0 = 不限</span>{/snippet}
344
+ <input id="rounds-m" type="number" min="0" max="1000" disabled={p.autoActive}
345
+ value={p.autoActive ? p.autoLeft : p.autoRounds}
346
+ onchange={e => { p.autoRounds = Math.max(0, parseInt(e.currentTarget.value, 10) || 0);
347
+ e.currentTarget.value = String(p.autoRounds); }} />
348
+ </Field>
349
+ <button class="cta cash" type="button" disabled={!p.ready0 || !p.spec.autoplay} onclick={toggleAuto}>
350
+ {#if p.autoActive}停止<span class="n">({p.autoLeft})</span>{:else if p.spec.autoplay}开始自动{:else}这个游戏不支持自动{/if}
351
+ </button>
352
+ <button type="button" class="ghostbtn" onclick={() => menuOpen = false}>关闭</button>
353
+ </div>
354
+ </dialog>
355
+ {/if}
248
356
  </div>
249
357
 
250
358
  <!-- 机台下面:开发者要看的东西。玩家站点这里放的是最近对局和这局用哪张表 -->
@@ -328,6 +436,55 @@
328
436
  </section>
329
437
  </div>
330
438
 
439
+ {#snippet cta()}
440
+ {#if p.mode === 'auto'}
441
+ <button class="cta cash" type="button" disabled={!p.ready0 || !p.spec.autoplay}
442
+ onclick={() => p.autoActive ? p.stopAuto() : p.startAuto()}>
443
+ {#if p.autoActive}停止<span class="n">({p.autoLeft})</span>{:else if p.spec.autoplay}开始自动{:else}这个游戏不支持自动{/if}
444
+ </button>
445
+ {:else if p.game.busy}
446
+ <button class="cta" type="button" disabled={!p.game.canSkip} onclick={() => inst?.skip()}>
447
+ {p.game.canSkip ? '直接看结果' : '进行中'}
448
+ </button>
449
+ {:else}
450
+ <button class="cta" type="button" disabled={!p.canBet} onclick={() => p.playOne()}>
451
+ 下注 <span class="n">{money(p.bet)}</span>
452
+ </button>
453
+ {/if}
454
+ {/snippet}
455
+
456
+ {#snippet params()}
457
+ <SchemaFields />
458
+ {#each p.issues as i (i.field)}
459
+ <p class="bad small">{Object.values(i.message)[0]}</p>
460
+ {/each}
461
+ {#if p.tableMode}
462
+ <p class="hint">表 <span class="mono">{p.tableMode}</span>{#if tableStats} · 实算 RTP {(tableStats.computedRtpPpm / 1e4).toFixed(2)}% · 命中 {(tableStats.hitRatePpm / 1e4).toFixed(2)}% · 最大 {(tableStats.maxPayoutCenti / 100).toFixed(2)}x{:else if !p.remote} · 第一注时就地建表{/if}</p>
463
+ {/if}
464
+ {/snippet}
465
+
466
+ {#snippet toggles()}
467
+ <button type="button" aria-pressed={p.view.turbo}
468
+ onclick={() => p.setView({ turbo: !p.view.turbo })}>
469
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7z" /></svg>
470
+ 快速模式
471
+ </button>
472
+ <button type="button" aria-pressed={p.view.sound}
473
+ onclick={() => p.setView({ sound: !p.view.sound })}>
474
+ {#if p.view.sound}
475
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9zM17 8a5 5 0 0 1 0 8" /></svg>
476
+ {:else}
477
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9zM17 10l4 4M21 10l-4 4" /></svg>
478
+ {/if}
479
+ 音效
480
+ </button>
481
+ <label class="seed">客户端种子
482
+ <input bind:value={p.clientSeed} disabled={p.autoActive} /></label>
483
+ {#if p.server.refill}
484
+ <button type="button" onclick={() => p.refill()}>重置余额</button>
485
+ {/if}
486
+ {/snippet}
487
+
331
488
  <style>
332
489
  /* 只写这一页自己的布局。机台、表单、按钮的样式全在共享的 theme.css 里 */
333
490
  .shell { width: min(1200px, calc(100% - 32px)); margin: 0 auto; padding-bottom: 48px; }
@@ -336,10 +493,17 @@
336
493
  /* 嵌入时 iframe 的视口就是机台那一栏的宽度,studio 把它缩到 1000 以下很常见(1440 的屏就会),
337
494
  但机台不该因此切成窄屏的上下堆叠——它在 studio 里始终是一台横着的机器。
338
495
  把 theme.css 里窄屏媒体查询改掉的那几条按宽屏写回来 */
339
- .shell.embedded .cab { grid-template-columns: 304px minmax(0, 1fr); grid-template-areas: "panel stage" "bar bar"; }
340
- .shell.embedded .panel-col { min-height: 0; }
341
- .shell.embedded .panel-inner { position: absolute; overflow: hidden; }
342
- .shell.embedded .params { flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; }
496
+ .shell.embedded .cab:not(.mobile) { grid-template-columns: 304px minmax(0, 1fr); grid-template-areas: "panel stage" "bar bar"; }
497
+ .shell.embedded .cab:not(.mobile) .panel-col { min-height: 0; }
498
+ .shell.embedded .cab:not(.mobile) .panel-inner { position: absolute; overflow: hidden; }
499
+ .shell.embedded .cab:not(.mobile) .params { flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; }
500
+ /* 手机机台顶满整个视口:它自己就是那台手机的屏幕,开发面板在它下面 */
501
+ .shell.mobile { width: 100%; padding-bottom: 16px; }
502
+ .shell.mobile .dev { margin: 16px 12px 0; }
503
+ .sheet .body .tg { align-items: stretch; }
504
+ .sheet .body .tg > button { display: flex; align-items: center; gap: 8px; border: 1px solid var(--line); background: var(--field); border-radius: 12px; min-height: 42px; padding: 0 12px; font-weight: 700; color: var(--dim); }
505
+ .sheet .body .tg > button[aria-pressed="true"] { color: var(--ink); border-color: var(--ink); }
506
+ .sheet .body .tg svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
343
507
  /* 和玩家站点的 game.html 一样:游戏根元素撑满整块逻辑画面,由游戏自己在里面排版。
344
508
  不撑的话根元素按内容高度长,超出 771 的那一截会被舞台裁掉,而且很不显眼 */
345
509
  /* 写成 .stage > .gameframe 是为了压过 theme.css 里同名的 display:block */
package/dev-host/index.ts CHANGED
@@ -4,5 +4,6 @@ export * from './local.ts';
4
4
  export * from './remote.ts';
5
5
  export * from './platform.svelte.ts';
6
6
  export * from './context.svelte.ts';
7
+ export * from './layout.ts';
7
8
  export { runDevShell, type RunOptions } from './run.ts';
8
9
  export { ensureHostFonts } from './fonts.ts';
@@ -0,0 +1,80 @@
1
+ /* 布局判定与舞台缩放。玩家站点的 GameFrame 与参考平台的 DevShell 都走这里,
2
+ 两处只剩「谁是容器、谁是画面」的差别。 */
3
+ import { fitStage, type StageFit, type StageLayout } from '../sdk/index.ts';
4
+
5
+ /** 竖屏窄屏才算手机;横屏手机沿用桌面机台的堆叠布局 */
6
+ export const MOBILE_QUERY = '(max-width: 600px) and (orientation: portrait)';
7
+
8
+ const LAYOUTS = new Set<string>(['desktop', 'mobile']);
9
+
10
+ /** 惰性读 matchMedia:这个模块会被 node 测试 import,模块顶层不能碰它 */
11
+ export function detectLayout(mm: ((q: string) => { matches: boolean }) | undefined = globalThis.matchMedia): StageLayout {
12
+ try { return mm?.(MOBILE_QUERY).matches ? 'mobile' : 'desktop'; } catch { return 'desktop'; }
13
+ }
14
+
15
+ /** `?layout=mobile` 这种显式指定;没写或写错返回 null */
16
+ export function layoutFromSearch(search: string): StageLayout | null {
17
+ const v = new URLSearchParams(search).get('layout');
18
+ return v && LAYOUTS.has(v) ? (v as StageLayout) : null;
19
+ }
20
+
21
+ /** `?safe=47,34`:studio 模拟刘海与 home 指示条,iframe 里 env(safe-area-inset-*) 恒为 0 */
22
+ export function safeFromSearch(search: string): { top: number; bottom: number } | null {
23
+ const v = new URLSearchParams(search).get('safe');
24
+ if (!v) return null;
25
+ const [t = '0', b = '0'] = v.split(',');
26
+ const top = Number(t), bottom = Number(b);
27
+ return Number.isFinite(top) && Number.isFinite(bottom) ? { top, bottom } : null;
28
+ }
29
+
30
+ /**
31
+ * 让 `frame`(游戏容器:CSS 尺寸恒为逻辑分辨率)在 `stage` 里按布局整体缩放。
32
+ *
33
+ * 桌面:`.stage` 的形状由 CSS aspect-ratio 决定,只看它的宽。
34
+ * 手机:`.stage` 的父元素(`.cab.mobile`)是整块屏幕,两个方向都看,算出的显式尺寸写回 `.stage`,
35
+ * 由 CSS 把它居中。观察的是这两个元素而不是窗口——机台重排时同样的窗口宽度可能对应两种舞台宽度。
36
+ */
37
+ export function observeStage(o: {
38
+ stage: HTMLElement;
39
+ frame: HTMLElement;
40
+ layout: () => StageLayout;
41
+ onFit?: (fit: StageFit) => void;
42
+ }): { refit(): void; dispose(): void } {
43
+ let last: StageFit | null = null;
44
+ /* .stage 自己也在被观察:写它的尺寸只能在观察回调之外(下面的 rAF),且值没变就不写,
45
+ 否则每次重排都报一条 ResizeObserver loop——studio 会把它当控制台报错交给 AI */
46
+ const size = (w: string, h: string): void => {
47
+ if (o.stage.style.width !== w) o.stage.style.width = w;
48
+ if (o.stage.style.height !== h) o.stage.style.height = h;
49
+ };
50
+ const refit = (): void => {
51
+ const layout = o.layout();
52
+ const box = o.stage.parentElement;
53
+ let fit: StageFit;
54
+ if (layout === 'mobile' && box) {
55
+ fit = fitStage('mobile', box.clientWidth, box.clientHeight);
56
+ size(`${fit.width * fit.scale}px`, `${fit.height * fit.scale}px`);
57
+ } else {
58
+ size('', '');
59
+ fit = fitStage('desktop', o.stage.clientWidth, o.stage.clientHeight);
60
+ }
61
+ if (!(fit.scale > 0) || !isFinite(fit.scale)) return;
62
+ o.frame.style.width = `${fit.width}px`;
63
+ o.frame.style.height = `${fit.height}px`;
64
+ o.frame.style.transform = `scale(${fit.scale})`;
65
+ if (last && last.layout === fit.layout && last.width === fit.width && last.height === fit.height
66
+ && last.scale === fit.scale && last.x === fit.x && last.y === fit.y) return;
67
+ last = fit;
68
+ o.onFit?.(fit);
69
+ };
70
+ let raf = 0;
71
+ const schedule = (): void => {
72
+ if (raf) return;
73
+ raf = requestAnimationFrame(() => { raf = 0; refit(); });
74
+ };
75
+ const ro = new ResizeObserver(schedule);
76
+ ro.observe(o.stage);
77
+ if (o.stage.parentElement) ro.observe(o.stage.parentElement);
78
+ refit();
79
+ return { refit, dispose: () => { ro.disconnect(); if (raf) cancelAnimationFrame(raf); } };
80
+ }
@@ -6,8 +6,9 @@
6
6
  状态用 runes 写(这是个 Svelte 宿主),交给游戏的是框架中立的 Readable。
7
7
  换成 React 宿主时这一层换成 useSyncExternalStore,游戏侧零改动。 */
8
8
  import {
9
- defaultsOf, writable,
10
- type GameDefinition, type GameStatus, type Host, type Readable, type RoundResult, type ViewSettings, type WalletView,
9
+ DESKTOP_STAGE, defaultsOf, writable,
10
+ type GameDefinition, type GameStatus, type Host, type Readable, type RoundResult, type StageInfo, type StageLayout,
11
+ type ViewSettings, type WalletView,
11
12
  } from '../sdk/index.ts';
12
13
  import { localServer } from './local.ts';
13
14
  import { DEFAULT_LIMITS, type Check, type PlatformServer, type PlayInput } from './platform-server.ts';
@@ -48,6 +49,7 @@ export interface PlayOptions {
48
49
  */
49
50
  export function createPlatform<C, O>(
50
51
  spec: GameDefinition<C, O>, server: PlatformServer<C, O> = localServer({ spec }),
52
+ opts: { layout?: StageLayout } = {},
51
53
  ) {
52
54
  let api: GameApi<O> | null = null;
53
55
  const defaults = defaultsOf(spec.schema);
@@ -61,6 +63,7 @@ export function createPlatform<C, O>(
61
63
  });
62
64
  const walletStore = writable<WalletView>({ balance: 0, currency: 'CNY', decimals: 2 });
63
65
  const jackpotStore = writable<{ v: number | null }>({ v: null });
66
+ const stageStore = writable<StageInfo>(DESKTOP_STAGE);
64
67
 
65
68
  class Platform {
66
69
  config = $state<C>(defaults);
@@ -95,10 +98,14 @@ export function createPlatform<C, O>(
95
98
  clientSeed = $state('player-local');
96
99
  /** 游戏交回来的状态 */
97
100
  game = $state<GameStatus>({ phase: 'loading', busy: false, canBet: false, canSkip: false, canEditConfig: false });
98
- /** 资源预加载进度 0..1。平台侧的加载器写,游戏不知道有这回事;null = 代码还没下完 */
99
- loadProgress = $state<number | null>(null);
101
+ /** 机台上的加载进度 0..1。平台侧的表写,游戏不知道有这回事 */
102
+ loadProgress = $state(0);
100
103
  /** 本地服务端已加载的表(mode 名) */
101
104
  tableModes = $state<string[]>([]);
105
+ /** 机台的布局。切换只改机台的兄弟节点和缩放,游戏实例不重挂 */
106
+ layout = $state<StageLayout>(opts.layout ?? 'desktop');
107
+ /** 交给游戏的舞台的只读副本,机台渲染用;真值在 stageStore 里 */
108
+ stage = $state<StageInfo>(DESKTOP_STAGE);
102
109
 
103
110
  readonly spec = spec;
104
111
  readonly server = server;
@@ -139,6 +146,17 @@ export function createPlatform<C, O>(
139
146
  bindGame(a: GameApi<O>): void { api = a; }
140
147
  setGameStatus(s: GameStatus): void { this.game = s; }
141
148
  setLoadProgress(ratio: number): void { this.loadProgress = ratio; }
149
+ setLayout(l: StageLayout): void { this.layout = l; }
150
+ /** 机台量好了舞台与安全区,写给游戏。传进来的对象会被原样交出去,调用方给的是字面量 */
151
+ setStage(info: StageInfo): boolean {
152
+ const cur = stageStore.get();
153
+ if (cur.layout === info.layout && cur.width === info.width && cur.height === info.height
154
+ && cur.safe.top === info.safe.top && cur.safe.bottom === info.safe.bottom
155
+ && cur.safe.left === info.safe.left && cur.safe.right === info.safe.right) return false;
156
+ stageStore.set(info);
157
+ this.stage = info;
158
+ return true;
159
+ }
142
160
 
143
161
  host(): Host<C> {
144
162
  return {
@@ -146,6 +164,7 @@ export function createPlatform<C, O>(
146
164
  view: viewStore,
147
165
  wallet: walletStore,
148
166
  jackpot: { get: () => jackpotStore.get().v, subscribe: (run) => jackpotStore.subscribe((s) => run(s.v)) } as Readable<number | null>,
167
+ stage: stageStore,
149
168
  audio: { context: () => audioCtx(), volume: () => this.view.volume },
150
169
  locale: 'zh-CN',
151
170
  requestConfig: (p) => { this.requestConfig(p); },
package/dev-host/run.ts CHANGED
@@ -5,11 +5,12 @@
5
5
  (Record<string, unknown>),于是每个游戏的 dev.ts 都得写一次强转。
6
6
  包在这里,强转只出现一次,游戏侧的调用是类型安全的。 */
7
7
  import { mount } from 'svelte';
8
- import type { GameModule } from '../sdk/index.ts';
8
+ import type { GameModule, StageLayout } from '../sdk/index.ts';
9
9
  import type { PlatformServer } from './platform-server.ts';
10
10
  import type { HistoryEntry, PlatformOf } from './platform.svelte.ts';
11
11
  import DevShell from './DevShell.svelte';
12
12
  import { ensureHostFonts } from './fonts.ts';
13
+ import { detectLayout, layoutFromSearch, safeFromSearch } from './layout.ts';
13
14
  import './theme.css';
14
15
 
15
16
  export interface RunOptions<C = object, O = unknown> {
@@ -25,6 +26,10 @@ export interface RunOptions<C = object, O = unknown> {
25
26
  server?: PlatformServer<C, O>;
26
27
  onSaveCase?: (h: HistoryEntry, title: string) => Promise<string>;
27
28
  onPlatform?: (p: PlatformOf<C, O>) => void;
29
+ /** 初始布局。不传:`?layout=` 说了算,没说就按窗口判 */
30
+ layout?: StageLayout;
31
+ /** 模拟真机的刘海与 home 指示条(屏幕像素),加进 HUD 的内边距。不传:`?safe=top,bottom` */
32
+ simSafe?: { top: number; bottom: number };
28
33
  }
29
34
 
30
35
  export function runDevShell<C extends object, O>(
@@ -41,6 +46,8 @@ export function runDevShell<C extends object, O>(
41
46
  server: o.server as unknown as PlatformServer<object, unknown> | undefined,
42
47
  onSaveCase: o.onSaveCase,
43
48
  onPlatform: o.onPlatform as unknown as ((p: PlatformOf<object, unknown>) => void) | undefined,
49
+ layout: o.layout ?? layoutFromSearch(location.search) ?? detectLayout(),
50
+ simSafe: o.simSafe ?? safeFromSearch(location.search) ?? undefined,
44
51
  }
45
52
  });
46
53
  }