beyond-simulator-web 0.4.1 → 0.5.1

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.
@@ -3,24 +3,24 @@
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/play.css">
8
8
  </head>
9
- <body>
9
+ <body data-qxqy-font-ui>
10
10
  <div class="app">
11
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="audio" data-audio-toggle title="点击解锁浏览器音效">开启声音</button><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>
12
+ <div class="brand"><span class="icon">UI</span><strong data-i18n="模拟器">模拟器</strong><select id="deviceSel" class="devsel hidden" aria-label="切换设备画布" data-i18n-aria-label="切换设备画布" title="切换设备画布:以新画布重建运行时,GetUICanvasSize 与预设像素一致" data-i18n-title="切换设备画布:以新画布重建运行时,GetUICanvasSize 与预设像素一致"></select><select id="playerCountSel" class="playersel hidden" aria-label="试玩人数" data-i18n-aria-label="试玩人数" title="试玩人数:最多 8 人,每人一份独立客户端" data-i18n-title="试玩人数:最多 8 人,每人一份独立客户端"></select><select id="playerViewSel" class="viewsel hidden" aria-label="当前玩家视角" data-i18n-aria-label="当前玩家视角" title="切换当前玩家视角:输入、画面和 PlayerSelf 都跟随该玩家" data-i18n-title="切换当前玩家视角:输入、画面和 PlayerSelf 都跟随该玩家"></select><span class="device"><span data-i18n="设备">设备 </span><b id="resolution" data-i18n="准备中…">准备中…</b></span></div>
13
+ <div class="view-tabs" role="tablist" aria-label="试玩页面" data-i18n-aria-label="试玩页面"><button class="active" data-view="play" role="tab" data-i18n="试玩">试玩</button><button data-view="log" role="tab" data-i18n="日志">日志</button></div>
14
+ <div class="actions"><button id="keyInput" data-key-input-toggle aria-pressed="true" title="转发官方键位的按下/抬起事件(含鼠标左右键);关闭不影响画布点击" data-i18n-title="转发官方键位的按下/抬起事件(含鼠标左右键);关闭不影响画布点击" data-i18n="键位响应:开">键位响应:开</button><button id="audio" data-audio-toggle title="点击解锁浏览器音效" data-i18n-title="点击解锁浏览器音效" data-i18n="开启声音">开启声音</button><button id="pause" data-i18n="Ⅱ 暂停运行">Ⅱ 暂停运行</button><button id="step" disabled title="暂停后可单帧调试" data-i18n-title="暂停后可单帧调试" data-i18n="▷ 单帧调试">▷ 单帧调试</button><button class="stop" id="stop" data-i18n="■ 结束试玩">■ 结束试玩</button><button class="primary hidden" id="restart" data-i18n="↻ 重新开始">↻ 重新开始</button><button id="theme" title="切换亮暗主题" data-i18n-title="切换亮暗主题">◐</button><button id="close" data-i18n="返回编辑">返回编辑</button></div>
15
15
  </header>
16
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>
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><span data-i18n="正在准备试玩…">正在准备试玩…</span></div><div class="fatal hidden" id="fatal"></div></main>
18
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>
19
+ <aside class="panel tree-panel"><div class="panel-head"><span data-i18n="运行时场景树">运行时场景树 </span><small id="nodeCount" data-i18n="0 节点">0 节点</small></div><div class="tree" id="sceneTree"></div><div class="panel-head"><span data-i18n="服务端变量">服务端变量 </span><small id="varCount" data-i18n="0 项">0 项</small></div><div class="vars" id="serverVars"></div></aside>
20
+ <div class="log-stack"><main class="panel log-panel"><div class="panel-head"><span data-i18n="客户端脚本日志">客户端脚本日志 </span><small data-i18n="最近 100 条">最近 100 条</small></div><div class="log" id="log"></div></main><aside class="panel log-panel"><div class="panel-head"><span data-i18n="服务端日志">服务端日志 </span><small data-i18n="变量 / 信号">变量 / 信号</small></div><div class="log" id="serverLog"></div></aside><section class="panel case-panel"><div class="panel-head"><span data-i18n="自动测用例">自动测用例 </span><small id="caseHint" data-i18n="先试玩,再保存回放">先试玩,再保存回放</small></div><div class="case-body"><textarea id="caseAsserts" spellcheck="false" placeholder='断言 JSON 数组,例如 [{"kind":"log","contains":"clicked"}]' data-i18n-placeholder="断言 JSON 数组,例如 [{&quot;kind&quot;:&quot;log&quot;,&quot;contains&quot;:&quot;clicked&quot;}]"></textarea><button id="saveCase" data-i18n="保存用例">保存用例</button><button id="runCase" data-i18n="回放用例">回放用例</button></div></section></div>
21
21
  </section>
