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.
- package/desktop/main.js +2 -1
- package/desktop/preload.js +1 -0
- package/lib/client.core.js +279 -41
- package/lib/client.js +114 -16
- package/lib/client.menu.js +14 -4
- package/lib/client.scene.js +134 -7
- package/lib/config-page.html +11 -0
- package/lib/config-page.js +153 -25
- package/lib/core.js +279 -41
- package/lib/desktop-page.js +158 -40
- package/lib/desktop.js +18 -1
- package/lib/global-page.html +34 -4
- package/lib/index.js +46 -25
- package/lib/menu.js +14 -4
- package/lib/scene.js +134 -7
- package/lib/vrm-info.js +16 -1
- package/package.json +1 -1
package/lib/desktop-page.js
CHANGED
|
@@ -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
|
-
*
|
|
720
|
+
* 宠物回到 DSH 里;本页持久化选择后直接请宿主关闭浮窗,不依赖 DSH 页面在前台。
|
|
719
721
|
*/
|
|
720
|
-
|
|
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
|
-
*
|
|
742
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1751
|
-
|
|
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
|
-
|
|
1891
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
2437
|
-
spanY: core.resolveGazeSpan(preferences.gazeSpanY,
|
|
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 (
|
|
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.
|
|
2892
|
-
|
|
2893
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/global-page.html
CHANGED
|
@@ -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-
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
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>
|