dsh-plugin-vrmpet3d 0.1.0 → 0.1.2

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.
@@ -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
  *
@@ -121,6 +164,7 @@
121
164
  */
122
165
  '.vrmpet3d-desktop-status[data-tone="gold"]{color:#ffc94d}',
123
166
  '.vrmpet3d-desktop-status[data-tone="gold"][data-bold="1"]{font-weight:700}',
167
+ '.vrmpet3d-desktop-status[data-attention="1"]{pointer-events:auto;cursor:pointer;text-shadow:0 0 4px currentColor,0 0 10px currentColor,0 0 18px currentColor}',
124
168
  '.vrmpet3d-desktop-status[data-tone="danger"]{color:#ff6b6b}',
125
169
  /**
126
170
  * 碎碎念: the pet's own short remark, above the prompt.
@@ -179,6 +223,7 @@
179
223
  '.vrmpet3d-desktop-status-content{display:flex;align-items:center;min-width:0}',
180
224
  /* `nowrap`, always: the operator's "提示文字永不换行". One line, however long the line is. */
181
225
  '.vrmpet3d-desktop-status-text{color:inherit;font-variant-numeric:tabular-nums;white-space:nowrap}',
226
+ '.vrmpet3d-desktop-status-dot{pointer-events:auto;cursor:pointer}',
182
227
  '.vrmpet3d-desktop-status-text[data-shimmer-text]::after{content:attr(data-shimmer-text)}',
183
228
  '.vrmpet3d-desktop-status-decoration{position:absolute;inset:0;overflow:clip;pointer-events:none;user-select:none}',
184
229
  '.vrmpet3d-desktop-status-decoration[hidden]{display:none}',
@@ -593,8 +638,21 @@
593
638
  // 是不对的。删掉两个单独的设置面板。」); every setting it used to offer is on one of the two settings pages.
594
639
  // ---------------------------------------------------------------------------------------
595
640
  var doc = canvas.ownerDocument || globalThis.document || null;
596
- var language = options.language
597
- || ((globalThis.navigator && /^zh/i.test(globalThis.navigator.language || '')) ? 'zh' : 'en');
641
+ /**
642
+ * 界面语言, resolved through the ONE shared rule rather than guessed here.
643
+ *
644
+ * An explicit `preferences.language` wins, and `auto` follows the FIRST hint that says anything — and this
645
+ * page has no DSH locale to ask (it is served by the launcher, not by DSH), so the hints are the browser's own
646
+ * two: `navigator.language`, which in this window is Electron's and therefore the machine's, and then the
647
+ * document's `lang`. English is what nothing-at-all answers.
648
+ *
649
+ * A core chunk that predates the rule (the stale-delivery case this repository has eaten more than once) falls
650
+ * back to the browser hint this page used before, so an older host costs the SHARING of the rule, not the
651
+ * language. The preference is re-read by `adoptLanguage` whenever the settings change.
652
+ */
653
+ var language = typeof core.resolveLanguage === 'function'
654
+ ? core.resolveLanguage(preferences.language, core.browserLanguageHints())
655
+ : ((globalThis.navigator && /^zh/i.test(globalThis.navigator.language || '')) ? 'zh' : 'en');
598
656
  installDesktopStyles(doc);
599
657
  var menu = null;
600
658
  var menuOpen = false;
@@ -659,10 +717,9 @@
659
717
  * 旧的 `client.menu.js`,而那个 chunk 里这一行还在。这时它必须照旧工作,而不是掉出函数末尾什么都不做 ——
660
718
  * 一个点了没反应的菜单项,比一个不显示的行难查得多。
661
719
  *
662
- * 动作本身没变:宠物回到 DSH 里,而这个窗由 APP 关掉(一个写者;见 `applySurface`/`adoptSurface`)。
720
+ * 宠物回到 DSH 里;本页持久化选择后直接请宿主关闭浮窗,不依赖 DSH 页面在前台。
663
721
  */
664
- preferences.petSurface = 'app';
665
- writePreferences(store, core, preferences);
722
+ returnToApp();
666
723
  return;
667
724
  }
668
725
  /**
@@ -682,13 +739,12 @@
682
739
  /**
683
740
  * 关闭独立宠物: the pet goes back INSIDE the app, which is the only thing closing this window can mean.
684
741
  *
685
- * It is the same write as 切换回窗口内 — this window is the pet's box, so a window with no pet in it is
686
- * nothing at all, and the app is what stops this window (one writer; see the `surface` branch above).
742
+ * Persist the same choice as 切换回窗口内, then ask the host to stop this window.
743
+ * The app renderer may be paused in the background; it reads that choice when it wakes.
687
744
  * The row was 关闭宠物 until 2026-10-12 and used to HIDE the pet as well; the operator split the two
688
745
  * meanings then, keeping hiding for the app's pet (隐藏宠物) and this one for handing the pet back.
689
746
  */
