dsh-plugin-vrmpet3d 0.1.1 → 0.1.3

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.
@@ -164,6 +164,7 @@
164
164
  */
165
165
  '.vrmpet3d-desktop-status[data-tone="gold"]{color:#ffc94d}',
166
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}',
167
168
  '.vrmpet3d-desktop-status[data-tone="danger"]{color:#ff6b6b}',
168
169
  /**
169
170
  * 碎碎念: the pet's own short remark, above the prompt.
@@ -222,6 +223,7 @@
222
223
  '.vrmpet3d-desktop-status-content{display:flex;align-items:center;min-width:0}',
223
224
  /* `nowrap`, always: the operator's "提示文字永不换行". One line, however long the line is. */
224
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}',
225
227
  '.vrmpet3d-desktop-status-text[data-shimmer-text]::after{content:attr(data-shimmer-text)}',
226
228
  '.vrmpet3d-desktop-status-decoration{position:absolute;inset:0;overflow:clip;pointer-events:none;user-select:none}',
227
229
  '.vrmpet3d-desktop-status-decoration[hidden]{display:none}',
@@ -690,6 +692,7 @@
690
692
  if (id === 'globalSettings') { openSettingsWindow('global'); return; }
691
693
  // 切换模型: the model picker's page, the same one the guide's 偏好模型 step is built from.
692
694
  if (id === 'switchModel') { openSettingsWindow('picker'); return; }
695
+ if (id === 'miniGame') { openSettingsWindow('game'); return; }
693
696
  /**
694
697
  * 重载模型 — the same row the app pet's menu grew, with the same meaning (operator, 2026-10-14: 「给内置和外置宠物的右键
695
698
  * 菜单新增“重载模型”的选项,用来刷新模型」): re-read the model FILE and rebuild this window's scene. Nothing about the
@@ -715,10 +718,9 @@
715
718
  * 旧的 `client.menu.js`,而那个 chunk 里这一行还在。这时它必须照旧工作,而不是掉出函数末尾什么都不做 ——
716
719
  * 一个点了没反应的菜单项,比一个不显示的行难查得多。
717
720
  *
718
- * 动作本身没变:宠物回到 DSH 里,而这个窗由 APP 关掉(一个写者;见 `applySurface`/`adoptSurface`)。
721
+ * 宠物回到 DSH 里;本页持久化选择后直接请宿主关闭浮窗,不依赖 DSH 页面在前台。
719
722
  */
720
- preferences.petSurface = 'app';
721
- writePreferences(store, core, preferences);
723
+ returnToApp();
722
724
  return;
723
725
  }
724
726
  /**
@@ -738,13 +740,12 @@
738
740
  /**
739
741
  * 关闭独立宠物: the pet goes back INSIDE the app, which is the only thing closing this window can mean.
740
742
  *
741
- * It is the same write as 切换回窗口内 — this window is the pet's box, so a window with no pet in it is
742
- * nothing at all, and the app is what stops this window (one writer; see the `surface` branch above).
743
+ * Persist the same choice as 切换回窗口内, then ask the host to stop this window.
744
+ * The app renderer may be paused in the background; it reads that choice when it wakes.
743
745
  * The row was 关闭宠物 until 2026-10-12 and used to HIDE the pet as well; the operator split the two
744
746
  * meanings then, keeping hiding for the app's pet (隐藏宠物) and this one for handing the pet back.
745
747
  */
746
- preferences.petSurface = 'app';
747
- writePreferences(store, core, preferences);
748
+ returnToApp();
748
749
  return;
749
750
  }
750
751
  if (id === 'restartHarness' || id === 'quitHarness') {
@@ -755,6 +756,26 @@
755
756
  }
756
757
  }
757
758
 
759
+ function returnToApp() {
760
+ preferences.petSurface = 'app';
761
+ // Persist first, then stop this window through the host. A background DSH
762
+ // renderer may not run its settings callback until it becomes foreground.
763
+ return fetch('/vrmpet3d/settings', {
764
+ method: 'POST', headers: { 'content-type': 'application/json' },
765
+ body: JSON.stringify({ preferences: { petSurface: 'app' } }),
766
+ }).then(function (response) { return response.ok ? response.json() : null; })
767
+ .then(function (snapshot) {
768
+ if (!snapshot || !snapshot.preferences || snapshot.preferences.petSurface !== 'app') return null;
769
+ preferences.petSurface = 'app';
770
+ try { if (store) store.setItem(core.STORAGE_KEY, JSON.stringify(preferences)); } catch (_error) { /* host is authoritative */ }
771
+ return fetch('/vrmpet3d/desktop', {
772
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'disable' }),
773
+ }).then(function (response) {
774
+ if (!response.ok && bridge && typeof bridge.quit === 'function') bridge.quit();
775
+ }, function () { if (bridge && typeof bridge.quit === 'function') bridge.quit(); });
776
+ }).catch(function () { return null; });
777
+ }
778
+
758
779
  /**
759
780
  * Push this surface's 重力 (`windowGravity`) into the placement.
760
781
  *
@@ -832,6 +853,7 @@
832
853
  */
