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.
package/lib/core.js CHANGED
@@ -71,16 +71,32 @@
71
71
  * The `{duration}` token and its machinery stay — a line the user writes may still carry it (see
72
72
  * {@link STATUS_DURATION_TOKEN}) — but the shipped line is DSH's token-free 「深度求索中 ···」, which is also what the
73
73
  * app shows while the latest turn is not open.
74
+ *
75
+ * ONE TABLE PER LANGUAGE, keyed state → language, the same shape `SPEECH_LINES` uses for its pools and for the
76
+ * same reason: a status line is TEXT THE USER READS, so an interface in English that still said 「任务达成」 would
77
+ * be a half-translated one. The two halves are written rather than translated word for word, and the Chinese is
78
+ * the operator's own wording, unchanged since 2026-10-13.
79
+ *
80
+ * An EDIT wins over both: `statusEntryFor` only reaches for these while the user's own box is empty, so a line
81
+ * typed by hand is one line in one language and stays exactly that, in either interface language. That is the
82
+ * whole reason this is a per-language DEFAULT rather than a second field in the settings.
74
83
  */
75
84
  var DEFAULT_STATUS_TEXT = {
76
- resting: '',
77
- working: '深度求索中 ···',
78
- waiting: '我有问题要问你',
79
- done: '任务达成',
80
- error: '遇到问题了...',
81
- offline: '',
85
+ resting: { zh: '', en: '' },
86
+ working: { zh: '深度求索中 ···', en: 'Working ···' },
87
+ waiting: { zh: '我有问题要问你', en: 'I have a question for you' },
88
+ done: { zh: '任务达成', en: 'Task complete' },
89
+ error: { zh: '遇到问题了...', en: 'Something went wrong...' },
90
+ offline: { zh: '', en: '' },
82
91
  };
83
92
 
