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/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-plugin-vrmpet3d ?host half.
1
+ // dsh-plugin-vrmpet3d — host half.
2
2
  //
3
3
  // Responsibilities, and deliberately nothing else:
4
4
  //
@@ -7,8 +7,8 @@
7
7
  // text, tool arguments and attachments are never read, stored or forwarded.
8
8
  // 2. Serve the user's own model and authored-motion files to the page over the
9
9
  // web carrier's `/vrmpet3d` routes. A plugin's `assets/` directory is NOT served
10
- // to the browser by DSH ?the only plugin-owned browser routes are
11
- // `/plugins/<id>/client*.js` ?so a model or a `.vrma` the user drops
10
+ // to the browser by DSH — the only plugin-owned browser routes are
11
+ // `/plugins/<id>/client*.js` — so a model or a `.vrma` the user drops
12
12
  // on disk can only reach the page through a host route. Every route is
13
13
  // allowlisted to the configured directories and never exposes an absolute
14
14
  // path.
@@ -25,8 +25,9 @@ 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
- // `lib/core.js` is a classic script that publishes itself on the global ?that is
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
31
32
  // of file extensions in the project. A host-side copy of the motion extensions is
32
33
  // exactly the kind of mirror that drifts: the browser would offer the user a
@@ -78,7 +79,7 @@ const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', '.svn', '__pycache_
78
79
  *
79
80
  * Taken from `lib/core.js` rather than listed again here. The motion catalog
80
81
  * beside it is built from `core.MOTION_EXTENSIONS`, and the two must stay
81
- * disjoint ?a file offered by both catalogs, or by neither, is a class of bug
82
+ * disjoint — a file offered by both catalogs, or by neither, is a class of bug
82
83
  * that no single route can see, so membership is decided in one place.
83
84
  */
84
85
  const MODEL_EXTENSIONS = new Set(core.MODEL_EXTENSIONS);