22
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>
23
+ <footer class="status"><span class="live" id="state" data-i18n="● 正在启动">● 正在启动</span><span id="viewMode" data-i18n="页面:试玩">页面:试玩</span><span class="spacer"></span><span class="metric" id="frame">Frame 0</span><span class="metric" id="time">0.00s</span></footer>
24
24
  </div>
25
25
  <script type="module" src="/editor/play.js"></script>
26
26
  </body>
@@ -1,8 +1,13 @@
1
1
 
2
- import { PixiPlayRenderer, createPlaySession } from '/editor/play-renderer.js'
2
+ import { PixiPlayRenderer, createPlaySession, loadBrowserFont, t, getLocale, localizePage, subscribeLocale, loadSystemLanguage } from '/editor/play-renderer.js'
3
3
 
4
4
  (() => {
5
5
  'use strict'
6
+ // The parent reads qxqyEmbeddedPlay synchronously on iframe load. Do not
7
+ // delay that controller's registration behind an OS-language request.
8
+ void loadSystemLanguage('/editor/api/locale')
9
+ localizePage()
10
+ subscribeLocale(() => { localizePage(); if (play.snapshot) applyStatus(play.snapshot) })
6
11
  const API = '/editor/api/play'
7
12
  const sessionId = decodeURIComponent(location.hash.slice(1))
8
13
  const embedded = window.parent !== window && new URLSearchParams(location.search).get('embedded') === '1'
@@ -12,7 +17,7 @@
12
17
  const $ = (id) => document.getElementById(id)
13
18
  const stage = $('stage')
14
19
  const cv = $('cv')
15
- const renderer = new PixiPlayRenderer(cv)
20
+ const renderer = new PixiPlayRenderer(cv, { fontReady: loadBrowserFont() })
16
21
  const shell = $('shell')
17
22
  const workspace = $('workspace')
18
23
  const logView = $('logView')
@@ -24,15 +29,16 @@
24
29
 
25
30
  function setView(view) {
26
31
  const showLog = view === 'log'
32
+ if (showLog) void play.releaseKeys()
27
33
  workspace.classList.toggle('hidden', showLog)
28
34
  logView.classList.toggle('hidden', !showLog)
29
- $('viewMode').textContent = showLog ? '页面:日志' : '页面:试玩'
35
+ $('viewMode').textContent = showLog ? t('页面:日志') : t('页面:试玩')
30
36
  for (const button of document.querySelectorAll('[data-view]')) button.classList.toggle('active', button.dataset.view === view)
31
37
  if (!showLog) requestAnimationFrame(() => fitStage(true))
32
38
  }
33
39
 
34
40
  function api(action, args = {}) {
35
- if (closing && action !== 'stop') return Promise.reject(new Error('试玩正在关闭'))
41
+ if (closing && action !== 'stop') return Promise.reject(new Error(t('试玩正在关闭')))
36
42
  return embeddedApi ? embeddedApi(action, args) : request(action, args)
37
43
  }
38
44
  async function request(action, args) {
@@ -43,7 +49,7 @@
43
49
  signal: AbortSignal.timeout(15_000),
44
50
  })
45
51
  const envelope = await response.json().catch(() => null)
46
- if (!response.ok || !envelope || envelope.ok !== true) throw new Error(envelope?.error || '试玩请求失败')
52
+ if (!response.ok || !envelope || envelope.ok !== true) throw new Error(envelope?.error || t('试玩请求失败'))
47
53
  return envelope.value
48
54
  }
49
55
 
@@ -51,11 +57,11 @@
51
57
  const host = $('serverVars')
