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.
@@ -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,
@@ -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) {
@@ -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: '当前模型参数设置',
@@ -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: '当前模型参数设置',