690
- preferences.petSurface = 'app';
691
- writePreferences(store, core, preferences);
747
+ returnToApp();
692
748
  return;
693
749
  }
694
750
  if (id === 'restartHarness' || id === 'quitHarness') {
@@ -699,6 +755,26 @@
699
755
  }
700
756
  }
701
757
 
758
+ function returnToApp() {
759
+ preferences.petSurface = 'app';
760
+ // Persist first, then stop this window through the host. A background DSH
761
+ // renderer may not run its settings callback until it becomes foreground.
762
+ return fetch('/vrmpet3d/settings', {
763
+ method: 'POST', headers: { 'content-type': 'application/json' },
764
+ body: JSON.stringify({ preferences: { petSurface: 'app' } }),
765
+ }).then(function (response) { return response.ok ? response.json() : null; })
766
+ .then(function (snapshot) {
767
+ if (!snapshot || !snapshot.preferences || snapshot.preferences.petSurface !== 'app') return null;
768
+ preferences.petSurface = 'app';
769
+ try { if (store) store.setItem(core.STORAGE_KEY, JSON.stringify(preferences)); } catch (_error) { /* host is authoritative */ }
770
+ return fetch('/vrmpet3d/desktop', {
771
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'disable' }),
772
+ }).then(function (response) {
773
+ if (!response.ok && bridge && typeof bridge.quit === 'function') bridge.quit();
774
+ }, function () { if (bridge && typeof bridge.quit === 'function') bridge.quit(); });
775
+ }).catch(function () { return null; });
776
+ }
777
+
702
778
  /**
703
779
  * Push this surface's 重力 (`windowGravity`) into the placement.
704
780
  *
@@ -725,6 +801,13 @@
725
801
  */
