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.
@@ -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,
@@ -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) => {