beyond-simulator-web 0.1.2

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.
@@ -0,0 +1,14 @@
1
+
2
+ *{box-sizing:border-box}html,body{width:100%;height:100%;margin:0;overflow:hidden}
3
+ body{--bg:#0c1422;--panel:#101a2a;--panel2:#152238;--line:#263750;--text:#edf3ff;--muted:#8f9caf;--workspace:#111c2c;--input:#0d1726;--blue:#4382f3;color:var(--text);background:var(--bg);font:13px/1.45 Inter,"Microsoft YaHei UI","Microsoft YaHei",sans-serif}
4
+ body[data-theme=light]{--bg:#eef1f5;--panel:#fff;--panel2:#f3f5f8;--line:#d5dbe4;--text:#20252c;--muted:#68717e;--workspace:#d9dee6;--input:#fff}
5
+ button{font:inherit;color:inherit;cursor:pointer}button:disabled{opacity:.42;cursor:default}.app{height:100%;display:grid;grid-template-rows:56px minmax(0,1fr) 34px;background:var(--bg)}
6
+ .bar{display:grid;grid-template-columns:minmax(250px,1fr) auto minmax(360px,1fr);align-items:center;gap:12px;padding:0 14px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,var(--panel2),var(--panel));box-shadow:0 1px 12px #0003;z-index:2}
7
+ .brand,.device,.view-tabs,.actions,.status{display:flex;align-items:center}.brand{gap:10px;min-width:0}.icon{width:30px;height:30px;display:grid;place-items:center;border:1px solid var(--line);border-radius:6px;background:var(--input);font-size:11px}.brand strong{font-size:16px}.device{gap:8px;margin-left:18px;color:var(--muted)}.device b{color:var(--text);font-weight:500}.view-tabs{height:36px;padding:3px;border:1px solid var(--line);border-radius:7px;background:var(--input)}.view-tabs button{min-width:100px;height:28px;padding:0 16px;border:0;border-radius:5px;background:transparent;color:var(--muted)}.view-tabs button.active{color:#fff;background:linear-gradient(#4c87fa,#376fd7);box-shadow:0 0 0 1px #5e90ef55}.actions{justify-content:flex-end;gap:7px}.actions button{height:34px;padding:0 12px;border:1px solid var(--line);border-radius:6px;background:var(--input)}.actions button:hover:not(:disabled){border-color:var(--blue)}.actions .stop{color:#f17884}.actions .primary{color:#fff;border-color:#3265d8;background:linear-gradient(#4c87fa,#376fd7)}.devsel,.playersel,.viewsel{height:30px;width:150px;margin-left:12px;padding:0 4px;border:1px solid var(--line);border-radius:6px;background:var(--input);color:var(--text);text-overflow:ellipsis}.playersel,.viewsel{width:108px}
8
+ .views{position:relative;min-width:0;min-height:0}.play-view{position:absolute;inset:0}.debug-view{position:absolute;inset:0;display:grid;grid-template-columns:minmax(280px,32%) minmax(420px,1fr);background:var(--bg)}.panel{min-height:0;background:color-mix(in srgb,var(--panel) 96%,transparent)}.tree-panel{display:grid;grid-template-rows:42px minmax(0,1fr) 42px minmax(160px,32%);border-right:1px solid var(--line)}.log-stack{min-height:0;display:grid;grid-template-rows:minmax(0,1fr) minmax(0,1fr) auto}.log-panel{display:grid;grid-template-rows:42px minmax(0,1fr);min-height:0}.log-panel + .log-panel{border-top:1px solid var(--line)}.case-panel{display:grid;grid-template-rows:42px auto;border-top:1px solid var(--line)}.case-body{display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;padding:10px 14px}.case-body input,.case-body textarea{width:100%;min-height:34px;padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--input);color:var(--text);font:12px/1.4 Consolas,"Microsoft YaHei UI",monospace}.case-body textarea{min-height:72px;resize:vertical}.vars{min-height:0;overflow:auto;padding:8px 10px}.var-row{display:grid;grid-template-columns:72px minmax(72px,1fr) 52px minmax(80px,1.2fr);gap:6px;align-items:center;padding:5px 4px;border-bottom:1px solid color-mix(in srgb,var(--line) 70%,transparent);font:12px/1.4 Consolas,"Microsoft YaHei UI",monospace}.var-row b{font-weight:650;color:var(--text)}.var-empty{padding:14px 8px;color:var(--muted)}.panel-head{display:flex;align-items:center;padding:0 14px;border-bottom:1px solid var(--line);font-weight:650}.panel-head small{margin-left:auto;color:var(--muted);font-weight:400}.tree{min-height:0;overflow:auto;padding:8px 7px}.log{min-height:0;overflow:auto;padding:12px 16px;white-space:pre-wrap;color:var(--muted);font:12px/1.8 Consolas,"Microsoft YaHei UI",monospace}.log:empty:before{content:"暂无日志。";color:var(--muted)}#log:empty:before{content:"暂无客户端脚本日志。Lua print / printerr 会显示在这里。"}#serverLog:empty:before{content:"暂无服务端日志。变量缺失、越界和信号投递记录在这里,不会混入脚本输出。"}
9
+ .workspace{position:absolute;inset:0;min-width:0;min-height:0;display:flex;align-items:center;justify-content:center;padding:28px;overflow:hidden;background-color:var(--workspace);background-image:radial-gradient(#ffffff0c 1px,transparent 1px),linear-gradient(135deg,#ffffff07 25%,transparent 25%,transparent 75%,#00000008 75%);background-size:4px 4px,24px 24px}.stage-shell{position:relative;filter:drop-shadow(0 14px 28px #0005)}.stage-shell.veiled{visibility:hidden}.loading{position:absolute;inset:0;z-index:3;display:flex;align-items:center;justify-content:center;gap:10px;color:var(--muted);background:var(--bg)}.loading .spin{width:14px;height:14px;border-radius:50%;border:2px solid #4382f338;border-top-color:var(--blue);animation:qxqy-spin .9s linear infinite}@keyframes qxqy-spin{to{transform:rotate(1turn)}}.stage-label{position:absolute;left:0;top:-23px;color:var(--muted);font-size:11px}.stage{position:relative;overflow:hidden;border:1px solid #4d7899;outline:1px solid #0005;background:radial-gradient(circle at 50% 45%,#123a6b,#061934 72%,#071426);touch-action:none;user-select:none}.fatal{position:absolute;inset:0;display:grid;place-items:center;padding:24px;color:#ef7784;background:color-mix(in srgb,var(--workspace) 88%,transparent)}.hidden{display:none!important}
10
+ .stage-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
11
+ .status{gap:14px;padding:0 14px;border-top:1px solid var(--line);background:var(--panel);color:var(--muted);font-size:11px}.status .live{color:#62d398}.status .error{color:#f07480}.spacer{flex:1}.metric{padding-left:14px;border-left:1px solid var(--line)}
12
+ @media(max-width:1050px){.bar{grid-template-columns:1fr auto 1fr}.device{display:none}.devsel{width:118px;margin-left:8px}.view-tabs button{min-width:78px}.workspace{padding:20px}.debug-view{grid-template-columns:260px minmax(360px,1fr)}}
13
+ @media(max-width:760px){.debug-view{grid-template-columns:1fr}.tree-panel{display:none}.brand strong{font-size:14px}.brand .devsel{width:104px;margin-left:6px}.actions button{padding:0 9px}.actions #theme{display:none}.view-tabs button{min-width:64px;padding:0 10px}}
14
+
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>千星沙箱 · 独立试玩</title>
7
+ <link rel="stylesheet" href="/editor/play.css">
8
+ </head>
9
+ <body>
10
+ <div class="app">
11
+ <header class="bar">
12
+ <div class="brand"><span class="icon">UI</span><strong>模拟器</strong><select id="deviceSel" class="devsel hidden" aria-label="切换设备画布" title="切换设备画布:以新画布重建运行时,GetUICanvasSize 与预设像素一致"></select><select id="playerCountSel" class="playersel hidden" aria-label="试玩人数" title="试玩人数:最多 8 人,每人一份独立客户端"></select><select id="playerViewSel" class="viewsel hidden" aria-label="当前玩家视角" title="切换当前玩家视角:输入、画面和 PlayerSelf 都跟随该玩家"></select><span class="device">设备 <b id="resolution">准备中…</b></span></div>
13
+ <div class="view-tabs" role="tablist" aria-label="试玩页面"><button class="active" data-view="play" role="tab">试玩</button><button data-view="log" role="tab">日志</button></div>
14
+ <div class="actions"><button id="pause">Ⅱ 暂停运行</button><button id="step" disabled title="暂停后可单帧调试">▷ 单帧调试</button><button class="stop" id="stop">■ 结束试玩</button><button class="primary hidden" id="restart">↻ 重新开始</button><button id="theme" title="切换亮暗主题">◐</button><button id="close">返回编辑</button></div>
15
+ </header>
16
+ <div class="views">
17
+ <main class="workspace play-view" id="workspace"><div class="stage-shell veiled" id="shell"><div class="stage-label" id="stageLabel"></div><div class="stage" id="stage"><canvas id="cv" class="stage-canvas"></canvas></div></div><div class="loading" id="loading"><span class="spin"></span>正在准备试玩…</div><div class="fatal hidden" id="fatal"></div></main>
18
+ <section class="debug-view hidden" id="logView">
19
+ <aside class="panel tree-panel"><div class="panel-head">运行时场景树 <small id="nodeCount">0 节点</small></div><div class="tree" id="sceneTree"></div><div class="panel-head">服务端变量 <small id="varCount">0 项</small></div><div class="vars" id="serverVars"></div></aside>
20
+ <div class="log-stack"><main class="panel log-panel"><div class="panel-head">客户端脚本日志 <small>最近 100 条</small></div><div class="log" id="log"></div></main><aside class="panel log-panel"><div class="panel-head">服务端日志 <small>变量 / 信号</small></div><div class="log" id="serverLog"></div></aside><section class="panel case-panel"><div class="panel-head">自动测用例 <small id="caseHint">先试玩,再保存回放</small></div><div class="case-body"><textarea id="caseAsserts" spellcheck="false" placeholder='断言 JSON 数组,例如 [{"kind":"log","contains":"clicked"}]'></textarea><button id="saveCase">保存用例</button><button id="runCase">回放用例</button></div></section></div>
21
+ </section>
22
+ </div>
23
+ <footer class="status"><span class="live" id="state">● 正在启动</span><span id="viewMode">页面:试玩</span><span class="spacer"></span><span class="metric" id="frame">Frame 0</span><span class="metric" id="time">0.00s</span></footer>
24
+ </div>
25
+ <script type="module" src="/editor/play.js"></script>
26
+ </body>
27
+ </html>
@@ -0,0 +1,300 @@
1
+
2
+ import { PixiPlayRenderer, createPlaySession } from '/editor/play-renderer.js'
3
+
4
+ (() => {
5
+ 'use strict'
6
+ const API = '/editor/api/play'
7
+ const sessionId = decodeURIComponent(location.hash.slice(1))
8
+ const $ = (id) => document.getElementById(id)
9
+ const stage = $('stage')
10
+ const cv = $('cv')
11
+ const renderer = new PixiPlayRenderer(cv)
12
+ const shell = $('shell')
13
+ const workspace = $('workspace')
14
+ const logView = $('logView')
15
+ let theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
16
+ let fittedCanvas = ''
17
+
18
+ function applyTheme() { document.body.dataset.theme = theme }
19
+ applyTheme()
20
+
21
+ function setView(view) {
22
+ const showLog = view === 'log'
23
+ workspace.classList.toggle('hidden', showLog)
24
+ logView.classList.toggle('hidden', !showLog)
25
+ $('viewMode').textContent = showLog ? '页面:日志' : '页面:试玩'
26
+ for (const button of document.querySelectorAll('[data-view]')) button.classList.toggle('active', button.dataset.view === view)
27
+ if (!showLog) requestAnimationFrame(() => fitStage(true))
28
+ }
29
+
30
+ async function api(action, args = {}) {
31
+ const response = await fetch(API, {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json' },
34
+ body: JSON.stringify({ sessionId, action, args }),
35
+ })
36
+ const envelope = await response.json().catch(() => null)
37
+ if (!response.ok || !envelope || envelope.ok !== true) throw new Error(envelope?.error || '试玩请求失败')
38
+ return envelope.value
39
+ }
40
+
41
+ function renderVars(rows) {
42
+ const host = $('serverVars')
43
+ host.replaceChildren()
44
+ const list = Array.isArray(rows) ? rows : []
45
+ $('varCount').textContent = list.length + ' 项'
46
+ if (!list.length) {
47
+ const empty = document.createElement('div')
48
+ empty.className = 'var-empty'
49
+ empty.textContent = '暂无自定义变量。脚本 Get/Set 或 serverSet 之后会出现在这里。'
50
+ host.appendChild(empty)
51
+ return
52
+ }
53
+ for (const row of list) {
54
+ const el = document.createElement('div')
55
+ el.className = 'var-row'
56
+ const type = row.type || '—'
57
+ const value = row.value === undefined ? 'nil' : JSON.stringify(row.value)
58
+ const entityCell = document.createElement('span')
59
+ entityCell.textContent = row.entityLabel || row.entityType || ''
60
+ const nameCell = document.createElement('b')
61
+ nameCell.textContent = row.name || ''
62
+ const typeCell = document.createElement('span')
63
+ typeCell.textContent = type
64
+ const valueCell = document.createElement('span')
65
+ valueCell.textContent = value
66
+ el.append(entityCell, nameCell, typeCell, valueCell)
67
+ host.appendChild(el)
68
+ }
69
+ }
70
+
71
+ function renderTree(snapshot) {
72
+ const host = $('sceneTree')
73
+ host.replaceChildren()
74
+ const empty = document.createElement('div')
75
+ empty.className = 'var-empty'
76
+ empty.textContent = '完整运行树请用 Agent 的 inspect 快照。舞台按左树上方盖住下方绘制。'
77
+ host.appendChild(empty)
78
+ $('nodeCount').textContent = (snapshot?.scene?.count || snapshot?.paint?.length || 0) + ' 个可见图元'
79
+ }
80
+
81
+ function fitStage(force = false) {
82
+ const snapshot = play.snapshot
83
+ if (!snapshot) return
84
+ const key = snapshot.canvasWidth + '×' + snapshot.canvasHeight
85
+ if (!force && (fittedCanvas === key || !Number.isFinite(snapshot.canvasWidth) || !Number.isFinite(snapshot.canvasHeight))) return
86
+ fittedCanvas = key
87
+ const maxW = Math.max(120, workspace.clientWidth - 48)
88
+ const maxH = Math.max(80, workspace.clientHeight - 48)
89
+ const scale = Math.min(maxW / snapshot.canvasWidth, maxH / snapshot.canvasHeight)
90
+ if (!Number.isFinite(scale) || scale <= 0) return
91
+ shell.style.width = (snapshot.canvasWidth * scale) + 'px'
92
+ shell.style.height = (snapshot.canvasHeight * scale) + 'px'
93
+ stage.style.width = '100%'
94
+ stage.style.height = '100%'
95
+ }
96
+
97
+ function revealStage() {
98
+ shell.classList.remove('veiled')
99
+ $('loading').classList.add('hidden')
100
+ }
101
+
102
+ function applyStatus(next) {
103
+ $('frame').textContent = 'Frame ' + (next.frame || 0)
104
+ $('time').textContent = Number(next.time || 0).toFixed(2) + 's'
105
+ $('pause').textContent = next.paused ? '▶ 继续运行' : 'Ⅱ 暂停运行'
106
+ $('pause').disabled = false
107
+ $('step').disabled = !next.paused
108
+ }
109
+
110
+ function syncDeviceSelect(next) {
111
+ const sel = $('deviceSel')
112
+ const presets = Array.isArray(next.canvasPresets) ? next.canvasPresets : []
113
+ if (!presets.length || !next.canvasId) {
114
+ sel.replaceChildren()
115
+ sel.classList.add('hidden')
116
+ return
117
+ }
118
+ const stale = sel.options.length !== presets.length
119
+ || [...sel.options].some((opt, i) => opt.value !== presets[i].id)
120
+ if (stale) {
121
+ sel.replaceChildren(...presets.map((preset) => {
122
+ const opt = document.createElement('option')
123
+ opt.value = preset.id
124
+ opt.textContent = preset.label || (preset.id + ' · ' + preset.width + '×' + preset.height)
125
+ return opt
126
+ }))
127
+ }
128
+ if (sel.value !== next.canvasId) sel.value = next.canvasId
129
+ sel.classList.remove('hidden')
130
+ }
131
+
132
+ function fillCountSelect(sel, count) {
133
+ const n = Math.max(1, Math.min(8, Number(count) || 1))
134
+ if (sel.options.length !== 8) {
135
+ sel.replaceChildren(...Array.from({ length: 8 }, (_, i) => {
136
+ const opt = document.createElement('option')
137
+ opt.value = String(i + 1)
138
+ opt.textContent = (i + 1) + ' 人'
139
+ return opt
140
+ }))
141
+ }
142
+ sel.value = String(n)
143
+ sel.classList.remove('hidden')
144
+ return n
145
+ }
146
+
147
+ function syncPlayerSelects(next) {
148
+ const countSel = $('playerCountSel')
149
+ const viewSel = $('playerViewSel')
150
+ const count = fillCountSelect(countSel, next.playerCount || 1)
151
+ const players = Array.isArray(next.players) && next.players.length
152
+ ? next.players
153
+ : Array.from({ length: count }, (_, i) => ({ index: i + 1, label: '玩家' + (i + 1) }))
154
+ const stale = viewSel.options.length !== players.length
155
+ || [...viewSel.options].some((opt, i) => Number(opt.value) !== players[i].index)
156
+ if (stale) {
157
+ viewSel.replaceChildren(...players.map((player) => {
158
+ const opt = document.createElement('option')
159
+ opt.value = String(player.index)
160
+ opt.textContent = player.label || ('玩家' + player.index)
161
+ return opt
162
+ }))
163
+ }
164
+ const view = Number(next.viewPlayerIndex) || 1
165
+ if (viewSel.value !== String(view)) viewSel.value = String(view)
166
+ viewSel.classList.toggle('hidden', count < 2)
167
+ }
168
+
169
+ async function renderChrome(next) {
170
+ $('fatal').classList.add('hidden')
171
+ fitStage()
172
+ syncDeviceSelect(next)
173
+ syncPlayerSelects(next)
174
+ $('resolution').textContent = next.canvasWidth + '×' + next.canvasHeight
175
+ const viewLabel = next.playerCount > 1 ? (' · 玩家' + (next.viewPlayerIndex || 1)) : ''
176
+ $('stageLabel').textContent = next.canvasWidth + '×' + next.canvasHeight + viewLabel + ' · 点击输入'
177
+ applyStatus(next)
178
+ $('stop').disabled = false
179
+ $('restart').classList.add('hidden')
180
+ $('state').textContent = next.mountError ? '⚠ 脚本挂载失败' : next.paused ? 'Ⅱ 已暂停' : '● 试玩运行中'
181
+ $('state').className = next.mountError ? 'error' : 'live'
182
+ renderTree(next)
183
+ renderVars(next.serverVars)
184
+ $('log').textContent = (next.logs || []).slice(-100).map((entry) => Number(entry.time || 0).toFixed(2).padStart(7, ' ') + ' [' + entry.level + '] ' + entry.text).join('\n')
185
+ $('serverLog').textContent = (next.serverLogs || next.server?.logs || []).slice(-100).map((entry) => Number(entry.time || 0).toFixed(2).padStart(7, ' ') + ' [' + entry.level + '] ' + entry.text).join('\n')
186
+ $('caseHint').textContent = (next.history && next.history.length ? ('已记录 ' + next.history.length + ' 步') : '先试玩,再保存回放')
187
+ if (next.mountError) setView('log')
188
+ }
189
+
190
+ function showError(error) {
191
+ $('loading').classList.add('hidden')
192
+ $('fatal').classList.remove('hidden')
193
+ $('fatal').textContent = error?.message || String(error)
194
+ $('state').className = 'error'
195
+ $('state').textContent = '⚠ 试玩错误'
196
+ }
197
+
198
+ const play = createPlaySession({
199
+ renderer,
200
+ api,
201
+ onSnapshot: renderChrome,
202
+ onStatus: applyStatus,
203
+ onError: showError,
204
+ })
205
+ play.bindInput(stage, { keysOn: window })
206
+
207
+ $('step').addEventListener('click', () => void play.act('step', { dt: 1 / 30 }))
208
+ $('pause').addEventListener('click', () => void play.act(play.snapshot?.paused ? 'resume' : 'pause'))
209
+ $('stop').addEventListener('click', async () => {
210
+ if (!play.running) return
211
+ try { await play.stop() } catch (error) { showError(error); return }
212
+ $('state').textContent = '■ 试玩已结束,可重新开始或返回编辑'
213
+ $('state').className = ''
214
+ $('pause').disabled = true
215
+ $('step').disabled = true
216
+ $('stop').disabled = true
217
+ $('restart').classList.remove('hidden')
218
+ })
219
+ $('restart').addEventListener('click', async () => {
220
+ try {
221
+ fittedCanvas = ''
222
+ const keepArgs = {}
223
+ const snapshot = play.snapshot
224
+ if (snapshot?.canvasId) keepArgs.canvasId = snapshot.canvasId
225
+ if (snapshot?.playerCount) keepArgs.playerCount = snapshot.playerCount
226
+ if (snapshot?.viewPlayerIndex) keepArgs.viewPlayerIndex = snapshot.viewPlayerIndex
227
+ await play.start(keepArgs)
228
+ fitStage(true)
229
+ } catch (error) { showError(error) }
230
+ })
231
+ $('playerCountSel').addEventListener('change', async () => {
232
+ const playerCount = Number($('playerCountSel').value) || 1
233
+ try {
234
+ shell.classList.add('veiled')
235
+ $('loading').classList.remove('hidden')
236
+ fittedCanvas = ''
237
+ const args = { playerCount, viewPlayerIndex: 1 }
238
+ if (play.snapshot?.canvasId) args.canvasId = play.snapshot.canvasId
239
+ await play.start(args)
240
+ fitStage(true)
241
+ revealStage()
242
+ } catch (error) { showError(error) }
243
+ })
244
+ $('playerViewSel').addEventListener('change', async () => {
245
+ const playerIndex = Number($('playerViewSel').value) || 1
246
+ if (!play.snapshot || !play.running) return
247
+ try { await play.view({ playerIndex }) }
248
+ catch (error) { showError(error) }
249
+ })
250
+ $('deviceSel').addEventListener('change', async () => {
251
+ const canvasId = $('deviceSel').value
252
+ if (!canvasId) return
253
+ try {
254
+ shell.classList.add('veiled')
255
+ $('loading').classList.remove('hidden')
256
+ fittedCanvas = ''
257
+ await play.device({
258
+ canvasId,
259
+ playerCount: play.snapshot?.playerCount || 1,
260
+ viewPlayerIndex: play.snapshot?.viewPlayerIndex || 1,
261
+ })
262
+ fitStage(true)
263
+ revealStage()
264
+ } catch (error) { showError(error) }
265
+ })
266
+ $('close').addEventListener('click', async () => { if (play.running) await play.stop().catch(() => {}); window.close() })
267
+ $('theme').addEventListener('click', () => { theme = theme === 'dark' ? 'light' : 'dark'; applyTheme() })
268
+ let lastCase = null
269
+ function parseAsserts() {
270
+ const text = $('caseAsserts').value.trim()
271
+ if (!text) return []
272
+ const parsed = JSON.parse(text)
273
+ if (!Array.isArray(parsed)) throw new Error('断言必须是 JSON 数组')
274
+ return parsed
275
+ }
276
+ $('saveCase').addEventListener('click', async () => {
277
+ try {
278
+ lastCase = await api('saveCase', { name: 'play-case', asserts: parseAsserts() })
279
+ $('caseHint').textContent = '已保存 ' + (lastCase.events || []).length + ' 步 / ' + (lastCase.asserts || []).length + ' 条断言'
280
+ } catch (error) { showError(error) }
281
+ })
282
+ $('runCase').addEventListener('click', async () => {
283
+ try {
284
+ if (!lastCase) lastCase = await api('saveCase', { name: 'play-case', asserts: parseAsserts() })
285
+ const report = await api('runCase', { case: lastCase })
286
+ $('caseHint').textContent = report.passed ? '回放通过' : ('回放失败 @' + (report.failedAt ?? '?') + 's')
287
+ if (!report.passed) setView('log')
288
+ } catch (error) { showError(error) }
289
+ })
290
+ for (const button of document.querySelectorAll('[data-view]')) button.addEventListener('click', () => setView(button.dataset.view))
291
+ addEventListener('resize', () => fitStage(true))
292
+ addEventListener('pagehide', () => {
293
+ play.destroy()
294
+ fetch(API, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, action: 'stop', args: {} }), keepalive: true }).catch(() => {})
295
+ })
296
+
297
+ if (!sessionId) showError(new Error('缺少会话 ID'))
298
+ else play.start().then(() => setTimeout(revealStage, 0)).catch(showError)
299
+ })()
300
+
@@ -0,0 +1,291 @@
1
+ :root {
2
+ --blue: #477cf0;
3
+ --danger: #e75a6a;
4
+ font-family: Inter, "Microsoft YaHei UI", "Microsoft YaHei", system-ui, sans-serif;
5
+ font-size: 13px;
6
+ color-scheme: dark;
7
+ }
8
+
9
+ :root[data-theme="dark"] {
10
+ --bg: #20242e;
11
+ --panel: #181d27;
12
+ --panel-2: #202632;
13
+ --input: #151b25;
14
+ --line: #363e4d;
15
+ --line-soft: #2b3240;
16
+ --text: #edf1f7;
17
+ --muted: #929baa;
18
+ --quiet: #697484;
19
+ --hover: #252c37;
20
+ --workspace: #383d47;
21
+ --toolbar-a: #242a35;
22
+ --toolbar-b: #1e232d;
23
+ --status: #242a33;
24
+ --button: #1d2430;
25
+ --shadow: #0004;
26
+ color-scheme: dark;
27
+ }
28
+
29
+ :root[data-theme="light"] {
30
+ --bg: #f5f6f8;
31
+ --panel: #fff;
32
+ --panel-2: #f4f6f8;
33
+ --input: #fff;
34
+ --line: #d6dbe3;
35
+ --line-soft: #e7e9ed;
36
+ --text: #20242a;
37
+ --muted: #69717d;
38
+ --quiet: #8c939d;
39
+ --hover: #eef1f5;
40
+ --workspace: #d9dde3;
41
+ --toolbar-a: #fff;
42
+ --toolbar-b: #f3f5f8;
43
+ --status: #f7f8fa;
44
+ --button: #f7f8fa;
45
+ --shadow: #18203318;
46
+ color-scheme: light;
47
+ }
48
+
49
+ * { box-sizing: border-box; }
50
+
51
+ html, body { width: 100%; min-width: 320px; min-height: 100%; }
52
+
53
+ body {
54
+ margin: 0;
55
+ overflow: hidden;
56
+ background: var(--bg);
57
+ color: var(--text);
58
+ }
59
+
60
+ button, select { font: inherit; color: inherit; }
61
+
62
+ button {
63
+ height: 32px;
64
+ padding: 0 10px;
65
+ border: 1px solid var(--line);
66
+ border-radius: 6px;
67
+ background: var(--button);
68
+ cursor: pointer;
69
+ }
70
+
71
+ button:hover:not(:disabled) { border-color: #6689d8; background: var(--hover); }
72
+ button:active:not(:disabled) { transform: translateY(1px); }
73
+ button:disabled { cursor: default; opacity: .42; }
74
+ button.primary { border-color: #4779e6; color: #fff; background: linear-gradient(#5388fa, #376ad8); font-weight: 650; }
75
+ button.primary:hover:not(:disabled) { background: linear-gradient(#6092ff, #4073df); }
76
+ button.danger { border-color: color-mix(in srgb, var(--danger) 65%, var(--line)); color: var(--danger); }
77
+
78
+ select {
79
+ height: 32px;
80
+ min-width: 0;
81
+ padding: 0 28px 0 9px;
82
+ border: 1px solid var(--line);
83
+ border-radius: 6px;
84
+ outline: 0;
85
+ background: var(--input);
86
+ }
87
+
88
+ button:focus-visible, select:focus-visible, .stage-shell:focus-visible, .tree-row:focus-visible {
89
+ outline: 2px solid color-mix(in srgb, var(--blue) 65%, transparent);
90
+ outline-offset: 1px;
91
+ }
92
+
93
+ .topbar {
94
+ height: 50px;
95
+ display: grid;
96
+ grid-template-columns: minmax(190px, 1fr) minmax(300px, 1.6fr) minmax(190px, 1fr);
97
+ align-items: center;
98
+ gap: 14px;
99
+ padding: 0 10px;
100
+ border-bottom: 1px solid var(--line);
101
+ background: linear-gradient(var(--toolbar-a), var(--toolbar-b));
102
+ box-shadow: 0 1px 8px var(--shadow);
103
+ }
104
+
105
+ .brand { min-width: 0; display: flex; align-items: center; gap: 9px; }
106
+ .brand-mark {
107
+ width: 26px;
108
+ height: 26px;
109
+ display: grid;
110
+ place-items: center;
111
+ flex: 0 0 26px;
112
+ border: 1px solid #5688f4;
113
+ border-radius: 5px;
114
+ background: linear-gradient(145deg, #5388fa, #3565c6);
115
+ color: #fff;
116
+ box-shadow: inset 0 1px #ffffff30;
117
+ font-size: 9px;
118
+ font-weight: 800;
119
+ letter-spacing: -.02em;
120
+ }
121
+ .brand > div { min-width: 0; display: flex; align-items: baseline; gap: 8px; }
122
+ .brand strong { font-size: 13px; font-weight: 700; white-space: nowrap; }
123
+ .brand span:last-child { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
124
+
125
+ .project-picker { min-width: 0; display: flex; justify-content: center; align-items: center; gap: 7px; }
126
+ .project-picker label { color: var(--muted); font-size: 10px; white-space: nowrap; }
127
+ .project-picker select { width: min(430px, 100%); }
128
+ .icon-button { width: 32px; min-width: 32px; padding: 0; font-size: 16px; }
129
+ .topbar-status { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 9px; }
130
+ .theme-button { font-size: 15px; }
131
+
132
+ .connection { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
133
+ .connection span { width: 7px; height: 7px; display: inline-block; margin-right: 6px; border-radius: 50%; background: #dea943; box-shadow: 0 0 0 3px color-mix(in srgb, #dea943 12%, transparent); }
134
+ .connection.online span { background: #62d398; box-shadow: 0 0 0 3px color-mix(in srgb, #62d398 12%, transparent); }
135
+ .connection.error { color: var(--danger); }
136
+ .connection.error span { background: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 12%, transparent); }
137
+
138
+ .app-shell {
139
+ height: calc(100vh - 50px);
140
+ display: grid;
141
+ grid-template-columns: clamp(220px, 17vw, 280px) minmax(360px, 1fr) clamp(310px, 22vw, 356px);
142
+ overflow: hidden;
143
+ }
144
+
145
+ .panel { background: var(--panel); }
146
+ .tree-panel, .info-panel { min-height: 0; overflow: hidden; }
147
+ .tree-panel { border-right: 1px solid var(--line); }
148
+ .info-panel { border-left: 1px solid var(--line); overflow-y: auto; }
149
+
150
+ .panel-heading {
151
+ height: 44px;
152
+ display: flex;
153
+ align-items: center;
154
+ justify-content: space-between;
155
+ gap: 8px;
156
+ padding: 6px 10px;
157
+ border-bottom: 1px solid var(--line-soft);
158
+ }
159
+ .panel-heading > div { display: flex; align-items: baseline; gap: 7px; }
160
+ .panel-heading h2 { margin: 0; font-size: 13px; font-weight: 650; }
161
+ .eyebrow { color: var(--muted); font-size: 9px; letter-spacing: .1em; }
162
+ .count { min-width: 24px; padding: 2px 6px; border: 1px solid var(--line); border-radius: 9px; background: var(--input); color: var(--muted); text-align: center; font-size: 9px; }
163
+
164
+ .tree { height: calc(100% - 44px); overflow: auto; padding: 3px 6px 8px; scrollbar-color: color-mix(in srgb, var(--muted) 55%, transparent) transparent; }
165
+ .tree-row {
166
+ position: relative;
167
+ width: 100%;
168
+ min-height: 32px;
169
+ display: grid;
170
+ grid-template-columns: 19px minmax(0, 1fr) auto;
171
+ align-items: center;
172
+ gap: 6px;
173
+ padding-right: 7px;
174
+ border: 1px solid transparent;
175
+ border-radius: 4px;
176
+ background: transparent;
177
+ color: var(--text);
178
+ text-align: left;
179
+ }
180
+ .tree-row:hover { background: var(--hover); }
181
+ .tree-row.active { border-color: #4d7ee4; background: linear-gradient(90deg, #3569cf, #315a9f); color: #fff; }
182
+ .tree-icon { width: 19px; height: 19px; display: grid; place-items: center; border: 1px solid currentColor; border-radius: 3px; color: #77a5ff; font-size: 9px; }
183
+ .tree-row.active .tree-icon { color: #fff; }
184
+ .tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
185
+ .tree-kind { color: var(--muted); font-size: 9px; }
186
+ .tree-row.active .tree-kind { color: #dce7ff; }
187
+ .tree-empty { padding: 22px 10px; color: var(--muted); text-align: center; font-size: 11px; line-height: 1.6; }
188
+
189
+ .preview-panel { min-width: 0; min-height: 0; display: grid; grid-template-rows: 44px minmax(0, 1fr); overflow: hidden; background: var(--workspace); }
190
+ .preview-toolbar {
191
+ min-width: 0;
192
+ display: flex;
193
+ align-items: center;
194
+ gap: 9px;
195
+ padding: 5px 8px;
196
+ border-bottom: 1px solid var(--line);
197
+ background: linear-gradient(var(--toolbar-a), var(--toolbar-b));
198
+ overflow-x: auto;
199
+ }
200
+ .segmented { display: flex; flex: 0 0 auto; gap: 2px; padding: 2px; border: 1px solid var(--line); border-radius: 9px; background: var(--input); }
201
+ .segmented button { height: 26px; padding: 0 10px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); font-size: 11px; font-weight: 600; }
202
+ .segmented button:hover:not(:disabled) { border: 0; color: var(--text); background: var(--hover); }
203
+ .segmented button.active { color: #fff; background: linear-gradient(#4f86ff, #3c6ed8); box-shadow: 0 1px 5px #1d4b9f44; }
204
+ .select-field { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; white-space: nowrap; }
205
+ .select-field select { height: 30px; font-size: 10px; }
206
+ .play-controls { margin-left: auto; display: none; align-items: center; gap: 4px; }
207
+ .play-controls.visible { display: flex; }
208
+ .play-controls button { height: 30px; padding: 0 8px; font-size: 10px; white-space: nowrap; }
209
+
210
+ .workspace { min-height: 0; display: grid; grid-template-rows: 48px minmax(0, 1fr) 30px; overflow: hidden; background: var(--workspace); }
211
+ .stage-meta { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 0 12px; border-bottom: 1px solid color-mix(in srgb, var(--line) 82%, transparent); background: color-mix(in srgb, var(--workspace) 86%, var(--panel)); }
212
+ .stage-meta > div:first-child { min-width: 0; display: flex; align-items: center; gap: 7px; }
213
+ .stage-meta span { color: var(--muted); font-size: 9px; }
214
+ .stage-meta strong { overflow: hidden; padding: 2px 7px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel); font-size: 10px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
215
+ .stage-readout { display: flex; gap: 10px; white-space: nowrap; }
216
+
217
+ .stage-shell {
218
+ position: relative;
219
+ min-width: 0;
220
+ min-height: 0;
221
+ display: grid;
222
+ place-items: center;
223
+ margin: 0;
224
+ padding: 34px 22px 20px;
225
+ overflow: auto;
226
+ background-color: var(--workspace);
227
+ background-image: radial-gradient(#ffffff0b 1px, transparent 1px), linear-gradient(135deg, #ffffff09 25%, transparent 25%, transparent 75%, #00000008 75%);
228
+ background-size: 4px 4px, 24px 24px;
229
+ }
230
+ :root[data-theme="light"] .stage-shell { background-image: radial-gradient(#0000000c 1px, transparent 1px), linear-gradient(135deg, #ffffff32 25%, transparent 25%, transparent 75%, #00000008 75%); }
231
+ .stage-shell img, .stage-canvas { display: block; max-width: calc(100% - 24px); max-height: calc(100% - 24px); object-fit: contain; border: 1px solid #40b8cf; outline: 1px solid #1f2732b3; box-shadow: 0 12px 24px var(--shadow); user-select: none; }
232
+ .stage-canvas { width: auto; height: auto; touch-action: none; }
233
+ .stage-shell.playing .stage-canvas { cursor: crosshair; }
234
+ .stage-empty { max-width: 360px; display: grid; place-items: center; gap: 7px; padding: 24px; color: var(--muted); text-align: center; }
235
+ .stage-empty > span { color: #77a5ff; font-size: 28px; }
236
+ .stage-empty strong { color: var(--text); font-size: 13px; }
237
+ .stage-empty p { margin: 0; font-size: 10px; line-height: 1.6; }
238
+ .stage-loading { position: absolute; right: 10px; bottom: 10px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 5px; background: var(--panel); color: var(--muted); font-size: 9px; box-shadow: 0 4px 14px var(--shadow); }
239
+ .stage-footer { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 10px; border-top: 1px solid var(--line); background: var(--status); color: var(--muted); font: 10px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
240
+ .stage-footer span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
241
+
242
+ .info-card, .log-card { margin: 6px 7px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel-2); overflow: hidden; }
243
+ .info-card { padding: 9px; }
244
+ .info-label { display: block; margin-bottom: 5px; color: var(--muted); font-size: 9px; }
245
+ .info-card > strong { font-size: 13px; font-weight: 650; }
246
+ .info-card dl { display: grid; grid-template-columns: 66px minmax(0, 1fr); gap: 6px 8px; margin: 9px 0 0; padding-top: 8px; border-top: 1px solid var(--line-soft); font-size: 10px; }
247
+ .info-card dt { color: var(--muted); }
248
+ .info-card dd { margin: 0; overflow: hidden; color: var(--text); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
249
+ .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; padding: 0; text-align: center; }
250
+ .metrics div { min-width: 0; display: grid; gap: 4px; padding: 9px 4px; border-right: 1px solid var(--line-soft); }
251
+ .metrics div:last-child { border-right: 0; }
252
+ .metrics span { color: var(--muted); font-size: 9px; }
253
+ .metrics strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
254
+ .log-heading { height: 34px; display: flex; align-items: center; justify-content: space-between; padding: 0 9px; border-bottom: 1px solid var(--line-soft); color: var(--text); font-size: 10px; font-weight: 650; }
255
+ .log-heading button { height: 24px; padding: 0 6px; border: 0; background: transparent; color: var(--muted); font-size: 9px; }
256
+ .log-card pre { min-height: 130px; max-height: 34vh; margin: 0; padding: 9px; overflow: auto; color: var(--muted); background: var(--input); white-space: pre-wrap; word-break: break-word; font: 10px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; }
257
+ .watch-note { margin: 10px 11px 16px; padding: 7px 8px; border-left: 2px solid #477cf0; background: color-mix(in srgb, var(--blue) 8%, var(--panel-2)); color: var(--muted); font-size: 9px; line-height: 1.5; }
258
+ .watch-note span { color: #62d398; }
259
+
260
+ .toast { position: fixed; left: 50%; bottom: 18px; z-index: 20; max-width: min(520px, calc(100vw - 32px)); padding: 8px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel-2); box-shadow: 0 12px 32px var(--shadow); color: var(--text); font-size: 10px; opacity: 0; pointer-events: none; transform: translate(-50%, 8px); transition: 160ms ease; }
261
+ .toast.visible { opacity: 1; transform: translate(-50%, 0); }
262
+ .toast.error { border-color: var(--danger); color: var(--danger); }
263
+
264
+ [hidden] { display: none !important; }
265
+
266
+ @media (max-width: 1180px) {
267
+ .app-shell { grid-template-columns: 200px minmax(300px, 1fr) 292px; }
268
+ .brand span:last-child, .select-field > span { display: none; }
269
+ .play-controls button { padding: 0 6px; }
270
+ }
271
+
272
+ @media (max-width: 860px) {
273
+ .topbar { grid-template-columns: 170px minmax(250px, 1fr) auto; }
274
+ .app-shell { grid-template-columns: 190px minmax(300px, 1fr); }
275
+ .info-panel { display: none; }
276
+ .connection { display: none; }
277
+ }
278
+
279
+ @media (max-width: 680px) {
280
+ body { overflow: auto; }
281
+ .topbar { height: auto; grid-template-columns: 1fr auto; padding: 8px; }
282
+ .project-picker { grid-column: 1 / -1; grid-row: 2; }
283
+ .project-picker label { display: none; }
284
+ .project-picker select { width: 100%; }
285
+ .app-shell { height: calc(100vh - 90px); grid-template-columns: minmax(0, 1fr); }
286
+ .tree-panel { display: none; }
287
+ .preview-toolbar { flex-wrap: wrap; height: auto; }
288
+ .preview-panel { grid-template-rows: auto minmax(0, 1fr); }
289
+ .play-controls { width: 100%; margin-left: 0; overflow-x: auto; }
290
+ .stage-meta { align-items: flex-start; flex-direction: column; justify-content: center; gap: 3px; }
291
+ }