52
58
  host.replaceChildren()
53
59
  const list = Array.isArray(rows) ? rows : []
54
- $('varCount').textContent = list.length + ' 项'
60
+ $('varCount').textContent = list.length + t(' 项')
55
61
  if (!list.length) {
56
62
  const empty = document.createElement('div')
57
63
  empty.className = 'var-empty'
58
- empty.textContent = '暂无自定义变量。脚本 Get/Set 或 serverSet 之后会出现在这里。'
64
+ empty.textContent = t('暂无自定义变量。脚本 Get/Set 或 serverSet 之后会出现在这里。')
59
65
  host.appendChild(empty)
60
66
  return
61
67
  }
@@ -65,7 +71,7 @@
65
71
  const type = row.type || '—'
66
72
  const value = row.value === undefined ? 'nil' : JSON.stringify(row.value)
67
73
  const entityCell = document.createElement('span')
68
- entityCell.textContent = row.entityLabel || row.entityType || ''
74
+ entityCell.textContent = t(row.entityLabel) || row.entityType || ''
69
75
  const nameCell = document.createElement('b')
70
76
  nameCell.textContent = row.name || ''
71
77
  const typeCell = document.createElement('span')
@@ -82,9 +88,9 @@
82
88
  host.replaceChildren()
83
89
  const empty = document.createElement('div')
84
90
  empty.className = 'var-empty'
85
- empty.textContent = '完整运行树请用 Agent 的 inspect 快照。舞台按左树上方盖住下方绘制。'
91
+ empty.textContent = t('完整运行树请用 Agent 的 inspect 快照。舞台按左树上方盖住下方绘制。')
86
92
  host.appendChild(empty)
87
- $('nodeCount').textContent = (snapshot?.scene?.count || snapshot?.paint?.length || 0) + ' 个可见图元'
93
+ $('nodeCount').textContent = (snapshot?.scene?.count || snapshot?.paint?.length || 0) + t(' 个可见图元')
88
94
  }
89
95
 
90
96
  function fitStage(force = false) {
@@ -111,7 +117,7 @@
111
117
  function applyStatus(next) {
112
118
  $('frame').textContent = 'Frame ' + (next.frame || 0)
113
119
  $('time').textContent = Number(next.time || 0).toFixed(2) + 's'
114
- $('pause').textContent = next.paused ? '▶ 继续运行' : 'Ⅱ 暂停运行'
120
+ $('pause').textContent = next.paused ? t('▶ 继续运行') : t('Ⅱ 暂停运行')
115
121
  $('pause').disabled = false
116
122
  $('step').disabled = !next.paused
117
123
  }
@@ -124,15 +130,16 @@
124
130
  sel.classList.add('hidden')
125
131
  return
126
132
  }
127
- const stale = sel.options.length !== presets.length
133
+ const stale = sel.dataset.locale !== getLocale() || sel.options.length !== presets.length
128
134
  || [...sel.options].some((opt, i) => opt.value !== presets[i].id)
129
135
  if (stale) {
130
136
  sel.replaceChildren(...presets.map((preset) => {
131
137
  const opt = document.createElement('option')
132
138
  opt.value = preset.id
133
- opt.textContent = preset.label || (preset.id + ' · ' + preset.width + '×' + preset.height)
139
+ opt.textContent = t(preset.label) || (preset.id + ' · ' + preset.width + '×' + preset.height)
134
140
  return opt
135
141
  }))
142
+ sel.dataset.locale = getLocale()
136
143
  }
137
144
  if (sel.value !== next.canvasId) sel.value = next.canvasId
138
145
  sel.classList.remove('hidden')
@@ -140,13 +147,14 @@
140
147
 
