dsh-plugin-vrmpet3d 0.1.1 → 0.1.2

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
@@ -1762,6 +1778,7 @@
1762
1778
  */
1763
1779
  VrmPet3DWidget.prototype.openMenuAt = function (clientX, clientY) {
1764
1780
  if (!this.menu) return false;
1781
+ if (typeof this.menu.setSurfaceVisible === 'function') this.menu.setSurfaceVisible(this.canUseIndependentPet());
1765
1782
  var doc = this.document;
1766
1783
  var view = doc.defaultView;
1767
1784
  var viewportWidth = Number(view && view.innerWidth) || doc.documentElement.clientWidth || 0;
@@ -1774,6 +1791,7 @@
1774
1791
  * it is read when the menu is built.
1775
1792
  */
1776
1793
  this.menu.setSurfaceAllowed(this.preferences.petSurface !== 'both');
1794
+ if (this.menu.setDanceState) this.menu.setDanceState(this.machine.state);
1777
1795
  /**
1778
1796
  * 重力's check mark, read at the same moment and for the same reason: a host snapshot or a settings-window
1779
1797
  * edit can change the preference while the page keeps running, so a menu that cached it would show the
@@ -1834,6 +1852,7 @@
1834
1852
  container: this.root,
1835
1853
  language: this.language,
1836
1854
  toSurface: 'window',
1855
+ showSurface: this.canUseIndependentPet(),
1837
1856
  onChoose: function (id) { self.onMenuChoice(id); },
1838
1857
  });
1839
1858
  return this.menu;
@@ -2284,6 +2303,12 @@
2284
2303
  * the widget's own state. "The window is up but the pet is not drawing in it" stays answerable that way, which
2285
2304
  * is the whole reason the host reports the page's own verdict.
2286
2305
  */
2306
+ VrmPet3DWidget.prototype.canUseIndependentPet = function () {
2307
+ // The host can borrow Electron even when it is itself a plain Node process.
2308
+ // Its runtime result, rather than the UI bridge or process type, decides this.
2309
+ return Boolean(this.desktopStatus && this.desktopStatus.electronAvailable === true);
2310
+ };
2311
+
2287
2312
  VrmPet3DWidget.prototype.syncDesktop = function (status, note) {
2288
2313
  var strings = words(this.language);
2289
2314
  this.desktopStatus = status || null;
@@ -2370,6 +2395,7 @@
2370
2395
  * place that knows it. A caller that omits it gets the click pool, which is what an older cached chunk does.
2371
2396
  */
2372
2397
  VrmPet3DWidget.prototype.reactSpeech = function (anatomy, gesture) {
2398
+ if (this.machine && this.machine.state === 'dancing') return null;
2373
2399
  if (!this.speech) return null;
2374
2400
  var random = this.scene && typeof this.scene.random === 'function' ? this.scene.random : null;
2375
2401
  var line = this.speech.react(
@@ -3052,6 +3078,7 @@
3052
3078
  */
3053
3079
  VrmPet3DWidget.prototype.startDance = function (motionId) {
3054
3080
  if (!this.machine || typeof this.machine.setDancing !== 'function') return false;
3081
+ if (['resting', 'offline', 'dancing'].indexOf(this.machine.resolve(this.aggregate.snapshot())) === -1) return false;
3055
3082
  var wanted = typeof motionId === 'string' && motionId ? motionId : null;
3056
3083
  if (!wanted) {
3057
3084
  var ids = this.danceClipIds();
@@ -3080,6 +3107,9 @@
3080
3107
  self.scene.setPreview({ state: 'dancing', motionId: wanted });
3081
3108
  }
3082
3109
  });
3110
+ // Publish the accepted dance immediately, including when this surface is hidden.
3111
+ // The floating window must not wait for the next background animation frame.
3112
+ if (!this.disposed) this.pump();
3083
3113
  return true;
3084
3114
  };
3085
3115
 
@@ -3347,6 +3377,11 @@
3347
3377
  running: snap.running === true,
3348
3378
  working: Number(snap.workingCount) || 0,
3349
3379
  waiting: Number(snap.waitingCount) || 0,
3380
+ statusSessionId: this.machine.state === 'waiting' ? snap.waitingSessionId
3381
+ : (this.machine.notice && this.machine.notice.sessionId) || null,
3382
+ completedSessionId: snap.completedSessionId || null,
3383
+ waitingSessionIds: snap.waitingSessionIds || [],
3384
+ completedSessionIds: snap.completedSessionIds || [],
3350
3385
  unread: Number(snap.unreadCount) || 0,
3351
3386
  /**
3352
3387
  * The breakdown behind that count, as numbers — why the green dot is lit, with no session id or title in it.
@@ -3952,8 +3987,11 @@
3952
3987
  }
3953
3988
  }
3954
3989
 
3990
+ this.acknowledgeVisibleConversation();
3955
3991
  var snapshot = this.aggregate.snapshot();
3956
3992
  var state = this.machine.resolve(snapshot);
3993
+ this.completedUnreadCount = snapshot.completedUnreadCount;
3994
+ if (this.menu && this.menu.setDanceState) this.menu.setDanceState(state);
3957
3995
  // Dance previews belong to the dance lifetime, including work interruption.
3958
3996
  if (this.scene.preview && this.scene.preview.state === 'dancing' && state !== 'dancing') {
3959
3997
  this.scene.setPreview(null);
@@ -3985,11 +4023,13 @@
3985
4023
  */
3986
4024
  var clock = this.machine && typeof this.machine.statusClock === 'function' ? this.machine.statusClock() : null;
3987
4025
  var clockKey = clock ? clock.startedAt + ':' + (clock.endedAt || '') : '';
4026
+ var completedKey = (snapshot.completedSessionIds || []).join(',') + '|' + (snapshot.waitingSessionIds || []).join(',');
3988
4027
  if (state !== this.lastPublishedState || speech !== this.lastPublishedSpeech || touch !== null
3989
- || clockKey !== this.lastPublishedClockKey) {
4028
+ || clockKey !== this.lastPublishedClockKey || completedKey !== this.lastPublishedCompletedKey) {
3990
4029
  this.lastPublishedState = state;
3991
4030
  this.lastPublishedSpeech = speech;
3992
4031
  this.lastPublishedClockKey = clockKey;
4032
+ this.lastPublishedCompletedKey = completedKey;
3993
4033
  /**
3994
4034
  * `clock` rides along because the window has no turn frames of its own, and the UNREAD count because the window
3995
4035
  * has no session store either: its green dot is DSH's `completionUnread`, which only this half can see.
@@ -3998,7 +4038,7 @@
3998
4038
  * question with an answer (`/vrmpet3d/state`) instead of a guess. See `decisionWhy`.
3999
4039
  */
4000
4040
  if (this.publishState) {
4001
- this.publishState(state, speech, touch, clock, null, this.machine ? this.machine.unreadCount : 0, this.decisionWhy());
4041
+ this.publishState(state, speech, touch, clock, null, snapshot.completedUnreadCount || 0, this.decisionWhy());
4002
4042
  }
4003
4043
  }
4004
4044
 
@@ -4019,7 +4059,7 @@
4019
4059
  // The TURN clock, not the state's own age: 用时 is a claim about DSH's turn (`PetMachine.statusClock`), and the
4020
4060
  // token is dropped entirely while no turn is open — DSH's own token-free 「深度求索中」.
4021
4061
  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);
4062
+ var line = this.core.statusFor(this.preferences, state, this.machine.stateSince, Date.now(), clock, this.language, this.completedUnreadCount);
4023
4063
  /**
4024
4064
  * A state with no line clears the prompt rather than leaving the previous one up: a caption that
4025
4065
  * outlives its state is how a pet ends up saying 深度求索中 over a finished turn.
@@ -4047,6 +4087,8 @@
4047
4087
  * other, and the attribute is cheap. `data-tone` is what the stylesheet reads, so the colours live in CSS
4048
4088
  * (where the two surfaces can differ) and the DECISION lives in `core.statusFor` (where they cannot).
4049
4089
  */
4090
+ if (['done', 'waiting', 'error'].indexOf(state) !== -1) node.setAttribute('data-attention', '1');
4091
+ else node.removeAttribute('data-attention');
4050
4092
  if (line.tone) node.setAttribute('data-tone', line.tone);
4051
4093
  else node.removeAttribute('data-tone');
4052
4094
  if (line.bold) node.setAttribute('data-bold', '1');
@@ -4074,8 +4116,8 @@
4074
4116
  // The dot is about ANOTHER session's unread completion, so it is drawn from the machine's count and not from
4075
4117
  // the line: the line is what is happening here, the dot is what finished elsewhere.
4076
4118
  if (this.statusDot) {
4077
- this.statusDot.hidden = !(line.state === 'working'
4078
- && this.machine && Number(this.machine.unreadCount) > 0);
4119
+ this.statusDot.hidden = !(['working', 'waiting'].indexOf(line.state) !== -1
4120
+ && Number(this.completedUnreadCount) > 0);
4079
4121
  }
4080
4122
  }
