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/client.js CHANGED
@@ -300,6 +300,7 @@
300
300
  */
301
301
  '.vrmpet3d-status[data-tone="gold"]{color:light-dark(#8a5a00,#ffc94d);--dsw-alias-label-shimmer:light-dark(#ffe0a3,#fff0c9)}',
302
302
  '.vrmpet3d-status[data-tone="gold"][data-bold="1"]{font-weight:700}',
303
+ '.vrmpet3d-status[data-attention="1"]{pointer-events:auto;cursor:pointer;text-shadow:0 0 4px currentColor,0 0 10px currentColor,0 0 18px currentColor}',
303
304
  '.vrmpet3d-status[data-tone="danger"]{color:light-dark(#b3261e,#ff6b6b);--dsw-alias-label-shimmer:light-dark(#ffc9c4,#ffd0cc)}',
304
305
  /**
305
306
  * 碎碎念: the pet's own short remark, in a bubble above the status prompt.
@@ -411,6 +412,7 @@
411
412
  '.vrmpet3d-status-content{display:flex;align-items:center;min-width:0}',
412
413
  /* `nowrap`, always: the operator's "提示文字永不换行". One line, however long the line is. */
413
414
  '.vrmpet3d-status-text{color:inherit;font-variant-numeric:tabular-nums;white-space:nowrap}',
415
+ '.vrmpet3d-status-dot{pointer-events:auto;cursor:pointer}',
414
416
  /* The words are rendered twice from ONE string: `attr()` feeds both copies, so the two cannot drift. */
415
417
  '.vrmpet3d-status-text[data-shimmer-text]::after{content:attr(data-shimmer-text)}',
416
418
  '.vrmpet3d-status-decoration{position:absolute;inset:0;overflow:clip;pointer-events:none;user-select:none}',
