dsh-plugin-vrmpet3d 0.1.2 → 0.1.4
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 +65 -0
- package/desktop/main.js +58 -2
- package/desktop/preload.js +10 -0
- package/lib/client.core.js +78 -33
- package/lib/client.js +41 -11
- package/lib/client.menu.js +3 -0
- package/lib/client.scene.js +3 -2
- package/lib/config-page.js +12 -12
- package/lib/core.js +78 -33
- package/lib/desktop-page.js +16 -7
- package/lib/index.js +182 -34
- package/lib/menu.js +3 -0
- 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/model-picker.js +4 -4
- package/lib/scene.js +3 -2
- package/lib/setup-page.js +3 -3
- package/package.json +3 -3
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A focused game temporarily hides only a pet window that was visible beforehand.
|
|
2
|
+
// A transparent window can paint and report pixel hits after showInactive while
|
|
3
|
+
// Chromium still uses the pre-hide input surface. A real bounds transition (the
|
|
4
|
+
// same transition wheel zoom causes) rebuilds that surface without changing the pet.
|
|
5
|
+
function refreshInputRegion(win, schedule = setTimeout) {
|
|
6
|
+
if (win.isDestroyed() || !win.isVisible()) return;
|
|
7
|
+
const original = win.getBounds();
|
|
8
|
+
const temporary = { ...original, width: original.width + 1 };
|
|
9
|
+
win.setBounds(temporary, false);
|
|
10
|
+
schedule(() => {
|
|
11
|
+
if (win.isDestroyed()) return;
|
|
12
|
+
const current = win.getBounds();
|
|
13
|
+
// A drag or another layout update owns its new bounds; never move it back.
|
|
14
|
+
if (['x', 'y', 'width', 'height'].every(key => current[key] === temporary[key])) win.setBounds(original, false);
|
|
15
|
+
}, 32);
|
|
16
|
+
}
|
|
17
|
+
function createVisibilityController(win, hooks = {}) {
|
|
18
|
+
let hiddenByGame = false;
|
|
19
|
+
return function apply(active) {
|
|
20
|
+
if (win.isDestroyed()) return;
|
|
21
|
+
if (active && !hiddenByGame && win.isVisible()) { hiddenByGame = true; hooks.suspend?.(); win.hide(); }
|
|
22
|
+
else if (!active && hiddenByGame) { hiddenByGame = false; win.showInactive(); hooks.resume?.(); }
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function watchGameVisibility(win, pageURL, interval = 500, hooks = {}) {
|
|
26
|
+
const apply = createVisibilityController(win, hooks);
|
|
27
|
+
const url = new URL('/vrmpet3d/desktop', pageURL);
|
|
28
|
+
// The page and this poll must use the SAME Chromium session (cookies/proxy/TLS).
|
|
29
|
+
// Node's http.get never inherited the window's authentication cookies.
|
|
30
|
+
const session = win.webContents.session;
|
|
31
|
+
let request = null, stopped = false;
|
|
32
|
+
async function poll() {
|
|
33
|
+
if (stopped || request || win.isDestroyed()) return;
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
request = controller;
|
|
36
|
+
const deadline = setTimeout(() => controller.abort(), 1500);
|
|
37
|
+
deadline.unref?.();
|
|
38
|
+
try {
|
|
39
|
+
const response = await session.fetch(url.href, {
|
|
40
|
+
credentials: 'include', redirect: 'error', cache: 'no-store', signal: controller.signal,
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok || response.redirected || !/^application\/json\b/i.test(response.headers.get('content-type') || '')) {
|
|
43
|
+
await response.body?.cancel();
|
|
44
|
+
throw new Error('game status is not authenticated JSON');
|
|
45
|
+
}
|
|
46
|
+
const reader = response.body.getReader();
|
|
47
|
+
let size = 0;
|
|
48
|
+
const chunks = [];
|
|
49
|
+
try {
|
|
50
|
+
for (;;) {
|
|
51
|
+
const { value, done } = await reader.read();
|
|
52
|
+
if (done) break;
|
|
53
|
+
size += value.byteLength;
|
|
54
|
+
if (size > 16384) { await reader.cancel(); throw new Error('game status exceeds limit'); }
|
|
55
|
+
chunks.push(Buffer.from(value));
|
|
56
|
+
}
|
|
57
|
+
} finally { reader.releaseLock(); }
|
|
58
|
+
if (!stopped) apply(JSON.parse(Buffer.concat(chunks).toString('utf8')).gameFocused === true);
|
|
59
|
+
} catch (_) { if (!stopped) apply(false); }
|
|
60
|
+
finally { clearTimeout(deadline); if (request === controller) request = null; }
|
|
61
|
+
}
|
|
62
|
+
const timer = setInterval(poll, interval); timer.unref(); poll();
|
|
63
|
+
return function stop() { stopped = true; clearInterval(timer); if (request) request.abort(); apply(false); };
|
|
64
|
+
}
|
|
65
|
+
module.exports = { createVisibilityController, watchGameVisibility, refreshInputRegion };
|
package/desktop/main.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
const { app, BrowserWindow, dialog, ipcMain, screen, shell } = require('electron');
|
|
12
12
|
const fs = require('node:fs');
|
|
13
13
|
const path = require('node:path');
|
|
14
|
+
const { watchGameVisibility, refreshInputRegion } = require('./game-visibility.cjs');
|
|
14
15
|
|
|
15
16
|
/** Cursor polling rate. The gaze is smoothed by the idle, so this is about drag latency. */
|
|
16
17
|
const CURSOR_HZ = 60;
|
|
@@ -251,6 +252,19 @@ function writeSelfTest(code) {
|
|
|
251
252
|
app.exit(code);
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
// A second game launch focuses the existing game instead of opening another process on its profile.
|
|
256
|
+
const gameMode = options.config === true && new URL(options.url).pathname === '/vrmpet3d/game';
|
|
257
|
+
let activeGameWindow = null;
|
|
258
|
+
function focusGameWindow() {
|
|
259
|
+
if (!activeGameWindow || activeGameWindow.isDestroyed()) return;
|
|
260
|
+
if (activeGameWindow.isMinimized()) activeGameWindow.restore();
|
|
261
|
+
activeGameWindow.show(); activeGameWindow.focus();
|
|
262
|
+
}
|
|
263
|
+
if (gameMode) {
|
|
264
|
+
if (!app.requestSingleInstanceLock()) app.exit(0);
|
|
265
|
+
else app.on('second-instance', focusGameWindow);
|
|
266
|
+
}
|
|
267
|
+
|
|
254
268
|
/** The pet's rectangle on screen, as last reported by the page; null until it mounts. */
|
|
255
269
|
let petRect = null;
|
|
256
270
|
/** The right-click menu's rectangle in screen coordinates, while it is open. DOM, so the window holds it. */
|
|
@@ -269,6 +283,7 @@ let speechRect = null;
|
|
|
269
283
|
let lastCursor = null;
|
|
270
284
|
/** Whether the cursor is on a pixel the page actually painted — the page's own verdict. */
|
|
271
285
|
let overPet = false;
|
|
286
|
+
const inputDiagnostics = { through: true, recoveries: 0, regions: 0, events: {}, lastEvent: null };
|
|
272
287
|
let interacting = false;
|
|
273
288
|
/** True while the self-test drives the page with synthetic cursors. */
|
|
274
289
|
let probing = false;
|
|
@@ -300,6 +315,7 @@ app.whenReady().then(async () => {
|
|
|
300
315
|
// settings form. Read from the argument rather than from a separate entry point, because the rest of
|
|
301
316
|
// the launcher (the borrowed runtime, the watchdog, the parent-pid handshake) is identical for both.
|
|
302
317
|
const isConfig = options.config === true;
|
|
318
|
+
const isGame = isConfig && new URL(options.url).pathname === '/vrmpet3d/game';
|
|
303
319
|
|
|
304
320
|
/**
|
|
305
321
|
* Arm the self-test's own deadline before anything can hang.
|
|
@@ -329,7 +345,8 @@ app.whenReady().then(async () => {
|
|
|
329
345
|
height: 860,
|
|
330
346
|
minWidth: 560,
|
|
331
347
|
minHeight: 420,
|
|
332
|
-
title: 'vrmpet3d - model settings',
|
|
348
|
+
title: isGame ? '吃白饭 · 桌宠弹球' : 'vrmpet3d - model settings',
|
|
349
|
+
fullscreen: isGame,
|
|
333
350
|
backgroundColor: '#16171c',
|
|
334
351
|
show: true,
|
|
335
352
|
webPreferences: {
|
|
@@ -428,7 +445,7 @@ app.whenReady().then(async () => {
|
|
|
428
445
|
|
|
429
446
|
const setClickThrough = (through) => {
|
|
430
447
|
if (win.isDestroyed() || mouseThrough === through) return;
|
|
431
|
-
mouseThrough = through;
|
|
448
|
+
mouseThrough = through; inputDiagnostics.through = through;
|
|
432
449
|
if (through) win.setIgnoreMouseEvents(true, { forward: true });
|
|
433
450
|
else win.setIgnoreMouseEvents(false);
|
|
434
451
|
keepOnTop();
|
|
@@ -651,6 +668,12 @@ app.whenReady().then(async () => {
|
|
|
651
668
|
return null;
|
|
652
669
|
}
|
|
653
670
|
});
|
|
671
|
+
ipcMain.on('vrmpet3d:input-event', (event, value) => {
|
|
672
|
+
if (event.sender !== win.webContents || !value || !['pointerdown','pointerup','mousedown','mouseup','wheel'].includes(value.type)) return;
|
|
673
|
+
inputDiagnostics.events[value.type] = (inputDiagnostics.events[value.type] || 0) + 1;
|
|
674
|
+
inputDiagnostics.lastEvent = {type:value.type,button:Number.isInteger(value.button) ? value.button : null,
|
|
675
|
+
target:typeof value.target === 'string' ? value.target.slice(0,32) : '',trusted:value.trusted === true};
|
|
676
|
+
});
|
|
654
677
|
ipcMain.on('vrmpet3d:rect', (_event, rect) => {
|
|
655
678
|
if (!rect || typeof rect !== 'object') return;
|
|
656
679
|
petRect = {
|
|
@@ -795,6 +818,31 @@ app.whenReady().then(async () => {
|
|
|
795
818
|
});
|
|
796
819
|
ipcMain.on('vrmpet3d:quit', () => app.quit());
|
|
797
820
|
win.on('closed', () => app.quit());
|
|
821
|
+
if (!isConfig) {
|
|
822
|
+
const resetPetInput = () => {
|
|
823
|
+
interacting = false; overPet = false; zoomHoldUntil = 0; orbitFocus = false;
|
|
824
|
+
win.setFocusable(false); releaseOrbitLock();
|
|
825
|
+
// Recreate Electron's native forwarding hook; repeating `true` alone can reuse the stale hook after hide/show.
|
|
826
|
+
win.setIgnoreMouseEvents(false);
|
|
827
|
+
mouseThrough = false;
|
|
828
|
+
setClickThrough(true);
|
|
829
|
+
inputDiagnostics.recoveries++;
|
|
830
|
+
sendCursor(screen.getCursorScreenPoint());
|
|
831
|
+
};
|
|
832
|
+
const stopGameVisibility = watchGameVisibility(win, options.url, 500, {
|
|
833
|
+
suspend: resetPetInput,
|
|
834
|
+
resume: () => {
|
|
835
|
+
resetPetInput();
|
|
836
|
+
refreshInputRegion(win); inputDiagnostics.regions++;
|
|
837
|
+
},
|
|
838
|
+
});
|
|
839
|
+
win.once('closed', stopGameVisibility);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (isGame) {
|
|
843
|
+
activeGameWindow = win;
|
|
844
|
+
win.once('closed', () => { activeGameWindow = null; });
|
|
845
|
+
}
|
|
798
846
|
|
|
799
847
|
// The cursor is read from the OS rather than from page events: a click-through window
|
|
800
848
|
// receives no pointer events at all outside the pet, so events cannot drive the gaze,
|
|
@@ -1624,6 +1672,14 @@ function announce(info) {
|
|
|
1624
1672
|
* from the page state, so a page that never published reports `null` rather than a stale number.
|
|
1625
1673
|
*/
|
|
1626
1674
|
frames: lastPageState && typeof lastPageState.frames === 'number' ? lastPageState.frames : null,
|
|
1675
|
+
input: {
|
|
1676
|
+
through: inputDiagnostics.through, recoveries: inputDiagnostics.recoveries,
|
|
1677
|
+
regions: inputDiagnostics.regions, events: inputDiagnostics.events, lastEvent: inputDiagnostics.lastEvent,
|
|
1678
|
+
cursor: lastCursor, pageCursor: lastPageState && lastPageState.cursor || null,
|
|
1679
|
+
lastHit: lastPageState && lastPageState.lastHit || null,
|
|
1680
|
+
hitReads: lastPageState && lastPageState.hitReads || 0,
|
|
1681
|
+
hit: lastPageState && lastPageState.hit || null,
|
|
1682
|
+
},
|
|
1627
1683
|
over: overPet,
|
|
1628
1684
|
interacting: interacting,
|
|
1629
1685
|
rect: petRect,
|
package/desktop/preload.js
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
// process, or any other window.
|
|
8
8
|
const { contextBridge, ipcRenderer } = require('electron');
|
|
9
9
|
|
|
10
|
+
// Read-only counters distinguish pixel hits from actual button delivery after hide/show.
|
|
11
|
+
for (const type of ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'wheel']) {
|
|
12
|
+
document.addEventListener(type, event => {
|
|
13
|
+
ipcRenderer.send('vrmpet3d:input-event', {
|
|
14
|
+
type, button: event.button, trusted: event.isTrusted === true,
|
|
15
|
+
target: event.target && event.target.tagName || '',
|
|
16
|
+
});
|
|
17
|
+
}, true);
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
contextBridge.exposeInMainWorld('vrmpet3dDesktop', {
|
|
11
21
|
/**
|
|
12
22
|
* Ask for the launch configuration: which model to show, where the asset route lives.
|
package/lib/client.core.js
CHANGED
|
@@ -5013,7 +5013,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
|
|
|
5013
5013
|
function load() {
|
|
5014
5014
|
if (!supported || stopped) return Promise.resolve(null);
|
|
5015
5015
|
var request = ++issued;
|
|
5016
|
-
return doFetch(url, { credentials: '
|
|
5016
|
+
return doFetch(url, { credentials: 'same-origin' })
|
|
5017
5017
|
.then(function (response) {
|
|
5018
5018
|
if (response.status === 404 || response.status === 405) {
|
|
5019
5019
|
supported = false;
|
|
@@ -5177,7 +5177,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
|
|
|
5177
5177
|
function load() {
|
|
5178
5178
|
if (!supported || stopped) return Promise.resolve(null);
|
|
5179
5179
|
var request = ++issued;
|
|
5180
|
-
return doFetch(url, { credentials: '
|
|
5180
|
+
return doFetch(url, { credentials: 'same-origin' })
|
|
5181
5181
|
.then(function (response) {
|
|
5182
5182
|
if (response.status === 404 || response.status === 405) {
|
|
5183
5183
|
supported = false;
|
|
@@ -5221,7 +5221,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
|
|
|
5221
5221
|
/**
|
|
5222
5222
|
* The reporting half of `watchHostState`: the app's pet tells the host what it resolved.
|
|
5223
5223
|
*
|
|
5224
|
-
* Deduplicated on the
|
|
5224
|
+
* Deduplicated on the latest in-flight value or acknowledged value, so the frame loop can call this on every change
|
|
5225
5225
|
* without a POST per frame — and a state that flaps back to what the host already has costs nothing.
|
|
5226
5226
|
*
|
|
5227
5227
|
* The 碎碎念 LINE travels with the state, and the dedupe key is both together: the pet can say something
|
|
@@ -5253,6 +5253,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
|
|
|
5253
5253
|
var sentUnread = null;
|
|
5254
5254
|
/** The last diagnostic sent (see `report`), so the heartbeat re-states what the state it repeats was based on. */
|
|
5255
5255
|
var sentWhy = null;
|
|
5256
|
+
var reportSequence = 0;
|
|
5257
|
+
var pendingReport = null;
|
|
5256
5258
|
var beatTimer = null;
|
|
5257
5259
|
/**
|
|
5258
5260
|
* What the heartbeat re-states: the last STATE and LINE, never the touch.
|
|
@@ -5473,22 +5475,27 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
|
|
|
5473
5475
|
return JSON.stringify(value ? [value.waitingSessionIds || [], value.completedSessionIds || [],
|
|
5474
5476
|
value.statusSessionId || null, value.completedSessionId || null] : [[], [], null, null]);
|
|
5475
5477
|
};
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
+
// Dedupe against the newest in-flight request, or the last ACKNOWLEDGED report.
|
|
5479
|
+
// A rejected request must remain retryable; an older response cannot commit over a newer report.
|
|
5480
|
+
if (!doFetch || !next) return Promise.resolve(null);
|
|
5481
|
+
var key = JSON.stringify([next, nextLine, nextTouchKey, nextClockKey, nextUnread, navigationKey(nextWhy)]);
|
|
5482
|
+
if (force !== true && ((pendingReport && pendingReport.key === key)
|
|
5483
|
+
|| (!pendingReport && next === sent && nextLine === sentLine && nextTouchKey === sentTouchKey
|
|
5484
|
+
&& nextClockKey === sentClockKey && nextUnread === sentUnread && navigationKey(nextWhy) === navigationKey(sentWhy)))) {
|
|
5478
5485
|
return Promise.resolve(null);
|
|
5479
5486
|
}
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5487
|
+
var request = ++reportSequence;
|
|
5488
|
+
pendingReport = { key: key, request: request };
|
|
5489
|
+
// Heartbeats retry the latest desired STATE, never a one-shot touch event.
|
|
5490
|
+
lastBeat = { state: next, line: nextLine, why: nextWhy, clock: nextClock, unread: nextUnread };
|
|
5491
|
+
function failed() {
|
|
5492
|
+
if (request === reportSequence) {
|
|
5493
|
+
pendingReport = null;
|
|
5494
|
+
sent = null;
|
|
5495
|
+
sentLine = sentTouch = sentClock = sentUnread = sentWhy = null;
|
|
5496
|
+
}
|
|
5497
|
+
return null;
|
|
5498
|
+
}
|
|
5492
5499
|
try {
|
|
5493
5500
|
var body = { state: next };
|
|
5494
5501
|
if (nextLine) body.line = nextLine;
|
|
@@ -5496,16 +5503,57 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
|
|
|
5496
5503
|
if (nextClock) body.clock = nextClock;
|
|
5497
5504
|
if (nextUnread !== null) body.unread = nextUnread;
|
|
5498
5505
|
if (nextWhy) body.why = nextWhy;
|
|
5499
|
-
return
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5506
|
+
return postReport(body).then(function (accepted) {
|
|
5507
|
+
if (!accepted) return failed();
|
|
5508
|
+
if (request === reportSequence) {
|
|
5509
|
+
pendingReport = null;
|
|
5510
|
+
sent = next;
|
|
5511
|
+
sentLine = nextLine;
|
|
5512
|
+
sentTouch = nextTouch;
|
|
5513
|
+
sentClock = nextClock;
|
|
5514
|
+
sentUnread = nextUnread;
|
|
5515
|
+
sentWhy = nextWhy;
|
|
5516
|
+
}
|
|
5517
|
+
return next;
|
|
5518
|
+
}).catch(failed);
|
|
5505
5519
|
} catch (_error) {
|
|
5506
|
-
return Promise.resolve(
|
|
5520
|
+
return Promise.resolve(failed());
|
|
5507
5521
|
}
|
|
5508
5522
|
}
|
|
5523
|
+
function readAcknowledgement(response) {
|
|
5524
|
+
if (!response || !response.ok || response.redirected) return Promise.resolve(false);
|
|
5525
|
+
return response.json().then(function (payload) {
|
|
5526
|
+
return !!payload && typeof payload.seq === 'number' && Number.isFinite(payload.seq)
|
|
5527
|
+
&& payload.seq >= 0 && payload.superseded !== true;
|
|
5528
|
+
});
|
|
5529
|
+
}
|
|
5530
|
+
function postReport(body) {
|
|
5531
|
+
var controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
5532
|
+
var deadline = null;
|
|
5533
|
+
// A stalled fetch/body must not permanently suppress all subsequent heartbeats.
|
|
5534
|
+
var timeoutMs = Number(settings.requestTimeoutMs) > 0 ? Number(settings.requestTimeoutMs) : 8000;
|
|
5535
|
+
return new Promise(function (resolve, reject) {
|
|
5536
|
+
deadline = setTimeout(function () {
|
|
5537
|
+
if (controller) controller.abort();
|
|
5538
|
+
resolve(false);
|
|
5539
|
+
}, timeoutMs);
|
|
5540
|
+
if (deadline && typeof deadline.unref === 'function') deadline.unref();
|
|
5541
|
+
try {
|
|
5542
|
+
var options = {
|
|
5543
|
+
method: 'POST', credentials: 'same-origin', redirect: 'error',
|
|
5544
|
+
headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
5545
|
+
};
|
|
5546
|
+
if (controller) options.signal = controller.signal;
|
|
5547
|
+
Promise.resolve(doFetch(url, options)).then(readAcknowledgement).then(resolve, reject);
|
|
5548
|
+
} catch (error) { reject(error); }
|
|
5549
|
+
}).then(function (accepted) {
|
|
5550
|
+
clearTimeout(deadline);
|
|
5551
|
+
return accepted;
|
|
5552
|
+
}, function (error) {
|
|
5553
|
+
clearTimeout(deadline);
|
|
5554
|
+
throw error;
|
|
5555
|
+
});
|
|
5556
|
+
}
|
|
5509
5557
|
/**
|
|
5510
5558
|
* Re-state the last report, unchanged, so that SILENCE can be told from DEATH.
|
|
5511
5559
|
*
|
|
@@ -5522,7 +5570,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
|
|
|
5522
5570
|
* all" on every beat — so the floating window lost the turn's 用时 a few seconds into every long turn. `force`
|
|
5523
5571
|
* is the parameter that exists for this.
|
|
5524
5572
|
*/
|
|
5525
|
-
|
|
5573
|
+
if (pendingReport) return Promise.resolve(null);
|
|
5574
|
+
return report(lastBeat.state, lastBeat.line, null, lastBeat.clock, true, lastBeat.unread, lastBeat.why);
|
|
5526
5575
|
}
|
|
5527
5576
|
|
|
5528
5577
|
function startHeartbeat(intervalMs) {
|
|
@@ -5552,12 +5601,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
|
|
|
5552
5601
|
if (!doFetch) return Promise.resolve(null);
|
|
5553
5602
|
var message = error && (error.stack || error.message) ? String(error.stack || error.message) : String(error);
|
|
5554
5603
|
try {
|
|
5555
|
-
return
|
|
5556
|
-
|
|
5557
|
-
credentials: 'omit',
|
|
5558
|
-
headers: { 'content-type': 'application/json' },
|
|
5559
|
-
body: JSON.stringify({ state: sent || '', error: message.slice(0, 500) }),
|
|
5560
|
-
}).then(function () { return message; }, function () { return null; });
|
|
5604
|
+
return postReport({ state: sent || '', error: message.slice(0, 500) })
|
|
5605
|
+
.then(function (accepted) { return accepted ? message : null; }).catch(function () { return null; });
|
|
5561
5606
|
} catch (_error) {
|
|
5562
5607
|
return Promise.resolve(null);
|
|
5563
5608
|
}
|
|
@@ -5618,7 +5663,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
|
|
|
5618
5663
|
function load() {
|
|
5619
5664
|
if (!supported || stopped) return Promise.resolve(null);
|
|
5620
5665
|
var request = ++issued;
|
|
5621
|
-
return doFetch(url, { credentials: '
|
|
5666
|
+
return doFetch(url, { credentials: 'same-origin' })
|
|
5622
5667
|
.then(function (response) {
|
|
5623
5668
|
if (response.status === 404 || response.status === 405) {
|
|
5624
5669
|
// No store on this host: stop asking, and let the caller keep its own storage.
|
|
@@ -5644,7 +5689,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
|
|
|
5644
5689
|
var request = ++issued;
|
|
5645
5690
|
return doFetch(url, {
|
|
5646
5691
|
method: 'POST',
|
|
5647
|
-
credentials: '
|
|
5692
|
+
credentials: 'same-origin',
|
|
5648
5693
|
headers: { 'content-type': 'application/json' },
|
|
5649
5694
|
body: JSON.stringify(body || {}),
|
|
5650
5695
|
})
|
package/lib/client.js
CHANGED
|
@@ -1667,6 +1667,19 @@
|
|
|
1667
1667
|
*/
|
|
1668
1668
|
VrmPet3DWidget.prototype.openSettingsWindow = function (page) {
|
|
1669
1669
|
var self = this;
|
|
1670
|
+
if (page === 'game') {
|
|
1671
|
+
var gameUrl = '/vrmpet3d/game?model=' + encodeURIComponent(self.currentModelId() || '');
|
|
1672
|
+
if ((!globalThis.dshDesktop && !/Electron\//i.test(globalThis.navigator.userAgent)) || !self.canUseIndependentPet()) {
|
|
1673
|
+
globalThis.open(gameUrl, '_blank', 'noopener'); return;
|
|
1674
|
+
}
|
|
1675
|
+
fetch('/vrmpet3d/desktop', { method: 'POST', credentials: 'same-origin',
|
|
1676
|
+
headers: { 'content-type': 'application/json' },
|
|
1677
|
+
body: JSON.stringify({ action: 'game', modelId: self.currentModelId() })
|
|
1678
|
+
}).then(function (response) {
|
|
1679
|
+
if (!response.ok) self.showStatus('小游戏窗口打开失败,请重试。');
|
|
1680
|
+
}).catch(function () { self.showStatus('小游戏窗口打开失败,请重试。'); });
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1670
1683
|
/**
|
|
1671
1684
|
* Which window, and the host action that opens it.
|
|
1672
1685
|
*
|
|
@@ -1692,7 +1705,7 @@
|
|
|
1692
1705
|
fetch('/vrmpet3d/desktop', {
|
|
1693
1706
|
method: 'POST',
|
|
1694
1707
|
headers: { 'content-type': 'application/json' },
|
|
1695
|
-
credentials: '
|
|
1708
|
+
credentials: 'same-origin',
|
|
1696
1709
|
// The model goes with the request: the model window must configure the model that is ON SCREEN, and this
|
|
1697
1710
|
// surface is the only one that knows it at that moment. Without it the host can only fall back to the
|
|
1698
1711
|
// launcher's model (or nothing), which is how the settings window ended up describing the built-in
|
|
@@ -1728,12 +1741,12 @@
|
|
|
1728
1741
|
var send = function (body) {
|
|
1729
1742
|
return fetch('/vrmpet3d/setup-state', {
|
|
1730
1743
|
method: 'POST',
|
|
1731
|
-
credentials: '
|
|
1744
|
+
credentials: 'same-origin',
|
|
1732
1745
|
headers: { 'content-type': 'application/json' },
|
|
1733
1746
|
body: JSON.stringify(body),
|
|
1734
1747
|
});
|
|
1735
1748
|
};
|
|
1736
|
-
return fetch('/vrmpet3d/setup-state', { credentials: '
|
|
1749
|
+
return fetch('/vrmpet3d/setup-state', { credentials: 'same-origin' })
|
|
1737
1750
|
.then(function (response) { return response.ok ? response.json() : null; })
|
|
1738
1751
|
.then(function (state) {
|
|
1739
1752
|
if (!state || state.completed === true || state.prompted === true) return null;
|
|
@@ -1741,7 +1754,7 @@
|
|
|
1741
1754
|
.then(function () {
|
|
1742
1755
|
return fetch('/vrmpet3d/desktop', {
|
|
1743
1756
|
method: 'POST',
|
|
1744
|
-
credentials: '
|
|
1757
|
+
credentials: 'same-origin',
|
|
1745
1758
|
headers: { 'content-type': 'application/json' },
|
|
1746
1759
|
body: JSON.stringify({ action: 'setup' }),
|
|
1747
1760
|
});
|
|
@@ -1875,6 +1888,7 @@
|
|
|
1875
1888
|
// 切换模型: the model picker's own page. It decides WHICH model every other page is about, so it is a
|
|
1876
1889
|
// window of its own rather than a row inside 当前模型参数设置 (see ITEMS in lib/menu.js).
|
|
1877
1890
|
if (id === 'switchModel') { this.openSettingsWindow('picker'); return; }
|
|
1891
|
+
if (id === 'miniGame') { this.openSettingsWindow('game'); return; }
|
|
1878
1892
|
/**
|
|
1879
1893
|
* 重载模型 — re-read the model file and rebuild the scene (operator, 2026-10-14: 「给内置和外置宠物的右键菜单新增“重载模型”
|
|
1880
1894
|
* 的选项,用来刷新模型」).
|
|
@@ -2259,7 +2273,7 @@
|
|
|
2259
2273
|
this.syncDesktop(null, strings.desktopStarting);
|
|
2260
2274
|
return fetch('/vrmpet3d/desktop', {
|
|
2261
2275
|
method: 'POST',
|
|
2262
|
-
credentials: '
|
|
2276
|
+
credentials: 'same-origin',
|
|
2263
2277
|
headers: { 'content-type': 'application/json' },
|
|
2264
2278
|
body: JSON.stringify({
|
|
2265
2279
|
action: enabled ? 'enable' : 'disable',
|
|
@@ -2282,7 +2296,7 @@
|
|
|
2282
2296
|
/** Ask the host what the window is doing, and show it. */
|
|
2283
2297
|
VrmPet3DWidget.prototype.refreshDesktopStatus = function () {
|
|
2284
2298
|
var self = this;
|
|
2285
|
-
return fetch('/vrmpet3d/desktop', { credentials: '
|
|
2299
|
+
return fetch('/vrmpet3d/desktop', { credentials: 'same-origin' })
|
|
2286
2300
|
.then(function (response) { return response.ok ? response.json() : null; })
|
|
2287
2301
|
.then(function (status) { self.syncDesktop(status, null); return status; })
|
|
2288
2302
|
.catch(function () {
|
|
@@ -2990,7 +3004,7 @@
|
|
|
2990
3004
|
*/
|
|
2991
3005
|
VrmPet3DWidget.prototype.refreshCatalog = function () {
|
|
2992
3006
|
var self = this;
|
|
2993
|
-
return fetch('/vrmpet3d/catalog', { credentials: '
|
|
3007
|
+
return fetch('/vrmpet3d/catalog', { credentials: 'same-origin' })
|
|
2994
3008
|
.then(function (response) {
|
|
2995
3009
|
if (!response.ok) throw new Error(word(self.language, 'catalogRoute', { status: response.status }));
|
|
2996
3010
|
return response.json();
|
|
@@ -3208,7 +3222,7 @@
|
|
|
3208
3222
|
*/
|
|
3209
3223
|
VrmPet3DWidget.prototype.refreshMotions = function () {
|
|
3210
3224
|
var self = this;
|
|
3211
|
-
return fetch('/vrmpet3d/motions', { credentials: '
|
|
3225
|
+
return fetch('/vrmpet3d/motions', { credentials: 'same-origin' })
|
|
3212
3226
|
.then(function (response) {
|
|
3213
3227
|
if (!response.ok) throw new Error('motion catalog route returned HTTP ' + response.status);
|
|
3214
3228
|
return response.json();
|
|
@@ -3675,7 +3689,7 @@
|
|
|
3675
3689
|
/** Fetch one motion's bytes from the host's byte route. */
|
|
3676
3690
|
VrmPet3DWidget.prototype.fetchMotionBytes = function (motionId) {
|
|
3677
3691
|
var url = '/vrmpet3d/motion?m=' + encodeURIComponent(motionId);
|
|
3678
|
-
return fetch(url, { credentials: '
|
|
3692
|
+
return fetch(url, { credentials: 'same-origin' }).then(function (response) {
|
|
3679
3693
|
if (!response.ok) throw new Error(word(this.language, 'motionRoute', { status: response.status }));
|
|
3680
3694
|
return response.arrayBuffer();
|
|
3681
3695
|
});
|
|
@@ -4284,7 +4298,7 @@
|
|
|
4284
4298
|
return fetch('/vrmpet3d/say', {
|
|
4285
4299
|
method: 'POST',
|
|
4286
4300
|
headers: { 'content-type': 'application/json' },
|
|
4287
|
-
credentials: '
|
|
4301
|
+
credentials: 'same-origin',
|
|
4288
4302
|
/**
|
|
4289
4303
|
* The request carries five things, and each is a different answer to "what is this line about".
|
|
4290
4304
|
*
|
|
@@ -5151,8 +5165,9 @@
|
|
|
5151
5165
|
var entries = ctx.slots.entries('sidebar.workspaces');
|
|
5152
5166
|
for (var i = 0; i < entries.length; i += 1) {
|
|
5153
5167
|
var injected = typeof entries[i].inject === 'function' ? entries[i].inject() : entries[i].inject;
|
|
5154
|
-
if (injected && typeof injected.open === 'function') { injected.open(id); return; }
|
|
5168
|
+
if (injected && typeof injected.open === 'function') { injected.open(id); return true; }
|
|
5155
5169
|
}
|
|
5170
|
+
return false;
|
|
5156
5171
|
},
|
|
5157
5172
|
onScopeChange: function (scope) { if (changeScope.current) changeScope.current(scope); },
|
|
5158
5173
|
/**
|
|
@@ -5171,6 +5186,21 @@
|
|
|
5171
5186
|
}
|
|
5172
5187
|
});
|
|
5173
5188
|
widgetRef.current = widget;
|
|
5189
|
+
// A browser game opens this explicit query contract in a NEW DSH tab.
|
|
5190
|
+
// Retry until the workspace slot is ready; consume the query only on success.
|
|
5191
|
+
var gameTarget = new URL(globalThis.location.href).searchParams.get('vrmpet3d-session');
|
|
5192
|
+
if (gameTarget && gameTarget.length <= 64) {
|
|
5193
|
+
var gameAttempts = 0;
|
|
5194
|
+
var gameNavigation = setInterval(function () {
|
|
5195
|
+
if (disposed || ++gameAttempts > 60) { clearInterval(gameNavigation); return; }
|
|
5196
|
+
if (widget.openSession(gameTarget)) {
|
|
5197
|
+
clearInterval(gameNavigation);
|
|
5198
|
+
var cleanUrl = new URL(globalThis.location.href);
|
|
5199
|
+
cleanUrl.searchParams.delete('vrmpet3d-session');
|
|
5200
|
+
globalThis.history.replaceState(globalThis.history.state, '', cleanUrl.href);
|
|
5201
|
+
}
|
|
5202
|
+
}, 250);
|
|
5203
|
+
}
|
|
5174
5204
|
widgets.add(widget);
|
|
5175
5205
|
setStatus('ready');
|
|
5176
5206
|
} catch (error) {
|
package/lib/client.menu.js
CHANGED
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
* whose effect ends by itself (the clip finishes, or the user double-clicks the pet, or work starts).
|
|
69
69
|
*/
|
|
70
70
|
'dance',
|
|
71
|
+
'miniGame',
|
|
71
72
|
/**
|
|
72
73
|
* 重载模型 — refresh the model on screen without touching anything else (operator, 2026-10-14: 「给内置和外置宠物的右键菜单
|
|
73
74
|
* 新增“重载模型”的选项,用来刷新模型」).
|
|
@@ -110,6 +111,7 @@
|
|
|
110
111
|
resetSize: 'Reset size',
|
|
111
112
|
globalSettings: 'General settings',
|
|
112
113
|
switchModel: 'Switch model',
|
|
114
|
+
miniGame: 'Pet breakout',
|
|
113
115
|
reloadModel: 'Reload model',
|
|
114
116
|
dance: 'Dance for me',
|
|
115
117
|
modelSettings: "This model's parameters",
|
|
@@ -138,6 +140,7 @@
|
|
|
138
140
|
resetSize: '重置大小',
|
|
139
141
|
globalSettings: '通用设置',
|
|
140
142
|
switchModel: '切换模型',
|
|
143
|
+
miniGame: '小游戏 · 吃白饭',
|
|
141
144
|
reloadModel: '重载模型',
|
|
142
145
|
dance: '跳支舞',
|
|
143
146
|
modelSettings: '当前模型参数设置',
|
package/lib/client.scene.js
CHANGED
|
@@ -2060,11 +2060,12 @@
|
|
|
2060
2060
|
* repeated GET of the same URL without touching the disk — which would make 重载模型 look like it did nothing. The
|
|
2061
2061
|
* one-shot buster from `bustModelCache` rides the query string, and is consumed here so an ordinary load stays clean.
|
|
2062
2062
|
*/
|
|
2063
|
-
|
|
2063
|
+
var reload = Boolean(this.modelBust);
|
|
2064
|
+
if (reload) {
|
|
2064
2065
|
url += (url.indexOf('?') === -1 ? '?' : '&') + 'v=' + this.modelBust;
|
|
2065
2066
|
this.modelBust = null;
|
|
2066
2067
|
}
|
|
2067
|
-
var promise = fetch(url, { credentials: '
|
|
2068
|
+
var promise = fetch(url, { credentials: 'same-origin', cache: reload ? 'reload' : 'default' })
|
|
2068
2069
|
.then(function (response) {
|
|
2069
2070
|
if (!response.ok) throw new Error('asset route returned HTTP ' + response.status);
|
|
2070
2071
|
return response.arrayBuffer();
|