gamekit777 0.1.6 → 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 (47) hide show
  1. package/create-game/scaffold.ts +72 -15
  2. package/create-game/template/base/game.meta.json +9 -0
  3. package/create-game/template/{game.ts.tmpl → base/game.ts} +15 -11
  4. package/create-game/template/{package.json.tmpl → base/package.json} +7 -3
  5. package/create-game/template/{src/components/App.svelte.tmpl → base/src/components/App.svelte} +8 -3
  6. package/create-game/template/{src/context.svelte.ts.tmpl → base/src/context.svelte.ts} +5 -5
  7. package/create-game/template/{src → base/src}/rules/const.ts +2 -1
  8. package/create-game/template/{src/rules/restore.ts.tmpl → base/src/rules/restore.ts} +7 -5
  9. package/create-game/template/{src/rules/schema.ts.tmpl → base/src/rules/schema.ts} +2 -2
  10. package/create-game/template/{src/rules/simulate.ts.tmpl → base/src/rules/simulate.ts} +2 -2
  11. package/create-game/template/{src/rules/types.ts.tmpl → base/src/rules/types.ts} +2 -2
  12. package/create-game/template/{src/state/game.svelte.ts.tmpl → base/src/state/game.svelte.ts} +14 -7
  13. package/create-game/template/{src/view/mount.svelte.ts.tmpl → base/src/view/mount.svelte.ts} +6 -4
  14. package/create-game/template/{src/view/present.ts.tmpl → base/src/view/present.ts} +2 -2
  15. package/create-game/template/{test/e2e.test.ts.tmpl → base/test/e2e.test.ts} +4 -1
  16. package/create-game/template/base/tsconfig.json +8 -0
  17. package/create-game/template/{CLAUDE.md.tmpl → overlay/CLAUDE.md.tmpl} +13 -3
  18. package/dev-host/DevShell.svelte +237 -73
  19. package/dev-host/index.ts +1 -0
  20. package/dev-host/layout.ts +80 -0
  21. package/dev-host/platform.svelte.ts +23 -4
  22. package/dev-host/run.ts +8 -1
  23. package/dev-host/theme.css +142 -10
  24. package/package.json +1 -1
  25. package/runtime/index.ts +1 -0
  26. package/runtime/meter.ts +72 -0
  27. package/runtime/registry.ts +5 -1
  28. package/sdk/host.ts +8 -0
  29. package/sdk/stage.ts +97 -11
  30. package/studio/app/App.svelte +92 -10
  31. package/studio/app/panels/PreviewPanel.svelte +4 -0
  32. package/studio/preview/entry.ts +10 -1
  33. package/studio/src/engine.ts +5 -1
  34. package/studio/src/mcp.ts +4 -2
  35. package/studio/src/studio-plugin.ts +29 -13
  36. package/create-game/template/game.meta.json.tmpl +0 -8
  37. package/create-game/template/tsconfig.json +0 -22
  38. /package/create-game/template/{scripts/verify.ts.tmpl → base/scripts/verify.ts} +0 -0
  39. /package/create-game/template/{src → base/src}/assets/critical/.gitkeep +0 -0
  40. /package/create-game/template/{src → base/src}/assets/lazy/.gitkeep +0 -0
  41. /package/create-game/template/{src/index.ts.tmpl → base/src/index.ts} +0 -0
  42. /package/create-game/template/{src → base/src}/styles/animations.css +0 -0
  43. /package/create-game/template/{src → base/src}/styles/global.css +0 -0
  44. /package/create-game/template/{src → base/src}/view/bridge.svelte.ts +0 -0
  45. /package/create-game/template/{vite.config.ts → base/vite.config.ts} +0 -0
  46. /package/create-game/template/{vitest.config.ts → base/vitest.config.ts} +0 -0
  47. /package/create-game/template/{_gitignore → overlay/_gitignore} +0 -0
@@ -71,11 +71,24 @@ body {
71
71
  -webkit-font-smoothing: antialiased;
72
72
  }
73
73
 
74
- /* 游戏容器:CSS 尺寸恒为逻辑分辨率(sdk 的 STAGE_W/STAGE_H),由平台按舞台宽度整体 scale。
75
- 玩家站点里它是 sandbox iframe,参考平台里是直接挂载游戏的 div,缩放规则是同一条。
76
- 放在 @scope 外面,因为它自己就是下面那道边界。 */
74
+ /* 游戏容器:CSS 尺寸恒为逻辑分辨率(sdk 的 STAGE_W/STAGE_H 或 MOBILE_W/H),由平台按 fitStage 整体 scale。
75
+ 玩家站点里它是 sandbox iframe,参考平台里是直接挂载游戏的 div,缩放规则是同一条(dev-host 的 observeStage)。
76
+ 放在 @scope 外面,因为它自己就是下面那道边界。
77
+ container-type 让游戏用 @container gk-stage 而不是 @media:iframe 的视口是逻辑分辨率、
78
+ DevShell 同文档下视口是整页,两处 @media 的含义不同,容器查询在两处一致 */
77
79
  .stage > .gameframe {
78
80
  position: absolute; top: 0; left: 0; border: 0; display: block; transform-origin: 0 0;
81
+ container-type: size; container-name: gk-stage;
82
+ /* 画布里不许选中文字、长按不弹系统菜单:游戏是拿来点的,不是拿来读的。
83
+ 玩家站点里 .gameframe 是 iframe,这条进不了里面的文档,game.html 自己再写一遍 */
84
+ user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
85
+ /* 装载期间画面藏着,就绪后淡入;加载层用 Svelte 的 out:fade 同时淡出,两边交叉。
86
+ iframe 一建就存在,不藏的话游戏第一帧会在进度条底下闪出来 */
87
+ opacity: 0; transition: opacity .3s ease;
88
+ }
89
+ .stage.ready > .gameframe { opacity: 1; }
90
+ @media (prefers-reduced-motion: reduce) {
91
+ .stage > .gameframe { transition: none; }
79
92
  }
80
93
 
