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/client.js CHANGED
@@ -76,6 +76,98 @@
76
76
  try { return JSON.stringify(error); } catch (e) { return String(error); }
77
77
  }
78
78
 
79
+ /**
80
+ * The DSH locale service's own answer, or null when it has none to give.
81
+ *
82
+ * `ctx.locale` is a cordis service whose snapshot is a plain object, and which field carries the language has
83
+ * moved between builds (`language`, then `locale`, then `current`), so all three are asked for in that order. A
84
+ * service that is absent, shaped differently or throws is NOT an error here: it means this deployment has no app
85
+ * locale to consult and the browser's own hints answer instead, which is what the try/catch is for.
86
+ */
87
+ function localeHintOf(locale) {
88
+ try {
89
+ var snapshot = locale && typeof locale.getSnapshot === 'function' ? locale.getSnapshot() : undefined;
90
+ var value = snapshot && (snapshot.language || snapshot.locale || snapshot.current);
91
+ return typeof value === 'string' && value ? value : null;
92
+ } catch (error) {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Which of the two languages this surface draws in — the ONE rule, asked every time it is needed.
99
+ *
100
+ * `core.resolveLanguage` owns the rule (an explicit 'zh'/'en' wins; 'auto' takes the FIRST hint that says
101
+ * anything, else English), and the hints are gathered here in their order of authority: the DSH locale first,
102
+ * because DSH is the application the pet lives inside, then the browser's own two (`navigator.language`, then the
103
+ * document's `lang`). English is what nothing-at-all answers, which is also the plugin's own fallback.
104
+ *
105
+ * Deliberately NOT cached in a module-level variable: the language can change while the app runs (the 界面语言
106
+ * preference, or DSH's own locale), and a captured answer is a surface stuck in the language it started in.
107
+ */
108
+ function readLanguage(locale, preference) {
109
+ var core = globalThis.__VRMPET3D_CORE__;
110
+ var hints = [localeHintOf(locale)];
111
+ if (core && typeof core.browserLanguageHints === 'function') {
112
+ hints = hints.concat(core.browserLanguageHints());
113
+ } else {
114
+ /**
115
+ * A core chunk that predates the rule — the stale-delivery case this repository has eaten more than once.
116
+ * The same two hints, in the same order, from the page itself, so an older host costs the SHARING of the rule
117
+ * rather than the language.
118
+ */
119
+ try {
120
+ hints.push(globalThis.navigator && globalThis.navigator.language);
121
+ hints.push(globalThis.document && globalThis.document.documentElement && globalThis.document.documentElement.lang);
122
+ } catch (error) { /* no window: the hints stay empty */ }
123
+ }
124
+ if (core && typeof core.resolveLanguage === 'function') return core.resolveLanguage(preference, hints);
125
+ if (preference === 'zh' || preference === 'en') return preference;
126
+ for (var i = 0; i < hints.length; i += 1) {
127
+ var hint = hints[i];
128
+ if (typeof hint !== 'string' || !hint) continue;
129
+ return /^(zh|cn|chinese)/i.test(hint.trim()) ? 'zh' : 'en';
130
+ }
131
+ return 'en';
132
+ }
133
+
134
+ /**
135
+ * The language a message with NO widget behind it is written in.
136
+ *
137
+ * The chunk loader runs before any widget exists — and before `ctx.locale` is reachable at all — so the browser's
138
+ * own two hints are the whole of what there is, and English is what nothing-at-all answers. Every message that
139
+ * HAS a widget reads that widget's own resolved language instead.
140
+ */
141
+ function ambientLanguage() {
142
+ return readLanguage(null, null);
143
+ }
144
+
145
+ /**
146
+ * One language's half of {@link STRINGS}, read at CALL time.
147
+ *
148
+ * 'zh' is the only value that selects the Chinese half: a widget whose language is missing (a stand-in built
149
+ * from the prototype) or anything unexpected reads the English half, which every entry is required to have.
150
+ */
151
+ function words(language) {
152
+ return STRINGS[language === 'zh' ? 'zh' : 'en'] || STRINGS.en;
153
+ }
154
+
155
+ /**
156
+ * One entry out of {@link STRINGS}, with `{name}` placeholders filled from `values`.
157
+ *
158
+ * A key missing from BOTH halves returns the key, so a gap is visible rather than silently blank — the rule
159
+ * `core.textOf` follows, which this cannot call because it is also used before a core chunk arrives.
160
+ */
161
+ function word(language, key, values) {
162
+ var text = words(language)[key];
163
+ if (typeof text !== 'string') text = STRINGS.en[key];
164
+ if (typeof text !== 'string') return key;
165
+ if (!values) return text;
166
+ return text.replace(/\{(\w+)\}/g, function (match, name) {
167
+ return Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match;
168
+ });
169
+ }
170
+
79
171
  /**
80
172
  * Every chunk request this half makes, resolved through the module system.
81
173
  *
@@ -108,7 +200,12 @@
108
200
  }
109
201
  return require.async(chunk.file).then(function (exports) {
110
202
  var value = exports || globalThis[chunk.global];
111
- if (!value) throw new Error('vrmpet3d: ' + chunk.file + ' loaded without publishing ' + chunk.global);
203
+ if (!value) {
204
+ // This failure has no widget to ask, so `ambientLanguage` answers: the page's own hints, else English.
205
+ throw new Error('vrmpet3d: ' + word(ambientLanguage(), 'chunkNoPublish', {
206
+ file: chunk.file, global: chunk.global,
207
+ }));
208
+ }
112
209
  loaded[chunk.global] = value;
113
210
  return publish(chunk, value);
114
211
  }, function (error) {
@@ -122,7 +219,7 @@
122
219
 
123
220
  return pending.then(function () {
124
221
  if (missing.length) {
125
- throw new Error('vrmpet3d: DSH did not deliver ' + missing.join('; '));
222
+ throw new Error('vrmpet3d: ' + word(ambientLanguage(), 'chunksMissing', { list: missing.join('; ') }));
126
223
  }
127
224
  return loaded;
128
225
  });
@@ -367,7 +464,12 @@
367
464
  */
368
465
  function Placement(preferences, now, core) {
369
466
  if (!core || typeof core.stepFall !== 'function') {
370
- throw new Error('vrmpet3d: Placement needs the core module (stepFall).');
467
+ /**
468
+ * Said in the language the preferences name, because a Placement is built before any widget exists to be
469
+ * asked — the first one is constructed by the widget itself, and the floating window's by its own page.
470
+ */
471
+ throw new Error('vrmpet3d: ' + word(
472
+ readLanguage(null, preferences && preferences.language), 'placementNoCore'));
371
473
  }
372
474
  this.core = core;
373
475
  this.preferences = preferences;
@@ -673,22 +775,40 @@
673
775
 
674
776
  /**
675
777
  * @param {HTMLElement} host the element the slot gave us
676
- * @param {object} options { vendor, language, onScopeChange, onModelChange }
778
+ * @param {object} options { locale, language, onScopeChange, onModelChange }
677
779
  */
678
780
  function VrmPet3DWidget(host, options) {
679
781
  options = options || {};
680
782
  this.host = host;
681
783
  this.core = globalThis.__VRMPET3D_CORE__;
682
784
  this.sceneApi = globalThis.__VRMPET3D_SCENE__;
683
- if (!this.core) throw new Error('vrmpet3d: lib/core.js is not loaded.');
684
- if (!this.sceneApi) throw new Error('vrmpet3d: lib/scene.js is not loaded.');
685
-
686
- this.language = options.language === 'zh' ? 'zh' : 'en';
785
+ /**
786
+ * The DSH locale SERVICE itself, kept rather than a snapshot of it.
787
+ *
788
+ * The language has to be re-resolvable while the pet runs (`adoptLanguage`), because
789
+ * `preferences.language === 'auto'` means "whatever DSH says" and DSH's own locale can change under it.
790
+ */
791
+ this.locale = options.locale || null;
792
+ /**
793
+ * An EXPLICIT language is an override, and that is what the probes and the suites inject (`{language:'zh'}`);
794
+ * the real mount passes `locale` and lets the user's preference decide.
795
+ */
796
+ this.injectedLanguage = options.language === 'zh' || options.language === 'en' ? options.language : null;
797
+ /**
798
+ * Resolved BEFORE the two failures below, because those messages are shown to the user in a badge and have to
799
+ * be said in the same language as everything else. The preference is not readable yet (`readStorage` needs
800
+ * `this.document`, and that needs a core), so this pass has DSH's locale and the browser's hints only; the
801
+ * line after `readStorage()` re-resolves with the user's own answer.
802
+ */
803
+ this.language = this.injectedLanguage || readLanguage(this.locale, null);
804
+ if (!this.core) throw new Error('vrmpet3d: ' + word(this.language, 'mountNoCore'));
805
+ if (!this.sceneApi) throw new Error('vrmpet3d: ' + word(this.language, 'mountNoScene'));
687
806
  this.onScopeChange = typeof options.onScopeChange === 'function' ? options.onScopeChange : function () {};
688
807
  this.onModelChange = typeof options.onModelChange === 'function' ? options.onModelChange : function () {};
689
808
  this.document = host.ownerDocument || document;
690
809
 
691
810
  this.preferences = this.readStorage();
811
+ this.language = this.injectedLanguage || readLanguage(this.locale, this.preferences.language);
692
812
  this.placement = new Placement(this.preferences, function () { return Date.now(); }, this.core);
693
813
 
694
814
  this.machine = new this.core.PetMachine(function () { return Date.now(); }, this.preferences.sleepAfterMs);
@@ -985,6 +1105,13 @@
985
1105
  // The model is ONE setting for both surfaces, so a change made in the window or in the settings
986
1106
  // window moves this pet too - there is no per-surface model to keep in step.
987
1107
  self.preferences = next;
1108
+ /**
1109
+ * 界面语言 follows the snapshot like every other preference, and only NOW is the answer readable:
1110
+ * `preferences.language` may be an explicit 中文/English or `auto` (what DSH's own locale says). A language
1111
+ * that actually CHANGED re-says this surface's own words and rebuilds the shared menu; nothing else about
1112
+ * the pet is touched.
1113
+ */
1114
+ self.adoptLanguage();
988
1115
  if (self.scene) self.scene.applyPreferences(Object.assign({}, next, { motions: self.effectiveMotions() }));
989
1116
  self.applyMotions();
990
1117
  self.applyPlacement();
@@ -1052,6 +1179,8 @@
1052
1179
  if (event && event.key && !known) return;
1053
1180
  var next = self.readStorage();
1054
1181
  self.preferences = next;
1182
+ // The other document may have been the settings window's 界面语言 field: same rule as the host snapshot.
1183
+ self.adoptLanguage();
1055
1184
  if (self.scene) self.scene.applyPreferences(Object.assign({}, next, { motions: self.effectiveMotions() }));
1056
1185
  self.applyMotions();
1057
1186
  self.applyPlacement();
@@ -1198,7 +1327,7 @@
1198
1327
  };
1199
1328
 
1200
1329
  VrmPet3DWidget.prototype.applyStrings = function () {
1201
- var text = STRINGS[this.language];
1330
+ var text = words(this.language);
1202
1331
  /**
1203
1332
  * The ONE label this surface still writes by hand.
1204
1333
  *
@@ -1210,6 +1339,37 @@
1210
1339
  this.pet.setAttribute('aria-label', text.ariaPet);
1211
1340
  };
1212
1341
 
1342
+ /**
1343
+ * Follow 界面语言 — the preference, DSH's locale, and this surface's own words.
1344
+ *
1345
+ * Called from every path that can change the preference (a host snapshot, a storage write from another
1346
+ * document) because 'auto' means "whatever DSH says" and DSH's locale can change under a running pet. Only a
1347
+ * CHANGE does anything: this rebuilds the menu, so running it on every poll would throw away and rebuild a live
1348
+ * element every few seconds for nothing.
1349
+ *
1350
+ * What is re-said, and why exactly these three:
1351
+ * * the restore label and the aria name are the only strings this surface CAPTURES — everything else is read
1352
+ * out of the table when it is drawn, which is the rule that makes a live language change possible at all;
1353
+ * * the shared menu carries its words from `createMenu` and cannot be re-worded in place, so it is rebuilt
1354
+ * (the old element is destroyed by `buildMenu`, not left in the document);
1355
+ * * the prompt is drawn from `core.statusFor(..., this.language)` on the next frame, and its cached key is
1356
+ * dropped so "next frame" is when the new words arrive rather than "the next time the line differs";
1357
+ * * the 碎碎念 already on screen is the machine's own held line and is left alone — it expires on its own
1358
+ * clock, and the remark after it is picked in the new language. Clearing the machine here would also throw
1359
+ * away its throttle memory, which is a change nobody asked for.
1360
+ */
1361
+ VrmPet3DWidget.prototype.adoptLanguage = function () {
1362
+ // A stand-in built from the prototype (a probe's own widget) has no language to change and no DOM to re-say.
1363
+ if (!this.language) return;
1364
+ var wanted = this.injectedLanguage
1365
+ || readLanguage(this.locale, this.preferences && this.preferences.language);
1366
+ if (wanted === this.language) return;
1367
+ this.language = wanted;
1368
+ this.applyStrings();
1369
+ this.buildMenu();
1370
+ this.headStatusKey = null;
1371
+ };
1372
+
1213
1373
  VrmPet3DWidget.prototype.bindMenu = function () {
1214
1374
  var self = this;
1215
1375
 
@@ -1510,7 +1670,7 @@
1510
1670
  var fallback = function (reason) {
1511
1671
  var opened = null;
1512
1672
  try { opened = globalThis.open(url, '_blank'); } catch (error) { opened = null; }
1513
- if (!opened) self.showStatus((STRINGS[self.language].configNote || '') + ' ' + (reason || ''));
1673
+ if (!opened) self.showStatus((words(self.language).configNote || '') + ' ' + (reason || ''));
1514
1674
  };
1515
1675
  try {
1516
1676
  fetch('/vrmpet3d/desktop', {
@@ -1523,9 +1683,11 @@
1523
1683
  // placeholder while a VRM was being shown.
1524
1684
  body: JSON.stringify({ action: which, modelId: self.currentModelId() }),
1525
1685
  }).then(function (response) {
1526
- if (!response.ok) fallback('the launcher could not open a window');
1686
+ // Both reasons are APPENDED to `configNote` and shown in the badge, so they are user-visible sentences of
1687
+ // this surface's own and come out of the table like the rest.
1688
+ if (!response.ok) fallback(words(self.language).settingsNoWindow);
1527
1689
  }).catch(function () {
1528
- fallback('the host is not reachable');
1690
+ fallback(words(self.language).settingsUnreachable);
1529
1691
  });
1530
1692
  } catch (error) {
1531
1693
  fallback(error && error.message);
@@ -1577,7 +1739,7 @@
1577
1739
  */
1578
1740
  var opened = null;
1579
1741
  try { opened = globalThis.open('/vrmpet3d/setup', '_blank'); } catch (error) { opened = null; }
1580
- if (!opened) self.showStatus((STRINGS[self.language].setupNote || ''));
1742
+ if (!opened) self.showStatus((words(self.language).setupNote || ''));
1581
1743
  return null;
1582
1744
  });
1583
1745
  })
@@ -1655,6 +1817,14 @@
1655
1817
  */
1656
1818
  VrmPet3DWidget.prototype.buildMenu = function () {
1657
1819
  var api = globalThis.__VRMPET3D_MENU__;
1820
+ /**
1821
+ * A REBUILD replaces the element rather than leaving the old one in the document — the menu is DOM, and a
1822
+ * second copy would keep its own document listeners and its own words. `adoptLanguage` is the one caller that
1823
+ * rebuilds (the menu carries its labels from `createMenu` and cannot be re-worded in place), and the first
1824
+ * call finds `this.menu` null.
1825
+ */
1826
+ if (this.menu && typeof this.menu.destroy === 'function') this.menu.destroy();
1827
+ this.menu = null;
1658
1828
  if (!api || typeof api.createMenu !== 'function') return null;
1659
1829
  this.menuAvailable = true;
1660
1830
  api.injectStyle(this.document, 'vrmpet3d-menu-style');
@@ -2000,7 +2170,7 @@
2000
2170
  */
2001
2171
  VrmPet3DWidget.prototype.applySurface = function (attempt) {
2002
2172
  var self = this;
2003
- var strings = STRINGS[this.language] || STRINGS.en;
2173
+ var strings = words(this.language);
2004
2174
  var surface = this.preferences.petSurface || 'auto';
2005
2175
  /**
2006
2176
  * What was last ASKED for, which is not the same as what is running: the window can fail to start. It is
@@ -2066,7 +2236,7 @@
2066
2236
 
2067
2237
  VrmPet3DWidget.prototype.setDesktopWindow = function (enabled) {
2068
2238
  var self = this;
2069
- var strings = STRINGS[this.language] || STRINGS.en;
2239
+ var strings = words(this.language);
2070
2240
  this.syncDesktop(null, strings.desktopStarting);
2071
2241
  return fetch('/vrmpet3d/desktop', {
2072
2242
  method: 'POST',
@@ -2099,7 +2269,7 @@
2099
2269
  .catch(function () {
2100
2270
  // A session without the route (an older host half) is RECORDED rather than thrown: `desktopNote` is kept
2101
2271
  // as data (see `syncDesktop`), so "the window could not start" stays answerable.
2102
- var strings = STRINGS[self.language] || STRINGS.en;
2272
+ var strings = words(self.language);
2103
2273
  self.syncDesktop(null, strings.desktopUnavailable);
2104
2274
  return null;
2105
2275
  });
@@ -2115,7 +2285,7 @@
2115
2285
  * is the whole reason the host reports the page's own verdict.
2116
2286
  */
2117
2287
  VrmPet3DWidget.prototype.syncDesktop = function (status, note) {
2118
- var strings = STRINGS[this.language] || STRINGS.en;
2288
+ var strings = words(this.language);
2119
2289
  this.desktopStatus = status || null;
2120
2290
  if (note) {
2121
2291
  this.desktopNote = note;
@@ -2796,7 +2966,7 @@
2796
2966
  var self = this;
2797
2967
  return fetch('/vrmpet3d/catalog', { credentials: 'omit' })
2798
2968
  .then(function (response) {
2799
- if (!response.ok) throw new Error('catalog route returned HTTP ' + response.status);
2969
+ if (!response.ok) throw new Error(word(self.language, 'catalogRoute', { status: response.status }));
2800
2970
  return response.json();
2801
2971
  })
2802
2972
  .then(function (body) {
@@ -2886,15 +3056,23 @@
2886
3056
  if (!wanted) {
2887
3057
  var ids = this.danceClipIds();
2888
3058
  if (!ids.length) {
2889
- this.showStatus('没有可用的舞蹈动作(assets/motions/dance 是空的)');
3059
+ // The same sentence the floating window's 跳支舞 puts in its bubble (see `lib/desktop-page.js`).
3060
+ this.showStatus(words(this.language).danceEmpty);
2890
3061
  return false;
2891
3062
  }
3063
+ var current = this.machine.dancing && this.machine.dancing.motionId;
3064
+ var alternatives = ids.filter(function (id) { return id !== current; });
3065
+ if (alternatives.length) ids = alternatives;
2892
3066
  wanted = ids[Math.floor(Math.random() * ids.length)];
2893
3067
  }
2894
3068
  // A generous deadline: the clip's own end is what normally stops this, and a dance that outstays its welcome is worse
2895
3069
  // than one that is cut short at three minutes.
2896
3070
  if (!this.machine.setDancing(wanted, 180000)) return false;
2897
- if (this.scene) this.scene.setPreview(null);
3071
+ if (this.scene) {
3072
+ this.scene.setPreview(null);
3073
+ // A previous run may already be finished, even with no preview left to clear.
3074
+ if (this.scene.playback && this.scene.playback.state === 'dancing') this.scene.stopPlayback(null);
3075
+ }
2898
3076
  var self = this;
2899
3077
  var dance = this.machine.dancing;
2900
3078
  this.ensureMotion(wanted, false).then(function () {
@@ -2908,7 +3086,10 @@
2908
3086
  /** End a dance early — the double-click gesture, and the clip's own end. */
2909
3087
  VrmPet3DWidget.prototype.stopDance = function () {
2910
3088
  if (!this.machine || typeof this.machine.stopDancing !== 'function') return null;
2911
- return this.machine.stopDancing();
3089
+ var stopped = this.machine.stopDancing();
3090
+ if (this.scene && this.scene.preview && this.scene.preview.state === 'dancing') this.scene.setPreview(null);
3091
+ if (stopped && !this.disposed) this.pump();
3092
+ return stopped;
2912
3093
  };
2913
3094
 
2914
3095
  VrmPet3DWidget.prototype.reloadModel = function () { var self = this;
@@ -2919,10 +3100,15 @@
2919
3100
  * answered from the HTTP cache — and "reload" that returns the cached bytes is a button that does nothing.
2920
3101
  */
2921
3102
  if (typeof this.scene.bustModelCache === 'function') this.scene.bustModelCache();
2922
- this.showStatus('正在重载模型…');
3103
+ // The badge's three lines of 重载模型, read out of the table at each moment it is said — the language can change
3104
+ // while the load is in flight — and the same three the floating window says in its bubble.
3105
+ this.showStatus(words(this.language).reloading);
2923
3106
  return this.loadModel(id).then(function (status) {
2924
3107
  // Say what happened, because the visible difference between a good reload and a cached one is nothing at all.
2925
- if (self.statusNode) self.showStatus(status && status.ok ? '模型已重载' : ((status && status.error) || '模型重载失败'));
3108
+ if (self.statusNode) {
3109
+ var done = words(self.language);
3110
+ self.showStatus(status && status.ok ? done.reloaded : ((status && status.error) || done.reloadFailed));
3111
+ }
2926
3112
  return status;
2927
3113
  });
2928
3114
  };
@@ -3097,7 +3283,7 @@
3097
3283
  * where the clips are chosen.
3098
3284
  */
3099
3285
  VrmPet3DWidget.prototype.refreshMotionNotice = function () {
3100
- var text = STRINGS[this.language];
3286
+ var text = words(this.language);
3101
3287
  var message = null;
3102
3288
 
3103
3289
  if (!this.motionCatalog) {
@@ -3455,7 +3641,7 @@
3455
3641
  VrmPet3DWidget.prototype.fetchMotionBytes = function (motionId) {
3456
3642
  var url = '/vrmpet3d/motion?m=' + encodeURIComponent(motionId);
3457
3643
  return fetch(url, { credentials: 'omit' }).then(function (response) {
3458
- if (!response.ok) throw new Error('motion route returned HTTP ' + response.status);
3644
+ if (!response.ok) throw new Error(word(this.language, 'motionRoute', { status: response.status }));
3459
3645
  return response.arrayBuffer();
3460
3646
  });
3461
3647
  };
@@ -3516,7 +3702,10 @@
3516
3702
  self.motionLoaded[motionId] = status;
3517
3703
  self.motionLoadedLoop[motionId] = declaredLoop;
3518
3704
  } else {
3519
- var message = (status && status.reason) || ('vrmpet3d: could not load motion ' + motionId);
3705
+ // A scene that gave a reason has already said it in its own words; this is the widget's own sentence, and
3706
+ // `vrmpet3d:` stays in front of it because that prefix is what a report greps for.
3707
+ var message = (status && status.reason)
3708
+ || ('vrmpet3d: ' + word(self.language, 'motionLoad', { id: motionId }));
3520
3709
  self.showStatus(message);
3521
3710
  self.motionStatusMessage = message;
3522
3711
  }
@@ -3747,6 +3936,9 @@
3747
3936
  var now = Date.now();
3748
3937
  if (now - (this.focusCheckedAt || 0) >= FOCUS_POLL_MS) {
3749
3938
  this.focusCheckedAt = now;
3939
+ // Locale changes do not advance the plugin settings version. Re-read alongside focus,
3940
+ // including background/wake pumps, so auto follows DSH without another settings write.
3941
+ this.adoptLanguage();
3750
3942
  var doc = this.document;
3751
3943
  var focused = true;
3752
3944
  try {
@@ -3827,7 +4019,7 @@
3827
4019
  // The TURN clock, not the state's own age: 用时 is a claim about DSH's turn (`PetMachine.statusClock`), and the
3828
4020
  // token is dropped entirely while no turn is open — DSH's own token-free 「深度求索中」.
3829
4021
  var clock = this.machine && typeof this.machine.statusClock === 'function' ? this.machine.statusClock() : undefined;
3830
- var line = this.core.statusFor(this.preferences, state, this.machine.stateSince, Date.now(), clock);
4022
+ var line = this.core.statusFor(this.preferences, state, this.machine.stateSince, Date.now(), clock, this.language);
3831
4023
  /**
3832
4024
  * A state with no line clears the prompt rather than leaving the previous one up: a caption that
3833
4025
  * outlives its state is how a pet ends up saying 深度求索中 over a finished turn.
@@ -4180,7 +4372,8 @@
4180
4372
  if (this.machine && typeof this.machine.dancingNow === 'function' && this.machine.dancing) {
4181
4373
  var dancing = this.machine.dancingNow();
4182
4374
  var playing = this.scene.playback;
4183
- if (dancing && playing && playing.state === 'dancing' && playing.record && playing.record.finished === true) {
4375
+ if (dancing && playing && playing.state === 'dancing' && playing.id === dancing.motionId && playing.record
4376
+ && this.scene.motionFinished(playing.record)) {
4184
4377
  this.machine.stopDancing();
4185
4378
  }
4186
4379
  }
@@ -4598,7 +4791,14 @@
4598
4791
  return out;
4599
4792
  }
4600
4793
 
4601
- /** UI strings. Two languages, chosen from the DSH locale service. */
4794
+ /**
4795
+ * This surface's own words, in the two languages.
4796
+ *
4797
+ * One table per surface, `{ zh: {...}, en: {...} }` with IDENTICAL key sets — the repository's shape (see
4798
+ * `lib/menu.js`'s `LABELS`), so a language half that is missing an entry is a line that reads in the other
4799
+ * language rather than a line that is blank. Read through `words`/`word` at CALL time, never captured into a
4800
+ * variable: the language can change while the pet runs.
4801
+ */
4602
4802
  var STRINGS = {
4603
4803
  en: {
4604
4804
  /* The floating window's own state, kept as data by `syncDesktop`. */
@@ -4625,6 +4825,32 @@
4625
4825
  vrm: 'This VRM model is driven by humanoid bones, so only .vrma clips work here — no .vrma clip was found.',
4626
4826
  other: 'The loaded avatar has no skeleton, so no motion clip can drive it. Load a VRM model first.',
4627
4827
  },
4828
+ /*
4829
+ * The pet's own OPERATIONS, said in the badge: 跳支舞 with nothing to dance to, and the three moments of
4830
+ * 重载模型. These are the SAME four events the floating window puts in its bubble, so the two surfaces'
4831
+ * wording is kept in step by hand — a drift here is the pet telling the user two different things about one
4832
+ * gesture. `assets/motions/dance` is a path, not words, and is written the same way in both languages.
4833
+ */
4834
+ danceEmpty: 'No dance clip is available (assets/motions/dance is empty).',
4835
+ reloading: 'Reloading the model…',
4836
+ reloaded: 'Model reloaded',
4837
+ reloadFailed: 'Could not reload the model',
4838
+ /*
4839
+ * DIAGNOSTICS, shown in the badge (a chunk that never arrived, a constructor that threw). `vrmpet3d:` stays
4840
+ * in front of every one of them because it is a greppable marker in a report and a bug report quotes it; the
4841
+ * sentence AFTER it is what a user reads. The names in braces are data — a file, a global, an id, a number.
4842
+ */
4843
+ mountNoCore: 'lib/core.js is not loaded.',
4844
+ mountNoScene: 'lib/scene.js is not loaded.',
4845
+ placementNoCore: 'Placement needs the core module (stepFall).',
4846
+ chunkNoPublish: '{file} loaded without publishing {global}',
4847
+ chunksMissing: 'DSH did not deliver {list}',
4848
+ catalogRoute: 'catalog route returned HTTP {status}',
4849
+ motionRoute: 'motion route returned HTTP {status}',
4850
+ motionLoad: 'could not load motion {id}',
4851
+ // The two reasons 打开设置 can fail, appended to `configNote` and shown in the badge.
4852
+ settingsNoWindow: 'the launcher could not open a window',
4853
+ settingsUnreachable: 'the host is not reachable',
4628
4854
  },
4629
4855
  zh: {
4630
4856
  /* 悬浮窗自身的状态,由 `syncDesktop` 作为数据保存。 */
@@ -4648,6 +4874,23 @@
4648
4874
  vrm: 'VRM 模型使用人形骨骼,因此只能用 .vrma 动作文件——未找到 .vrma 文件。',
4649
4875
  other: '当前替身没有骨骼,任何动作文件都无法驱动它。请先加载 VRM 模型。',
4650
4876
  },
4877
+ /* 宠物自身的操作提示,写在角标里;与外置悬浮窗气泡里的同一件事保持同一套措辞。路径不翻译。 */
4878
+ danceEmpty: '没有可用的舞蹈动作(assets/motions/dance 是空的)',
4879
+ reloading: '正在重载模型…',
4880
+ reloaded: '模型已重载',
4881
+ reloadFailed: '模型重载失败',
4882
+ /* 诊断信息:角标里给用户看的。`vrmpet3d:` 前缀保留,它是报告里可检索的标记;花括号里是数据。 */
4883
+ mountNoCore: '未加载 lib/core.js。',
4884
+ mountNoScene: '未加载 lib/scene.js。',
4885
+ placementNoCore: 'Placement 需要 core 模块(stepFall)。',
4886
+ chunkNoPublish: '{file} 加载后没有发布 {global}',
4887
+ chunksMissing: 'DSH 没有下发 {list}',
4888
+ catalogRoute: '模型目录路由返回 HTTP {status}',
4889
+ motionRoute: '动作路由返回 HTTP {status}',
4890
+ motionLoad: '无法加载动作 {id}',
4891
+ /* 打开设置失败的两种原因,接在 `configNote` 后面显示在角标里。 */
4892
+ settingsNoWindow: '启动器无法打开窗口',
4893
+ settingsUnreachable: '无法连接到宿主',
4651
4894
  }
4652
4895
  };
4653
4896
 
@@ -4655,18 +4898,6 @@
4655
4898
  // Plugin
4656
4899
  // ===========================================================================
4657
4900
 
4658
- function readLanguage(locale) {
4659
- try {
4660
- var snapshot = locale && typeof locale.getSnapshot === 'function' ? locale.getSnapshot() : undefined;
4661
- var value = snapshot && (snapshot.language || snapshot.locale || snapshot.current);
4662
- if (typeof value === 'string' && value.toLowerCase().indexOf('zh') === 0) return 'zh';
4663
- var lang = document.documentElement.lang || '';
4664
- return lang.toLowerCase().indexOf('zh') === 0 ? 'zh' : 'en';
4665
- } catch (error) {
4666
- return 'en';
4667
- }
4668
- }
4669
-
4670
4901
  /**
4671
4902
  * Subscribe to live turn boundaries over the plugin's own host service.
4672
4903
  *
@@ -4819,7 +5050,12 @@
4819
5050
  if (disposed) return;
4820
5051
  try {
4821
5052
  widget = new VrmPet3DWidget(host, {
4822
- language: readLanguage(ctx.locale),
5053
+ /**
5054
+ * The DSH locale SERVICE, not a snapshot of it: the widget resolves 界面语言 itself — from the
5055
+ * user's `preferences.language`, this locale and the browser's hints — and re-resolves it
5056
+ * whenever the settings change (`adoptLanguage`), because `auto` means "whatever DSH says".
5057
+ */
5058
+ locale: ctx.locale,
4823
5059
  onScopeChange: function (scope) { if (changeScope.current) changeScope.current(scope); },
4824
5060
  /**
4825
5061
  * Report the state to the host, so the floating window can show the same prompt.