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,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>