81
94
  /* 下面所有带类名的规则都止步于 .gameframe:参考平台和 studio 预览里游戏和平台在同一个
@@ -119,7 +132,11 @@ h3 { font-size: 13px; font-weight: 700; margin: 0 0 8px; }
119
132
  border-radius: 22px;
120
133
  box-shadow: var(--cab-shadow);
121
134
  overflow: hidden;
135
+ /* 机台上的控件同样不许选中:按钮、标签、抽屉里的文字都是操作面,手机上双击/长按不该拉出选区。
136
+ 只有真正的输入框例外 */
137
+ user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
122
138
  }
139
+ .cab input, .cab textarea { user-select: text; -webkit-user-select: text; }
123
140
  /* 参数区里那一层是绝对定位的:机台这一行的高度必须只由画布的黄金比例决定,
124
141
  而 grid 的 auto 行高会取到 item 的 max-content——参数多一条机台就高一截。
125
142
  脱离文档流之后它对行高的贡献是 0,装不下就自己滚。
@@ -168,11 +185,11 @@ h3 { font-size: 13px; font-weight: 700; margin: 0 0 8px; }
168
185
  }
169
186
  .stage .loading p { margin: 0; }
170
187
  .stage .loadbar { display: block; width: 220px; height: 6px; border-radius: 99px; background: rgba(255, 255, 255, .12); overflow: hidden; }
171
- .stage .loadbar i { display: block; height: 100%; border-radius: 99px; background: var(--banana); transition: width .15s; }
188
+ .stage .loadbar i { display: block; height: 100%; border-radius: 99px; background: var(--banana); transition: width .12s; }
172
189
 
173
190
  .bar {
174
191
  grid-area: bar; display: flex; align-items: center; justify-content: space-between;
175
- gap: 12px; padding: 10px 20px; border-top: 1px solid var(--line);
192
+ gap: 12px; padding: 10px 20px;
176
193
  }
177
194
  .bar .tg { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
178
195
  .bar button {
@@ -188,13 +205,128 @@ h3 { font-size: 13px; font-weight: 700; margin: 0 0 8px; }
188
205
  .bal { font-family: var(--num); font-weight: 400; font-size: 19px; font-variant-numeric: tabular-nums; color: var(--accent-ink); }
189
206
 
190
207
  /* 窄屏:画布在上,操作区在中,工具条在底。整机高度改为自适应——
191
- 画布仍按黄金比例跟着宽度走,参数区不再滚动而是整个摊开 */
208
+ 画布仍按黄金比例跟着宽度走,参数区不再滚动而是整个摊开。
209
+ 手机机台不走这条:它有自己的一套(下面) */
192
210
  @media (max-width: 1000px) {
193
- .cab { grid-template-columns: minmax(0, 1fr); grid-template-areas: "stage" "panel" "bar"; }
194
- .panel-col { min-height: auto; }
195
- .panel-inner { position: static; overflow: visible; }
196
- .params { flex: none; min-height: auto; overflow-y: visible; padding-right: 0; }
211
+ .cab:not(.mobile) { grid-template-columns: minmax(0, 1fr); grid-template-areas: "stage" "panel" "bar"; }
212
+ .cab:not(.mobile) .panel-col { min-height: auto; }
213
+ .cab:not(.mobile) .panel-inner { position: static; overflow: visible; }
214
+ .cab:not(.mobile) .params { flex: none; min-height: auto; overflow-y: visible; padding-right: 0; }
215
+ }
216
+
217
+ /* ---------- 手机机台:画布铺满整屏,操作 UI 叠在上下两端 ----------
218
+ .stage 的显式尺寸由 JS 按 sdk 的 fitStage 写(两个方向都要放得下),这里只负责把它居中。
219
+ HUD 是 .stage 的兄弟而不是 .gameframe 的子节点:transform:scale 在 .gameframe 上建了层叠上下文,
220
+ 放进去 z-index 就失效。HUD 自己的内边距吃 env(safe-area-inset-*)——它只在顶层文档里有值,
221
+ 游戏那边(iframe 里)拿不到,所以被盖住多少由平台量出来经 host.stage 下发。
222
+ 用 height:100dvh 而不是 position:fixed:DevShell 嵌在 studio 里时机台下面还有开发面板要能滚到 */
223
+ .cab.mobile {
224
+ grid-template-columns: minmax(0, 1fr);
225
+ grid-template-rows: minmax(0, 1fr);
226
+ grid-template-areas: "stage";
227
+ height: 100dvh;
228
+ border-radius: 0; box-shadow: none;
229
+ background: var(--stage);
230
+ position: relative;
231
+ }
232
+ .cab.mobile .stage { aspect-ratio: auto; justify-self: center; align-self: center; }
233
+ /* 系统安全区的探针:机台读它的 computed padding 拿到 env() 的数值(JS 读不到 env)。
234
+ 顶层文档里才有值,iframe 里恒为 0——所以只有平台量得到,再连同 HUD 的遮挡一起下发给游戏 */
235
+ .cab.mobile .sysprobe {
236
+ position: absolute; top: 0; left: 0; visibility: hidden; pointer-events: none;
237
+ padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
238
+ }
239
+ .cab.mobile .hud { position: absolute; left: 0; right: 0; z-index: 2; display: flex; flex-direction: column; gap: 8px; }
240
+ /* 渐变在系统安全区那段是实的:新 iOS 的状态栏底下自带一层渐变,落在均匀的深色带上才不显眼 */
241
+ .cab.mobile .hud-top {
242
+ top: 0; flex-direction: row; align-items: center; gap: 10px;
243
+ padding: calc(8px + env(safe-area-inset-top, 0px)) 12px 14px;
244
+ color: var(--s-ink);
245
+ background: linear-gradient(to bottom, rgba(0, 0, 0, .7) 0, rgba(0, 0, 0, .7) env(safe-area-inset-top, 0px), rgba(0, 0, 0, 0) 100%);
246
+ pointer-events: none;
247
+ }
248
+ .cab.mobile .hud-top > * { pointer-events: auto; }
249
+ .cab.mobile .hud-top .title { font-family: var(--fun); font-size: 17px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
250
+ .cab.mobile .hud-top .bal { color: var(--banana); font-size: 17px; }
251
+ .cab.mobile .hud-top a { color: var(--s-ink); }
252
+ .hud-btn {
253
+ border: 0; background: rgba(255, 255, 255, .14); color: inherit;
254
+ border-radius: 10px; min-width: 36px; height: 36px; padding: 0 10px; font-size: 14px; font-weight: 700;
255
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
256
+ }
257
+ .hud-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
258
+ /* 底栏没有底色:三件控件直接悬浮在画布上,安全区就是为它们和顶上的标题栏留的。
259
+ 容器不吃点击,只有控件自己吃——没被盖住的像素仍归游戏 */
260
+ .cab.mobile .hud-bottom {
261
+ bottom: 0;
262
+ padding: 10px 16px calc(14px + env(safe-area-inset-bottom, 0px));
263
+ color: var(--s-ink);
264
+ pointer-events: none;
265
+ transition: transform .28s cubic-bezier(.4, 0, .2, 1), opacity .24s ease;
266
+ }
267
+ /* 下注后底栏从下方离场,演完回来。transform 不改 offsetHeight,安全区不跟着抖 */
268
+ .cab.mobile .hud-bottom.away { transform: translateY(115%); opacity: 0; pointer-events: none; }
269
+ .cab.mobile .hud-bottom.away > * { pointer-events: none; }
270
+ @media (prefers-reduced-motion: reduce) { .cab.mobile .hud-bottom { transition: none; } }
271
+ .cab.mobile .hud-bottom > * { pointer-events: auto; }
272
+ .cab.mobile .hud-bottom .ghostbtn { color: var(--s-ink); border-color: rgba(255, 255, 255, .35); }
273
+ .hud-row { display: flex; gap: 8px; align-items: stretch; }
274
+ .hud-row > .tabs { flex: 1; min-width: 0; }
275
+ .hud-row > .inp { flex: 1; min-width: 0; }
276
+ .hud-row > .cta { flex: 1.1; min-width: 0; }
277
+ .hud-row > .ghostbtn { flex: none; }
278
+ .hud-bottom .err { padding: 6px 10px; }
279
+
280
+ /* 手机底栏:左边一个「假输入框」(点开注额抽屉),中间圆形的下注键(播放图标),右边设置齿轮。
281
+ 下注键是两边的两倍大;两边不带底色,靠描边和投影在任何画面上都看得清。
282
+ 注额真正的输入框在抽屉里贴屏幕底——手机键盘弹起时它正好在键盘上方 */
283
+ .hud-bar { --hud-btn: 44px; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 12px; }
284
+ .hud-bar > * { pointer-events: auto; }
285
+ .fakeinp {
286
+ justify-self: start; display: inline-flex; align-items: center; gap: 8px; max-width: 100%;
287
+ height: var(--hud-btn); padding: 0 4px; border: 0; background: none;
288
+ color: var(--s-ink); font-size: 16px; text-align: left;
289
+ text-shadow: 0 1px 2px rgba(0, 0, 0, .6);
290
+ }
291
+ .fakeinp b { font-family: var(--num); font-weight: 400; font-size: 20px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
292
+ .fakeinp .coin { box-shadow: inset 0 0 0 2px var(--banana-d), inset 0 0 0 4px var(--banana), 0 1px 2px rgba(0, 0, 0, .5); }
293
+ .fakeinp svg { flex: none; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; opacity: .8; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .6)); }
294
+ .fakeinp:disabled { opacity: .5; }
295
+ .playbtn {
296
+ width: calc(var(--hud-btn) * 2); height: calc(var(--hud-btn) * 2); border-radius: 50%; border: 0;
297
+ background: var(--coco); color: #FFF6EA; box-shadow: 0 5px 0 var(--coco-d), 0 8px 24px rgba(0, 0, 0, .35);
298
+ display: inline-flex; align-items: center; justify-content: center;
299
+ transition: transform .06s, box-shadow .06s;
300
+ }
301
+ .playbtn svg { width: 40px; height: 40px; fill: currentColor; }
302
+ .playbtn:active:not(:disabled) { transform: translateY(4px); box-shadow: 0 1px 0 var(--coco-d), 0 4px 12px rgba(0, 0, 0, .35); }
303
+ .playbtn:disabled { background: rgba(255, 255, 255, .18); color: rgba(255, 255, 255, .6); box-shadow: 0 5px 0 rgba(0, 0, 0, .25); }
304
+ .playbtn.stop { background: var(--banana); color: #2A0F12; box-shadow: 0 5px 0 var(--banana-d), 0 8px 24px rgba(0, 0, 0, .35); }
305
+ .playbtn.stop:active:not(:disabled) { box-shadow: 0 1px 0 var(--banana-d), 0 4px 12px rgba(0, 0, 0, .35); }
306
+ .gearbtn {
307
+ justify-self: end; width: var(--hud-btn); height: var(--hud-btn); border-radius: 50%;
308
+ border: 0; background: none; color: var(--s-ink);
309
+ display: inline-flex; align-items: center; justify-content: center;
310
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6));
311
+ }
312
+ .gearbtn svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
313
+
314
+ /* 底部抽屉:注额、设置。原生 dialog,焦点陷阱和 Esc 都是自带的 */
315
+ .sheet {
316
+ position: fixed; inset: auto 0 0 0; margin: 0; width: 100%; max-width: none;
317
+ max-height: 80dvh; overflow-y: auto; border: 0;
318
+ border-radius: 18px 18px 0 0;
319
+ padding: 16px 16px calc(16px + env(safe-area-inset-bottom, 0px));
320
+ background: var(--cab); color: var(--ink); box-shadow: 0 -1px 0 var(--line);
197
321
  }
