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.
- package/LICENSE +56 -0
- package/README.md +425 -0
- package/cordis.patch.yml +8 -0
- package/docs/README.md +14 -0
- package/docs/downscale-fix.png +0 -0
- package/docs/preview.png +0 -0
- package/lib/client.js +3525 -0
- package/lib/index.js +1027 -0
- package/lib/live2d-vendor.js +997 -0
- package/package.json +61 -0
- package/pets/ds-whale-girl/LICENSE +37 -0
- package/pets/ds-whale-girl/README.md +67 -0
- package/pets/ds-whale-girl/c_0120.model3.json +249 -0
- package/pets/ds-whale-girl/catalog.json +310 -0
- package/pets/ds-whale-girl/expressions/angry.exp3.json +55 -0
- package/pets/ds-whale-girl/expressions/blank-eyes.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/cat-paws.exp3.json +20 -0
- package/pets/ds-whale-girl/expressions/claw-recolor.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/claw.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/cry.exp3.json +60 -0
- package/pets/ds-whale-girl/expressions/dark-tablecloth.exp3.json +11 -0
- package/pets/ds-whale-girl/expressions/dizzy.exp3.json +40 -0
- package/pets/ds-whale-girl/expressions/draw-brush.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/draw-eraser.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/draw-undo.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/drool.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/excited.exp3.json +65 -0
- package/pets/ds-whale-girl/expressions/exclaim.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/facial-red.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/glasses-oval.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/glasses-round.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/glasses-square.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/gloomy.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/hands-peace.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/headband.exp3.json +11 -0
- package/pets/ds-whale-girl/expressions/heart-pop.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/heartbeat.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/ketchup-squeeze.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/love-eyes.exp3.json +60 -0
- package/pets/ds-whale-girl/expressions/menu-press.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/naughty.exp3.json +20 -0
- package/pets/ds-whale-girl/expressions/omurice.exp3.json +16 -0
- package/pets/ds-whale-girl/expressions/parfait.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/phone-recolor.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/ponytail.exp3.json +11 -0
- package/pets/ds-whale-girl/expressions/question.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/sad.exp3.json +35 -0
- package/pets/ds-whale-girl/expressions/soul-out.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/sparkle.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/star-eyes.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/sticker-bow.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/sticker-cat.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/sticker-rabbit.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/sunglasses.exp3.json +20 -0
- package/pets/ds-whale-girl/expressions/sweat.exp3.json +10 -0
- package/pets/ds-whale-girl/expressions/tongue-out.exp3.json +26 -0
- package/pets/ds-whale-girl/expressions/whale-on-desk.exp3.json +11 -0
- package/pets/ds-whale-girl/expressions/whale.exp3.json +11 -0
- package/pets/ds-whale-girl/model/c_0120.cdi3.json +1802 -0
- package/pets/ds-whale-girl/model/c_0120.moc3 +0 -0
- package/pets/ds-whale-girl/model/c_0120.physics3.json +2396 -0
- package/pets/ds-whale-girl/motions/bubble-gum.motion3.json +369 -0
- package/pets/ds-whale-girl/motions/hammer.motion3.json +1120 -0
- package/pets/ds-whale-girl/motions/idle.motion3.json +4746 -0
- package/pets/ds-whale-girl/motions/ketchup.motion3.json +768 -0
- package/pets/ds-whale-girl/motions/open-case.motion3.json +116 -0
- package/pets/ds-whale-girl/motions/selfie-quick.motion3.json +47 -0
- package/pets/ds-whale-girl/motions/selfie.motion3.json +671 -0
- package/pets/ds-whale-girl/motions/spray-water.motion3.json +31 -0
- package/pets/ds-whale-girl/pet.json +599 -0
- package/pets/ds-whale-girl/previews/idle.png +0 -0
- package/pets/ds-whale-girl/textures/texture_00.png +0 -0
- package/pets/ds-whale-girl/textures/texture_01.png +0 -0
- package/pets/ds-whale-girl/voice.json +46 -0
- package/pets/ds-whale-girl//345/256/211/350/243/205/350/257/264/346/230/216.md +30 -0
- 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
|
+
}});
|