dsh-pet-live2d 1.23.0

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.
Files changed (76) hide show
  1. package/LICENSE +56 -0
  2. package/README.md +425 -0
  3. package/cordis.patch.yml +8 -0
  4. package/docs/README.md +14 -0
  5. package/docs/downscale-fix.png +0 -0
  6. package/docs/preview.png +0 -0
  7. package/lib/client.js +3525 -0
  8. package/lib/index.js +1027 -0
  9. package/lib/live2d-vendor.js +997 -0
  10. package/package.json +61 -0
  11. package/pets/ds-whale-girl/LICENSE +37 -0
  12. package/pets/ds-whale-girl/README.md +67 -0
  13. package/pets/ds-whale-girl/c_0120.model3.json +249 -0
  14. package/pets/ds-whale-girl/catalog.json +310 -0
  15. package/pets/ds-whale-girl/expressions/angry.exp3.json +55 -0
  16. package/pets/ds-whale-girl/expressions/blank-eyes.exp3.json +10 -0
  17. package/pets/ds-whale-girl/expressions/cat-paws.exp3.json +20 -0
  18. package/pets/ds-whale-girl/expressions/claw-recolor.exp3.json +10 -0
  19. package/pets/ds-whale-girl/expressions/claw.exp3.json +10 -0
  20. package/pets/ds-whale-girl/expressions/cry.exp3.json +60 -0
  21. package/pets/ds-whale-girl/expressions/dark-tablecloth.exp3.json +11 -0
  22. package/pets/ds-whale-girl/expressions/dizzy.exp3.json +40 -0
  23. package/pets/ds-whale-girl/expressions/draw-brush.exp3.json +10 -0
  24. package/pets/ds-whale-girl/expressions/draw-eraser.exp3.json +10 -0
  25. package/pets/ds-whale-girl/expressions/draw-undo.exp3.json +10 -0
  26. package/pets/ds-whale-girl/expressions/drool.exp3.json +10 -0
  27. package/pets/ds-whale-girl/expressions/excited.exp3.json +65 -0
  28. package/pets/ds-whale-girl/expressions/exclaim.exp3.json +10 -0
  29. package/pets/ds-whale-girl/expressions/facial-red.exp3.json +10 -0
  30. package/pets/ds-whale-girl/expressions/glasses-oval.exp3.json +10 -0
  31. package/pets/ds-whale-girl/expressions/glasses-round.exp3.json +10 -0
  32. package/pets/ds-whale-girl/expressions/glasses-square.exp3.json +10 -0
  33. package/pets/ds-whale-girl/expressions/gloomy.exp3.json +10 -0
  34. package/pets/ds-whale-girl/expressions/hands-peace.exp3.json +10 -0
  35. package/pets/ds-whale-girl/expressions/headband.exp3.json +11 -0
  36. package/pets/ds-whale-girl/expressions/heart-pop.exp3.json +10 -0
  37. package/pets/ds-whale-girl/expressions/heartbeat.exp3.json +10 -0
  38. package/pets/ds-whale-girl/expressions/ketchup-squeeze.exp3.json +10 -0
  39. package/pets/ds-whale-girl/expressions/love-eyes.exp3.json +60 -0
  40. package/pets/ds-whale-girl/expressions/menu-press.exp3.json +10 -0
  41. package/pets/ds-whale-girl/expressions/naughty.exp3.json +20 -0
  42. package/pets/ds-whale-girl/expressions/omurice.exp3.json +16 -0
  43. package/pets/ds-whale-girl/expressions/parfait.exp3.json +10 -0
  44. package/pets/ds-whale-girl/expressions/phone-recolor.exp3.json +10 -0
  45. package/pets/ds-whale-girl/expressions/ponytail.exp3.json +11 -0
  46. package/pets/ds-whale-girl/expressions/question.exp3.json +10 -0
  47. package/pets/ds-whale-girl/expressions/sad.exp3.json +35 -0
  48. package/pets/ds-whale-girl/expressions/soul-out.exp3.json +10 -0
  49. package/pets/ds-whale-girl/expressions/sparkle.exp3.json +10 -0
  50. package/pets/ds-whale-girl/expressions/star-eyes.exp3.json +10 -0
  51. package/pets/ds-whale-girl/expressions/sticker-bow.exp3.json +10 -0
  52. package/pets/ds-whale-girl/expressions/sticker-cat.exp3.json +10 -0
  53. package/pets/ds-whale-girl/expressions/sticker-rabbit.exp3.json +10 -0
  54. package/pets/ds-whale-girl/expressions/sunglasses.exp3.json +20 -0
  55. package/pets/ds-whale-girl/expressions/sweat.exp3.json +10 -0
  56. package/pets/ds-whale-girl/expressions/tongue-out.exp3.json +26 -0
  57. package/pets/ds-whale-girl/expressions/whale-on-desk.exp3.json +11 -0
  58. package/pets/ds-whale-girl/expressions/whale.exp3.json +11 -0
  59. package/pets/ds-whale-girl/model/c_0120.cdi3.json +1802 -0
  60. package/pets/ds-whale-girl/model/c_0120.moc3 +0 -0
  61. package/pets/ds-whale-girl/model/c_0120.physics3.json +2396 -0
  62. package/pets/ds-whale-girl/motions/bubble-gum.motion3.json +369 -0
  63. package/pets/ds-whale-girl/motions/hammer.motion3.json +1120 -0
  64. package/pets/ds-whale-girl/motions/idle.motion3.json +4746 -0
  65. package/pets/ds-whale-girl/motions/ketchup.motion3.json +768 -0
  66. package/pets/ds-whale-girl/motions/open-case.motion3.json +116 -0
  67. package/pets/ds-whale-girl/motions/selfie-quick.motion3.json +47 -0
  68. package/pets/ds-whale-girl/motions/selfie.motion3.json +671 -0
  69. package/pets/ds-whale-girl/motions/spray-water.motion3.json +31 -0
  70. package/pets/ds-whale-girl/pet.json +599 -0
  71. package/pets/ds-whale-girl/previews/idle.png +0 -0
  72. package/pets/ds-whale-girl/textures/texture_00.png +0 -0
  73. package/pets/ds-whale-girl/textures/texture_01.png +0 -0
  74. package/pets/ds-whale-girl/voice.json +46 -0
  75. package/pets/ds-whale-girl//345/256/211/350/243/205/350/257/264/346/230/216.md +30 -0
  76. package/src/vendor-entry.ts +28 -0
