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
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// A focused game temporarily hides only a pet window that was visible beforehand.
|
|
2
|
+
const http = require('node:http');
|
|
3
|
+
const https = require('node:https');
|
|
4
|
+
// A transparent window can paint and report pixel hits after showInactive while
|
|
5
|
+
// Chromium still uses the pre-hide input surface. A real bounds transition (the
|
|
6
|
+
// same transition wheel zoom causes) rebuilds that surface without changing the pet.
|
|
7
|
+
function refreshInputRegion(win, schedule = setTimeout) {
|
|
8
|
+
if (win.isDestroyed() || !win.isVisible()) return;
|
|
9
|
+
const original = win.getBounds();
|
|
10
|
+
const temporary = { ...original, width: original.width + 1 };
|
|
11
|
+
win.setBounds(temporary, false);
|
|
12
|
+
schedule(() => {
|
|
13
|
+
if (win.isDestroyed()) return;
|
|
14
|
+
const current = win.getBounds();
|
|
15
|
+
// A drag or another layout update owns its new bounds; never move it back.
|
|
16
|
+
if (['x', 'y', 'width', 'height'].every(key => current[key] === temporary[key])) win.setBounds(original, false);
|
|
17
|
+
}, 32);
|
|
18
|
+
}
|
|
19
|
+
function createVisibilityController(win, hooks = {}) {
|
|
20
|
+
let hiddenByGame = false;
|
|
21
|
+
return function apply(active) {
|
|
22
|
+
if (win.isDestroyed()) return;
|
|
23
|
+
if (active && !hiddenByGame && win.isVisible()) { hiddenByGame = true; hooks.suspend?.(); win.hide(); }
|
|
24
|
+
else if (!active && hiddenByGame) { hiddenByGame = false; win.showInactive(); hooks.resume?.(); }
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function watchGameVisibility(win, pageURL, interval = 500, hooks = {}) {
|
|
28
|
+
const apply = createVisibilityController(win, hooks);
|
|
29
|
+
const url = new URL('/vrmpet3d/desktop', pageURL);
|
|
30
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
31
|
+
let request = null, stopped = false;
|
|
32
|
+
function poll() {
|
|
33
|
+
if (stopped || request || win.isDestroyed()) return;
|
|
34
|
+
request = transport.get(url, { timeout: 1500 }, response => {
|
|
35
|
+
let body = '', failed = response.statusCode !== 200;
|
|
36
|
+
response.on('data', chunk => { body += chunk; if (body.length > 16384) { failed = true; response.destroy(); } });
|
|
37
|
+
response.on('end', () => {
|
|
38
|
+
if (stopped) return;
|
|
39
|
+
try { apply(!failed && JSON.parse(body).gameFocused === true); } catch (_) { apply(false); }
|
|
40
|
+
});
|
|
41
|
+
response.on('error', () => { if (!stopped) apply(false); });
|
|
42
|
+
});
|
|
43
|
+
request.on('timeout', () => request.destroy());
|
|
44
|
+
request.on('error', () => { if (!stopped) apply(false); });
|
|
45
|
+
request.on('close', () => { request = null; });
|
|
46
|
+
}
|
|
47
|
+
const timer = setInterval(poll, interval); timer.unref(); poll();
|
|
48
|
+
return function stop() { stopped = true; clearInterval(timer); if (request) request.destroy(); apply(false); };
|
|
49
|
+
}
|
|
50
|
+
module.exports = { createVisibilityController, watchGameVisibility, refreshInputRegion };
|
package/desktop/main.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
// Everything here was measured before it was written (see HANDOFF §12): a transparent
|
|
9
9
|
// always-on-top window renders WebGL with a real alpha channel, can be resized to the whole
|
|
10
10
|
// screen, and `capturePage()` returns pixels that are mostly fully transparent.
|
|
11
|
-
const { app, BrowserWindow, dialog, ipcMain, screen } = require('electron');
|
|
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();
|
|
@@ -604,6 +621,7 @@ app.whenReady().then(async () => {
|
|
|
604
621
|
if (!probing) sendCursor(lastCursor);
|
|
605
622
|
};
|
|
606
623
|
|
|
624
|
+
ipcMain.on('vrmpet3d:focus-host', () => { shell.openExternal('dsh://open').catch(() => {}); });
|
|
607
625
|
ipcMain.handle('vrmpet3d:config', () => ({
|
|
608
626
|
modelId: options.modelId,
|
|
609
627
|
assetBase: '/vrmpet3d/asset',
|
|
@@ -650,6 +668,12 @@ app.whenReady().then(async () => {
|
|
|
650
668
|
return null;
|
|
651
669
|
}
|
|
652
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
|
+
});
|
|
653
677
|
ipcMain.on('vrmpet3d:rect', (_event, rect) => {
|
|
654
678
|
if (!rect || typeof rect !== 'object') return;
|
|
655
679
|
petRect = {
|
|
@@ -794,6 +818,31 @@ app.whenReady().then(async () => {
|
|
|
794
818
|
});
|
|
795
819
|
ipcMain.on('vrmpet3d:quit', () => app.quit());
|
|
796
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
|
+
}
|
|
797
846
|
|
|
798
847
|
// The cursor is read from the OS rather than from page events: a click-through window
|
|
799
848
|
// receives no pointer events at all outside the pet, so events cannot drive the gaze,
|
|
@@ -1623,6 +1672,14 @@ function announce(info) {
|
|
|
1623
1672
|
* from the page state, so a page that never published reports `null` rather than a stale number.
|
|
1624
1673
|
*/
|
|
1625
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
|
+
},
|
|
1626
1683
|
over: overPet,
|
|
1627
1684
|
interacting: interacting,
|
|
1628
1685
|
rect: petRect,
|
package/desktop/preload.js
CHANGED
|
@@ -7,12 +7,23 @@
|
|
|
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.
|
|
13
23
|
* Sent as an invoke so the page can await it before mounting anything.
|
|
14
24
|
*/
|
|
15
25
|
config: () => ipcRenderer.invoke('vrmpet3d:config'),
|
|
26
|
+
focusHost: () => ipcRenderer.send('vrmpet3d:focus-host'),
|
|
16
27
|
|
|
17
28
|
/** The global cursor position, pushed by the main process at frame rate. */
|
|
18
29
|
onCursor: (callback) => {
|