dsh-plugin-vrmpet3d 0.1.1 → 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.
@@ -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}',
@@ -715,10 +717,9 @@
715
717
  * 旧的 `client.menu.js`,而那个 chunk 里这一行还在。这时它必须照旧工作,而不是掉出函数末尾什么都不做 ——
716
718
  * 一个点了没反应的菜单项,比一个不显示的行难查得多。
717
719
  *
718
- * 动作本身没变:宠物回到 DSH 里,而这个窗由 APP 关掉(一个写者;见 `applySurface`/`adoptSurface`)。
720
+ * 宠物回到 DSH 里;本页持久化选择后直接请宿主关闭浮窗,不依赖 DSH 页面在前台。
719
721
  */
720
- preferences.petSurface = 'app';
721
- writePreferences(store, core, preferences);
722
+ returnToApp();
722
723
  return;
723
724
  }
724
725
  /**
@@ -738,13 +739,12 @@
738
739
  /**
739
740
  * 关闭独立宠物: the pet goes back INSIDE the app, which is the only thing closing this window can mean.
740
741
  *
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).
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.
743
744
  * The row was 关闭宠物 until 2026-10-12 and used to HIDE the pet as well; the operator split the two
744
745
  * meanings then, keeping hiding for the app's pet (隐藏宠物) and this one for handing the pet back.
745
746
  */
746
- preferences.petSurface = 'app';
747
- writePreferences(store, core, preferences);
747
+ returnToApp();
748
748
  return;
749
749
  }
750
750
  if (id === 'restartHarness' || id === 'quitHarness') {
@@ -755,6 +755,26 @@
755
755
  }
756
756
  }
757
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
+
758
778
  /**
759
779
  * Push this surface's 重力 (`windowGravity`) into the placement.
760
780
  *
@@ -832,6 +852,7 @@
832
852
  */