4081
4123
  // Every frame, not only when the words change: the caption is anchored to the HEAD, so an orbit, a zoom
@@ -4206,6 +4248,27 @@
4206
4248
  return line;
4207
4249
  };
4208
4250
 
4251
+ VrmPet3DWidget.prototype.openStatusSession = function () {
4252
+ var state = this.machine.state;
4253
+ if (state === 'waiting' || state === 'done') return this.openQueuedSession(state === 'waiting' ? 'waiting' : 'completed');
4254
+ var snap = this.aggregate.snapshot();
4255
+ var id = state === 'waiting' ? snap.waitingSessionId
4256
+ : ((state === 'done' || state === 'error') && this.machine.notice ? this.machine.notice.sessionId : null);
4257
+ if (id) this.openSession(id);
4258
+ };
4259
+
4260
+ VrmPet3DWidget.prototype.openQueuedSession = function (kind) {
4261
+ var snap = this.aggregate.snapshot();
4262
+ var ids = kind === 'waiting' ? snap.waitingSessionIds : snap.completedSessionIds;
4263
+ if (!ids || !ids.length) return null;
4264
+ this.queueCursor = this.queueCursor || {};
4265
+ var index = ids.indexOf(this.queueCursor[kind]);
4266
+ var id = ids[(index + 1) % ids.length];
4267
+ this.queueCursor[kind] = id;
4268
+ this.openSession(id);
4269
+ return id;
4270
+ };
4271
+
4209
4272
  /**
4210
4273
  * Ask the host for a generated line, when the machine has asked for one and nobody has yet.
4211
4274
  *
@@ -4436,22 +4499,27 @@
4436
4499
  // …and a badge the scene wrote is retracted once the scene is happy again (see `retractSceneStatus`).
4437
4500
  this.retractSceneStatus();
4438
4501
 
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.
4502
+ // The rendered eyes, rather than the slot's centre, are the zero of the aim.
4443
4503
  if (this.preferences.lookAtPointer && this.pointer) {
4444
4504
  var pixels = this.placement.pixels();
4505
+ var eye = typeof this.scene.gazeOrigin === 'function' ? this.scene.gazeOrigin() : null;
4506
+ var gazeView = this.document && this.document.defaultView;
4507
+ var gazeScreenWidth = Number(gazeView && gazeView.screen && gazeView.screen.width) || this.placement.viewportWidth;
4445
4508
  var aim = this.core.pointerAim({
4446
4509
  pointerX: this.pointer.x,
4447
4510
  pointerY: this.pointer.y,
4448
- centreX: pixels.left + this.placement.width / 2,
4449
- centreY: pixels.top + this.placement.height / 2,
4511
+ centreX: pixels.left + (eye ? eye.x * this.placement.width / this.scene.width : this.placement.width / 2),
4512
+ centreY: pixels.top + (eye ? eye.y * this.placement.height / this.scene.height : this.placement.height / 2),
4450
4513
  // 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
4514
+ // same "auto" default: 0 px means half the screen width for both axes.
4515
+ spanX: this.core.resolveGazeSpan(this.preferences.gazeSpanX, gazeScreenWidth),
4516
+ spanY: this.core.resolveGazeSpan(this.preferences.gazeSpanY, gazeScreenWidth),
4517
+ curve: this.preferences.gazeCurve,
4518
+ curveY: this.preferences.gazeCurveY,
4519
+ curveRadial: this.preferences.gazeCurveRadial,
4520
+ curveSpatial: this.preferences.gazeCurveSpatial,
4521
+ distance: this.preferences.gazeDistance,
4522
+ mode: this.preferences.gazeCurveMode
4455
4523
  });
4456
4524
  this.scene.motion.setGaze(this.scene.gazeFromAim(aim));
4457
4525
  } else {
@@ -4463,15 +4531,30 @@
4463
4531
 
4464
4532
  // ---- session bridge ------------------------------------------------------
4465
4533
 
4534
+ VrmPet3DWidget.prototype.acknowledgeVisibleConversation = function () {
4535
+ var focused = typeof this.document.hasFocus === 'function' ? this.document.hasFocus() : this.machine.focused;
4536
+ if (!focused || this.document.visibilityState === 'hidden' || this.machine.interaction || !this.conversationVisible()) return;
4537
+ // Read the visible conversation on every host update and pump, regardless of how it was opened.
4538
+ var input = this.aggregate.input;
4539
+ this.aggregate.acknowledgeViewed(selectedSessionId(input.catalog) || input.currentSessionId);
4540
+ };
4541
+
4466
4542
  VrmPet3DWidget.prototype.setInput = function (input) {
4467
4543
  this.aggregate.setInput(input);
4544
+ this.acknowledgeVisibleConversation();
4468
4545
  var snapshot = this.aggregate.snapshot();
4469
4546
  this.machine.resolve(snapshot);
4470
4547
  };
4471
4548
 
4472
4549
  VrmPet3DWidget.prototype.boundary = function (event) {
4473
4550
  this.aggregate.boundary(event);
4474
- this.machine.boundary(this.aggregate.snapshot(), event);
4551
+ if (event && event.sessionId && !this.aggregate.mainSession(event.sessionId)) {
4552
+ this.machine.resolve(this.aggregate.snapshot());
4553
+ return;
4554
+ }
4555
+ this.machine.boundary(this.aggregate.snapshot(), Object.assign({}, event, {
4556
+ confirmed: event && event.reason === 'completed' && this.aggregate.running(event.sessionId) === false,
4557
+ }));
4475
4558
  };
4476
4559
 
4477
4560
  VrmPet3DWidget.prototype.resetAggregate = function (identities) {
@@ -4721,6 +4804,10 @@
4721
4804
 
4722
4805
  function selectedSessionId(catalog) {
4723
4806
  if (!catalog) return undefined;
4807
+ var visible = Object.values(catalog.byId || {}).find(function (row) {
4808
+ return row && row.retainedBy && Number(row.retainedBy.mainView) > 0;
4809
+ });
4810
+ if (visible && typeof visible.id === 'string') return visible.id;
4724
4811
  if (typeof catalog.currentSessionId === 'string') return catalog.currentSessionId;
4725
4812
  if (catalog.selectedSessionId) return catalog.selectedSessionId;
4726
4813
  if (catalog.current && typeof catalog.current.id === 'string') return catalog.current.id;
@@ -5056,6 +5143,17 @@
5056
5143
  * whenever the settings change (`adoptLanguage`), because `auto` means "whatever DSH says".
5057
5144
  */
