dsh-plugin-vrmpet3d 0.1.2 → 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 +58 -2
- package/desktop/preload.js +10 -0
- package/lib/client.js +31 -1
- package/lib/client.menu.js +3 -0
- package/lib/desktop-page.js +9 -0
- package/lib/index.js +48 -0
- 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/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
|
@@ -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.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: 'omit',
|
|
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
|
*
|
|
@@ -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
|
* 的选项,用来刷新模型」).
|
|
@@ -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/desktop-page.js
CHANGED
|
@@ -692,6 +692,7 @@
|
|
|
692
692
|
if (id === 'globalSettings') { openSettingsWindow('global'); return; }
|
|
693
693
|
// 切换模型: the model picker's page, the same one the guide's 偏好模型 step is built from.
|
|
694
694
|
if (id === 'switchModel') { openSettingsWindow('picker'); return; }
|
|
695
|
+
if (id === 'miniGame') { openSettingsWindow('game'); return; }
|
|
695
696
|
/**
|
|
696
697
|
* 重载模型 — the same row the app pet's menu grew, with the same meaning (operator, 2026-10-14: 「给内置和外置宠物的右键
|
|
697
698
|
* 菜单新增“重载模型”的选项,用来刷新模型」): re-read the model FILE and rebuild this window's scene. Nothing about the
|
|
@@ -1868,6 +1869,14 @@
|
|
|
1868
1869
|
* query parameter this build no longer reads.
|
|
1869
1870
|
*/
|
|
1870
1871
|
function openSettingsWindow(page) {
|
|
1872
|
+
if (page === 'game') {
|
|
1873
|
+
fetch('/vrmpet3d/desktop', { method: 'POST', credentials: 'omit',
|
|
1874
|
+
headers: { 'content-type': 'application/json' },
|
|
1875
|
+
body: JSON.stringify({ action: 'game', modelId: resolvedModelId(preferences.modelId || loadedModel) })
|
|
1876
|
+
}).then(function (response) { if (!response.ok) throw new Error('小游戏窗口打开失败'); })
|
|
1877
|
+
.catch(function (error) { console.warn(error.message); });
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1871
1880
|
var which = page === 'global' ? 'settings' : (page === 'picker' ? 'picker' : 'config');
|
|
1872
1881
|
var url = which === 'settings'
|
|
1873
1882
|
? '/vrmpet3d/global'
|
package/lib/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
25
25
|
import { SettingsStore, settingsFileFor } from './settings.js';
|
|
26
26
|
// `VRMPET3D_*` with the pre-rename `PET3D_*` names as a fallback — see the module's own note.
|
|
27
27
|
import { envVar } from './env.js';
|
|
28
|
+
import { createLevelStore } from './mini-game-level-store.js';
|
|
28
29
|
|
|
29
30
|
// `lib/core.js` is a classic script that publishes itself on the global — that is
|
|
30
31
|
// how the client half reads it too, so importing it here is what keeps ONE list
|
|
@@ -208,6 +209,7 @@ export const ROUTES = {
|
|
|
208
209
|
* guide's 偏好模型 step — one page plus one shared component, instead of two hand-kept copies of a grid.
|
|
209
210
|
*/
|
|
210
211
|
picker: '/vrmpet3d/picker',
|
|
212
|
+
game: '/vrmpet3d/game',
|
|
211
213
|
/**
|
|
212
214
|
* The pet's live state, reported by the in-app pet and read by the floating window.
|
|
213
215
|
*
|
|
@@ -267,6 +269,7 @@ export const WINDOW_SOURCES = new Set([
|
|
|
267
269
|
// grid of thumbnails is exactly how the two would drift (and the operator asked for them to be identical).
|
|
268
270
|
'model-picker.js',
|
|
269
271
|
'picker-page.js',
|
|
272
|
+
'mini-game-core.js', 'mini-game-page.js', 'mini-game.css',
|
|
270
273
|
// The stylesheet the TWO settings pages share. One file rather than a copy in each page's `<head>`: the two
|
|
271
274
|
// render the same rows, tables and status lines, and a copy is how the two drift apart.
|
|
272
275
|
'pages.css',
|
|
@@ -2278,6 +2281,8 @@ function sendBuffer(req, res, bytes, name) {
|
|
|
2278
2281
|
* routes, the service and the stream listeners in one step.
|
|
2279
2282
|
*/
|
|
2280
2283
|
export function apply(ctx, config = {}) {
|
|
2284
|
+
const gameLevelStore = createLevelStore();
|
|
2285
|
+
void gameLevelStore.start();
|
|
2281
2286
|
const agents = ctx.get('agents');
|
|
2282
2287
|
const hub = new VrmPet3DBoundaryHub({ agents });
|
|
2283
2288
|
|
|
@@ -3877,6 +3882,11 @@ export function apply(ctx, config = {}) {
|
|
|
3877
3882
|
let html = null;
|
|
3878
3883
|
try {
|
|
3879
3884
|
html = readFileSync(join(PLUGIN_ROOT, 'lib', file));
|
|
3885
|
+
if (file === 'mini-game.html') {
|
|
3886
|
+
const data = JSON.stringify(gameLevelStore.snapshot()).replaceAll('<', '\\u003c');
|
|
3887
|
+
html = Buffer.from(html.toString('utf8').replace('<script id="game-level-data" type="application/json">null</script>',
|
|
3888
|
+
'<script id="game-level-data" type="application/json">' + data + '</script>'));
|
|
3889
|
+
}
|
|
3880
3890
|
} catch (error) {
|
|
3881
3891
|
sendStatus(res, 500, `cannot read the ${label}: ${error.message}`);
|
|
3882
3892
|
return;
|
|
@@ -3910,6 +3920,7 @@ export function apply(ctx, config = {}) {
|
|
|
3910
3920
|
* mounts is the only version of that which cannot drift.
|
|
3911
3921
|
*/
|
|
3912
3922
|
const pickerPageHandler = settingsPageHandler('picker-page.html', 'model-picker page');
|
|
3923
|
+
const gamePageHandler = settingsPageHandler('mini-game.html', 'mini-game page');
|
|
3913
3924
|
|
|
3914
3925
|
/**
|
|
3915
3926
|
* The page's own sources, by NAME — the module-level `WINDOW_SOURCES`, so the verification suite reads the
|
|
@@ -4009,6 +4020,12 @@ export function apply(ctx, config = {}) {
|
|
|
4009
4020
|
* way: numbers and short CSS lengths only, sanitised here the way every other report is.
|
|
4010
4021
|
*/
|
|
4011
4022
|
let desktopPageMetrics = null;
|
|
4023
|
+
const gameFocusLeases = new Map();
|
|
4024
|
+
function gameHasFocus() {
|
|
4025
|
+
const now = Date.now();
|
|
4026
|
+
for (const [client, deadline] of gameFocusLeases) if (deadline <= now) gameFocusLeases.delete(client);
|
|
4027
|
+
return gameFocusLeases.size > 0;
|
|
4028
|
+
}
|
|
4012
4029
|
function cleanPageMetrics(raw) {
|
|
4013
4030
|
if (!raw || typeof raw !== 'object') return null;
|
|
4014
4031
|
const out = {};
|
|
@@ -4043,6 +4060,7 @@ export function apply(ctx, config = {}) {
|
|
|
4043
4060
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
4044
4061
|
sendJson(req, res, {
|
|
4045
4062
|
...desktopWindow.describe(),
|
|
4063
|
+
gameFocused: gameHasFocus(),
|
|
4046
4064
|
/**
|
|
4047
4065
|
* The WINDOW's OWN bubble measurements, posted by its page (action metrics below).
|
|
4048
4066
|
*
|
|
@@ -4062,6 +4080,35 @@ export function apply(ctx, config = {}) {
|
|
|
4062
4080
|
}
|
|
4063
4081
|
readJsonBody(req, res, 16 * 1024, (body) => {
|
|
4064
4082
|
const action = typeof body?.action === 'string' ? body.action : '';
|
|
4083
|
+
if (action === 'game-focus') {
|
|
4084
|
+
const clientId = body?.clientId;
|
|
4085
|
+
if (typeof clientId !== 'string' || !/^[a-zA-Z0-9-]{8,96}$/.test(clientId) || typeof body.active !== 'boolean') {
|
|
4086
|
+
sendStatus(res, 400, 'invalid game focus report'); return;
|
|
4087
|
+
}
|
|
4088
|
+
gameHasFocus();
|
|
4089
|
+
if (body.active && (gameFocusLeases.has(clientId) || gameFocusLeases.size < 64)) gameFocusLeases.set(clientId, Date.now() + 3500);
|
|
4090
|
+
else if (!body.active) gameFocusLeases.delete(clientId);
|
|
4091
|
+
sendJson(req, res, { ok: true, gameFocused: gameHasFocus() }); return;
|
|
4092
|
+
}
|
|
4093
|
+
if (action === 'game-session') {
|
|
4094
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : '';
|
|
4095
|
+
const allowed = petStateStale ? [] : [petStateWhy?.statusSessionId,
|
|
4096
|
+
...(petStateWhy?.waitingSessionIds || []), ...(petStateWhy?.completedSessionIds || [])];
|
|
4097
|
+
if (!sessionId || !allowed.includes(sessionId)) { sendJson(req, res, { ok: false }); return; }
|
|
4098
|
+
navigation = { seq: navigation.seq + 1, sessionId };
|
|
4099
|
+
sendJson(req, res, { ok: true });
|
|
4100
|
+
return;
|
|
4101
|
+
}
|
|
4102
|
+
if (action === 'game') {
|
|
4103
|
+
const gameUrl = pageUrlFor(req, ROUTES.game);
|
|
4104
|
+
if (!gameUrl) { sendStatus(res, 400, 'no host header'); return; }
|
|
4105
|
+
const url = new URL(gameUrl);
|
|
4106
|
+
if (typeof body.modelId === 'string') url.searchParams.set('model', body.modelId.slice(0, 256));
|
|
4107
|
+
const opened = desktopWindow.openConfig({ url: url.href });
|
|
4108
|
+
if (!opened.ok) { sendStatus(res, 500, opened.error || 'game could not be opened'); return; }
|
|
4109
|
+
sendJson(req, res, { state: 'game-open', pid: opened.pid || null, url: url.href });
|
|
4110
|
+
return;
|
|
4111
|
+
}
|
|
4065
4112
|
if (action === 'open-status-session' || action === 'open-completed-session') {
|
|
4066
4113
|
let sessionId = petStateStale ? null : action === 'open-completed-session'
|
|
4067
4114
|
? (['working', 'waiting'].includes(petStateValue) && petStateUnread > 0 ? petStateWhy?.completedSessionId : null)
|
|
@@ -4256,6 +4303,7 @@ export function apply(ctx, config = {}) {
|
|
|
4256
4303
|
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.config, handler: configPageHandler }));
|
|
4257
4304
|
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.global, handler: globalPageHandler }));
|
|
4258
4305
|
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.picker, handler: pickerPageHandler }));
|
|
4306
|
+
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.game, handler: gamePageHandler }));
|
|
4259
4307
|
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.settings, handler: settingsHandler }));
|
|
4260
4308
|
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.preview, handler: previewHandler }));
|
|
4261
4309
|
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.setup, handler: setupPageHandler }));
|
package/lib/menu.js
CHANGED
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
* whose effect ends by itself (the clip finishes, or the user double-clicks the pet, or work starts).
|
|
61
61
|
*/
|
|
62
62
|
'dance',
|
|
63
|
+
'miniGame',
|
|
63
64
|
/**
|
|
64
65
|
* 重载模型 — refresh the model on screen without touching anything else (operator, 2026-10-14: 「给内置和外置宠物的右键菜单
|
|
65
66
|
* 新增“重载模型”的选项,用来刷新模型」).
|
|
@@ -102,6 +103,7 @@
|
|
|
102
103
|
resetSize: 'Reset size',
|
|
103
104
|
globalSettings: 'General settings',
|
|
104
105
|
switchModel: 'Switch model',
|
|
106
|
+
miniGame: 'Pet breakout',
|
|
105
107
|
reloadModel: 'Reload model',
|
|
106
108
|
dance: 'Dance for me',
|
|
107
109
|
modelSettings: "This model's parameters",
|
|
@@ -130,6 +132,7 @@
|
|
|
130
132
|
resetSize: '重置大小',
|
|
131
133
|
globalSettings: '通用设置',
|
|
132
134
|
switchModel: '切换模型',
|
|
135
|
+
miniGame: '小游戏 · 吃白饭',
|
|
133
136
|
reloadModel: '重载模型',
|
|
134
137
|
dance: '跳支舞',
|
|
135
138
|
modelSettings: '当前模型参数设置',
|