833
853
  function openMenuAt(frameX, frameY) {
834
854
  if (!menu) return false;
855
+ prepareDance();
835
856
  /**
836
857
  * 非debug模式生效, read at OPEN time.
837
858
  *
@@ -853,6 +874,7 @@
853
874
  if (typeof menu.setChecked === 'function') {
854
875
  menu.setChecked('gravity', preferences.windowGravity === true);
855
876
  }
877
+ if (menu.setDanceState) menu.setDanceState(hostState);
856
878
  var size = menu.open();
857
879
  var width = Math.min(size.width, Math.max(80, placement.viewportWidth - MENU_MARGIN * 2));
858
880
  var height = Math.min(size.height, Math.max(80, placement.viewportHeight - MENU_MARGIN * 2));
@@ -987,6 +1009,24 @@
987
1009
  var speechNode = null;
988
1010
  var speechBox = null;
989
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
+ }
990
1030
  var speechShown = null;
991
1031
  /**
992
1032
  * A line THIS page said, because its own pet was touched, and until when.
@@ -1108,6 +1148,17 @@
1108
1148
  statusNode.appendChild(statusIconNode);
1109
1149
  statusNode.appendChild(shimmer);
1110
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
+ });
1111
1162
  }
1112
1163
 
1113
1164
  /**
@@ -1223,7 +1274,7 @@
1223
1274
  if (scene.THREE && typeof scene.projectToCanvas === 'function') {
1224
1275
  top = scene.projectToCanvas(new scene.THREE.Vector3(0, modelHeight, 0));
1225
1276
  }
1226
- var centreX = top ? box.left + top.x : box.left + box.width / 2;
1277
+ var centreX = box.left + box.width / 2;
1227
1278
  var width = speechNode.offsetWidth || 0;
1228
1279
  var height = speechNode.offsetHeight || 0;
1229
1280
  var promptHeight = statusBox && statusBox.width > 0 ? statusBox.height : 0;
@@ -1259,7 +1310,7 @@
1259
1310
  // A line THIS page said takes precedence while it lasts: the pet was touched here, and the next state poll
1260
1311
  // must not wipe the answer before it has been read.
1261
1312
  var localUp = localSpeech && Date.now() < localSpeech.until ? localSpeech : null;
1262
- var line = localUp ? localUp.text : hostSpeech;
1313
+ var line = hostState === 'dancing' ? null : (localUp ? localUp.text : (Date.now() < hostSpeechUntil ? hostSpeech : null));
1263
1314
  if (!line) {
1264
1315
  speechShown = null;
1265
1316
  speechNode.hidden = true;
@@ -1290,6 +1341,7 @@
1290
1341
 
1291
1342
  /** Put a line up for `holdSeconds`, then hand the bubble back to whatever the app is publishing. */
1292
1343
  function showLocalSpeech(text, holdSeconds) {
1344
+ if (hostState === 'dancing') return;
1293
1345
  var hold = Number.isFinite(Number(holdSeconds)) ? Math.max(1, Number(holdSeconds)) : 4;
1294
1346
  localSpeech = { text: text, until: Date.now() + hold * 1000 };
1295
1347
  if (localSpeechTimer) clearTimeout(localSpeechTimer);
@@ -1410,8 +1462,10 @@
1410
1462
  function applyHostState(state, at, line, error, stale, clock) {
1411
1463
  var known = typeof state === 'string' && Array.isArray(core.STATES) && core.STATES.indexOf(state) !== -1;
1412
1464
  hostState = known ? state : null;
1465
+ if (menu && menu.setDanceState) menu.setDanceState(hostState);
1466
+ if (hostState === 'dancing') localSpeech = null;
1413
1467
  hostStateAt = Number(at) > 0 ? Number(at) : Date.now();
1414
- hostSpeech = typeof line === 'string' && line ? line : null;
1468
+ receiveHostSpeech(line);
1415
1469
  hostStateError = typeof error === 'string' && error ? error : null;
1416
1470
  hostStateClock = clock === 'state'
1417
1471
  // The sentinel from a deployment with no turn frames: the duration is measured on this page's own state age.
@@ -1515,7 +1569,7 @@
1515
1569
  if (!statusNode) return;
1516
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,
1517
1571
  // 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);
1572
+ var line = core.statusFor(preferences, hostState, hostStateAt, Date.now(), hostStateClock, language, hostUnread);
1519
1573
  if (!line) {
1520
1574
  statusKey = null;
1521
1575
  statusShown = null;
@@ -1531,7 +1585,7 @@
1531
1585
  * 「用时 14分46秒」 — so the clock ticks by re-keying, exactly as the app's own label counts.
1532
1586
  */
1533
1587
  var key = line.animation + '\u0000' + (line.icon ? '1' : '0') + '\u0000'
1534
- + (line.state === 'working' && Number(hostUnread) > 0 ? 'dot' : '') + '\u0000' + shown;
1588
+ + (['working', 'waiting'].indexOf(line.state) !== -1 && Number(hostUnread) > 0 ? 'dot' : '') + '\u0000' + shown;
1535
1589
  /**
1536
1590
  * The tone — 任务达成 gold and bold, 任务失败/出错 red — written every frame rather than on a re-key.
1537
1591
  *
@@ -1539,6 +1593,8 @@
1539
1593
  * about which state is gold; only the shades below are the window's own, because it draws on a
1540
1594
  * transparent desktop instead of DSH's theme tokens.
1541
1595
  */
1596
+ if (['done', 'waiting', 'error'].indexOf(hostState) !== -1) statusNode.setAttribute('data-attention', '1');
1597
+ else statusNode.removeAttribute('data-attention');
1542
1598
  if (line.tone) statusNode.setAttribute('data-tone', line.tone);
1543
1599
  else statusNode.removeAttribute('data-tone');
1544
1600
  if (line.bold) statusNode.setAttribute('data-bold', '1');
@@ -1558,7 +1614,7 @@
1558
1614
  if (statusIconNode) statusIconNode.hidden = line.icon !== true;
1559
1615
  // And the green dot, when another session's completion is still unread (DSH's own marker for it).
1560
1616
  if (statusDotNode) {
1561
- statusDotNode.hidden = !(line.state === 'working' && Number(hostUnread) > 0);
1617
+ statusDotNode.hidden = !(['working', 'waiting'].indexOf(line.state) !== -1 && Number(hostUnread) > 0);
1562
1618
  }
1563
1619
  // A line that just appeared has no measured box yet: laying it out on this same frame keeps the
1564
1620
  // window from lagging a frame behind the text.
@@ -1732,7 +1788,7 @@
1732
1788
  * whose `state` is `dancing` into a real dance, and a preview of anything else interrupts one — which is what 「预览动作的
1733
1789
  * 时候,也应该打断跳舞」 asks for, with no second channel.
1734
1790
  */
1735
- function requestDance() {
1791
+ function danceClipIds() {
1736
1792
  var all = motionCatalog && Array.isArray(motionCatalog.motions) ? motionCatalog.motions : [];
1737
1793
  var folders = motionCatalog && motionCatalog.stateFolders && Array.isArray(motionCatalog.stateFolders.dancing)
1738
1794
  ? motionCatalog.stateFolders.dancing
@@ -1742,19 +1798,49 @@
1742
1798
  return entry && typeof entry.id === 'string'
1743
1799
  && prefixes.some(function (prefix) { return String(entry.name || '').indexOf(prefix) === 0; });
1744
1800
  }).map(function (entry) { return entry.id; });
1745
- if (!ids.length) {
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) {
1746
1820
  // The same sentence the app's pet shows in its badge for its own 跳支舞 row.
1747
1821
  showLocalSpeech(word(language, 'danceEmpty'), 3);
1748
1822
  return Promise.resolve(null);
1749
1823
  }
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)];
1824
+ var request = ++danceRequest;
1825
+ preparedDance = null;
1754
1826
  return fetch('/vrmpet3d/preview', {
1755
1827
  method: 'POST',
1756
1828
  headers: { 'content-type': 'application/json' },
1757
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;
1758
1844
  }).catch(function () { return null; });
1759
1845
  }
1760
1846
 
@@ -1887,8 +1973,9 @@
1887
1973
  * guard against sub-pixel `setBounds` churn absorbs.
1888
1974
  */
1889
1975
  if (statusBox && statusBox.width > 0) {
1890
- x = Math.min(x, statusBox.left);
1891
- 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);
1892
1979
  }