93
+ /** One state's shipped line in one language; `''` for a state that says nothing by default. */
94
+ function defaultStatusText(state, language) {
95
+ var entry = DEFAULT_STATUS_TEXT[state];
96
+ if (!entry || typeof entry !== 'object') return '';
97
+ return typeof entry[language === 'en' ? 'en' : 'zh'] === 'string' ? entry[language === 'en' ? 'en' : 'zh'] : '';
98
+ }
99
+
84
100
  /**
85
101
  * The token a status line may carry where the live elapsed time goes, and the units it is written with.
86
102
  *
@@ -96,6 +112,155 @@
96
112
  en: { hour: 'h ', minute: 'm ', second: 's' },
97
113
  };
98
114
 
115
+ /**
116
+ * The three answers to 「界面语言」, and the ONE rule that turns them into the language a surface draws in.
117
+ *
118
+ * `auto` is the shipped answer and means "whatever the surface is already running in": DSH's own locale inside
119
+ * the app, the browser's on a host-served page, and on a surface that can see neither (a suite, a headless
120
+ * host) English — the plugin's own fallback rather than a guess about the operator.
121
+ *
122
+ * The other two are the user overruling that, and they exist because the two questions are NOT the same one:
123
+ * DSH can be running in English while its owner wants the pet in Chinese, and the first-run guide is read once,
124
+ * before any of that has settled. Operator, 2026-10-15: 「把全部界面做中英双语自动识别,并在通用设置和初始安装
125
+ * 引导页的第一页欢迎页面中加上语言选择」.
126
+ *
127
+ * `auto` is STORED as `auto`, never resolved on write. A profile that stored the resolved 'zh' would freeze the
128
+ * language on the day it was created, and the user who later switched DSH to English would keep a Chinese pet
129
+ * with no way to tell that from a deliberate choice — the same mistake `DEFAULTS.petSurface` documents for
130
+ * 显示位置, and it is why this is a preference the CLEANER knows about rather than a value a page writes.
131
+ */
132
+ var LANGUAGE_SETTINGS = ['auto', 'zh', 'en'];
133
+
134
+ /** `'auto'` for anything that is not one of the three — an unknown stored value is a reset, not an error. */
135
+ function cleanLanguage(value) {
136
+ return LANGUAGE_SETTINGS.indexOf(value) === -1 ? 'auto' : value;
137
+ }
138
+
139
+ /**
140
+ * Which of the two languages this surface draws in, given the user's setting and what the environment says.
141
+ *
142
+ * `hints` is a LIST, most authoritative first — DSH's locale, then the document's own `lang`, then
143
+ * `navigator.language` — and the FIRST hint that says anything decides. That order is the rule and not a detail:
144
+ * DSH is the application the pet lives inside, so its language outranks the browser's, and a page DSH has
145
+ * already marked `lang="zh"` outranks a browser whose own preference is en-US.
146
+ *
147
+ * The first hint decides the ZH-or-not question instead of the chain continuing past it, because this plugin has
148
+ * exactly two languages. A hint of `fr` is not a reason to go looking for a Chinese one further down the chain;
149
+ * it is a reason to fall back to English, which is also what `auto` with nothing to go on means.
150
+ */
151
+ function resolveLanguage(preference, hints) {
152
+ var wanted = cleanLanguage(preference);
153
+ if (wanted !== 'auto') return wanted;
154
+ var list = Array.isArray(hints) ? hints : [hints];
155
+ for (var i = 0; i < list.length; i += 1) {
156
+ var hint = list[i];
157
+ if (typeof hint !== 'string' || !hint) continue;
158
+ return /^(zh|cn|chinese)/i.test(hint.trim()) ? 'zh' : 'en';
159
+ }
160
+ return 'en';
161
+ }
162
+
163
+ /**
164
+ * The environment's own two hints, in the order they outrank each other — what a surface that has no DSH locale
165
+ * to ask uses. `scope` exists so a suite can hand in a fake window instead of moving the real one.
166
+ *
167
+ * The BROWSER comes first, and the document's own `lang` second, which is the opposite of the order the app's
168
+ * widget uses — deliberately. Every page this plugin serves ships `<html lang="zh">` (the plugin was written in
169
+ * Chinese), so a page that read its own markup first could never be auto-detected as English: the hint would be
170
+ * its own default rather than anything about the user. `navigator.language` is the browser's answer and in the
171
+ * floating window it is Electron's, i.e. the machine's.
172
+ */
173
+ function browserLanguageHints(scope) {
174
+ var win = scope || (typeof globalThis !== 'undefined' ? globalThis : null);
175
+ var doc = win && win.document;
176
+ var nav = win && win.navigator;
177
+ return [
178
+ nav ? (nav.language || (nav.languages && nav.languages[0])) : null,
179
+ doc && doc.documentElement ? doc.documentElement.lang : null,
180
+ ];
181
+ }
182
+
183
+ /**
184
+ * One surface's own wording, in the two languages, chosen by {@link resolveLanguage}.
185
+ *
186
+ * Every surface keeps its OWN table — `lib/menu.js`'s `LABELS`, `lib/model-picker.js`'s `TEXT`, the settings
187
+ * pages' `TEXT` — rather than one global dictionary, because that is already this repository's shape (a surface
188
+ * owns the words it draws) and because a single table for six surfaces would make every one of them edit the
189
+ * same file. What IS shared is the SHAPE: `{ zh: {...}, en: {...} }`, this accessor, and {@link applyI18n}.
190
+ *
191
+ * A language half that is missing or empty falls back to the other one rather than to blank: a half-translated
192
+ * surface reads as Chinese in one spot, which is a bug somebody reports, where a blank label is a page that
193
+ * looks broken. `textOf` below is what makes a GAP visible, and a suite is what keeps one from shipping.
194
+ */
195
+ function textFor(table, language) {
196
+ if (!table || typeof table !== 'object') return {};
197
+ var wanted = language === 'en' ? table.en : table.zh;
198
+ var other = language === 'en' ? table.zh : table.en;
199
+ if (wanted && typeof wanted === 'object') return wanted;
200
+ return other && typeof other === 'object' ? other : {};
201
+ }
202
+
203
+ /** One entry out of a surface's table. A missing key returns the KEY, so a gap is visible on screen. */
204
+ function textOf(table, language, key) {
205
+ var value = textFor(table, language)[key];
206
+ return typeof value === 'string' ? value : key;
207
+ }
208
+
209
+ /**
210
+ * Fill a document's marked-up text from one surface's table.
211
+ *
212
+ * The markup carries the CHINESE text as well as the key, and that is on purpose: this plugin's pages all ship a
213
+ * bootstrap watchdog for the case where a script never ran, so a page whose script DID not run should still say
214
+ * something in one language rather than show nothing. `verify-desktop.mjs` asserts the two agree, which is what
215
+ * stops the markup and the table from drifting apart.
216
+ *
217
+ * Four attributes, one per place text lives: `data-i18n` writes `textContent`, `data-i18n-html` writes
218
+ * `innerHTML` and is for OUR OWN sentence with a `<strong>` in it (never for anything a user or a model wrote),
219
+ * and `data-i18n-placeholder` / `data-i18n-title` / `data-i18n-aria` write those three attributes.
220
+ *
221
+ * A key the table does not have leaves the markup ALONE rather than printing the key: the visible text stays the
222
+ * Chinese fallback, and the missing entry is the suite's business, not the user's.
223
+ *
224
+ * @returns {number} how many places were rewritten, so a suite can tell "applied nothing" from "no markup".
225
+ */
226
+ function applyI18n(root, table, language) {
227
+ var doc = root && typeof root.querySelectorAll === 'function'
228
+ ? root
229
+ : (typeof globalThis !== 'undefined' ? globalThis.document : null);
230
+ if (!doc || typeof doc.querySelectorAll !== 'function') return 0;
231
+ var words = textFor(table, language);
232
+ var selector = '[data-i18n],[data-i18n-html],[data-i18n-placeholder],[data-i18n-title],[data-i18n-aria]';
233
+ var nodes;
234
+ try {
235
+ nodes = doc.querySelectorAll(selector);
236
+ } catch (error) {
237
+ return 0;
238
+ }
239
+ var applied = 0;
240
+ var write = function (node, attribute, key, html) {
241
+ var value = words[key];
242
+ if (typeof value !== 'string') return;
243
+ if (attribute === 'data-i18n') node.textContent = value;
244
+ else if (html) node.innerHTML = value;
245
+ else node.setAttribute(attribute, value);
246
+ applied += 1;
247
+ };
248
+ for (var i = 0; i < nodes.length; i += 1) {
249
+ var node = nodes[i];
250
+ var key = node.getAttribute('data-i18n');
251
+ if (key) write(node, 'data-i18n', key, false);
252
+ key = node.getAttribute('data-i18n-html');
253
+ if (key) write(node, 'data-i18n-html', key, true);
254
+ key = node.getAttribute('data-i18n-placeholder');
255
+ if (key) write(node, 'placeholder', key, false);
256
+ key = node.getAttribute('data-i18n-title');
257
+ if (key) write(node, 'title', key, false);
258
+ key = node.getAttribute('data-i18n-aria');
259
+ if (key) write(node, 'aria-label', key, false);
260
+ }
261
+ return applied;
262
+ }
263
+
99
264
  /**
100
265
  * The states whose prompt carries DSH's whale mark.
101
266
  *
@@ -1080,6 +1245,12 @@
1080
1245
  /** Default pose and behaviour knobs. */