@@ -780,6 +782,8 @@
780
782
  function VrmPet3DWidget(host, options) {
781
783
  options = options || {};
782
784
  this.host = host;
785
+ this.openSession = options.openSession || function () {};
786
+ this.conversationVisible = options.conversationVisible || function () { return true; };
783
787
  this.core = globalThis.__VRMPET3D_CORE__;
784
788
  this.sceneApi = globalThis.__VRMPET3D_SCENE__;
785
789
  /**
@@ -812,6 +816,7 @@
812
816
  this.placement = new Placement(this.preferences, function () { return Date.now(); }, this.core);
813
817
 
814
818
  this.machine = new this.core.PetMachine(function () { return Date.now(); }, this.preferences.sleepAfterMs);
819
+ this.machine.requireViewedAcknowledgement = typeof options.conversationVisible === 'function';
815
820
  /**
816
821
  * The machine reads the settings it needs through the WIDGET, not from a copy.
817
822
  *
@@ -1135,6 +1140,7 @@
1135
1140
  */
1136
1141
  if (this.core.watchHostPreview) {
1137
1142
  this.previewSync = this.core.watchHostPreview({
1143
+ onNavigate: function (id) { self.openSession(id); },
1138
1144
  onPreview: function (preview) {
1139
1145
  /**
1140
1146
  * A PREVIEW ENDS A DANCE — and a preview of the DANCING state STARTS one.
@@ -1224,6 +1230,7 @@
1224
1230
  // ---- DOM -----------------------------------------------------------------
1225
1231
 
1226
1232
  VrmPet3DWidget.prototype.build = function () {
1233
+ var self = this;
1227
1234
  var doc = this.document;
1228
1235
  var root = doc.createElement('div');
1229
1236
  root.className = 'vrmpet3d-root';
@@ -1288,6 +1295,10 @@
1288
1295
  this.canvas = root.querySelector('.vrmpet3d-pet canvas');
1289
1296
  this.badge = root.querySelector('.vrmpet3d-badge');
1290
1297
  this.statusNode = root.querySelector('.vrmpet3d-status');
1298
+ this.statusNode.addEventListener('click', function (event) {
1299
+ event.stopPropagation();
1300
+ self.openStatusSession();
1301
+ });
1291
1302
  this.statusIcon = root.querySelector('.vrmpet3d-status-icon');
1292
1303
  /**
1293
1304
  * The green dot, when a completion is waiting elsewhere.
@@ -1298,6 +1309,11 @@
1298
1309
  * puts beside that session in its list, and it disappears when the user goes back there.
1299
1310
  */
1300
1311
  this.statusDot = root.querySelector('.vrmpet3d-status-dot');
1312
+ this.statusDot.addEventListener('click', function (event) {
1313
+ event.stopPropagation();
1314
+ self.openQueuedSession('completed');
1315
+ });
1316
+ this.statusDot.title = this.language === 'en' ? 'Open completed conversation' : '打开已完成的会话';
1301
1317
  this.statusDecoration = root.querySelector('.vrmpet3d-status-decoration');
1302
1318
  /**
1303
1319
  * The prompt's two text copies — the real one and the masked highlight — which always render the SAME
@@ -1651,6 +1667,19 @@
1651
1667
  */
1652
1668
  VrmPet3DWidget.prototype.openSettingsWindow = function (page) {
1653
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
+ }
1654
1683
  /**
1655
1684
  * Which window, and the host action that opens it.
1656
1685
  *
@@ -1762,6 +1791,7 @@
1762
1791
  */
1763
1792
  VrmPet3DWidget.prototype.openMenuAt = function (clientX, clientY) {
1764
1793
  if (!this.menu) return false;
1794
+ if (typeof this.menu.setSurfaceVisible === 'function') this.menu.setSurfaceVisible(this.canUseIndependentPet());
1765
1795
  var doc = this.document;
1766
1796
  var view = doc.defaultView;
1767
1797
  var viewportWidth = Number(view && view.innerWidth) || doc.documentElement.clientWidth || 0;
@@ -1774,6 +1804,7 @@
1774
1804
  * it is read when the menu is built.
1775
1805
  */
1776
1806
  this.menu.setSurfaceAllowed(this.preferences.petSurface !== 'both');
1807
+ if (this.menu.setDanceState) this.menu.setDanceState(this.machine.state);
1777
1808
  /**
1778
1809
  * 重力's check mark, read at the same moment and for the same reason: a host snapshot or a settings-window
1779
1810
  * edit can change the preference while the page keeps running, so a menu that cached it would show the
@@ -1834,6 +1865,7 @@
1834
1865
  container: this.root,
1835
1866
  language: this.language,
1836
1867
  toSurface: 'window',
1868
+ showSurface: this.canUseIndependentPet(),
1837
1869
  onChoose: function (id) { self.onMenuChoice(id); },
1838
1870
  });
1839
1871
  return this.menu;
@@ -1856,6 +1888,7 @@
1856
1888
  // 切换模型: the model picker's own page. It decides WHICH model every other page is about, so it is a
1857
1889
  // window of its own rather than a row inside 当前模型参数设置 (see ITEMS in lib/menu.js).
1858
1890
  if (id === 'switchModel') { this.openSettingsWindow('picker'); return; }
1891
+ if (id === 'miniGame') { this.openSettingsWindow('game'); return; }
1859
1892
  /**
1860
1893
  * 重载模型 — re-read the model file and rebuild the scene (operator, 2026-10-14: 「给内置和外置宠物的右键菜单新增“重载模型”
1861
1894
  * 的选项,用来刷新模型」).
@@ -2284,6 +2317,12 @@
2284
2317
  * the widget's own state. "The window is up but the pet is not drawing in it" stays answerable that way, which
2285
2318
  * is the whole reason the host reports the page's own verdict.
2286
2319
  */
2320
+ VrmPet3DWidget.prototype.canUseIndependentPet = function () {
2321
+ // The host can borrow Electron even when it is itself a plain Node process.
2322
+ // Its runtime result, rather than the UI bridge or process type, decides this.
2323
+ return Boolean(this.desktopStatus && this.desktopStatus.electronAvailable === true);
2324
+ };
2325
+
2287
2326
  VrmPet3DWidget.prototype.syncDesktop = function (status, note) {
2288
2327
  var strings = words(this.language);
2289
2328
  this.desktopStatus = status || null;
@@ -2370,6 +2409,7 @@
2370
2409
  * place that knows it. A caller that omits it gets the click pool, which is what an older cached chunk does.
2371
2410
  */
2372
2411
  VrmPet3DWidget.prototype.reactSpeech = function (anatomy, gesture) {
2412
+ if (this.machine && this.machine.state === 'dancing') return null;
2373
2413
  if (!this.speech) return null;
2374
2414
  var random = this.scene && typeof this.scene.random === 'function' ? this.scene.random : null;
2375
2415
  var line = this.speech.react(
@@ -3052,6 +3092,7 @@
3052
3092
  */
3053
3093
  VrmPet3DWidget.prototype.startDance = function (motionId) {
3054
3094
  if (!this.machine || typeof this.machine.setDancing !== 'function') return false;
3095
+ if (['resting', 'offline', 'dancing'].indexOf(this.machine.resolve(this.aggregate.snapshot())) === -1) return false;
3055
3096
  var wanted = typeof motionId === 'string' && motionId ? motionId : null;
3056
3097
  if (!wanted) {
3057
3098
  var ids = this.danceClipIds();
@@ -3080,6 +3121,9 @@
3080
3121
  self.scene.setPreview({ state: 'dancing', motionId: wanted });
3081
3122
  }
3082
3123
  });
3124
+ // Publish the accepted dance immediately, including when this surface is hidden.
3125
+ // The floating window must not wait for the next background animation frame.
3126
+ if (!this.disposed) this.pump();
3083
3127
  return true;
3084
3128
  };
3085
3129
 
@@ -3347,6 +3391,11 @@
3347
3391
  running: snap.running === true,
3348
3392
  working: Number(snap.workingCount) || 0,
3349
3393
  waiting: Number(snap.waitingCount) || 0,
3394
+ statusSessionId: this.machine.state === 'waiting' ? snap.waitingSessionId
3395
+ : (this.machine.notice && this.machine.notice.sessionId) || null,
3396
+ completedSessionId: snap.completedSessionId || null,
3397
+ waitingSessionIds: snap.waitingSessionIds || [],
3398
+ completedSessionIds: snap.completedSessionIds || [],
3350
3399
  unread: Number(snap.unreadCount) || 0,
3351
3400
  /**
3352
3401
  * The breakdown behind that count, as numbers — why the green dot is lit, with no session id or title in it.
@@ -3952,8 +4001,11 @@
3952
4001
  }
3953
4002
  }
3954
4003
 
4004
+ this.acknowledgeVisibleConversation();
3955
4005
  var snapshot = this.aggregate.snapshot();
3956
4006
  var state = this.machine.resolve(snapshot);
4007
+ this.completedUnreadCount = snapshot.completedUnreadCount;
4008
+ if (this.menu && this.menu.setDanceState) this.menu.setDanceState(state);
3957
4009
  // Dance previews belong to the dance lifetime, including work interruption.
3958
4010
  if (this.scene.preview && this.scene.preview.state === 'dancing' && state !== 'dancing') {
3959
4011
  this.scene.setPreview(null);
@@ -3985,11 +4037,13 @@
3985
4037
  */
3986
4038
  var clock = this.machine && typeof this.machine.statusClock === 'function' ? this.machine.statusClock() : null;
3987
4039
  var clockKey = clock ? clock.startedAt + ':' + (clock.endedAt || '') : '';
4040
+ var completedKey = (snapshot.completedSessionIds || []).join(',') + '|' + (snapshot.waitingSessionIds || []).join(',');
3988
4041
  if (state !== this.lastPublishedState || speech !== this.lastPublishedSpeech || touch !== null
3989
- || clockKey !== this.lastPublishedClockKey) {
4042
+ || clockKey !== this.lastPublishedClockKey || completedKey !== this.lastPublishedCompletedKey) {
3990
4043
  this.lastPublishedState = state;
3991
4044
  this.lastPublishedSpeech = speech;
3992
4045
  this.lastPublishedClockKey = clockKey;
4046
+ this.lastPublishedCompletedKey = completedKey;
3993
4047
  /**
3994
4048
  * `clock` rides along because the window has no turn frames of its own, and the UNREAD count because the window
3995
4049
  * has no session store either: its green dot is DSH's `completionUnread`, which only this half can see.
@@ -3998,7 +4052,7 @@
3998
4052
  * question with an answer (`/vrmpet3d/state`) instead of a guess. See `decisionWhy`.
3999
4053
  */
4000
4054
  if (this.publishState) {
4001
- this.publishState(state, speech, touch, clock, null, this.machine ? this.machine.unreadCount : 0, this.decisionWhy());
4055
+ this.publishState(state, speech, touch, clock, null, snapshot.completedUnreadCount || 0, this.decisionWhy());
4002
4056
  }
4003
4057
  }
4004
4058
 
@@ -4019,7 +4073,7 @@
4019
4073
  // The TURN clock, not the state's own age: 用时 is a claim about DSH's turn (`PetMachine.statusClock`), and the
4020
4074
  // token is dropped entirely while no turn is open — DSH's own token-free 「深度求索中」.
4021
4075
  var clock = this.machine && typeof this.machine.statusClock === 'function' ? this.machine.statusClock() : undefined;
4022
- var line = this.core.statusFor(this.preferences, state, this.machine.stateSince, Date.now(), clock, this.language);
4076
+ var line = this.core.statusFor(this.preferences, state, this.machine.stateSince, Date.now(), clock, this.language, this.completedUnreadCount);
4023
4077
  /**
4024
4078
  * A state with no line clears the prompt rather than leaving the previous one up: a caption that
4025
4079
  * outlives its state is how a pet ends up saying 深度求索中 over a finished turn.
@@ -4047,6 +4101,8 @@
4047
4101
  * other, and the attribute is cheap. `data-tone` is what the stylesheet reads, so the colours live in CSS
4048
4102
  * (where the two surfaces can differ) and the DECISION lives in `core.statusFor` (where they cannot).
4049
4103
  */
4104
+ if (['done', 'waiting', 'error'].indexOf(state) !== -1) node.setAttribute('data-attention', '1');
4105
+ else node.removeAttribute('data-attention');
4050
4106
  if (line.tone) node.setAttribute('data-tone', line.tone);
4051
4107
  else node.removeAttribute('data-tone');
4052
4108
  if (line.bold) node.setAttribute('data-bold', '1');
@@ -4074,8 +4130,8 @@
4074
4130
  // The dot is about ANOTHER session's unread completion, so it is drawn from the machine's count and not from
4075
4131
  // the line: the line is what is happening here, the dot is what finished elsewhere.
4076
4132
  if (this.statusDot) {
4077
- this.statusDot.hidden = !(line.state === 'working'
4078
- && this.machine && Number(this.machine.unreadCount) > 0);
4133
+ this.statusDot.hidden = !(['working', 'waiting'].indexOf(line.state) !== -1
4134
+ && Number(this.completedUnreadCount) > 0);
4079
4135
  }
4080
4136
  }
4081
4137
  // Every frame, not only when the words change: the caption is anchored to the HEAD, so an orbit, a zoom
@@ -4206,6 +4262,27 @@
4206
4262
  return line;
4207
4263
  };
4208
4264
 
4265
+ VrmPet3DWidget.prototype.openStatusSession = function () {
4266
+ var state = this.machine.state;
4267
+ if (state === 'waiting' || state === 'done') return this.openQueuedSession(state === 'waiting' ? 'waiting' : 'completed');
4268
+ var snap = this.aggregate.snapshot();
4269
+ var id = state === 'waiting' ? snap.waitingSessionId
4270
+ : ((state === 'done' || state === 'error') && this.machine.notice ? this.machine.notice.sessionId : null);
4271
+ if (id) this.openSession(id);
4272
+ };
4273
+
4274
+ VrmPet3DWidget.prototype.openQueuedSession = function (kind) {
4275
+ var snap = this.aggregate.snapshot();
4276
+ var ids = kind === 'waiting' ? snap.waitingSessionIds : snap.completedSessionIds;
4277
+ if (!ids || !ids.length) return null;
4278
+ this.queueCursor = this.queueCursor || {};
4279
+ var index = ids.indexOf(this.queueCursor[kind]);
4280
+ var id = ids[(index + 1) % ids.length];
4281
+ this.queueCursor[kind] = id;
4282
+ this.openSession(id);
4283
+ return id;
4284
+ };
4285
+
4209
4286
  /**
4210
4287
  * Ask the host for a generated line, when the machine has asked for one and nobody has yet.
4211
4288
  *
@@ -4436,22 +4513,27 @@
4436
4513
  // …and a badge the scene wrote is retracted once the scene is happy again (see `retractSceneStatus`).
4437
4514
  this.retractSceneStatus();
4438
4515
 
4439
- // The aim is measured against the pet's own centre each frame, from the placement
4440
- // the pet has THIS frame: the cursor can sit anywhere in the window (the listener is
4441
- // on the document), and the pet can be dragged or falling while it looks. Using the
4442
- // placement instead of `getBoundingClientRect` keeps this out of layout.
4516
+ // The rendered eyes, rather than the slot's centre, are the zero of the aim.
4443
4517
  if (this.preferences.lookAtPointer && this.pointer) {
4444
4518
  var pixels = this.placement.pixels();
4519
+ var eye = typeof this.scene.gazeOrigin === 'function' ? this.scene.gazeOrigin() : null;
4520
+ var gazeView = this.document && this.document.defaultView;
4521
+ var gazeScreenWidth = Number(gazeView && gazeView.screen && gazeView.screen.width) || this.placement.viewportWidth;
4445
4522
  var aim = this.core.pointerAim({
4446
4523
  pointerX: this.pointer.x,
4447
4524
  pointerY: this.pointer.y,
4448
- centreX: pixels.left + this.placement.width / 2,
4449
- centreY: pixels.top + this.placement.height / 2,
4525
+ centreX: pixels.left + (eye ? eye.x * this.placement.width / this.scene.width : this.placement.width / 2),
4526
+ centreY: pixels.top + (eye ? eye.y * this.placement.height / this.scene.height : this.placement.height / 2),
4450
4527
  // The same two settings as the floating window's (the 模型参数 window's 视线 section), with the
4451
- // same "auto" default: 0 px means this frame's width, which here is the app window.
4452
- spanX: this.core.resolveGazeSpan(this.preferences.gazeSpanX, this.placement.viewportWidth),
4453
- spanY: this.core.resolveGazeSpan(this.preferences.gazeSpanY, this.core.GAZE_SPAN.y),
4454
- curve: this.preferences.gazeCurve
4528
+ // same "auto" default: 0 px means half the screen width for both axes.
4529
+ spanX: this.core.resolveGazeSpan(this.preferences.gazeSpanX, gazeScreenWidth),
4530
+ spanY: this.core.resolveGazeSpan(this.preferences.gazeSpanY, gazeScreenWidth),
4531
+ curve: this.preferences.gazeCurve,
4532
+ curveY: this.preferences.gazeCurveY,
4533
+ curveRadial: this.preferences.gazeCurveRadial,
4534
+ curveSpatial: this.preferences.gazeCurveSpatial,
4535
+ distance: this.preferences.gazeDistance,
4536
+ mode: this.preferences.gazeCurveMode
4455
4537
  });
4456
4538
  this.scene.motion.setGaze(this.scene.gazeFromAim(aim));
4457
4539
  } else {
@@ -4463,15 +4545,30 @@
4463
4545
 
4464
4546
  // ---- session bridge ------------------------------------------------------
4465
4547
 
4548
+ VrmPet3DWidget.prototype.acknowledgeVisibleConversation = function () {
4549
+ var focused = typeof this.document.hasFocus === 'function' ? this.document.hasFocus() : this.machine.focused;
4550
+ if (!focused || this.document.visibilityState === 'hidden' || this.machine.interaction || !this.conversationVisible()) return;
4551
+ // Read the visible conversation on every host update and pump, regardless of how it was opened.
4552
+ var input = this.aggregate.input;
4553
+ this.aggregate.acknowledgeViewed(selectedSessionId(input.catalog) || input.currentSessionId);
4554
+ };
4555
+
4466
4556
  VrmPet3DWidget.prototype.setInput = function (input) {
4467
4557
  this.aggregate.setInput(input);
4558
+ this.acknowledgeVisibleConversation();
4468
4559
  var snapshot = this.aggregate.snapshot();
4469
4560
  this.machine.resolve(snapshot);
4470
4561
  };
4471
4562
 
4472
4563
  VrmPet3DWidget.prototype.boundary = function (event) {
4473
4564
  this.aggregate.boundary(event);
4474
- this.machine.boundary(this.aggregate.snapshot(), event);
4565
+ if (event && event.sessionId && !this.aggregate.mainSession(event.sessionId)) {
4566
+ this.machine.resolve(this.aggregate.snapshot());
4567
+ return;
4568
+ }
4569
+ this.machine.boundary(this.aggregate.snapshot(), Object.assign({}, event, {
4570
+ confirmed: event && event.reason === 'completed' && this.aggregate.running(event.sessionId) === false,
4571
+ }));
4475
4572
  };
4476
4573
 
4477
4574
  VrmPet3DWidget.prototype.resetAggregate = function (identities) {
@@ -4721,6 +4818,10 @@
4721
4818
 
4722
4819
  function selectedSessionId(catalog) {
4723
4820
  if (!catalog) return undefined;
4821
+ var visible = Object.values(catalog.byId || {}).find(function (row) {
4822
+ return row && row.retainedBy && Number(row.retainedBy.mainView) > 0;
4823
+ });
4824
+ if (visible && typeof visible.id === 'string') return visible.id;
4724
4825
  if (typeof catalog.currentSessionId === 'string') return catalog.currentSessionId;
4725
4826
  if (catalog.selectedSessionId) return catalog.selectedSessionId;
4726
4827
  if (catalog.current && typeof catalog.current.id === 'string') return catalog.current.id;
@@ -5056,6 +5157,18 @@
5056
5157
  * whenever the settings change (`adoptLanguage`), because `auto` means "whatever DSH says".
5057
5158
  */
5058
5159
  locale: ctx.locale,
5160
+ conversationVisible: function () {
5161
+ var layout = typeof ctx.get === 'function' ? ctx.get('layout') : ctx.layout;
5162
+ return !layout || !layout.panelInfo || layout.panelInfo.getSnapshot().activePanelId === null;
5163
+ },
5164
+ openSession: function (id) {
5165
+ var entries = ctx.slots.entries('sidebar.workspaces');
5166
+ for (var i = 0; i < entries.length; i += 1) {
5167
+ var injected = typeof entries[i].inject === 'function' ? entries[i].inject() : entries[i].inject;
5168
+ if (injected && typeof injected.open === 'function') { injected.open(id); return true; }
5169
+ }
5170
+ return false;
5171
+ },
5059
5172
  onScopeChange: function (scope) { if (changeScope.current) changeScope.current(scope); },
5060
5173
  /**
5061
5174
  * Report the state to the host, so the floating window can show the same prompt.
@@ -5073,6 +5186,21 @@
5073
5186
  }
5074
5187
  });
5075
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
+ }
5076
5204
  widgets.add(widget);
5077
5205
  setStatus('ready');
5078
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,10 +111,11 @@
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",
116
- surfaceWindow: 'Move to the floating window',
118
+ surfaceWindow: 'Switch to an independent pet',
117
119
  surfaceApp: 'Move back inside the app',
118
120
  surfaceOff: 'disabled while debug mode is on',
119
121
  gravity: 'Gravity',
@@ -138,10 +140,11 @@
138
140
  resetSize: '重置大小',
139
141
  globalSettings: '通用设置',
140
142
  switchModel: '切换模型',
143
+ miniGame: '小游戏 · 吃白饭',
141
144
  reloadModel: '重载模型',
142
145
  dance: '跳支舞',
143
146
  modelSettings: '当前模型参数设置',
144
- surfaceWindow: '切换回窗口外',
147
+ surfaceWindow: '切换为独立宠物',
145
148
  surfaceApp: '切换回窗口内',
146
149
  surfaceOff: '调试模式下不生效',
147
150
  gravity: '重力',
@@ -236,6 +239,7 @@
236
239
  var onChoose = typeof options.onChoose === 'function' ? options.onChoose : function () {};
237
240
  var toSurface = options.toSurface === 'app' ? 'app' : 'window';
238
241
  var surfaceAllowed = options.surfaceAllowed !== false;
242
+ var danceState = 'resting';
239
243
  /** Whether this surface offers the 切换 row at all. See the `showSurface` option. */
240
244
  var showSurface = options.showSurface !== false;
241
245
 
@@ -262,6 +266,8 @@
262
266
 
263
267
  /** The label for one id, from the state this menu was built with. */
264
268
  function labelFor(id) {
269
+ if (id === 'dance') return danceState === 'dancing' ? (options.language === 'en' ? 'Another dance' : '换一支舞')
270
+ : (['resting', 'offline'].indexOf(danceState) === -1 ? (options.language === 'en' ? 'The pet is busy…' : '宠物目前很忙…') : text.dance);
265
271
  if (id === 'surface') return toSurface === 'app' ? text.surfaceApp : text.surfaceWindow;
266
272
  if (id === 'closePet') return toSurface === 'app' ? text.closePetWindow : text.closePetApp;
267
273
  return text[id] || id;
@@ -275,7 +281,8 @@
275
281
  ITEMS.forEach(function (id) {
276
282
  // The 切换 row is the one entry a surface may omit — the window's 关闭独立宠物 already does its job.
277
283
  if (id === 'surface' && !showSurface) return;
278
- var disabled = id === 'surface' && surfaceAllowed !== true;
284
+ var disabled = (id === 'surface' && surfaceAllowed !== true)
285
+ || (id === 'dance' && ['resting', 'offline', 'dancing'].indexOf(danceState) === -1);
279
286
  var toggle = TOGGLES.indexOf(id) !== -1;
280
287
  var checked = toggle && checkedState[id] === true;
281
288
  var item = doc.createElement('button');
@@ -300,7 +307,7 @@
300
307
  * waiting to happen ("the switch does nothing"). The reason is the operator's own qualifier —
301
308
  * 非debug模式生效 — spelled out for the mode that turns it off.
302
309
  */
303
- item.title = text.surfaceOff;
310
+ item.title = id === 'dance' ? label : text.surfaceOff;
304
311
  } else {
305
312
  if (toggle && !checked) item.title = text.gravityOff;
306
313
  item.addEventListener('click', function (event) {
@@ -456,6 +463,12 @@
456
463
  setSurface: function (next) { toSurface = next === 'app' ? 'app' : 'window'; },
457
464
  /** Whether that row may be used at all (false in debug mode). */
458
465
  setSurfaceAllowed: function (allowed) { surfaceAllowed = allowed !== false; },
466
+ setSurfaceVisible: function (visible) { showSurface = visible === true; },
467
+ setDanceState: function (state) {
468
+ if (danceState === state) return;
469
+ danceState = state;
470
+ if (!element.hidden) render();
471
+ },
459
472
  /**
460
473
  * Which way a SWITCH row is thrown, read at OPEN time by the surface that owns the preference.
461
474
  *