1893
1980
  /**
1894
1981
  * AND THE 碎碎念 BAND — because it is REPORTED, and this function's one job is to predict the window the launcher
@@ -2327,7 +2414,11 @@
2327
2414
  * no prompt up, the pet's box is.
2328
2415
  */
2329
2416
  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 };
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 };
2331
2422
  }
2332
2423
 
2333
2424
  function reportRect() {
@@ -2355,10 +2446,10 @@
2355
2446
  */
2356
2447
  status: statusBox && statusBox.width > 0
2357
2448
  ? {
2358
- left: screenBox.x + statusBox.left,
2359
- top: screenBox.y + statusBox.top,
2360
- width: statusBox.width,
2361
- 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),
2362
2453
  }
2363
2454
  : null,
2364
2455
  /**
@@ -2414,7 +2505,7 @@
2414
2505
  /**
2415
2506
  * The gaze, from the launcher's cursor rather than from an event.
2416
2507
  *
2417
- * 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
2418
2509
  * overlay uses), so the pet looks at the cursor wherever it is on the desktop ?including
2419
2510
  * outside the pet, which is the whole point of a desktop pet.
2420
2511
  */
@@ -2425,17 +2516,25 @@
2425
2516
  return;
2426
2517
  }
2427
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;
2428
2522
  var aim = core.pointerAim({
2429
2523
  pointerX: cursor.x - screenBox.x,
2430
2524
  pointerY: cursor.y - screenBox.y,
2431
- centreX: pixels.left + placement.width / 2,
2432
- 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,
2433
2527
  // 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
2528
+ // 0 means half the screen width for both axes, and the
2435
2529
  // 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),
2530
+ spanX: core.resolveGazeSpan(preferences.gazeSpanX, gazeScreenWidth),
2531
+ spanY: core.resolveGazeSpan(preferences.gazeSpanY, gazeScreenWidth),
2438
2532
  curve: preferences.gazeCurve,
2533
+ curveY: preferences.gazeCurveY,
2534
+ curveRadial: preferences.gazeCurveRadial,
2535
+ curveSpatial: preferences.gazeCurveSpatial,
2536
+ distance: preferences.gazeDistance,
2537
+ mode: preferences.gazeCurveMode,
2439
2538
  });
2440
2539
  lastAim = aim;
2441
2540
  scene.motion.setGaze(scene.gazeFromAim(aim));
@@ -2532,7 +2631,8 @@
2532
2631
  * `setMotionMap` refuses them, and a clip assigned to 空闲 is the right thing to show during a nap.
2533
2632
  */
2534
2633
  var drawn = 'resting';
