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.
- package/README.md +1 -0
- package/assets/game/levels.json +2925 -0
- package/desktop/game-visibility.cjs +50 -0
- package/desktop/main.js +60 -3
- package/desktop/preload.js +11 -0
- package/lib/client.core.js +279 -41
- package/lib/client.js +144 -16
- package/lib/client.menu.js +17 -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 +167 -40
- package/lib/desktop.js +18 -1
- package/lib/global-page.html +34 -4
- package/lib/index.js +94 -25
- package/lib/menu.js +17 -4
- package/lib/mini-game-core.js +329 -0
- package/lib/mini-game-level-store.js +37 -0
- package/lib/mini-game-page.js +615 -0
- package/lib/mini-game.css +19 -0
- package/lib/mini-game.html +50 -0
- package/lib/scene.js +134 -7
- package/lib/vrm-info.js +16 -1
- package/package.json +3 -3
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}',
|
|
@@ -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
|
-
*
|
|
721
|
+
* 宠物回到 DSH 里;本页持久化选择后直接请宿主关闭浮窗,不依赖 DSH 页面在前台。
|
|
719
722
|
*/
|
|
720
|
-
|
|
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
|
-
*
|
|
742
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1751
|
-
|
|
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
|
-
|
|
1891
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
2437
|
-
spanY: core.resolveGazeSpan(preferences.gazeSpanY,
|
|
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 (
|
|
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.
|
|
2892
|
-
|
|
2893
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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>
|