package/lib/client.js ADDED
@@ -0,0 +1,3525 @@
1
+ // dsh-live2d-pet — browser half.
2
+ //
3
+ // A self-contained Live2D desk pet for the DSH Web GUI. Hand-written
4
+ // __ModuleLoader__ factory (no build step); the only external require is
5
+ // react / react-dom/client, which the loader module table seeds.
6
+ //
7
+ // The plugin mounts one page-global floating surface on document.body:
8
+ // * a WebGL Live2D model rendered by the lazily-loaded vendor bundle,
9
+ // * mouse tracking — the model's eyes and head follow the pointer,
10
+ // * drag to move, position and size persisted in localStorage,
11
+ // * click reaction (a motion + a speech bubble),
12
+ // * a control panel listing every motion group and expression the loaded
13
+ // model declares, discovered from the host catalog endpoint.
14
+ //
15
+ // The proprietary Cubism Core runtime is never bundled: the page loads the
16
+ // user-supplied file from the host's runtime route first, and reports a
17
+ // localized install hint when it is absent.
18
+ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
19
+
20
+ var module = { exports: {} };
21
+ var exports = module.exports;
22
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
23
+
24
+ const react = require("react");
25
+ const h = react.createElement;
26
+ const { useCallback, useEffect, useRef, useState } = react;
27
+
28
+ const name = "live2d-pet";
29
+ const inject = [];
30
+
31
+ const API = "/api/live2d-pet";
32
+ const STORAGE_KEY = "dsh-live2d-pet.state.v1";
33
+ const ROOT_ATTR = "data-dsh-live2d-pet-root";
34
+ const PET_ATTR = "data-dsh-live2d-pet";
35
+ const LEGACY_ATTR = "data-dsh-live2d-pet-container";
36
+ const MIN_SIZE = 160;
37
+ const MAX_SIZE = 760;
38
+ const DEFAULT_SIZE = 300;
39
+
40
+ // -------------------------------------------------- motion controller
41
+ //
42
+ // Why this is a state machine rather than "just call model.motion()":
43
+ //
44
+ // * The engine's MotionManager refuses to (re)start a group+index that is
45
+ // still active, so replaying the same reaction needs an explicit
46
+ // stopAllMotions() first — otherwise a second click does nothing.
47
+ // * Its priority gate means a NORMAL request cannot interrupt a motion
48
+ // that is already playing, so reactions must use FORCE or the pet
49
+ // silently stops responding after the first one.
50
+ // * motionFinish fires only for a motion that ends by itself. A model whose
51
+ // motions are all flagged Loop in their own motion3.json (the DS whale
52
+ // girl is exactly that) never finishes, so "play once, then go back to
53
+ // idle" has to be driven by the motion's declared Duration instead.
54
+ //
55
+ // The controller therefore owns the whole motion lifecycle: one action at a
56
+ // time, always returning to the idle loop, every transition interruptible.
57
+
58
+ /** Idle group names tried in order before falling back to the first group. */
59
+ const IDLE_CANDIDATES = ["Idle", "idle", "待机"];
60
+
61
+ /** How long a one-shot reaction is held when it declares no duration. */
62
+ const REACTION_FALLBACK_MS = 1600;
63
+
64
+ /** Reserved for future head/eye yielding while a reaction owns the body. */
65
+ const REACTION_TAIL_MS = 60;
66
+
67
+ /**
68
+ * How long a prerequisite motion runs before the action it precedes.
69
+ *
70
+ * 自拍 motions start with `phone: 1` already baked into their first keyframe:
71
+ * the author assumes the phone is ALREADY in hand. Playing 快速自拍 on its own
72
+ * therefore waves an invisible phone around. Running 掏出手机 first — the
73
+ * motion that actually raises it — is what makes the selfie read correctly.
74
+ */
75
+ const PREPEND_HOLD_MS = 1100;
76
+
77
+ /**
78
+ * A motionFinish arriving sooner than this after a start cannot be genuine.
79
+ *
80
+ * model.motion() is asynchronous: it has to load and parse the motion before
81
+ * it is queued. In that window stopAllMotions() has already cleared the
82
+ * previous motion while MotionManager still reports playing===true and
83
+ * isFinished()===true, so it emits motionFinish for a motion that never
84
+ * actually ran. Trusting that event ends the new reaction instantly, which
85
+ * is precisely the "click and it snaps back / loops forever" failure.
86
+ */
87
+ const MOTION_FINISH_GUARD_MS = 250;
88
+
89
+ function createMotionController() {
90
+ let vendor = null;
91
+ let model = null;
92
+ let idleName = null;
93
+ let groups = {};
94
+ let motionOptions = null;
95
+ let applyExpression = null;
96
+
97
+ let kind = "idle";
98
+ let token = 0;
99
+ let timer = 0;
100
+ let currentGroup = null;
101
+ let currentEntry = null;
102
+ let startedAt = 0;
103
+ let onChange = null;
104
+ /**
105
+ * Parameters this controller has deliberately written and must undo.
106
+ * See restoreHeld() — a motion's own curves are not reset by the engine,
107
+ * so anything we pinned on purpose has to be un-pinned on purpose.
108
+ */
109
+ let heldParams = null;
110
+ /** Parameters a retired action wants put back, re-applied every frame. */
111
+ let releasedOverrides = null;
112
+ /**
113
+ * The session phase currently being sustained, if any (requirement #4).
114
+ */
115
+ /**
116
+ * The session phase currently being sustained, if any (requirement #4).
117
+ * While set, finishing the phase's motion re-triggers it instead of
118
+ * dropping to the idle loop, so the pet keeps visibly working.
119
+ */
120
+ let sustainPhase = null;
121
+ let sustainTimer = 0;
122
+ /**
123
+ * True once a held action has finished animating and is just sitting in
124
+ * its final pose.
125
+ *
126
+ * A held pose is deliberately NOT "busy": if it were, the idle-fidget
127
+ * scheduler would never fire again and a session phase could never take
128
+ * the body back, so one click on 掏出手机 would freeze the pet for the rest
129
+ * of the session. It is instead a resting state that merely looks
130
+ * different from the idle loop.
131
+ */
132
+ let settled = false;
133
+ /** Downsampled opacity grid of the rendered character (null = unknown). */
134
+ let hitMask = null;
135
+ /** The stage-local box the grid spans (the model's bounding box). */
136
+ let hitBox = null;
137
+
138
+ const notify = () => {
139
+ if (onChange !== null) {
140
+ try {
141
+ onChange(currentGroup, kind);
142
+ } catch {
143
+ /* a listener must never break playback */
144
+ }
145
+ }
146
+ };
147
+
148
+ const motionManager = () => model?.internalModel?.motionManager ?? null;
149
+
150
+ const clearTimer = () => {
151
+ if (timer !== 0) {
152
+ window.clearTimeout(timer);
153
+ timer = 0;
154
+ }
155
+ };
156
+
157
+ /** Stop whatever plays now; required before replaying the same motion. */
158
+ const stopAll = () => {
159
+ try {
160
+ motionManager()?.stopAllMotions?.();
161
+ } catch {
162
+ /* not booted yet */
163
+ }
164
+ };
165
+
166
+ /** Resolve one concrete motion entry, clamped to the group's real length. */
167
+ const entryFor = (group, index) => {
168
+ const list = groups[group];
169
+ if (!Array.isArray(list) || list.length === 0) return null;
170
+ const at = Math.max(0, Math.min(index, list.length - 1));
171
+ return list[at];
172
+ };
173
+
174
+ /**
175
+ * Per-motion playback policy declared by the pet (pet.json
176
+ * live2d.motionOptions, keyed by motion group):
177
+ *
178
+ * { "OpenCase": { "hold": true },
179
+ * "Selfie": { "prepend": "OpenCase" },
180
+ * "SprayWater": { "preset": { "jingyu": 1 } } }
181
+ *
182
+ * The model cannot express any of this itself: every motion3.json in this
183
+ * pack declares "Loop": true and only animates its own handful of
184
+ * parameters, so "hold the phone", "raise the phone first" and "the whale
185
+ * is what sprays" are all facts about the AUTHOR's intent that have to be
186
+ * declared alongside the pet.
187
+ */
188
+ const optionsFor = (group) => {
189
+ const declared = motionOptions !== null && typeof motionOptions === "object"
190
+ ? motionOptions[group]
191
+ : undefined;
192
+ return declared !== null && typeof declared === "object" ? declared : null;
193
+ };
194
+
195
+ /**
196
+ * Resolve a session phase to a motion group.
197
+ *
198
+ * The per-pet override lives on the component (it comes from pet.json), so
199
+ * the controller reads it through a hook the component installs. Keeping it
200
+ * here rather than in the component is what lets the sustain loop re-trigger
201
+ * a phase's motion without the component driving every beat.
202
+ */
203
+ let phaseMotionFor = () => undefined;
204
+
205
+ /**
206
+ * Whether the random idle fidget may pick this motion.
207
+ *
208
+ * Interaction verbs (锤人、喷水) are excluded so the pet never appears to
209
+ * react to something that did not happen; the pet can opt any group back in
210
+ * or out with motionOptions: { "<group>": { "fidget": false | true } }.
211
+ */
212
+ const fidgetAllowed = (group) => {
213
+ const declared = optionsFor(group);
214
+ if (declared !== null && typeof declared.fidget === "boolean") return declared.fidget;
215
+ return FIDGET_DENY.indexOf(group) === -1;
216
+ };
217
+
218
+ /** Layer the currently pinned expression back over a freshly started motion. */
219
+ const reapplyExpression = () => {
220
+ if (applyExpression !== null) applyExpression();
221
+ };
222
+
223
+ /** The Cubism core model, or null before boot. */
224
+ const coreModel = () => model?.internalModel?.coreModel ?? null;
225
+
226
+ /**
227
+ * The head's bounding box in MODEL space, or null when the model has no
228
+ * recognisable facial drawables (in which case every tap counts as a head
229
+ * tap, preserving the old behaviour for unknown models).
230
+ */
231
+ let headBox = null;
232
+
233
+ /**
234
+ * Measure the head from the model's own drawable geometry.
235
+ *
236
+ * Runs once per attach. The values are model-space, so they stay valid
237
+ * across resizes and drags; `hitsHead` maps through the live transform.
238
+ */
239
+ const measureHead = (nextModel) => {
240
+ try {
241
+ const im = nextModel?.internalModel;
242
+ const ids = im?.getDrawableIDs?.();
243
+ if (ids === undefined || ids === null || typeof im.getDrawableIndex !== "function") return null;
244
+ if (typeof im.getDrawableBounds !== "function") return null;
245
+ let minX = Infinity;
246
+ let minY = Infinity;
247
+ let maxX = -Infinity;
248
+ let maxY = -Infinity;
249
+ let found = 0;
250
+ for (const raw of ids) {
251
+ const id = String(raw);
252
+ if (!HEAD_DRAWABLE_HINTS.test(id)) continue;
253
+ const index = im.getDrawableIndex(id);
254
+ if (index < 0) continue;
255
+ const b = im.getDrawableBounds(index, {});
256
+ if (b === undefined || !Number.isFinite(b.x) || !Number.isFinite(b.y)) continue;
257
+ if (!(b.width > 0) || !(b.height > 0)) continue;
258
+ minX = Math.min(minX, b.x);
259
+ minY = Math.min(minY, b.y);
260
+ maxX = Math.max(maxX, b.x + b.width);
261
+ maxY = Math.max(maxY, b.y + b.height);
262
+ found += 1;
263
+ }
264
+ if (found === 0 || maxX <= minX || maxY <= minY) return null;
265
+ // The facial drawables cover the face only; a head pat should also land
266
+ // on the hair, ears and headband around and above it.
267
+ const w = maxX - minX;
268
+ const h = maxY - minY;
269
+ const padX = w * HEAD_PAD_SIDE;
270
+ const padTop = h * HEAD_PAD_TOP;
271
+ const padBottom = h * HEAD_PAD_BOTTOM;
272
+ return {
273
+ minX: minX - padX,
274
+ maxX: maxX + padX,
275
+ minY: minY - padTop,
276
+ maxY: maxY + padBottom,
277
+ };
278
+ } catch {
279
+ return null;
280
+ }
281
+ };
282
+
283
+ /**
284
+ * Live parameter state, addressed by NAME.
285
+ *
286
+ * The wrapper's getParameterIndex() compares against CubismId objects, so
287
+ * looking up a string always misses (it returns a fresh out-of-range index
288
+ * and the value reads back undefined). The core model's raw tables are
289
+ * plain string arrays, so the name -> index mapping has to go through
290
+ * those. Reading _model.parameters directly is the only reliable way to
291
+ * touch a parameter by name, and it is stable across the Cubism 3/4/5
292
+ * runtimes the engine supports.
293
+ */
294
+ const parameterIndex = (core, id) => {
295
+ try {
296
+ const raw = core?._model?.parameters;
297
+ if (raw === undefined || raw === null) return -1;
298
+ return Array.from(raw.ids).indexOf(id);
299
+ } catch {
300
+ return -1;
301
+ }
302
+ };
303
+
304
+ const readParameter = (id) => {
305
+ const core = coreModel();
306
+ const at = parameterIndex(core, id);
307
+ if (at < 0) return undefined;
308
+ try {
309
+ return core._model.parameters.values[at];
310
+ } catch {
311
+ return undefined;
312
+ }
313
+ };
314
+
315
+ /**
316
+ * The value the last drawn frame holds for this parameter.
317
+ *
318
+ * Differs from readParameter() by exactly the layers this controller
319
+ * applies: readParameter() gives the engine's baseline, this gives what the
320
+ * user is looking at.
321
+ */
322
+ const readDrawn = (id) => {
323
+ const at = parameterIndex(coreModel(), id);
324
+ if (at < 0) return undefined;
325
+ if (drawnValues !== null && at < drawnValues.length) return drawnValues[at];
326
+ return readParameter(id);
327
+ };
328
+
329
+ const writeParameter = (id, value) => {
330
+ const core = coreModel();
331
+ const at = parameterIndex(core, id);
332
+ if (at < 0) return false;
333
+ try {
334
+ core._model.parameters.values[at] = value;
335
+ return true;
336
+ } catch {
337
+ return false;
338
+ }
339
+ };
340
+
341
+ /**
342
+ * The parameter writes contributed by the pinned expressions.
343
+ *
344
+ * Each entry is { id, value, blend } straight from that expression's own
345
+ * .exp3.json, layered on top of whatever the motion system wrote — which is
346
+ * exactly what those expressions' "Add" blend means.
347
+ */
348
+ let expressionLayers = [];
349
+ /** The core model whose saveParameters hook is installed. */
350
+ let hookedCore = null;
351
+ /**
352
+ * Every parameter value the LAST frame actually drew.
353
+ *
354
+ * The engine's frame runs saveParameters() -> update() -> loadParameters(),
355
+ * so loadParameters() lands at the END: it puts the engine's own baseline
356
+ * back over everything written at the save seam. Between frames the live
357
+ * array therefore holds the pose BEFORE the layers — reading it from outside
358
+ * a frame answers "what would the motion have drawn", not "what is on
359
+ * screen".
360
+ *
361
+ * This is what `drawn(id)` answers, and it is the ONLY honest way to assert
362
+ * from a test that a per-frame write reached the screen. The action
363
+ * snapshot deliberately does NOT use it: restoring a drawn value would
364
+ * re-apply the mouth's own old offset and then add the current one on top.
365
+ */
366
+ let drawnValues = null;
367
+ /**
368
+ * 上一帧采样到的、**引擎自己写出来**的每个被还原参数的值。
369
+ *
370
+ * 用来区分"这个参数还有活的东西在驱动"和"它只是停在动作留下的值上"。
371
+ */
372
+ /**
373
+ * 引擎自己的动画系统每帧都在驱动的参数,**永远不进还原表**。
374
+ *
375
+ * 视线跟随(focusController)写 ParamAngleX/Y/Z、ParamEyeBallX/Y,
376
+ * 物理摆动写头发/身体,嘴部与眨眼由本插件每帧写。这些参数一旦被还原表
377
+ * 钉住,宠物就"死"了:实测挤番茄酱 → 无 之后,头不再跟着鼠标转、也不再
378
+ * 有待机摆动(帧外基线明明在动,画面却纹丝不动)。
379
+ *
380
+ * 动作真正私有的参数(chuipaopao*、phone*、danbaofan、ji…)不在此列,
381
+ * 它们才是还原要负责的东西。
382
+ */
383
+ // 名单收得很窄:只有**视线跟随和物理摆动**真正每帧在写的那些。
384
+ // ParamEye* / ParamMouth* 曾经也在里面,代价是动作留下的嘴形永远收不回来 ——
385
+ // 挤番茄酱写过 ParamMouthOpenY/Form,排除掉之后没人还原它,嘴就一直张着。
386
+ // 眼睛同理(动作把它眯起来之后就再也没人睁开)。它们只由动作和本插件的图层
387
+ // 驱动,不跟引擎抢,所以必须留在还原表里。
388
+ const ENGINE_OWNED_PARAM = /^Param(Angle|Body|Breath|Hair)/;
389
+ /**
390
+ * How many times the frame hook actually ran, and what it saw.
391
+ *
392
+ * Everything this controller writes lands in the saveParameters hook, so
393
+ * "the write had no effect" has two very different causes: the hook never
394
+ * ran (a write that lands nowhere), or it ran and something later in the
395
+ * same frame overwrote it. Counting the calls and sampling one parameter
396
+ * either side of the pass is what tells them apart.
397
+ */
398
+ let hookCalls = 0;
399
+ let hookProbe = null;
400
+ /**
401
+ * Samples of one parameter at each seam of the frame.
402
+ *
403
+ * The engine writes its own baseline back at points this controller does
404
+ * not control, so "our write landed" and "our write survived the frame"
405
+ * are different claims. Sampling after loadParameters, after the save
406
+ * hook's own write, and after update() is what separates them.
407
+ */
408
+ let seamAt = -1;
409
+ let loadCalls = 0;
410
+ let loadSample = null;
411
+ let updateCalls = 0;
412
+ let updateSample = null;
413
+ /**
414
+ * The order the engine visits the three seams in, most recent last.
415
+ *
416
+ * Counts cannot tell "load runs before save" from "load runs after it", and
417
+ * that difference decides whether a write at the save seam survives the
418
+ * frame at all.
419
+ */
420
+ let seamOrder = "";
421
+ const markSeam = (ch) => { seamOrder = (seamOrder + ch).slice(-12); };
422
+
423
+ /**
424
+ * Apply the pinned expressions' parameters.
425
+ *
426
+ * Expressions blend on top of the motion output, so the write has to land
427
+ * at the exact seam the engine's own expression pass uses — which is AFTER
428
+ * saveParameters(), not after loadParameters().
429
+ *
430
+ * The frame runs: loadParameters() (undo last frame's expression) ->
431
+ * motions write -> saveParameters() (snapshot the pose the motions produced)
432
+ * -> expressions write on top -> deformers. Writing after loadParameters
433
+ * instead puts the value INSIDE the saved snapshot, so it becomes part of
434
+ * the baseline: the next frame restores it and adds another copy on top,
435
+ * and it can never be taken back off. That is exactly the "switches stay on
436
+ * forever" failure.
437
+ */
438
+ /**
439
+ * The procedural animation the pinned slot option asks for, if any.
440
+ *
441
+ * This model ships 点菜手X / 点菜手Y / 点菜手Z (pointX / pointY / pointY2) with a
442
+ * ±30 range and NOTHING in the model ever writes them — the author intended
443
+ * the hand to follow the pointer and never finished it. Driving them here
444
+ * gives the pet a hand that actually moves across the tablet, which is what
445
+ * the "a tool is running" state needed.
446
+ */
447
+ let sweepSpec = null;
448
+ /** Last normalized gaze target, for diagnostics. */
449
+ let gazeTarget = { x: 0, y: 0 };
450
+ /** 0..1 pointer distance, driving the mouth. Eased, not raw. */
451
+ let mouthFollow = 0;
452
+ /** -1..1 pointer height, driving the mouth's shape: up positive. Eased. */
453
+ let mouthLean = 0;
454
+ /** Where the pointer currently says the mouth should be. */
455
+ let mouthTargetFollow = 0;
456
+ let mouthTargetLean = 0;
457
+ /** Timestamp of the previous frame, for frame-rate independent easing. */
458
+ let mouthEasedAt = 0;
459
+ /** When the next blink starts, and when the current one started. */
460
+ let blinkAt = 0;
461
+ let blinkStart = 0;
462
+ /** How shut the eyes were on the last frame, for diagnostics. */
463
+ let blinkWrote = 0;
464
+ /**
465
+ * Blinks started since load.
466
+ *
467
+ * Counted here rather than sampled from outside: a blink is ~225ms end to
468
+ * end and a CDP round trip is easily 100ms+, so a polling test misses most
469
+ * of them and reports "never blinks" for a pet that blinks fine.
470
+ */
471
+ let blinkCount = 0;
472
+ /** The mouth values as last written inside a frame, for diagnostics. */
473
+ let mouthWritten = { open: 0, form: 0 };
474
+ /** Answers whether a motion group's premise currently holds. */
475
+ let guardFor = null;
476
+ /** Last pen position, for diagnostics. */
477
+ let sweepLast = null;
478
+
479
+ /**
480
+ * 把被还原的参数写回它们动作之前的值 —— 但只写那些**真的需要钉住**的。
481
+ *
482
+ * 哪些参数进得了这张表,由 ENGINE_OWNED_PARAM 决定(见 snapshot()):
483
+ * 引擎自己的视线跟随和物理摆动每帧都在写 ParamAngle* / ParamEye* / ParamMouth*,
484
+ * 把它们钉住会让宠物僵掉 —— 实测挤番茄酱收回之后头就不再跟着鼠标转。
485
+ */
486
+ const applyRelease = (values, core) => {
487
+ if (releasedOverrides === null || values === null) return;
488
+ for (const id of Object.keys(releasedOverrides)) {
489
+ const at = parameterIndex(core, id);
490
+ if (at >= 0) values[at] = releasedOverrides[id];
491
+ }
492
+ };
493
+
494
+ const applyExpressionLayers = (core) => {
495
+ // The mouth follows the pointer even with nothing pinned and no sweep, so
496
+ // it has to be part of this condition — otherwise the whole pass bails out
497
+ // before reaching it and the mouth never moves.
498
+ // Ease the mouth toward the pointer BEFORE the early return below: when
499
+ // the pointer leaves the focus range the target drops to 0, and bailing
500
+ // out here would leave the mouth frozen half-open instead of closing.
501
+ {
502
+ const now = (typeof performance !== "undefined" ? performance.now() : Date.now());
503
+ // No upper clamp on dt. The exponential below is only frame-rate
504
+ // independent while dt is the REAL elapsed time; capping it at 120ms
505
+ // made every frame slower than ~8fps ease by a fixed step instead of by
506
+ // wall-clock, so on a loaded machine the mouth visibly lagged the
507
+ // pointer (and a test that slept a fixed 1.5s read a half-travelled
508
+ // mouth). After a real stall — a backgrounded tab — the same formula
509
+ // simply arrives in one step, which is the correct real-time answer.
510
+ const dt = mouthEasedAt === 0 ? 16 : Math.max(1, now - mouthEasedAt);
511
+ mouthEasedAt = now;
512
+ // Exponential, so it is smooth and frame-rate independent.
513
+ const k = 1 - Math.exp(-dt / MOUTH_EASE_MS);
514
+ mouthFollow += (mouthTargetFollow - mouthFollow) * k;
515
+ mouthLean += (mouthTargetLean - mouthLean) * k;
516
+ if (Math.abs(mouthTargetFollow - mouthFollow) < 0.002) mouthFollow = mouthTargetFollow;
517
+ if (Math.abs(mouthTargetLean - mouthLean) < 0.002) mouthLean = mouthTargetLean;
518
+ }
519
+ // Blink. Runs before the early return because it is unconditional — it
520
+ // has nothing to do with what is pinned, and the engine's own blink is
521
+ // disabled precisely because its gate never opens for this model.
522
+ try {
523
+ const values = core._model.parameters.values;
524
+ const left = parameterIndex(core, EYE_L_PARAM);
525
+ const right = parameterIndex(core, EYE_R_PARAM);
526
+ const now = (typeof performance !== "undefined" ? performance.now() : Date.now());
527
+ if (blinkAt === 0) blinkAt = now + BLINK_MIN_MS + Math.random() * (BLINK_MAX_MS - BLINK_MIN_MS);
528
+ if (blinkStart === 0 && now >= blinkAt) { blinkStart = now; blinkCount += 1; }
529
+ if (blinkStart !== 0) {
530
+ const elapsed = now - blinkStart;
531
+ const shut = BLINK_CLOSE_MS + BLINK_HOLD_MS;
532
+ let open = 1;
533
+ if (elapsed < BLINK_CLOSE_MS) open = 1 - elapsed / BLINK_CLOSE_MS;
534
+ else if (elapsed < shut) open = 0;
535
+ else if (elapsed < shut + BLINK_OPEN_MS) open = (elapsed - shut) / BLINK_OPEN_MS;
536
+ else {
537
+ blinkStart = 0;
538
+ blinkAt = now + BLINK_MIN_MS + Math.random() * (BLINK_MAX_MS - BLINK_MIN_MS);
539
+ }
540
+ if (open < 1) {
541
+ // Multiply rather than assign: a pinned expression may already have
542
+ // narrowed the eyes, and a blink must close whatever is there.
543
+ // Skipped when the eyes are already shut, so it cannot fight a wink.
544
+ if (left >= 0 && values[left] > 0.2) values[left] *= open;
545
+ if (right >= 0 && values[right] > 0.2) values[right] *= open;
546
+ blinkWrote = 1 - open;
547
+ } else {
548
+ blinkWrote = 0;
549
+ }
550
+ }
551
+ } catch {
552
+ /* a torn-down model */
553
+ }
554
+ if (expressionLayers.length === 0 && sweepSpec === null && mouthFollow <= 0 && mouthLean === 0
555
+ && releasedOverrides === null) {
556
+ // The mouth contributes nothing at rest, and saying so is part of the
557
+ // contract: leaving the last moving values here would report an open
558
+ // mouth after the pointer had already come back to the centre.
559
+ mouthWritten = { open: 0, form: 0 };
560
+ // The release still has to be applied — it is not tied to any of the
561
+ // things this guard is about.
562
+ applyRelease(core._model.parameters.values, core);
563
+ return;
564
+ }
565
+ try {
566
+ const values = core._model.parameters.values;
567
+ applyRelease(values, core);
568
+ // The mouth follows the pointer too. It has to be written per frame —
569
+ // setting it once from the pointermove handler would be overwritten by
570
+ // the very next frame the motion system runs.
571
+ if (true) {
572
+ const params = core._model.parameters;
573
+ const add = (id, delta) => {
574
+ const at = parameterIndex(core, id);
575
+ if (at < 0) return;
576
+ const min = params.minimumValues[at];
577
+ const max = params.maximumValues[at];
578
+ // Added on top of whatever the pose or a pinned face already wrote,
579
+ // then clamped to the model's own range.
580
+ const next = values[at] + delta;
581
+ values[at] = next > max ? max : (next < min ? min : next);
582
+ };
583
+ const openAt = parameterIndex(core, MOUTH_OPEN_PARAM);
584
+ const formAt = parameterIndex(core, MOUTH_FORM_PARAM);
585
+ if (openAt >= 0) {
586
+ add(MOUTH_OPEN_PARAM, mouthFollow * (params.maximumValues[openAt] - params.minimumValues[openAt]) * MOUTH_FOLLOW);
587
+ }
588
+ // Scale the author's own open-mouth direction by how high the pointer
589
+ // is: up leans the shape the way selfie.motion3.json does, down leans
590
+ // it the other way.
591
+ add(MOUTH_FORM_PARAM, mouthLean * MOUTH_DROP);
592
+ // The CONTRIBUTION, not the absolute value: the absolute one also
593
+ // carries the pose's own resting shape, which is not ours to assert.
594
+ mouthWritten = {
595
+ open: Number((mouthFollow * MOUTH_FOLLOW).toFixed(3)),
596
+ form: Number((mouthLean * MOUTH_DROP).toFixed(3)),
597
+ };
598
+ }
599
+ if (sweepSpec !== null) {
600
+ const spec = sweepSpec;
601
+ const at = (id) => (id === undefined ? -1 : parameterIndex(core, id));
602
+ // Add to whatever the pose already wrote rather than replacing it, so
603
+ // the hand still rides the body's own motion.
604
+ const add = (id, delta) => {
605
+ const i = at(id);
606
+ if (i >= 0) values[i] += delta;
607
+ };
608
+ const now = (typeof performance !== "undefined" ? performance.now() : Date.now());
609
+ // A closed loop the pen never leaves: a lemniscate (figure-eight),
610
+ // which is the 2D shadow of a Möbius strip's centre line. The old
611
+ // version was a sawtooth — write left to right, snap back — and the
612
+ // snap is what read as stiff.
613
+ const u = (now / spec.loopMs) * Math.PI * 2;
614
+ // The half-twist: the strip only comes back to itself after TWO
615
+ // passes, so anything tied to the twist runs at half the loop rate.
616
+ const half = u / 2;
617
+ const px = spec.ampX * Math.sin(u);
618
+ const py = spec.ampY * 0.5 * Math.sin(2 * u);
619
+ const drift = spec.driftMs > 0 ? spec.driftY * Math.sin((now / spec.driftMs) * Math.PI * 2) : 0;
620
+ sweepLast = { x: px, y: py + drift };
621
+ add(spec.x, px);
622
+ add(spec.y, py + drift);
623
+ // The pen leans with the twist, so the loop has a front and a back
624
+ // instead of being a flat outline, and stays pressed to the tablet.
625
+ add(spec.rz, spec.ampZ * Math.sin(half));
626
+ add(spec.z, 0.6);
627
+ }
628
+ for (const layer of expressionLayers) {
629
+ const at = parameterIndex(core, layer.id);
630
+ if (at < 0) continue;
631
+ const current = values[at];
632
+ if (layer.blend === "Multiply") values[at] = current * layer.value;
633
+ else if (layer.blend === "Overwrite") values[at] = layer.value;
634
+ else values[at] = current + layer.value;
635
+ }
636
+ } catch {
637
+ /* a torn-down model: nothing to write */
638
+ }
639
+ };
640
+
641
+ /**
642
+ * Install the per-frame expression pass.
643
+ *
644
+ * This is what makes several dress-up slots possible at all: the engine's
645
+ * expression manager holds exactly ONE expression, so asking it to layer
646
+ * would render only the last pin. Writing the union ourselves has no such
647
+ * limit, and it is the same arithmetic the engine would have done.
648
+ */
649
+ const installCoreHook = (core) => {
650
+ if (core === null || core === undefined || core === hookedCore) return;
651
+ try {
652
+ if (typeof core.saveParameters !== "function") return;
653
+ const base = core.saveParameters.bind(core);
654
+ core.saveParameters = () => {
655
+ base();
656
+ hookCalls += 1;
657
+ markSeam("S");
658
+ let values = null;
659
+ try {
660
+ values = core._model.parameters.values;
661
+ } catch {
662
+ values = null;
663
+ }
664
+ // Sample the release table's first entry on both sides of the pass.
665
+ const probeId = releasedOverrides === null ? null : Object.keys(releasedOverrides)[0];
666
+ const probeAt = probeId === null || values === null ? -1 : parameterIndex(core, probeId);
667
+ const pre = probeAt >= 0 ? values[probeAt] : null;
668
+ applyExpressionLayers(core);
669
+ // The layers are now in place and update() is next, so this is the
670
+ // pose the frame is about to draw.
671
+ if (values !== null) {
672
+ if (drawnValues === null || drawnValues.length !== values.length) {
673
+ drawnValues = new Float32Array(values.length);
674
+ }
675
+ drawnValues.set(values);
676
+ }
677
+ hookProbe = probeId === null
678
+ ? null
679
+ : { id: probeId, at: probeAt, pre, post: probeAt >= 0 ? values[probeAt] : null };
680
+ if (probeAt >= 0) seamAt = probeAt;
681
+ };
682
+ const sample = () => {
683
+ if (seamAt < 0) return null;
684
+ try {
685
+ return core._model.parameters.values[seamAt];
686
+ } catch {
687
+ return null;
688
+ }
689
+ };
690
+ if (typeof core.loadParameters === "function") {
691
+ const loadBase = core.loadParameters.bind(core);
692
+ core.loadParameters = () => {
693
+ loadBase();
694
+ loadCalls += 1;
695
+ markSeam("L");
696
+ loadSample = sample();
697
+ };
698
+ }
699
+ if (typeof core.update === "function") {
700
+ const updateBase = core.update.bind(core);
701
+ core.update = () => {
702
+ updateBase();
703
+ updateCalls += 1;
704
+ markSeam("U");
705
+ updateSample = sample();
706
+ };
707
+ }
708
+ hookedCore = core;
709
+ } catch {
710
+ /* an engine that will not let us wrap it: pins simply do nothing */
711
+ }
712
+ };
713
+
714
+ /**
715
+ * Put a motion's parameters back where they were before it ran.
716
+ *
717
+ * The engine only ever WRITES the parameters a motion curves; it never
718
+ * restores them when the motion stops. That is fine while the idle loop
719
+ * happens to drive the same parameter, but this model's action-specific
720
+ * parameters (chuipaopao*, phone*, pengshui, …) are driven by NOTHING
721
+ * except the action itself. Once 吹泡泡糖 ends, its last written mouth
722
+ * value sticks forever — the "泡泡吹完嘴没还原" bug.
723
+ *
724
+ * The snapshot is taken when the action starts; restoring it on the way
725
+ * back to idle is what makes a one-shot action actually be one-shot.
726
+ */
727
+ const snapshot = (ids, extra) => {
728
+ const out = {};
729
+ const all = (ids || []).concat(extra === null || extra === undefined ? [] : Object.keys(extra));
730
+ for (const id of all) {
731
+ // 引擎自己会一直驱动的身体参数不进来:钉住它们等于把宠物冻住。
732
+ if (ENGINE_OWNED_PARAM.test(id)) continue;
733
+ // 取值顺序(三档,缺一档都会出 bug):
734
+ // 1. 还挂着的那个动作的快照 —— 它记的是这只手**还没抬起来**时的值;
735
+ // 2. 已经装好的还原表 —— 同样记的是动作之前的值;
736
+ // 3. 引擎自己的值 —— 前两档都没有时才用它。
737
+ //
738
+ // 第 3 档单独用不行:装好还原表之后它是冻结的动作输出(吹泡泡糖第二轮
739
+ // 就是这么把"鼓着的嘴"记成还原目标的)。第 1 档少了更糟:重播同一个动作、
740
+ // 或者走前置链时,它正**举着自己写的东西**——掏出手机之后播自拍(自拍的
741
+ // 前置就是掏出手机),phone 被记成 1,从此谁也放不下这只手。
742
+ //
743
+ // DRAWN 值仍然不用:它把插件图层自己的贡献也算进去了,还原那些会算两遍
744
+ // (还原补一遍嘴的旧偏移,嘴部图层再加一遍当前的)。
745
+ const outstanding = heldParams === null ? null : heldParams.saved;
746
+ const value = outstanding !== null && Object.prototype.hasOwnProperty.call(outstanding, id)
747
+ ? outstanding[id]
748
+ : (releasedOverrides !== null && Object.prototype.hasOwnProperty.call(releasedOverrides, id)
749
+ ? releasedOverrides[id]
750
+ : readParameter(id));
751
+ if (value !== undefined) out[id] = value;
752
+ }
753
+ return out;
754
+ };
755
+
756
+ const restore = (snapshotValues) => {
757
+ if (snapshotValues === null || snapshotValues === undefined) return;
758
+ for (const [id, value] of Object.entries(snapshotValues)) writeParameter(id, value);
759
+ };
760
+
761
+ /**
762
+ * Undo whatever a finished one-shot action deliberately pinned.
763
+ *
764
+ * NOT a one-shot write: writing the old values straight into the core lands
765
+ * OUTSIDE the frame, and the very next `loadParameters()` restores them from
766
+ * the snapshot — which still holds the action's values, because that snapshot
767
+ * was taken while the action was running. The write vanished, so a parked
768
+ * pose could never be let go (吹泡泡糖 stayed inflated for good).
769
+ *
770
+ * Instead the saved values become a per-frame override: applied every frame
771
+ * at the same seam as everything else, and dropped the moment a new motion
772
+ * starts and takes those parameters over.
773
+ */
774
+ const restoreHeld = () => {
775
+ if (heldParams === null) return;
776
+ // 合并,**不是替换**。
777
+ //
778
+ // 一次只有一个动作在播(desired 只认第一个带 motion 的槽位),但可以有好几个
779
+ // 动作"停在那里",各自钉着一批参数——点了吹泡泡糖再点掏出手机,两个槽位都还
780
+ // 选着。替换会让先收起来的那个动作凭空失去还原:收掉手机时还原表只剩
781
+ // OpenCase 的快照,chuipaopao:0 那条没了,泡泡就永远挂回脸上。
782
+ // 这就是"三个里任意点两个就还原不回去"。
783
+ //
784
+ // 同名项由新表覆盖:新动作启动时快照读的就是还原缝上的值,也就是旧表正要写的
785
+ // 那个值,两者本来就一致。
786
+ releasedOverrides = Object.assign({}, releasedOverrides, heldParams.saved);
787
+ heldParams = null;
788
+ };
789
+
790
+ /**
791
+ * Retire a held action into its resting pose.
792
+ *
793
+ * The motion keeps painting its final frame (it was started with
794
+ * loop:false and has since finished), so nothing has to be re-triggered —
795
+ * the pet just stops counting as busy. The parameter pins stay installed
796
+ * on purpose, and playIdle() releases them when the body changes hands.
797
+ */
798
+ const settleHeld = () => {
799
+ if (settled) return;
800
+ settled = true;
801
+ // `kind` returns to idle (so the body is up for grabs) but the GROUP is
802
+ // deliberately kept: the pet really is parked in 掏出手机's final pose, and
803
+ // both data-motion and the panel chip should keep saying so.
804
+ kind = "idle";
805
+ notify();
806
+ };
807
+
808
+ /**
809
+ * Start one entry; false when the group is missing or the start threw.
810
+ *
811
+ * `keep` carries a parameter snapshot from an earlier motion in the same
812
+ * chain: when 掏出手机 is prepended to 自拍, the phone must stay up across
813
+ * both motions, so the second start must NOT re-snapshot (that would
814
+ * capture the already-raised phone and "restore" it to raised forever).
815
+ */
816
+ const start = (entry, priority, options, keep) => {
817
+ if (model === null || entry === null || vendor === null) return false;
818
+ const opts = options || {};
819
+ // `preset` pins parameters the ACTION needs but the motion itself does
820
+ // not animate. 鲸鱼喷水 only writes `pengshui` (碰水); the whale that is
821
+ // supposed to do the spraying is a separate parameter (`jingyu`) that
822
+ // nothing in that motion touches — which is why it looked like a no-op.
823
+ const preset = opts.preset ?? null;
824
+ // Stop ONLY when replaying the very same group+index, which is the one
825
+ // case the engine refuses on its own. Clearing the queue unconditionally
826
+ // removed the outgoing motion instantly, so there was nothing left to
827
+ // fade OUT of and every switch became a hard cut — the transitions were
828
+ // being destroyed by this one line.
829
+ const replaying = currentEntry !== null
830
+ && currentEntry.group === entry.group && currentEntry.index === entry.index;
831
+ if (replaying) stopAll();
832
+ // Releasing the previous action's pins before the new one starts keeps
833
+ // two actions from fighting over the same parameter.
834
+ // Snapshot BEFORE releasing the previous action's pins. restoreHeld()
835
+ // replaces the release table a line later, and that table is part of the
836
+ // pose being captured — taking the snapshot after it would drop exactly
837
+ // the values that are holding the previous action's pose (see snapshot()).
838
+ const saved = keep === undefined ? snapshot(entry.params, preset) : keep;
839
+ if (keep === undefined) restoreHeld();
840
+ // Snapshot first: it reads the OVERRIDDEN values, which is the true
841
+ // pre-action state. Then hand back only the parameters this motion
842
+ // actually drives — clearing the whole map here would wipe the release
843
+ // that playIdle() had just installed a line earlier, since playIdle
844
+ // calls restoreHeld() and then start().
845
+ if (releasedOverrides !== null && keep === undefined) {
846
+ for (const id of entry.params ?? []) delete releasedOverrides[id];
847
+ }
848
+ currentGroup = entry.group;
849
+ currentEntry = entry;
850
+ startedAt = Date.now();
851
+ settled = false;
852
+ try {
853
+ // loop:false is essential. Every motion3.json in this model declares
854
+ // "Loop": true, and the engine merges the motion's own flag with the
855
+ // caller's (`setLoop(loop ?? motionData.loop)`), so a motion started
856
+ // without an explicit flag loops forever and never holds a pose.
857
+ void model.motion(entry.group, entry.index, priority, { loop: false });
858
+ } catch {
859
+ currentEntry = null;
860
+ return false;
861
+ }
862
+ // Applied AFTER the snapshot, so retiring the action puts them back.
863
+ if (preset !== null && keep === undefined) {
864
+ for (const [id, value] of Object.entries(preset)) writeParameter(id, value);
865
+ }
866
+ // A chain keeps the ORIGINAL pre-action snapshot, so retiring it undoes
867
+ // everything the whole chain touched rather than just the last motion.
868
+ heldParams = { saved, holds: opts.holds || null };
869
+ reapplyExpression();
870
+ return true;
871
+ };
872
+
873
+ /**
874
+ * Drop every override and return to the pristine initial state.
875
+ *
876
+ * Requirement #3: after any action or expression has had its moment, the pet
877
+ * must end up exactly where it started — the idle loop, no pinned
878
+ * expression, no parameter left behind by a motion. This is the one funnel
879
+ * that guarantees it, and it is also what the sustain loop calls when a
880
+ * session phase ends.
881
+ */
882
+ const resetToRest = () => {
883
+ sustainPhase = null;
884
+ window.clearTimeout(sustainTimer);
885
+ sustainTimer = 0;
886
+ restoreHeld();
887
+ playIdle();
888
+ };
889
+ /** Return to the looping idle animation; the resting state of the pet. */
890
+ const playIdle = () => {
891
+ clearTimer();
892
+ token += 1;
893
+ kind = "idle";
894
+ currentEntry = null;
895
+ if (model === null || idleName === null) return;
896
+ // Coming back to rest retires the previous action's parameter pins, so
897
+ // the bubble-gum mouth (and anything else action-specific) is released
898
+ // before the idle loop takes over.
899
+ restoreHeld();
900
+ const entry = entryFor(idleName, 0);
901
+ if (entry === null) return;
902
+ if (!start(entry, vendor.MotionPriority.IDLE)) return;
903
+ // Idle is the resting state, so it deliberately highlights no chip —
904
+ // notify() reports the committed action, not the running loop.
905
+ currentGroup = null;
906
+ notify();
907
+ // Idle is also started with loop:false, so it has to be re-queued when
908
+ // its declared duration elapses to keep looping.
909
+ if (entry.duration > 0) {
910
+ const mine = token;
911
+ timer = window.setTimeout(() => {
912
+ timer = 0;
913
+ if (mine === token) playIdle();
914
+ }, entry.duration + REACTION_TAIL_MS);
915
+ }
916
+ };
917
+
918
+ /**
919
+ * What an action does when its motion finishes.
920
+ *
921
+ * Order of precedence:
922
+ * 1. a sustained session phase re-triggers its own motion (requirement #4),
923
+ * 2. `hold: true` keeps the pose — but only until ACTION_HOLD_MAX_MS, so
924
+ * nothing can park the pet forever (requirement #3),
925
+ * 3. otherwise fall back to the idle loop.
926
+ */
927
+ const finishAction = (opts) => {
928
+ if (sustainPhase !== null) {
929
+ const mine = token;
930
+ window.clearTimeout(sustainTimer);
931
+ sustainTimer = window.setTimeout(() => {
932
+ sustainTimer = 0;
933
+ if (mine === token && sustainPhase !== null) playSustained();
934
+ }, PHASE_SUSTAIN_GAP_MS);
935
+ return;
936
+ }
937
+ if (opts !== null && opts.hold === true) {
938
+ settleHeld();
939
+ // A SLOT's motion parks for good: 吹泡泡糖 belongs to the mouth slot and
940
+ // 掏出手机 to the hand slot, so their pose is part of the chosen look and
941
+ // must survive until the slot changes. Only an ad-hoc action (a preview
942
+ // from the 动作 tab) is released by the watchdog.
943
+ if (opts.persist === true) return;
944
+ const mine = token;
945
+ window.clearTimeout(sustainTimer);
946
+ sustainTimer = window.setTimeout(() => {
947
+ sustainTimer = 0;
948
+ if (mine === token && sustainPhase === null) playIdle();
949
+ }, ACTION_HOLD_MAX_MS);
950
+ return;
951
+ }
952
+ playIdle();
953
+ };
954
+
955
+ /** Re-trigger the sustained phase's motion; the sustain loop's heartbeat. */
956
+ const playSustained = () => {
957
+ if (sustainPhase === null) return;
958
+ const group = phaseMotionFor(sustainPhase);
959
+ if (group === undefined || !Array.isArray(groups[group])) {
960
+ // The pet has no motion for this phase; the idle loop is the honest
961
+ // representation of "nothing to show".
962
+ playIdle();
963
+ return;
964
+ }
965
+ playOnce(group, 0, { kind: "phase" });
966
+ };
967
+
968
+ /**
969
+ * Play one motion, then either return to idle or hold its final pose.
970
+ *
971
+ * Every motion is started with loop:false, so the controller's own timer
972
+ * always owns the lifetime — the model's declared Duration is what decides
973
+ * how long that is. `hold: true` parks the pet in the last frame instead
974
+ * of snapping back; `prepend` runs a prerequisite motion first.
975
+ */
976
+ const playOnce = (group, index, options) => {
977
+ const entry = entryFor(group, index);
978
+ if (entry === null || model === null || vendor === null) return false;
979
+ // A motion whose premise is missing must not play from ANY caller.
980
+ if (guardFor !== null && guardFor(group) !== true) return false;
981
+ // The pet's declared policy is the default; an explicit caller option
982
+ // (the panel, or the session-phase driver) still wins.
983
+ const opts = Object.assign({}, optionsFor(group), options || {});
984
+ const mine = ++token;
985
+ clearTimer();
986
+
987
+ // A prerequisite action (掏出手机 before 拍照) runs first and chains into
988
+ // the real motion. The snapshot is taken BEFORE the prerequisite so that
989
+ // retiring the whole chain puts the phone back down.
990
+ const prepend = opts.prepend === undefined ? null : entryFor(opts.prepend, 0);
991
+ const first = prepend ?? entry;
992
+ const cycleCount = typeof opts.cycles === "number" && opts.cycles > 0 ? opts.cycles : 1;
993
+ const ms = (item) => (item.duration > 0 ? item.duration : REACTION_FALLBACK_MS);
994
+ if (!start(first, vendor.MotionPriority.FORCE, opts)) return false;
995
+ const chainSnapshot = heldParams === null ? null : heldParams.saved;
996
+
997
+ kind = opts.kind || "action";
998
+ notify();
999
+
1000
+ // Every motion in this model declares Loop, so none of them terminate on
1001
+ // their own and the controller always owns the lifetime.
1002
+ const holdMs = (prepend === null ? ms(entry) * cycleCount : PREPEND_HOLD_MS) + REACTION_TAIL_MS;
1003
+ timer = window.setTimeout(() => {
1004
+ timer = 0;
1005
+ if (mine !== token) return;
1006
+ // The prerequisite is done; run the action it was preparing for.
1007
+ if (prepend !== null) {
1008
+ if (!start(entry, vendor.MotionPriority.FORCE, opts, chainSnapshot)) { finishAction(opts); return; }
1009
+ // The chip and data-motion follow the committed action, so the second
1010
+ // half of a chain has to announce itself just like the first half.
1011
+ notify();
1012
+ timer = window.setTimeout(() => {
1013
+ timer = 0;
1014
+ if (mine !== token) return;
1015
+ finishAction(opts);
1016
+ }, ms(entry) * cycleCount + REACTION_TAIL_MS);
1017
+ return;
1018
+ }
1019
+ finishAction(opts);
1020
+ }, holdMs);
1021
+ return true;
1022
+ };
1023
+
1024
+ /**
1025
+ * A motion that genuinely ended by itself releases the pet back to idle.
1026
+ *
1027
+ * The event is only trustworthy once the new motion has had time to become
1028
+ * the playing one; anything earlier is the stop() artifact described on
1029
+ * MOTION_FINISH_GUARD_MS. Because a looping motion never finishes on its
1030
+ * own, the duration timer armed by playOnce is the real backstop — this
1031
+ * handler exists for non-looping motions, where it retires the pet sooner
1032
+ * than the timer would.
1033
+ */
1034
+ const onMotionFinish = () => {
1035
+ if (Date.now() - startedAt < MOTION_FINISH_GUARD_MS) return;
1036
+ // Deliberately inert.
1037
+ //
1038
+ // Every motion is now started with loop:false, so they ALL finish on
1039
+ // their own — including the first half of a chain (掏出手机 → 自拍) and
1040
+ // actions that must hold their last pose. Acting on this event would
1041
+ // cancel the chain or drop the pose at exactly the wrong moment.
1042
+ //
1043
+ // The controller's own timers are the single authority on what happens
1044
+ // when an action ends, because only they know about chains and holds.
1045
+ };
1046
+
1047
+ /** Index the model's real motion groups, enriched with declared timing. */
1048
+ const indexGroups = (nextModel, catalogMotions) => {
1049
+ const declared = {};
1050
+ for (const entry of catalogMotions || []) {
1051
+ if (entry !== null && typeof entry === "object" && Array.isArray(entry.items)) {
1052
+ declared[entry.group] = entry.items;
1053
+ }
1054
+ }
1055
+ const settings = nextModel?.internalModel?.settings?.motions ?? {};
1056
+ const out = {};
1057
+ for (const group of Object.keys(settings)) {
1058
+ const list = settings[group];
1059
+ if (!Array.isArray(list) || list.length === 0) continue;
1060
+ const meta = declared[group] || [];
1061
+ out[group] = list.map((_, index) => {
1062
+ const item = meta[index] || {};
1063
+ return {
1064
+ group,
1065
+ index,
1066
+ duration: typeof item.duration === "number" ? item.duration : 0,
1067
+ loop: item.loop === true,
1068
+ // Parameters this motion's curves touch; needed to undo them.
1069
+ params: Array.isArray(item.params) ? item.params : [],
1070
+ };
1071
+ });
1072
+ }
1073
+ return out;
1074
+ };
1075
+
1076
+ const resolveIdleName = (built) => {
1077
+ for (const candidate of IDLE_CANDIDATES) {
1078
+ if (Array.isArray(built[candidate])) return candidate;
1079
+ }
1080
+ const keys = Object.keys(built);
1081
+ return keys.length > 0 ? keys[0] : null;
1082
+ };
1083
+
1084
+ return {
1085
+ /** Bind a freshly loaded model and start its idle loop. */
1086
+ attach(nextVendor, nextModel, catalogMotions, nextOptions) {
1087
+ vendor = nextVendor;
1088
+ model = nextModel;
1089
+ groups = indexGroups(nextModel, catalogMotions);
1090
+ motionOptions = nextOptions ?? null;
1091
+ idleName = resolveIdleName(groups);
1092
+ // Locate the head once, from the model's own geometry; it is stored in
1093
+ // model space so it survives every later resize and drag.
1094
+ headBox = measureHead(nextModel);
1095
+ // Expressions are written by this controller, not the engine, so the
1096
+ // per-frame pass has to be armed on the freshly loaded core.
1097
+ installCoreHook(coreModel());
1098
+ token += 1;
1099
+ clearTimer();
1100
+ try {
1101
+ motionManager()?.on?.("motionFinish", onMotionFinish);
1102
+ } catch {
1103
+ /* older engine without the event: the duration timers carry it */
1104
+ }
1105
+ playIdle();
1106
+ },
1107
+ /** Unbind before the model is destroyed. */
1108
+ detach() {
1109
+ clearTimer();
1110
+ token += 1;
1111
+ model = null;
1112
+ vendor = null;
1113
+ groups = {};
1114
+ motionOptions = null;
1115
+ heldParams = null;
1116
+ idleName = null;
1117
+ kind = "idle";
1118
+ currentGroup = null;
1119
+ currentEntry = null;
1120
+ startedAt = 0;
1121
+ heldParams = null;
1122
+ settled = false;
1123
+ sustainPhase = null;
1124
+ window.clearTimeout(sustainTimer);
1125
+ sustainTimer = 0;
1126
+ phaseMotionFor = () => undefined;
1127
+ expressionLayers = [];
1128
+ sweepSpec = null;
1129
+ hookedCore = null;
1130
+ drawnValues = null;
1131
+ releasedOverrides = null;
1132
+ headBox = null;
1133
+ hitMask = null;
1134
+ hitBox = null;
1135
+ },
1136
+ playIdle,
1137
+ playOnce,
1138
+ /**
1139
+ * Replace the pinned expressions' parameter writes.
1140
+ *
1141
+ * The component owns the catalog and the pin set, so it hands down fully
1142
+ * resolved layers; the controller only applies them.
1143
+ */
1144
+ setExpressionLayers(layers) {
1145
+ expressionLayers = Array.isArray(layers) ? layers : [];
1146
+ },
1147
+ /** Install (or clear) the procedural sweep the pinned option asks for. */
1148
+ setSweep(spec) {
1149
+ sweepSpec = spec === undefined || spec === null ? null : spec;
1150
+ },
1151
+ /** Diagnostic: where the procedural sweep currently has the pen. */
1152
+ sweepPosition: () => (sweepSpec === null ? null : sweepLast),
1153
+ /** Diagnostic: how many parameter writes the pinned set contributes. */
1154
+ expressionLayerCount: () => expressionLayers.length,
1155
+ /**
1156
+ * Install the premise check for a motion group.
1157
+ *
1158
+ * Some motions only make sense in a particular state: a selfie needs the
1159
+ * phone already out, the whale spray needs a whale on screen, the ketchup
1160
+ * squeeze needs the omurice under it. The resolver answers whether the
1161
+ * pet is currently in that state, and playOnce REFUSES the motion when it
1162
+ * is not — so no path (panel, fidget, phase) can play an impossible one.
1163
+ */
1164
+ setGuardResolver(fn) {
1165
+ guardFor = typeof fn === "function" ? fn : null;
1166
+ },
1167
+ /**
1168
+ * Diagnostic: the value the last frame DREW for a parameter.
1169
+ *
1170
+ * The only honest way to assert on a per-frame write from outside the
1171
+ * frame: reading the model's live array between frames returns the
1172
+ * engine's own baseline, with every layer already loaded back off.
1173
+ */
1174
+ drawn: (id) => {
1175
+ const value = readDrawn(id);
1176
+ return value === undefined ? null : value;
1177
+ },
1178
+ /** Diagnostic: blinks started since load. */
1179
+ blinkCount: () => blinkCount,
1180
+ /**
1181
+ * Diagnostic: the core this controller hooked.
1182
+ *
1183
+ * An A/B harness reaches the model through its own path; if that path
1184
+ * resolves to a DIFFERENT core than the frame hook writes to, every
1185
+ * measurement of a per-frame write is worthless. Comparing identities is
1186
+ * the only way to rule that out.
1187
+ */
1188
+ coreIdentity: () => hookedCore,
1189
+ /** Diagnostic: the release override and the held snapshot. */
1190
+ releaseDebug: () => ({
1191
+ release: releasedOverrides === null ? null : Object.keys(releasedOverrides).length,
1192
+ releaseSample: releasedOverrides === null ? null : releasedOverrides.chuipaopao,
1193
+ held: heldParams === null ? null : Object.keys(heldParams.saved).length,
1194
+ heldSample: heldParams === null ? null : heldParams.saved.chuipaopao,
1195
+ // Proof that the frame hook runs at all, and that the release pass
1196
+ // really moved the parameter it says it moved.
1197
+ hookCalls,
1198
+ probe: hookProbe,
1199
+ /** 还钉着的参数个数(引擎自己还在动的那些已经被交还掉了)。 */
1200
+ released: releasedOverrides === null ? null : Object.keys(releasedOverrides).length,
1201
+ seamAt,
1202
+ loadCalls,
1203
+ loadSample,
1204
+ updateCalls,
1205
+ updateSample,
1206
+ seamOrder,
1207
+ }),
1208
+ /** Diagnostic: how shut the eyes were on the last frame, 0..1. */
1209
+ blinkAmount: () => blinkWrote,
1210
+ /** Force a blink now, so a test does not have to wait for one. */
1211
+ blinkNow: () => { blinkAt = 0; blinkStart = (typeof performance !== "undefined" ? performance.now() : Date.now()); },
1212
+ /** Diagnostic: how many motions the engine is cross-fading right now. */
1213
+ blending: () => {
1214
+ try {
1215
+ const manager = motionManager();
1216
+ if (manager === null || manager === undefined) return -1;
1217
+ for (const key of Object.keys(manager)) {
1218
+ const value = manager[key];
1219
+ if (Array.isArray(value)) return value.length;
1220
+ }
1221
+ return -2;
1222
+ } catch {
1223
+ return -3;
1224
+ }
1225
+ },
1226
+ /** Diagnostic: may this group play right now? */
1227
+ canPlay: (group) => guardFor === null || guardFor(group),
1228
+ /** Install the phase -> group resolver the sustain loop needs. */
1229
+ setPhaseResolver(fn) {
1230
+ phaseMotionFor = typeof fn === "function" ? fn : () => undefined;
1231
+ },
1232
+ /**
1233
+ * Enter (or leave) a sustained session phase.
1234
+ *
1235
+ * `null` leaves the phase and drops straight back to the initial idle
1236
+ * state, which is also what the watchdog does if a phase never ends.
1237
+ */
1238
+ setSustain(phase) {
1239
+ if (phase === sustainPhase) return;
1240
+ sustainPhase = phase === undefined ? null : phase;
1241
+ window.clearTimeout(sustainTimer);
1242
+ sustainTimer = 0;
1243
+ if (sustainPhase === null) {
1244
+ // The phase ended: leave whatever it was doing and go back to rest.
1245
+ if (kind === "phase") playIdle();
1246
+ }
1247
+ // A phase only ever STARTS through the component's applyPhase, which
1248
+ // runs the motion; this call just arms the sustain.
1249
+ },
1250
+ /** Force the pet back to its initial idle state (diagnostics / reset). */
1251
+ resetToRest,
1252
+ /** Diagnostic: the session phase currently being sustained, if any. */
1253
+ sustained: () => sustainPhase,
1254
+ /**
1255
+ * Aim the gaze at a point given in stage pixels.
1256
+ *
1257
+ * The engine's own model.focus(x, y) CANNOT be used for this. Its
1258
+ * implementation is:
1259
+ *
1260
+ * const i = x / originalWidth * 2 - 1
1261
+ * const n = y / originalHeight * 2 - 1
1262
+ * const o = Math.atan2(n, i)
1263
+ * focusController.focus(Math.cos(o), -Math.sin(o))
1264
+ *
1265
+ * It converts the point into a DIRECTION and then takes the unit vector, so
1266
+ * the DISTANCE from the centre is thrown away entirely. Every position,
1267
+ * however close to the middle, pulls the head to full deflection — and
1268
+ * crossing the centre flips the direction by 180 degrees, snapping the gaze
1269
+ * from full-left to full-right. That is why a millimetre of mouse movement
1270
+ * near the middle swung the whole body.
1271
+ *
1272
+ * Passing the normalized offset straight to the focus controller keeps the
1273
+ * magnitude, so the gaze is proportional to how far the pointer actually is.
1274
+ */
1275
+ updatePointer(x, y, width, height) {
1276
+ if (model === null) return;
1277
+ const half = { x: Math.max(1, width / 2), y: Math.max(1, height / 2) };
1278
+ const shape = (value) => {
1279
+ // A small dead zone, so hand tremor near the centre does not make the
1280
+ // eyes wander, and a linear ramp beyond it up to full deflection.
1281
+ const size = Math.abs(value);
1282
+ if (size <= GAZE_DEADZONE) return 0;
1283
+ const t = Math.min(1, (size - GAZE_DEADZONE) / (1 - GAZE_DEADZONE));
1284
+ return value < 0 ? -t : t;
1285
+ };
1286
+ const nx = shape((x - half.x) / half.x);
1287
+ // Screen y grows downward; the controller wants up-positive.
1288
+ const ny = shape((y - half.y) / half.y);
1289
+ gazeTarget = { x: nx, y: ny };
1290
+ // How far the pointer is, on the SAME normalized scale the gaze uses, so
1291
+ // the mouth and the eyes agree about how far away it is.
1292
+ mouthTargetFollow = Math.min(1, Math.hypot(nx, ny));
1293
+ // The mouth SHAPE follows the pointer VERTICALLY instead: up is positive
1294
+ // and down is negative, so the opening leans with the cursor rather than
1295
+ // always curving the same way. ny is screen-down-positive, hence the flip.
1296
+ mouthTargetLean = -ny;
1297
+ try {
1298
+ model.internalModel?.focusController?.focus(nx, -ny);
1299
+ } catch {
1300
+ /* an engine without a focus controller simply does not follow */
1301
+ }
1302
+ },
1303
+ /**
1304
+ * Diagnostic: the mouth parameters as written INSIDE the frame.
1305
+ *
1306
+ * Deliberately not a live read: outside the frame the engine has already
1307
+ * restored the pose, so a read there reports the resting value and looks
1308
+ * like nothing happened. That mistake is recorded in the project skill.
1309
+ */
1310
+ mouthDebug: () => mouthWritten,
1311
+ /** Diagnostic: 0..1 pointer distance driving the mouth. */
1312
+ mouthFollow: () => mouthFollow,
1313
+ /** Diagnostic: the normalized gaze target the pointer last produced. */
1314
+ gazeTarget: () => gazeTarget,
1315
+ setExpressionApplier(fn) {
1316
+ applyExpression = typeof fn === "function" ? fn : null;
1317
+ },
1318
+ /** Subscribe to motion transitions; the panel chip follows them. */
1319
+ subscribe(fn) {
1320
+ onChange = typeof fn === "function" ? fn : null;
1321
+ },
1322
+ /**
1323
+ * Install (or clear) the rendered-character alpha mask used to decide
1324
+ * whether a press landed on the pet rather than on empty canvas.
1325
+ */
1326
+ setHitMask(mask, box) {
1327
+ hitMask = mask;
1328
+ hitBox = box;
1329
+ },
1330
+ /**
1331
+ * Whether the given STAGE-local point is over the character. With no mask
1332
+ * available the whole box is accepted, which is the pre-mask behaviour.
1333
+ */
1334
+ /** Diagnostic: how many cells of the installed mask are opaque. */
1335
+ maskInfo() {
1336
+ if (hitMask === null) return { present: false };
1337
+ let count = 0;
1338
+ for (const value of hitMask.data) count += value;
1339
+ return { present: true, size: hitMask.width, opaque: count };
1340
+ },
1341
+ /**
1342
+ * The clickable silhouette as SVG path data, in stage-local pixels.
1343
+ *
1344
+ * Requirement #5: the pet must not swallow clicks meant for the page
1345
+ * underneath. DOM hit-testing follows `clip-path`, so an invisible proxy
1346
+ * carrying this path lets the transparent margin fall through to whatever
1347
+ * is behind while the character itself stays draggable — no per-event JS
1348
+ * and no full-canvas interception.
1349
+ *
1350
+ * The 64x64 grid is merged into rectangles so the path stays short.
1351
+ * Returns null while no mask is available (the whole box is live then,
1352
+ * which is the pre-mask behaviour).
1353
+ */
1354
+ maskPath() {
1355
+ if (hitMask === null) return null;
1356
+ const box = hitBox;
1357
+ if (box === null || box.width <= 0 || box.height <= 0) return null;
1358
+ const cols = hitMask.width;
1359
+ const rows = hitMask.height;
1360
+ const raw = hitMask.data;
1361
+ // Dilate by one cell so the clip matches hitsMask exactly: that test
1362
+ // accepts a hit when ANY neighbour within one cell is opaque, so the
1363
+ // exact grid left a one-cell ring (most visibly the top of the head)
1364
+ // where a press counted as "on the model" yet fell through the proxy.
1365
+ // The same tolerance is what makes edge clicks feel reliable, so the
1366
+ // proxy inherits it rather than the other way round.
1367
+ const data = new Uint8Array(cols * rows);
1368
+ for (let y = 0; y < rows; y += 1) {
1369
+ for (let x = 0; x < cols; x += 1) {
1370
+ let solid = 0;
1371
+ for (let dy = -1; dy <= 1 && solid === 0; dy += 1) {
1372
+ for (let dx = -1; dx <= 1; dx += 1) {
1373
+ const nx = x + dx;
1374
+ const ny = y + dy;
1375
+ if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
1376
+ if (raw[ny * cols + nx] === 1) { solid = 1; break }
1377
+ }
1378
+ }
1379
+ data[y * cols + x] = solid;
1380
+ }
1381
+ }
1382
+ const used = new Uint8Array(cols * rows);
1383
+ const cw = box.width / cols;
1384
+ const ch = box.height / rows;
1385
+ const parts = [];
1386
+ for (let y = 0; y < rows; y += 1) {
1387
+ for (let x = 0; x < cols; x += 1) {
1388
+ const at = y * cols + x;
1389
+ if (data[at] !== 1 || used[at] === 1) continue;
1390
+ // Extend right while the row stays opaque.
1391
+ let w = 1;
1392
+ while (x + w < cols && data[y * cols + x + w] === 1 && used[y * cols + x + w] === 0) w += 1;
1393
+ // Extend down while the whole span stays opaque.
1394
+ let h = 1;
1395
+ for (;;) {
1396
+ const ny = y + h;
1397
+ if (ny >= rows) break;
1398
+ let ok = true;
1399
+ for (let k = 0; k < w; k += 1) {
1400
+ const nAt = ny * cols + x + k;
1401
+ if (data[nAt] !== 1 || used[nAt] === 1) { ok = false; break }
1402
+ }
1403
+ if (!ok) break;
1404
+ h += 1;
1405
+ }
1406
+ for (let yy = y; yy < y + h; yy += 1) {
1407
+ for (let xx = x; xx < x + w; xx += 1) used[yy * cols + xx] = 1;
1408
+ }
1409
+ const px = (box.x + x * cw).toFixed(2);
1410
+ const py = (box.y + y * ch).toFixed(2);
1411
+ const pw = (w * cw).toFixed(2);
1412
+ const ph = (h * ch).toFixed(2);
1413
+ parts.push("M" + px + " " + py + "h" + pw + "v" + ph + "h-" + pw + "Z");
1414
+ }
1415
+ }
1416
+ return parts.length === 0 ? null : parts.join("");
1417
+ },
1418
+ hitsMask(x, y, width, height) {
1419
+ if (hitMask === null) return true;
1420
+ if (width <= 0 || height <= 0) return true;
1421
+ // The grid covers the model's own bounding box, so normalise against
1422
+ // that box rather than the whole stage.
1423
+ const box = hitBox ?? { x: 0, y: 0, width, height };
1424
+ const gx = Math.floor(((x - box.x) / box.width) * hitMask.width);
1425
+ const gy = Math.floor(((y - box.y) / box.height) * hitMask.height);
1426
+ // One cell of tolerance: the model breathes and sways, so requiring an
1427
+ // exact opaque cell would make edge clicks feel unreliable.
1428
+ for (let dy = -1; dy <= 1; dy += 1) {
1429
+ for (let dx = -1; dx <= 1; dx += 1) {
1430
+ const cx = gx + dx;
1431
+ const cy = gy + dy;
1432
+ if (cx < 0 || cy < 0 || cx >= hitMask.width || cy >= hitMask.height) continue;
1433
+ if (hitMask.data[cy * hitMask.width + cx] === 1) return true;
1434
+ }
1435
+ }
1436
+ return false;
1437
+ },
1438
+ idleName: () => idleName,
1439
+ groups: () => groups,
1440
+ /** Declared playback policy for one motion group (diagnostics). */
1441
+ optionsFor,
1442
+ /** Whether the idle fidget is allowed to pick this motion group. */
1443
+ fidgetAllowed,
1444
+ /**
1445
+ * Whether a tap landed on the head (requirement #1).
1446
+ *
1447
+ * The stored box is in MODEL space, so the click is pushed through the
1448
+ * model's own inverse transform — the same mapping the engine uses for
1449
+ * gaze — which keeps it correct at any pet size or position.
1450
+ *
1451
+ * Returns true when the head could not be measured: an unrecognised model
1452
+ * keeps the previous "any tap reacts" behaviour instead of going inert.
1453
+ */
1454
+ hitsHead(x, y) {
1455
+ if (headBox === null || model === null || vendor === null) return true;
1456
+ try {
1457
+ // Pass one arg only: the engine then clones into a fresh Point, so
1458
+ // the stage-space input and the model-space output never alias.
1459
+ const point = model.toModelPosition(new vendor.Point(x, y));
1460
+ return point.x >= headBox.minX && point.x <= headBox.maxX
1461
+ && point.y >= headBox.minY && point.y <= headBox.maxY;
1462
+ } catch {
1463
+ return true;
1464
+ }
1465
+ },
1466
+ /** Diagnostic: the measured head box in model space, or null. */
1467
+ headBox: () => headBox,
1468
+ /**
1469
+ * Play a motion with its declared policy applied; used by the panel, the
1470
+ * tap reaction and the session-phase driver.
1471
+ */
1472
+ playGroup(group, index, overrides) {
1473
+ return playOnce(group, index, overrides);
1474
+ },
1475
+ currentGroup: () => currentGroup,
1476
+ /**
1477
+ * Whether the body is actively animating something the user asked for.
1478
+ * A held pose has settled into rest, so it reports false — otherwise a
1479
+ * single 掏出手机 would suppress idle fidgets and session phases forever.
1480
+ */
1481
+ isPlaying: () => kind !== "idle" && !settled,
1482
+ /** Diagnostic: is the pet parked in a held pose? */
1483
+ isHeld: () => settled,
1484
+ /**
1485
+ * Which kind of action owns the body right now ('idle', 'tap', 'panel',
1486
+ * 'fidget', 'phase'). Session phases may preempt each other but must
1487
+ * never cut off something the user just triggered.
1488
+ */
1489
+ kind: () => kind,
1490
+ };
1491
+ }
1492
+
1493
+ // ------------------------------------------------------------- storage
1494
+
1495
+ function loadStored() {
1496
+ try {
1497
+ const raw = window.localStorage.getItem(STORAGE_KEY);
1498
+ if (raw === null) return {};
1499
+ const parsed = JSON.parse(raw);
1500
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
1501
+ } catch {
1502
+ return {};
1503
+ }
1504
+ }
1505
+
1506
+ function saveStored(patch) {
1507
+ try {
1508
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.assign(loadStored(), patch)));
1509
+ } catch {
1510
+ /* storage is best-effort */
1511
+ }
1512
+ }
1513
+
1514
+ // ------------------------------------------------------------- runtime
1515
+
1516
+ /** Inject one classic script; repeat calls share the same in-flight promise. */
1517
+ const scriptCache = new Map();
1518
+ function injectScript(src) {
1519
+ let pending = scriptCache.get(src);
1520
+ if (pending === undefined) {
1521
+ pending = new Promise((resolve, reject) => {
1522
+ const tag = document.createElement("script");
1523
+ tag.src = src;
1524
+ tag.async = false;
1525
+ tag.onload = () => resolve();
1526
+ tag.onerror = () => reject(new Error("script failed: " + src));
1527
+ document.head.appendChild(tag);
1528
+ });
1529
+ scriptCache.set(src, pending);
1530
+ }
1531
+ return pending;
1532
+ }
1533
+
1534
+ /** Ensure the user-supplied Cubism Core global exists. */
1535
+ async function ensureCore(coreUrl) {
1536
+ if (window.Live2DCubismCore !== undefined) return true;
1537
+ try {
1538
+ await injectScript(coreUrl);
1539
+ } catch {
1540
+ return false;
1541
+ }
1542
+ return window.Live2DCubismCore !== undefined;
1543
+ }
1544
+
1545
+ async function ensureVendor(vendorUrl) {
1546
+ if (window.__dshLive2dPetVendor !== undefined) return window.__dshLive2dPetVendor;
1547
+ await injectScript(vendorUrl);
1548
+ return window.__dshLive2dPetVendor;
1549
+ }
1550
+
1551
+ let vendorConfigured = false;
1552
+ function configureVendor(vendor) {
1553
+ if (vendorConfigured) return;
1554
+ vendorConfigured = true;
1555
+ vendor.extensions.add(vendor.Live2DPlugin);
1556
+ vendor.configureCubismSDK({ memorySizeMB: 64 });
1557
+ }
1558
+
1559
+ // --------------------------------------------------------------- style
1560
+
1561
+ const STYLE_ID = "dsh-live2d-pet-style";
1562
+ // The selector every rule below hangs off is the PET's own root div
1563
+ // ('data-dsh-live2d-pet'), not the bare React container that carries
1564
+ // ROOT_ATTR — the container is only a mount point and a takeover marker.
1565
+ const ROOT_SEL = "[" + PET_ATTR + "]";
1566
+ const CSS = [
1567
+ // The root never takes the pointer itself (requirement #5): a transparent
1568
+ // div still swallows clicks across its whole box, which is what made the
1569
+ // empty margin of the canvas block the page behind it. Only the explicitly
1570
+ // re-armed children below are interactive.
1571
+ ROOT_SEL + "{position:fixed;z-index:2147483000;user-select:none;-webkit-user-select:none;touch-action:none;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;pointer-events:none}",
1572
+ // The stage itself never takes the pointer: it would swallow every click in
1573
+ // the transparent margin. The proxy below is the only interactive layer.
1574
+ ROOT_SEL + " [data-stage]{position:relative;width:100%;height:100%;border-radius:14px;overflow:visible;pointer-events:none}",
1575
+ ROOT_SEL + " [data-stage][data-dragging]{cursor:grabbing}",
1576
+ ROOT_SEL + " [data-stage] canvas{display:block;width:100%!important;height:100%!important}",
1577
+ // The hit-through proxy: an invisible box clipped to the character's
1578
+ // silhouette. DOM hit-testing honours clip-path, so the transparent margin
1579
+ // falls through to the page while the character stays draggable (#5).
1580
+ // While no mask is ready the proxy is hidden and the stage keeps the whole
1581
+ // box live, which is the safe pre-mask behaviour.
1582
+ ROOT_SEL + " [data-hit]{position:absolute;inset:0;cursor:grab;pointer-events:auto}",
1583
+ ROOT_SEL + " [data-stage][data-dragging] [data-hit]{cursor:grabbing}",
1584
+ ROOT_SEL + " [data-hit][data-off]{display:none}",
1585
+ // Until the silhouette is known the whole box stays live, so the pet is
1586
+ // never inert; it degrades to the pre-mask behaviour instead of nothing.
1587
+ ROOT_SEL + " [data-stage][data-nomask]{pointer-events:auto;cursor:grab}",
1588
+ ROOT_SEL + " [data-bubble]{position:absolute;left:50%;bottom:100%;transform:translateX(-50%);margin-bottom:6px;max-width:min(240px,60vw);width:max-content;padding:7px 11px;border-radius:12px;background:linear-gradient(160deg,rgba(38,52,84,.95),rgba(21,28,46,.95));border:1px solid rgba(120,170,255,.3);box-shadow:0 8px 24px rgba(0,0,0,.35);color:#e8eefc;font:400 12px/1.5 inherit;white-space:pre-wrap;pointer-events:none}",
1589
+ // Sits outside the pet's box entirely, so it must re-arm itself.
1590
+ ROOT_SEL + " [data-panel]{position:absolute;right:calc(100% + 10px);bottom:0;width:270px;max-height:min(440px,72vh);display:flex;flex-direction:column;border-radius:14px;overflow:hidden;background:rgba(22,29,46,.95);backdrop-filter:blur(14px);border:1px solid rgba(120,170,255,.24);box-shadow:0 14px 40px rgba(0,0,0,.44);color:#e8eefc;font:400 12px/1.5 inherit;pointer-events:auto}",
1591
+ ROOT_SEL + " [data-panel] header{display:flex;align-items:center;gap:6px;padding:9px 11px;border-bottom:1px solid rgba(120,170,255,.14);font-weight:600}",
1592
+ ROOT_SEL + " [data-panel] header select{flex:1;min-width:0;background:rgba(255,255,255,.08);color:inherit;border:1px solid rgba(120,170,255,.24);border-radius:7px;padding:4px 6px;font:inherit}",
1593
+ ROOT_SEL + " [data-panel] header [data-title]{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
1594
+ ROOT_SEL + " [data-panel] header [data-close]{margin-left:auto;flex:none;width:22px;height:22px;padding:0;line-height:1;border:0;border-radius:6px;background:transparent;color:#9fb0cf;font:400 15px/1 inherit;cursor:pointer}",
1595
+ ROOT_SEL + " [data-panel] header [data-close]:hover{background:rgba(255,255,255,.14);color:#eaf1ff}",
1596
+ // The panel is the whole UI now, so it also owns the hint that tells you
1597
+ // how to get rid of it.
1598
+ ROOT_SEL + " [data-panel] [data-hintrow]{padding:0 10px 7px;color:#7f90ad;font-size:10px;line-height:1.5}",
1599
+ ROOT_SEL + " [data-panel] [data-tabs]{display:flex;gap:2px;padding:6px 8px 0}",
1600
+ ROOT_SEL + " [data-panel] [data-tabs] button{flex:1;border:0;background:transparent;color:#9fb0cf;font:600 11px/2 inherit;border-radius:7px;cursor:pointer}",
1601
+ ROOT_SEL + " [data-panel] [data-tabs] button[data-on]{background:rgba(120,170,255,.2);color:#eaf1ff}",
1602
+ ROOT_SEL + " [data-panel] [data-body]{flex:1;overflow:auto;padding:8px}",
1603
+ ROOT_SEL + " [data-panel] [data-group]{margin-bottom:9px}",
1604
+ ROOT_SEL + " [data-panel] [data-group]>span{display:block;margin:0 0 4px 2px;color:#8ea3c8;font-size:10px;letter-spacing:.06em}",
1605
+ ROOT_SEL + " [data-panel] [data-chips]{display:flex;flex-wrap:wrap;gap:4px}",
1606
+ ROOT_SEL + " [data-panel] [data-chips] button{border:1px solid rgba(120,170,255,.22);background:rgba(255,255,255,.055);color:#dce6f8;font:400 11px/1.5 inherit;padding:3px 8px;border-radius:999px;cursor:pointer;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
1607
+ ROOT_SEL + " [data-panel] [data-chips] button:hover{background:rgba(120,170,255,.24)}",
1608
+ ROOT_SEL + " [data-panel] [data-chips] button[data-on]{background:rgba(120,170,255,.34);border-color:rgba(160,200,255,.55)}",
1609
+ ROOT_SEL + " [data-panel] footer{display:flex;align-items:center;gap:8px;padding:7px 10px;border-top:1px solid rgba(120,170,255,.14);color:#9fb0cf;font-size:11px}",
1610
+ ROOT_SEL + " [data-panel] footer input[type=range]{flex:1;min-width:0}",
1611
+ ROOT_SEL + " [data-panel] footer [data-sizelabel]{min-width:42px;text-align:right;font-variant-numeric:tabular-nums}",
1612
+ ROOT_SEL + " [data-panel] footer button{border:0;background:transparent;color:#9fb0cf;font:inherit;cursor:pointer}",
1613
+ ROOT_SEL + " [data-hint]{position:absolute;inset:0;display:grid;place-items:center;padding:12px;text-align:center;color:#c3cee6;font-size:12px;line-height:1.6}",
1614
+ ROOT_SEL + " [data-hint] code{display:block;margin-top:5px;font-size:11px;opacity:.85;word-break:break-all}",
1615
+ ].join("\n");
1616
+
1617
+ function ensureStyle() {
1618
+ if (document.getElementById(STYLE_ID) !== null) return;
1619
+ const tag = document.createElement("style");
1620
+ tag.id = STYLE_ID;
1621
+ tag.textContent = CSS;
1622
+ document.head.appendChild(tag);
1623
+ }
1624
+
1625
+ // ------------------------------------------------------------ lines
1626
+
1627
+ const LINES = {
1628
+ greet: ["你好呀,我是鲸鱼娘~", "今天也一起加油吧!", "终于见到你了", "摸鱼时间到?"],
1629
+ click: ["呀!", "痒痒的~", "干嘛呀", "摸摸头?", "嘿嘿"],
1630
+ reset: ["表情归位~", "清清爽爽"],
1631
+ loadFailed: ["呜呜,模型加载失败了"],
1632
+ };
1633
+
1634
+ function pick(list) {
1635
+ return list[Math.floor(Math.random() * list.length)];
1636
+ }
1637
+
1638
+ // ---------------------------------------------------------- the pet
1639
+
1640
+ /** The layout callback the boot effect publishes for resize handling. */
1641
+ const layoutRef = { current: null };
1642
+
1643
+ /** The mask rebuild hook the boot effect publishes (null before boot). */
1644
+ const rebuildMaskRef = { current: null };
1645
+
1646
+ /** The active pet's fit adjustments (manifest live2d.scale / translate). */
1647
+ const fitRef = { scale: 1, x: 0, y: 0 };
1648
+
1649
+ /** How far outside the stage the pointer still steers the gaze, in px. */
1650
+ const GAZE_RANGE = 240;
1651
+
1652
+ /** How long a tap may move, in px, before it counts as a drag. */
1653
+ const DRAG_SLOP_PX = 4;
1654
+
1655
+ /** Quiet time before the first idle fidget, and the randomised gap after. */
1656
+ const IDLE_FIDGET_MIN_MS = 12000;
1657
+ const IDLE_FIDGET_MAX_MS = 26000;
1658
+
1659
+ /**
1660
+ * Which slots the idle fidget may draw from (requirement #4).
1661
+ *
1662
+ * Hands, mood, blush and mouth — the pet's own body and face. Deliberately
1663
+ * NOT the outfit slots: a random 摸鱼 that swapped her glasses or put a whale
1664
+ * on her head would undo a choice the user made on purpose.
1665
+ */
1666
+ /**
1667
+ * Fraction of the half-width/height around the centre that is ignored.
1668
+ *
1669
+ * Without it the eyes twitch on every pixel of hand tremor; with it the gaze
1670
+ * only starts moving once the pointer has genuinely left the middle.
1671
+ */
1672
+ const GAZE_DEADZONE = 0.12;
1673
+
1674
+ /** The model's mouth-opening parameter. */
1675
+ const MOUTH_OPEN_PARAM = "ParamMouthOpenY";
1676
+
1677
+ /**
1678
+ * The mouth's SHAPE parameter (range -2..1 on this model).
1679
+ *
1680
+ * This is what decides whether an open mouth reads as a natural "ah" or as a
1681
+ * gasp. Read off the author's own 拍照 action: selfie.motion3.json takes
1682
+ * ParamMouthOpenY from 0 to 1 while taking ParamMouthForm UP to +0.7..+1.
1683
+ *
1684
+ * I first drove it NEGATIVE on the theory that it dropped the jaw. Zooming in
1685
+ * on the rendered mouth showed the opposite: -1 slants the opening into a
1686
+ * smirk, 0 gives a clean oval, +0.7..+1 gives the wide natural opening the
1687
+ * author uses. Matching the author beats my guess.
1688
+ */
1689
+ const MOUTH_FORM_PARAM = "ParamMouthForm";
1690
+
1691
+ /** How far POSITIVE the form is driven at full mouth opening. */
1692
+ const MOUTH_DROP = 0.7;
1693
+
1694
+ /**
1695
+ * Time constant for the mouth easing, in milliseconds.
1696
+ *
1697
+ * The gaze is already smooth because the engine lerps its focus controller,
1698
+ * but the mouth was written straight from the pointer event, so moving in or
1699
+ * out of range snapped it open and shut. ~170ms reads as a reaction rather
1700
+ * than a cut.
1701
+ */
1702
+ const MOUTH_EASE_MS = 170;
1703
+
1704
+ /**
1705
+ * Blinking, driven by US rather than by the engine.
1706
+ *
1707
+ * The engine's own eye blink is gated behind "no motion drove parameters this
1708
+ * frame":
1709
+ *
1710
+ * const motionUpdated = this.updateMotions(coreModel, now)
1711
+ * ... motionUpdated || this.eyeBlink?.updateParameters?.(coreModel, dt)
1712
+ *
1713
+ * Every motion in this model declares Loop:true, and the controller keeps the
1714
+ * idle loop running more or less continuously, so `motionUpdated` is true on
1715
+ * essentially every frame — which means the engine's blink NEVER ran and the
1716
+ * pet simply never blinked.
1717
+ *
1718
+ * So the engine's blink is switched off at load (options.eyeBlink = false) and
1719
+ * reproduced here, at the same per-frame seam as everything else, where no
1720
+ * engine gate can suppress it.
1721
+ */
1722
+ const EYE_L_PARAM = "ParamEyeLOpen";
1723
+ const EYE_R_PARAM = "ParamEyeROpen";
1724
+ /** Gap between blinks: a random interval in this range. */
1725
+ const BLINK_MIN_MS = 2200;
1726
+ const BLINK_MAX_MS = 6400;
1727
+ /** Closing, shut, and opening durations. */
1728
+ const BLINK_CLOSE_MS = 70;
1729
+ const BLINK_HOLD_MS = 45;
1730
+ const BLINK_OPEN_MS = 110;
1731
+
1732
+ /**
1733
+ * How much of the model's mouth range a fully-deflected pointer uses.
1734
+ *
1735
+ * Deliberately not 1: the mouth should read as following the cursor, not as
1736
+ * being permanently wide open whenever the pointer leaves the middle.
1737
+ */
1738
+ const MOUTH_FOLLOW = 0.65;
1739
+
1740
+ const FIDGET_SLOTS = ["rhand", "lhand", "mood", "cheek", "mouth", "eyes"];
1741
+
1742
+ /** Chance that a fidget with the phone out also takes a photo. */
1743
+ const SELFIE_CHANCE = 0.4;
1744
+
1745
+
1746
+ /**
1747
+ * What a head pat may answer with (requirement #5).
1748
+ *
1749
+ * One of these at random, and no blush — the blush is what a tap used to add
1750
+ * unconditionally, which made every pat look identical.
1751
+ */
1752
+ const HEAD_PAT_REACTIONS = [
1753
+ { motion: "Hammer" },
1754
+ { expression: "问号" },
1755
+ { expression: "星星眼" },
1756
+ ];
1757
+
1758
+ /**
1759
+ * How long a session phase keeps replaying its motion.
1760
+ *
1761
+ * "持续播放" — a phase is a STATE, not an event, so a one-shot animation that
1762
+ * drops back to the idle loop the moment it ends reads as "ignored". While a
1763
+ * phase is live the controller re-triggers its motion, so the pet visibly
1764
+ * stays busy for as long as the assistant is.
1765
+ */
1766
+ const PHASE_SUSTAIN_GAP_MS = 200;
1767
+
1768
+ /**
1769
+ * Upper bound on how long any single action may hold the body.
1770
+ *
1771
+ * Requirement #3: everything must eventually fall back to the initial idle
1772
+ * state. Without this, a motion declared `hold: true` (掏出手机 keeps the
1773
+ * phone up) would park the pet in that pose forever, and a pinned expression
1774
+ * would stay on the face until manually cleared.
1775
+ */
1776
+ const ACTION_HOLD_MAX_MS = 9000;
1777
+
1778
+ /** How long a manually pinned expression stays before auto-clearing. */
1779
+ const EXPRESSION_HOLD_MS = 12000;
1780
+
1781
+ /**
1782
+ * Motions that must never be picked as an idle "摸鱼" animation.
1783
+ *
1784
+ * These are the user's own interaction verbs: 重锤出击 is what a tap does and
1785
+ * 鲸鱼喷水 is what a failure does. Letting the random fidget pick them makes
1786
+ * the pet appear to react to a click or an error that never happened, which
1787
+ * is exactly the confusion reported as "摸鱼动画里也会重锤出击".
1788
+ *
1789
+ * A pet may extend this through motionOptions: { "<group>": { "fidget": false } }.
1790
+ */
1791
+ const FIDGET_DENY = ["Hammer", "SprayWater"];
1792
+
1793
+ /**
1794
+ * Drawable-name hints that identify the FACE, used to locate the head.
1795
+ *
1796
+ * 重锤出击 is the "pat the head" reaction, so it must only fire when the click
1797
+ * actually lands on the head — tapping the desk or the body answered with a
1798
+ * hammer swing (requirement #1).
1799
+ *
1800
+ * The model declares no Cubism HitAreas, so the head is derived from its own
1801
+ * drawable geometry instead of a guessed percentage: any drawable whose id
1802
+ * looks like a facial feature is unioned, and the box is grown to cover the
1803
+ * hair and headband sitting above it. That keeps the region correct when the
1804
+ * pet is resized or dragged, because it is measured in MODEL space and mapped
1805
+ * through the live transform at click time.
1806
+ */
1807
+ const HEAD_DRAWABLE_HINTS = /(face|eye|mouth|nose|brow|cheek|head|kao)/i;
1808
+
1809
+ /** How far the face box grows to become the whole head, as a fraction of it. */
1810
+ const HEAD_PAD_SIDE = 0.55;
1811
+ const HEAD_PAD_TOP = 0.85;
1812
+ const HEAD_PAD_BOTTOM = 0.10;
1813
+
1814
+ /**
1815
+ * Session phase -> motion group (#4).
1816
+ *
1817
+ * A pet may override any slot through its manifest's `live2d.motions`, which
1818
+ * uses these same phase keys; anything unmapped simply stays on the idle
1819
+ * loop, so a model without a suitable group degrades quietly.
1820
+ */
1821
+ const PHASE_MOTION = {
1822
+ thinking: "Idle",
1823
+ waiting: "Idle",
1824
+ // NOT Ketchup: that motion drives 蛋包饭 and 挤压 as well as the squeeze, so
1825
+ // it painted omurice and ketchup during every tool call. The tool phase is
1826
+ // carried by the 写本本 sweep instead.
1827
+ tool: "Idle",
1828
+ done: "BubbleGum",
1829
+ failed: "SprayWater",
1830
+ };
1831
+
1832
+ /**
1833
+ * Which session phases replay their motion for as long as they last.
1834
+ *
1835
+ * Only phases that map to a DISTINCTIVE motion are sustained — repeating the
1836
+ * idle loop every few seconds would just look twitchy. 'thinking' and
1837
+ * 'waiting' both rest on the idle loop, which already reads as "alive but
1838
+ * not doing anything", so they are left alone; 'tool' (busy hands), 'done'
1839
+ * (a small celebration) and 'failed' (the whale sprays) each have a real
1840
+ * animation to keep running.
1841
+ */
1842
+ const PHASE_SUSTAIN = ["tool", "done", "failed"];
1843
+
1844
+ /**
1845
+ * Session phase -> expression, layered like a manual expression pin.
1846
+ *
1847
+ * Names are matched against the model's declared Expression `Name`, not its
1848
+ * file name: this pack's 哭.exp3.json is declared as "大哭", so the obvious
1849
+ * "哭" never resolves and the failed phase silently pinned nothing.
1850
+ */
1851
+ /**
1852
+ * Built-in phase -> single expression. Empty on purpose: a phase now drives a
1853
+ * whole LOOK (looksByPhase), and the old defaults fought it — 呆呆眼 for
1854
+ * thinking survived the merge and stayed on screen through every session.
1855
+ * Pets without looksByPhase simply get no phase expression.
1856
+ */
1857
+ const PHASE_EXPRESSION = {};
1858
+
1859
+ /**
1860
+ * How many device pixels the canvas backing store gets per CSS pixel.
1861
+ *
1862
+ * This is the single biggest lever on how the pet looks when it is SHRUNK.
1863
+ * The stage is only 160-760 CSS px but the model's atlas is 2048², so at a
1864
+ * 300px pet every screen pixel is fed by ~7 texture texels — and whatever
1865
+ * the sampler does, the renderer only ever produces 300² samples. Thin line
1866
+ * art therefore lands between sample points and washes out ("线条很虚").
1867
+ *
1868
+ * Rendering at 2x and letting the browser filter the canvas down to its CSS
1869
+ * size is plain super-sampling: 4 render samples per displayed pixel instead
1870
+ * of 1. That is what actually brings the outlines back at small sizes, and
1871
+ * it costs nothing extra at the sizes this pet uses (2x of 300px is 600²,
1872
+ * about a third of a megapixel).
1873
+ *
1874
+ * A HiDPI screen already renders at 2x, so this only raises the floor; the
1875
+ * ceiling stops a 3x display from quadrupling the memory for no gain.
1876
+ */
1877
+ const RENDER_RESOLUTION_MIN = 2;
1878
+ const RENDER_RESOLUTION_MAX = 3;
1879
+
1880
+ /**
1881
+ * Anisotropic filtering level for the model's textures.
1882
+ *
1883
+ * The engine keeps the LOD trim/filter knobs but never applies the sampler
1884
+ * anisotropy from `textureOptions`, so it is set on each texture's style
1885
+ * after load. 8x is ample for line art and costs nothing measurable at the
1886
+ * sizes this pet uses.
1887
+ */
1888
+ const TEXTURE_ANISOTROPY = 8;
1889
+
1890
+ function renderResolution() {
1891
+ const dpr = (typeof window !== "undefined" && window.devicePixelRatio) || 1;
1892
+ return Math.min(RENDER_RESOLUTION_MAX, Math.max(RENDER_RESOLUTION_MIN, dpr));
1893
+ }
1894
+
1895
+ /** Resolution of the opacity grid derived from the rendered character. */
1896
+ const HIT_MASK_SIZE = 64;
1897
+
1898
+ /** Alpha above which a sampled pixel counts as part of the character. */
1899
+ const HIT_MASK_ALPHA = 24;
1900
+
1901
+ /**
1902
+ * Build a coarse opacity grid of the character as actually rendered.
1903
+ *
1904
+ * Cubism hit areas cannot be used here: this model declares none (and the
1905
+ * engine's hitTest leans on the physics hit-testing that only exists when a
1906
+ * model ships them), so a click anywhere in the canvas' transparent margin
1907
+ * would otherwise register. Extracting the model itself gives the true
1908
+ * silhouette for any model, with or without hit areas.
1909
+ *
1910
+ * Returns null when extraction is unavailable, in which case callers fall
1911
+ * back to accepting the whole box.
1912
+ */
1913
+ async function buildHitMask(app, model) {
1914
+ try {
1915
+ const source = app?.canvas;
1916
+ if (source === undefined || source === null || source.width === 0) return null;
1917
+ // The model is drawn inside the stage box; sample exactly its bounds so
1918
+ // the 64x64 grid maps onto the character, not onto empty margins.
1919
+ let bounds;
1920
+ try {
1921
+ bounds = model.getBounds();
1922
+ } catch {
1923
+ bounds = undefined;
1924
+ }
1925
+ const sourceW = source.width;
1926
+ const sourceH = source.height;
1927
+ const rect = bounds === undefined || bounds.width === 0 || bounds.height === 0
1928
+ ? { x: 0, y: 0, width: sourceW, height: sourceH }
1929
+ : bounds;
1930
+ // Model bounds are in logical stage px; the drawing buffer is scaled by
1931
+ // the renderer resolution, so convert before cropping.
1932
+ const ratio = sourceW / Math.max(1, app.renderer.width || sourceW);
1933
+ const sx = Math.max(0, Math.floor(rect.x * ratio));
1934
+ const sy = Math.max(0, Math.floor(rect.y * ratio));
1935
+ const sw = Math.min(sourceW - sx, Math.ceil(rect.width * ratio));
1936
+ const sh = Math.min(sourceH - sy, Math.ceil(rect.height * ratio));
1937
+ if (sw <= 0 || sh <= 0) return null;
1938
+ const canvas = document.createElement("canvas");
1939
+ canvas.width = HIT_MASK_SIZE;
1940
+ canvas.height = HIT_MASK_SIZE;
1941
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
1942
+ if (ctx === null) return null;
1943
+ // The grid spans exactly the model's bounding box, and hitsMask() maps a
1944
+ // stage-local point through the same box, so no aspect math is needed.
1945
+ ctx.drawImage(source, sx, sy, sw, sh, 0, 0, HIT_MASK_SIZE, HIT_MASK_SIZE);
1946
+ const pixels = ctx.getImageData(0, 0, HIT_MASK_SIZE, HIT_MASK_SIZE).data;
1947
+ const data = new Uint8Array(HIT_MASK_SIZE * HIT_MASK_SIZE);
1948
+ let opaque = 0;
1949
+ for (let i = 0; i < data.length; i += 1) {
1950
+ if (pixels[i * 4 + 3] > HIT_MASK_ALPHA) {
1951
+ data[i] = 1;
1952
+ opaque += 1;
1953
+ }
1954
+ }
1955
+ // A mask with almost nothing in it is useless (extraction produced a
1956
+ // blank frame); treat it as "no mask" rather than making the pet inert.
1957
+ if (opaque < data.length * 0.01) return null;
1958
+ // Convert the cropped device-pixel box back into stage-local units.
1959
+ const box = {
1960
+ x: sx / ratio,
1961
+ y: sy / ratio,
1962
+ width: sw / ratio,
1963
+ height: sh / ratio,
1964
+ };
1965
+ return { width: HIT_MASK_SIZE, height: HIT_MASK_SIZE, data, box };
1966
+ } catch {
1967
+ return null;
1968
+ }
1969
+ }
1970
+
1971
+ /**
1972
+ * Build the hit mask once the model has actually painted.
1973
+ *
1974
+ * Reading the drawing buffer immediately after boot yields an empty frame —
1975
+ * the first draw has not been composited yet — so this waits a few animation
1976
+ * frames and retries until the silhouette has pixels, then gives up quietly
1977
+ * (leaving the whole box clickable, which is the safe fallback).
1978
+ */
1979
+ async function buildHitMaskWhenPainted(app, model, isDisposed) {
1980
+ for (let attempt = 0; attempt < 30; attempt += 1) {
1981
+ if (isDisposed()) return null;
1982
+ // eslint-disable-next-line no-await-in-loop -- retries are inherently serial
1983
+ await new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
1984
+ // eslint-disable-next-line no-await-in-loop -- retries are inherently serial
1985
+ const mask = await buildHitMask(app, model);
1986
+ if (mask !== null) return mask;
1987
+ }
1988
+ return null;
1989
+ }
1990
+
1991
+ /**
1992
+ * Relax the gaze to the model's default resting position — the centre of the
1993
+ * stage. Published through a ref because it is needed from the gaze effect,
1994
+ * the layout pass and the drag handler, which live in different scopes.
1995
+ */
1996
+ const focusDefaultRef = { current: () => {} };
1997
+ function focusDefault() {
1998
+ focusDefaultRef.current();
1999
+ }
2000
+
2001
+ /**
2002
+ * Observability: which target the gaze is currently tracking. Published on
2003
+ * the pet root as `data-gaze` ('center' while resting, 'pointer' while the
2004
+ * cursor steers it) so the resting behaviour is directly assertable.
2005
+ */
2006
+ const gazeSinkRef = { current: () => {} };
2007
+ function reportGaze(target) {
2008
+ gazeSinkRef.current(target);
2009
+ }
2010
+
2011
+ function Pet() {
2012
+ const stageRef = useRef(null);
2013
+ const appRef = useRef(null);
2014
+ const modelRef = useRef(null);
2015
+ const rootRef = useRef(null);
2016
+ const sizeRef = useRef(null);
2017
+ const posRef = useRef(null);
2018
+ const bubbleTimer = useRef(0);
2019
+ const greeted = useRef(false);
2020
+ // One controller per mounted pet: it owns the entire motion lifecycle, so
2021
+ // no component callback ever calls model.motion() directly.
2022
+ const motion = useRef(null);
2023
+ if (motion.current === null) motion.current = createMotionController();
2024
+ // Diagnostic seam: the controller is published on window so the clickable
2025
+ // region and internal state can be characterised from a test harness
2026
+ // without reaching through React internals.
2027
+ if (typeof window !== "undefined") window.__dshLive2dPet = motion.current;
2028
+ const pinnedRef = useRef({});
2029
+
2030
+ // Two diagnostics have to be attached from HERE, not from inside the
2031
+ // controller: they read refs that live in this component's scope, and a
2032
+ // controller-scoped copy throws ReferenceError on every call, which shows
2033
+ // up as a silent `undefined` rather than as an error.
2034
+ useEffect(() => {
2035
+ const api = motion.current;
2036
+ api.slotSelections = () => slotSelectionsRef.current;
2037
+ api.fidgetNow = () => fidgetRef.current();
2038
+ // Same reason as the two above: fidgetTally lives in this component's
2039
+ // scope, and a controller-scoped copy throws ReferenceError on every call
2040
+ // — which surfaces as a silent `undefined`, not as an error.
2041
+ api.fidgetReady = () => fidgetLiveRef.current;
2042
+ // Drivers that assert "this state stays put" call setFidgetEnabled(false)
2043
+ // first; otherwise a 摸鱼 can rewrite the state mid-assertion.
2044
+ api.setFidgetEnabled = (on) => { fidgetEnabledRef.current = on !== false; };
2045
+ api.fidgetEnabled = () => fidgetEnabledRef.current;
2046
+ api.fidgetTally = () => fidgetTallyRef.current;
2047
+ api.resetFidgetTally = () => { fidgetTallyRef.current.picked = {}; fidgetTallyRef.current.drawn = {}; };
2048
+ }, []);
2049
+ /**
2050
+ * The pins the USER owns (slot choices, flashes) and the pins the SESSION
2051
+ * phase imposes, kept apart so a phase can drive the look without destroying
2052
+ * the user's outfit, and give it back when the phase ends.
2053
+ */
2054
+ const userPinsRef = useRef({});
2055
+ const phasePinsRef = useRef({});
2056
+ /**
2057
+ * The motion the user's CURRENT slot selection owns, if any.
2058
+ *
2059
+ * Tracked explicitly rather than inferred from the pin set: a motion-only
2060
+ * option (掏出手机) has no expressions, so "all of its expressions are
2061
+ * pinned" is vacuously true for it and it would match every time — which
2062
+ * parked the phone forever after any fidget.
2063
+ */
2064
+ /** Fires one idle fidget immediately; used by the panel and by tests. */
2065
+ const fidgetRef = useRef(() => {});
2066
+ /** Set when the fidget effect has actually installed its trigger. */
2067
+ const fidgetLiveRef = useRef(false);
2068
+ /** Draw counts, for working out whether the weighting itself is wrong. */
2069
+ // A REF, not a plain object: a plain one is rebuilt on every render, so the
2070
+ // API attached in a [] effect and the fire() closure in a [ready, pet] effect
2071
+ // would end up mutating two different objects, and the tally would read 0
2072
+ // forever while the fidget worked perfectly.
2073
+ const fidgetTallyRef = useRef({ picked: {}, drawn: {} });
2074
+ /** Whether the SCHEDULED fidget may run. Forced calls ignore it. */
2075
+ const fidgetEnabledRef = useRef(true);
2076
+ const slotMotionRef = useRef(null);
2077
+ /** slot id -> chosen option label, for the panel highlight and diagnostics. */
2078
+ const slotSelectionsRef = useRef({});
2079
+ /**
2080
+ * Procedural sweeps, layered like the pins: what the user's slots ask for,
2081
+ * and what a live session phase asks for (the phase wins while it lasts).
2082
+ */
2083
+ const userSweepRef = useRef(null);
2084
+ const phaseSweepRef = useRef(null);
2085
+ /** Slot ids the live phase owns; their user pins are dropped while it lasts. */
2086
+ const phaseSlotsRef = useRef([]);
2087
+ const slotByIdRef = useRef(new Map());
2088
+ /** motion group -> premise, from the manifest. */
2089
+ const guardsRef = useRef({});
2090
+ /** slot id -> option label the USER chose, and the phase's own picks. */
2091
+ const phaseChoicesRef = useRef({});
2092
+ const applySweep = useCallback(() => {
2093
+ motion.current.setSweep(phaseSweepRef.current ?? userSweepRef.current);
2094
+ }, []);
2095
+ /** Commit both layers; the phase wins while it lasts. */
2096
+ const commitPinsRef = useRef(() => {});
2097
+ /** Late-bound handle to applyExpressions, which is declared further down. */
2098
+ const applyExpressionsRef = useRef(() => {});
2099
+ // The pinned-expression set lives in the component, not the controller, so
2100
+ // expose it on the same diagnostic seam; otherwise a test can only see it
2101
+ // through the panel's chips, which do not exist while the panel is closed.
2102
+ if (typeof window !== "undefined") {
2103
+ window.__dshLive2dPet.expressions = () => Object.keys(pinnedRef.current);
2104
+ // Programmatic pin set, for diagnostics and the regression suite. It goes
2105
+ // through the same funnel as the panel, so slot rules apply identically.
2106
+ window.__dshLive2dPet.setExpressions = (names) => {
2107
+ const next = {};
2108
+ for (const name of names || []) next[name] = true;
2109
+ applyExpressionsRef.current(next);
2110
+ };
2111
+ }
2112
+ const [motionGroup, setMotionGroup] = useState("");
2113
+
2114
+ const [catalog, setCatalog] = useState(null);
2115
+ const [error, setError] = useState(null);
2116
+ const [coreMissing, setCoreMissing] = useState(false);
2117
+ const [ready, setReady] = useState(false);
2118
+ const [bubble, setBubble] = useState(null);
2119
+ const [panelOpen, setPanelOpen] = useState(false);
2120
+ /**
2121
+ * Viewport coordinates the panel was pinned at, captured once when it opens.
2122
+ *
2123
+ * Measured on open and never again: the whole point is that resizing the pet
2124
+ * must not move the panel the size slider lives in.
2125
+ */
2126
+ const [panelBox, setPanelBox] = useState(null);
2127
+
2128
+ useEffect(() => {
2129
+ if (!panelOpen) {
2130
+ setPanelBox(null);
2131
+ return undefined;
2132
+ }
2133
+ // One frame after it appears, so the panel has been laid out.
2134
+ const id = window.requestAnimationFrame(() => {
2135
+ const root = rootRef.current;
2136
+ if (root === null) return;
2137
+ const el = root.querySelector("[data-panel]");
2138
+ if (el === null) return;
2139
+ const rect = el.getBoundingClientRect();
2140
+ const margin = 8;
2141
+ setPanelBox({
2142
+ left: Math.max(margin, Math.min(rect.left, window.innerWidth - rect.width - margin)),
2143
+ top: Math.max(margin, Math.min(rect.top, window.innerHeight - rect.height - margin)),
2144
+ });
2145
+ });
2146
+ return () => window.cancelAnimationFrame(id);
2147
+ }, [panelOpen]);
2148
+ const [tab, setTab] = useState("motions");
2149
+
2150
+ const [pinned, setPinned] = useState({});
2151
+ const [dragging, setDragging] = useState(false);
2152
+ /**
2153
+ * 宠物最多能往视口下边沉多少。
2154
+ *
2155
+ * 下界原来是 0(脚一贴到屏幕底边就不许再往下)。可模型的画布有透明边距,
2156
+ * 角色看起来是"悬空"的,用户要把它再往下压一点、让脚真的压出屏幕底边。
2157
+ * 按尺寸取比例:大的宠物能压出去更多,小的不至于被推没。
2158
+ */
2159
+ const BOTTOM_OVERHANG_RATIO = 0.4;
2160
+ const BOTTOM_OVERHANG_MAX = 400;
2161
+ const clampBottom = (value, width) =>
2162
+ Math.max(-Math.round(width * BOTTOM_OVERHANG_RATIO), Math.min(window.innerHeight - 60, value));
2163
+
2164
+ const [petId, setPetId] = useState(() => loadStored().petId);
2165
+ const [size, setSize] = useState(() => {
2166
+ const stored = loadStored().size;
2167
+ return typeof stored === "number" && stored >= MIN_SIZE && stored <= MAX_SIZE ? stored : DEFAULT_SIZE;
2168
+ });
2169
+ const [pos, setPos] = useState(() => {
2170
+ const stored = loadStored();
2171
+ return {
2172
+ right: typeof stored.right === "number" ? Math.max(0, stored.right) : 24,
2173
+ // 存档里可能是负的(用户把它压到了屏幕下边),别把它夹回 0。
2174
+ bottom: typeof stored.bottom === "number" ? Math.max(-BOTTOM_OVERHANG_MAX, stored.bottom) : 0,
2175
+ };
2176
+ });
2177
+
2178
+ sizeRef.current = size;
2179
+ posRef.current = pos;
2180
+
2181
+ const pet = catalog !== null && catalog.pets.length > 0
2182
+ ? (catalog.pets.find((entry) => entry.id === petId) ?? catalog.pets[0])
2183
+ : undefined;
2184
+
2185
+ const say = useCallback((text) => {
2186
+ setBubble(text);
2187
+ window.clearTimeout(bubbleTimer.current);
2188
+ bubbleTimer.current = window.setTimeout(() => setBubble(null), 4200);
2189
+ }, []);
2190
+
2191
+ useEffect(() => () => window.clearTimeout(bubbleTimer.current), []);
2192
+
2193
+ // The controller owns the motion lifecycle; the panel highlights whatever
2194
+ // group it is currently playing and clears the highlight on idle.
2195
+ useEffect(() => {
2196
+ const controller = motion.current;
2197
+ controller.setExpressionApplier(() => {
2198
+ const model = modelRef.current;
2199
+ if (model === null) return;
2200
+ const names = Object.keys(pinnedRef.current);
2201
+ if (names.length > 0) void model.expression(names[names.length - 1]);
2202
+ });
2203
+ controller.subscribe((group) => {
2204
+ setMotionGroup(group === null ? "" : group);
2205
+ // Back at rest: flush a phase that had to wait for the body.
2206
+ if (group === null && pendingPhaseRef.current !== null) {
2207
+ const next = pendingPhaseRef.current;
2208
+ pendingPhaseRef.current = null;
2209
+ flushPhaseRef.current(next);
2210
+ }
2211
+ });
2212
+ return () => {
2213
+ controller.subscribe(null);
2214
+ controller.setExpressionApplier(null);
2215
+ };
2216
+ }, []);
2217
+
2218
+ // ---- catalog ------------------------------------------------------
2219
+ useEffect(() => {
2220
+ let alive = true;
2221
+ fetch(API + "/catalog").then(
2222
+ (response) => {
2223
+ if (!response.ok) throw new Error("catalog HTTP " + response.status);
2224
+ return response.json();
2225
+ },
2226
+ ).then((value) => {
2227
+ if (!alive) return;
2228
+ setCatalog(value);
2229
+ setPetId((current) => (
2230
+ value.pets.length === 0 || value.pets.some((entry) => entry.id === current)
2231
+ ? current
2232
+ : value.pets[0].id
2233
+ ));
2234
+ }, (reason) => {
2235
+ if (alive) setError(String((reason && reason.message) || reason));
2236
+ });
2237
+ return () => { alive = false; };
2238
+ }, []);
2239
+
2240
+ // ---- model boot ---------------------------------------------------
2241
+ useEffect(() => {
2242
+ if (catalog === null || pet === undefined) return undefined;
2243
+ const stage = stageRef.current;
2244
+ if (stage === null) return undefined;
2245
+ let disposed = false;
2246
+ let app;
2247
+ let model;
2248
+
2249
+ // Per-pet phase overrides: the manifest's live2d.motions/expressions use
2250
+ // the same phase keys, so a model can retarget any slot. Unset slots keep
2251
+ // the built-in defaults.
2252
+ phaseMotionRef.current = Object.assign({}, PHASE_MOTION, pet.motionsByPhase || {});
2253
+ phaseExpressionRef.current = Object.assign({}, PHASE_EXPRESSION, pet.expressionsByPhase || {});
2254
+ looksByPhaseRef.current = pet.looksByPhase || {};
2255
+ slotByIdRef.current = new Map((pet.expressionSlots ?? []).map((slot) => [slot.id, slot]));
2256
+ guardsRef.current = pet.motionGuards || {};
2257
+ phaseRef.current = "idle";
2258
+
2259
+ fitRef.scale = typeof pet.scale === "number" && pet.scale > 0 ? pet.scale : 1;
2260
+ fitRef.x = typeof pet.translate?.x === "number" ? pet.translate.x : 0;
2261
+ fitRef.y = typeof pet.translate?.y === "number" ? pet.translate.y : 0;
2262
+
2263
+ // The model's UNSCALED size, captured once at load while scale is still
2264
+ // 1. It is essential that the fit is derived from this and never from
2265
+ // model.width/height: Pixi's Container.width getter reports the size at
2266
+ // the CURRENT scale, so using it as the fit input makes every layout
2267
+ // multiply the previous scale by itself again — which is why merely
2268
+ // opening the panel (one relayout) blew the pet up dramatically.
2269
+ let source = null;
2270
+
2271
+ const layout = () => {
2272
+ const currentApp = appRef.current;
2273
+ const currentModel = modelRef.current;
2274
+ if (currentApp === undefined || currentApp === null || currentModel === null || source === null) return;
2275
+ const rect = stage.getBoundingClientRect();
2276
+ const width = Math.max(1, Math.round(rect.width));
2277
+ const height = Math.max(1, Math.round(rect.height));
2278
+ // Logical size in CSS px; the renderer's resolution (set at init) keeps
2279
+ // the backing store at device-pixel density so scaling stays crisp.
2280
+ currentApp.renderer.resize(width, height);
2281
+ const fit = Math.min(width / source.width, height / source.height) * 0.94;
2282
+ currentModel.anchor.set(0.5, 0.5);
2283
+ currentModel.scale.set(fit * fitRef.scale);
2284
+ currentModel.position.set(width / 2 + fitRef.x, height / 2 + fitRef.y);
2285
+ // Keep the gaze anchored to the model's own centre after a resizeso a
2286
+ // stale pointer position cannot leave it staring off-frame.
2287
+ focusDefault();
2288
+ };
2289
+ layoutRef.current = layout;
2290
+
2291
+ const boot = async () => {
2292
+ if (!await ensureCore(catalog.coreUrl)) {
2293
+ if (!disposed) setCoreMissing(true);
2294
+ return;
2295
+ }
2296
+ if (disposed) return;
2297
+ setCoreMissing(false);
2298
+ const vendor = await ensureVendor(catalog.vendorUrl);
2299
+ if (disposed) return;
2300
+ if (vendor === undefined) throw new Error("vendor bundle unavailable");
2301
+ configureVendor(vendor);
2302
+
2303
+ const nextApp = new vendor.Application();
2304
+ const rect = stage.getBoundingClientRect();
2305
+ // resolution = max(2, DPR) with autoDensity off: the backing store is
2306
+ // sized in device pixels by Pixi, while the CSS size is still driven by
2307
+ // our own 100%/100% rule. That is what keeps a large or upscaled pet
2308
+ // sharp instead of a stretched 1x bitmap, and the 2x floor doubles the
2309
+ // samples available for a small pet (see RENDER_RESOLUTION_MIN).
2310
+ await nextApp.init({
2311
+ width: Math.max(1, Math.round(rect.width)),
2312
+ height: Math.max(1, Math.round(rect.height)),
2313
+ backgroundAlpha: 0,
2314
+ antialias: true,
2315
+ autoDensity: false,
2316
+ resolution: renderResolution(),
2317
+ preference: "webgl",
2318
+ // The rendered frame must stay readable so the character's
2319
+ // silhouette can be sampled for click hit-testing (see
2320
+ // buildHitMask). Without this the drawing buffer is cleared after
2321
+ // compositing and every readback comes back empty.
2322
+ preserveDrawingBuffer: true,
2323
+ });
2324
+ if (disposed) {
2325
+ nextApp.destroy({ removeView: true }, { children: true });
2326
+ return;
2327
+ }
2328
+ app = nextApp;
2329
+ appRef.current = nextApp;
2330
+ app.canvas.style.width = "100%";
2331
+ app.canvas.style.height = "100%";
2332
+ stage.appendChild(app.canvas);
2333
+
2334
+ const loaded = await vendor.Live2DModel.from(pet.modelUrl, {
2335
+ autoUpdate: false,
2336
+ autoHitTest: true,
2337
+ autoFocus: false,
2338
+ // The engine's blink is gated behind "no motion drove parameters this
2339
+ // frame", and this model's idle loop runs continuously — so its gate
2340
+ // never opened and the pet never blinked. Blinking is driven by this
2341
+ // plugin instead; leaving the engine's on as well would double up on
2342
+ // whatever frames its gate did happen to open.
2343
+ eyeBlink: false,
2344
+ // Textures stay at full resolution and are minified by a real mip
2345
+ // chain instead of the engine's LOD copies.
2346
+ //
2347
+ // The model ships a 2048x2048 atlas that is drawn at ~160-760 CSS
2348
+ // px, so it is minified 3-12x. Two things were wrong before:
2349
+ //
2350
+ // * 'single-auto' only kicks in below effectiveScale 0.5 and then
2351
+ // swaps the texture for ONE 2^n-divided copy — at a 300px pet
2352
+ // effectiveScale is ~0.59, so that branch never even fired and
2353
+ // the 2048px atlas was point-sampled straight down to 300px,
2354
+ // throwing away 6 of every 7 texels. That is the shimmer and the
2355
+ // washed-out ("虚") thin linework.
2356
+ // * 'lod: false' is not "keep the full texture": the engine only
2357
+ // asks the asset loader for a mip chain when lod === "full", so
2358
+ // lod:false gives a full-res texture with NO mipmaps — the worst
2359
+ // of both worlds under minification.
2360
+ //
2361
+ // "full" is the setting that actually builds the mip chain (feeding
2362
+ // every level to GL), while still leaving the trim/filter LOD knobs
2363
+ // at their defaults. Anisotropy then keeps the diagonals of the line
2364
+ // art from smearing at grazing angles.
2365
+ textureOptions: { lod: "full" },
2366
+ });
2367
+ // Only `lod` is forwarded to the asset loader, so the sampler style has
2368
+ // to be applied to the live texture sources afterwards. Anisotropic
2369
+ // filtering is what keeps the diagonals of the line art (bangs, ribbon
2370
+ // edges) from smearing into a soft blur when the surface is at a
2371
+ // grazing angle to the screen.
2372
+ for (const texture of loaded.textures ?? []) {
2373
+ const style = texture?.source?.style;
2374
+ if (style === undefined || style === null) continue;
2375
+ style.maxAnisotropy = TEXTURE_ANISOTROPY;
2376
+ }
2377
+ if (disposed) {
2378
+ loaded.destroy({ children: true });
2379
+ return;
2380
+ }
2381
+ model = loaded;
2382
+ modelRef.current = loaded;
2383
+ app.stage.addChild(loaded);
2384
+ // Capture the intrinsic geometry now, before any scaling is applied.
2385
+ const intrinsic = loaded.internalModel;
2386
+ source = {
2387
+ width: Math.max(1, intrinsic?.originalWidth || loaded.width),
2388
+ height: Math.max(1, intrinsic?.originalHeight || loaded.height),
2389
+ };
2390
+ layout();
2391
+ loaded.automator.autoUpdate = true;
2392
+ motion.current.attach(vendor, loaded, pet.motions, pet.motionOptions);
2393
+ setReady(true);
2394
+ // Derive the clickable silhouette from the first rendered frame. This
2395
+ // runs after ready so the panel and pet are usable even if extraction
2396
+ // is slow, and a failure simply leaves the whole box clickable.
2397
+ const refreshMask = async () => {
2398
+ const mask = await buildHitMaskWhenPainted(app, loaded, () => disposed);
2399
+ if (disposed) return;
2400
+ if (mask === null) motion.current.setHitMask(null, null);
2401
+ else motion.current.setHitMask(mask, mask.box);
2402
+ // Publish the silhouette for the hit-through proxy. An empty string
2403
+ // means "no mask": the proxy stays hidden and behaves like before.
2404
+ setMaskPath(motion.current.maskPath() ?? "");
2405
+ };
2406
+ rebuildMaskRef.current = refreshMask;
2407
+ void refreshMask();
2408
+ };
2409
+
2410
+ boot().catch((reason) => {
2411
+ if (!disposed) {
2412
+ setError(String((reason && reason.message) || reason));
2413
+ say(pick(LINES.loadFailed));
2414
+ }
2415
+ });
2416
+
2417
+ return () => {
2418
+ disposed = true;
2419
+ motion.current.detach();
2420
+ layoutRef.current = null;
2421
+ rebuildMaskRef.current = null;
2422
+ appRef.current = null;
2423
+ modelRef.current = null;
2424
+ setReady(false);
2425
+ const currentApp = app;
2426
+ const currentModel = model;
2427
+ app = undefined;
2428
+ model = undefined;
2429
+ if (currentApp !== undefined) {
2430
+ try { currentApp.destroy({ removeView: true }, { children: true }); } catch { /* partial boot */ }
2431
+ } else if (currentModel !== undefined) {
2432
+ // A model that finished loading before its app existed is still ours
2433
+ // to release; the app-owned path is handled by the app destroy above.
2434
+ try { currentModel.destroy({ children: true }); } catch { /* partial boot */ }
2435
+ }
2436
+ };
2437
+ }, [catalog, pet, say]);
2438
+
2439
+ // ---- resize -------------------------------------------------------
2440
+ // A resized pet moves and rescales the model, so the silhouette captured
2441
+ // at boot no longer lines up with the clickable area. Re-derive it after
2442
+ // the layout settles (debounced: a drag-resize fires many times).
2443
+ useEffect(() => {
2444
+ const layout = layoutRef.current;
2445
+ if (layout !== null) layout();
2446
+ const timer = window.setTimeout(() => {
2447
+ const rebuild = rebuildMaskRef.current;
2448
+ if (rebuild !== null) void rebuild();
2449
+ }, 250);
2450
+ return () => window.clearTimeout(timer);
2451
+ }, [size, panelOpen]);
2452
+
2453
+ useEffect(() => {
2454
+ const stage = stageRef.current;
2455
+ if (stage === null || typeof ResizeObserver === "undefined") return undefined;
2456
+ const observer = new ResizeObserver(() => {
2457
+ const layout = layoutRef.current;
2458
+ if (layout !== null) layout();
2459
+ });
2460
+ observer.observe(stage);
2461
+ return () => observer.disconnect();
2462
+ }, []);
2463
+
2464
+ // ---- greeting -----------------------------------------------------
2465
+ useEffect(() => {
2466
+ if (!ready || greeted.current) return;
2467
+ greeted.current = true;
2468
+ say(pick(LINES.greet));
2469
+ }, [ready, say]);
2470
+
2471
+
2472
+ // ---- mouse tracking -----------------------------------------------
2473
+ // Gaze is driven only while the pointer is in or near the stage, and relaxes
2474
+ // to the model's DEFAULT resting position — its own centre, not wherever the
2475
+ // pointer happened to be last — the moment it leaves that neighbourhood.
2476
+ useEffect(() => {
2477
+ if (!ready) return undefined;
2478
+ const stage = stageRef.current;
2479
+ if (stage === null) return undefined;
2480
+ // The resting target is the stage centre, i.e. where the model sits.
2481
+ focusDefaultRef.current = () => {
2482
+ const rect = stage.getBoundingClientRect();
2483
+ // The DEFAULT resting target is the model's own centre — not the last
2484
+ // pointer position — so the pet always settles back to a neutral gaze.
2485
+ motion.current.updatePointer(rect.width / 2, rect.height / 2, rect.width, rect.height);
2486
+ reportGaze("center");
2487
+ };
2488
+ let resting = false;
2489
+ focusDefault();
2490
+ resting = true;
2491
+ const onMove = (event) => {
2492
+ const rect = stage.getBoundingClientRect();
2493
+ const x = event.clientX - rect.left;
2494
+ const y = event.clientY - rect.top;
2495
+ const near = x >= -GAZE_RANGE && y >= -GAZE_RANGE
2496
+ && x <= rect.width + GAZE_RANGE && y <= rect.height + GAZE_RANGE;
2497
+ if (near) {
2498
+ resting = false;
2499
+ motion.current.updatePointer(x, y, rect.width, rect.height);
2500
+ reportGaze("pointer");
2501
+ } else if (!resting) {
2502
+ resting = true;
2503
+ focusDefault();
2504
+ }
2505
+ };
2506
+ window.addEventListener("pointermove", onMove, { passive: true });
2507
+ return () => {
2508
+ window.removeEventListener("pointermove", onMove);
2509
+ focusDefaultRef.current = () => {};
2510
+ };
2511
+ }, [ready]);
2512
+
2513
+ // ---- imperative actions -------------------------------------------
2514
+ // Every manual play is a one-shot through the controller: it stops the
2515
+ // previous motion, forces the new one past the priority gate, and returns
2516
+ // to idle afterwards even when the motion is flagged Loop.
2517
+ const playMotion = useCallback((group, index) => {
2518
+ motion.current.playOnce(group, index, { kind: "panel" });
2519
+ }, []);
2520
+
2521
+ // The pinned expression is re-layered after every motion start: a motion
2522
+ // resets expression parameters as it takes over, so a pinned face would
2523
+ // otherwise be wiped the moment the pet plays a reaction.
2524
+ const applyExpressions = useCallback((next) => {
2525
+ setPinned(next);
2526
+ pinnedRef.current = next;
2527
+ const model = modelRef.current;
2528
+ if (model === null) return;
2529
+ // The engine's own expression pass is deliberately NOT used, in either
2530
+ // the single or the multi case.
2531
+ //
2532
+ // Its manager holds exactly ONE expression, so pinning several would
2533
+ // render only the last. Worse, an earlier attempt to hand it a synthetic
2534
+ // merged definition made the fade start and then collapse, rendering
2535
+ // nothing at all. Writing the parameters ourselves has neither problem,
2536
+ // and it is the same arithmetic: every expression in this model blends
2537
+ // with "Add" on top of the motion output.
2538
+ //
2539
+ // Clear the engine's expression anyway, so a pin applied before this
2540
+ // change (or by another code path) cannot keep writing its own values.
2541
+ model.internalModel?.motionManager?.expressionManager?.resetExpression?.();
2542
+ }, []);
2543
+ applyExpressionsRef.current = applyExpressions;
2544
+
2545
+ /**
2546
+ * Push both pin layers to the model: the user's own choices, with the live
2547
+ * session phase layered on top.
2548
+ *
2549
+ * The phase wins while it lasts because the session is what the pet is meant
2550
+ * to be mirroring; when the phase ends its layer is emptied and the user's
2551
+ * outfit comes straight back, without having been destroyed in between.
2552
+ */
2553
+ /**
2554
+ * 装扮槽:用户"穿在身上"的东西,不是这一轮的临时效果。
2555
+ *
2556
+ * 三条规矩,都是用户定的:会话相位不动它们、归位不清它们、跨启动记住它们。
2557
+ */
2558
+ const OUTFIT_SLOTS = ["glasses", "hair", "claw", "desk", "cloth", "other"];
2559
+ const OUTFIT_KEY = "dsh-live2d-pet:outfit";
2560
+ /** 把当前装扮翻译成表达式 pin(相位覆盖不了它们,因为最后才合并)。 */
2561
+ const outfitPins = () => {
2562
+ const pins = {};
2563
+ for (const id of OUTFIT_SLOTS) {
2564
+ const label = slotSelectionsRef.current[id];
2565
+ if (label === undefined) continue;
2566
+ const option = slotByIdRef.current.get(id)?.options.find((o) => o.label === label);
2567
+ for (const name of option?.expressions ?? []) pins[name] = true;
2568
+ for (const name of option?.requires ?? []) pins[name] = true;
2569
+ }
2570
+ return pins;
2571
+ };
2572
+ const saveOutfit = () => {
2573
+ try {
2574
+ const out = {};
2575
+ for (const id of OUTFIT_SLOTS) {
2576
+ const label = slotSelectionsRef.current[id];
2577
+ if (label !== undefined) out[id] = label;
2578
+ }
2579
+ window.localStorage.setItem(OUTFIT_KEY, JSON.stringify(out));
2580
+ } catch {
2581
+ /* 无痕模式之类存不下:不影响这次,只是下次不记得 */
2582
+ }
2583
+ };
2584
+ const readOutfit = () => {
2585
+ try {
2586
+ const parsed = JSON.parse(window.localStorage.getItem(OUTFIT_KEY) ?? "null");
2587
+ return parsed !== null && typeof parsed === "object" ? parsed : null;
2588
+ } catch {
2589
+ return null;
2590
+ }
2591
+ };
2592
+
2593
+ commitPinsRef.current = () => {
2594
+ const merged = Object.assign({}, userPinsRef.current);
2595
+ // A phase owns the slots it names. Overriding key-by-key is not enough:
2596
+ // 蛋包饭 and 画笔 are DIFFERENT expressions, so a user-chosen 蛋包饭 would
2597
+ // stay pinned through the whole session and put omurice on screen.
2598
+ for (const slotId of phaseSlotsRef.current) {
2599
+ // 装扮槽归用户:会话相位不碰眼镜/发饰/魔爪/巴菲/桌布/手机换色。
2600
+ if (OUTFIT_SLOTS.indexOf(slotId) !== -1) continue;
2601
+ const slot = slotByIdRef.current.get(slotId);
2602
+ for (const option of slot?.options ?? []) {
2603
+ for (const name of option.expressions) delete merged[name];
2604
+ }
2605
+ }
2606
+ // 装扮最后合并:相位即使点名了这些槽位,也压不过用户自己的选择。
2607
+ applyExpressions(Object.assign(merged, phasePinsRef.current, outfitPins()));
2608
+ };
2609
+
2610
+ /**
2611
+ * Arm the auto-clear for a MANUALLY chosen expression.
2612
+ *
2613
+ * Requirement #3: a face or prop the user picked must not stay on forever.
2614
+ * Phase-driven expressions deliberately do not use this — the session
2615
+ * stream owns them and clears them when the phase changes.
2616
+ */
2617
+ const armExpressionClear = useCallback(() => {
2618
+ window.clearTimeout(expressionTimer.current);
2619
+ expressionTimer.current = window.setTimeout(() => {
2620
+ expressionTimer.current = 0;
2621
+ // Only clear if the face still is what we pinned; a later phase may
2622
+ // have replaced it already.
2623
+ // Only the user's own layer expires; a live phase owns its own face.
2624
+ if (Object.keys(userPinsRef.current).length > 0) {
2625
+ userPinsRef.current = {};
2626
+ commitPinsRef.current();
2627
+ }
2628
+ }, EXPRESSION_HOLD_MS);
2629
+ }, [applyExpressions]);
2630
+
2631
+ /**
2632
+ * Show an expression for a moment without toggling it.
2633
+ *
2634
+ * Used by reactions (a head pat blushes). The panel does not toggle
2635
+ * expressions any more — every effect is a slot choice that persists — so
2636
+ * this is the only path that shows a face and hands it back on a timer.
2637
+ */
2638
+ const flashExpression = useCallback((expressionName) => {
2639
+ const next = Object.assign({}, userPinsRef.current, { [expressionName]: true });
2640
+ userPinsRef.current = next;
2641
+ commitPinsRef.current();
2642
+ armExpressionClear();
2643
+ }, [armExpressionClear]);
2644
+
2645
+ /**
2646
+ * Choose an option within one dress-up slot.
2647
+ *
2648
+ * Every other slot keeps its choice — that is the whole point of the slots,
2649
+ * and it works because the controller layers the parameter writes instead
2650
+ * of asking the engine (which holds a single expression) to switch.
2651
+ * The 'none' option clears just this slot.
2652
+ */
2653
+ /** Latest pet, for callbacks that must not re-subscribe on every catalog change. */
2654
+ const petRef = useRef(undefined);
2655
+ petRef.current = pet;
2656
+ const chooseSlotOptionRef = useRef(() => {});
2657
+ const chooseSlotOption = useCallback((slot, option) => {
2658
+ const next = Object.assign({}, pinnedRef.current);
2659
+ for (const candidate of slot.options) {
2660
+ for (const name of candidate.expressions) delete next[name];
2661
+ }
2662
+ if (option !== null) {
2663
+ for (const name of option.expressions) next[name] = true;
2664
+ // 'requires' are forced on even when another slot owns them: 挤番茄酱 is a
2665
+ // right-hand action whose 蛋包饭 base lives in the left-hand slot. The
2666
+ // panel then shows that slot as 蛋包饭 because the pin is there, not
2667
+ // because this code touched the slot.
2668
+ for (const name of option.requires ?? []) next[name] = true;
2669
+ }
2670
+ // 'pairs' and 'breaks' reach across slots, so they are resolved here rather
2671
+ // than in the fidget: choosing 喵喵手 from the PANEL must pull the cat
2672
+ // sticker in just the same, and choosing any other hand pose must take it
2673
+ // off. Both loop until stable, so a pair that triggers another settles.
2674
+ if (option !== null || option === null) {
2675
+ const slots = petRef.current?.expressionSlots ?? [];
2676
+ const applyLabel = (slotId, label, wanted) => {
2677
+ const target = slots.find((s) => s.id === slotId);
2678
+ if (target === undefined) return;
2679
+ for (const candidate of target.options) {
2680
+ // Only the NAMED option is turned on. This used to set every option's
2681
+ // expressions when wanted was true, so pairing 爱心眼 -> 冒爱心 also
2682
+ // switched on 心跳 and 情绪花花: three ambient effects at once.
2683
+ const isNamed = wanted && candidate.label === label;
2684
+ for (const name of candidate.expressions) {
2685
+ if (isNamed) next[name] = true;
2686
+ else delete next[name];
2687
+ }
2688
+ if (!isNamed) for (const name of candidate.requires ?? []) delete next[name];
2689
+ }
2690
+ const chosen = Object.assign({}, slotSelectionsRef.current);
2691
+ if (wanted) chosen[slotId] = label;
2692
+ else delete chosen[slotId];
2693
+ slotSelectionsRef.current = chosen;
2694
+ saveOutfit();
2695
+ if (!wanted && typeof target.options.find((o) => o.label === label)?.motion === "string") {
2696
+ slotMotionRef.current = null;
2697
+ }
2698
+ };
2699
+ // Choosing "none" applies the slot's UNION of breaks: leaving the hand
2700
+ // empty must take the cat sticker off just as any other hand pose does,
2701
+ // otherwise the sticker stays on with no cat paws to justify it.
2702
+ // Undo whatever this slot's PREVIOUS option paired in. Choosing 爱心眼
2703
+ // pulls 冒爱心 in; going back to 默认 eyes has to let it go again.
2704
+ const previousLabel = slotSelectionsRef.current[slot.id];
2705
+ if (previousLabel !== undefined) {
2706
+ const previous = slot.options.find((o) => o.label === previousLabel);
2707
+ for (const pairedId of Object.keys(previous?.pairs ?? {})) applyLabel(pairedId, "", false);
2708
+ }
2709
+ const sources = option === null
2710
+ ? slot.options
2711
+ : [option];
2712
+ for (const source of sources) {
2713
+ for (const [slotId, label] of Object.entries(source.pairs ?? {})) {
2714
+ if (option !== null) applyLabel(slotId, label, true);
2715
+ }
2716
+ for (const label of source.breaks ?? []) {
2717
+ for (const other of slots) {
2718
+ if (other.options.some((o) => o.label === label)) applyLabel(other.id, label, false);
2719
+ }
2720
+ }
2721
+ }
2722
+ }
2723
+ // An option may name labels it cannot coexist with. Nothing in the engine
2724
+ // enforces this: 吐魂 and 吹泡泡糖 write disjoint parameters, so both would
2725
+ // simply render — one mouth doing two things. Declared symmetrically on
2726
+ // both sides, so picking either drops the other, including its motion.
2727
+ if (option !== null) {
2728
+ for (const label of option.conflicts ?? []) {
2729
+ for (const other of petRef.current?.expressionSlots ?? []) {
2730
+ const rival = other.options.find((o) => o.label === label);
2731
+ if (rival === undefined) continue;
2732
+ if (slotSelectionsRef.current[other.id] !== label) continue;
2733
+ for (const candidate of other.options) {
2734
+ for (const name of candidate.expressions) delete next[name];
2735
+ for (const name of candidate.requires ?? []) delete next[name];
2736
+ }
2737
+ // Deleted IN PLACE: the code below re-reads this ref to record the
2738
+ // new choice, so replacing it with a copy here would simply be
2739
+ // overwritten and the rival would come straight back.
2740
+ delete slotSelectionsRef.current[other.id];
2741
+ if (typeof rival.motion === "string" && slotMotionRef.current === rival.motion) {
2742
+ slotMotionRef.current = null;
2743
+ }
2744
+ }
2745
+ }
2746
+ }
2747
+ // A 'clears' option needs other slots emptied first (写本本 wants the left
2748
+ // hand free), so drop their expressions before applying this one.
2749
+ if (option !== null) {
2750
+ for (const slotId of option.clears ?? []) {
2751
+ const target = (petRef.current?.expressionSlots ?? []).find((s) => s.id === slotId);
2752
+ for (const candidate of target?.options ?? []) {
2753
+ for (const name of candidate.expressions) delete next[name];
2754
+ for (const name of candidate.requires ?? []) delete next[name];
2755
+ }
2756
+ }
2757
+ }
2758
+ applyExpressions(next);
2759
+ // A motion attached to a slot plays and PARKS on its last frame, so the
2760
+ // chosen look stays put instead of dropping back to the idle loop.
2761
+ const chosen = Object.assign({}, slotSelectionsRef.current);
2762
+ if (option === null) delete chosen[slot.id];
2763
+ else chosen[slot.id] = option.label;
2764
+ slotSelectionsRef.current = chosen;
2765
+ saveOutfit();
2766
+ // The body follows whichever slot currently holds a motion option, worked
2767
+ // out from the selections rather than remembered. Remembering only the
2768
+ // LAST motion meant 掏出手机 -> 喵喵手 (a motion option to a plain
2769
+ // expression) left the phone parked forever: the new option starts no
2770
+ // motion, and nothing stopped the old one either — so the right hand was
2771
+ // stuck on the phone and no later draw could change it.
2772
+ if (option === null || option.sweep === undefined) userSweepRef.current = null;
2773
+ else userSweepRef.current = option.sweep;
2774
+ applySweep();
2775
+ let desired = null;
2776
+ for (const other of petRef.current?.expressionSlots ?? []) {
2777
+ const label = slotSelectionsRef.current[other.id];
2778
+ if (label === undefined) continue;
2779
+ const picked = other.options.find((o) => o.label === label);
2780
+ if (typeof picked?.motion === "string") { desired = picked.motion; break; }
2781
+ }
2782
+ const previous = slotMotionRef.current;
2783
+ slotMotionRef.current = desired;
2784
+ // 只在"该播的动作真的换了"时才播 —— 原来还有个 `|| option !== null`,
2785
+ // 意思是点任何表情都顺手把当前动作重播一遍。它会**重新快照**,而这时
2786
+ // 动作早就在最后一帧停着了:掏出手机之后点爱心眼,快照里的 phone 记的就是
2787
+ // 1(手机已在手里),于是"还原"忠实地把手机举着不放。
2788
+ // 用户报的"掏出手机切不到其他状态"就是这个。
2789
+ if (desired !== null && desired !== previous) {
2790
+ motion.current.playOnce(desired, 0, { kind: "slot", hold: true, persist: true });
2791
+ } else if (desired === null && previous !== null) {
2792
+ // The slot gave up its motion: hand the body back. Other slots' pins
2793
+ // are untouched, so their look survives.
2794
+ motion.current.playIdle();
2795
+ }
2796
+ // A dress-up choice PERSISTS. The auto-clear exists so a reaction or a
2797
+ // session phase cannot leave the pet stuck, but an outfit is an explicit
2798
+ // choice the user reverses from this panel (or with 归位), and expiring it
2799
+ // after a few seconds would make the panel feel broken.
2800
+ window.clearTimeout(expressionTimer.current);
2801
+ }, [applyExpressions]);
2802
+
2803
+ // The fidget effect below subscribes on a different dependency list, so it
2804
+ // reaches the chooser through a ref. Without this assignment the ref keeps
2805
+ // its no-op default and every fidget silently does nothing at all.
2806
+ chooseSlotOptionRef.current = chooseSlotOption;
2807
+
2808
+ const resetAll = useCallback(() => {
2809
+ window.clearTimeout(expressionTimer.current);
2810
+ userPinsRef.current = {};
2811
+ phasePinsRef.current = {};
2812
+ // 归位不动装扮:那六件是用户穿在身上的,不是这一轮的临时效果。
2813
+ // 表达式 pin 会被 outfitPins() 在 commit 时重新合并回去。
2814
+ const keepOutfit = {};
2815
+ for (const id of OUTFIT_SLOTS) {
2816
+ const label = slotSelectionsRef.current[id];
2817
+ if (label !== undefined) keepOutfit[id] = label;
2818
+ }
2819
+ slotSelectionsRef.current = keepOutfit;
2820
+ commitPinsRef.current();
2821
+ motion.current.resetToRest();
2822
+ say(pick(LINES.reset));
2823
+ }, [applyExpressions, say]);
2824
+
2825
+ /**
2826
+ * 启动时把上次的装扮穿回来。
2827
+ *
2828
+ * 放在 ready 之后:那时 catalog 已经填好 slotByIdRef,能校验存档里的
2829
+ * label 在当前 pet.json 里还存在(换模型/改配置之后存档可能对不上,
2830
+ * 对不上就当没存过,不要凭空造一个选项出来)。
2831
+ */
2832
+ const outfitRestoredRef = useRef(false);
2833
+ useEffect(() => {
2834
+ if (outfitRestoredRef.current || !ready) return;
2835
+ if (slotByIdRef.current.size === 0) return;
2836
+ outfitRestoredRef.current = true;
2837
+ const saved = readOutfit();
2838
+ if (saved === null) return;
2839
+ const chosen = Object.assign({}, slotSelectionsRef.current);
2840
+ let restored = false;
2841
+ for (const id of OUTFIT_SLOTS) {
2842
+ const label = saved[id];
2843
+ if (typeof label !== "string") continue;
2844
+ if (slotByIdRef.current.get(id)?.options.some((o) => o.label === label) !== true) continue;
2845
+ chosen[id] = label;
2846
+ restored = true;
2847
+ }
2848
+ if (!restored) return;
2849
+ slotSelectionsRef.current = chosen;
2850
+ commitPinsRef.current();
2851
+ }, [ready]);
2852
+
2853
+ // ---- session activity (#4) -----------------------------------------
2854
+ // The host pushes the agent's coarse phase over same-origin SSE; each
2855
+ // transition drives a motion + expression so the pet visibly follows what
2856
+ // the assistant is doing. EventSource reconnects on its own.
2857
+ useEffect(() => {
2858
+ if (!ready || typeof window.EventSource === "undefined") return undefined;
2859
+ let source;
2860
+ try {
2861
+ source = new window.EventSource(API + "/events");
2862
+ } catch {
2863
+ return undefined;
2864
+ }
2865
+ /**
2866
+ * Drive the motion + expression for one session phase.
2867
+ *
2868
+ * A phase is a STATE, not a one-shot event: 'waiting', 'tool' and 'done'
2869
+ * can each last many seconds, so they are handed to the controller's
2870
+ * sustain loop, which re-triggers the motion until the phase changes
2871
+ * (requirement #4). Everything else simply plays once and settles.
2872
+ */
2873
+ const applyPhase = (phase) => {
2874
+ // A phase is a whole LOOK, expressed in the panel's own vocabulary
2875
+ // (requirement #10), so it drives several slots at once — and every one
2876
+ // of them includes a whale, so the pet is never idle-looking mid-session.
2877
+ const look = looksByPhaseRef.current[phase];
2878
+ const slotById = new Map((pet?.expressionSlots ?? []).map((slot) => [slot.id, slot]));
2879
+ const pins = {};
2880
+ if (look !== undefined) {
2881
+ for (const [slotId, label] of Object.entries(look)) {
2882
+ const option = slotById.get(slotId)?.options.find((o) => o.label === label);
2883
+ if (option === undefined) continue;
2884
+ for (const name of option.expressions) pins[name] = true;
2885
+ for (const name of option.requires ?? []) pins[name] = true;
2886
+ }
2887
+ }
2888
+ phasePinsRef.current = pins;
2889
+ phaseSlotsRef.current = Object.keys(look ?? {});
2890
+ phaseChoicesRef.current = Object.assign({}, look ?? {});
2891
+ // A phase may also need a generated animation (the tool phase writes).
2892
+ let sweep = null;
2893
+ if (look !== undefined) {
2894
+ for (const [slotId, label] of Object.entries(look)) {
2895
+ const option = slotById.get(slotId)?.options.find((o) => o.label === label);
2896
+ if (option?.sweep !== undefined) sweep = option.sweep;
2897
+ }
2898
+ }
2899
+ phaseSweepRef.current = sweep;
2900
+ applySweep();
2901
+ commitPinsRef.current();
2902
+ const group = phaseMotionRef.current[phase];
2903
+ const sustained = PHASE_SUSTAIN.indexOf(phase) !== -1;
2904
+ if (phase === "idle" || group === undefined) {
2905
+ // No motion for this phase: stop sustaining and return to rest.
2906
+ motion.current.setSustain(null);
2907
+ motion.current.playIdle();
2908
+ } else {
2909
+ const groups = motion.current.groups();
2910
+ if (Array.isArray(groups[group])) {
2911
+ motion.current.setSustain(sustained ? phase : null);
2912
+ motion.current.playOnce(group, 0, { kind: "phase" });
2913
+ } else {
2914
+ motion.current.setSustain(null);
2915
+ }
2916
+ }
2917
+ const expression = phaseExpressionRef.current[phase];
2918
+ if (expression !== undefined) {
2919
+ phasePinsRef.current[expression] = true;
2920
+ commitPinsRef.current();
2921
+ }
2922
+ };
2923
+ // The sustain loop lives in the controller, but the phase -> group map
2924
+ // comes from the pet manifest, so hand the resolver over.
2925
+ motion.current.setPhaseResolver((phase) => phaseMotionRef.current[phase]);
2926
+ // Premise check for a motion group. Evaluated against the CURRENT slot
2927
+ // selections, so it stays true while the look keeps the phone out and goes
2928
+ // false the moment the slot changes.
2929
+ motion.current.setGuardResolver((group) => {
2930
+ const guard = guardsRef.current[group];
2931
+ if (guard === undefined) return true;
2932
+ return Object.entries(guard).every(([slotId, labels]) => {
2933
+ const chosen = phaseChoicesRef.current[slotId] ?? slotSelectionsRef.current[slotId];
2934
+ return chosen !== undefined && labels.includes(chosen);
2935
+ });
2936
+ });
2937
+ // The motion subscription (declared above) flushes a deferred phase here.
2938
+ flushPhaseRef.current = applyPhase;
2939
+ const onMessage = (event) => {
2940
+ let payload;
2941
+ try {
2942
+ payload = JSON.parse(event.data);
2943
+ } catch {
2944
+ return;
2945
+ }
2946
+ const phase = payload?.phase;
2947
+ if (typeof phase !== "string") return;
2948
+ setPhaseState(phase);
2949
+ if (phase === phaseRef.current) return;
2950
+ phaseRef.current = phase;
2951
+ // A phase animation may replace another phase animation, but must never
2952
+ // cut off something the user just triggered (tap / fidget / panel).
2953
+ const owner = motion.current.kind();
2954
+ if (motion.current.isPlaying() && owner !== "phase") {
2955
+ // Defer rather than drop: onDeferRedPhase re-applies it once the
2956
+ // current animation finishes, so the mirror never goes stale.
2957
+ pendingPhase.current = phase;
2958
+ return;
2959
+ }
2960
+ applyPhase(phase);
2961
+ };
2962
+ // A phase that persists would otherwise be re-applied after every
2963
+ // reaction; the ref remembers where we are so refires are no-ops.
2964
+ source.addEventListener("message", onMessage);
2965
+ return () => {
2966
+ source.close();
2967
+ phaseRef.current = "idle";
2968
+ phasePinsRef.current = {};
2969
+ phaseSlotsRef.current = [];
2970
+ phaseChoicesRef.current = {};
2971
+ phaseSweepRef.current = null;
2972
+ applySweep();
2973
+ commitPinsRef.current();
2974
+ // A dropped stream must not leave the pet sustaining a phase forever.
2975
+ motion.current.setSustain(null);
2976
+ motion.current.setPhaseResolver(null);
2977
+ };
2978
+ }, [ready, applyExpressions]);
2979
+
2980
+ // ---- idle fidget (#6) ----------------------------------------------
2981
+ // After the pet has been left alone for a while it picks one or two SLOT
2982
+ // options at random — a hand pose, a mood, a blush, a mouth — and KEEPS
2983
+ // them. A 摸鱼 is the pet changing what it is doing, not a brief animation
2984
+ // that snaps back: the next fidget switches again from wherever this one
2985
+ // left off, and the look drifts while nobody is watching.
2986
+ //
2987
+ // It goes through the ordinary slot path, so a fidget choice is
2988
+ // indistinguishable from one the user made — same pins, same parked motion,
2989
+ // same sweep — and the panel highlights it.
2990
+ //
2991
+ // It never fires while a session phase is live: the pet is following the
2992
+ // assistant then, and the phase's look is fixed. A random fidget would read
2993
+ // as the pet losing track of the conversation.
2994
+ useEffect(() => {
2995
+ if (!ready) return undefined;
2996
+ let timer = 0;
2997
+ const schedule = () => {
2998
+ window.clearTimeout(timer);
2999
+ const wait = IDLE_FIDGET_MIN_MS + Math.random() * (IDLE_FIDGET_MAX_MS - IDLE_FIDGET_MIN_MS);
3000
+ timer = window.setTimeout(fire, wait);
3001
+ };
3002
+ const fire = (force = false) => {
3003
+ // A forced call always runs; the SCHEDULED one honours the switch. Tests
3004
+ // turn it off for long drivers: a 摸鱼 every 12-26s rewrites the very slot
3005
+ // selections a slow assertion is watching, which made four drivers look
3006
+ // broken under parallel load and pass when run alone.
3007
+ if (!force && !fidgetEnabledRef.current) { schedule(); return; }
3008
+ fidgetTallyRef.current.fired = (fidgetTallyRef.current.fired ?? 0) + 1;
3009
+ const quietFor = Date.now() - lastInteraction.current;
3010
+ const busy = motion.current.isPlaying() || dragState.current !== null;
3011
+ // 'fixed' means a session owns the look; leave it alone. A forced call
3012
+ // (the diagnostic, and the tests) skips the idle gate — otherwise the
3013
+ // trigger is unreachable for the first 12 seconds and looks broken.
3014
+ if (!force && (busy || quietFor < IDLE_FIDGET_MIN_MS || phaseRef.current !== "idle")) {
3015
+ schedule();
3016
+ return;
3017
+ }
3018
+ const slots = (pet?.expressionSlots ?? [])
3019
+ .filter((slot) => FIDGET_SLOTS.includes(slot.id) && slot.options.length > 0);
3020
+ if (slots.length === 0) {
3021
+ schedule();
3022
+ return;
3023
+ }
3024
+ lastInteraction.current = Date.now();
3025
+ // Options that may come up at all: a motion whose premise is missing is
3026
+ // out (a selfie with no phone would set the pins and play nothing), and
3027
+ // so is anything the pet marked fidget:false — 吐舌 does not belong in
3028
+ // an idle 摸鱼.
3029
+ const usable = (slot) => slot.options.filter((o) =>
3030
+ (typeof o.motion !== "string" || motion.current.canPlay(o.motion)) && o.fidget !== false);
3031
+ // Weighted draw over "leave it alone" plus the usable options. The mouth
3032
+ // carries a heavy fidgetNone so the pet mostly looks normal rather than
3033
+ // pulling a face every time it idles.
3034
+ const draw = (slot) => {
3035
+ const opts = usable(slot);
3036
+ if (opts.length === 0) return null;
3037
+ const weightOf = (o) => (typeof o.fidgetWeight === "number" && o.fidgetWeight > 0 ? o.fidgetWeight : 1);
3038
+ const entries = [[null, typeof slot.fidgetNone === "number" ? slot.fidgetNone : 1]]
3039
+ .concat(opts.map((o) => [o, weightOf(o)]));
3040
+ let total = 0;
3041
+ for (const [, w] of entries) total += w;
3042
+ let roll = Math.random() * total;
3043
+ for (const [option, w] of entries) {
3044
+ roll -= w;
3045
+ if (roll <= 0) return option;
3046
+ }
3047
+ return entries[entries.length - 1][0];
3048
+ };
3049
+ const pool = slots.filter((slot) => usable(slot).length > 0);
3050
+ fidgetTallyRef.current.poolSize = slots.length + "/" + pool.length;
3051
+ if (pool.length === 0) { schedule(); return; }
3052
+ // 每个池子各自 roll 一次 —— 手部、情绪、脸红、嘴、眼睛**同时**摇,
3053
+ // 而不是"这次只摇一两个槽位"。用户要的是每次摸鱼都重新掷一遍所有池子,
3054
+ // 组合出来的样子才会变;只摇一个的话,其余槽位永远停在上一次的结果上,
3055
+ // 摸鱼看起来就总是同一套。
3056
+ // "保持不变"仍然由各槽位自己的 fidgetNone 权重决定(嘴 8、眼 11…),
3057
+ // 所以这不是"每次都全变",而是"每次每个池子都掷一次骰子"。
3058
+ const changes = pool.map((slot) => [slot, draw(slot)]);
3059
+ // A fidget should still be MOVEMENT. If the weighted draw left everything
3060
+ // alone, force one HAND slot that can play a motion — the hands are where
3061
+ // the pet's actions live, and forcing the mouth would defeat the point of
3062
+ // weighting it.
3063
+ // NO "make sure something happens" fallback. There used to be one, and
3064
+ // it fired on 82% of draws — overriding the very weights that decide how
3065
+ // often each slot should move, and collapsing the pet onto whichever
3066
+ // option happened to be the only lively one. The weights alone control
3067
+ // the mix now; fidgetNone is the knob for "how often does this slot
3068
+ // move at all".
3069
+ // With the phone already out, a fidget sometimes takes a photo — the
3070
+ // whole reason the phone slot exists. The selfie's own guard requires the
3071
+ // phone, so this can only fire when it is genuinely out.
3072
+ const phoneOut = () => slotSelectionsRef.current.rhand === "掏出手机";
3073
+ const phoneWanted = changes.some(([slot, option]) => slot.id === "rhand" && option?.label === "掏出手机")
3074
+ || phoneOut();
3075
+ if (phoneWanted && motion.current.canPlay("Selfie") && Math.random() < SELFIE_CHANCE) {
3076
+ changes.push([null, { label: "__selfie__", selfie: true }]);
3077
+ }
3078
+ for (const [slot, option] of changes) {
3079
+ if (slot === null) {
3080
+ // Not a slot choice: a one-shot reaction that parks like the rest.
3081
+ const group = Math.random() < 0.5 ? "Selfie" : "SelfieQuick";
3082
+ if (motion.current.canPlay(group)) {
3083
+ motion.current.playOnce(group, 0, { kind: "fidget", hold: true, persist: true });
3084
+ }
3085
+ continue;
3086
+ }
3087
+ fidgetTallyRef.current.picked[slot.id] = (fidgetTallyRef.current.picked[slot.id] ?? 0) + 1;
3088
+ const key = slot.id + ":" + (option === null ? "无" : option.label);
3089
+ fidgetTallyRef.current.drawn[key] = (fidgetTallyRef.current.drawn[key] ?? 0) + 1;
3090
+ chooseSlotOptionRef.current(slot, option);
3091
+ }
3092
+ schedule();
3093
+ };
3094
+ fidgetLiveRef.current = true;
3095
+ fidgetRef.current = () => fire(true);
3096
+ schedule();
3097
+ return () => window.clearTimeout(timer);
3098
+ }, [ready, pet]);
3099
+
3100
+ // ---- click + drag -------------------------------------------------
3101
+ // Interaction bookkeeping lives above the effects that read it, so the
3102
+ // idle-fidget scheduler can tell "left alone" from "being handled".
3103
+ const dragState = useRef(null);
3104
+ // Last time the user touched the pet; the idle-fidget timer (#6) measures
3105
+ // quiet time from here so a fidget never fires under the user's cursor.
3106
+ const lastInteraction = useRef(Date.now());
3107
+ // Auto-clear timer for a manually pinned expression (requirement #3).
3108
+ const expressionTimer = useRef(0);
3109
+ // Session-phase plumbing (declared here so the SSE effect can read it).
3110
+ const phaseRef = useRef("idle");
3111
+ const phaseMotionRef = useRef(PHASE_MOTION);
3112
+ const phaseExpressionRef = useRef(PHASE_EXPRESSION);
3113
+ /** phase -> slot-vocabulary look, from the pet manifest (requirement #10). */
3114
+ const looksByPhaseRef = useRef({});
3115
+ // Gaze target, mirrored onto the pet root as data-gaze.
3116
+ const [gaze, setGaze] = useState("center");
3117
+ gazeSinkRef.current = setGaze;
3118
+ // Last session phase the stream delivered, mirrored as data-phase, and a
3119
+ // phase that arrived while another animation held the body (re-applied on
3120
+ // the next idle so a busy moment cannot make the mirror go stale).
3121
+ const [phase, setPhaseState] = useState("idle");
3122
+ /**
3123
+ * The character's silhouette as CSS `clip-path` path data (requirement #5).
3124
+ * Empty until the alpha mask has been extracted; while empty the proxy is
3125
+ * hidden and the stage keeps its old full-box behaviour.
3126
+ */
3127
+ const [maskPath, setMaskPath] = useState("");
3128
+ const pendingPhase = useRef(null);
3129
+ // Published by the stream effect so the (earlier-declared) subscription can
3130
+ // flush a deferred phase; a ref avoids a declaration-order dependency.
3131
+ const pendingPhaseRef = pendingPhase;
3132
+ const flushPhaseRef = useRef(() => {});
3133
+
3134
+ /**
3135
+ * Whether the press landed on the character rather than on the transparent
3136
+ * part of its square canvas.
3137
+ *
3138
+ * This pack declares no Cubism HitAreas at all, so the region comes from the
3139
+ * rendered alpha silhouette. It is the fallback path: once the mask is known
3140
+ * the interactive proxy is already clipped to the same silhouette, and this
3141
+ * only has to answer for the pre-mask window.
3142
+ */
3143
+ const hitsModel = useCallback((clientX, clientY) => {
3144
+ const stage = stageRef.current;
3145
+ if (stage === null) return false;
3146
+ const rect = stage.getBoundingClientRect();
3147
+ return motion.current.hitsMask(clientX - rect.left, clientY - rect.top, rect.width, rect.height);
3148
+ }, []);
3149
+
3150
+ /**
3151
+ * Whether the press landed on the pet's HEAD (requirement #1).
3152
+ *
3153
+ * 重锤出击 is the "pat the head" reaction, so it is reserved for the head;
3154
+ * tapping the desk or the body no longer swings a hammer. The region is
3155
+ * measured from the model's own facial drawables, so it needs no per-pet
3156
+ * tuning.
3157
+ */
3158
+ const hitsHead = useCallback((clientX, clientY) => {
3159
+ const stage = stageRef.current;
3160
+ if (stage === null) return false;
3161
+ const rect = stage.getBoundingClientRect();
3162
+ return motion.current.hitsHead(clientX - rect.left, clientY - rect.top);
3163
+ }, []);
3164
+
3165
+ /**
3166
+ * Right-click on the pet opens the whole control panel.
3167
+ *
3168
+ * The pet has no always-visible chrome any more: a toolbar that appeared on
3169
+ * hover sat on top of the character and covered her, and hover is also the
3170
+ * one gesture a click-through overlay cannot express well. A context menu
3171
+ * is deliberate, and the browser's own menu is suppressed so the gesture
3172
+ * means only one thing.
3173
+ */
3174
+ /**
3175
+ * Close the panel on Escape or on a click outside the pet.
3176
+ *
3177
+ * Clicks that land on the pet or the panel are ignored, so using the panel
3178
+ * never dismisses it. The pet's root is pointer-events:none, so a click on
3179
+ * a transparent corner targets the page behind and does count as outside —
3180
+ * which is the behaviour you want.
3181
+ */
3182
+ useEffect(() => {
3183
+ if (!panelOpen) return undefined;
3184
+ const onKey = (event) => {
3185
+ if (event.key === "Escape") setPanelOpen(false);
3186
+ };
3187
+ const onDown = (event) => {
3188
+ const root = rootRef.current;
3189
+ if (root !== null && event.target instanceof Node && root.contains(event.target)) return;
3190
+ setPanelOpen(false);
3191
+ };
3192
+ window.addEventListener("keydown", onKey);
3193
+ window.addEventListener("pointerdown", onDown, true);
3194
+ return () => {
3195
+ window.removeEventListener("keydown", onKey);
3196
+ window.removeEventListener("pointerdown", onDown, true);
3197
+ };
3198
+ }, [panelOpen]);
3199
+
3200
+ const onContextMenu = useCallback((event) => {
3201
+ event.preventDefault();
3202
+ setPanelOpen(true);
3203
+ }, []);
3204
+
3205
+ const onPointerDown = useCallback((event) => {
3206
+ if (event.button !== 0) return;
3207
+ // A press on a transparent corner only ever starts a drag: it must not
3208
+ // arm a click reaction, which is what made the whole square feel live.
3209
+ dragState.current = {
3210
+ startX: event.clientX,
3211
+ startY: event.clientY,
3212
+ right: posRef.current.right,
3213
+ bottom: posRef.current.bottom,
3214
+ moved: false,
3215
+ onModel: hitsModel(event.clientX, event.clientY),
3216
+ // Resolved once, at press time: the model keeps swaying, so asking
3217
+ // again on release could answer differently than the press did.
3218
+ onHead: hitsHead(event.clientX, event.clientY),
3219
+ };
3220
+ setDragging(true);
3221
+ try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* not capturable */ }
3222
+ }, [hitsModel, hitsHead]);
3223
+
3224
+ useEffect(() => {
3225
+ const onMove = (event) => {
3226
+ const state = dragState.current;
3227
+ if (state === null) return;
3228
+ const dx = event.clientX - state.startX;
3229
+ const dy = event.clientY - state.startY;
3230
+ if (!state.moved && Math.abs(dx) < DRAG_SLOP_PX && Math.abs(dy) < DRAG_SLOP_PX) return;
3231
+ state.moved = true;
3232
+ const width = sizeRef.current;
3233
+ setPos({
3234
+ right: Math.max(0, Math.min(window.innerWidth - width, state.right - dx)),
3235
+ bottom: clampBottom(state.bottom - dy, width),
3236
+ });
3237
+ };
3238
+ const onUp = () => {
3239
+ const state = dragState.current;
3240
+ if (state === null) return;
3241
+ dragState.current = null;
3242
+ setDragging(false);
3243
+ if (state.moved) {
3244
+ lastInteraction.current = Date.now();
3245
+ setPos((current) => {
3246
+ saveStored({ right: Math.round(current.right), bottom: Math.round(current.bottom) });
3247
+ return current;
3248
+ });
3249
+ } else if (state.onModel) {
3250
+ lastInteraction.current = Date.now();
3251
+ if (state.onHead) {
3252
+ // Patting the head picks ONE of three reactions at random
3253
+ // (requirement #5) — and deliberately does not blush. The two face
3254
+ // reactions are transient: they are flashed and the auto-clear
3255
+ // takes them away, so a pat never leaves a permanent face on a
3256
+ // slot the user chose.
3257
+ const reaction = pick(HEAD_PAT_REACTIONS);
3258
+ if (reaction.motion !== undefined) {
3259
+ const groups = motion.current.groups();
3260
+ const tap = [reaction.motion, "TapHead", "tap_head"].find((group) => Array.isArray(groups[group]));
3261
+ if (tap !== undefined) motion.current.playOnce(tap, 0, { kind: "tap" });
3262
+ } else if (reaction.expression !== undefined) {
3263
+ flashExpression(reaction.expression);
3264
+ }
3265
+ say(pick(LINES.click));
3266
+ } else {
3267
+ // Anywhere else on the character is a lighter acknowledgement —
3268
+ // deliberately WITHOUT 重锤出击, which now belongs to the head only.
3269
+ say(pick(LINES.click));
3270
+ }
3271
+ }
3272
+ };
3273
+ window.addEventListener("pointermove", onMove, { passive: true });
3274
+ window.addEventListener("pointerup", onUp);
3275
+ window.addEventListener("pointercancel", onUp);
3276
+ return () => {
3277
+ window.removeEventListener("pointermove", onMove);
3278
+ window.removeEventListener("pointerup", onUp);
3279
+ window.removeEventListener("pointercancel", onUp);
3280
+ };
3281
+ }, [flashExpression, say]);
3282
+
3283
+ // ---- persistence ---------------------------------------------------
3284
+ useEffect(() => { saveStored({ size }); }, [size]);
3285
+ useEffect(() => { saveStored({ petId }); if (petId !== undefined) applyExpressions({}); }, [petId, applyExpressions]);
3286
+
3287
+ if (catalog !== null && catalog.pets.length === 0) {
3288
+ return h("div", { [PET_ATTR]: "", style: rootStyle(size, pos) },
3289
+ h("div", { "data-hint": "" },
3290
+ h("div", null, "还没有可用的 Live2D 宠物。"),
3291
+ h("div", { style: { marginTop: 6 } }, "把宠物目录放到:"),
3292
+ h("code", null, "%DSH_HOME%\\pets\\<id>\\pet.json"),
3293
+ ),
3294
+ );
3295
+ }
3296
+
3297
+ let overlay = null;
3298
+ if (coreMissing) {
3299
+ overlay = h("div", { "data-hint": "" },
3300
+ h("div", null, h("b", null, "缺少 Live2D Cubism Core 运行时")),
3301
+ h("div", { style: { marginTop: 6 } }, "请把官方 live2dcubismcore.min.js 放到:"),
3302
+ h("code", null, "%DSH_HOME%\\pets\\.runtime\\live2dcubismcore.min.js"),
3303
+ );
3304
+ } else if (error !== null) {
3305
+ overlay = h("div", { "data-hint": "" },
3306
+ h("div", null, h("b", null, "加载失败")),
3307
+ h("div", { style: { marginTop: 6, opacity: .8, fontSize: 11 } }, error),
3308
+ );
3309
+ } else if (!ready) {
3310
+ overlay = h("div", { "data-hint": "", style: { opacity: .65 } }, "加载模型…");
3311
+ }
3312
+
3313
+ /**
3314
+ * Turn the pinned expression set into parameter writes.
3315
+ *
3316
+ * Every expression carries its own .exp3.json parameters in the catalog, so
3317
+ * a pin becomes a flat list of { id, value, blend }, applied by the
3318
+ * controller on every frame. Because they layer on top of the motion
3319
+ * output, several can be active at once — which is what a dress-up panel
3320
+ * needs and what the engine's single-current-expression manager could never
3321
+ * do.
3322
+ */
3323
+ useEffect(() => {
3324
+ const byName = new Map((pet?.expressions ?? []).map((entry) => [entry.name, entry]));
3325
+ const layers = [];
3326
+ const seen = new Map();
3327
+ for (const name of Object.keys(pinned)) {
3328
+ if (pinned[name] !== true) continue;
3329
+ for (const parameter of byName.get(name)?.params ?? []) {
3330
+ // Last pin wins for a shared parameter, so a later choice overrides
3331
+ // an earlier one rather than accumulating.
3332
+ const at = seen.get(parameter.id);
3333
+ if (at === undefined) {
3334
+ seen.set(parameter.id, layers.length);
3335
+ layers.push(parameter);
3336
+ } else {
3337
+ layers[at] = parameter;
3338
+ }
3339
+ }
3340
+ }
3341
+ motion.current.setExpressionLayers(layers);
3342
+ }, [pinned, pet]);
3343
+
3344
+ const panel = panelOpen && pet !== undefined
3345
+ ? h("div", {
3346
+ "data-panel": "",
3347
+ // Pin the panel once it is on screen (requirement #11).
3348
+ //
3349
+ // It is anchored to the pet's box, so resizing the pet moved the panel
3350
+ // out from under the pointer — right while the user is dragging the
3351
+ // size slider INSIDE that panel. Freezing it at the coordinates it
3352
+ // first appeared at keeps the controls reachable.
3353
+ style: panelBox === null ? undefined : {
3354
+ position: "fixed",
3355
+ left: panelBox.left + "px",
3356
+ top: panelBox.top + "px",
3357
+ right: "auto",
3358
+ bottom: "auto",
3359
+ },
3360
+ },
3361
+ h("header", null,
3362
+ catalog.pets.length > 1
3363
+ ? h("select", {
3364
+ value: pet.id,
3365
+ onChange: (event) => setPetId(event.target.value),
3366
+ }, catalog.pets.map((entry) => h("option", { key: entry.id, value: entry.id }, entry.displayName)))
3367
+ : h("span", { "data-title": "" }, pet.displayName),
3368
+ h("button", {
3369
+ type: "button",
3370
+ "data-close": "",
3371
+ title: "关闭(Esc)",
3372
+ onClick: () => setPanelOpen(false),
3373
+ }, "×"),
3374
+ ),
3375
+ h("div", { "data-tabs": "" },
3376
+ h("button", { type: "button", ...(tab === "motions" ? { "data-on": "" } : {}), onClick: () => setTab("motions") }, "动作 " + pet.motions.length),
3377
+ // 表情 and 装扮 are one menu now: all 44 expressions are slots
3378
+ // (glasses, stickers, hair, cloth, claws, desk, hands, then eyes,
3379
+ // mood, mouth, symbols, ambience, blush, desk actions).
3380
+ h("button", { type: "button", ...(tab === "slots" ? { "data-on": "" } : {}), onClick: () => setTab("slots") }, "装扮 " + (pet.expressionSlots ?? []).length),
3381
+ ),
3382
+ h("div", { "data-body": "" }, tab === "slots"
3383
+ // Dress-up slots: one choice each, and choices in different slots
3384
+ // coexist (glasses AND cat ears AND a dark tablecloth).
3385
+ ? (pet.expressionSlots ?? []).map((slot) => {
3386
+ // An option is active when every expression it carries is pinned:
3387
+ // 白魔爪 needs the claw AND its recolour, so checking only the
3388
+ // first would light it up for 粉魔爪 too.
3389
+ // Selected = the label this slot actually holds. NOT
3390
+ // "every expression is pinned": a motion-only option has an EMPTY
3391
+ // expression list, and [].every(...) is vacuously true, so
3392
+ // 掏出手机 and 吹泡泡糖 rendered as permanently pressed.
3393
+ const chosenLabel = slotSelectionsRef.current[slot.id];
3394
+ const active = slot.options.find((option) => option.label === chosenLabel);
3395
+ return h("div", { "data-group": "", key: slot.id, "data-slot": slot.id },
3396
+ h("span", null, slot.label),
3397
+ h("div", { "data-chips": "" },
3398
+ h("button", {
3399
+ type: "button",
3400
+ key: "__none",
3401
+ ...(active === undefined ? { "data-on": "" } : {}),
3402
+ onClick: () => chooseSlotOption(slot, null),
3403
+ }, slot.none),
3404
+ slot.options.map((option) => h("button", {
3405
+ type: "button",
3406
+ key: option.label,
3407
+ ...(option.label === chosenLabel ? { "data-on": "" } : {}),
3408
+ "data-slot-option": option.label,
3409
+ onClick: () => chooseSlotOption(slot, option),
3410
+ }, option.label)),
3411
+ ),
3412
+ );
3413
+ })
3414
+ : tab === "motions"
3415
+ ? pet.motions.filter((entry) => !(pet.hiddenMotions ?? []).includes(entry.group))
3416
+ .map((entry) => h("div", { "data-group": "", key: entry.group },
3417
+ h("span", null, entry.label),
3418
+ h("div", { "data-chips": "" },
3419
+ Array.from({ length: entry.count }, (_, index) => h("button", {
3420
+ key: index,
3421
+ type: "button",
3422
+ ...(motionGroup === entry.group ? { "data-on": "" } : {}),
3423
+ "data-motion-group": entry.group,
3424
+ onClick: () => playMotion(entry.group, index),
3425
+ }, entry.count > 1 ? "第 " + (index + 1) + " 段" : "播放")),
3426
+ ),
3427
+ ))
3428
+ : null,
3429
+ ),
3430
+ h("div", { "data-hintrow": "" }, "在宠物身上点右键打开这里 · Esc 或点空白处关闭"),
3431
+ h("footer", null,
3432
+ h("button", { type: "button", title: "缩小", onClick: () => setSize((current) => Math.max(MIN_SIZE, current - 40)) }, "-"),
3433
+ h("input", {
3434
+ type: "range", min: MIN_SIZE, max: MAX_SIZE, step: 20, value: size,
3435
+ title: size + "px",
3436
+ onChange: (event) => setSize(Number(event.target.value)),
3437
+ }),
3438
+ h("button", { type: "button", title: "放大", onClick: () => setSize((current) => Math.min(MAX_SIZE, current + 40)) }, "+"),
3439
+ h("span", { "data-sizelabel": "" }, size + "px"),
3440
+ h("button", { type: "button", onClick: resetAll }, "归位"),
3441
+ ),
3442
+ )
3443
+ : null;
3444
+
3445
+ return h("div", {
3446
+ [PET_ATTR]: "",
3447
+ ref: rootRef,
3448
+ style: rootStyle(size, pos),
3449
+ // Observability: the committed action of the motion state machine
3450
+ // ('idle' while resting) and the current gaze target, so the pet's
3451
+ // behaviour is inspectable without reaching into engine internals.
3452
+ "data-motion": motionGroup === "" ? "idle" : motionGroup,
3453
+ "data-gaze": gaze,
3454
+ "data-phase": phase,
3455
+ },
3456
+ overlay !== null ? overlay : null,
3457
+ h("div", {
3458
+ ref: stageRef,
3459
+ "data-stage": "",
3460
+ // No mask yet: keep the whole box interactive rather than inert.
3461
+ ...(maskPath === "" ? { "data-nomask": "" } : {}),
3462
+ ...(dragging ? { "data-dragging": "" } : {}),
3463
+ // The fallback path: with no mask the stage itself starts the drag.
3464
+ ...(maskPath === "" ? { onPointerDown, onContextMenu } : {}),
3465
+ },
3466
+ // Only the silhouette is interactive; everything else in the square
3467
+ // canvas stays click-through to the page behind (requirement #5).
3468
+ h("div", {
3469
+ "data-hit": "",
3470
+ ...(maskPath === "" ? { "data-off": "" } : { style: { clipPath: "path('" + maskPath + "')", WebkitClipPath: "path('" + maskPath + "')" } }),
3471
+ ...(maskPath === "" ? {} : { onPointerDown, onContextMenu }),
3472
+ }),
3473
+ ),
3474
+ bubble === null ? null : h("div", { "data-bubble": "" }, bubble),
3475
+ panel,
3476
+ );
3477
+ }
3478
+
3479
+
3480
+
3481
+ /** Positioning lives on the pet's own root div, so it works whether it is
3482
+ * reached through the React container or not. */
3483
+ function rootStyle(size, pos) {
3484
+ return { width: size, height: size, right: pos.right, bottom: pos.bottom };
3485
+ }
3486
+
3487
+ // --------------------------------------------------------------- mount
3488
+
3489
+ let mounted = null;
3490
+
3491
+ function teardown() {
3492
+ if (mounted === null) return;
3493
+ const current = mounted;
3494
+ mounted = null;
3495
+ try { current.root.unmount(); } catch { /* already gone */ }
3496
+ current.container.remove();
3497
+ }
3498
+
3499
+ function apply(ctx) {
3500
+ ensureStyle();
3501
+ // Takeover: an earlier instance — a hot reload, or one left behind by a
3502
+ // crashed reload — must not leave a second floating pet on the page.
3503
+ teardown();
3504
+ // Sweep containers AND any orphaned pet root an earlier instance left
3505
+ // behind, so this apply body is the page's only floating pet.
3506
+ for (const stale of Array.from(document.querySelectorAll(
3507
+ "[" + ROOT_ATTR + "],[" + LEGACY_ATTR + "],[" + PET_ATTR + "]",
3508
+ ))) stale.remove();
3509
+
3510
+ const container = document.createElement("div");
3511
+ container.setAttribute(ROOT_ATTR, "");
3512
+ document.body.appendChild(container);
3513
+
3514
+ const root = require("react-dom/client").createRoot(container);
3515
+ mounted = { root, container };
3516
+ root.render(h(Pet, null));
3517
+
3518
+ ctx.effect(() => () => teardown(), "live2d-pet: client lifecycle");
3519
+ }
3520
+
3521
+ exports.name = name;
3522
+ exports.inject = inject;
3523
+ exports.apply = apply;
3524
+ return module.exports;
3525
+ }});