2535
- if (placement.dragging && drag && drag.moved) drawn = 'held';
2634
+ if (hostState === 'waiting') drawn = 'waiting';
2635
+ else if (placement.dragging && drag && drag.moved) drawn = 'held';
2536
2636
  else if (placement.isAirborne()) drawn = 'falling';
2537
2637
  else if (motionStates.indexOf(hostState) !== -1) drawn = hostState;
2538
2638
  scene.setState(drawn);
@@ -2619,6 +2719,10 @@
2619
2719
  /** Set once a 跳舞 clip has been reported as finished, so one dance posts one "it is over" (see the frame loop). */
2620
2720
  var danceEndedReported = false;
2621
2721
  var dancePreview = null;
2722
+ var preparedDance = null;
2723
+ var danceRequest = 0;
2724
+ var previewLink = null;
2725
+ var stateLink = null;
2622
2726
 
2623
2727
  /** One line of text for anything throwable, the way the rest of this page reports failures. */
2624
2728
  function errorText(error) {
@@ -2888,9 +2992,14 @@
2888
2992
  // Published so a launcher-side probe can tell "the setting reached the renderer" from "the slider
2889
2993
  // did nothing" — the two are indistinguishable from outside otherwise.
2890
2994
  gaze: {
2891
- curve: preferences.gazeCurve,
2892
- spanX: core.resolveGazeSpan(preferences.gazeSpanX, screenBox.width),
2893
- 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),
2894
3003
  aim: lastAim,
2895
3004
  /**
2896
3005
  * What the RENDERER applied, which is not the same thing as the aim.
@@ -3158,7 +3267,8 @@
3158
3267
  core.VIEW_LIMITS.pitchMax,
3159
3268
  );
3160
3269
  scene.applyPreferences(preferences);
3161
- 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.
3162
3272
  }
3163
3273
 
3164
3274
  /**
@@ -3660,6 +3770,10 @@
3660
3770
  * the model the renderer resolves overrides for.
3661
3771
  */
3662
3772
  function applyExternalPreferences(next, shownModel) {
3773
+ if (viewDrag && viewDrag.moved) {
3774
+ next.viewYaw = preferences.viewYaw;
3775
+ next.viewPitch = preferences.viewPitch;
3776
+ }
3663
3777
  // Empty rather than a built-in id: with no avatar installed there is nothing to compare against, and a
3664
3778
  // stand-in name here is what used to make the page describe a pet that was not on screen.
3665
3779
  var current = scene.currentModelId || '';
@@ -3757,7 +3871,7 @@
3757
3871
  * on demand before it is handed to the scene, exactly as a mapped clip is.
3758
3872
  */
3759
3873
  if (core.watchHostPreview) {
3760
- core.watchHostPreview({
3874
+ previewLink = core.watchHostPreview({
3761
3875
  onPreview: function (preview) {
3762
3876
  dancePreview = preview && preview.state === 'dancing' ? preview : null;
3763
3877
  if (dancePreview) {
@@ -3796,7 +3910,7 @@
3796
3910
  * and this page draws whatever it is told. One clock for the feature, not two that can disagree.
3797
3911
  */
3798
3912
  if (core.watchHostState) {
3799
- core.watchHostState({
3913
+ stateLink = core.watchHostState({
3800
3914
  onState: function (state, body) {
3801
3915
  // The unread count first: `renderStatus` reads it when it decides whether the dot is drawn, and it runs
3802
3916
  // inside `applyHostState`.
@@ -4031,7 +4145,7 @@
4031
4145
  */
4032
4146
  speech: function () { return speechBox; },
4033
4147
  setSpeech: function (line) {
4034
- hostSpeech = typeof line === 'string' && line ? line : null;
4148
+ receiveHostSpeech(line);
4035
4149
  renderSpeech();
4036
4150
  layout();
4037
4151
  reportRect();
@@ -4039,7 +4153,11 @@
4039
4153
  return speechShown;
4040
4154
  },
4041
4155
  destroy: function () {
4156
+ if (hostSpeechTimer) clearTimeout(hostSpeechTimer);
4042
4157
  running = false;
4158
+ danceRequest += 1;
4159
+ if (previewLink && typeof previewLink.stop === 'function') previewLink.stop();
4160
+ if (stateLink && typeof stateLink.stop === 'function') stateLink.stop();
4043
4161
  canvas.removeEventListener('pointerdown', onPointerDown);
4044
4162
  canvas.removeEventListener('pointermove', onPointerMove);
4045
4163
  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>