dsh-plugin-vrmpet3d 0.1.0 → 0.1.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,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>vrmpet3d · 切换模型</title>
6
+ <title data-i18n="title">vrmpet3d · 切换模型</title>
7
7
  <meta name="color-scheme" content="dark light">
8
8
  <!--
9
9
  切换模型 — the model picker's own page (`/vrmpet3d/picker`), opened from either pet's right-click menu.
@@ -21,15 +21,21 @@
21
21
  <link rel="stylesheet" href="/vrmpet3d/window/lib/pages.css">
22
22
  </head>
23
23
  <body data-page="picker">
24
+ <!--
25
+ The page's own five pieces of text carry `data-i18n` and keep the Chinese inline: `lib/picker-page.js`
26
+ resolves the language and calls `core.applyI18n`, and a page whose script never ran still reads in one
27
+ language rather than showing empty nodes. `html lang` is that same Chinese default, and the script rewrites it
28
+ once it knows better — it is NOT what the language is resolved from (see `core.browserLanguageHints`).
29
+ -->
24
30
  <header class="page-header">
25
- <div class="eyebrow">VRMPET3D · MODELS</div>
26
- <h1>切换模型</h1>
27
- <div id="status" role="status" aria-live="polite">正在读取…</div>
31
+ <div class="eyebrow" data-i18n="eyebrow">VRMPET3D · MODELS</div>
32
+ <h1 data-i18n="heading">切换模型</h1>
33
+ <div id="status" role="status" aria-live="polite" data-i18n="statusLoading">正在读取…</div>
28
34
  </header>
29
35
 
30
36
  <main>
31
37
  <section class="settings-section" id="settings-0">
32
- <h2>模型</h2>
38
+ <h2 data-i18n="sectionModels">模型</h2>
33
39
  <div class="card" id="picker-mount"></div>
34
40
  </section>
35
41
  </main>
@@ -40,20 +46,40 @@
40
46
  -->
41
47
  <script>
42
48
  (function () {
49
+ /**
50
+ * The watchdog's OWN words, in its own two languages, because it runs BEFORE core.js and can use neither
51
+ * `core.textFor` nor the settings store. `navigator.language` is the whole of its rule: the document's own
52
+ * `lang` is the Chinese default every page here ships, so reading it would only ever say "Chinese".
53
+ */
54
+ var WORDS = {
55
+ zh: {
56
+ pageError: '页面脚本出错:',
57
+ stuckMissing: '初始化卡住:model-picker.js 没有加载(检查 /vrmpet3d/window/lib/model-picker.js 是否 404)。',
58
+ stuckSlow: '初始化卡住:脚本已加载但没有完成初始化(看看窗口的控制台,或宿主是否提供 /vrmpet3d/models 与 /vrmpet3d/settings)。',
59
+ },
60
+ en: {
61
+ pageError: 'Script error: ',
62
+ stuckMissing: 'Stuck on startup: model-picker.js did not load — check whether /vrmpet3d/window/lib/model-picker.js is a 404.',
63
+ stuckSlow: 'Stuck on startup: the script loaded but never finished — check the window console, and whether the host serves /vrmpet3d/models and /vrmpet3d/settings.',
64
+ },
65
+ };
66
+ var language = /^(zh|cn|chinese)/i.test(navigator.language || '') ? 'zh' : 'en';
67
+ function t(key) { return WORDS[language][key]; }
43
68
  function show(message) {
44
69
  var status = document.getElementById("status");
45
70
  if (status) { status.textContent = message; status.className = "warn"; }
46
71
  }
47
72
  window.addEventListener("error", function (event) {
48
- show("页面脚本出错:" + (event.message || event.type) + (event.filename ? " (" + event.filename + ")" : ""));
73
+ var where = event.filename ? (language === 'zh' ? ' (' + event.filename + ')' : ' (' + event.filename + ')') : '';
74
+ show(t('pageError') + (event.message || event.type) + where);
49
75
  }, true);
50
76
  window.setTimeout(function () {
51
77
  if (window.__VRMPET3D_PICKER_STATE__) return;
52
78
  if (!window.__VRMPET3D_MODEL_PICKER__) {
53
- show("初始化卡住:model-picker.js 没有加载(检查 /vrmpet3d/window/lib/model-picker.js 是否 404)。");
79
+ show(t('stuckMissing'));
54
80
  return;
55
81
  }
56
- show("初始化卡住:脚本已加载但没有完成初始化(看看窗口的控制台,或宿主是否提供 /vrmpet3d/models 与 /vrmpet3d/settings)。");
82
+ show(t('stuckSlow'));
57
83
  }, 2000);
58
84
  })();