726
802
  function buildMenu() {
727
803
  var api = globalThis.__VRMPET3D_MENU__;
804
+ /**
805
+ * A REBUILD replaces the element rather than leaving the old one in the body: the menu carries its words
806
+ * from `createMenu` and cannot be re-worded in place, so `adoptLanguage` builds a new one — and a second
807
+ * copy would keep its own document listeners. `menuOpen`/`menuBox` are the page's own and are cleared by
808
+ * `closeMenu`/`onMenuClosed` before this runs.
809
+ */
810
+ if (menu && typeof menu.destroy === 'function') menu.destroy();
728
811
  if (!api || typeof api.createMenu !== 'function' || !doc) return null;
729
812
  api.injectStyle(doc, 'vrmpet3d-menu-style');
730
813
  return api.createMenu({
@@ -769,6 +852,7 @@
769
852
  */
770
853
  function openMenuAt(frameX, frameY) {
771
854
  if (!menu) return false;
855
+ prepareDance();
772
856
  /**
773
857
  * 非debug模式生效, read at OPEN time.
774
858
  *
@@ -790,6 +874,7 @@
790
874
  if (typeof menu.setChecked === 'function') {
791
875
  menu.setChecked('gravity', preferences.windowGravity === true);
792
876
  }
877
+ if (menu.setDanceState) menu.setDanceState(hostState);
793
878
  var size = menu.open();
794
879
  var width = Math.min(size.width, Math.max(80, placement.viewportWidth - MENU_MARGIN * 2));
795
880
  var height = Math.min(size.height, Math.max(80, placement.viewportHeight - MENU_MARGIN * 2));
@@ -836,6 +921,40 @@
836
921
  }
837
922
 
838
923
  menu = buildMenu();
924
+ /**
925
+ * Follow 界面语言 — the preference, this page's environment, and its own four sentences.
926
+ *
927
+ * Called from every path that replaces `preferences` (a host snapshot, a storage write from the settings
928
+ * window), because the language is a preference like any other and a change made there has to reach this
929
+ * window without a reload. 'auto' means what the browser says here, and the browser can be re-read at any
930
+ * time; only a CHANGE does anything, since this rebuilds the menu.
931
+ *
932
+ * What a change touches, and why exactly these:
933
+ * * `language` is read at CALL time by everything that words this page (the say request, the shipped pool
934
+ * for a touched part, `core.statusFor`'s default line and its clock units, the published readout, the four
935
+ * bubble sentences), so a new value reaches all of them by itself;
936
+ * * the MENU carries its labels from `createMenu`, so it is rebuilt — and a menu that is OPEN is closed
937
+ * first, because its rows are the old words and its closing is what gives the window its pet-sized box
938
+ * back;
939
+ * * the prompt is redrawn here rather than on the next frame: this page's frame loop re-renders on a cached
940
+ * key, and a language change does not alter the STATE, so the cached line would otherwise keep the old
941
+ * words until the state changed.
942
+ */
943
+ function adoptLanguage() {
944
+ var next = typeof core.resolveLanguage === 'function'
945
+ ? core.resolveLanguage(preferences.language, core.browserLanguageHints())
946
+ : ((globalThis.navigator && /^zh/i.test(globalThis.navigator.language || '')) ? 'zh' : 'en');
947
+ if (next === language) return;
948
+ language = next;
949
+ if (menuOpen) closeMenu();
950
+ menu = buildMenu();
951
+ statusKey = null;
952
+ renderStatus();
953
+ layout();
954
+ reportRect();
955
+ publish();
956
+ }
957
+
839
958
  /**
840
959
  * The model catalog, fetched once — and re-read whenever the store names a model this list cannot answer for
841
960
  * (`refreshCatalog`). See the snapshot handler for the import-while-running case that made that necessary.
@@ -890,6 +1009,24 @@
890
1009
  var speechNode = null;
891
1010
  var speechBox = null;
892
1011
  var hostSpeech = null;
1012
+ var hostSpeechUntil = 0;
1013
+ var hostSpeechTimer = null;
1014
+
1015
+ function receiveHostSpeech(line) {
1016
+ var next = typeof line === 'string' && line ? line : null;
1017
+ if (next === hostSpeech) return;
1018
+ if (hostSpeechTimer) clearTimeout(hostSpeechTimer);
1019
+ hostSpeechTimer = null;
1020
+ hostSpeech = next;
1021
+ var hold = preferences && preferences.speech && Number(preferences.speech.holdSeconds);
1022
+ hostSpeechUntil = next ? Date.now() + (hold > 0 ? hold : 8) * 1000 : 0;
1023
+ if (next) hostSpeechTimer = setTimeout(function () {
1024
+ hostSpeechTimer = null;
1025
+ renderSpeech();
1026
+ reportRect();
1027
+ publish();
1028
+ }, Math.max(0, hostSpeechUntil - Date.now()));
1029
+ }
893
1030
  var speechShown = null;
894
1031
  /**
895
1032
  * A line THIS page said, because its own pet was touched, and until when.
@@ -1011,6 +1148,17 @@
1011
1148
  statusNode.appendChild(statusIconNode);
1012
1149
  statusNode.appendChild(shimmer);
1013
1150
  doc.body.appendChild(statusNode);
1151
+ statusDotNode.title = language === 'en' ? 'Open completed conversation' : '打开已完成的会话';
1152
+ statusNode.addEventListener('click', function (event) {
1153
+ var completed = event.target === statusDotNode && ['working', 'waiting'].indexOf(hostState) !== -1 && Number(hostUnread) > 0;
1154
+ if (!completed && ['done', 'waiting', 'error'].indexOf(hostState) === -1) return;
1155
+ fetch('/vrmpet3d/desktop', {
1156
+ method: 'POST', headers: { 'content-type': 'application/json' },
1157
+ body: JSON.stringify({ action: completed ? 'open-completed-session' : 'open-status-session' }),
1158
+ }).then(function (response) { return response.ok ? response.json() : null; }).then(function (body) {
1159
+ if (body && body.ok && globalThis.vrmpet3dDesktop && globalThis.vrmpet3dDesktop.focusHost) globalThis.vrmpet3dDesktop.focusHost();
1160
+ }).catch(function () {});
1161
+ });
1014
1162
  }
1015
1163
 
1016
1164
  /**
@@ -1126,7 +1274,7 @@
1126
1274
  if (scene.THREE && typeof scene.projectToCanvas === 'function') {
1127
1275
  top = scene.projectToCanvas(new scene.THREE.Vector3(0, modelHeight, 0));
1128
1276
  }
1129
- var centreX = top ? box.left + top.x : box.left + box.width / 2;
1277
+ var centreX = box.left + box.width / 2;
1130
1278
  var width = speechNode.offsetWidth || 0;
1131
1279
  var height = speechNode.offsetHeight || 0;
1132
1280
  var promptHeight = statusBox && statusBox.width > 0 ? statusBox.height : 0;
@@ -1162,7 +1310,7 @@
1162
1310
  // A line THIS page said takes precedence while it lasts: the pet was touched here, and the next state poll
1163
1311
  // must not wipe the answer before it has been read.
1164
1312
  var localUp = localSpeech && Date.now() < localSpeech.until ? localSpeech : null;
1165
- var line = localUp ? localUp.text : hostSpeech;
1313
+ var line = hostState === 'dancing' ? null : (localUp ? localUp.text : (Date.now() < hostSpeechUntil ? hostSpeech : null));
1166
1314
  if (!line) {
1167
1315
  speechShown = null;
1168
1316
  speechNode.hidden = true;
@@ -1193,6 +1341,7 @@
1193
1341
 
1194
1342
  /** Put a line up for `holdSeconds`, then hand the bubble back to whatever the app is publishing. */
1195
1343
  function showLocalSpeech(text, holdSeconds) {
1344
+ if (hostState === 'dancing') return;
1196
1345
  var hold = Number.isFinite(Number(holdSeconds)) ? Math.max(1, Number(holdSeconds)) : 4;
1197
1346
  localSpeech = { text: text, until: Date.now() + hold * 1000 };
1198
1347
  if (localSpeechTimer) clearTimeout(localSpeechTimer);
@@ -1228,7 +1377,12 @@
1228
1377
  body: JSON.stringify({
1229
1378
  state: hostState || null,
1230
1379
  event: stateEventName(),
1231
- language: preferences.language === 'en' ? 'en' : 'zh',
1380
+ /**
1381
+ * The language this page is DRAWN in, not `preferences.language` raw: 'auto' is not a language, and a
1382
+ * host asked for a line in 'auto' would have to guess at exactly the thing this page has already
1383
+ * resolved. The host's prompt is built from this, so a line arrives in the language on screen.
1384
+ */
1385
+ language: language,
1232
1386
  part: item.id,
1233
1387
  structure: structure || null,
1234
1388
  chain: chain || null,
@@ -1308,8 +1462,10 @@
1308
1462
  function applyHostState(state, at, line, error, stale, clock) {
1309
1463
  var known = typeof state === 'string' && Array.isArray(core.STATES) && core.STATES.indexOf(state) !== -1;
1310
1464
  hostState = known ? state : null;
1465
+ if (menu && menu.setDanceState) menu.setDanceState(hostState);
1466
+ if (hostState === 'dancing') localSpeech = null;
1311
1467
  hostStateAt = Number(at) > 0 ? Number(at) : Date.now();
1312
- hostSpeech = typeof line === 'string' && line ? line : null;
1468
+ receiveHostSpeech(line);
1313
1469
  hostStateError = typeof error === 'string' && error ? error : null;
1314
1470
  hostStateClock = clock === 'state'
1315
1471
  // The sentinel from a deployment with no turn frames: the duration is measured on this page's own state age.
@@ -1413,7 +1569,7 @@
1413
1569
  if (!statusNode) return;
1414
1570
  // 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
1571
  // 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);
1572
+ var line = core.statusFor(preferences, hostState, hostStateAt, Date.now(), hostStateClock, language, hostUnread);
1417
1573
  if (!line) {
1418
1574
  statusKey = null;
1419
1575
  statusShown = null;
@@ -1429,7 +1585,7 @@
1429
1585
  * 「用时 14分46秒」 — so the clock ticks by re-keying, exactly as the app's own label counts.
1430
1586
  */
1431
1587
  var key = line.animation + '\u0000' + (line.icon ? '1' : '0') + '\u0000'
1432
- + (line.state === 'working' && Number(hostUnread) > 0 ? 'dot' : '') + '\u0000' + shown;
1588
+ + (['working', 'waiting'].indexOf(line.state) !== -1 && Number(hostUnread) > 0 ? 'dot' : '') + '\u0000' + shown;
1433
1589
  /**
1434
1590
  * The tone — 任务达成 gold and bold, 任务失败/出错 red — written every frame rather than on a re-key.
1435
1591
  *
@@ -1437,6 +1593,8 @@
1437
1593
  * about which state is gold; only the shades below are the window's own, because it draws on a
1438
1594
  * transparent desktop instead of DSH's theme tokens.
1439
1595
  */
1596
+ if (['done', 'waiting', 'error'].indexOf(hostState) !== -1) statusNode.setAttribute('data-attention', '1');
1597
+ else statusNode.removeAttribute('data-attention');
1440
1598
  if (line.tone) statusNode.setAttribute('data-tone', line.tone);
1441
1599
  else statusNode.removeAttribute('data-tone');
1442
1600
  if (line.bold) statusNode.setAttribute('data-bold', '1');
@@ -1456,7 +1614,7 @@
1456
1614
  if (statusIconNode) statusIconNode.hidden = line.icon !== true;
1457
1615
  // And the green dot, when another session's completion is still unread (DSH's own marker for it).
1458
1616
  if (statusDotNode) {
1459
- statusDotNode.hidden = !(line.state === 'working' && Number(hostUnread) > 0);
1617
+ statusDotNode.hidden = !(['working', 'waiting'].indexOf(line.state) !== -1 && Number(hostUnread) > 0);
1460
1618
  }
1461
1619
  // A line that just appeared has no measured box yet: laying it out on this same frame keeps the
1462
1620
  // window from lagging a frame behind the text.
@@ -1610,12 +1768,15 @@
1610
1768
  if (!id) return Promise.resolve(null);
1611
1769
  if (typeof scene.bustModelCache === 'function') scene.bustModelCache();
1612
1770
  // The bubble is this page's own way of saying something transient — the same channel a touched part uses.
1613
- showLocalSpeech('正在重载模型…', 3);
1771
+ // The three lines of 重载模型 come from this page's own table, and are the same three the app's pet puts in
1772
+ // its badge for the same menu row.
1773
+ showLocalSpeech(word(language, 'reloading'), 3);
1614
1774
  return loadModelSerialized(id).then(function (status) {
1615
1775
  layout();
1616
1776
  scene.applyPreferences(preferences);
1617
1777
  publish();
1618
- showLocalSpeech(status && status.ok ? '模型已重载' : ((status && status.error) || '模型重载失败'), 3);
1778
+ showLocalSpeech(status && status.ok ? word(language, 'reloaded')
1779
+ : ((status && status.error) || word(language, 'reloadFailed')), 3);
1619
1780
  return status;
1620
1781
  });
1621
1782
  }
@@ -1627,7 +1788,7 @@
1627
1788
  * whose `state` is `dancing` into a real dance, and a preview of anything else interrupts one — which is what 「预览动作的
1628
1789
  * 时候,也应该打断跳舞」 asks for, with no second channel.
1629
1790
  */
1630
- function requestDance() {
1791
+ function danceClipIds() {
1631
1792
  var all = motionCatalog && Array.isArray(motionCatalog.motions) ? motionCatalog.motions : [];
1632
1793
  var folders = motionCatalog && motionCatalog.stateFolders && Array.isArray(motionCatalog.stateFolders.dancing)
1633
1794
  ? motionCatalog.stateFolders.dancing
@@ -1637,15 +1798,49 @@
1637
1798
  return entry && typeof entry.id === 'string'
1638
1799
  && prefixes.some(function (prefix) { return String(entry.name || '').indexOf(prefix) === 0; });
1639
1800
  }).map(function (entry) { return entry.id; });
1640
- if (!ids.length) {
1641
- showLocalSpeech('没有可用的舞蹈动作(assets/motions/dance 是空的)', 3);
1801
+ return ids;
1802
+ }
1803
+
1804
+ // Prepare one random candidate while the menu is open, without changing state.
1805
+ function prepareDance() {
1806
+ var ids = danceClipIds();
1807
+ var current = dancePreview && dancePreview.motionId;
1808
+ var alternatives = ids.filter(function (id) { return id !== current; });
1809
+ if (alternatives.length) ids = alternatives;
1810
+ if (!ids.length) return null;
1811
+ if (!preparedDance || ids.indexOf(preparedDance) === -1) preparedDance = ids[Math.floor(Math.random() * ids.length)];
1812
+ ensureMotion(preparedDance, false);
1813
+ return preparedDance;
1814
+ }
1815
+
1816
+ function requestDance() {
1817
+ if (['resting', 'offline', 'dancing'].indexOf(hostState) === -1) return Promise.resolve(null);
1818
+ var picked = prepareDance();
1819
+ if (!picked) {
1820
+ // The same sentence the app's pet shows in its badge for its own 跳支舞 row.
1821
+ showLocalSpeech(word(language, 'danceEmpty'), 3);
1642
1822
  return Promise.resolve(null);
1643
1823
  }
1644
- var picked = ids[Math.floor(Math.random() * ids.length)];
1824
+ var request = ++danceRequest;
1825
+ preparedDance = null;
1645
1826
  return fetch('/vrmpet3d/preview', {
1646
1827
  method: 'POST',
1647
1828
  headers: { 'content-type': 'application/json' },
1648
1829
  body: JSON.stringify({ preview: { state: 'dancing', motionId: picked } }),
1830
+ }).then(function (response) {
1831
+ if (!response.ok) return null;
1832
+ if (previewLink && typeof previewLink.load === 'function') previewLink.load();
1833
+ // The app still owns the state. Only while this request is pending do we
1834
+ // ask for its answer faster than the normal one-second background poll.
1835
+ var deadline = Date.now() + 3000;
1836
+ function follow() {
1837
+ if (request !== danceRequest || !stateLink || typeof stateLink.load !== 'function') return;
1838
+ stateLink.load().then(function () {
1839
+ if (request === danceRequest && hostState !== 'dancing' && Date.now() < deadline) setTimeout(follow, 100);
1840
+ });
1841
+ }
1842
+ follow();
1843
+ return response;
1649
1844
  }).catch(function () { return null; });
1650
1845
  }
1651
1846
 
@@ -1778,8 +1973,9 @@
1778
1973
  * guard against sub-pixel `setBounds` churn absorbs.
1779
1974
  */
1780
1975
  if (statusBox && statusBox.width > 0) {
1781
- x = Math.min(x, statusBox.left);
1782
- y = Math.min(y, statusBox.top);
1976
+ var glowPadding = ['done', 'waiting', 'error'].indexOf(hostState) !== -1 ? 18 : 0;
1977
+ x = Math.min(x, statusBox.left - glowPadding);
1978
+ y = Math.min(y, statusBox.top - glowPadding);
1783
1979
  }
1784
1980
  /**
1785
1981
  * AND THE 碎碎念 BAND — because it is REPORTED, and this function's one job is to predict the window the launcher
@@ -2218,7 +2414,11 @@
2218
2414
  * no prompt up, the pet's box is.
2219
2415
  */
2220
2416
  var anchor = statusBox && statusBox.width > 0 && statusBox.top < box.top ? statusBox.top : box.top;
2221
- return { left: box.left, top: anchor - height, width: box.width, height: height };
2417
+ // The text stays the same size when the pet shrinks. Reserve its fixed wrap width too,
2418
+ // otherwise layoutSpeech paints a 220px plate into a window narrower than that plate.
2419
+ var wrapPx = core.SPEECH_LIMITS && core.SPEECH_LIMITS.wrapPx ? core.SPEECH_LIMITS.wrapPx : 220;
2420
+ var width = Math.max(box.width, wrapPx);
2421
+ return { left: box.left - (width - box.width) / 2, top: anchor - height, width: width, height: height };
2222
2422
  }
2223
2423
 
2224
2424
  function reportRect() {
@@ -2246,10 +2446,10 @@
2246
2446
  */
2247
2447
  status: statusBox && statusBox.width > 0
2248
2448
  ? {
2249
- left: screenBox.x + statusBox.left,
2250
- top: screenBox.y + statusBox.top,
2251
- width: statusBox.width,
2252
- height: statusBox.height,
2449
+ left: screenBox.x + statusBox.left - (['done', 'waiting', 'error'].indexOf(hostState) !== -1 ? 18 : 0),
2450
+ top: screenBox.y + statusBox.top - (['done', 'waiting', 'error'].indexOf(hostState) !== -1 ? 18 : 0),
2451
+ width: statusBox.width + (['done', 'waiting', 'error'].indexOf(hostState) !== -1 ? 36 : 0),
2452
+ height: statusBox.height + (['done', 'waiting', 'error'].indexOf(hostState) !== -1 ? 36 : 0),
2253
2453
  }
2254
2454
  : null,
2255
2455
  /**
@@ -2305,7 +2505,7 @@
2305
2505
  /**
2306
2506
  * The gaze, from the launcher's cursor rather than from an event.
2307
2507
  *
2308
- * Aim is measured against the PET's centre (`core.pointerAim`, the same function the in-app
2508
+ * Aim is measured against the projected eye midpoint (`core.pointerAim`, the same function the in-app
2309
2509
  * overlay uses), so the pet looks at the cursor wherever it is on the desktop ?including
2310
2510
  * outside the pet, which is the whole point of a desktop pet.
2311
2511
  */
@@ -2316,17 +2516,25 @@
2316
2516
  return;
2317
2517
  }
2318
2518
  var pixels = boxPixels();
2519
+ var eye = typeof scene.gazeOrigin === 'function' ? scene.gazeOrigin() : null;
2520
+ var canvasPixels = canvasBox();
2521
+ var gazeScreenWidth = Number(globalThis.screen && globalThis.screen.width) || screenBox.width;
2319
2522
  var aim = core.pointerAim({
2320
2523
  pointerX: cursor.x - screenBox.x,
2321
2524
  pointerY: cursor.y - screenBox.y,
2322
- centreX: pixels.left + placement.width / 2,
2323
- centreY: pixels.top + placement.height / 2,
2525
+ centreX: eye ? canvasPixels.left + eye.x * canvasPixels.width / scene.width : pixels.left + placement.width / 2,
2526
+ centreY: eye ? canvasPixels.top + eye.y * canvasPixels.height / scene.height : pixels.top + placement.height / 2,
2324
2527
  // Both of these are SETTINGS (the 模型参数 window's 视线 section) with an "auto" default: a span of
2325
- // 0 means one frame width, which on the floating pet is the screen's horizontal resolution, and the
2528
+ // 0 means half the screen width for both axes, and the
2326
2529
  // curve defaults to the shipped notch between "sluggish near, twitchy far" and "straight line".
2327
- spanX: core.resolveGazeSpan(preferences.gazeSpanX, screenBox.width),
2328
- spanY: core.resolveGazeSpan(preferences.gazeSpanY, core.GAZE_SPAN.y),
2530
+ spanX: core.resolveGazeSpan(preferences.gazeSpanX, gazeScreenWidth),
2531
+ spanY: core.resolveGazeSpan(preferences.gazeSpanY, gazeScreenWidth),
2329
2532
  curve: preferences.gazeCurve,
2533
+ curveY: preferences.gazeCurveY,
2534
+ curveRadial: preferences.gazeCurveRadial,
2535
+ curveSpatial: preferences.gazeCurveSpatial,
2536
+ distance: preferences.gazeDistance,
2537
+ mode: preferences.gazeCurveMode,
2330
2538
  });
2331
2539
  lastAim = aim;
2332
2540
  scene.motion.setGaze(scene.gazeFromAim(aim));
@@ -2423,7 +2631,8 @@
2423
2631
  * `setMotionMap` refuses them, and a clip assigned to 空闲 is the right thing to show during a nap.
2424
2632
  */
2425
2633
  var drawn = 'resting';
2426
- if (placement.dragging && drag && drag.moved) drawn = 'held';
2634
+ if (hostState === 'waiting') drawn = 'waiting';
2635
+ else if (placement.dragging && drag && drag.moved) drawn = 'held';
2427
2636
  else if (placement.isAirborne()) drawn = 'falling';
2428
2637
  else if (motionStates.indexOf(hostState) !== -1) drawn = hostState;
2429
2638
  scene.setState(drawn);
@@ -2448,7 +2657,7 @@
2448
2657
  *
2449
2658
  * Sent ONCE per dance: the flag resets as soon as the drawn state leaves 跳舞, so a finished clip cannot post every frame.
2450
2659
  */
2451
- if (drawn === 'dancing' && scene.playback && scene.playback.record && scene.playback.record.finished === true) {
2660
+ if (drawn === 'dancing' && scene.playback && scene.playback.state === 'dancing' && scene.playback.record && scene.motionFinished(scene.playback.record)) {
2452
2661
  if (!danceEndedReported) {
2453
2662
  danceEndedReported = true;
2454
2663
  try {
@@ -2510,6 +2719,10 @@
2510
2719
  /** Set once a 跳舞 clip has been reported as finished, so one dance posts one "it is over" (see the frame loop). */
2511
2720
  var danceEndedReported = false;
2512
2721
  var dancePreview = null;
2722
+ var preparedDance = null;
2723
+ var danceRequest = 0;
2724
+ var previewLink = null;
2725
+ var stateLink = null;
2513
2726
 
2514
2727
  /** One line of text for anything throwable, the way the rest of this page reports failures. */
2515
2728
  function errorText(error) {
@@ -2779,9 +2992,14 @@
2779
2992
  // Published so a launcher-side probe can tell "the setting reached the renderer" from "the slider
2780
2993
  // did nothing" — the two are indistinguishable from outside otherwise.
2781
2994
  gaze: {
2782
- curve: preferences.gazeCurve,
2783
- spanX: core.resolveGazeSpan(preferences.gazeSpanX, screenBox.width),
2784
- spanY: core.resolveGazeSpan(preferences.gazeSpanY, core.GAZE_SPAN.y),
2995
+ curve: preferences.gazeCurveMode === 'spatial' ? preferences.gazeCurveSpatial
2996
+ : preferences.gazeCurveMode === 'radial' ? preferences.gazeCurveRadial : preferences.gazeCurve,
2997
+ distance: preferences.gazeDistance,
2998
+ autoSpan: core.resolveGazeSpan(0, Number(globalThis.screen && globalThis.screen.width) || screenBox.width),
2999
+ curveY: preferences.gazeCurveY,
3000
+ mode: preferences.gazeCurveMode,
3001
+ spanX: core.resolveGazeSpan(preferences.gazeSpanX, Number(globalThis.screen && globalThis.screen.width) || screenBox.width),
3002
+ spanY: core.resolveGazeSpan(preferences.gazeSpanY, Number(globalThis.screen && globalThis.screen.width) || screenBox.width),
2785
3003
  aim: lastAim,
2786
3004
  /**
2787
3005
  * What the RENDERER applied, which is not the same thing as the aim.
@@ -3049,7 +3267,8 @@
3049
3267
  core.VIEW_LIMITS.pitchMax,
3050
3268
  );
3051
3269
  scene.applyPreferences(preferences);
3052
- writePreferences(store, core, preferences);
3270
+ // finishViewDrag persists once. Posting on every move echoed partial angles
3271
+ // back into the active gesture and repeatedly laid out the transparent canvas.
3053
3272
  }
3054
3273
 
3055
3274
  /**
@@ -3410,6 +3629,19 @@
3410
3629
  canvas.addEventListener('pointermove', onPointerMove);
3411
3630
  canvas.addEventListener('pointerup', onPointerUp);
3412
3631
  canvas.addEventListener('pointercancel', onPointerUp);
3632
+ function onDoubleClick(event) {
3633
+ if (event.button !== undefined && event.button !== 0) return;
3634
+ if (hostState !== 'dancing' && !dancePreview) return;
3635
+ dancePreview = null;
3636
+ if (hostState === 'dancing') hostState = 'resting';
3637
+ scene.setPreview(null);
3638
+ scene.setState('resting');
3639
+ fetch('/vrmpet3d/preview', {
3640
+ method: 'POST', credentials: 'omit',
3641
+ headers: { 'content-type': 'application/json' }, body: '{}',
3642
+ }).catch(function () { return null; });
3643
+ }
3644
+ canvas.addEventListener('dblclick', onDoubleClick);
3413
3645
  canvas.addEventListener('wheel', onWheel, { passive: false });
3414
3646
  canvas.addEventListener('contextmenu', onContextMenu);
3415
3647
  /**
@@ -3538,6 +3770,10 @@
3538
3770
  * the model the renderer resolves overrides for.
3539
3771
  */
3540
3772
  function applyExternalPreferences(next, shownModel) {
3773
+ if (viewDrag && viewDrag.moved) {
3774
+ next.viewYaw = preferences.viewYaw;
3775
+ next.viewPitch = preferences.viewPitch;
3776
+ }
3541
3777
  // Empty rather than a built-in id: with no avatar installed there is nothing to compare against, and a
3542
3778
  // stand-in name here is what used to make the page describe a pet that was not on screen.
3543
3779
  var current = scene.currentModelId || '';
@@ -3558,6 +3794,12 @@
3558
3794
  placement.preferences = next;
3559
3795
  placement.setSize(client.BASE_PET_SIZE.width, client.BASE_PET_SIZE.height);
3560
3796
  placement.clampToViewport();
3797
+ /**
3798
+ * 界面语言 is a preference like the rest, and only now is the answer readable — `next.language` may be an
3799
+ * explicit 中文/English or `auto`, which this page answers from its own environment. After the size clamp,
3800
+ * because a language change lays the window out again.
3801
+ */
3802
+ adoptLanguage();
3561
3803
  /**
3562
3804
  * 重力 comes from the store like everything else, and it is PHYSICS rather than a value the scene
3563
3805
  * re-applies — so it has to be pushed here too. Without this line the switch would work until the next
@@ -3629,10 +3871,14 @@
3629
3871
  * on demand before it is handed to the scene, exactly as a mapped clip is.
3630
3872
  */
3631
3873
  if (core.watchHostPreview) {
3632
- core.watchHostPreview({
3874
+ previewLink = core.watchHostPreview({
3633
3875
  onPreview: function (preview) {
3634
3876
  dancePreview = preview && preview.state === 'dancing' ? preview : null;
3635
3877
  if (dancePreview) {
3878
+ // Each preview notification is a NEW run, including the same sole clip.
3879
+ danceEndedReported = false;
3880
+ scene.setPreview(null);
3881
+ if (scene.playback && scene.playback.state === 'dancing') scene.stopPlayback(null);
3636
3882
  ensureMotion(preview.motionId, false);
3637
3883
  return;
3638
3884
  }
@@ -3664,7 +3910,7 @@
3664
3910
  * and this page draws whatever it is told. One clock for the feature, not two that can disagree.
3665
3911
  */
3666
3912
  if (core.watchHostState) {
3667
- core.watchHostState({
3913
+ stateLink = core.watchHostState({
3668
3914
  onState: function (state, body) {
3669
3915
  // The unread count first: `renderStatus` reads it when it decides whether the dot is drawn, and it runs
3670
3916
  // inside `applyHostState`.
@@ -3717,6 +3963,9 @@
3717
3963
  placement.preferences = next;
3718
3964
  placement.setSize(client.BASE_PET_SIZE.width, client.BASE_PET_SIZE.height);
3719
3965
  placement.clampToViewport();
3966
+ // A storage write is the fallback path for a host without the settings route, and the settings window is
3967
+ // where 界面语言 is chosen: the same re-resolution the snapshot path does, after the size clamp.
3968
+ adoptLanguage();
3720
3969
  // The scene re-reads the whole preference set: springs and expressions are applied from it, so
3721
3970
  // a change made in the settings window lands on the pet through this one call.
3722
3971
  scene.applyPreferences(Object.assign({}, next, { motions: effectiveMotions() }));
@@ -3896,7 +4145,7 @@
3896
4145
  */
3897
4146
  speech: function () { return speechBox; },
3898
4147
  setSpeech: function (line) {
3899
- hostSpeech = typeof line === 'string' && line ? line : null;
4148
+ receiveHostSpeech(line);
3900
4149
  renderSpeech();
3901
4150
  layout();
3902
4151
  reportRect();
@@ -3904,11 +4153,16 @@
3904
4153
  return speechShown;
3905
4154
  },
3906
4155
  destroy: function () {
4156
+ if (hostSpeechTimer) clearTimeout(hostSpeechTimer);
3907
4157
  running = false;
4158
+ danceRequest += 1;
4159
+ if (previewLink && typeof previewLink.stop === 'function') previewLink.stop();
4160
+ if (stateLink && typeof stateLink.stop === 'function') stateLink.stop();
3908
4161
  canvas.removeEventListener('pointerdown', onPointerDown);
3909
4162
  canvas.removeEventListener('pointermove', onPointerMove);
3910
4163
  canvas.removeEventListener('pointerup', onPointerUp);
3911
4164
  canvas.removeEventListener('pointercancel', onPointerUp);
4165
+ canvas.removeEventListener('dblclick', onDoubleClick);
3912
4166
  canvas.removeEventListener('wheel', onWheel);
3913
4167
  canvas.removeEventListener('contextmenu', onContextMenu);
3914
4168
  if (menu && typeof menu.destroy === 'function') menu.destroy();