5058
5145
  locale: ctx.locale,
5146
+ conversationVisible: function () {
5147
+ var layout = typeof ctx.get === 'function' ? ctx.get('layout') : ctx.layout;
5148
+ return !layout || !layout.panelInfo || layout.panelInfo.getSnapshot().activePanelId === null;
5149
+ },
5150
+ openSession: function (id) {
5151
+ var entries = ctx.slots.entries('sidebar.workspaces');
5152
+ for (var i = 0; i < entries.length; i += 1) {
5153
+ var injected = typeof entries[i].inject === 'function' ? entries[i].inject() : entries[i].inject;
5154
+ if (injected && typeof injected.open === 'function') { injected.open(id); return; }
5155
+ }
5156
+ },
5059
5157
  onScopeChange: function (scope) { if (changeScope.current) changeScope.current(scope); },
5060
5158
  /**
5061
5159
  * Report the state to the host, so the floating window can show the same prompt.
@@ -113,7 +113,7 @@
113
113
  reloadModel: 'Reload model',
114
114
  dance: 'Dance for me',
115
115
  modelSettings: "This model's parameters",
116
- surfaceWindow: 'Move to the floating window',
116
+ surfaceWindow: 'Switch to an independent pet',
117
117
  surfaceApp: 'Move back inside the app',
118
118
  surfaceOff: 'disabled while debug mode is on',
119
119
  gravity: 'Gravity',
@@ -141,7 +141,7 @@
141
141
  reloadModel: '重载模型',
142
142
  dance: '跳支舞',
143
143
  modelSettings: '当前模型参数设置',
144
- surfaceWindow: '切换回窗口外',
144
+ surfaceWindow: '切换为独立宠物',
145
145
  surfaceApp: '切换回窗口内',
146
146
  surfaceOff: '调试模式下不生效',
147
147
  gravity: '重力',
@@ -236,6 +236,7 @@
236
236
  var onChoose = typeof options.onChoose === 'function' ? options.onChoose : function () {};
237
237
  var toSurface = options.toSurface === 'app' ? 'app' : 'window';
238
238
  var surfaceAllowed = options.surfaceAllowed !== false;
239
+ var danceState = 'resting';
239
240
  /** Whether this surface offers the 切换 row at all. See the `showSurface` option. */
