beyond-simulator-web 0.2.2 → 0.3.0
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 +13 -1
- package/dist/public/app.js +45 -20
- package/dist/public/index.html +7 -1
- package/dist/public/styles.css +4 -0
- package/dist/server.js +13 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,7 +42,19 @@ beyond-simulator-web --workspace /absolute/path/to/workspace --open
|
|
|
42
42
|
|
|
43
43
|
## 与 MCP 配合
|
|
44
44
|
|
|
45
|
-
MCP 显式调用 `qxqy_project_save` 保存工作区 JSON 后,`/` 每 700ms
|
|
45
|
+
MCP 显式调用 `qxqy_project_save` 保存工作区 JSON 后,`/` 每 700ms 检查当前文件。还没有打开存档时,每 5 秒重新发现存档并自动打开最新的 `qxqy-simulator-save`;已经打开时,只在该文件变化后停止旧试玩、重新加载,并通过 SSE 刷新浏览器。不会自动改看另一个路径。顶部刷新按钮可立即扫描。该预览不写文件。`/editor` 不自动用磁盘内容覆盖正在编辑的草稿,需要手动“从工作区拉取存档”。
|
|
46
|
+
|
|
47
|
+
MCP 0.3.0 起提供 `qxqy_preview_status()` 和 `qxqy_preview_open({ path: "game.save.json" })`:前者查询 Web 实际版本、工作区、当前存档和加载错误,后者直接让预览服务读取指定存档,无需重新保存。MCP 默认连接 `http://127.0.0.1:4173`,其他端口使用 MCP 的 `--web-url` 或 `QXQY_WEB_URL`;Web 启用口令时,在 MCP 环境中配置相同的 `QXQY_WEB_PASSWORD`。两边必须使用同一工作区,桥接会在读取和打开时校验。工具回执确认服务端已加载,浏览器通过 SSE 或定期状态同步刷新。
|
|
48
|
+
|
|
49
|
+
页面“预览连接”显示 Web 版本、工作区、加载错误与扫描警告。发现列表只读取 JSON 的前 4 KiB,不再排除超过 8 MiB 的合法存档;在扫描预算内按修改时间取最新 50 项。自动扫描跳过隐藏条目及依赖/生成目录,最多 8 层、10,000 条目和 1,000 目录;达到限制或读取失败会显示汇总警告。明确指定的 `--file` / 预览工具路径不受列表筛选限制,但仍受工作区边界保护。损坏 JSON 会显示加载错误并继续重试;初始文件缺失不会导致整个 Web 服务无法启动。
|
|
50
|
+
|
|
51
|
+
源码更新后需运行 `pnpm build` 并重启 Web/MCP;`pnpm start:web` 直接运行已有 `web/dist`,不会自动构建。npm 安装用户更新包后也需重启进程。
|
|
52
|
+
|
|
53
|
+
### 2026-09-26 预览同步修复记录
|
|
54
|
+
|
|
55
|
+
Web/MCP 0.3.0、共享宿主所在 DSH 2.0.3:修复空工作区启动后跳过新文件发现、默认保存路径与打开路径脱节、大文件不出现在列表及前 50 项截断遗漏最新文件。补充显式预览桥接、加载错误与版本信息。证据为仓库内 `web/test/preview-state.test.mjs`、`studio/test/workspace.test.mjs`、`studio/test/controller-archive.test.mjs` 与实际启动两个进程的 `mcp/test/web-integration.test.mjs`;`evidence_source: observed`,运行端 `simulator`,`device_status: not_required`。包公开状态以 npm 审核结果为准。
|
|
56
|
+
|
|
57
|
+
同日浏览器实测:启动试玩后将临时存档替换为损坏 JSON,页面显示加载错误并停止旧试玩;恢复原文件(保留原大小与时间戳)后错误清除并自动回到编辑预览。轮询不得因旧试玩状态屏蔽错误事件;存在加载错误时,即使文件时间戳与上次成功加载相同也需要重试。
|
|
46
58
|
|
|
47
59
|
试玩使用固定 30 FPS Worker、增量场景和 Pixi/WebGL;刷新试玩页会接回已有 Worker,保留暂停和当前玩家。截图仍由 Host 根据统一场景输出 PNG。
|
|
48
60
|
|
package/dist/public/app.js
CHANGED
|
@@ -39,6 +39,9 @@ const elements = {
|
|
|
39
39
|
runtimeLogs: byId('runtime-logs'),
|
|
40
40
|
clearLogs: byId('clear-logs'),
|
|
41
41
|
toast: byId('toast'),
|
|
42
|
+
serverVersion: byId('server-version'),
|
|
43
|
+
workspacePath: byId('workspace-path'),
|
|
44
|
+
diagnostics: byId('preview-diagnostics'),
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
const kindIcons = {
|
|
@@ -110,7 +113,7 @@ function renderArchives() {
|
|
|
110
113
|
const previous = elements.archive.value
|
|
111
114
|
elements.archive.replaceChildren()
|
|
112
115
|
const archives = model?.archives || []
|
|
113
|
-
if (!archives.length) {
|
|
116
|
+
if (!archives.length && !model?.activePath) {
|
|
114
117
|
const empty = option('', '未发现 qxqy-simulator-save')
|
|
115
118
|
empty.disabled = true
|
|
116
119
|
elements.archive.append(empty)
|
|
@@ -119,6 +122,9 @@ function renderArchives() {
|
|
|
119
122
|
for (const archive of archives) {
|
|
120
123
|
elements.archive.append(option(archive.path, `${archive.name} · ${archive.path}`))
|
|
121
124
|
}
|
|
125
|
+
if (model.activePath && !archives.some(archive => archive.path === model.activePath)) {
|
|
126
|
+
elements.archive.prepend(option(model.activePath, `${model.snapshot?.save?.name || '当前存档'} · ${model.activePath}`))
|
|
127
|
+
}
|
|
122
128
|
elements.archive.value = model.activePath || previous || archives[0].path
|
|
123
129
|
}
|
|
124
130
|
|
|
@@ -133,7 +139,7 @@ function renderDevices() {
|
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
function renderTree() {
|
|
136
|
-
const rows = model?.snapshot?.tree || []
|
|
142
|
+
const rows = model?.activePath ? (model?.snapshot?.tree || []) : []
|
|
137
143
|
elements.tree.replaceChildren()
|
|
138
144
|
elements.treeCount.textContent = String(rows.length)
|
|
139
145
|
elements.treeTitle.textContent = model?.snapshot?.asset?.type === 'client-control-template' ? '模板与子控件' : '控件树'
|
|
@@ -178,7 +184,7 @@ function addDetail(label, value) {
|
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
function renderNodeDetails() {
|
|
181
|
-
const row = model?.snapshot?.tree?.find((item) => item.id === selectedId)
|
|
187
|
+
const row = model?.activePath && model?.snapshot?.tree?.find((item) => item.id === selectedId)
|
|
182
188
|
elements.nodeDetails.replaceChildren()
|
|
183
189
|
elements.nodeName.textContent = row?.name || '未选择'
|
|
184
190
|
if (!row) return
|
|
@@ -209,17 +215,20 @@ function renderStage() {
|
|
|
209
215
|
elements.stageEmpty.hidden = showEditor || showPlay
|
|
210
216
|
elements.stageShell.classList.toggle('playing', mode === 'play' && Boolean(model?.play?.running))
|
|
211
217
|
if (!hasArchive) {
|
|
212
|
-
elements.stageEmpty.querySelector('strong').textContent = '等待工作区存档'
|
|
213
|
-
|
|
218
|
+
elements.stageEmpty.querySelector('strong').textContent = model?.lastError ? '存档加载失败' : '等待工作区存档'
|
|
219
|
+
const watched = model?.workspace ? `正在监听 ${model.workspace}。` : ''
|
|
220
|
+
elements.stageEmpty.querySelector('p').textContent = model?.lastError
|
|
221
|
+
? model.lastError
|
|
222
|
+
: `${watched}Codex 保存 qxqy-simulator-save JSON 后会自动出现在这里。`
|
|
214
223
|
} else if (mode === 'play' && !model.play.running) {
|
|
215
224
|
elements.stageEmpty.querySelector('strong').textContent = '试玩尚未启动'
|
|
216
225
|
elements.stageEmpty.querySelector('p').textContent = '选择设备并点击“启动”,即可运行 Lua 和交互逻辑。'
|
|
217
226
|
}
|
|
218
227
|
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 ?? '—'}`
|
|
228
|
+
elements.assetLabel.textContent = hasArchive ? (snapshot?.asset?.label || '等待存档') : '等待存档'
|
|
229
|
+
elements.saveName.textContent = hasArchive ? (snapshot?.save?.name || '未命名存档') : '未命名存档'
|
|
230
|
+
elements.canvasLabel.textContent = hasArchive && snapshot?.canvas ? `${snapshot.canvas.label} · ${snapshot.canvas.width}×${snapshot.canvas.height}` : '—'
|
|
231
|
+
elements.revisionLabel.textContent = hasArchive ? `Revision ${snapshot?.version ?? '—'}` : 'Revision —'
|
|
223
232
|
elements.activePath.textContent = model?.activePath || '尚未打开存档'
|
|
224
233
|
elements.playReadout.textContent = mode === 'play'
|
|
225
234
|
? model?.play?.running
|
|
@@ -309,7 +318,7 @@ function renderControls() {
|
|
|
309
318
|
elements.playStop.disabled = busy || !running
|
|
310
319
|
elements.asset.disabled = busy || mode === 'play'
|
|
311
320
|
elements.device.disabled = busy || !hasArchive
|
|
312
|
-
elements.archive.disabled = busy || !(model?.archives?.length)
|
|
321
|
+
elements.archive.disabled = busy || !(model?.archives?.length || model?.activePath)
|
|
313
322
|
elements.reload.disabled = busy
|
|
314
323
|
}
|
|
315
324
|
|
|
@@ -319,20 +328,27 @@ function render() {
|
|
|
319
328
|
renderTree()
|
|
320
329
|
renderNodeDetails()
|
|
321
330
|
const snapshot = model?.snapshot
|
|
331
|
+
const hasArchive = Boolean(model?.activePath)
|
|
322
332
|
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}` : '—'
|
|
333
|
+
elements.metricControls.textContent = hasArchive ? String(snapshot?.tree?.length || 0) : '0'
|
|
334
|
+
elements.metricScripts.textContent = hasArchive ? String(snapshot?.scriptCount || 0) : '0'
|
|
335
|
+
elements.metricCanvas.textContent = hasArchive && snapshot?.canvas ? `${snapshot.canvas.width}×${snapshot.canvas.height}` : '—'
|
|
326
336
|
elements.runtimeLogs.textContent = formatLogs(model?.play?.logs)
|
|
337
|
+
elements.serverVersion.textContent = model?.version ? `Web ${model.version}` : '服务版本未知,请更新 Web'
|
|
338
|
+
elements.workspacePath.textContent = model?.workspace ? `工作区:${model.workspace}` : ''
|
|
339
|
+
elements.diagnostics.textContent = [model?.lastError, ...(model?.discovery?.warnings || [])].filter(Boolean).join('\n')
|
|
340
|
+
elements.diagnostics.hidden = !elements.diagnostics.textContent
|
|
341
|
+
elements.diagnostics.classList.toggle('error', Boolean(model?.lastError))
|
|
327
342
|
renderControls()
|
|
328
343
|
renderStage()
|
|
329
344
|
syncPlayPolling()
|
|
330
345
|
}
|
|
331
346
|
|
|
332
|
-
async function refreshState({ quiet = false } = {}) {
|
|
333
|
-
if (mode === 'play' && model?.play?.running && quiet) return
|
|
347
|
+
async function refreshState({ quiet = false, force = false, forceDiscovery = false } = {}) {
|
|
348
|
+
if (mode === 'play' && model?.play?.running && play.running && quiet && !force) return
|
|
334
349
|
try {
|
|
335
|
-
model = await requestJson('/api/state')
|
|
350
|
+
model = await requestJson(forceDiscovery ? '/api/state?refresh=1' : '/api/state')
|
|
351
|
+
if (!model?.play?.running && play.running) play.reset()
|
|
336
352
|
if (!quiet && model?.play?.running && !play.running) {
|
|
337
353
|
mode = 'play'
|
|
338
354
|
await play.attach()
|
|
@@ -389,7 +405,15 @@ elements.reload.addEventListener('click', () => run(async () => {
|
|
|
389
405
|
render()
|
|
390
406
|
notify('已重新读取磁盘存档')
|
|
391
407
|
} else {
|
|
392
|
-
await refreshState()
|
|
408
|
+
await refreshState({ forceDiscovery: true })
|
|
409
|
+
const newest = model?.archives?.[0]?.path
|
|
410
|
+
if (newest && !model?.activePath) {
|
|
411
|
+
model = await requestJson('/api/open', { method: 'POST', body: JSON.stringify({ path: newest }) })
|
|
412
|
+
mode = 'editor'
|
|
413
|
+
selectedId = ''
|
|
414
|
+
render()
|
|
415
|
+
notify('已打开存档')
|
|
416
|
+
}
|
|
393
417
|
}
|
|
394
418
|
}))
|
|
395
419
|
|
|
@@ -447,13 +471,14 @@ events.addEventListener('error', () => setConnection('error', '正在重连'))
|
|
|
447
471
|
events.addEventListener('state', (event) => {
|
|
448
472
|
let change = {}
|
|
449
473
|
try { change = JSON.parse(event.data) } catch {}
|
|
450
|
-
if (change.
|
|
474
|
+
if (change.type === 'archive') {
|
|
451
475
|
mode = 'editor'
|
|
476
|
+
selectedId = ''
|
|
452
477
|
play.reset()
|
|
453
|
-
notify('
|
|
478
|
+
notify(change.reason === 'external-change' ? '检测到存档变化,预览已更新' : '预览存档已打开')
|
|
454
479
|
}
|
|
455
480
|
// 试玩画面有独立的轻量状态轮询;忽略 play 事件,避免重复刷新。
|
|
456
|
-
if (change.type !== 'play') void refreshState({ quiet: true })
|
|
481
|
+
if (change.type !== 'play') void refreshState({ quiet: true, force: true })
|
|
457
482
|
})
|
|
458
483
|
|
|
459
484
|
setInterval(() => { void refreshState({ quiet: true }) }, 5000)
|
package/dist/public/index.html
CHANGED
|
@@ -115,7 +115,13 @@
|
|
|
115
115
|
<div class="log-heading"><span>Runtime Logs</span><button id="clear-logs">清空</button></div>
|
|
116
116
|
<pre id="runtime-logs">试玩启动后显示 Lua 日志。</pre>
|
|
117
117
|
</section>
|
|
118
|
-
<
|
|
118
|
+
<section class="info-card connection-details">
|
|
119
|
+
<span class="info-label">预览连接</span>
|
|
120
|
+
<div id="server-version">正在连接服务…</div>
|
|
121
|
+
<div id="workspace-path"></div>
|
|
122
|
+
<div id="preview-diagnostics" role="status" aria-live="polite" hidden></div>
|
|
123
|
+
</section>
|
|
124
|
+
<p class="watch-note"><span>●</span> 当前存档保存后自动刷新;首次存档会自动发现。切换其他存档请使用顶部列表或 MCP 预览工具。</p>
|
|
119
125
|
</aside>
|
|
120
126
|
</main>
|
|
121
127
|
|
package/dist/public/styles.css
CHANGED
|
@@ -256,6 +256,10 @@ button:focus-visible, select:focus-visible, .stage-shell:focus-visible, .tree-ro
|
|
|
256
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
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
258
|
.watch-note span { color: #62d398; }
|
|
259
|
+
.connection-details { font-size: 10px; line-height: 1.6; overflow-wrap: anywhere; }
|
|
260
|
+
#workspace-path { color: var(--muted); }
|
|
261
|
+
#preview-diagnostics { margin-top: 7px; white-space: pre-wrap; }
|
|
262
|
+
#preview-diagnostics.error { color: var(--danger); }
|
|
259
263
|
|
|
260
264
|
.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
265
|
.toast.visible { opacity: 1; transform: translate(-50%, 0); }
|