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