240
241
  var showSurface = options.showSurface !== false;
241
242
 
@@ -262,6 +263,8 @@
262
263
 
263
264
  /** The label for one id, from the state this menu was built with. */
264
265
  function labelFor(id) {
266
+ if (id === 'dance') return danceState === 'dancing' ? (options.language === 'en' ? 'Another dance' : '换一支舞')
267
+ : (['resting', 'offline'].indexOf(danceState) === -1 ? (options.language === 'en' ? 'The pet is busy…' : '宠物目前很忙…') : text.dance);
265
268
  if (id === 'surface') return toSurface === 'app' ? text.surfaceApp : text.surfaceWindow;
266
269
  if (id === 'closePet') return toSurface === 'app' ? text.closePetWindow : text.closePetApp;
267
270
  return text[id] || id;
@@ -275,7 +278,8 @@
275
278
  ITEMS.forEach(function (id) {
276
279
  // The 切换 row is the one entry a surface may omit — the window's 关闭独立宠物 already does its job.
277
280
  if (id === 'surface' && !showSurface) return;
278
- var disabled = id === 'surface' && surfaceAllowed !== true;
281
+ var disabled = (id === 'surface' && surfaceAllowed !== true)
282
+ || (id === 'dance' && ['resting', 'offline', 'dancing'].indexOf(danceState) === -1);
279
283
  var toggle = TOGGLES.indexOf(id) !== -1;
280
284
  var checked = toggle && checkedState[id] === true;
281
285
  var item = doc.createElement('button');
@@ -300,7 +304,7 @@
300
304
  * waiting to happen ("the switch does nothing"). The reason is the operator's own qualifier —
301
305
  * 非debug模式生效 — spelled out for the mode that turns it off.
302
306
  */
303
- item.title = text.surfaceOff;
307
+ item.title = id === 'dance' ? label : text.surfaceOff;
304
308
  } else {
305
309
  if (toggle && !checked) item.title = text.gravityOff;
306
310
  item.addEventListener('click', function (event) {
@@ -456,6 +460,12 @@
456
460
  setSurface: function (next) { toSurface = next === 'app' ? 'app' : 'window'; },
457
461
  /** Whether that row may be used at all (false in debug mode). */
458
462
  setSurfaceAllowed: function (allowed) { surfaceAllowed = allowed !== false; },
463
+ setSurfaceVisible: function (visible) { showSurface = visible === true; },
464
+ setDanceState: function (state) {
465
+ if (danceState === state) return;
466
+ danceState = state;
467
+ if (!element.hidden) render();
468
+ },
459
469
  /**
460
470
  * Which way a SWITCH row is thrown, read at OPEN time by the surface that owns the preference.
461
471
  *
@@ -75,10 +75,20 @@
75
75
  var GAZE_YAW_LIMIT = ((80 * Math.PI) / 180) / GAZE_WEIGHT;
76
76
 
77
77
  /**
78
- * Pitch is left where it was: a head does not tip 90° forward or back without looking broken, and the
79
- * request was for the turn. Raise this the same way if a bigger nod is ever wanted.
78
+ * Automatic pitch limit, before the gaze weight. Settings expose separate actual
79
+ * upward/downward angles in degrees; both default to 60 degrees.
80
80
  */
81
- var GAZE_PITCH_LIMIT = 0.3;
81
+ var GAZE_PITCH_LIMIT = (60 * Math.PI / 180) / GAZE_WEIGHT;
82
+
83
+ function gazePitchLimits(preferences) {
84
+ var prefs = preferences || {};
85
+ function limit(key) {
86
+ return Number.isFinite(prefs[key])
87
+ ? Math.max(0, Math.min(60, prefs[key])) * Math.PI / 180 / GAZE_WEIGHT
88
+ : GAZE_PITCH_LIMIT;
89
+ }
90
+ return { up: limit('gazePitchUp'), down: limit('gazePitchDown') };
91
+ }
82
92
 
83
93
  /**
84
94
  * How far the head may turn IN TOTAL while an authored clip is driving it, in radians.
@@ -794,7 +804,8 @@
794
804
  }
795
805
 
796
806
  var yaw = Math.max(-GAZE_YAW_LIMIT, Math.min(GAZE_YAW_LIMIT, aim.yaw)) * GAZE_WEIGHT * this.gazeBlend;
797
- var pitch = Math.max(-GAZE_PITCH_LIMIT, Math.min(GAZE_PITCH_LIMIT, aim.pitch)) * GAZE_WEIGHT * this.gazeBlend;
807
+ var pitchLimits = gazePitchLimits(this.scene && this.scene.preferences);
808
+ var pitch = Math.max(-pitchLimits.down, Math.min(pitchLimits.up, aim.pitch)) * GAZE_WEIGHT * this.gazeBlend;
798
809
  var axes = rest ? this.gazeAxes(head) : null;
799
810
 
800
811
  if (layeredOnClip) {
@@ -807,8 +818,7 @@
807
818
  var budget = Math.max(0, HEAD_LAYER_LIMIT - clipTurn);
808
819
  yaw = Math.max(-budget, Math.min(budget, yaw));
809
820
  // Pitch keeps its own (smaller) limit, but never more than what is left of the budget either.
810
- pitch = Math.max(-Math.min(GAZE_PITCH_LIMIT * GAZE_WEIGHT, budget),
811
- Math.min(Math.min(GAZE_PITCH_LIMIT * GAZE_WEIGHT, budget), pitch));
821
+ pitch = Math.max(-budget, Math.min(budget, pitch));
812
822
  if (axes) {
813
823
  this._quat.setFromAxisAngle(axes.yaw, yaw);
814
824
  this._quat2.setFromAxisAngle(axes.pitch, pitch);
@@ -1677,6 +1687,9 @@
1677
1687
  if (this.disposed) return;
1678
1688
  var w = Math.max(1, Math.round(width));
1679
1689
  var h = Math.max(1, Math.round(height));
1690
+ // Layout works in fractional CSS pixels. Reapplying the same rounded size clears
1691
+ // the framebuffer between rAFs when a settings reply arrives during an orbit.
1692
+ if (this.width === w && this.height === h) return;
1680
1693
  this.renderer.setSize(w, h, false);
1681
1694
  this.camera.aspect = w / h;
1682
1695
  this.camera.updateProjectionMatrix();
@@ -1763,6 +1776,8 @@
1763
1776
 
1764
1777
  /** Replace the current avatar with a hardware-ready one. */
1765
1778
  Scene.prototype.installAvatar = function (avatar, root) {
1779
+ (this.toonMaterials || []).forEach(function (entry) { if (entry.plain) entry.plain.dispose(); });
1780
+ this.toonMaterials = null;
1766
1781
  // Stop before the old avatar is replaced: stopping is what undoes the clip's
1767
1782
  // pose, and it has to happen while the old model is still installed. Every
1768
1783
  // clip is then dropped, because a clip is fitted to one model — the cached
@@ -1787,6 +1802,7 @@
1787
1802
  this.frameAvatar(root);
1788
1803
  this.stage.add(root);
1789
1804
  this.avatar = avatar;
1805
+ this.captureToonMaterials(root);
1790
1806
  this.kind = avatar.kind;
1791
1807
  /**
1792
1808
  * …and the collider switch, applied to the avatar that was just adopted.
@@ -1852,6 +1868,7 @@
1852
1868
  * model is loaded, so on a fresh pet that call finds nothing to snapshot and the first sweep would have
1853
1869
  * had no gravity to lean against (measured: `windState().joints === 0` on a real model with two chains).
1854
1870
  */
1871
+ this.applySprings(this.preferences);
1855
1872
  this.captureSpringBase();
1856
1873
  this.expressionBehaviour = { blink: 0, blinkExpression: 'blink', emotion: null, emotionWeight: 0 };
1857
1874
  this.expressionState = { nextBlinkAt: null, blinkUntil: 0, nextIdleEmotionAt: null, emotionUntil: 0 };
@@ -1860,6 +1877,73 @@
1860
1877
  this.motion.setGazeIdle(this.preferences.gazeIdleSeconds);
1861
1878
  this.updateShadowPlane();
1862
1879
  this.refitCamera();
1880
+ this.applyToonPreferences();
1881
+ };
1882
+
1883
+ /** Keep the loaded materials (and expression bindings) intact so the switch is reversible. */
1884
+ Scene.prototype.captureToonMaterials = function (root) {
1885
+ var self = this;
1886
+ this.toonMeshes = [];
1887
+ this.toonMaterials = [];
1888
+ var seen = new Map();
1889
+ root.traverse(function (node) {
1890
+ if (!node.isMesh || !node.material) return;
1891
+ var originals = Array.isArray(node.material) ? node.material : [node.material];
1892
+ var entries = originals.map(function (material) {
1893
+ if (!material.isMToonMaterial) return null;
1894
+ if (seen.has(material)) return seen.get(material);
1895
+ var entry = { material: material, toony: material.shadingToonyFactor, shift: material.shadingShiftFactor,
1896
+ shade: material.shadeColorFactor.clone(), width: material.outlineWidthFactor, plain: null };
1897
+ seen.set(material, entry);
1898
+ self.toonMaterials.push(entry);
1899
+ return entry;
1900
+ });
1901
+ if (entries.some(Boolean)) self.toonMeshes.push({ node: node, original: node.material, entries: entries, originals: originals });
1902
+ });
1903
+ };
1904
+
1905
+ Scene.prototype.applyToonPreferences = function () {
1906
+ var self = this;
1907
+ var id = this.currentModelId || this.preferences.modelId;
1908
+ var prefs = (this.preferences.toon || {})[id] || {};
1909
+ var disabled = prefs.enabled === false;
1910
+ (this.toonMaterials || []).forEach(function (entry) {
1911
+ var material = entry.material;
1912
+ material.shadingToonyFactor = prefs.shadingToonyFactor === undefined ? entry.toony : prefs.shadingToonyFactor;
1913
+ material.shadingShiftFactor = prefs.shadingShiftFactor === undefined ? entry.shift : prefs.shadingShiftFactor;
1914
+ material.shadeColorFactor.copy(entry.shade);
1915
+ if (prefs.shadeStrength !== undefined) material.shadeColorFactor.lerp(material.color, 1 - prefs.shadeStrength);
1916
+ material.outlineWidthFactor = entry.width * (prefs.outlineScale === undefined ? 1 : prefs.outlineScale);
1917
+ if (disabled && !entry.plain) {
1918
+ var plain = new self.THREE.MeshStandardMaterial();
1919
+ ['map', 'alphaMap', 'alphaTest', 'transparent', 'opacity', 'side', 'depthWrite', 'depthTest',
1920
+ 'normalMap', 'normalScale', 'emissive', 'emissiveMap', 'emissiveIntensity', 'color'].forEach(function (key) {
1921
+ if (material[key] === undefined) return;
1922
+ if (plain[key] && typeof plain[key].copy === 'function') plain[key].copy(material[key]);
1923
+ else plain[key] = material[key];
1924
+ });
1925
+ plain.visible = !material.isOutline;
1926
+ entry.plain = plain;
1927
+ }
1928
+ });
1929
+ (this.toonMeshes || []).forEach(function (mesh) {
1930
+ mesh.node.material = disabled
1931
+ ? (Array.isArray(mesh.original) ? mesh.originals.map(function (m, i) { return mesh.entries[i] ? mesh.entries[i].plain : m; })
1932
+ : mesh.entries[0].plain)
1933
+ : mesh.original;
1934
+ });
1935
+ this.toonDisabled = disabled;
1936
+ };
1937
+
1938
+ Scene.prototype.syncPlainMaterials = function () {
1939
+ if (!this.toonDisabled) return;
1940
+ (this.toonMaterials || []).forEach(function (entry) {
1941
+ if (!entry.plain) return;
1942
+ // Expressions still target the authored MToon material, including UV animation.
1943
+ entry.plain.color.copy(entry.material.color);
1944
+ entry.plain.emissive.copy(entry.material.emissive);
1945
+ entry.plain.opacity = entry.material.opacity;
1946
+ });
1863
1947
  };
1864
1948
 
1865
1949
  /** A soft ground blob, so the pet does not appear to float. */
@@ -3388,7 +3472,8 @@
3388
3472
  var x = Math.max(-1, Math.min(1, aim.x));
3389
3473
  var y = Math.max(-1, Math.min(1, aim.y));
3390
3474
  // Screen y grows downward, pitch grows upward.
3391
- return { yaw: x * GAZE_YAW_LIMIT, pitch: -y * GAZE_PITCH_LIMIT };
3475
+ var limits = gazePitchLimits(this.preferences);
3476
+ return { yaw: x * GAZE_YAW_LIMIT, pitch: -y * (y < 0 ? limits.up : limits.down) };
3392
3477
  };
3393
3478
 
3394
3479
  /** Advance one frame. `dt` is in seconds. */
@@ -3445,6 +3530,7 @@
3445
3530
  this.advanceExpressions(dt);
3446
3531
  this.applyExpressions(this.preferences);
3447
3532
 
3533
+ this.syncPlainMaterials();
3448
3534
  this.renderer.render(this.scene, this.camera);
3449
3535
  this.frame += 1;
3450
3536
  };
@@ -3456,6 +3542,7 @@
3456
3542
  Scene.prototype.applyPreferences = function (preferences) {
3457
3543
  var raw = preferences && typeof preferences === 'object' ? preferences : {};
3458
3544
  this.preferences = this.core.cleanPreferences(preferences);
3545
+ this.applyToonPreferences();
3459
3546
  // A preferences push only carries `motions` when the caller meant to change
3460
3547
  // it. cleanPreferences always produces a (possibly empty) key, so adopting it
3461
3548
  // unconditionally would silently wipe a map set through setMotionMap.
@@ -3649,6 +3736,40 @@
3649
3736
  };
3650
3737
  };
3651
3738
 
3739
+ /** The rendered eyes' midpoint, in canvas CSS pixels, independent of framing and camera orbit. */
3740
+ Scene.prototype.gazeOrigin = function () {
3741
+ var avatar = this.avatar;
3742
+ if (!avatar || !this.THREE || !this.camera) return null;
3743
+ var point = this._gazeOriginVec || (this._gazeOriginVec = new this.THREE.Vector3());
3744
+ var other = this._gazeOtherEyeVec || (this._gazeOtherEyeVec = new this.THREE.Vector3());
3745
+ var humanoid = avatar.vrm && avatar.vrm.humanoid;
3746
+ var left = humanoid && typeof humanoid.getRawBoneNode === 'function' ? humanoid.getRawBoneNode('leftEye') : null;
3747
+ var right = humanoid && typeof humanoid.getRawBoneNode === 'function' ? humanoid.getRawBoneNode('rightEye') : null;
3748
+ left = left || (avatar.bones && avatar.bones.leftEye);
3749
+ right = right || (avatar.bones && avatar.bones.rightEye);
3750
+ var source;
3751
+ if (left && right) {
3752
+ left.getWorldPosition(point); right.getWorldPosition(other);
3753
+ point.add(other).multiplyScalar(0.5);
3754
+ source = 'eyes';
3755
+ } else if (avatar.vrm && avatar.vrm.lookAt && typeof avatar.vrm.lookAt.getLookAtWorldPosition === 'function') {
3756
+ // The loader has already converted VRM 0/1 coordinate conventions for this offset.
3757
+ if (avatar.root) avatar.root.updateWorldMatrix(true, true);
3758
+ avatar.vrm.lookAt.getLookAtWorldPosition(point);
3759
+ source = 'lookAt';
3760
+ } else {
3761
+ var head = gazeNodeFor(avatar);
3762
+ if (!head) return null;
3763
+ head.getWorldPosition(point);
3764
+ source = 'head';
3765
+ }
3766
+ this.camera.updateMatrixWorld(true);
3767
+ var projected = this.projectToCanvas(point);
3768
+ if (!projected || projected.z < -1 || projected.z > 1) return null;
3769
+ projected.source = source;
3770
+ return projected;
3771
+ };
3772
+
3652
3773
  /**
3653
3774
  * Apply the draught to the chains, decay it, and keep it LOCAL.
3654
3775
  *
@@ -3873,6 +3994,10 @@
3873
3994
  */
3874
3995
  Scene.prototype.setSpringDefaults = function (defaults) {
3875
3996
  this.springDefaults = defaults && typeof defaults === 'object' ? defaults : null;
3997
+ // Catalog defaults can arrive after the avatar and preferences on either surface.
3998
+ // Apply them now; waiting for another preference edit makes a reload appear to fix physics.
3999
+ this.applySprings(this.preferences);
4000
+ this.captureSpringBase();
3876
4001
  };
3877
4002
 
3878
4003
  /**
@@ -4939,6 +5064,8 @@
4939
5064
 
4940
5065
  Scene.prototype.dispose = function () {
4941
5066
  if (this.disposed) return;
5067
+ (this.toonMeshes || []).forEach(function (mesh) { mesh.node.material = mesh.original; });
5068
+ (this.toonMaterials || []).forEach(function (entry) { if (entry.plain) entry.plain.dispose(); });
4942
5069
  this.disposed = true;
4943
5070
  // Stop and uncache every mixer before the renderer goes: a mixer holds a
4944
5071
  // binding table that references the model's bones, so leaving them behind
@@ -53,6 +53,17 @@
53
53
  <div class="row"><span class="k" data-i18n="availableInfo">可用信息</span><span class="grow note" id="model-meta">—</span></div>
54
54
  </div>
55
55
 
56
+ <div class="card" id="toon-card" style="margin-top:12px">
57
+ <h3 data-i18n="toonTitle">卡通增强</h3>
58
+ <div class="row"><span class="k" data-i18n="toonState">材质状态</span><span class="grow note" id="toon-state" role="status">—</span></div>
59
+ <div class="row">
60
+ <label class="k" for="toon-mode">MToon</label>
61
+ <select id="toon-mode"><option value="original" data-i18n="toonOriginal">模型原始</option><option value="on" data-i18n="on">开启</option><option value="off" data-i18n="toonDisabled">关闭</option></select>
62
+ <button id="toon-reset" type="button" data-i18n="toonReset">恢复模型原始参数</button>
63
+ </div>
64
+ <div id="toon-controls"></div>
65
+ <p class="note" data-i18n="toonNote">参数仅保存到当前模型,留空使用模型原始值。关闭 MToon 后使用普通光照材质。描边倍率只调整模型已有的描边。</p>
66
+ </div>
56
67
  </section>
57
68
 
58
69
  <section class="settings-section" id="settings-1">