beyond-simulator-web 0.4.0 → 0.5.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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  单用户自托管 Web 编辑器和磁盘存档预览,无需 Harness。复用共享 Studio、Lua Runtime、固定步长试玩 Worker、Pixi 渲染器及 `editor-ui` 编辑界面。
4
4
 
5
+ ## 0.5.0 更新
6
+
7
+ - 新增中英文界面、顶部语言切换、本地字体设置和更新检查。
8
+ - 字体范围可选「仅游戏内文本」或「整体 UI」,默认整体 UI。
9
+ - 完善键鼠输入、文字排版与缓存,修复部分 GIA 导入和脚本参数校验。
10
+
11
+ 更新或修改字体后,重启 Web 服务并刷新页面。
12
+
5
13
  ## 安装与启动
6
14
 
7
15
  推荐 Node.js 22+。源码开发在仓库根执行:
@@ -54,6 +62,8 @@ MCP 0.3.0 起提供 `qxqy_preview_status()` 和 `qxqy_preview_open({ path: "game
54
62
 
55
63
  源码更新后需运行 `pnpm build` 并重启 Web/MCP;`pnpm start:web` 直接运行已有 `web/dist`,不会自动构建。npm 安装用户更新包后也需重启进程。
56
64
 
65
+ 大工作区可用 `QXQY_SCAN_DIR_LIMIT`、`QXQY_SCAN_ENTRY_LIMIT`、`QXQY_SCAN_DEPTH_LIMIT` 调整自动发现预算;只接受正安全整数,其他值使用上述默认值。每次扫描开始时读取一次,诊断回执返回实际限制。环境变量不会放宽显式文件路径的工作区边界。
66
+
57
67
  ### 2026-09-26 预览同步修复记录
58
68
 
59
69
  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 审核结果为准。
@@ -62,6 +72,8 @@ Web/MCP 0.3.0、共享宿主所在 DSH 2.0.3:修复空工作区启动后跳过
62
72
 
63
73
  试玩使用固定 30 FPS Worker、增量场景和 Pixi/WebGL;刷新试玩页会接回已有 Worker,保留暂停和当前玩家。截图仍由 Host 根据统一场景输出 PNG。
64
74
 
75
+ 试玩工具栏「键位响应」默认开启,按官方文档转发键盘及鼠标左右键的 Down/Up;关闭后光标点击和滚动仍可用,失焦会释放已按键。映射、文档/回传差异及范围见 [浏览器键位响应](../studio/docs/browser-input.md)。
76
+
65
77
  白名单静态图片按需从素材源加载,通过同源 `/qxqy-assets/` 路由显示,浏览器复用 HTTP 缓存,Host 与 MCP 共用磁盘缓存。可用 `QXQY_IMAGE_CACHE_DIR` 指定目录;图片属性面板提供「重新下载此素材」。详见 [素材缓存与预览边界](../studio/docs/image-assets.md)。
66
78
 
67
79
  Lua `game.PlayAudio2D(id)` 触发白名单 MP3 按需缓存与播放,同源路由为 `/qxqy-audio/`,缓存目录可用 `QXQY_AUDIO_CACHE_DIR` 指定。首次自动播放被浏览器阻止时点击「开启声音」;支持停止、试玩暂停/继续和静音。参见 [音频说明](../studio/docs/audio-assets.md)。
@@ -1,4 +1,8 @@
1
- import { PixiPlayRenderer, createPlaySession } from './play-renderer.js'
1
+ import { PixiPlayRenderer, createPlaySession, loadBrowserFont, t, getLocale, localizePage, subscribeLocale, loadSystemLanguage } from './play-renderer.js'
2
+
3
+ await loadSystemLanguage('/editor/api/locale')
4
+ localizePage()
5
+ subscribeLocale(() => { localizePage(); if (model) render() })
2
6
 
3
7
  const byId = (id) => document.getElementById(id)
4
8
 
@@ -16,6 +20,7 @@ const elements = {
16
20
  device: byId('device-select'),
17
21
  playControls: byId('play-controls'),
18
22
  playStart: byId('play-start'),
23
+ playKeys: byId('play-keys'),
19
24
  playAudio: byId('play-audio'),
20
25
  playPause: byId('play-pause'),
21
26
  playResume: byId('play-resume'),
@@ -68,13 +73,13 @@ let toastTimer = null
68
73
  let editorImageRefreshing = false
69
74
  let editorImageRefreshPending = false
70
75
 
71
- const playRenderer = new PixiPlayRenderer(elements.stageCanvas)
76
+ const playRenderer = new PixiPlayRenderer(elements.stageCanvas, { fontReady: loadBrowserFont() })
72
77
 
73
78
  function applyTheme(theme) {
74
79
  const next = theme === 'light' ? 'light' : 'dark'
75
80
  document.documentElement.dataset.theme = next
76
81
  document.querySelector('meta[name="theme-color"]').content = next === 'light' ? '#f5f6f8' : '#20242e'
77
- elements.theme.title = next === 'dark' ? '切换到亮色主题' : '切换到暗色主题'
82
+ elements.theme.title = next === 'dark' ? t('切换到亮色主题') : t('切换到暗色主题')
78
83
  localStorage.setItem('qxqy-simulator-theme', next)
79
84
  }
80
85
 
@@ -115,7 +120,7 @@ function renderArchives() {
115
120
  elements.archive.replaceChildren()
116
121
  const archives = model?.archives || []
117
122
  if (!archives.length && !model?.activePath) {
118
- const empty = option('', '未发现 qxqy-simulator-save')
123
+ const empty = option('', t('未发现 qxqy-simulator-save'))
119
124
  empty.disabled = true
120
125
  elements.archive.append(empty)
121
126
  return
@@ -124,7 +129,7 @@ function renderArchives() {
124
129
  elements.archive.append(option(archive.path, `${archive.name} · ${archive.path}`))
125
130
  }
126
131
  if (model.activePath && !archives.some(archive => archive.path === model.activePath)) {
127
- elements.archive.prepend(option(model.activePath, `${model.snapshot?.save?.name || '当前存档'} · ${model.activePath}`))
132
+ elements.archive.prepend(option(model.activePath, `${model.snapshot?.save?.name || t('当前存档')} · ${model.activePath}`))
128
133
  }
129
134
  elements.archive.value = model.activePath || previous || archives[0].path
130
135
  }
@@ -132,7 +137,7 @@ function renderArchives() {
132
137
  function renderDevices() {
133
138
  const presets = model?.snapshot?.canvas?.presets || []
134
139
  const previous = elements.device.value
135
- elements.device.replaceChildren(...presets.map((preset) => option(preset.id, preset.label)))
140
+ elements.device.replaceChildren(...presets.map((preset) => option(preset.id, t(preset.label))))
136
141
  const active = mode === 'play' && model?.play?.canvasId
137
142
  ? model.play.canvasId
138
143
  : model?.snapshot?.canvas?.id
@@ -143,11 +148,11 @@ function renderTree() {
143
148
  const rows = model?.activePath ? (model?.snapshot?.tree || []) : []
144
149
  elements.tree.replaceChildren()
145
150
  elements.treeCount.textContent = String(rows.length)
146
- elements.treeTitle.textContent = model?.snapshot?.asset?.type === 'client-control-template' ? '模板与子控件' : '控件树'
151
+ elements.treeTitle.textContent = model?.snapshot?.asset?.type === 'client-control-template' ? t('模板与子控件') : t('控件树')
147
152
  if (!rows.length) {
148
153
  const empty = document.createElement('div')
149
154
  empty.className = 'tree-empty'
150
- empty.textContent = model?.activePath ? '当前资产没有控件。' : '打开存档后显示控件结构。'
155
+ empty.textContent = model?.activePath ? t('当前资产没有控件。') : t('打开存档后显示控件结构。')
151
156
  elements.tree.append(empty)
152
157
  return
153
158
  }
@@ -187,18 +192,18 @@ function addDetail(label, value) {
187
192
  function renderNodeDetails() {
188
193
  const row = model?.activePath && model?.snapshot?.tree?.find((item) => item.id === selectedId)
189
194
  elements.nodeDetails.replaceChildren()
190
- elements.nodeName.textContent = row?.name || '未选择'
195
+ elements.nodeName.textContent = row?.name || t('未选择')
191
196
  if (!row) return
192
- addDetail('类型', row.label || row.kind)
197
+ addDetail(t('类型'), t(row.label) || row.kind)
193
198
  addDetail('ID', row.id)
194
199
  addDetail('GUID', row.guid)
195
- addDetail('树深度', row.depth)
196
- addDetail('父节点', row.parentId)
197
- addDetail('子控件', row.childCount)
200
+ addDetail(t('树深度'), row.depth)
201
+ addDetail(t('父节点'), row.parentId)
202
+ addDetail(t('子控件'), row.childCount)
198
203
  }
199
204
 
200
205
  function formatLogs(logs) {
201
- if (!Array.isArray(logs) || !logs.length) return '试玩启动后显示 Lua 日志。'
206
+ if (!Array.isArray(logs) || !logs.length) return t('试玩启动后显示 Lua 日志。')
202
207
  return logs.map((entry) => {
203
208
  if (typeof entry === 'string') return entry
204
209
  const time = entry.time == null ? '' : `[${Number(entry.time).toFixed(3)}] `
@@ -216,26 +221,26 @@ function renderStage() {
216
221
  elements.stageEmpty.hidden = showEditor || showPlay
217
222
  elements.stageShell.classList.toggle('playing', mode === 'play' && Boolean(model?.play?.running))
218
223
  if (!hasArchive) {
219
- elements.stageEmpty.querySelector('strong').textContent = model?.lastError ? '存档加载失败' : '等待工作区存档'
220
- const watched = model?.workspace ? `正在监听 ${model.workspace}。` : ''
224
+ elements.stageEmpty.querySelector('strong').textContent = model?.lastError ? t('存档加载失败') : t('等待工作区存档')
225
+ const watched = model?.workspace ? t`正在监听 ${model.workspace}。` : ''
221
226
  elements.stageEmpty.querySelector('p').textContent = model?.lastError
222
227
  ? model.lastError
223
- : `${watched}Codex 保存 qxqy-simulator-save JSON 后会自动出现在这里。`
228
+ : t`${watched}Codex 保存 qxqy-simulator-save JSON 后会自动出现在这里。`
224
229
  } else if (mode === 'play' && !model.play.running) {
225
- elements.stageEmpty.querySelector('strong').textContent = '试玩尚未启动'
226
- elements.stageEmpty.querySelector('p').textContent = '选择设备并点击“启动”,即可运行 Lua 和交互逻辑。'
230
+ elements.stageEmpty.querySelector('strong').textContent = t('试玩尚未启动')
231
+ elements.stageEmpty.querySelector('p').textContent = t('选择设备并点击“启动”,即可运行 Lua 和交互逻辑。')
227
232
  }
228
233
  if (showEditor) refreshEditorImage()
229
- elements.assetLabel.textContent = hasArchive ? (snapshot?.asset?.label || '等待存档') : '等待存档'
230
- elements.saveName.textContent = hasArchive ? (snapshot?.save?.name || '未命名存档') : '未命名存档'
234
+ elements.assetLabel.textContent = hasArchive ? (snapshot?.asset?.label || t('等待存档')) : t('等待存档')
235
+ elements.saveName.textContent = hasArchive ? (snapshot?.save?.name || t('未命名存档')) : t('未命名存档')
231
236
  elements.canvasLabel.textContent = hasArchive && snapshot?.canvas ? `${snapshot.canvas.label} · ${snapshot.canvas.width}×${snapshot.canvas.height}` : '—'
232
237
  elements.revisionLabel.textContent = hasArchive ? `Revision ${snapshot?.version ?? '—'}` : 'Revision —'
233
- elements.activePath.textContent = model?.activePath || '尚未打开存档'
238
+ elements.activePath.textContent = model?.activePath || t('尚未打开存档')
234
239
  elements.playReadout.textContent = mode === 'play'
235
240
  ? model?.play?.running
236
- ? `Frame ${model.play.frame} · ${model.play.time.toFixed(3)}s${model.play.paused ? ' · 已暂停' : ''}`
237
- : '等待启动 Runtime'
238
- : '编辑器静态预览 · 磁盘自动刷新'
241
+ ? `Frame ${model.play.frame} · ${model.play.time.toFixed(3)}s${model.play.paused ? t(' · 已暂停') : ''}`
242
+ : t('等待启动 Runtime')
243
+ : t('编辑器静态预览 · 磁盘自动刷新')
239
244
  }
240
245
 
241
246
  function refreshEditorImage() {
@@ -263,7 +268,7 @@ function refreshEditorImage() {
263
268
  }
264
269
  image.onerror = () => {
265
270
  elements.stageLoading.hidden = true
266
- notify('编辑器画面加载失败。', true)
271
+ notify(t('编辑器画面加载失败。'), true)
267
272
  finish()
268
273
  }
269
274
  image.src = next
@@ -289,16 +294,16 @@ function updatePlayChrome() {
289
294
  const playState = model?.play
290
295
  elements.playReadout.textContent = mode === 'play'
291
296
  ? playState?.running
292
- ? `Frame ${playState.frame} · ${Number(playState.time || 0).toFixed(3)}s${playState.paused ? ' · 已暂停' : ''}`
293
- : '等待启动 Runtime'
294
- : '编辑器静态预览 · 磁盘自动刷新'
297
+ ? `Frame ${playState.frame} · ${Number(playState.time || 0).toFixed(3)}s${playState.paused ? t(' · 已暂停') : ''}`
298
+ : t('等待启动 Runtime')
299
+ : t('编辑器静态预览 · 磁盘自动刷新')
295
300
  if (mode === 'play') elements.runtimeLogs.textContent = formatLogs(playState?.logs)
296
301
  renderControls()
297
302
  }
298
303
 
299
304
  function syncPlayPolling() {
300
305
  if (mode === 'play' && play.running) play.startPolling()
301
- else play.stopPolling()
306
+ else { void play.releaseKeys(); play.stopPolling() }
302
307
  }
303
308
 
304
309
  async function onPlaySnapshot(next) {
@@ -335,8 +340,8 @@ function render() {
335
340
  elements.metricScripts.textContent = hasArchive ? String(snapshot?.scriptCount || 0) : '0'
336
341
  elements.metricCanvas.textContent = hasArchive && snapshot?.canvas ? `${snapshot.canvas.width}×${snapshot.canvas.height}` : '—'
337
342
  elements.runtimeLogs.textContent = formatLogs(model?.play?.logs)
338
- elements.serverVersion.textContent = model?.version ? `Web ${model.version}` : '服务版本未知,请更新 Web'
339
- elements.workspacePath.textContent = model?.workspace ? `工作区:${model.workspace}` : ''
343
+ elements.serverVersion.textContent = model?.version ? `Web ${model.version}` : t('服务版本未知,请更新 Web')
344
+ elements.workspacePath.textContent = model?.workspace ? t`工作区:${model.workspace}` : ''
340
345
  elements.diagnostics.textContent = [model?.lastError, ...(model?.discovery?.warnings || [])].filter(Boolean).join('\n')
341
346
  elements.diagnostics.hidden = !elements.diagnostics.textContent
342
347
  elements.diagnostics.classList.toggle('error', Boolean(model?.lastError))
@@ -354,10 +359,10 @@ async function refreshState({ quiet = false, force = false, forceDiscovery = fal
354
359
  mode = 'play'
355
360
  await play.attach()
356
361
  }
357
- setConnection('online', '实时监听中')
362
+ setConnection('online', t('实时监听中'))
358
363
  render()
359
364
  } catch (error) {
360
- setConnection('error', '连接中断')
365
+ setConnection('error', t('连接中断'))
361
366
  if (!quiet) notify(error.message, true)
362
367
  }
363
368
  }
@@ -386,12 +391,18 @@ const play = createPlaySession({
386
391
  onStatus: (next) => { applyPlayStatus(next); updatePlayChrome() },
387
392
  onError: (error) => notify(error.message, true),
388
393
  onAudioStatus: status => {
389
- elements.playAudio.textContent = !status.unlocked ? '开启声音' : status.muted ? '声音已关闭' : '声音已开启'
390
- elements.playAudio.title = status.error || (!status.unlocked ? '点击或在画布内操作以解锁音效' : '切换静音')
394
+ elements.playAudio.textContent = !status.unlocked ? t('开启声音') : status.muted ? t('声音已关闭') : t('声音已开启')
395
+ elements.playAudio.title = status.error || (!status.unlocked ? t('点击或在画布内操作以解锁音效') : t('切换静音'))
391
396
  },
392
397
  isActive: () => mode === 'play',
393
398
  })
394
399
  play.bindInput(elements.stageCanvas, { keysOn: elements.stageShell })
400
+ elements.playKeys.addEventListener('click', () => {
401
+ play.setKeyInputEnabled(!play.keyInputEnabled)
402
+ elements.playKeys.setAttribute('aria-pressed', String(play.keyInputEnabled))
403
+ elements.playKeys.textContent = play.keyInputEnabled ? t('键位响应:开') : t('键位响应:关')
404
+ if (play.keyInputEnabled) elements.stageShell.focus()
405
+ })
395
406
  elements.playAudio.addEventListener('click', () => play.audio?.toggle())
396
407
 
397
408
  elements.archive.addEventListener('change', () => run(async () => {
@@ -400,7 +411,7 @@ elements.archive.addEventListener('change', () => run(async () => {
400
411
  mode = 'editor'
401
412
  selectedId = ''
402
413
  render()
403
- notify('已打开存档')
414
+ notify(t('已打开存档'))
404
415
  }))
405
416
 
406
417
  elements.reload.addEventListener('click', () => run(async () => {
@@ -409,7 +420,7 @@ elements.reload.addEventListener('click', () => run(async () => {
409
420
  model = await requestJson('/api/open', { method: 'POST', body: JSON.stringify({ path: model.activePath }) })
410
421
  mode = 'editor'
411
422
  render()
412
- notify('已重新读取磁盘存档')
423
+ notify(t('已重新读取磁盘存档'))
413
424
  } else {
414
425
  await refreshState({ forceDiscovery: true })
415
426
  const newest = model?.archives?.[0]?.path
@@ -418,7 +429,7 @@ elements.reload.addEventListener('click', () => run(async () => {
418
429
  mode = 'editor'
419
430
  selectedId = ''
420
431
  render()
421
- notify('已打开存档')
432
+ notify(t('已打开存档'))
422
433
  }
423
434
  }
424
435
  }))
@@ -469,11 +480,11 @@ elements.playStop.addEventListener('click', () => run(async () => {
469
480
  updatePlayChrome()
470
481
  }))
471
482
 
472
- elements.clearLogs.addEventListener('click', () => { elements.runtimeLogs.textContent = '日志已在当前视图清空。' })
483
+ elements.clearLogs.addEventListener('click', () => { elements.runtimeLogs.textContent = t('日志已在当前视图清空。') })
473
484
 
474
485
  const events = new EventSource('/api/events')
475
- events.addEventListener('open', () => setConnection('online', '实时监听中'))
476
- events.addEventListener('error', () => setConnection('error', '正在重连'))
486
+ events.addEventListener('open', () => setConnection('online', t('实时监听中')))
487
+ events.addEventListener('error', () => setConnection('error', t('正在重连')))
477
488
  events.addEventListener('state', (event) => {
478
489
  let change = {}
479
490
  try { change = JSON.parse(event.data) } catch {}
@@ -481,7 +492,7 @@ events.addEventListener('state', (event) => {
481
492
  mode = 'editor'
482
493
  selectedId = ''
483
494
  play.reset()
484
- notify(change.reason === 'external-change' ? '检测到存档变化,预览已更新' : '预览存档已打开')
495
+ notify(change.reason === 'external-change' ? t('检测到存档变化,预览已更新') : t('预览存档已打开'))
485
496
  }
486
497
  // 试玩画面有独立的轻量状态轮询;忽略 play 事件,避免重复刷新。
487
498
  if (change.type !== 'play') void refreshState({ quiet: true, force: true })
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <title>千星模拟器 · 编辑工作台</title>
6
+ <title data-i18n="千星模拟器 · 编辑工作台">千星模拟器 · 编辑工作台</title>
7
7
  <link rel="stylesheet" href="/editor.css">
8
8
  </head>
9
9
  <body>