322
+ .sheet::backdrop { background: rgba(18, 7, 11, .5); }
323
+ /* dialog 在 top layer,机台上那条 user-select:none 传不到它,这里再写;输入框由上面的 .cab input 放开 */
324
+ .sheet { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
325
+ .sheet h3 { font-family: var(--fun); font-weight: 400; font-size: 18px; margin: 0 0 12px; }
326
+ .sheet .body h3 { margin: 8px 0 0; font-size: 16px; }
327
+ .sheet .body { display: flex; flex-direction: column; gap: 14px; }
328
+ .sheet .body .tg { display: flex; flex-direction: column; gap: 6px; }
329
+ .sheet .body .tg button, .sheet .body .tg label { justify-content: flex-start; }
198
330
 
199
331
  /* ---------- 按钮 ---------- */
200
332
  .cta {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gamekit777",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "gamekit777": "./studio/cli.ts"
package/runtime/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './assets.ts';
2
2
  export * from './fonts.ts';
3
+ export * from './meter.ts';
3
4
  export * from './preload.ts';
4
5
  export * from './registry.ts';
5
6
  export * from './types.ts';
@@ -0,0 +1,72 @@
1
+ /* 机台上的加载进度表。
2
+
3
+ 真实进度只覆盖资源那一段:游戏代码在下载时 import() 一个字节都不汇报,条会停在 0。
4
+ 所以先走一条模拟曲线:
5
+ 0–3s 缓出到 80%(大多数包这时候已经到了,看起来像真的)
6
+ 3s 后 向 95% 无限逼近、永远到不了——「快好了但没好」是诚实的
7
+ 60s 还没就绪就按超时报错,不让玩家对着一条 94% 的条等一下午
8
+ 真实进度第一次到达就**接管**:曲线停下,之后只听真实值。接管那一刻条在 s0,
9
+ 真实的 0→1 映射到 s0→1——每一步真实进度都看得见,且不会为了「对齐真实值」往回跳。
10
+ 显示值单调不减,这一条比任何一处的准确都重要:往后退的进度条等于告诉玩家出事了。 */
11
+ export const LOAD_TIMEOUT_MS = 60_000;
12
+
13
+ const FAST_MS = 3_000, FAST_TO = 0.8, CEIL = 0.95, SLOW_TAU_MS = 20_000;
14
+
15
+ export function simulatedProgress(elapsedMs: number): number {
16
+ const t = Math.max(0, elapsedMs);
17
+ if (t <= FAST_MS) { const x = t / FAST_MS; return FAST_TO * (1 - (1 - x) * (1 - x)); }
18
+ return FAST_TO + (CEIL - FAST_TO) * (1 - Math.exp(-(t - FAST_MS) / SLOW_TAU_MS));
19
+ }
20
+
21
+ export interface LoadMeter {
22
+ /** 真实进度(0..1),来自 preloadGame 的 onProgress */
23
+ real(ratio: number): void;
24
+ /** 超时即 reject。和「就绪」的 promise 一起 race */
25
+ timeout(): Promise<never>;
26
+ /** 就绪或失败后调:停表、清定时器 */
27
+ stop(): void;
28
+ }
29
+
30
+ export interface LoadMeterOptions {
31
+ onChange(ratio: number): void;
32
+ timeoutMs?: number;
33
+ tickMs?: number;
34
+ now?(): number;
35
+ }
36
+
37
+ export function createLoadMeter(o: LoadMeterOptions): LoadMeter {
38
+ const now = o.now ?? (() => performance.now());
39
+ const t0 = now();
40
+ let shown = 0, stopped = false;
41
+ /** 接管那一刻的显示值;null = 真实进度还没来,仍在走曲线 */
42
+ let base: number | null = null;
43
+ let timer: ReturnType<typeof setTimeout> | null = null;
44
+
45
+ const publish = (v: number): void => {
46
+ if (stopped || v <= shown) return;
47
+ shown = Math.min(1, v);
48
+ o.onChange(shown);
49
+ };
50
+ const tick = (): void => { if (base === null) publish(simulatedProgress(now() - t0)); };
51
+ const iv = setInterval(tick, o.tickMs ?? 80);
52
+ o.onChange(0);
53
+
54
+ return {
55
+ real(ratio) {
56
+ // 先把曲线推进到此刻再接管:两次 tick 之间到达时,接管点才是曲线现在的位置而不是上一格
57
+ if (base === null) { tick(); base = shown; }
58
+ publish(base + (1 - base) * Math.min(1, Math.max(0, ratio)));
59
+ },
60
+ timeout() {
61
+ return new Promise<never>((_, rej) => {
62
+ timer = setTimeout(() => rej(new Error(`加载超时:${Math.round((o.timeoutMs ?? LOAD_TIMEOUT_MS) / 1000)} 秒内游戏没有装起来,检查网络后刷新重试`)),
63
+ o.timeoutMs ?? LOAD_TIMEOUT_MS);
64
+ });
65
+ },
66
+ stop() {
67
+ stopped = true;
68
+ clearInterval(iv);
69
+ if (timer) clearTimeout(timer);
70
+ }
71
+ };
72
+ }
@@ -14,13 +14,17 @@
14
14
  return true,assetsInlineLimit 完全失效),唯一的逃生口就是这个 query。
15
15
  漏掉的话构建照样成功,只是资源悄悄胖进 index.js、不在 manifest 里、
16
16
  平台预加载不到——所以 manifest 插件里还有一道 base64 告警兜底。 */
17
- /// <reference types="vite/client" />
18
17
  import type { AssetBundle, AssetEntry, AssetType } from '../sdk/index.ts';
19
18
 
19
+ /* import.meta.glob 的类型在消费方的 vite/client 里。这里不去 reference 它:三斜线按文件的真实位置找 vite,
20
+ 包被 bun link / file: 符号链接进项目时那个位置没有 node_modules,整个检查会炸。
21
+ ts-ignore 两边都安全——有 vite/client 时它是空指令,没有时压掉那一条 */
22
+ // @ts-ignore
20
23
  const critical = import.meta.glob('/src/assets/critical/**/*', {
21
24
  eager: true, query: '?no-inline', import: 'default'
22
25
  }) as Record<string, string>;
23
26
 
27
+ // @ts-ignore
24
28
  const lazy = import.meta.glob('/src/assets/lazy/**/*', {
25
29
  eager: true, query: '?no-inline', import: 'default'
26
30
  }) as Record<string, string>;
package/sdk/host.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  3. 跨框架的双向绑定在 React StrictMode 下会出微妙的 bug */
8
8
  import type { Readable } from './store.ts';
9
9
  import type { RoundResult } from './contract.ts';
10
+ import type { StageInfo } from './stage.ts';
10
11
 
11
12
  /** 不影响结果的表现参数。每个游戏都有,所以是固定字段,不进 schema */
12
13
  export interface ViewSettings {
@@ -39,6 +40,12 @@ export interface Host<C> {
39
40
  /** 只读展示。游戏不碰钱 */
40
41
  wallet: Readable<WalletView>;
41
42
  jackpot: Readable<number | null>;
43
+ /**
44
+ * 游戏所在的舞台:桌面 900×771 还是手机 450×H,以及被平台 UI 盖住的边(逻辑像素)。
45
+ * 平台同时把 data-layout 与 --gk-safe-top/right/bottom/left 写在容器 el 上,CSS 不订阅也能用。
46
+ * 安全区外可以渲染,但会被挡,不要放交互。
47
+ */
48
+ stage: Readable<StageInfo>;
42
49
  /** 由平台统一创建和解锁(iOS 要用户手势),游戏不要自己 new AudioContext */
43
50
  audio: { context(): AudioContext | null; volume(): number };
44
51
  locale: string;
@@ -81,6 +88,7 @@ export interface GameInstance<O> {
81
88
  /** 快进到结果 */
82
89
  skip(): void;
83
90
  reset(): void;
91
+ /** 舞台或安全区变了(旋转、切布局、HUD 高度变化),平台在写完 host.stage 之后调它 */
84
92
  resize(): void;
85
93
  destroy(): void;
86
94
  }
package/sdk/stage.ts CHANGED
@@ -1,20 +1,106 @@
1
1
  /**
2
2
  * 游戏的逻辑分辨率。平台的舞台形状由这一组数决定,不由任何 CSS 里的字面量决定。
3
3
  *
4
- * 游戏容器的 CSS 尺寸恒为这一组数,屏幕放不下就整体 scale——游戏里看到的宽度
5
- * 永远是 900,排版不随窗口变形,缩放的是整台机器的画面。
6
- * 这和「让游戏自报高度」是两条路:那条路上每个游戏的舞台高矮不一,
7
- * 机台的高度会跟着游戏变;这条路上机台的尺寸是确定的,游戏去适应它。
4
+ * 两块舞台,宽度都是固定的,屏幕放不下就整体 scale——游戏里看到的宽度永远是 900 或 450,
5
+ * 排版不随窗口变形,缩放的是整台机器的画面。这和「让游戏自报高度」是两条路:
6
+ * 那条路上每个游戏的舞台高矮不一,机台的高度会跟着游戏变;这条路上机台的尺寸是确定的,游戏去适应它。
8
7
  *
9
- * 900 不是随便取的:游戏的内部布局是按这个宽度调的,换一个宽度它们会重新分配
10
- * 各区块的占比——coco 放到 1280 上试过,牌面吃掉了多出来的空间,右边的发牌区
11
- * 相对就瘪了。缩放不会有这个问题,因为它整块等比放大。
12
- *
13
- * 7:6 是量出来的:coco 在 900 逻辑宽下内容自然高 770,这里给 771。
8
+ * 桌面 900×771(7:6):900 不是随便取的,游戏的内部布局是按这个宽度调的,换一个宽度它们会重新分配
9
+ * 各区块的占比——coco 放到 1280 上试过,牌面吃掉了多出来的空间。771 来自 coco 在 900 宽下的自然高 770,
14
10
  * 比这更扁会把最后一行牌切掉,而且很不显眼——换比例之前先量一遍各游戏的自然高度。
15
11
  *
16
- * 放在 sdk 里是因为它是平台↔游戏契约的一部分:玩家站点的 iframe、参考平台的
17
- * 舞台、studio 的预览都按同一组数缩放,游戏只需要为这一个尺寸排版。
12
+ * 手机 450×[800..1000]:竖屏全屏,宽固定 450(真机 CSS 宽 375–430,倍率接近 1,字号读起来是自然的),
13
+ * 高按屏幕比例取,只允许五档(步长 50)。不量化的话 URL 栏收放时 100dvh 连续变化,游戏会每帧重排;
14
+ * 量化后余量落到上下留边,最多十几个屏幕像素,肉眼看不出。区间外的屏幕(更扁或更长)同样留边。
15
+ * fit 必须是二维的:iOS Safari 带工具栏时 390×664 比 800/450 更扁,只按宽算倍率画布会溢出屏幕。
16
+ *
17
+ * 手机上平台的操作 UI 叠在画布上下两端,被盖住的那部分由 StageInfo.safe 告诉游戏——
18
+ * 那里可以渲染,但信息会被遮挡,不要放交互。iframe 里 env(safe-area-inset-*) 恒为 0,安全区只能由平台下发。
19
+ *
20
+ * 放在 sdk 里是因为它是平台↔游戏契约的一部分:玩家站点的 iframe、参考平台的舞台、studio 的预览
21
+ * 都按同一组数、同一个 fitStage 缩放,游戏只需要为这两个宽度排版。
18
22
  */
19
23
  export const STAGE_W = 900;
20
24
  export const STAGE_H = Math.round((STAGE_W * 6) / 7); // 771
25
+
26
+ export const MOBILE_W = 450;
27
+ export const MOBILE_H_MIN = 800;
28
+ export const MOBILE_H_MAX = 1000;
29
+ export const MOBILE_H_STEP = 50;
30
+
31
+ export type StageLayout = 'desktop' | 'mobile';
32
+
33
+ /** 被平台 UI 盖住的边,逻辑像素。手机上有值,桌面恒为 0 */
34
+ export interface SafeInsets {
35
+ top: number;
36
+ right: number;
37
+ bottom: number;
38
+ left: number;
39
+ }
40
+
41
+ /** 游戏当下所在的舞台。宽高是逻辑像素(游戏容器的 CSS 尺寸),不是屏幕像素 */
42
+ export interface StageInfo {
43
+ layout: StageLayout;
44
+ width: number;
45
+ height: number;
46
+ safe: SafeInsets;
47
+ }
48
+
49
+ export const NO_INSETS: SafeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
50
+ export const DESKTOP_STAGE: StageInfo = { layout: 'desktop', width: STAGE_W, height: STAGE_H, safe: NO_INSETS };
51
+
52
+ /** 手机舞台的逻辑高:按盒子比例算,夹进区间,再量化到步长 */
53
+ export function mobileStageHeight(boxW: number, boxH: number): number {
54
+ if (!(boxW > 0) || !(boxH > 0)) return MOBILE_H_MIN;
55
+ const natural = (MOBILE_W * boxH) / boxW;
56
+ const clamped = Math.min(MOBILE_H_MAX, Math.max(MOBILE_H_MIN, natural));
57
+ return Math.round(clamped / MOBILE_H_STEP) * MOBILE_H_STEP;
58
+ }
59
+
60
+ export interface StageFit {
61
+ layout: StageLayout;
62
+ /** 逻辑尺寸 */
63
+ width: number;
64
+ height: number;
65
+ /** 逻辑 → 屏幕的倍率 */
66
+ scale: number;
67
+ /** 舞台在盒子里的偏移(屏幕像素)。桌面恒为 0,手机居中后的留边 */
68
+ x: number;
69
+ y: number;
70
+ }
71
+
72
+ /**
73
+ * 把舞台放进一个盒子。桌面盒子自己就是 7:6(CSS aspect-ratio),只看宽;
74
+ * 手机盒子是整块屏幕,两个方向都要看,放不下的方向决定倍率,另一方向居中留边。
75
+ */
76
+ export function fitStage(layout: StageLayout, boxW: number, boxH: number): StageFit {
77
+ if (layout === 'desktop') {
78
+ return { layout, width: STAGE_W, height: STAGE_H, scale: boxW > 0 ? boxW / STAGE_W : 1, x: 0, y: 0 };
79
+ }
80
+ const height = mobileStageHeight(boxW, boxH);
81
+ const scale = boxW > 0 && boxH > 0 ? Math.min(boxW / MOBILE_W, boxH / height) : 1;
82
+ return {
83
+ layout, width: MOBILE_W, height, scale,
84
+ x: Math.max(0, (boxW - MOBILE_W * scale) / 2),
85
+ y: Math.max(0, (boxH - height * scale) / 2),
86
+ };
87
+ }
88
+
89
+ /** 盒子四边各被盖住多少屏幕像素:平台 HUD 的高度(它的内边距已含系统安全区)、系统安全区(左右只有它) */
90
+ export interface CoverPx {
91
+ top: number;
92
+ bottom: number;
93
+ left?: number;
94
+ right?: number;
95
+ }
96
+
97
+ /**
98
+ * 被盖住的边换成舞台的逻辑像素。游戏画布的安全区 = 平台 HUD 遮挡 + 系统安全区(刘海、home 指示条):
99
+ * 上下由 HUD 的高度给出(HUD 自己 padding 了 env(safe-area-inset-*),所以已经叠加),左右直接是系统的。
100
+ * 盖住的东西贴盒子边,舞台居中,所以要先扣掉留边。
101
+ */
102
+ export function safeInsetsOf(fit: StageFit, cover: CoverPx): SafeInsets {
103
+ const y = (v: number) => Math.max(0, Math.round((v - fit.y) / fit.scale));
104
+ const x = (v: number) => Math.max(0, Math.round((v - fit.x) / fit.scale));
105
+ return { top: y(cover.top), right: x(cover.right ?? 0), bottom: y(cover.bottom), left: x(cover.left ?? 0) };
106
+ }
@@ -3,8 +3,9 @@
3
3
  机台是预览 iframe(dev-host 的 DevShell,嵌入模式),和玩家站点的对局页同一套机台、同一块舞台;
4
4
  它靠左、宽度固定,剩下的宽度全给右边的编辑面板。余额由预览页通过总线报上来,显示在这条头上。
5
5
  页面和游戏同源,所以发布面板能直接 import 游戏清单算描述配置。 */
6
- import { onMount } from 'svelte';
6
+ import { onMount, untrack } from 'svelte';
7
7
  import { ensureHostFonts } from 'gamekit777/dev-host';
8
+ import type { StageLayout } from 'gamekit777/sdk';
8
9
  import { api, type Info } from './lib/api.ts';
9
10
  import { bus } from './lib/bus.svelte.ts';
10
11
  import { identity } from './lib/identity.svelte.ts';
@@ -44,11 +45,55 @@
44
45
  const money = (minor: number) =>
45
46
  (minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
46
47
 
47
- /* 预览 iframe 的高度跟着里面的内容走,让整页自然滚动,机台下面的开发面板才露得出来。
48
- 同源,所以直接量 contentDocument;vite 整页刷新 iframe 时 load 会再触发一次,重新挂观察器 */
48
+ /* 机台布局。单一真值在预览壳里(bus.preview.layout 每秒报一次),这里的按钮只是发一条 set 命令,
49
+ 和 MCP 的 preview_set 走同一条路;本地先乐观更新,免得等下一次上报才变。
50
+ 设备预设是页面自己的事:手机外框多大、模拟多少刘海,存 localStorage(hash 已被 tab 占用)。 */
51
+ interface Device { id: string; label: string; w: number; h: number; safeTop: number; safeBottom: number }
52
+ const DEVICES: Device[] = [
53
+ { id: 'iphone-se', label: 'iPhone SE · 375×667', w: 375, h: 667, safeTop: 0, safeBottom: 0 },
54
+ { id: 'iphone-14', label: 'iPhone 14 · 390×844', w: 390, h: 844, safeTop: 47, safeBottom: 34 },
55
+ { id: 'iphone-15-pro-max', label: 'iPhone 15 Pro Max · 430×932', w: 430, h: 932, safeTop: 59, safeBottom: 34 },
56
+ { id: 'pixel-7', label: 'Pixel 7 · 412×915', w: 412, h: 915, safeTop: 36, safeBottom: 24 },
57
+ ];
58
+ const DEVICE_KEY = 'studio.device';
59
+ const LAYOUT_KEY = 'studio.layout';
60
+ const readStore = (k: string): string | null => { try { return localStorage.getItem(k); } catch { return null; } };
61
+ const writeStore = (k: string, v: string): void => { try { localStorage.setItem(k, v); } catch { /* 隐私模式 */ } };
62
+ let deviceId = $state(readStore(DEVICE_KEY) ?? 'iphone-14');
63
+ const device = $derived(DEVICES.find((d) => d.id === deviceId) ?? DEVICES[1]!);
64
+ let layout = $state<StageLayout>(readStore(LAYOUT_KEY) === 'mobile' ? 'mobile' : 'desktop');
65
+ const mobile = $derived(layout === 'mobile');
66
+ const srcFor = (l: StageLayout, d: Device) => `/preview?layout=${l}&safe=${d.safeTop},${d.safeBottom}`;
67
+ // svelte-ignore state_referenced_locally
68
+ const previewSrc = srcFor(layout, device);
69
+ /* 模拟刘海是预览壳的一次性参数,换设备就得重载 iframe;切布局不重载(untrack),走 set 命令就地切 */
70
+ $effect(() => {
71
+ const d = device;
72
+ const f = frameEl;
73
+ if (!f) return;
74
+ const want = srcFor(untrack(() => layout), d);
75
+ if (f.getAttribute('src') !== want) f.setAttribute('src', want);
76
+ });
77
+ /* 只在上报的值变了才同步:点按钮后本地已经是新值,而总线里还是上一秒的旧值,
78
+ 照旧值回写会把按钮翻回去、把桌面用的 iframe 高度观察器挂回来,和 100dvh 的手机机台互相喂高度 */
79
+ let reported: StageLayout | null = null;
80
+ $effect(() => {
81
+ const l = bus.preview?.layout ?? null;
82
+ if (l && l !== reported) { reported = l; layout = l; }
83
+ });
84
+ $effect(() => { writeStore(LAYOUT_KEY, layout); writeStore(DEVICE_KEY, deviceId); });
85
+ async function setLayout(l: StageLayout): Promise<void> {
86
+ if (l === layout) return;
87
+ layout = l;
88
+ try { await api.previewCommand({ t: 'set', layout: l }); } catch (e) { err = e instanceof Error ? e.message : String(e); }
89
+ }
90
+
91
+ /* 桌面:预览 iframe 的高度跟着里面的内容走,让整页自然滚动,机台下面的开发面板才露得出来。
92
+ 同源,所以直接量 contentDocument;vite 整页刷新 iframe 时 load 会再触发一次,重新挂观察器。
93
+ 手机:外框就是那台手机的屏幕,高度由预设定,里面自己滚 */
49
94
  let ro: ResizeObserver | null = null;
50
- function fitFrame(e: Event): void {
51
- const f = e.currentTarget as HTMLIFrameElement;
95
+ let frameEl = $state<HTMLIFrameElement>();
96
+ function attachFit(f: HTMLIFrameElement): void {
52
97
  const doc = f.contentDocument;
53
98
  if (!doc) return;
54
99
  ro?.disconnect();
@@ -57,6 +102,16 @@
57
102
  ro.observe(doc.documentElement);
58
103
  apply();
59
104
  }
105
+ function detachFit(f: HTMLIFrameElement): void { ro?.disconnect(); ro = null; f.style.height = ''; }
106
+ $effect(() => {
107
+ const f = frameEl;
108
+ if (!f) return;
109
+ if (mobile) detachFit(f); else attachFit(f);
110
+ });
111
+ function onFrameLoad(e: Event): void {
112
+ const f = e.currentTarget as HTMLIFrameElement;
113
+ if (!mobile) attachFit(f);
114
+ }
60
115
  </script>
61
116
 
62
117
  <header>
@@ -83,9 +138,27 @@
83
138
  </header>
84
139
 
85
140
  <main>
86
- <!-- 左:机台。和玩家站点同一台机器,宽度也照那边的 1200 内容宽 + 两侧 16 留白 -->
87
- <section class="machine">
88
- <iframe title="游戏预览" src="/preview" onload={fitFrame}></iframe>
141
+ <!-- 左:机台。和玩家站点同一台机器,宽度也照那边的 1200 内容宽 + 两侧 16 留白;
142
+ 手机布局下画成一台手机的外框,尺寸按预设 -->
143
+ <section class="machine" class:mobile style:--dw="{device.w}px" style:--dh="{device.h}px">
144
+ <div class="layoutbar">
145
+ <div class="seg" role="tablist">
146
+ <button role="tab" aria-selected={!mobile} onclick={() => void setLayout('desktop')}>桌面</button>
147
+ <button role="tab" aria-selected={mobile} onclick={() => void setLayout('mobile')}>手机</button>
148
+ </div>
149
+ {#if mobile}
150
+ <select aria-label="设备" bind:value={deviceId}>
151
+ {#each DEVICES as d (d.id)}<option value={d.id}>{d.label}</option>{/each}
152
+ </select>
153
+ <span class="hint">刘海 {device.safeTop} · 底部 {device.safeBottom}(模拟,iframe 里 env() 拿不到)</span>
154
+ {/if}
155
+ {#if bus.preview?.stage && mobile}
156
+ <span class="hint">舞台 {bus.preview.stage.width}×{bus.preview.stage.height} · 安全区 上 {bus.preview.stage.safe.top} 下 {bus.preview.stage.safe.bottom}</span>
157
+ {/if}
158
+ </div>
159
+ <div class="phone">
160
+ <iframe title="游戏预览" src={previewSrc} bind:this={frameEl} onload={onFrameLoad}></iframe>
161
+ </div>
89
162
  </section>
90
163
 
91
164
  <!-- 右:编辑区,拉满剩余宽度 -->
@@ -129,13 +202,22 @@
129
202
  iframe 的高度由 fitFrame 跟着内容走,整页滚动;编辑区粘在顶上,自己滚 */
130
203
  /* 不能靠 flex-wrap 自动换行:换行看的是 flex-basis 之和,不看 min-width,1440 宽下会先把编辑区挤下去而不是缩机台 */
131
204
  main{display:flex;gap:16px;align-items:flex-start;padding:14px 16px 32px}
132
- .machine{flex:0 1 1232px;min-width:880px;max-width:100%}
205
+ .machine{flex:0 1 1232px;min-width:880px;max-width:100%;display:flex;flex-direction:column;gap:10px}
133
206
  .machine iframe{display:block;width:100%;min-height:640px;border:0;background:transparent}
207
+ .layoutbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
208
+ .layoutbar .seg{display:flex;gap:2px;padding:3px;background:var(--field);border:1px solid var(--line);border-radius:12px}
209
+ .layoutbar .seg button{border:0;background:none;border-radius:9px;padding:5px 12px;font-weight:700;color:var(--dim);font-size:13px}
210
+ .layoutbar .seg button[aria-selected="true"]{background:var(--ink);color:var(--cab)}
211
+ .layoutbar select{font-family:var(--txt);font-size:13px}
212
+ /* 手机外框:一台竖着的手机,屏幕尺寸按预设,iframe 在里面自己滚(机台 100dvh + 下面的开发面板) */
213
+ .machine.mobile{flex:0 0 auto;min-width:0;width:auto}
214
+ .machine.mobile .phone{width:calc(var(--dw) + 24px);padding:12px;border-radius:44px;background:#14090d;box-shadow:0 12px 0 #090305,inset 0 0 0 2px #2a1a20}
215
+ .machine.mobile iframe{width:var(--dw);height:var(--dh);min-height:0;border-radius:32px;background:var(--stage)}
134
216
  .editor{flex:1 1 420px;min-width:420px;position:sticky;top:72px;max-height:calc(100vh - 88px);overflow-y:auto;
135
217
  scrollbar-width:thin;scrollbar-color:var(--line) transparent;padding-bottom:12px}
136
218
  @media (max-width:1347px){
137
219
  main{flex-wrap:wrap}
138
- .machine{flex-basis:100%;min-width:0}
220
+ .machine:not(.mobile){flex-basis:100%;min-width:0}
139
221
  .editor{position:static;max-height:none;overflow:visible}
140
222
  }
141
223
 
@@ -10,6 +10,10 @@
10
10
  <h2>预览状态</h2>
11
11
  {#if p?.connected}
12
12
  <p class="hint">表 <span class="mono">{p.mode}</span> · 注额 {p.bet !== null ? (p.bet / 100).toFixed(2) : '—'}</p>
13
+ {#if p.stage}
14
+ <p class="hint">布局 <span class="mono">{p.layout}</span> · 舞台 {p.stage.width}×{p.stage.height}
15
+ · 安全区 上 {p.stage.safe.top} 下 {p.stage.safe.bottom}{#if p.stage.safe.left || p.stage.safe.right} 左 {p.stage.safe.left} 右 {p.stage.safe.right}{/if}</p>
16
+ {/if}
13
17
  {#if p.lastError}<p class="bad">{p.lastError}</p>{/if}
14
18
  {#if p.consoleErrors?.length}
15
19
  <details><summary class="warn">{p.consoleErrors.length} 条控制台报错</summary><pre>{p.consoleErrors.join('\n')}</pre></details>
@@ -3,7 +3,7 @@
3
3
  * 壳就是 dev-host 的 DevShell——和 `bun run dev` 看到的是同一个参考平台,同一条本地表模型:
4
4
  * 浏览器里就地建表、按权重抽行、restore 还原。studio 多接的只有三样:
5
5
  * 建好的正式产物(有就优先用)、存为 case、以及 node 侧的总线(MCP 的 preview_* 工具从那儿进来)。 */
6
- import { runDevShell, parseTable, localServer, type LoadedTable, type PlatformOf } from 'gamekit777/dev-host';
6
+ import { runDevShell, parseTable, localServer, layoutFromSearch, safeFromSearch, type LoadedTable, type PlatformOf } from 'gamekit777/dev-host';
7
7
  import type { GameModule } from 'gamekit777/sdk';
8
8
  import game from 'virtual:studio/game';
9
9
  import { api, connectBridge } from './bridge.ts';
@@ -23,6 +23,10 @@ let p: PlatformOf<object, unknown> | null = null;
23
23
  runDevShell(g, {
24
24
  // 嵌入模式:页眉和余额由 studio 页面画,这里只剩机台和机台下面的开发面板
25
25
  embedded: true,
26
+ /* 布局默认桌面,不按窗口判——iframe 有多宽由 studio 页面决定。首载从 ?layout= 读,之后由 set 命令就地切;
27
+ ?safe= 是设备预设模拟的刘海,iframe 里 env() 拿不到 */
28
+ layout: layoutFromSearch(location.search) ?? 'desktop',
29
+ simSafe: safeFromSearch(location.search) ?? undefined,
26
30
  server: localServer({ spec: g, external }),
27
31
  onPlatform: (plat) => { p = plat; },
28
32
  onSaveCase: async (h, title) => {
@@ -39,6 +43,7 @@ const snapshot = (): Record<string, unknown> => {
39
43
  return {
40
44
  config: plain(p.config), mode: p.tableMode, bet: p.bet, balance: p.balance, pnl: p.pnl,
41
45
  status: plain(p.game), lastError: p.err, tableModes: plain(p.tableModes),
46
+ layout: p.layout, stage: plain(p.stage),
42
47
  recent: p.history.slice(0, 10).map((h) => ({ n: h.n, mode: h.mode, multiplier: h.multiplier, payout: h.payout, simId: h.simId, ok: h.checks.every((c) => c.ok) })),
43
48
  };
44
49
  };
@@ -60,6 +65,10 @@ const bridge = connectBridge({
60
65
  if (cmd.clientSeed !== undefined) { plat.clientSeed = String(cmd.clientSeed); applied.push('clientSeed'); }
61
66
  if (cmd.skin !== undefined) { plat.setView({ skin: String(cmd.skin) }); applied.push('skin'); }
62
67
  if (cmd.turbo !== undefined) { plat.setView({ turbo: !!cmd.turbo }); applied.push('turbo'); }
68
+ if (cmd.layout !== undefined) {
69
+ if (cmd.layout === 'desktop' || cmd.layout === 'mobile') { plat.setLayout(cmd.layout); applied.push('layout'); }
70
+ else rejected.push({ key: 'layout', reason: '只能是 desktop / mobile' });
71
+ }
63
72
  return { applied, rejected, state: snapshot() };
64
73
  }
65
74
  case 'play': {