141
148
  function fillCountSelect(sel, count) {
142
149
  const n = Math.max(1, Math.min(8, Number(count) || 1))
143
- if (sel.options.length !== 8) {
150
+ if (sel.options.length !== 8 || sel.dataset.locale !== getLocale()) {
144
151
  sel.replaceChildren(...Array.from({ length: 8 }, (_, i) => {
145
152
  const opt = document.createElement('option')
146
153
  opt.value = String(i + 1)
147
- opt.textContent = (i + 1) + ' 人'
154
+ opt.textContent = i === 0 ? t('1 人') : t('{0} 人', i + 1)
148
155
  return opt
149
156
  }))
157
+ sel.dataset.locale = getLocale()
150
158
  }
151
159
  sel.value = String(n)
152
160
  sel.classList.remove('hidden')
@@ -159,16 +167,17 @@
159
167
  const count = fillCountSelect(countSel, next.playerCount || 1)
160
168
  const players = Array.isArray(next.players) && next.players.length
161
169
  ? next.players
162
- : Array.from({ length: count }, (_, i) => ({ index: i + 1, label: '玩家' + (i + 1) }))
163
- const stale = viewSel.options.length !== players.length
170
+ : Array.from({ length: count }, (_, i) => ({ index: i + 1, label: t('玩家') + ' ' + (i + 1) }))
171
+ const stale = viewSel.dataset.locale !== getLocale() || viewSel.options.length !== players.length
164
172
  || [...viewSel.options].some((opt, i) => Number(opt.value) !== players[i].index)
165
173
  if (stale) {
166
174
  viewSel.replaceChildren(...players.map((player) => {
167
175
  const opt = document.createElement('option')
168
176
  opt.value = String(player.index)
169
- opt.textContent = player.label || ('玩家' + player.index)
177
+ opt.textContent = t(player.label) || (t('玩家') + ' ' + player.index)
170
178
  return opt
171
179
  }))
180
+ viewSel.dataset.locale = getLocale()
172
181
  }
173
182
  const view = Number(next.viewPlayerIndex) || 1
174
183
  if (viewSel.value !== String(view)) viewSel.value = String(view)
@@ -181,18 +190,18 @@
181
190
  syncDeviceSelect(next)
182
191
  syncPlayerSelects(next)
183
192
  $('resolution').textContent = next.canvasWidth + '×' + next.canvasHeight
184
- const viewLabel = next.playerCount > 1 ? (' · 玩家' + (next.viewPlayerIndex || 1)) : ''
185
- $('stageLabel').textContent = next.canvasWidth + '×' + next.canvasHeight + viewLabel + ' · 点击输入'
193
+ const viewLabel = next.playerCount > 1 ? (t(' · 玩家') + (next.viewPlayerIndex || 1)) : ''
194
+ $('stageLabel').textContent = next.canvasWidth + '×' + next.canvasHeight + viewLabel + t(' · 点击输入')
186
195
  applyStatus(next)
187
196
  $('stop').disabled = false
188
197
  $('restart').classList.add('hidden')
189
- $('state').textContent = next.mountError ? '⚠ 脚本挂载失败' : next.paused ? 'Ⅱ 已暂停' : '● 试玩运行中'
198
+ $('state').textContent = next.mountError ? t('⚠ 脚本挂载失败') : next.paused ? t('Ⅱ 已暂停') : t('● 试玩运行中')
190
199
  $('state').className = next.mountError ? 'error' : 'live'
191
200
  renderTree(next)
192
201
  renderVars(next.serverVars)
193
202
  $('log').textContent = (next.logs || []).slice(-100).map((entry) => Number(entry.time || 0).toFixed(2).padStart(7, ' ') + ' [' + entry.level + '] ' + entry.text).join('\n')
194
203
  $('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')
195
- $('caseHint').textContent = (next.history && next.history.length ? ('已记录 ' + next.history.length + ' 步') : '先试玩,再保存回放')
204
+ $('caseHint').textContent = (next.history && next.history.length ? (t('已记录 ') + next.history.length + t(' 步')) : t('先试玩,再保存回放'))
196
205
  if (next.mountError) setView('log')
197
206
  }
198
207
 
@@ -200,9 +209,9 @@
200
209
  if (closing) return
201
210
  $('loading').classList.add('hidden')
202
211
  $('fatal').classList.remove('hidden')
203
- $('fatal').textContent = error?.message || String(error)
212
+ $('fatal').textContent = t(error?.message || String(error))
204
213
  $('state').className = 'error'
205
- $('state').textContent = '⚠ 试玩错误'
214
+ $('state').textContent = t('⚠ 试玩错误')
206
215
  }
207
216
 
