gamekit777 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/create-game/scaffold.ts +72 -15
- package/create-game/template/base/game.meta.json +9 -0
- package/create-game/template/{game.ts.tmpl → base/game.ts} +15 -11
- package/create-game/template/{package.json.tmpl → base/package.json} +7 -3
- package/create-game/template/{src/components/App.svelte.tmpl → base/src/components/App.svelte} +8 -3
- package/create-game/template/{src/context.svelte.ts.tmpl → base/src/context.svelte.ts} +5 -5
- package/create-game/template/{src → base/src}/rules/const.ts +2 -1
- package/create-game/template/{src/rules/restore.ts.tmpl → base/src/rules/restore.ts} +7 -5
- package/create-game/template/{src/rules/schema.ts.tmpl → base/src/rules/schema.ts} +2 -2
- package/create-game/template/{src/rules/simulate.ts.tmpl → base/src/rules/simulate.ts} +2 -2
- package/create-game/template/{src/rules/types.ts.tmpl → base/src/rules/types.ts} +2 -2
- package/create-game/template/{src/state/game.svelte.ts.tmpl → base/src/state/game.svelte.ts} +14 -7
- package/create-game/template/{src/view/mount.svelte.ts.tmpl → base/src/view/mount.svelte.ts} +6 -4
- package/create-game/template/{src/view/present.ts.tmpl → base/src/view/present.ts} +2 -2
- package/create-game/template/{test/e2e.test.ts.tmpl → base/test/e2e.test.ts} +4 -1
- package/create-game/template/base/tsconfig.json +8 -0
- package/create-game/template/{CLAUDE.md.tmpl → overlay/CLAUDE.md.tmpl} +13 -3
- package/dev-host/DevShell.svelte +240 -75
- package/dev-host/index.ts +1 -0
- package/dev-host/layout.ts +80 -0
- package/dev-host/platform.svelte.ts +23 -4
- package/dev-host/run.ts +8 -1
- package/dev-host/theme.css +142 -11
- package/package.json +1 -1
- package/runtime/index.ts +1 -0
- package/runtime/meter.ts +72 -0
- package/runtime/registry.ts +5 -1
- package/sdk/host.ts +8 -0
- package/sdk/stage.ts +97 -11
- package/studio/app/App.svelte +92 -10
- package/studio/app/panels/CasePanel.svelte +1 -1
- package/studio/app/panels/PreviewPanel.svelte +4 -0
- package/studio/bin.ts +8 -0
- package/studio/cli.ts +20 -7
- package/studio/preview/entry.ts +10 -1
- package/studio/src/engine.ts +5 -1
- package/studio/src/mcp.ts +11 -6
- package/studio/src/scaffold.ts +74 -31
- package/studio/src/studio-plugin.ts +29 -13
- package/studio/src/version.ts +62 -0
- package/create-game/template/game.meta.json.tmpl +0 -8
- package/create-game/template/tsconfig.json +0 -22
- /package/create-game/template/{scripts/verify.ts.tmpl → base/scripts/verify.ts} +0 -0
- /package/create-game/template/{src → base/src}/assets/critical/.gitkeep +0 -0
- /package/create-game/template/{src → base/src}/assets/lazy/.gitkeep +0 -0
- /package/create-game/template/{src/index.ts.tmpl → base/src/index.ts} +0 -0
- /package/create-game/template/{src → base/src}/styles/animations.css +0 -0
- /package/create-game/template/{src → base/src}/styles/global.css +0 -0
- /package/create-game/template/{src → base/src}/view/bridge.svelte.ts +0 -0
- /package/create-game/template/{vite.config.ts → base/vite.config.ts} +0 -0
- /package/create-game/template/{vitest.config.ts → base/vitest.config.ts} +0 -0
- /package/create-game/template/{_gitignore → overlay/_gitignore} +0 -0
package/dev-host/DevShell.svelte
CHANGED
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
默认服务端是本地表模型:浏览器里就地建表、按权重抽行、restore 还原——
|
|
7
7
|
和真平台同一条路,restore 契约违反在这里就会被拦下。
|
|
8
8
|
|
|
9
|
-
机台的样子和玩家站点的对局页是同一份:同一套类名(.cab / .panel-col / .stage / .bar
|
|
10
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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,75 +253,111 @@
|
|
|
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
|
-
|
|
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
|
-
<!--
|
|
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
|
|
216
|
-
<div class="loading">
|
|
217
|
-
<p
|
|
218
|
-
|
|
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
|
-
|
|
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
|
+
<!-- 机台下面:开发者要看的东西。玩家站点这里放的是最近对局和这局用哪张表。
|
|
359
|
+
嵌进 studio 时不渲染:预览只留机台,历史 / case / 表都在 studio 页面右侧和 MCP 里 -->
|
|
360
|
+
{#if !embedded}
|
|
251
361
|
<section class="panel dev">
|
|
252
362
|
<div class="tabs" role="tablist">
|
|
253
363
|
<button role="tab" aria-selected={tab === 'history'} onclick={() => tab = 'history'}>历史 {p.history.length}</button>
|
|
@@ -326,8 +436,58 @@
|
|
|
326
436
|
<p class="hint">{p.spec.slug} · 目标 RTP {(p.spec.rtp * 100).toFixed(2)}%</p>
|
|
327
437
|
{/if}
|
|
328
438
|
</section>
|
|
439
|
+
{/if}
|
|
329
440
|
</div>
|
|
330
441
|
|
|
442
|
+
{#snippet cta()}
|
|
443
|
+
{#if p.mode === 'auto'}
|
|
444
|
+
<button class="cta cash" type="button" disabled={!p.ready0 || !p.spec.autoplay}
|
|
445
|
+
onclick={() => p.autoActive ? p.stopAuto() : p.startAuto()}>
|
|
446
|
+
{#if p.autoActive}停止<span class="n">({p.autoLeft})</span>{:else if p.spec.autoplay}开始自动{:else}这个游戏不支持自动{/if}
|
|
447
|
+
</button>
|
|
448
|
+
{:else if p.game.busy}
|
|
449
|
+
<button class="cta" type="button" disabled={!p.game.canSkip} onclick={() => inst?.skip()}>
|
|
450
|
+
{p.game.canSkip ? '直接看结果' : '进行中'}
|
|
451
|
+
</button>
|
|
452
|
+
{:else}
|
|
453
|
+
<button class="cta" type="button" disabled={!p.canBet} onclick={() => p.playOne()}>
|
|
454
|
+
下注 <span class="n">{money(p.bet)}</span>
|
|
455
|
+
</button>
|
|
456
|
+
{/if}
|
|
457
|
+
{/snippet}
|
|
458
|
+
|
|
459
|
+
{#snippet params()}
|
|
460
|
+
<SchemaFields />
|
|
461
|
+
{#each p.issues as i (i.field)}
|
|
462
|
+
<p class="bad small">{Object.values(i.message)[0]}</p>
|
|
463
|
+
{/each}
|
|
464
|
+
{#if p.tableMode}
|
|
465
|
+
<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>
|
|
466
|
+
{/if}
|
|
467
|
+
{/snippet}
|
|
468
|
+
|
|
469
|
+
{#snippet toggles()}
|
|
470
|
+
<button type="button" aria-pressed={p.view.turbo}
|
|
471
|
+
onclick={() => p.setView({ turbo: !p.view.turbo })}>
|
|
472
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7z" /></svg>
|
|
473
|
+
快速模式
|
|
474
|
+
</button>
|
|
475
|
+
<button type="button" aria-pressed={p.view.sound}
|
|
476
|
+
onclick={() => p.setView({ sound: !p.view.sound })}>
|
|
477
|
+
{#if p.view.sound}
|
|
478
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9zM17 8a5 5 0 0 1 0 8" /></svg>
|
|
479
|
+
{:else}
|
|
480
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9zM17 10l4 4M21 10l-4 4" /></svg>
|
|
481
|
+
{/if}
|
|
482
|
+
音效
|
|
483
|
+
</button>
|
|
484
|
+
<label class="seed">客户端种子
|
|
485
|
+
<input bind:value={p.clientSeed} disabled={p.autoActive} /></label>
|
|
486
|
+
{#if p.server.refill}
|
|
487
|
+
<button type="button" onclick={() => p.refill()}>重置余额</button>
|
|
488
|
+
{/if}
|
|
489
|
+
{/snippet}
|
|
490
|
+
|
|
331
491
|
<style>
|
|
332
492
|
/* 只写这一页自己的布局。机台、表单、按钮的样式全在共享的 theme.css 里 */
|
|
333
493
|
.shell { width: min(1200px, calc(100% - 32px)); margin: 0 auto; padding-bottom: 48px; }
|
|
@@ -336,10 +496,16 @@
|
|
|
336
496
|
/* 嵌入时 iframe 的视口就是机台那一栏的宽度,studio 把它缩到 1000 以下很常见(1440 的屏就会),
|
|
337
497
|
但机台不该因此切成窄屏的上下堆叠——它在 studio 里始终是一台横着的机器。
|
|
338
498
|
把 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; }
|
|
499
|
+
.shell.embedded .cab:not(.mobile) { grid-template-columns: 304px minmax(0, 1fr); grid-template-areas: "panel stage" "bar bar"; }
|
|
500
|
+
.shell.embedded .cab:not(.mobile) .panel-col { min-height: 0; }
|
|
501
|
+
.shell.embedded .cab:not(.mobile) .panel-inner { position: absolute; overflow: hidden; }
|
|
502
|
+
.shell.embedded .cab:not(.mobile) .params { flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; }
|
|
503
|
+
/* 手机机台顶满整个视口:它自己就是那台手机的屏幕,开发面板在它下面 */
|
|
504
|
+
.shell.mobile { width: 100%; padding-bottom: 16px; }
|
|
505
|
+
.shell.mobile .dev { margin: 16px 12px 0; }
|
|
506
|
+
.sheet .body .tg { align-items: stretch; }
|
|
507
|
+
.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); }
|
|
508
|
+
.sheet .body .tg svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
343
509
|
/* 和玩家站点的 game.html 一样:游戏根元素撑满整块逻辑画面,由游戏自己在里面排版。
|
|
344
510
|
不撑的话根元素按内容高度长,超出 771 的那一截会被舞台裁掉,而且很不显眼 */
|
|
345
511
|
/* 写成 .stage > .gameframe 是为了压过 theme.css 里同名的 display:block */
|
|
@@ -364,6 +530,5 @@
|
|
|
364
530
|
.skins { display: grid; grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); gap: 8px; }
|
|
365
531
|
.skins button { border: 0; background: none; padding: 0; border-radius: 12px; overflow: hidden; line-height: 0; }
|
|
366
532
|
.skins img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 12px; opacity: .55; transition: opacity .15s; }
|
|
367
|
-
.skins button[aria-pressed="true"] img { opacity: 1; box-shadow: 0 0 0 2px var(--accent-ink); }
|
|
368
533
|
.skins span { display: block; font-size: 12px; padding: 8px 4px; line-height: 1.2; background: var(--field); border-radius: 12px; }
|
|
369
534
|
</style>
|
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
|
|
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
|
-
/**
|
|
99
|
-
loadProgress = $state
|
|
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
|
}
|