@@ -130,14 +131,14 @@ export const ROUTES = {
130
131
  /** The desktop window's page, its sources, and its on/off control. */
131
132
  window: '/vrmpet3d/window',
132
133
  /**
133
- * The page's own sources, as a prefix ?WITHOUT a trailing slash.
134
+ * The page's own sources, as a prefix — WITHOUT a trailing slash.
134
135
  *
135
136
  * DSH's matcher decides a prefix hit with `pathname === prefix || pathname.startsWith(prefix + '/')`,
136
137
  * so a registered prefix that already ends in `/` can only ever match the literal
137
138
  * `/vrmpet3d/window/lib//...`, which no browser sends. The first version of this route had the
138
139
  * trailing slash and every source 404'd: the window opened, the page loaded, the glue never
139
140
  * ran, and the pet was simply invisible. Every other prefix here works because it has no
140
- * trailing slash ?which is the tell that this one was wrong.
141
+ * trailing slash — which is the tell that this one was wrong.
141
142
  */
142
143
  windowLib: '/vrmpet3d/window/lib',
143
144
  desktop: '/vrmpet3d/desktop',
@@ -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',
@@ -1230,7 +1233,7 @@ export function motionLoops(manifest, name) {
1230
1233
  *
1231
1234
  * Compares by ID, never by object identity. `agents.list()` is a live registry
1232
1235
  * whose returned objects are not guaranteed to be the same references that
1233
- * `agents.roots()` holds ?a copy, a proxy or a re-read all produce
1236
+ * `agents.roots()` holds — a copy, a proxy or a re-read all produce
1234
1237
  * `roots().includes(agent) === false` and would silently classify EVERY session
1235
1238
  * as a subagent, which turns the whole pet off. That failure is invisible: the
1236
1239
  * pet simply never reacts.
@@ -1284,7 +1287,7 @@ export function isInside(root, absolute) {
1284
1287
  * directory that holds it, but a legitimate request also addresses the model's own
1285
1288
  * file, so paths are interpreted relative to that directory. With `..` permitted,
1286
1289
  * a path can climb above the declared directory and still resolve to something
1287
- * inside the root's parent ?a containment check against the wrong baseline serves
1290
+ * inside the root's parent — a containment check against the wrong baseline serves
1288
1291
  * it quite happily. Rejecting `..` outright removes the class instead of arguing
1289
1292
  * about which baselines happen to be safe.
1290
1293
  */
@@ -1313,7 +1316,7 @@ export function resolveAssetPath(root, relative) {
1313
1316
 
1314
1317
  let modelIdCounter = 0;
1315
1318
 
1316
- /** Absolute path ?id, so a rescan hands the same file back the same id. */
1319
+ /** Absolute path → id, so a rescan hands the same file back the same id. */
1317
1320
  const modelIdsByPath = new Map();
1318
1321
 
1319
1322
  /**
@@ -1327,7 +1330,7 @@ const modelIdsByPath = new Map();
1327
1330
  *
1328
1331
  * That breaks the page in a way that looks like a broken model rather than a broken
1329
1332
  * id: the client persists the selected `modelId`, reloads it on mount and after a
1330
- * page refresh, and asks `/vrmpet3d/asset?m=<saved id>` ?which then 404s and drops the
1333
+ * page refresh, and asks `/vrmpet3d/asset?m=<saved id>` — which then 404s and drops the
1331
1334
  * pet back to the placeholder. The id is therefore a property of the path, not of the
1332
1335
  * scan.
1333
1336
  *
@@ -1430,7 +1433,7 @@ function scanDirectory(root, options = {}) {
1430
1433
  }
1431
1434
 
1432
1435
  /**
1433
- * Build the catalog and the id ?file mapping in one pass.
1436
+ * Build the catalog and the id → file mapping in one pass.
1434
1437
  *
1435
1438
  * @param {object} config
1436
1439
  * @param {string[]} [config.modelDirs] extra directories supplied by the user
@@ -1498,7 +1501,7 @@ export function buildCatalog(config = {}, limits = {}) {
1498
1501
 
1499
1502
  let motionIdCounter = 0;
1500
1503
 
1501
- /** Absolute path ?id, so a rescan hands the same file back the same id. */
1504
+ /** Absolute path → id, so a rescan hands the same file back the same id. */
1502
1505
  const motionIdsByPath = new Map();
1503
1506
 
1504
1507
  /**
@@ -1508,7 +1511,7 @@ const motionIdsByPath = new Map();
1508
1511
  *
1509
1512
  * * The `'a'` prefix gives motions a namespace of their own. Model ids carry an
1510
1513
  * `'m'` and come from a separate counter that also starts at 1, so without
1511
- * the prefix the first motion and the first model would both be `1` ?the two
1514
+ * the prefix the first motion and the first model would both be `1` — the two
1512
1515
  * maps hold different files, and a page asking one route for the other's id
1513
1516
  * would be served the wrong kind of file by a lookup that reported success.
1514
1517
  * * The id does not encode the path, because an id travels to the browser and
@@ -1539,7 +1542,7 @@ export function bundledMotionDir() {
1539
1542
  }
1540
1543
 
1541
1544
  /**
1542
- * Build the motion catalog and the id ?file mapping in one pass.
1545
+ * Build the motion catalog and the id → file mapping in one pass.
1543
1546
  *
1544
1547
  * The plugin's own `assets/motions` is scanned FIRST (source `bundled`), exactly as `assets/models` is for
1545
1548
  * models, so the shipped pack is offered out of the box instead of waiting for a user to import a directory.
@@ -1548,7 +1551,7 @@ export function bundledMotionDir() {
1548
1551
  * apart by the same key the model catalog uses.
1549
1552
  *
1550
1553
  * Membership is decided by the extension, never by the bytes. That is the only
1551
- * test that can keep this catalog disjoint from the model catalog ?a GLB that
1554
+ * test that can keep this catalog disjoint from the model catalog — a GLB that
1552
1555
  * happens to carry `VRMC_vrm_animation` is still a `.glb`, and it must not turn
1553
1556
  * up in both lists because the page fetches the two lists by different routes.
1554
1557
  * `core.detectMotionFormat` remains the client's check that a file it was handed
@@ -1805,7 +1808,7 @@ function sendJson(req, res, payload) {
1805
1808
  * Read a small JSON request body, or answer 413/400 and return without calling back.
1806
1809
  *
1807
1810
  * One policy for every POST route: a bounded body, an honest status for each refusal. Two
1808
- * copies of this drifted before ?one capped at 64 KB and one not ?and the route without the
1811
+ * copies of this drifted before — one capped at 64 KB and one not — and the route without the
1809
1812
  * cap is the one that reads an arbitrary upload into memory.
1810
1813
  */
1811
1814
  function readJsonBody(req, res, limit, onBody) {
@@ -2015,7 +2018,7 @@ export function createModelInfoHandler(store) {
2015
2018
  }
2016
2019
 
2017
2020
  /**
2018
- * `/vrmpet3d/asset?m=<id>&p=<relative>` ?stream one model or texture file.
2021
+ * `/vrmpet3d/asset?m=<id>&p=<relative>` — stream one model or texture file.
2019
2022
  *
2020
2023
  * Streamed rather than buffered: VRM files are routinely tens of MB and
2021
2024
  * a read-into-memory route would spike the host's heap on every reload. Only
@@ -2163,11 +2166,11 @@ export function createModelThumbnailHandler(store) {
2163
2166
  }
2164
2167
 
2165
2168
  /**
2166
- * `/vrmpet3d/motion?m=<id>` ?stream one authored-motion file.
2169
+ * `/vrmpet3d/motion?m=<id>` — stream one authored-motion file.
2167
2170
  *
2168
2171
  * Deliberately has no `p` parameter. A model needs one because its textures sit
2169
2172
  * in the same directory; a `.vrma` is self-contained, so the only file
2170
- * this route can ever name is the exact one an id was scanned from ?nothing
2173
+ * this route can ever name is the exact one an id was scanned from — nothing
2171
2174
  * beside it becomes reachable, and there is no relative path to get wrong.
2172
2175
  *
2173
2176
  * A COMPRESSED clip (`<name>.vrma.br`, the dance pack) is unpacked HERE, so the bytes that reach the renderer
@@ -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
 
@@ -2420,7 +2425,7 @@ export function apply(ctx, config = {}) {
2420
2425
  };
2421
2426
 
2422
2427
  /**
2423
- * `/vrmpet3d/motions` ?the motion catalog, shaped like `/vrmpet3d/catalog`.
2428
+ * `/vrmpet3d/motions` — the motion catalog, shaped like `/vrmpet3d/catalog`.
2424
2429
  *
2425
2430
  * Its own route rather than a field of the model catalog: the two are rebuilt
2426
2431
  * from separate directories, and the settings panel opens one picker at a time.
@@ -2797,7 +2802,7 @@ export function apply(ctx, config = {}) {
2797
2802
  * The always-on-top window, as a separate process.
2798
2803
  *
2799
2804
  * A plugin cannot ask DSH for a window (the host half has no Electron, the client half's IPC
2800
- * surface has no such channel ?see HANDOFF sec.12), so the window is started here: the
2805
+ * surface has no such channel — see HANDOFF sec.12), so the window is started here: the
2801
2806
  * launcher in `desktop/` is run against a bare-Electron runtime borrowed from the
2802
2807
  * installation by hard links. `lib/desktop.js` owns all of that; this half owns the routes
2803
2808
  * that switch it on and off, and the requirement that it dies with the plugin.
@@ -2861,10 +2866,12 @@ export function apply(ctx, config = {}) {
2861
2866
  * repeatable, the counter is not, so a preview that is set twice in a row is still noticed once.
2862
2867
  */
2863
2868
  let previewValue = null;
2869
+ let navigation = { seq: 0, sessionId: null };
2870
+ const navigationCursor = { waiting: null, completed: null };
2864
2871
  let previewSeq = 0;
2865
2872
  const previewHandler = (req, res) => {
2866
2873
  if (req.method === 'GET' || req.method === 'HEAD') {
2867
- sendJson(req, res, { seq: previewSeq, preview: previewValue });
2874
+ sendJson(req, res, { seq: previewSeq, preview: previewValue, navigation });
2868
2875
  return;
2869
2876
  }
2870
2877
  if (req.method !== 'POST') {
@@ -2936,9 +2943,13 @@ export function apply(ctx, config = {}) {
2936
2943
  const value = Number(raw[key]);
2937
2944
  if (Number.isFinite(value)) out[key] = Math.floor(value);
2938
2945
  }
2939
- for (const key of ['scope', 'session', 'interaction']) {
2946
+ for (const key of ['scope', 'session', 'interaction', 'statusSessionId', 'completedSessionId']) {
2940
2947
  if (typeof raw[key] === 'string' && raw[key]) out[key] = raw[key].slice(0, 64);
2941
2948
  }
2949
+ for (const key of ['waitingSessionIds', 'completedSessionIds']) {
2950
+ if (Array.isArray(raw[key])) out[key] = [...new Set(raw[key].filter(id => typeof id === 'string' && id))]
2951
+ .slice(0, 256).map(id => id.slice(0, 64));
2952
+ }
2942
2953
  if (Array.isArray(raw.runningIds)) {
2943
2954
  out.runningIds = raw.runningIds
2944
2955
  .filter((id) => typeof id === 'string' && id)
@@ -3871,6 +3882,11 @@ export function apply(ctx, config = {}) {
3871
3882
  let html = null;
3872
3883
  try {
3873
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
+ }
3874
3890
  } catch (error) {
3875
3891
  sendStatus(res, 500, `cannot read the ${label}: ${error.message}`);
3876
3892
  return;
@@ -3904,6 +3920,7 @@ export function apply(ctx, config = {}) {
3904
3920
  * mounts is the only version of that which cannot drift.
3905
3921
  */
3906
3922
  const pickerPageHandler = settingsPageHandler('picker-page.html', 'model-picker page');
3923
+ const gamePageHandler = settingsPageHandler('mini-game.html', 'mini-game page');
3907
3924
 
3908
3925
  /**
3909
3926
  * The page's own sources, by NAME — the module-level `WINDOW_SOURCES`, so the verification suite reads the
@@ -4003,6 +4020,12 @@ export function apply(ctx, config = {}) {
4003
4020
  * way: numbers and short CSS lengths only, sanitised here the way every other report is.
4004
4021
  */
4005
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
+ }
4006
4029
  function cleanPageMetrics(raw) {
4007
4030
  if (!raw || typeof raw !== 'object') return null;
4008
4031
  const out = {};
@@ -4037,6 +4060,7 @@ export function apply(ctx, config = {}) {
4037
4060
  if (req.method === 'GET' || req.method === 'HEAD') {
4038
4061
  sendJson(req, res, {
4039
4062
  ...desktopWindow.describe(),
4063
+ gameFocused: gameHasFocus(),
4040
4064
  /**
4041
4065
  * The WINDOW's OWN bubble measurements, posted by its page (action metrics below).
4042
4066
  *
@@ -4056,6 +4080,50 @@ export function apply(ctx, config = {}) {
4056
4080
  }
4057
4081
  readJsonBody(req, res, 16 * 1024, (body) => {
4058
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
+ }
4112
+ if (action === 'open-status-session' || action === 'open-completed-session') {
4113
+ let sessionId = petStateStale ? null : action === 'open-completed-session'
4114
+ ? (['working', 'waiting'].includes(petStateValue) && petStateUnread > 0 ? petStateWhy?.completedSessionId : null)
4115
+ : (['done', 'waiting', 'error'].includes(petStateValue) ? petStateWhy?.statusSessionId : null);
4116
+ const kind = action === 'open-completed-session' || petStateValue === 'done' ? 'completed' : 'waiting';
4117
+ const ids = petStateWhy?.[kind === 'completed' ? 'completedSessionIds' : 'waitingSessionIds'];
4118
+ if (sessionId && petStateValue !== 'error' && Array.isArray(ids) && ids.length) {
4119
+ sessionId = ids[(ids.indexOf(navigationCursor[kind]) + 1) % ids.length];
4120
+ navigationCursor[kind] = sessionId;
4121
+ }
4122
+ if (!sessionId) { sendJson(req, res, { ok: false }); return; }
4123
+ navigation = { seq: navigation.seq + 1, sessionId };
4124
+ sendJson(req, res, { ok: true });
4125
+ return;
4126
+ }
4059
4127
  /**
4060
4128
  * The window reporting its own bubble numbers — see pageMetrics in the GET above. Answered by the HOST rather than
4061
4129
  * forwarded to the launcher, so an older launcher cannot reject it.
@@ -4209,7 +4277,7 @@ export function apply(ctx, config = {}) {
4209
4277
  *
4210
4278
  * `ctx.inject` is the declarative fix: Cordis calls the callback with a scope in
4211
4279
  * which `webServer` exists, whenever that becomes true, and disposes the scope if
4212
- * it goes away. The service is an OPTIONAL dependency on purpose ?a hard
4280
+ * it goes away. The service is an OPTIONAL dependency on purpose — a hard
4213
4281
  * `inject: ['webServer']` would leave the plugin waiting for activation forever
4214
4282
  * in a composition with no web server, which is a far worse failure than a pet
4215
4283
  * that cannot load model files.
@@ -4235,6 +4303,7 @@ export function apply(ctx, config = {}) {
4235
4303
  disposers.push(webServer.register({ kind: 'exact', path: ROUTES.config, handler: configPageHandler }));
4236
4304
  disposers.push(webServer.register({ kind: 'exact', path: ROUTES.global, handler: globalPageHandler }));
4237
4305
  disposers.push(webServer.register({ kind: 'exact', path: ROUTES.picker, handler: pickerPageHandler }));
4306
+ disposers.push(webServer.register({ kind: 'exact', path: ROUTES.game, handler: gamePageHandler }));
4238
4307
  disposers.push(webServer.register({ kind: 'exact', path: ROUTES.settings, handler: settingsHandler }));
4239
4308
  disposers.push(webServer.register({ kind: 'exact', path: ROUTES.preview, handler: previewHandler }));
4240
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,10 +103,11 @@
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",
108
- surfaceWindow: 'Move to the floating window',
110
+ surfaceWindow: 'Switch to an independent pet',
109
111
  surfaceApp: 'Move back inside the app',
110
112
  surfaceOff: 'disabled while debug mode is on',
111
113
  gravity: 'Gravity',
@@ -130,10 +132,11 @@
130
132
  resetSize: '重置大小',
131
133
  globalSettings: '通用设置',
132
134
  switchModel: '切换模型',
135
+ miniGame: '小游戏 · 吃白饭',
133
136
  reloadModel: '重载模型',
134
137
  dance: '跳支舞',
135
138
  modelSettings: '当前模型参数设置',
136
- surfaceWindow: '切换回窗口外',
139
+ surfaceWindow: '切换为独立宠物',
137
140
  surfaceApp: '切换回窗口内',
138
141
  surfaceOff: '调试模式下不生效',
139
142
  gravity: '重力',
@@ -228,6 +231,7 @@
228
231
  var onChoose = typeof options.onChoose === 'function' ? options.onChoose : function () {};
229
232
  var toSurface = options.toSurface === 'app' ? 'app' : 'window';
230
233
  var surfaceAllowed = options.surfaceAllowed !== false;
234
+ var danceState = 'resting';
231
235
  /** Whether this surface offers the 切换 row at all. See the `showSurface` option. */
232
236
  var showSurface = options.showSurface !== false;
233
237
 
@@ -254,6 +258,8 @@
254
258
 
255
259
  /** The label for one id, from the state this menu was built with. */
256
260
  function labelFor(id) {
261
+ if (id === 'dance') return danceState === 'dancing' ? (options.language === 'en' ? 'Another dance' : '换一支舞')
262
+ : (['resting', 'offline'].indexOf(danceState) === -1 ? (options.language === 'en' ? 'The pet is busy…' : '宠物目前很忙…') : text.dance);
257
263
  if (id === 'surface') return toSurface === 'app' ? text.surfaceApp : text.surfaceWindow;
258
264
  if (id === 'closePet') return toSurface === 'app' ? text.closePetWindow : text.closePetApp;
259
265
  return text[id] || id;
@@ -267,7 +273,8 @@
267
273
  ITEMS.forEach(function (id) {
268
274
  // The 切换 row is the one entry a surface may omit — the window's 关闭独立宠物 already does its job.
269
275
  if (id === 'surface' && !showSurface) return;
270
- var disabled = id === 'surface' && surfaceAllowed !== true;
276
+ var disabled = (id === 'surface' && surfaceAllowed !== true)
277
+ || (id === 'dance' && ['resting', 'offline', 'dancing'].indexOf(danceState) === -1);
271
278
  var toggle = TOGGLES.indexOf(id) !== -1;
272
279
  var checked = toggle && checkedState[id] === true;
273
280
  var item = doc.createElement('button');
@@ -292,7 +299,7 @@
292
299
  * waiting to happen ("the switch does nothing"). The reason is the operator's own qualifier —
293
300
  * 非debug模式生效 — spelled out for the mode that turns it off.
294
301
  */
295
- item.title = text.surfaceOff;
302
+ item.title = id === 'dance' ? label : text.surfaceOff;
296
303
  } else {
297
304
  if (toggle && !checked) item.title = text.gravityOff;
298
305
  item.addEventListener('click', function (event) {
@@ -448,6 +455,12 @@
448
455
  setSurface: function (next) { toSurface = next === 'app' ? 'app' : 'window'; },
449
456
  /** Whether that row may be used at all (false in debug mode). */
450
457
  setSurfaceAllowed: function (allowed) { surfaceAllowed = allowed !== false; },
458
+ setSurfaceVisible: function (visible) { showSurface = visible === true; },
459
+ setDanceState: function (state) {
460
+ if (danceState === state) return;
461
+ danceState = state;
462
+ if (!element.hidden) render();
463
+ },
451
464
  /**
452
465
  * Which way a SWITCH row is thrown, read at OPEN time by the surface that owns the preference.
453
466
  *