208
217
  const play = createPlaySession({
@@ -212,12 +221,19 @@
212
221
  onStatus: applyStatus,
213
222
  onError: showError,
214
223
  onAudioStatus: status => {
215
- $('audio').textContent = !status.unlocked ? '开启声音' : status.muted ? '声音已关闭' : '声音已开启'
216
- $('audio').title = status.error || (!status.unlocked ? '点击或在画布内操作以解锁音效' : '切换静音')
224
+ $('audio').textContent = !status.unlocked ? t('开启声音') : status.muted ? t('声音已关闭') : t('声音已开启')
225
+ $('audio').title = status.error || (!status.unlocked ? t('点击或在画布内操作以解锁音效') : t('切换静音'))
217
226
  },
218
227
  isActive: () => !closing,
228
+ isInputActive: () => !workspace.classList.contains('hidden'),
219
229
  })
220
230
  play.bindInput(stage, { keysOn: window })
231
+ $('keyInput').addEventListener('click', () => {
232
+ play.setKeyInputEnabled(!play.keyInputEnabled)
233
+ $('keyInput').setAttribute('aria-pressed', String(play.keyInputEnabled))
234
+ $('keyInput').textContent = play.keyInputEnabled ? t('键位响应:开') : t('键位响应:关')
235
+ if (play.keyInputEnabled) focusStage()
236
+ })
221
237
  $('audio').addEventListener('click', () => play.audio?.toggle())
222
238
 
223
239
  $('step').addEventListener('click', () => void play.act('step', { dt: 1 / 30 }))
@@ -225,7 +241,7 @@
225
241
  $('stop').addEventListener('click', async () => {
226
242
  if (!play.running) return
227
243
  try { await play.stop() } catch (error) { showError(error); return }
228
- $('state').textContent = '■ 试玩已结束,可重新开始或返回编辑'
244
+ $('state').textContent = t('■ 试玩已结束,可重新开始或返回编辑')
229
245
  $('state').className = ''
230
246
  $('pause').disabled = true
231
247
  $('step').disabled = true
@@ -319,20 +335,20 @@
319
335
  const text = $('caseAsserts').value.trim()
320
336
  if (!text) return []
321
337
  const parsed = JSON.parse(text)
322
- if (!Array.isArray(parsed)) throw new Error('断言必须是 JSON 数组')
338
+ if (!Array.isArray(parsed)) throw new Error(t('断言必须是 JSON 数组'))
323
339
  return parsed
324
340
  }
325
341
  $('saveCase').addEventListener('click', async () => {
326
342
  try {
327
343
  lastCase = await api('saveCase', { name: 'play-case', asserts: parseAsserts() })
328
- $('caseHint').textContent = '已保存 ' + (lastCase.events || []).length + ' 步 / ' + (lastCase.asserts || []).length + ' 条断言'
344
+ $('caseHint').textContent = t('已保存 ') + (lastCase.events || []).length + t(' 步 / ') + (lastCase.asserts || []).length + t(' 条断言')
329
345
  } catch (error) { showError(error) }
330
346
  })
331
347
  $('runCase').addEventListener('click', async () => {
332
348
  try {
333
349
  if (!lastCase) lastCase = await api('saveCase', { name: 'play-case', asserts: parseAsserts() })
334
350
  const report = await api('runCase', { case: lastCase })
335
- $('caseHint').textContent = report.passed ? '回放通过' : ('回放失败 @' + (report.failedAt ?? '?') + 's')
351
+ $('caseHint').textContent = report.passed ? t('回放通过') : (t('回放失败 @') + (report.failedAt ?? '?') + 's')
336
352
  if (!report.passed) setView('log')
337
353
  } catch (error) { showError(error) }
338
354
  })
@@ -356,7 +372,7 @@
356
372
  addEventListener('pagehide', () => parent.removeEventListener('pagehide', parentPagehide))
357
373
  }
358
374
 
359
- if (!sessionId) showError(new Error('缺少会话 ID'))
375
+ if (!sessionId) showError(new Error(t('缺少会话 ID')))
360
376
  else if (embedded) {
361
377
  // Wait for the parent's load handler: closing a still-loading iframe
362
378
  // must not race an automatic start from this document.