59
85
  </script>
@@ -11,6 +11,12 @@
11
11
  * (`hasSnapshot` in `lib/config-page.js`): nothing is written before the first read, or a page that booted into
12
12
  * defaults would overwrite everything the user configured.
13
13
  *
14
+ * AND THE LANGUAGE IS THE SECOND THING THIS PAGE OWNS. The two settings pages pick theirs when they boot, and so
15
+ * does the guide; this page can be sitting open while the user changes 界面语言 in 通用设置, because the setting
16
+ * lives in the same store the page already polls. So every snapshot re-resolves it (`resolveLanguage`), the
17
+ * page's own lines are written in it (`t`), and the mounted component is RE-LABELLED rather than rebuilt — see
18
+ * `applyLanguage` for why that is not just an optimisation.
19
+ *
14
20
  * For the browser probe and the launcher's self-test: `window.__VRMPET3D_PICKER_STATE__`.
15
21
  */
16
22
  (function () {
@@ -19,6 +25,46 @@
19
25
  var core = globalThis.__VRMPET3D_CORE__;
20
26
  var pickerApi = globalThis.__VRMPET3D_MODEL_PICKER__;
21
27
 
28
+ /**
29
+ * This page's own words, in both languages — `{ zh, en }`, the shape `core.textFor` and `core.applyI18n` take.
30
+ *
31
+ * The first five are the ones the MARKUP also carries: `lib/picker-page.html` marks its title, eyebrow, heading,
32
+ * status placeholder and section heading with `data-i18n`, and keeps the Chinese inline, so a page whose script
33
+ * never ran still says something in one language rather than showing nothing. The rest exist only here, because
34
+ * they are the lines THIS SCRIPT writes (the six status/error lines), and there is no markup to fall back to.
35
+ *
36
+ * THE TWO HALVES MUST HOLD THE SAME KEYS: a key the English half lacks falls back to Chinese mid-page
37
+ * (`core.textFor`), which is the half-translated interface this table exists to prevent.
38
+ */
39
+ var TEXT = {
40
+ zh: {
41
+ title: 'vrmpet3d · 切换模型',
42
+ eyebrow: 'VRMPET3D · MODELS',
43
+ heading: '切换模型',
44
+ statusLoading: '正在读取…',
45
+ sectionModels: '模型',
46
+ reading: '正在读取模型列表…',
47
+ settingsRead: '已读取当前设置。',
48
+ noHostSync: '这个宿主没有提供设置接口,切换只在本页生效。',
49
+ noCoreSettings: '这个版本的 core 没有设置接口,页面只能展示默认值。',
50
+ missingPicker: '页面脚本缺失:model-picker.js 没有加载。',
51
+ missingMount: '页面结构缺失:找不到 #picker-mount。',
52
+ },
53
+ en: {
54
+ title: 'vrmpet3d · Switch model',
55
+ eyebrow: 'VRMPET3D · MODELS',
56
+ heading: 'Switch model',
57
+ statusLoading: 'Reading…',
58
+ sectionModels: 'Model',
59
+ reading: 'Reading the model list…',
60
+ settingsRead: 'Settings loaded.',
61
+ noHostSync: 'This host has no settings API, so a switch only affects this page.',
62
+ noCoreSettings: 'This build of core has no settings API, so the page can only show defaults.',
63
+ missingPicker: 'Page script missing: model-picker.js did not load.',
64
+ missingMount: 'Page markup missing: #picker-mount was not found.',
65
+ },
66
+ };
67
+
22
68
  /** The first host snapshot is the permission to write. */
23
69
  var hasSnapshot = false;
24
70
  var preferences = core ? core.cleanPreferences({}) : {};
@@ -26,14 +72,67 @@
26
72
  var picker = null;
27
73
  /** Which model a SURFACE reports it is showing (`snapshot.live`), which is what ends the progress bar. */
28
74
  var liveModel = null;
75
+ /**
76
+ * The language this page draws in — `'zh'` or `'en'`, never `'auto'` (see `resolveLanguage`).
77
+ *
78
+ * `null` until the first resolution, which is what makes the boot pass the one that fills the markup in.
79
+ */
80
+ var language = null;
29
81
 
30
82
  function el(id) { return document.getElementById(id); }
31
83
 
32
- function setStatus(text, kind) {
84
+ /**
85
+ * The line on `#status` right now: `{key}` when it is one of THIS page's own, `{text}` when the picker or the
86
+ * bootstrap watchdog wrote it, and `null` before anything has been written.
87
+ *
88
+ * The distinction is what a language change needs. One of this page's lines can be said again in the new
89
+ * language because it is stored as a KEY; a finished sentence from the component cannot be re-derived, so it is
90
+ * left exactly as it is rather than replaced by a translation of the placeholder — which would read as "reading
91
+ * the model list" while a model is in fact still loading.
92
+ */
93
+ var statusLine = null;
94
+ /** What this page last painted, so a line written by somebody ELSE (the watchdog) can be told apart from it. */
95
+ var paintedStatus = null;
96
+
97
+ function paintStatus() {
33
98
  var node = el('status');
34
- if (!node) return;
35
- node.textContent = text || '';
36
- node.className = kind === 'warn' ? 'warn' : '';
99
+ if (!node || !statusLine) return;
100
+ node.textContent = statusLine.key ? t(statusLine.key) : statusLine.text;
101
+ node.className = statusLine.kind === 'warn' ? 'warn' : '';
102
+ paintedStatus = node.textContent;
103
+ }
104
+
105
+ /** A line this page wrote, as the key it came from — so a later language change can write it again. */
106
+ function setStatusKey(key, kind) {
107
+ statusLine = { key: key, kind: kind };
108
+ paintStatus();
109
+ }
110
+
111
+ /** A finished line, from the picker's `onStatus`. */
112
+ function setStatus(message, kind) {
113
+ statusLine = { text: message || '', kind: kind };
114
+ paintStatus();
115
+ }
116
+
117
+ /** The markup's own placeholder — the one line in `#status` that a translation is allowed to replace. */
118
+ function isPlaceholder(text) {
119
+ return text === TEXT.zh.statusLoading || text === TEXT.en.statusLoading;
120
+ }
121
+
122
+ /**
123
+ * One of THIS page's own lines, in the language on screen.
124
+ *
125
+ * `core.textFor` when core is there — so the lookup rule (and the fallback to the other language for a gap) is
126
+ * the shared one rather than a second implementation of it — and the table itself when core is old enough not to
127
+ * have it. A key with no entry shows as the KEY rather than as a blank line, which is how a missing entry gets
128
+ * reported instead of silently disappearing.
129
+ */
130
+ function t(key) {
131
+ var words = core && typeof core.textFor === 'function'
132
+ ? core.textFor(TEXT, language)
133
+ : (language === 'en' ? TEXT.en : TEXT.zh);
134
+ var value = words[key];
135
+ return typeof value === 'string' ? value : key;
37
136
  }
38
137
 
39
138
  /**
@@ -57,10 +156,80 @@
57
156
  return typeof preferences.modelId === 'string' ? preferences.modelId : '';
58
157
  }
59
158
 
159
+ /**
160
+ * Which language this page draws in, from the USER's setting and this surface's own environment.
161
+ *
162
+ * `preferences.language` is the store's answer — `'auto' | 'zh' | 'en'` (`core.LANGUAGE_SETTINGS`), which
163
+ * `core.cleanPreferences` passes through — so a choice made in 通用设置, in the guide or by another window
164
+ * reaches this page on its next snapshot. `'auto'` falls to `core.browserLanguageHints()`, which asks the
165
+ * browser BEFORE the document's own `lang`: every page in this plugin ships `<html lang="zh">`, so a page that
166
+ * read its own markup first could never auto-detect English.
167
+ *
168
+ * A core too old to know about languages keeps this page's own shipped answer, `'zh'` — the value this page
169
+ * passed unconditionally before it could resolve one, so an old host degrades to the old behaviour rather than
170
+ * failing to boot.
171
+ */
172
+ function resolveLanguage() {
173
+ if (core && typeof core.resolveLanguage === 'function') {
174
+ var hints = typeof core.browserLanguageHints === 'function' ? core.browserLanguageHints() : null;
175
+ return core.resolveLanguage(preferences.language, hints);
176
+ }
177
+ return 'zh';
178
+ }
179
+
180
+ /**
181
+ * Put the resolved language everywhere it is needed: the marked-up text, `html lang`, this page's own lines
182
+ * (through `t` at the point of use) and the mounted component.
183
+ *
184
+ * The component is RE-LABELLED, not rebuilt, and that is a real distinction rather than tidiness: a rebuild
185
+ * throws away a switch's progress bar, the tile it is pending on and the catalogue the grid already has — and a
186
+ * language change is exactly when a switch may be in flight, because the user has just come back from 通用设置
187
+ * to look at the window. `setLanguage` is the component's documented path for this; a component old enough not
188
+ * to have it keeps the language it was built with rather than being destroyed, so a language change can never
189
+ * cost the user the click they just made.
190
+ *
191
+ * The bootstrap watchdog writes into `#status` too, and it is the FIRST script on the page — it reports a
192
+ * core.js that threw before this script ran. `core.applyI18n` fills every marked node including that one, so a
193
+ * line the watchdog has already written is put back; the placeholder the markup ships is the only text the
194
+ * translation is allowed to replace.
195
+ *
196
+ * @returns {boolean} whether the language changed — false on the 700 ms polls that change nothing
197
+ */
198
+ function applyLanguage() {
199
+ var next = resolveLanguage();
200
+ if (next === language) return false;
201
+ language = next;
202
+ /**
203
+ * What is on the status line right now, if this page did NOT put it there.
204
+ *
205
+ * The bootstrap watchdog is the first script on the page and it writes into `#status` itself — it reports a
206
+ * core.js that threw while this script was still loading. `core.applyI18n` fills every marked node, that one
207
+ * included, so a line written by somebody else is captured here and put back: the placeholder the markup ships
208
+ * is the only text a translation may replace.
209
+ */
210
+ var status = el('status');
211
+ var foreign = status && !isPlaceholder(status.textContent) && status.textContent !== paintedStatus
212
+ ? status.textContent
213
+ : null;
214
+ if (core && typeof core.applyI18n === 'function') core.applyI18n(document, TEXT, language);
215
+ if (foreign !== null) {
216
+ status.textContent = foreign;
217
+ status.className = 'warn';
218
+ } else {
219
+ // One of this page's own lines — or the placeholder, which `applyI18n` has just translated.
220
+ paintStatus();
221
+ }
222
+ // The document says which language it is in, which is what a screen reader, a spell checker and a hyphenator
223
+ // read it for — and it is NOT the hint this page resolves from (that one was read before this line).
224
+ if (document.documentElement) document.documentElement.lang = language;
225
+ if (picker && typeof picker.setLanguage === 'function') picker.setLanguage(language);
226
+ return true;
227
+ }
228
+
60
229
  function patchPreferences(patch) {
61
230
  Object.keys(patch).forEach(function (key) { preferences[key] = patch[key]; });
62
231
  if (!hasSnapshot || !hostSync || typeof hostSync.push !== 'function') {
63
- setStatus('这个宿主没有提供设置接口,切换只在本页生效。', 'warn');
232
+ setStatusKey('noHostSync', 'warn');
64
233
  return;
65
234
  }
66
235
  hostSync.push({ preferences: patch });
@@ -79,7 +248,7 @@
79
248
 
80
249
  function watchSettings() {
81
250
  if (!core || typeof core.watchHostSettings !== 'function') {
82
- setStatus('这个版本的 core 没有设置接口,页面只能展示默认值。', 'warn');
251
+ setStatusKey('noCoreSettings', 'warn');
83
252
  return;
84
253
  }
85
254
  hostSync = core.watchHostSettings({
@@ -96,6 +265,10 @@
96
265
  onSnapshot: function (snapshot) {
97
266
  preferences = core.cleanPreferences(snapshot.preferences || {});
98
267
  hasSnapshot = true;
268
+ // The store's 界面语言 may have moved while this window was open (another window, or the guide), so the
269
+ // language is re-resolved BEFORE the lines below are written: a status line written first would be the one
270
+ // thing left in the old language.
271
+ applyLanguage();
99
272
  // Which model a pet reports it is SHOWING: the progress bar waits for this, not merely for the write.
100
273
  liveModel = liveModelIdOf(snapshot);
101
274
  // The value that wins is the STORE's, so a switch made from another window repaints this grid — and
@@ -103,24 +276,31 @@
103
276
  // what asks whether an in-flight switch has finished.
104
277
  if (picker) picker.setCurrent();
105
278
  // A snapshot that COMPLETES a switch must not have its line overwritten by this one.
106
- if (!(picker && picker.state().switching)) setStatus('已读取当前设置。');
279
+ if (!(picker && picker.state().switching)) setStatusKey('settingsRead');
107
280
  },
108
281
  }).start();
109
282
  }
110
283
 
111
284
  function boot() {
112
285
  var mount = el('picker-mount');
286
+ // The language FIRST, so that even the failure lines below are in the language the user asked for. The store
287
+ // has not answered yet, so this resolves `'auto'` from the browser; the first snapshot resolves it again and
288
+ // re-labels if the store says otherwise.
289
+ applyLanguage();
113
290
  if (!pickerApi || typeof pickerApi.createModelPicker !== 'function') {
114
- setStatus('页面脚本缺失:model-picker.js 没有加载。', 'warn');
291
+ setStatusKey('missingPicker', 'warn');
115
292
  return;
116
293
  }
117
294
  if (!mount) {
118
- setStatus('页面结构缺失:找不到 #picker-mount。', 'warn');
295
+ setStatusKey('missingMount', 'warn');
119
296
  return;
120
297
  }
121
298
  picker = pickerApi.createModelPicker({
122
299
  mount: mount,
123
- language: 'zh',
300
+ // The RESOLVED language, not the store's preference: the component owns two tables and no opinion about
301
+ // 'auto' (see `resolveText` in lib/model-picker.js). A change after this point goes through `setLanguage`,
302
+ // which is what `applyLanguage` calls.
303
+ language: language,
124
304
  currentModelId: modelId,
125
305
  // The live report is what makes the bar about the LOAD rather than about the write. See `liveModelIdOf`.
126
306
  liveModelId: function () { return liveModel; },
@@ -128,7 +308,7 @@
128
308
  onStatus: setStatus,
129
309
  });
130
310
  watchSettings();
131
- setStatus('正在读取模型列表…');
311
+ setStatusKey('reading');
132
312
  picker.refresh();
133
313
  }
134
314
 
@@ -139,6 +319,7 @@
139
319
  return {
140
320
  hasSnapshot: hasSnapshot,
141
321
  current: modelId(),
322
+ language: language,
142
323
  picker: picker ? picker.state() : null,
143
324
  };
144
325
  };
@@ -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>vrmpet3d · 初次安装引导</title>
6
+ <title data-i18n="pageTitle">vrmpet3d · 初次安装引导</title>
7
7
  <meta name="color-scheme" content="dark light">
8
8
  <!--
9
9
  The first-run guide: its screens and a finish, on its own route (`/vrmpet3d/setup`). Which of the six the flow
@@ -49,8 +49,8 @@
49
49
  <body data-page="setup">
50
50
  <header class="page-header">
51
51
  <div class="eyebrow">VRMPET3D · SETUP</div>
52
- <h1>初次安装引导</h1>
53
- <div id="status" role="status" aria-live="polite">正在读取…</div>
52
+ <h1 data-i18n="guideTitle">初次安装引导</h1>
53
+ <div id="status" role="status" aria-live="polite" data-i18n="reading">正在读取…</div>
54
54
  </header>
55
55
 
56
56
  <!--
@@ -62,29 +62,44 @@
62
62
  step that is not there and a gap where one never was. The words live here and the policy in the attributes; both
63
63
  files are read together by `verify-desktop.mjs`.
64
64
  -->
65
- <ol class="setup-steps" id="setup-steps" aria-label="引导进度">
66
- <li data-screen="setup-0">欢迎</li>
67
- <li data-screen="setup-1">偏好模型</li>
68
- <li data-screen="setup-2" data-desktop-only="1">桌宠界面</li>
69
- <li data-screen="setup-3">AI 碎碎念</li>
70
- <li data-screen="setup-4" data-hidden="1">模型动作</li>
71
- <li data-screen="setup-5">完成</li>
65
+ <ol class="setup-steps" id="setup-steps" aria-label="引导进度" data-i18n-aria="stepsAria">
66
+ <li data-screen="setup-0" data-i18n="stepWelcome">欢迎</li>
67
+ <li data-screen="setup-1" data-i18n="stepModel">偏好模型</li>
68
+ <li data-screen="setup-2" data-desktop-only="1" data-i18n="stepSurface">桌宠界面</li>
69
+ <li data-screen="setup-3" data-i18n="stepSpeech">AI 碎碎念</li>
70
+ <li data-screen="setup-4" data-hidden="1" data-i18n="stepMotions">模型动作</li>
71
+ <li data-screen="setup-5" data-i18n="stepDone">完成</li>
72
72
  </ol>
73
73
 
74
74
  <main>
75
75
  <!-- ① 欢迎 -->
76
76
  <section class="settings-section setup-screen" id="setup-0">
77
- <h2>欢迎,也先说清楚风险</h2>
77
+ <h2 data-i18n="welcomeTitle">欢迎,也先说清楚风险</h2>
78
78
  <div class="card">
79
- <p>这是一个<strong>早期版本</strong>的插件,作者也是第一次开发 DSH 插件。它<strong>还没有经过系统性的验证</strong>,
80
- 可能存在 bug,也可能带来性能下降;设置页面的排版也可能比较混乱。</p>
81
- <p class="setup-warning">如果你在使用中遇到任何性能问题,请<strong>第一时间禁用这个插件</strong>,再慢慢排查。</p>
79
+ <!--
80
+ 界面语言 sits ABOVE the risk note (operator, 2026-10-15: 「在初始安装引导页的第一页欢迎页面中加上语言选择」):
81
+ a reader who cannot read the paragraphs picks the language before reading them. It is the one choice on this
82
+ page that is written and applied the moment it changes rather than at 完成 — see `wireChoices` in setup-page.js.
83
+
84
+ The paragraphs below are single-line on purpose: `core.applyI18n` rewrites their whole `innerHTML`, and the
85
+ markup's own text is the no-JS fallback a parity check compares against the zh half of the table.
86
+ -->
87
+ <div class="row">
88
+ <label class="k" for="setup-language" data-i18n="language">界面语言</label>
89
+ <select id="setup-language">
90
+ <option value="auto" data-i18n="languageAuto">自动</option>
91
+ <option value="zh" data-i18n="languageZh">中文</option>
92
+ <option value="en" data-i18n="languageEn">English</option>
93
+ </select>
94
+ </div>
95
+ <p data-i18n-html="welcomeRisk">这是一个<strong>早期版本</strong>的插件,作者也是第一次开发 DSH 插件。它<strong>还没有经过系统性的验证</strong>,可能存在 bug,也可能带来性能下降;设置页面的排版也可能比较混乱。</p>
96
+ <p class="setup-warning" data-i18n-html="welcomeWarning">如果你在使用中遇到任何性能问题,请<strong>第一时间禁用这个插件</strong>,再慢慢排查。</p>
82
97
  </div>
83
98
  </section>
84
99
 
85
100
  <!-- ② 偏好模型 -->
86
101
  <section class="settings-section setup-screen" id="setup-1" hidden>
87
- <h2>偏好模型</h2>
102
+ <h2 data-i18n="stepModel">偏好模型</h2>
88
103
  <!--
89
104
  The picker itself is `lib/model-picker.js`, mounted here and mounted again on the 切换模型 page
90
105
  (`/vrmpet3d/picker`, opened from either pet's right-click menu). The operator, 2026-10-13:
@@ -100,13 +115,13 @@
100
115
 
101
116
  <!-- ③ 桌宠界面 -->
102
117
  <section class="settings-section setup-screen" id="setup-2" hidden>
103
- <h2>桌宠出现在哪里</h2>
118
+ <h2 data-i18n="surfaceTitle">桌宠出现在哪里</h2>
104
119
  <div class="card">
105
120
  <div class="setup-choice" id="setup-surface">
106
121
  <label><input type="radio" name="setup-surface" value="auto">
107
- <strong>自动</strong><span class="note">Desktop端自动开启独立桌宠</span></label>
122
+ <strong data-i18n="surfaceAuto">自动</strong><span class="note" data-i18n="surfaceAutoNote">Desktop端自动开启独立桌宠</span></label>
108
123
  <label><input type="radio" name="setup-surface" value="app">
109
- <strong>只在内置桌宠</strong></label>
124
+ <strong data-i18n="surfaceApp">只在内置桌宠</strong></label>
110
125
  </div>
111
126
  <p class="hint" id="setup-surface-note"></p>
112
127
  </div>
@@ -119,36 +134,33 @@
119
134
  所以这里说的是**本次真正会用的那个目录**,不是一句泛泛的话。
120
135
  -->
121
136
  <div class="card">
122
- <p><strong>浮窗会借用一份 DSH 自带的运行时。</strong>独立浮窗需要无边框、置顶、点击穿透的系统窗口,这只有 Electron 能给,而插件
123
- 不自带(那会多出两百多兆)。所以启用浮窗时,启动器会在 <strong>DSH 安装目录所在的分区</strong>里建一个运行时目录,用
124
- <strong>硬链接</strong>指向 DSH 自己的那批客户端文件 —— 于是它看起来就是一份没改动过的 DSH。</p>
125
- <p><strong>硬链接与 DSH 共享同一份数据,不额外占盘</strong>(新增的只有一个小 app 壳),并且它<strong>必须与 DSH 同盘</strong>:
126
- 硬链接不能跨卷。删掉这个目录是安全的,下次启用浮窗会重新建一份。</p>
137
+ <p data-i18n-html="runtimeBorrow"><strong>浮窗会借用一份 DSH 自带的运行时。</strong>独立浮窗需要无边框、置顶、点击穿透的系统窗口,这只有 Electron 能给,而插件不自带(那会多出两百多兆)。所以启用浮窗时,启动器会在 <strong>DSH 安装目录所在的分区</strong>里建一个运行时目录,用 <strong>硬链接</strong>指向 DSH 自己的那批客户端文件 —— 于是它看起来就是一份没改动过的 DSH。</p>
138
+ <p data-i18n-html="runtimeLinks"><strong>硬链接与 DSH 共享同一份数据,不额外占盘</strong>(新增的只有一个小 app 壳),并且它<strong>必须与 DSH 同盘</strong>:硬链接不能跨卷。删掉这个目录是安全的,下次启用浮窗会重新建一份。</p>
127
139
  <p class="hint" id="setup-runtime-path"></p>
128
140
  </div>
129
141
  </section>
130
142
 
131
143
  <!-- ④ AI 碎碎念 -->
132
144
  <section class="settings-section setup-screen" id="setup-3" hidden>
133
- <h2>AI 碎碎念</h2>
145
+ <h2 data-i18n="speechTitle">AI 碎碎念</h2>
134
146
  <div class="card">
135
147
  <div class="row">
136
- <label class="k" for="setup-speech-enabled">开启碎碎念</label>
148
+ <label class="k" for="setup-speech-enabled" data-i18n="speechEnabled">开启碎碎念</label>
137
149
  <input type="checkbox" id="setup-speech-enabled">
138
150
  </div>
139
151
  <div class="setup-choice" id="setup-speech-source">
140
152
  <label><input type="radio" name="setup-speech-source" value="builtin">
141
- <strong>内置台词</strong><span class="note">内置台词基本已经够用</span></label>
153
+ <strong data-i18n="speechBuiltin">内置台词</strong><span class="note" data-i18n="speechBuiltinNote">内置台词基本已经够用</span></label>
142
154
  <label><input type="radio" name="setup-speech-source" value="ai">
143
- <strong>AI 生成</strong></label>
155
+ <strong data-i18n="speechAi">AI 生成</strong></label>
144
156
  </div>
145
157
  <div class="row" id="setup-ai-row">
146
- <label class="k" for="setup-ai-provider">AI 来源</label>
158
+ <label class="k" for="setup-ai-provider" data-i18n="speechProvider">AI 来源</label>
147
159
  <select id="setup-ai-provider"></select>
148
160
  <select id="setup-ai-model"></select>
149
161
  </div>
150
162
  <!-- Shown with the row above: the AI's own words are configured on the OTHER page, in the same 碎碎念 section. -->
151
- <p class="hint" id="setup-ai-note" hidden>人格信息和不同状态的提示词,都在 通用设置 → 碎碎念 里设置。</p>
163
+ <p class="hint" id="setup-ai-note" hidden data-i18n="speechAiNote">人格信息和不同状态的提示词,都在 通用设置 → 碎碎念 里设置。</p>
152
164
  <p class="hint" id="setup-speech-note"></p>
153
165
  </div>
154
166
  <!--
@@ -161,12 +173,8 @@
161
173
  is about the SOFTWARE, this one about what the pet SAYS and what the plugin touches.
162
174
  -->
163
175
  <div class="card" id="setup-speech-disclaimer">
164
- <p><strong>碎碎念只是玩笑,请勿当真。</strong>这些话要么来自内置台词,要么由你配置的模型即兴生成,都属于角色扮演:
165
- 不是宠物的真实判断,也不构成任何建议。</p>
166
- <p>插件<strong>不会对你的项目或文件做任何实际操作</strong>(不执行命令、不改动代码),只读取会话的<strong>运行状态</strong>
167
- (进行中 / 等待确认 / 完成 等)来决定表情和动作,会话文字、工具参数与附件都不会被它读取;你导入的模型或动作,
168
- 只会被复制到插件自己的目录里。<strong>它也不会干扰 DSH 本身的运行</strong>,且不带任何运行时依赖。不过在配置较差的
169
- 机器上,3D 渲染与弹簧骨物理可能带来轻微的性能影响 —— 关掉桌宠,或把尺寸调小一些,就能缓解。</p>
176
+ <p data-i18n-html="speechDisclaimerA"><strong>碎碎念只是玩笑,请勿当真。</strong>这些话要么来自内置台词,要么由你配置的模型即兴生成,都属于角色扮演:不是宠物的真实判断,也不构成任何建议。</p>
177
+ <p data-i18n-html="speechDisclaimerB">插件<strong>不会对你的项目或文件做任何实际操作</strong>(不执行命令、不改动代码),只读取会话的<strong>运行状态</strong>(进行中 / 等待确认 / 完成 等)来决定表情和动作,会话文字、工具参数与附件都不会被它读取;你导入的模型或动作,只会被复制到插件自己的目录里。<strong>它也不会干扰 DSH 本身的运行</strong>,且不带任何运行时依赖。不过在配置较差的机器上,3D 渲染与弹簧骨物理可能带来轻微的性能影响 —— 关掉桌宠,或把尺寸调小一些,就能缓解。</p>
170
178
  </div>
171
179
  </section>
172
180
 
@@ -177,17 +185,17 @@
177
185
  where the same import lives for anyone who does want it.
178
186
  -->
179
187
  <section class="settings-section setup-screen" id="setup-4" hidden>
180
- <h2>模型动作</h2>
188
+ <h2 data-i18n="motionsTitle">模型动作</h2>
181
189
  <div class="card">
182
190
  <div class="setup-actions">
183
- <button id="setup-motion-pick" class="primary">选择动作文件夹…</button>
184
- <button id="setup-motion-manual" class="ghost">手动输入路径</button>
191
+ <button id="setup-motion-pick" class="primary" data-i18n="motionsPick">选择动作文件夹…</button>
192
+ <button id="setup-motion-manual" class="ghost" data-i18n="motionsManual">手动输入路径</button>
185
193
  <span class="note" id="setup-motion-count"></span>
186
194
  </div>
187
195
  <div class="row" id="setup-motion-manual-row" hidden>
188
- <label class="k" for="setup-motion-path">文件夹路径</label>
196
+ <label class="k" for="setup-motion-path" data-i18n="motionsPath">文件夹路径</label>
189
197
  <input type="text" id="setup-motion-path" class="grow" placeholder="D:\motions\my-pack">
190
- <button id="setup-motion-add" class="ghost">导入</button>
198
+ <button id="setup-motion-add" class="ghost" data-i18n="motionsAdd">导入</button>
191
199
  </div>
192
200
  <div class="setup-list" id="setup-motion-list"></div>
193
201
  <p class="hint" id="setup-motion-note"></p>
@@ -197,12 +205,12 @@
197
205
 
198
206
  <!-- 完成 -->
199
207
  <section class="settings-section setup-screen" id="setup-5" hidden>
200
- <h2>完成</h2>
208
+ <h2 data-i18n="doneTitle">完成</h2>
201
209
  <div class="card">
202
- <p class="setup-done">配置已保存。</p>
210
+ <p class="setup-done" data-i18n="doneSaved">配置已保存。</p>
203
211
  <div class="setup-actions">
204
- <button id="setup-finish" class="primary">完成</button>
205
- <button id="setup-close" class="ghost" hidden>关闭窗口</button>
212
+ <button id="setup-finish" class="primary" data-i18n="finishButton">完成</button>
213
+ <button id="setup-close" class="ghost" hidden data-i18n="closeWindow">关闭窗口</button>
206
214
  </div>
207
215
  <p class="hint" id="setup-finish-note"></p>
208
216
  </div>
@@ -210,16 +218,27 @@
210
218
  </main>
211
219
 
212
220
  <footer class="setup-nav">
213
- <button id="setup-later" class="ghost">稍后配置</button>
221
+ <button id="setup-later" class="ghost" data-i18n="later">稍后配置</button>
214
222
  <div class="setup-nav-right">
215
- <button id="setup-back" class="ghost">上一步</button>
216
- <button id="setup-next" class="primary">下一步</button>
223
+ <button id="setup-back" class="ghost" data-i18n="back">上一步</button>
224
+ <button id="setup-next" class="primary" data-i18n="next">下一步</button>
217
225
  </div>
218
226
  </footer>
219
227
 
220
- <!-- The same watchdog the two settings pages carry: a page that cannot initialise says so rather than sitting blank. -->
228
+ <!--
229
+ The same watchdog the two settings pages carry: a page that cannot initialise says so rather than sitting blank.
230
+
231
+ It runs BEFORE core.js, so it cannot use the shared table or `resolveLanguage` — hence the two-line map and picker
232
+ below. A stalled page is exactly the page whose reader needs a sentence they can read, and this is the only place
233
+ on the page where English and Chinese both have to fit in one script with no dependency.
234
+ -->
221
235
  <script>
222
236
  (function () {
237
+ var STUCK = {
238
+ zh: '初始化卡住:脚本已加载但没有完成初始化(看看窗口的控制台,或宿主是否提供 /vrmpet3d/setup-state)。',
239
+ en: 'Initialisation stalled: the script loaded but never finished (check the window console, or whether the host serves /vrmpet3d/setup-state).',
240
+ };
241
+ var language = /^(zh|cn|chinese)/i.test(navigator.language || '') ? 'zh' : 'en';
223
242
  function show(text) {
224
243
  var node = document.getElementById('status');
225
244
  if (node) { node.textContent = text; node.className = 'warn'; }
@@ -227,7 +246,7 @@
227
246
  window.setTimeout(function () {
228
247
  var node = document.getElementById('status');
229
248
  if (node && !node.getAttribute('data-ready')) {
230
- show('初始化卡住:脚本已加载但没有完成初始化(看看窗口的控制台,或宿主是否提供 /vrmpet3d/setup-state)。');
249
+ show(STUCK[language]);
231
250
  }
232
251
  }, 3000);
233
252
  })();