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.
- package/LICENSE +232 -0
- package/README.md +68 -0
- package/dist/public/app.js +461 -0
- package/dist/public/editor.css +1 -0
- package/dist/public/editor.html +13 -0
- package/dist/public/editor.js +80 -0
- package/dist/public/index.html +125 -0
- package/dist/public/play-renderer.js +936 -0
- package/dist/public/play.css +14 -0
- package/dist/public/play.html +27 -0
- package/dist/public/play.js +300 -0
- package/dist/public/styles.css +291 -0
- package/dist/server.js +115 -0
- package/dist/worker.js +105 -0
- package/package.json +32 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { PixiPlayRenderer, createPlaySession } from './play-renderer.js'
|
|
2
|
+
|
|
3
|
+
const byId = (id) => document.getElementById(id)
|
|
4
|
+
|
|
5
|
+
const elements = {
|
|
6
|
+
archive: byId('archive-select'),
|
|
7
|
+
reload: byId('reload-button'),
|
|
8
|
+
theme: byId('theme-button'),
|
|
9
|
+
connection: byId('connection'),
|
|
10
|
+
tree: byId('tree'),
|
|
11
|
+
treeTitle: byId('tree-title'),
|
|
12
|
+
treeCount: byId('tree-count'),
|
|
13
|
+
modeEditor: byId('mode-editor'),
|
|
14
|
+
modePlay: byId('mode-play'),
|
|
15
|
+
asset: byId('asset-select'),
|
|
16
|
+
device: byId('device-select'),
|
|
17
|
+
playControls: byId('play-controls'),
|
|
18
|
+
playStart: byId('play-start'),
|
|
19
|
+
playPause: byId('play-pause'),
|
|
20
|
+
playResume: byId('play-resume'),
|
|
21
|
+
playStep: byId('play-step'),
|
|
22
|
+
playStop: byId('play-stop'),
|
|
23
|
+
assetLabel: byId('asset-label'),
|
|
24
|
+
saveName: byId('save-name'),
|
|
25
|
+
canvasLabel: byId('canvas-label'),
|
|
26
|
+
revisionLabel: byId('revision-label'),
|
|
27
|
+
stageShell: byId('stage-shell'),
|
|
28
|
+
stageImage: byId('stage-image'),
|
|
29
|
+
stageCanvas: byId('stage-canvas'),
|
|
30
|
+
stageEmpty: byId('stage-empty'),
|
|
31
|
+
stageLoading: byId('stage-loading'),
|
|
32
|
+
activePath: byId('active-path'),
|
|
33
|
+
playReadout: byId('play-readout'),
|
|
34
|
+
nodeName: byId('node-name'),
|
|
35
|
+
nodeDetails: byId('node-details'),
|
|
36
|
+
metricControls: byId('metric-controls'),
|
|
37
|
+
metricScripts: byId('metric-scripts'),
|
|
38
|
+
metricCanvas: byId('metric-canvas'),
|
|
39
|
+
runtimeLogs: byId('runtime-logs'),
|
|
40
|
+
clearLogs: byId('clear-logs'),
|
|
41
|
+
toast: byId('toast'),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const kindIcons = {
|
|
45
|
+
'server-container': '▣',
|
|
46
|
+
container: '◇',
|
|
47
|
+
textbox: 'T',
|
|
48
|
+
image: '◫',
|
|
49
|
+
button: '▰',
|
|
50
|
+
cursor: '⌖',
|
|
51
|
+
grid: '▦',
|
|
52
|
+
reference: '↗',
|
|
53
|
+
textwindow: '▤',
|
|
54
|
+
keyhint: '⌨',
|
|
55
|
+
animation: '✦',
|
|
56
|
+
fullscreen: '◩',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let model = null
|
|
60
|
+
let mode = 'editor'
|
|
61
|
+
let selectedId = ''
|
|
62
|
+
let busy = false
|
|
63
|
+
let toastTimer = null
|
|
64
|
+
let editorImageRefreshing = false
|
|
65
|
+
let editorImageRefreshPending = false
|
|
66
|
+
|
|
67
|
+
const playRenderer = new PixiPlayRenderer(elements.stageCanvas)
|
|
68
|
+
|
|
69
|
+
function applyTheme(theme) {
|
|
70
|
+
const next = theme === 'light' ? 'light' : 'dark'
|
|
71
|
+
document.documentElement.dataset.theme = next
|
|
72
|
+
document.querySelector('meta[name="theme-color"]').content = next === 'light' ? '#f5f6f8' : '#20242e'
|
|
73
|
+
elements.theme.title = next === 'dark' ? '切换到亮色主题' : '切换到暗色主题'
|
|
74
|
+
localStorage.setItem('qxqy-simulator-theme', next)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const savedTheme = localStorage.getItem('qxqy-simulator-theme')
|
|
78
|
+
applyTheme(savedTheme || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'))
|
|
79
|
+
|
|
80
|
+
function notify(message, isError = false) {
|
|
81
|
+
clearTimeout(toastTimer)
|
|
82
|
+
elements.toast.textContent = message
|
|
83
|
+
elements.toast.className = `toast visible${isError ? ' error' : ''}`
|
|
84
|
+
toastTimer = setTimeout(() => { elements.toast.className = 'toast' }, 2600)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function requestJson(path, options = {}) {
|
|
88
|
+
const response = await fetch(path, {
|
|
89
|
+
...options,
|
|
90
|
+
headers: { 'content-type': 'application/json', ...(options.headers || {}) },
|
|
91
|
+
})
|
|
92
|
+
const body = await response.json()
|
|
93
|
+
if (!response.ok || !body.ok) throw new Error(body.error || `HTTP ${response.status}`)
|
|
94
|
+
return body.value
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function setConnection(status, text) {
|
|
98
|
+
elements.connection.className = `connection ${status}`
|
|
99
|
+
elements.connection.lastChild.textContent = text
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function option(value, label) {
|
|
103
|
+
const item = document.createElement('option')
|
|
104
|
+
item.value = value
|
|
105
|
+
item.textContent = label
|
|
106
|
+
return item
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderArchives() {
|
|
110
|
+
const previous = elements.archive.value
|
|
111
|
+
elements.archive.replaceChildren()
|
|
112
|
+
const archives = model?.archives || []
|
|
113
|
+
if (!archives.length) {
|
|
114
|
+
const empty = option('', '未发现 qxqy-simulator-save')
|
|
115
|
+
empty.disabled = true
|
|
116
|
+
elements.archive.append(empty)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
for (const archive of archives) {
|
|
120
|
+
elements.archive.append(option(archive.path, `${archive.name} · ${archive.path}`))
|
|
121
|
+
}
|
|
122
|
+
elements.archive.value = model.activePath || previous || archives[0].path
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderDevices() {
|
|
126
|
+
const presets = model?.snapshot?.canvas?.presets || []
|
|
127
|
+
const previous = elements.device.value
|
|
128
|
+
elements.device.replaceChildren(...presets.map((preset) => option(preset.id, preset.label)))
|
|
129
|
+
const active = mode === 'play' && model?.play?.canvasId
|
|
130
|
+
? model.play.canvasId
|
|
131
|
+
: model?.snapshot?.canvas?.id
|
|
132
|
+
elements.device.value = active || previous || presets[0]?.id || ''
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderTree() {
|
|
136
|
+
const rows = model?.snapshot?.tree || []
|
|
137
|
+
elements.tree.replaceChildren()
|
|
138
|
+
elements.treeCount.textContent = String(rows.length)
|
|
139
|
+
elements.treeTitle.textContent = model?.snapshot?.asset?.type === 'client-control-template' ? '模板与子控件' : '控件树'
|
|
140
|
+
if (!rows.length) {
|
|
141
|
+
const empty = document.createElement('div')
|
|
142
|
+
empty.className = 'tree-empty'
|
|
143
|
+
empty.textContent = model?.activePath ? '当前资产没有控件。' : '打开存档后显示控件结构。'
|
|
144
|
+
elements.tree.append(empty)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (!rows.some((row) => row.id === selectedId)) selectedId = model.snapshot.selectedId || rows[0].id
|
|
148
|
+
for (const row of rows) {
|
|
149
|
+
const button = document.createElement('button')
|
|
150
|
+
button.className = `tree-row${row.id === selectedId ? ' active' : ''}`
|
|
151
|
+
button.style.paddingLeft = `${8 + row.depth * 15}px`
|
|
152
|
+
button.setAttribute('role', 'treeitem')
|
|
153
|
+
button.addEventListener('click', () => {
|
|
154
|
+
selectedId = row.id
|
|
155
|
+
renderTree()
|
|
156
|
+
renderNodeDetails()
|
|
157
|
+
})
|
|
158
|
+
const icon = document.createElement('span')
|
|
159
|
+
icon.className = 'tree-icon'
|
|
160
|
+
icon.textContent = kindIcons[row.kind] || '·'
|
|
161
|
+
const name = document.createElement('span')
|
|
162
|
+
name.className = 'tree-name'
|
|
163
|
+
name.textContent = row.name
|
|
164
|
+
const kind = document.createElement('span')
|
|
165
|
+
kind.className = 'tree-kind'
|
|
166
|
+
kind.textContent = row.childCount ? String(row.childCount) : ''
|
|
167
|
+
button.append(icon, name, kind)
|
|
168
|
+
elements.tree.append(button)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function addDetail(label, value) {
|
|
173
|
+
const term = document.createElement('dt')
|
|
174
|
+
term.textContent = label
|
|
175
|
+
const data = document.createElement('dd')
|
|
176
|
+
data.textContent = value == null || value === '' ? '—' : String(value)
|
|
177
|
+
elements.nodeDetails.append(term, data)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function renderNodeDetails() {
|
|
181
|
+
const row = model?.snapshot?.tree?.find((item) => item.id === selectedId)
|
|
182
|
+
elements.nodeDetails.replaceChildren()
|
|
183
|
+
elements.nodeName.textContent = row?.name || '未选择'
|
|
184
|
+
if (!row) return
|
|
185
|
+
addDetail('类型', row.label || row.kind)
|
|
186
|
+
addDetail('ID', row.id)
|
|
187
|
+
addDetail('GUID', row.guid)
|
|
188
|
+
addDetail('树深度', row.depth)
|
|
189
|
+
addDetail('父节点', row.parentId)
|
|
190
|
+
addDetail('子控件', row.childCount)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatLogs(logs) {
|
|
194
|
+
if (!Array.isArray(logs) || !logs.length) return '试玩启动后显示 Lua 日志。'
|
|
195
|
+
return logs.map((entry) => {
|
|
196
|
+
if (typeof entry === 'string') return entry
|
|
197
|
+
const time = entry.time == null ? '' : `[${Number(entry.time).toFixed(3)}] `
|
|
198
|
+
return `${time}${entry.message || entry.text || JSON.stringify(entry)}`
|
|
199
|
+
}).join('\n')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderStage() {
|
|
203
|
+
const snapshot = model?.snapshot
|
|
204
|
+
const hasArchive = Boolean(model?.activePath)
|
|
205
|
+
const showEditor = hasArchive && mode === 'editor'
|
|
206
|
+
const showPlay = hasArchive && mode === 'play' && Boolean(model?.play?.running)
|
|
207
|
+
elements.stageImage.hidden = !showEditor
|
|
208
|
+
elements.stageCanvas.hidden = !showPlay
|
|
209
|
+
elements.stageEmpty.hidden = showEditor || showPlay
|
|
210
|
+
elements.stageShell.classList.toggle('playing', mode === 'play' && Boolean(model?.play?.running))
|
|
211
|
+
if (!hasArchive) {
|
|
212
|
+
elements.stageEmpty.querySelector('strong').textContent = '等待工作区存档'
|
|
213
|
+
elements.stageEmpty.querySelector('p').textContent = 'Codex 保存 qxqy-simulator-save JSON 后会自动出现在这里。'
|
|
214
|
+
} else if (mode === 'play' && !model.play.running) {
|
|
215
|
+
elements.stageEmpty.querySelector('strong').textContent = '试玩尚未启动'
|
|
216
|
+
elements.stageEmpty.querySelector('p').textContent = '选择设备并点击“启动”,即可运行 Lua 和交互逻辑。'
|
|
217
|
+
}
|
|
218
|
+
if (showEditor) refreshEditorImage()
|
|
219
|
+
elements.assetLabel.textContent = snapshot?.asset?.label || '等待存档'
|
|
220
|
+
elements.saveName.textContent = snapshot?.save?.name || '未命名存档'
|
|
221
|
+
elements.canvasLabel.textContent = snapshot?.canvas ? `${snapshot.canvas.label} · ${snapshot.canvas.width}×${snapshot.canvas.height}` : '—'
|
|
222
|
+
elements.revisionLabel.textContent = `Revision ${snapshot?.version ?? '—'}`
|
|
223
|
+
elements.activePath.textContent = model?.activePath || '尚未打开存档'
|
|
224
|
+
elements.playReadout.textContent = mode === 'play'
|
|
225
|
+
? model?.play?.running
|
|
226
|
+
? `Frame ${model.play.frame} · ${model.play.time.toFixed(3)}s${model.play.paused ? ' · 已暂停' : ''}`
|
|
227
|
+
: '等待启动 Runtime'
|
|
228
|
+
: '编辑器静态预览 · 磁盘自动刷新'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function refreshEditorImage() {
|
|
232
|
+
if (!model?.activePath) return
|
|
233
|
+
if (editorImageRefreshing) {
|
|
234
|
+
editorImageRefreshPending = true
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
editorImageRefreshing = true
|
|
238
|
+
editorImageRefreshPending = false
|
|
239
|
+
elements.stageLoading.hidden = false
|
|
240
|
+
const next = `/api/editor.png?v=${Date.now()}`
|
|
241
|
+
const image = new Image()
|
|
242
|
+
const finish = () => {
|
|
243
|
+
editorImageRefreshing = false
|
|
244
|
+
if (editorImageRefreshPending) {
|
|
245
|
+
editorImageRefreshPending = false
|
|
246
|
+
refreshEditorImage()
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
image.onload = () => {
|
|
250
|
+
elements.stageImage.src = next
|
|
251
|
+
elements.stageLoading.hidden = true
|
|
252
|
+
finish()
|
|
253
|
+
}
|
|
254
|
+
image.onerror = () => {
|
|
255
|
+
elements.stageLoading.hidden = true
|
|
256
|
+
notify('编辑器画面加载失败。', true)
|
|
257
|
+
finish()
|
|
258
|
+
}
|
|
259
|
+
image.src = next
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function applyPlayStatus(next) {
|
|
263
|
+
if (!next || typeof next !== 'object' || !model) return
|
|
264
|
+
const stopped = next.running === false
|
|
265
|
+
const previous = model.play || {}
|
|
266
|
+
model.play = {
|
|
267
|
+
running: !stopped,
|
|
268
|
+
paused: stopped ? false : Boolean(next.paused),
|
|
269
|
+
canvasId: String(next.canvasId || previous.canvasId || ''),
|
|
270
|
+
width: Number(next.canvasWidth || previous.width || 0),
|
|
271
|
+
height: Number(next.canvasHeight || previous.height || 0),
|
|
272
|
+
frame: Number(next.frame || 0),
|
|
273
|
+
time: Number(next.time || 0),
|
|
274
|
+
logs: Array.isArray(next.logs) ? next.logs.slice(-100) : previous.logs,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function updatePlayChrome() {
|
|
279
|
+
const playState = model?.play
|
|
280
|
+
elements.playReadout.textContent = mode === 'play'
|
|
281
|
+
? playState?.running
|
|
282
|
+
? `Frame ${playState.frame} · ${Number(playState.time || 0).toFixed(3)}s${playState.paused ? ' · 已暂停' : ''}`
|
|
283
|
+
: '等待启动 Runtime'
|
|
284
|
+
: '编辑器静态预览 · 磁盘自动刷新'
|
|
285
|
+
if (mode === 'play') elements.runtimeLogs.textContent = formatLogs(playState?.logs)
|
|
286
|
+
renderControls()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function syncPlayPolling() {
|
|
290
|
+
if (mode === 'play' && play.running) play.startPolling()
|
|
291
|
+
else play.stopPolling()
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function onPlaySnapshot(next) {
|
|
295
|
+
applyPlayStatus(next)
|
|
296
|
+
updatePlayChrome()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function renderControls() {
|
|
300
|
+
const running = Boolean(model?.play?.running)
|
|
301
|
+
const hasArchive = Boolean(model?.activePath)
|
|
302
|
+
elements.modeEditor.classList.toggle('active', mode === 'editor')
|
|
303
|
+
elements.modePlay.classList.toggle('active', mode === 'play')
|
|
304
|
+
elements.playControls.classList.toggle('visible', mode === 'play')
|
|
305
|
+
elements.playStart.disabled = busy || !hasArchive || running
|
|
306
|
+
elements.playPause.disabled = busy || !running
|
|
307
|
+
elements.playResume.disabled = busy || !running
|
|
308
|
+
elements.playStep.disabled = busy || !running
|
|
309
|
+
elements.playStop.disabled = busy || !running
|
|
310
|
+
elements.asset.disabled = busy || mode === 'play'
|
|
311
|
+
elements.device.disabled = busy || !hasArchive
|
|
312
|
+
elements.archive.disabled = busy || !(model?.archives?.length)
|
|
313
|
+
elements.reload.disabled = busy
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function render() {
|
|
317
|
+
renderArchives()
|
|
318
|
+
renderDevices()
|
|
319
|
+
renderTree()
|
|
320
|
+
renderNodeDetails()
|
|
321
|
+
const snapshot = model?.snapshot
|
|
322
|
+
elements.asset.value = snapshot?.asset?.type || 'server-control-template'
|
|
323
|
+
elements.metricControls.textContent = String(snapshot?.tree?.length || 0)
|
|
324
|
+
elements.metricScripts.textContent = String(snapshot?.scriptCount || 0)
|
|
325
|
+
elements.metricCanvas.textContent = snapshot?.canvas ? `${snapshot.canvas.width}×${snapshot.canvas.height}` : '—'
|
|
326
|
+
elements.runtimeLogs.textContent = formatLogs(model?.play?.logs)
|
|
327
|
+
renderControls()
|
|
328
|
+
renderStage()
|
|
329
|
+
syncPlayPolling()
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function refreshState({ quiet = false } = {}) {
|
|
333
|
+
if (mode === 'play' && model?.play?.running && quiet) return
|
|
334
|
+
try {
|
|
335
|
+
model = await requestJson('/api/state')
|
|
336
|
+
if (!quiet && model?.play?.running && !play.running) {
|
|
337
|
+
mode = 'play'
|
|
338
|
+
await play.attach()
|
|
339
|
+
}
|
|
340
|
+
setConnection('online', '实时监听中')
|
|
341
|
+
render()
|
|
342
|
+
} catch (error) {
|
|
343
|
+
setConnection('error', '连接中断')
|
|
344
|
+
if (!quiet) notify(error.message, true)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function run(task) {
|
|
349
|
+
if (busy) return
|
|
350
|
+
busy = true
|
|
351
|
+
renderControls()
|
|
352
|
+
try {
|
|
353
|
+
await task()
|
|
354
|
+
} catch (error) {
|
|
355
|
+
notify(error.message, true)
|
|
356
|
+
} finally {
|
|
357
|
+
busy = false
|
|
358
|
+
renderControls()
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const play = createPlaySession({
|
|
363
|
+
renderer: playRenderer,
|
|
364
|
+
api: (action, args = {}) => requestJson('/api/play', {
|
|
365
|
+
method: 'POST',
|
|
366
|
+
body: JSON.stringify({ action, args }),
|
|
367
|
+
}),
|
|
368
|
+
onSnapshot: onPlaySnapshot,
|
|
369
|
+
onStatus: (next) => { applyPlayStatus(next); updatePlayChrome() },
|
|
370
|
+
onError: (error) => notify(error.message, true),
|
|
371
|
+
isActive: () => mode === 'play',
|
|
372
|
+
})
|
|
373
|
+
play.bindInput(elements.stageCanvas, { keysOn: elements.stageShell })
|
|
374
|
+
|
|
375
|
+
elements.archive.addEventListener('change', () => run(async () => {
|
|
376
|
+
if (play.running) await play.stop().catch(() => {})
|
|
377
|
+
model = await requestJson('/api/open', { method: 'POST', body: JSON.stringify({ path: elements.archive.value }) })
|
|
378
|
+
mode = 'editor'
|
|
379
|
+
selectedId = ''
|
|
380
|
+
render()
|
|
381
|
+
notify('已打开存档')
|
|
382
|
+
}))
|
|
383
|
+
|
|
384
|
+
elements.reload.addEventListener('click', () => run(async () => {
|
|
385
|
+
if (play.running) await play.stop().catch(() => {})
|
|
386
|
+
if (model?.activePath) {
|
|
387
|
+
model = await requestJson('/api/open', { method: 'POST', body: JSON.stringify({ path: model.activePath }) })
|
|
388
|
+
mode = 'editor'
|
|
389
|
+
render()
|
|
390
|
+
notify('已重新读取磁盘存档')
|
|
391
|
+
} else {
|
|
392
|
+
await refreshState()
|
|
393
|
+
}
|
|
394
|
+
}))
|
|
395
|
+
|
|
396
|
+
elements.theme.addEventListener('click', () => {
|
|
397
|
+
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
elements.modeEditor.addEventListener('click', () => { mode = 'editor'; render() })
|
|
401
|
+
elements.modePlay.addEventListener('click', () => { mode = 'play'; render() })
|
|
402
|
+
|
|
403
|
+
elements.asset.addEventListener('change', () => run(async () => {
|
|
404
|
+
model = await requestJson('/api/view', {
|
|
405
|
+
method: 'POST',
|
|
406
|
+
body: JSON.stringify({ assetType: elements.asset.value }),
|
|
407
|
+
})
|
|
408
|
+
selectedId = ''
|
|
409
|
+
render()
|
|
410
|
+
}))
|
|
411
|
+
|
|
412
|
+
elements.device.addEventListener('change', () => run(async () => {
|
|
413
|
+
if (mode === 'play' && play.running) {
|
|
414
|
+
await play.device({ canvasId: elements.device.value })
|
|
415
|
+
renderStage()
|
|
416
|
+
} else {
|
|
417
|
+
model = await requestJson('/api/view', {
|
|
418
|
+
method: 'POST',
|
|
419
|
+
body: JSON.stringify({ canvasId: elements.device.value }),
|
|
420
|
+
})
|
|
421
|
+
render()
|
|
422
|
+
}
|
|
423
|
+
}))
|
|
424
|
+
|
|
425
|
+
elements.playStart.addEventListener('click', () => run(async () => {
|
|
426
|
+
mode = 'play'
|
|
427
|
+
await play.start({ canvasId: elements.device.value })
|
|
428
|
+
renderStage()
|
|
429
|
+
updatePlayChrome()
|
|
430
|
+
elements.stageShell.focus()
|
|
431
|
+
}))
|
|
432
|
+
elements.playPause.addEventListener('click', () => void play.act('pause'))
|
|
433
|
+
elements.playResume.addEventListener('click', () => void play.act('resume'))
|
|
434
|
+
elements.playStep.addEventListener('click', () => void play.act('step', { dt: 1 / 30 }))
|
|
435
|
+
elements.playStop.addEventListener('click', () => run(async () => {
|
|
436
|
+
await play.stop()
|
|
437
|
+
applyPlayStatus({ running: false, logs: [] })
|
|
438
|
+
renderStage()
|
|
439
|
+
updatePlayChrome()
|
|
440
|
+
}))
|
|
441
|
+
|
|
442
|
+
elements.clearLogs.addEventListener('click', () => { elements.runtimeLogs.textContent = '日志已在当前视图清空。' })
|
|
443
|
+
|
|
444
|
+
const events = new EventSource('/api/events')
|
|
445
|
+
events.addEventListener('open', () => setConnection('online', '实时监听中'))
|
|
446
|
+
events.addEventListener('error', () => setConnection('error', '正在重连'))
|
|
447
|
+
events.addEventListener('state', (event) => {
|
|
448
|
+
let change = {}
|
|
449
|
+
try { change = JSON.parse(event.data) } catch {}
|
|
450
|
+
if (change.reason === 'external-change') {
|
|
451
|
+
mode = 'editor'
|
|
452
|
+
play.reset()
|
|
453
|
+
notify('检测到 Codex/MCP 保存,预览已更新')
|
|
454
|
+
}
|
|
455
|
+
// 试玩画面有独立的轻量状态轮询;忽略 play 事件,避免重复刷新。
|
|
456
|
+
if (change.type !== 'play') void refreshState({ quiet: true })
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
setInterval(() => { void refreshState({ quiet: true }) }, 5000)
|
|
460
|
+
addEventListener('pagehide', () => play.destroy())
|
|
461
|
+
void refreshState()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
html,body,#editor{width:100%;height:100%;margin:0;overflow:hidden}body{background:#101a2a}button,input,select,textarea{font:inherit}
|
|
@@ -0,0 +1,13 @@
|
|
|
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.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="editor"></div>
|
|
11
|
+
<script type="module" src="/editor.js"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|