833
854
  function openMenuAt(frameX, frameY) {
834
855
  if (!menu) return false;
856
+ prepareDance();
835
857
  /**
836
858
  * 非debug模式生效, read at OPEN time.
837
859
  *
@@ -853,6 +875,7 @@
853
875
  if (typeof menu.setChecked === 'function') {
854
876
  menu.setChecked('gravity', preferences.windowGravity === true);
855
877
  }
878
+ if (menu.setDanceState) menu.setDanceState(hostState);
856
879
  var size = menu.open();
857
880
  var width = Math.min(size.width, Math.max(80, placement.viewportWidth - MENU_MARGIN * 2));
858
881
  var height = Math.min(size.height, Math.max(80, placement.viewportHeight - MENU_MARGIN * 2));
@@ -987,6 +1010,24 @@
987
1010
  var speechNode = null;
988
1011
  var speechBox = null;
989
1012
  var hostSpeech = null;
1013
+ var hostSpeechUntil = 0;
1014
+ var hostSpeechTimer = null;
1015
+
1016
+ function receiveHostSpeech(line) {
1017
+ var next = typeof line === 'string' && line ? line : null;
1018
+ if (next === hostSpeech) return;
1019
+ if (hostSpeechTimer) clearTimeout(hostSpeechTimer);
1020
+ hostSpeechTimer = null;
1021
+ hostSpeech = next;
1022
+ var hold = preferences && preferences.speech && Number(preferences.speech.holdSeconds);
1023
+ hostSpeechUntil = next ? Date.now() + (hold > 0 ? hold : 8) * 1000 : 0;
1024
+ if (next) hostSpeechTimer = setTimeout(function () {
1025
+ hostSpeechTimer = null;
1026
+ renderSpeech();
1027
+ reportRect();
1028
+ publish();
1029
+ }, Math.max(0, hostSpeechUntil - Date.now()));
1030
+ }
990
1031
  var speechShown = null;
991
1032
  /**
992
1033
  * A line THIS page said, because its own pet was touched, and until when.
@@ -1108,6 +1149,17 @@
1108
1149
  statusNode.appendChild(statusIconNode);
1109
1150
  statusNode.appendChild(shimmer);
1110
1151
  doc.body.appendChild(statusNode);
1152
+ statusDotNode.title = language === 'en' ? 'Open completed conversation' : '打开已完成的会话';
1153
+ statusNode.addEventListener('click', function (event) {
1154
+ var completed = event.target === statusDotNode && ['working', 'waiting'].indexOf(hostState) !== -1 && Number(hostUnread) > 0;
1155
+ if (!completed && ['done', 'waiting', 'error'].indexOf(hostState) === -1) return;
1156
+ fetch('/vrmpet3d/desktop', {
1157
+ method: 'POST', headers: { 'content-type': 'application/json' },
1158
+ body: JSON.stringify({ action: completed ? 'open-completed-session' : 'open-status-session' }),
1159
+ }).then(function (response) { return response.ok ? response.json() : null; }).then(function (body) {
1160
+ if (body && body.ok && globalThis.vrmpet3dDesktop && globalThis.vrmpet3dDesktop.focusHost) globalThis.vrmpet3dDesktop.focusHost();
1161
+ }).catch(function () {});
1162
+ });
1111
1163
  }
1112
1164
 
1113
1165
  /**
@@ -1223,7 +1275,7 @@
1223
1275
  if (scene.THREE && typeof scene.projectToCanvas === 'function') {
1224
1276
  top = scene.projectToCanvas(new scene.THREE.Vector3(0, modelHeight, 0));
1225
1277
  }
1226
- var centreX = top ? box.left + top.x : box.left + box.width / 2;
1278
+ var centreX = box.left + box.width / 2;
1227
1279
  var width = speechNode.offsetWidth || 0;
1228
1280
  var height = speechNode.offsetHeight || 0;
1229
1281
  var promptHeight = statusBox && statusBox.width > 0 ? statusBox.height : 0;
@@ -1259,7 +1311,7 @@
1259
1311
  // A line THIS page said takes precedence while it lasts: the pet was touched here, and the next state poll
1260
1312
  // must not wipe the answer before it has been read.
1261
1313
  var localUp = localSpeech && Date.now() < localSpeech.until ? localSpeech : null;
1262
- var line = localUp ? localUp.text : hostSpeech;
1314
+ var line = hostState === 'dancing' ? null : (localUp ? localUp.text : (Date.now() < hostSpeechUntil ? hostSpeech : null));
1263
1315
  if (!line) {
1264
1316
  speechShown = null;
1265
1317
  speechNode.hidden = true;
@@ -1290,6 +1342,7 @@
1290
1342
 
1291
1343
  /** Put a line up for `holdSeconds`, then hand the bubble back to whatever the app is publishing. */
1292
1344
  function showLocalSpeech(text, holdSeconds) {
1345
+ if (hostState === 'dancing') return;
1293
1346
  var hold = Number.isFinite(Number(holdSeconds)) ? Math.max(1, Number(holdSeconds)) : 4;
1294
1347
  localSpeech = { text: text, until: Date.now() + hold * 1000 };
1295
1348
  if (localSpeechTimer) clearTimeout(localSpeechTimer);
@@ -1410,8 +1463,10 @@
1410
1463
  function applyHostState(state, at, line, error, stale, clock) {
1411
1464
  var known = typeof state === 'string' && Array.isArray(core.STATES) && core.STATES.indexOf(state) !== -1;
1412
1465
  hostState = known ? state : null;
1466
+ if (menu && menu.setDanceState) menu.setDanceState(hostState);
1467
+ if (hostState === 'dancing') localSpeech = null;
1413
1468
  hostStateAt = Number(at) > 0 ? Number(at) : Date.now();
1414
- hostSpeech = typeof line === 'string' && line ? line : null;
1469
+ receiveHostSpeech(line);
1415
1470
  hostStateError = typeof error === 'string' && error ? error : null;
1416
1471
  hostStateClock = clock === 'state'
1417
1472
  // The sentinel from a deployment with no turn frames: the duration is measured on this page's own state age.
@@ -1515,7 +1570,7 @@
1515
1570
  if (!statusNode) return;
1516
1571
  // The TURN clock the app sent with the state, not this page's own age for it: 用时 is a claim about DSH's turn,
1517
1572
  // and with no turn to report the token is dropped (DSH's own token-free 「深度求索中」). See `PetMachine.statusClock`.
1518
- var line = core.statusFor(preferences, hostState, hostStateAt, Date.now(), hostStateClock, language);
1573
+ var line = core.statusFor(preferences, hostState, hostStateAt, Date.now(), hostStateClock, language, hostUnread);
1519
1574
  if (!line) {
1520
1575
  statusKey = null;
1521
1576
  statusShown = null;
@@ -1531,7 +1586,7 @@
1531
1586
  * 「用时 14分46秒」 — so the clock ticks by re-keying, exactly as the app's own label counts.
1532
1587
  */
1533
1588
  var key = line.animation + '\u0000' + (line.icon ? '1' : '0') + '\u0000'
1534
- + (line.state === 'working' && Number(hostUnread) > 0 ? 'dot' : '') + '\u0000' + shown;
1589
+ + (['working', 'waiting'].indexOf(line.state) !== -1 && Number(hostUnread) > 0 ? 'dot' : '') + '\u0000' + shown;
1535
1590
  /**
1536
1591
  * The tone — 任务达成 gold and bold, 任务失败/出错 red — written every frame rather than on a re-key.
1537
1592
  *
@@ -1539,6 +1594,8 @@
1539
1594
  * about which state is gold; only the shades below are the window's own, because it draws on a
1540
1595
  * transparent desktop instead of DSH's theme tokens.
1541
1596
  */
1597
+ if (['done', 'waiting', 'error'].indexOf(hostState) !== -1) statusNode.setAttribute('data-attention', '1');
1598
+ else statusNode.removeAttribute('data-attention');
1542
1599
  if (line.tone) statusNode.setAttribute('data-tone', line.tone);
1543
1600
  else statusNode.removeAttribute('data-tone');
1544
1601
  if (line.bold) statusNode.setAttribute('data-bold', '1');
@@ -1558,7 +1615,7 @@
1558
1615
  if (statusIconNode) statusIconNode.hidden = line.icon !== true;
1559
1616
  // And the green dot, when another session's completion is still unread (DSH's own marker for it).
1560
1617
  if (statusDotNode) {
1561
- statusDotNode.hidden = !(line.state === 'working' && Number(hostUnread) > 0);
1618
+ statusDotNode.hidden = !(['working', 'waiting'].indexOf(line.state) !== -1 && Number(hostUnread) > 0);
1562
1619
  }
1563
1620
  // A line that just appeared has no measured box yet: laying it out on this same frame keeps the
1564
1621
  // window from lagging a frame behind the text.
@@ -1732,7 +1789,7 @@
1732
1789
  * whose `state` is `dancing` into a real dance, and a preview of anything else interrupts one — which is what 「预览动作的
1733
1790
  * 时候,也应该打断跳舞」 asks for, with no second channel.
1734
1791
  */
1735
- function requestDance() {
1792
+ function danceClipIds() {
1736
1793
  var all = motionCatalog && Array.isArray(motionCatalog.motions) ? motionCatalog.motions : [];
1737
1794
  var folders = motionCatalog && motionCatalog.stateFolders && Array.isArray(motionCatalog.stateFolders.dancing)
1738
1795
  ? motionCatalog.stateFolders.dancing
@@ -1742,19 +1799,49 @@
1742
1799
  return entry && typeof entry.id === 'string'
1743
1800
  && prefixes.some(function (prefix) { return String(entry.name || '').indexOf(prefix) === 0; });
1744
1801
  }).map(function (entry) { return entry.id; });
1745
- if (!ids.length) {
1802
+ return ids;
1803
+ }
1804
+
1805
+ // Prepare one random candidate while the menu is open, without changing state.
1806
+ function prepareDance() {
1807
+ var ids = danceClipIds();
1808
+ var current = dancePreview && dancePreview.motionId;
1809
+ var alternatives = ids.filter(function (id) { return id !== current; });
1810
+ if (alternatives.length) ids = alternatives;
1811
+ if (!ids.length) return null;
1812
+ if (!preparedDance || ids.indexOf(preparedDance) === -1) preparedDance = ids[Math.floor(Math.random() * ids.length)];
1813
+ ensureMotion(preparedDance, false);
1814
+ return preparedDance;
1815
+ }
1816
+
1817
+ function requestDance() {
1818
+ if (['resting', 'offline', 'dancing'].indexOf(hostState) === -1) return Promise.resolve(null);
1819
+ var picked = prepareDance();
1820
+ if (!picked) {
1746
1821
  // The same sentence the app's pet shows in its badge for its own 跳支舞 row.
1747
1822
  showLocalSpeech(word(language, 'danceEmpty'), 3);
1748
1823
  return Promise.resolve(null);
1749
1824
  }
1750
- var current = dancePreview && dancePreview.motionId;
1751
- var alternatives = ids.filter(function (id) { return id !== current; });
1752
- if (alternatives.length) ids = alternatives;
1753
- var picked = ids[Math.floor(Math.random() * ids.length)];
1825
+ var request = ++danceRequest;
1826
+ preparedDance = null;
1754
1827
  return fetch('/vrmpet3d/preview', {
1755
1828
  method: 'POST',
1756
1829
  headers: { 'content-type': 'application/json' },
1757
1830
  body: JSON.stringify({ preview: { state: 'dancing', motionId: picked } }),
1831
+ }).then(function (response) {
1832
+ if (!response.ok) return null;
1833
+ if (previewLink && typeof previewLink.load === 'function') previewLink.load();
1834
+ // The app still owns the state. Only while this request is pending do we
1835
+ // ask for its answer faster than the normal one-second background poll.
1836
+ var deadline = Date.now() + 3000;
1837
+ function follow() {
1838
+ if (request !== danceRequest || !stateLink || typeof stateLink.load !== 'function') return;
1839
+ stateLink.load().then(function () {
1840
+ if (request === danceRequest && hostState !== 'dancing' && Date.now() < deadline) setTimeout(follow, 100);
1841
+ });
1842
+ }
1843
+ follow();
1844
+ return response;
1758
1845
  }).catch(function () { return null; });
1759
1846
  }
1760
1847
 
@@ -1782,6 +1869,14 @@
1782
1869
  * query parameter this build no longer reads.
1783
1870
  */
1784
1871
  function openSettingsWindow(page) {
1872
+ if (page === 'game') {
1873
+ fetch('/vrmpet3d/desktop', { method: 'POST', credentials: 'omit',
1874
+ headers: { 'content-type': 'application/json' },
1875
+ body: JSON.stringify({ action: 'game', modelId: resolvedModelId(preferences.modelId || loadedModel) })
1876
+ }).then(function (response) { if (!response.ok) throw new Error('小游戏窗口打开失败'); })
1877
+ .catch(function (error) { console.warn(error.message); });
1878
+ return;
1879
+ }
1785
1880
  var which = page === 'global' ? 'settings' : (page === 'picker' ? 'picker' : 'config');
1786
1881
  var url = which === 'settings'
1787
1882
  ? '/vrmpet3d/global'
@@ -1887,8 +1982,9 @@
1887
1982
  * guard against sub-pixel `setBounds` churn absorbs.
1888
1983
  */
1889
1984
  if (statusBox && statusBox.width > 0) {
1890
- x = Math.min(x, statusBox.left);
1891
- y = Math.min(y, statusBox.top);
1985
+ var glowPadding = ['done', 'waiting', 'error'].indexOf(hostState) !== -1 ? 18 : 0;
1986
+ x = Math.min(x, statusBox.left - glowPadding);
1987
+ y = Math.min(y, statusBox.top - glowPadding);
1892
1988
  }
1893
1989
  /**
1894
1990
  * AND THE 碎碎念 BAND — because it is REPORTED, and this function's one job is to predict the window the launcher
@@ -2327,7 +2423,11 @@
2327
2423
  * no prompt up, the pet's box is.
2328
2424
  */
2329
2425
  var anchor = statusBox && statusBox.width > 0 && statusBox.top < box.top ? statusBox.top : box.top;
2330
- return { left: box.left, top: anchor - height, width: box.width, height: height };
2426
+ // The text stays the same size when the pet shrinks. Reserve its fixed wrap width too,
2427
+ // otherwise layoutSpeech paints a 220px plate into a window narrower than that plate.
2428
+ var wrapPx = core.SPEECH_LIMITS && core.SPEECH_LIMITS.wrapPx ? core.SPEECH_LIMITS.wrapPx : 220;
2429
+ var width = Math.max(box.width, wrapPx);
2430
+ return { left: box.left - (width - box.width) / 2, top: anchor - height, width: width, height: height };
2331
2431
  }
2332
2432
 
2333
2433
  function reportRect() {
@@ -2355,10 +2455,10 @@
2355
2455
  */
2356
2456
  status: statusBox && statusBox.width > 0
2357
2457
  ? {
2358
- left: screenBox.x + statusBox.left,
2359
- top: screenBox.y + statusBox.top,
2360
- width: statusBox.width,
2361
- height: statusBox.height,
2458
+ left: screenBox.x + statusBox.left - (['done', 'waiting', 'error'].indexOf(hostState) !== -1 ? 18 : 0),
2459
+ top: screenBox.y + statusBox.top - (['done', 'waiting', 'error'].indexOf(hostState) !== -1 ? 18 : 0),
2460
+ width: statusBox.width + (['done', 'waiting', 'error'].indexOf(hostState) !== -1 ? 36 : 0),
2461
+ height: statusBox.height + (['done', 'waiting', 'error'].indexOf(hostState) !== -1 ? 36 : 0),
2362
2462
  }
2363
2463
  : null,
2364
2464
  /**
@@ -2414,7 +2514,7 @@
2414
2514
  /**
2415
2515
  * The gaze, from the launcher's cursor rather than from an event.
2416
2516
  *
2417
- * Aim is measured against the PET's centre (`core.pointerAim`, the same function the in-app
2517
+ * Aim is measured against the projected eye midpoint (`core.pointerAim`, the same function the in-app
2418
2518
  * overlay uses), so the pet looks at the cursor wherever it is on the desktop ?including
2419
2519
  * outside the pet, which is the whole point of a desktop pet.
2420
2520
  */
@@ -2425,17 +2525,25 @@
2425
2525
  return;
2426
2526
  }
2427
2527
  var pixels = boxPixels();
2528
+ var eye = typeof scene.gazeOrigin === 'function' ? scene.gazeOrigin() : null;
2529
+ var canvasPixels = canvasBox();
2530
+ var gazeScreenWidth = Number(globalThis.screen && globalThis.screen.width) || screenBox.width;
2428
2531
  var aim = core.pointerAim({
2429
2532
  pointerX: cursor.x - screenBox.x,
2430
2533
  pointerY: cursor.y - screenBox.y,
2431
- centreX: pixels.left + placement.width / 2,
2432
- centreY: pixels.top + placement.height / 2,
2534
+ centreX: eye ? canvasPixels.left + eye.x * canvasPixels.width / scene.width : pixels.left + placement.width / 2,
2535
+ centreY: eye ? canvasPixels.top + eye.y * canvasPixels.height / scene.height : pixels.top + placement.height / 2,
2433
2536
  // Both of these are SETTINGS (the 模型参数 window's 视线 section) with an "auto" default: a span of
2434
- // 0 means one frame width, which on the floating pet is the screen's horizontal resolution, and the
2537
+ // 0 means half the screen width for both axes, and the
2435
2538
  // curve defaults to the shipped notch between "sluggish near, twitchy far" and "straight line".
2436
- spanX: core.resolveGazeSpan(preferences.gazeSpanX, screenBox.width),
2437
- spanY: core.resolveGazeSpan(preferences.gazeSpanY, core.GAZE_SPAN.y),
2539
+ spanX: core.resolveGazeSpan(preferences.gazeSpanX, gazeScreenWidth),
2540
+ spanY: core.resolveGazeSpan(preferences.gazeSpanY, gazeScreenWidth),
2438
2541
  curve: preferences.gazeCurve,
2542
+ curveY: preferences.gazeCurveY,
2543
+ curveRadial: preferences.gazeCurveRadial,
2544
+ curveSpatial: preferences.gazeCurveSpatial,
2545
+ distance: preferences.gazeDistance,
2546
+ mode: preferences.gazeCurveMode,
2439
2547
  });
2440
2548
  lastAim = aim;
2441
2549
  scene.motion.setGaze(scene.gazeFromAim(aim));
@@ -2532,7 +2640,8 @@
2532
2640
  * `setMotionMap` refuses them, and a clip assigned to 空闲 is the right thing to show during a nap.
2533
2641
  */
2534
2642
  var drawn = 'resting';
2535
- if (placement.dragging && drag && drag.moved) drawn = 'held';
2643
+ if (hostState === 'waiting') drawn = 'waiting';
2644
+ else if (placement.dragging && drag && drag.moved) drawn = 'held';
2536
2645
  else if (placement.isAirborne()) drawn = 'falling';
2537
2646
  else if (motionStates.indexOf(hostState) !== -1) drawn = hostState;
2538
2647
  scene.setState(drawn);
@@ -2619,6 +2728,10 @@
2619
2728
  /** Set once a 跳舞 clip has been reported as finished, so one dance posts one "it is over" (see the frame loop). */
2620
2729
  var danceEndedReported = false;
2621
2730
  var dancePreview = null;
2731
+ var preparedDance = null;
2732
+ var danceRequest = 0;
2733
+ var previewLink = null;
2734
+ var stateLink = null;
2622
2735
 
2623
2736
  /** One line of text for anything throwable, the way the rest of this page reports failures. */
2624
2737
  function errorText(error) {
@@ -2888,9 +3001,14 @@
2888
3001
  // Published so a launcher-side probe can tell "the setting reached the renderer" from "the slider
2889
3002
  // did nothing" — the two are indistinguishable from outside otherwise.
2890
3003
  gaze: {
2891
- curve: preferences.gazeCurve,
2892
- spanX: core.resolveGazeSpan(preferences.gazeSpanX, screenBox.width),
2893
- spanY: core.resolveGazeSpan(preferences.gazeSpanY, core.GAZE_SPAN.y),
3004
+ curve: preferences.gazeCurveMode === 'spatial' ? preferences.gazeCurveSpatial
3005
+ : preferences.gazeCurveMode === 'radial' ? preferences.gazeCurveRadial : preferences.gazeCurve,
3006
+ distance: preferences.gazeDistance,
3007
+ autoSpan: core.resolveGazeSpan(0, Number(globalThis.screen && globalThis.screen.width) || screenBox.width),
3008
+ curveY: preferences.gazeCurveY,
3009
+ mode: preferences.gazeCurveMode,
3010
+ spanX: core.resolveGazeSpan(preferences.gazeSpanX, Number(globalThis.screen && globalThis.screen.width) || screenBox.width),
3011
+ spanY: core.resolveGazeSpan(preferences.gazeSpanY, Number(globalThis.screen && globalThis.screen.width) || screenBox.width),
2894
3012
  aim: lastAim,
2895
3013
  /**
2896
3014
  * What the RENDERER applied, which is not the same thing as the aim.
@@ -3158,7 +3276,8 @@
3158
3276
  core.VIEW_LIMITS.pitchMax,
3159
3277
  );
3160
3278
  scene.applyPreferences(preferences);
3161
- writePreferences(store, core, preferences);
3279
+ // finishViewDrag persists once. Posting on every move echoed partial angles
3280
+ // back into the active gesture and repeatedly laid out the transparent canvas.
3162
3281
  }
3163
3282
 
3164
3283
  /**
@@ -3660,6 +3779,10 @@
3660
3779
  * the model the renderer resolves overrides for.
3661
3780
  */
3662
3781
  function applyExternalPreferences(next, shownModel) {
3782
+ if (viewDrag && viewDrag.moved) {
3783
+ next.viewYaw = preferences.viewYaw;
3784
+ next.viewPitch = preferences.viewPitch;
3785
+ }
3663
3786
  // Empty rather than a built-in id: with no avatar installed there is nothing to compare against, and a
3664
3787
  // stand-in name here is what used to make the page describe a pet that was not on screen.
3665
3788
  var current = scene.currentModelId || '';
@@ -3757,7 +3880,7 @@
3757
3880
  * on demand before it is handed to the scene, exactly as a mapped clip is.
3758
3881
  */
3759
3882
  if (core.watchHostPreview) {
3760
- core.watchHostPreview({
3883
+ previewLink = core.watchHostPreview({
3761
3884
  onPreview: function (preview) {
3762
3885
  dancePreview = preview && preview.state === 'dancing' ? preview : null;
3763
3886
  if (dancePreview) {
@@ -3796,7 +3919,7 @@
3796
3919
  * and this page draws whatever it is told. One clock for the feature, not two that can disagree.
3797
3920
  */
3798
3921
  if (core.watchHostState) {
3799
- core.watchHostState({
3922
+ stateLink = core.watchHostState({
3800
3923
  onState: function (state, body) {
3801
3924
  // The unread count first: `renderStatus` reads it when it decides whether the dot is drawn, and it runs
3802
3925
  // inside `applyHostState`.
@@ -4031,7 +4154,7 @@
4031
4154
  */
4032
4155
  speech: function () { return speechBox; },
4033
4156
  setSpeech: function (line) {
4034
- hostSpeech = typeof line === 'string' && line ? line : null;
4157
+ receiveHostSpeech(line);
4035
4158
  renderSpeech();
4036
4159
  layout();
4037
4160
  reportRect();
@@ -4039,7 +4162,11 @@
4039
4162
  return speechShown;
4040
4163
  },
4041
4164
  destroy: function () {
4165
+ if (hostSpeechTimer) clearTimeout(hostSpeechTimer);
4042
4166
  running = false;
4167
+ danceRequest += 1;
4168
+ if (previewLink && typeof previewLink.stop === 'function') previewLink.stop();
4169
+ if (stateLink && typeof stateLink.stop === 'function') stateLink.stop();
4043
4170
  canvas.removeEventListener('pointerdown', onPointerDown);
4044
4171
  canvas.removeEventListener('pointermove', onPointerMove);
4045
4172
  canvas.removeEventListener('pointerup', onPointerUp);
package/lib/desktop.js CHANGED
@@ -526,7 +526,24 @@ export class DesktopWindow {
526
526
  }
527
527
 
528
528
  describe() {
529
- return { ...this.status, running: Boolean(this.child) };
529
+ return { ...this.status, running: Boolean(this.child), electronAvailable: this.hasElectronRuntime() };
530
+ }
531
+
532
+ /** Probe the runtime sources without creating links, copying files or starting Electron. */
533
+ hasElectronRuntime() {
534
+ if (this.child) return true;
535
+ try {
536
+ const supplied = this.options.runtime || resolveStandaloneRuntime(envVar('DESKTOP_RUNTIME'));
537
+ const installed = this.options.installRoot || findInstallRoot(dirname(process.execPath)) || findInstallRoot(process.cwd());
538
+ const cached = this.options.runtimeDir || this.status.runtimeDir;
539
+ const candidates = [supplied, cached && resolveStandaloneRuntime(cached),
540
+ installed && { dir: installed, exe: findExecutable(installed) }];
541
+ return candidates.some((runtime) => runtime && runtime.exe && existsSync(runtime.exe)
542
+ && ['icudtl.dat', 'resources.pak', 'v8_context_snapshot.bin'].every((name) => existsSync(join(runtime.dir, name)))
543
+ && existsSync(join(runtime.dir, RUNTIME_SUPPORT.locales)));
544
+ } catch (_error) {
545
+ return false;
546
+ }
530
547
  }
531
548
  }
532
549
 
@@ -98,10 +98,31 @@
98
98
  <div class="row"><label class="k" for="pref-look" data-i18n="lookAtPointer">看向光标</label><label class="inline"><input type="checkbox" id="pref-look"> <span data-i18n="on">开启</span></label></div>
99
99
 
100
100
  <div class="row">
101
- <label class="k" for="pref-gaze-curve" data-i18n="curve">曲率</label>
102
- <input type="range" id="pref-gaze-curve" min="5" max="100" step="5">
103
- <span class="value" id="pref-gaze-curve-value">0.10</span>
104
- <button id="pref-gaze-curve-reset" class="ghost" data-i18n="reset">复位</button>
101
+ <label class="k" for="pref-gaze-mode" data-i18n="gazeCurveMode">曲线模式</label>
102
+ <select id="pref-gaze-mode"><option value="spatial" data-i18n="gazeSpatial">空间角度</option><option value="radial" data-i18n="gazeRadial">二维距离曲线</option></select>
103
+ </div>
104
+ <div id="gaze-curves-radial" hidden>
105
+ <div class="row">
106
+ <label class="k" for="pref-gaze-curve-radial" data-i18n="gazeCurveRadial">二维距离曲率</label>
107
+ <input type="range" id="pref-gaze-curve-radial" min="5" max="100" step="5">
108
+ <span class="value" id="pref-gaze-curve-radial-value">0.10</span>
109
+ <button id="pref-gaze-curve-radial-reset" class="ghost" data-i18n="reset">复位</button>
110
+ </div>
111
+ </div>
112
+ <p class="hint" data-i18n="gazeCurveHint">数值越小,中心附近越灵敏;1 为线性。二维距离模式按满偏距离归一化后计算方向与强度,可减少远处光标上下穿过视线水平线时的俯仰跳变。各模式的参数分别保存。</p>
113
+ <div id="gaze-curves-spatial">
114
+ <div class="row">
115
+ <label class="k" for="pref-gaze-curve-spatial" data-i18n="gazeCurveSpatial">空间角度曲率</label>
116
+ <input type="range" id="pref-gaze-curve-spatial" min="5" max="100" step="5">
117
+ <span class="value" id="pref-gaze-curve-spatial-value">1.00</span>
118
+ <button id="pref-gaze-curve-spatial-reset" class="ghost" data-i18n="reset">复位</button>
119
+ </div>
120
+ <div class="row">
121
+ <label class="k" for="pref-gaze-distance" data-i18n="gazeDistance">注视距离</label>
122
+ <input type="number" id="pref-gaze-distance" min="0.1" max="5" step="0.1"> ×
123
+ <button id="pref-gaze-distance-reset" class="ghost" data-i18n="reset">复位</button>
124
+ </div>
125
+ <p class="hint" data-i18n="gazeSpatialHint">距离以满偏距离为单位,越大转头越缓。同高时,鼠标越偏向左右,俯仰越小;与双眼同高时俯仰为零。默认曲率 1、注视距离 0.1。</p>
105
126
  </div>
106
127
  <div class="row">
107
128
  <label class="k" for="pref-gaze-span-x" data-i18n="span">满偏距离</label>
@@ -114,6 +135,15 @@
114
135
  <input type="number" id="pref-gaze-idle" step="1" min="0" max="600"> <span data-i18n="secondsUnit">秒</span>
115
136
  <button id="pref-gaze-idle-reset" class="ghost" data-i18n="reset">复位</button>
116
137
  </div>
138
+ <div class="row">
139
+ <label class="k" for="pref-gaze-pitch-up" data-i18n="gazePitchUp">最大抬头角度</label>
140
+ <input type="number" id="pref-gaze-pitch-up" min="0" max="60" step="0.1"> °
141
+ <label class="k sub" for="pref-gaze-pitch-down" data-i18n="gazePitchDown">最大低头角度</label>
142
+ <input type="number" id="pref-gaze-pitch-down" min="0" max="60" step="0.1"> °
143
+ <button id="pref-gaze-pitch-reset" class="ghost" data-i18n="auto">自动</button>
144
+ </div>
145
+ <p class="hint" data-i18n="gazePitchHint">自动为抬头、低头各 60°。范围 0–60°;0 表示不向该方向转头。这是视线跟踪的附加角度,动作本身的头部姿势仍会保留。</p>
146
+ <p class="hint" data-i18n="gazeSpanHint">满偏距离填 0 为自动:水平和垂直均使用屏幕宽度的一半。</p>
117
147
  <p class="hint" id="pref-gaze-span-auto"></p>
118
148
  <p class="hint applied" id="pref-gaze-applied" data-i18n="readingWindow">(正在读取悬浮窗…)</p>
119
149
  </div>