1081
1246
  var DEFAULTS = {
1082
1247
  scope: 'global',
1248
+ /**
1249
+ * 界面语言 — `auto` follows the surface's own environment ({@link resolveLanguage}), and 中文 / English overrule
1250
+ * it. It sits first in the table rather than beside the appearance rows because it is the one preference that
1251
+ * decides how every OTHER row is spelled.
1252
+ */
1253
+ language: 'auto',
1083
1254
  size: 100,
1084
1255
  /**
1085
1256
  * WHERE the pet is shown, as the cleaner's DEFAULT rather than a string repeated beside it.
@@ -1925,6 +2096,13 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
1925
2096
  var out = {};
1926
2097
  var source = raw && typeof raw === 'object' ? raw : {};
1927
2098
  out.scope = source.scope === 'current' ? 'current' : 'global';
2099
+ /**
2100
+ * 界面语言 — cleaned like every other enumerated preference: an unknown value (an older profile, a typo, a page
2101
+ * that sent the wrong thing) is `auto`, which resolves to the surface's own environment anyway. It is NOT
2102
+ * counted as authorship by `authoredWeight` over in `lib/settings.js`: a language is a way of looking at the
2103
+ * pet, not something the user wrote into it.
2104
+ */
2105
+ out.language = cleanLanguage(source.language);
1928
2106
  // An unknown surface falls back to the shipped default, not to a string repeated here.
1929
2107
  out.petSurface = PET_SURFACES.indexOf(source.petSurface) === -1 ? DEFAULTS.petSurface : source.petSurface;
1930
2108
  out.size = Number.isFinite(Number(source.size)) ? clamp(Number(source.size), SIZE_RANGE.min, SIZE_RANGE.max) : DEFAULTS.size;
@@ -2480,8 +2658,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2480
2658
  // ---------------------------------------------------------------------------
2481
2659
 
2482
2660
  /** One state's status line, exactly as the renderer draws it: text, then the tag. */
2483
- function statusLineFor(preferences, state) {
2484
- var entry = statusEntryFor(preferences, state);
2661
+ function statusLineFor(preferences, state, language) {
2662
+ var entry = statusEntryFor(preferences, state, language);
2485
2663
  if (!entry) return '';
2486
2664
  var tag = typeof entry.tag === 'string' ? entry.tag : '';
2487
2665
  return (entry.text + tag).slice(0, STATUS_LIMITS.textLength + STATUS_LIMITS.tagLength);
@@ -2493,11 +2671,15 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2493
2671
  * Always an OBJECT, never null: every state has something to say or an explicit reason to say nothing,
2494
2672
  * and a caller that had to distinguish "unconfigured" from "configured empty" would get it wrong in the
2495
2673
  * one case that matters (a user who cleared a line).
2674
+ *
2675
+ * `language` picks WHICH shipped default fills an empty box — see {@link DEFAULT_STATUS_TEXT}. It is the third
2676
+ * parameter and optional, so a caller that does not know the surface's language keeps the Chinese default the
2677
+ * suites and the older call sites already pin.
2496
2678
  */
2497
- function statusEntryFor(preferences, state) {
2679
+ function statusEntryFor(preferences, state, language) {
2498
2680
  var map = preferences && preferences.status && typeof preferences.status === 'object' ? preferences.status : {};
2499
2681
  var stored = map[state] && typeof map[state] === 'object' ? map[state] : {};
2500
- var fallback = Object.prototype.hasOwnProperty.call(DEFAULT_STATUS_TEXT, state) ? DEFAULT_STATUS_TEXT[state] : '';
2682
+ var fallback = defaultStatusText(state, language);
2501
2683
  var text = typeof stored.text === 'string' ? stored.text : fallback;
2502
2684
  var fallbackAnimation = Object.prototype.hasOwnProperty.call(DEFAULT_STATUS_ANIMATION, state)
2503
2685
  ? DEFAULT_STATUS_ANIMATION[state]
@@ -3626,11 +3808,17 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
3626
3808
  * clause is DROPPED — DSH's own token-free 「深度求索中」 rather than a number invented here. Passed per call so the
3627
3809
  * two surfaces (the app's pet and the floating window) can only differ by what they are told, never by the rule.
3628
3810
  *
3811
+ * `language` is the SIXTH parameter and it reaches two places: which shipped default an empty box falls back to
3812
+ * ({@link DEFAULT_STATUS_TEXT}), and the units a `{duration}` clause is written in — DSH writes 1分30秒 in one
3813
+ * language and 1m 30s in the other, so a line translated without its clock would read as a foreign number.
3814
+ * Optional, like the status-entry language above: a caller that has no language to pass keeps the Chinese answer
3815
+ * every existing suite pins.
3816
+ *
3629
3817
  * @returns {{state: string, text: string, tag: string, animation: string, seconds: number}|null}
3630
3818
  */
3631
- function statusFor(preferences, state, enteredAt, now, clock) {
3819
+ function statusFor(preferences, state, enteredAt, now, clock, language) {
3632
3820
  if (STATUS_STATES.indexOf(state) === -1) return null;
3633
- var entry = statusEntryFor(preferences, state);
3821
+ var entry = statusEntryFor(preferences, state, language);
3634
3822
  if (!entry.text && !entry.tag) return null;
3635
3823
  var at = Number.isFinite(now) ? now : Date.now();
3636
3824
  /**
@@ -3683,7 +3871,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
3683
3871
  }
3684
3872
  text = elapsed === null
3685
3873
  ? durationClauseRemoved(text)
3686
- : text.split(STATUS_DURATION_TOKEN).join(formatRunDuration(elapsed));
3874
+ : text.split(STATUS_DURATION_TOKEN).join(formatRunDuration(elapsed, language));
3687
3875
  }
3688
3876
  var look = statusTone(state);
3689
3877
  return {
@@ -7299,6 +7487,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
7299
7487
  STATES: STATES,
7300
7488
  INTERACTION_STATES: INTERACTION_STATES,
7301
7489
  STATUS_STATES: STATUS_STATES,
7490
+ LANGUAGE_SETTINGS: LANGUAGE_SETTINGS,
7491
+ cleanLanguage: cleanLanguage,
7492
+ resolveLanguage: resolveLanguage,
7493
+ browserLanguageHints: browserLanguageHints,
7494
+ textFor: textFor,
7495
+ textOf: textOf,
7496
+ applyI18n: applyI18n,
7497
+ defaultStatusText: defaultStatusText,
7302
7498
  DEFAULT_STATUS_TEXT: DEFAULT_STATUS_TEXT,
7303
7499
  STATUS_TAGS: STATUS_TAGS,
7304
7500
  STATUS_ANIMATIONS: STATUS_ANIMATIONS,
@@ -65,6 +65,49 @@
65
65
  */
66
66
  var MENU_MARGIN = 4;
67
67
 
68
+ /**
69
+ * This page's own words, in the two languages — `{ zh: {...}, en: {...} }` with IDENTICAL key sets, the
70
+ * repository's per-surface shape (`lib/menu.js`'s `LABELS`, `lib/client.js`'s `STRINGS`).
71
+ *
72
+ * These four are the only strings this page PAINTS that are its own rather than the app's: the three moments
73
+ * of 重载模型 and 跳支舞 with nothing to dance to, all of them said in the pet's bubble (`showLocalSpeech`).
74
+ * The app's pet says the same four things in its badge for the same gestures, so the wording here is kept in
75
+ * step with that table BY HAND — a drift is one gesture answered in two different sentences. `assets/motions/dance`
76
+ * is a path rather than words and is written the same way in both halves.
77
+ *
78
+ * Read at CALL time through `word`: `preferences.language` can change while this window runs.
79
+ */
80
+ var TEXT = {
81
+ zh: {
82
+ reloading: '正在重载模型…',
83
+ reloaded: '模型已重载',
84
+ reloadFailed: '模型重载失败',
85
+ danceEmpty: '没有可用的舞蹈动作(assets/motions/dance 是空的)',
86
+ },
87
+ en: {
88
+ reloading: 'Reloading the model…',
89
+ reloaded: 'Model reloaded',
90
+ reloadFailed: 'Could not reload the model',
91
+ danceEmpty: 'No dance clip is available (assets/motions/dance is empty).',
92
+ },
93
+ };
94
+
95
+ /**
96
+ * One entry out of {@link TEXT}, in the language on screen — read at CALL time, never captured.
97
+ *
98
+ * `'en'` is the only value that selects the English half: every language this page resolves is exactly 'zh' or
99
+ * 'en' (see the mount), and anything unexpected reads the Chinese half, which is the fallback the markup and
100
+ * `lib/menu.js`'s own `strings()` already use. An entry missing from THAT half falls back to the other one, and
101
+ * an entry missing from both returns the key — a gap that SHOWS rather than `undefined` drawn in the pet's
102
+ * bubble, which is the rule `core.textOf` follows.
103
+ */
104
+ function word(language, key) {
105
+ var half = language === 'en' ? TEXT.en : TEXT.zh;
106
+ if (typeof half[key] === 'string') return half[key];
107
+ var other = language === 'en' ? TEXT.zh : TEXT.en;
108
+ return typeof other[key] === 'string' ? other[key] : key;
109
+ }
110
+
68
111
  /**
69
112
  * This page's own stylesheet, injected once.
70
113
  *
@@ -593,8 +636,21 @@
593
636
  // 是不对的。删掉两个单独的设置面板。」); every setting it used to offer is on one of the two settings pages.
594
637
  // ---------------------------------------------------------------------------------------
595
638
  var doc = canvas.ownerDocument || globalThis.document || null;
596
- var language = options.language
597
- || ((globalThis.navigator && /^zh/i.test(globalThis.navigator.language || '')) ? 'zh' : 'en');
639
+ /**
640
+ * 界面语言, resolved through the ONE shared rule rather than guessed here.
641
+ *
642
+ * An explicit `preferences.language` wins, and `auto` follows the FIRST hint that says anything — and this
643
+ * page has no DSH locale to ask (it is served by the launcher, not by DSH), so the hints are the browser's own
644
+ * two: `navigator.language`, which in this window is Electron's and therefore the machine's, and then the
645
+ * document's `lang`. English is what nothing-at-all answers.
646
+ *
647
+ * A core chunk that predates the rule (the stale-delivery case this repository has eaten more than once) falls
648
+ * back to the browser hint this page used before, so an older host costs the SHARING of the rule, not the
649
+ * language. The preference is re-read by `adoptLanguage` whenever the settings change.
650
+ */
651
+ var language = typeof core.resolveLanguage === 'function'
652
+ ? core.resolveLanguage(preferences.language, core.browserLanguageHints())
653
+ : ((globalThis.navigator && /^zh/i.test(globalThis.navigator.language || '')) ? 'zh' : 'en');
598
654
  installDesktopStyles(doc);
599
655
  var menu = null;
600
656
  var menuOpen = false;
@@ -725,6 +781,13 @@
725
781
  */
726
782
  function buildMenu() {
727
783
  var api = globalThis.__VRMPET3D_MENU__;
784
+ /**
785
+ * A REBUILD replaces the element rather than leaving the old one in the body: the menu carries its words
786
+ * from `createMenu` and cannot be re-worded in place, so `adoptLanguage` builds a new one — and a second
787
+ * copy would keep its own document listeners. `menuOpen`/`menuBox` are the page's own and are cleared by
788
+ * `closeMenu`/`onMenuClosed` before this runs.
789
+ */
790
+ if (menu && typeof menu.destroy === 'function') menu.destroy();
728
791
  if (!api || typeof api.createMenu !== 'function' || !doc) return null;
729
792
  api.injectStyle(doc, 'vrmpet3d-menu-style');
730
793
  return api.createMenu({
@@ -836,6 +899,40 @@
836
899
  }
837
900
 
838
901
  menu = buildMenu();
902
+ /**
903
+ * Follow 界面语言 — the preference, this page's environment, and its own four sentences.
904
+ *
905
+ * Called from every path that replaces `preferences` (a host snapshot, a storage write from the settings
906
+ * window), because the language is a preference like any other and a change made there has to reach this
907
+ * window without a reload. 'auto' means what the browser says here, and the browser can be re-read at any
908
+ * time; only a CHANGE does anything, since this rebuilds the menu.
909
+ *
910
+ * What a change touches, and why exactly these:
911
+ * * `language` is read at CALL time by everything that words this page (the say request, the shipped pool
912
+ * for a touched part, `core.statusFor`'s default line and its clock units, the published readout, the four
913
+ * bubble sentences), so a new value reaches all of them by itself;
914
+ * * the MENU carries its labels from `createMenu`, so it is rebuilt — and a menu that is OPEN is closed
915
+ * first, because its rows are the old words and its closing is what gives the window its pet-sized box
916
+ * back;
917
+ * * the prompt is redrawn here rather than on the next frame: this page's frame loop re-renders on a cached
918
+ * key, and a language change does not alter the STATE, so the cached line would otherwise keep the old
919
+ * words until the state changed.
920
+ */
921
+ function adoptLanguage() {
922
+ var next = typeof core.resolveLanguage === 'function'
923
+ ? core.resolveLanguage(preferences.language, core.browserLanguageHints())
924
+ : ((globalThis.navigator && /^zh/i.test(globalThis.navigator.language || '')) ? 'zh' : 'en');
925
+ if (next === language) return;
926
+ language = next;
927
+ if (menuOpen) closeMenu();
928
+ menu = buildMenu();
929
+ statusKey = null;
930
+ renderStatus();
931
+ layout();
932
+ reportRect();
933
+ publish();
934
+ }
935
+
839
936
  /**
840
937
  * The model catalog, fetched once — and re-read whenever the store names a model this list cannot answer for
841
938
  * (`refreshCatalog`). See the snapshot handler for the import-while-running case that made that necessary.
@@ -1228,7 +1325,12 @@
1228
1325
  body: JSON.stringify({
1229
1326
  state: hostState || null,
1230
1327
  event: stateEventName(),
1231
- language: preferences.language === 'en' ? 'en' : 'zh',
1328
+ /**
1329
+ * The language this page is DRAWN in, not `preferences.language` raw: 'auto' is not a language, and a
1330
+ * host asked for a line in 'auto' would have to guess at exactly the thing this page has already
1331
+ * resolved. The host's prompt is built from this, so a line arrives in the language on screen.
1332
+ */
1333
+ language: language,
1232
1334
  part: item.id,
1233
1335
  structure: structure || null,
1234
1336
  chain: chain || null,
@@ -1413,7 +1515,7 @@
1413
1515
  if (!statusNode) return;
1414
1516
  // The TURN clock the app sent with the state, not this page's own age for it: 用时 is a claim about DSH's turn,
1415
1517
  // and with no turn to report the token is dropped (DSH's own token-free 「深度求索中」). See `PetMachine.statusClock`.
1416
- var line = core.statusFor(preferences, hostState, hostStateAt, Date.now(), hostStateClock);
1518
+ var line = core.statusFor(preferences, hostState, hostStateAt, Date.now(), hostStateClock, language);
1417
1519
  if (!line) {
1418
1520
  statusKey = null;
1419
1521
  statusShown = null;
@@ -1610,12 +1712,15 @@
1610
1712
  if (!id) return Promise.resolve(null);
1611
1713
  if (typeof scene.bustModelCache === 'function') scene.bustModelCache();
1612
1714
  // The bubble is this page's own way of saying something transient — the same channel a touched part uses.
1613
- showLocalSpeech('正在重载模型…', 3);
1715
+ // The three lines of 重载模型 come from this page's own table, and are the same three the app's pet puts in
1716
+ // its badge for the same menu row.
1717
+ showLocalSpeech(word(language, 'reloading'), 3);
1614
1718
  return loadModelSerialized(id).then(function (status) {
1615
1719
  layout();
1616
1720
  scene.applyPreferences(preferences);
1617
1721
  publish();
1618
- showLocalSpeech(status && status.ok ? '模型已重载' : ((status && status.error) || '模型重载失败'), 3);
1722
+ showLocalSpeech(status && status.ok ? word(language, 'reloaded')
1723
+ : ((status && status.error) || word(language, 'reloadFailed')), 3);
1619
1724
  return status;
1620
1725
  });
1621
1726
  }
@@ -1638,9 +1743,13 @@
1638
1743
  && prefixes.some(function (prefix) { return String(entry.name || '').indexOf(prefix) === 0; });
1639
1744
  }).map(function (entry) { return entry.id; });
1640
1745
  if (!ids.length) {
1641
- showLocalSpeech('没有可用的舞蹈动作(assets/motions/dance 是空的)', 3);
1746
+ // The same sentence the app's pet shows in its badge for its own 跳支舞 row.
1747
+ showLocalSpeech(word(language, 'danceEmpty'), 3);
1642
1748
  return Promise.resolve(null);
1643
1749
  }
1750
+ var current = dancePreview && dancePreview.motionId;
1751
+ var alternatives = ids.filter(function (id) { return id !== current; });
1752
+ if (alternatives.length) ids = alternatives;
1644
1753
  var picked = ids[Math.floor(Math.random() * ids.length)];
1645
1754
  return fetch('/vrmpet3d/preview', {
1646
1755
  method: 'POST',
@@ -2448,7 +2557,7 @@
2448
2557
  *
2449
2558
  * Sent ONCE per dance: the flag resets as soon as the drawn state leaves 跳舞, so a finished clip cannot post every frame.
2450
2559
  */
2451
- if (drawn === 'dancing' && scene.playback && scene.playback.record && scene.playback.record.finished === true) {
2560
+ if (drawn === 'dancing' && scene.playback && scene.playback.state === 'dancing' && scene.playback.record && scene.motionFinished(scene.playback.record)) {
2452
2561
  if (!danceEndedReported) {
2453
2562
  danceEndedReported = true;
2454
2563
  try {
@@ -3410,6 +3519,19 @@
3410
3519
  canvas.addEventListener('pointermove', onPointerMove);
3411
3520
  canvas.addEventListener('pointerup', onPointerUp);
3412
3521
  canvas.addEventListener('pointercancel', onPointerUp);
3522
+ function onDoubleClick(event) {
3523
+ if (event.button !== undefined && event.button !== 0) return;
3524
+ if (hostState !== 'dancing' && !dancePreview) return;
3525
+ dancePreview = null;
3526
+ if (hostState === 'dancing') hostState = 'resting';
3527
+ scene.setPreview(null);
3528
+ scene.setState('resting');
3529
+ fetch('/vrmpet3d/preview', {
3530
+ method: 'POST', credentials: 'omit',
3531
+ headers: { 'content-type': 'application/json' }, body: '{}',
3532
+ }).catch(function () { return null; });
3533
+ }
3534
+ canvas.addEventListener('dblclick', onDoubleClick);
3413
3535
  canvas.addEventListener('wheel', onWheel, { passive: false });
3414
3536
  canvas.addEventListener('contextmenu', onContextMenu);
3415
3537
  /**
@@ -3558,6 +3680,12 @@
3558
3680
  placement.preferences = next;
3559
3681
  placement.setSize(client.BASE_PET_SIZE.width, client.BASE_PET_SIZE.height);
3560
3682
  placement.clampToViewport();
3683
+ /**
3684
+ * 界面语言 is a preference like the rest, and only now is the answer readable — `next.language` may be an
3685
+ * explicit 中文/English or `auto`, which this page answers from its own environment. After the size clamp,
3686
+ * because a language change lays the window out again.
3687
+ */
3688
+ adoptLanguage();
3561
3689
  /**
3562
3690
  * 重力 comes from the store like everything else, and it is PHYSICS rather than a value the scene
3563
3691
  * re-applies — so it has to be pushed here too. Without this line the switch would work until the next
@@ -3633,6 +3761,10 @@
3633
3761
  onPreview: function (preview) {
3634
3762
  dancePreview = preview && preview.state === 'dancing' ? preview : null;
3635
3763
  if (dancePreview) {
3764
+ // Each preview notification is a NEW run, including the same sole clip.
3765
+ danceEndedReported = false;
3766
+ scene.setPreview(null);
3767
+ if (scene.playback && scene.playback.state === 'dancing') scene.stopPlayback(null);
3636
3768
  ensureMotion(preview.motionId, false);
3637
3769
  return;
3638
3770
  }
@@ -3717,6 +3849,9 @@
3717
3849
  placement.preferences = next;
3718
3850
  placement.setSize(client.BASE_PET_SIZE.width, client.BASE_PET_SIZE.height);
3719
3851
  placement.clampToViewport();
3852
+ // A storage write is the fallback path for a host without the settings route, and the settings window is
3853
+ // where 界面语言 is chosen: the same re-resolution the snapshot path does, after the size clamp.
3854
+ adoptLanguage();
3720
3855
  // The scene re-reads the whole preference set: springs and expressions are applied from it, so
3721
3856
  // a change made in the settings window lands on the pet through this one call.
3722
3857
  scene.applyPreferences(Object.assign({}, next, { motions: effectiveMotions() }));
@@ -3909,6 +4044,7 @@
3909
4044
  canvas.removeEventListener('pointermove', onPointerMove);
3910
4045
  canvas.removeEventListener('pointerup', onPointerUp);
3911
4046
  canvas.removeEventListener('pointercancel', onPointerUp);
4047
+ canvas.removeEventListener('dblclick', onDoubleClick);
3912
4048
  canvas.removeEventListener('wheel', onWheel);
3913
4049
  canvas.removeEventListener('contextmenu', onContextMenu);
3914
4050
  if (menu && typeof menu.destroy === 'function') menu.destroy();