@doki-land/live2d 0.0.10 → 0.0.12
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/README.md +3 -0
- package/dist/index.d.ts +326 -0
- package/dist/index.js +1667 -0
- package/dist/reexports/core.d.ts +3 -0
- package/dist/reexports/core.js +2 -0
- package/dist/reexports/loader.d.ts +1 -0
- package/dist/reexports/loader.js +2 -0
- package/dist/reexports/renderer.d.ts +1 -0
- package/dist/reexports/renderer.js +2 -0
- package/package.json +61 -43
- package/src/create-live2d.ts +39 -0
- package/src/focus.ts +53 -0
- package/src/index.ts +78 -187
- package/src/load-textures.ts +85 -0
- package/src/motion/evaluate-curve.ts +119 -0
- package/src/motion/index.ts +20 -0
- package/src/motion/motion-player.ts +389 -0
- package/src/motion/parse-motion3.ts +164 -0
- package/src/motion/types.ts +89 -0
- package/src/reexports/core.ts +1 -0
- package/src/reexports/loader.ts +1 -0
- package/src/reexports/renderer.ts +1 -0
- package/src/stage/actor-model-slot.ts +429 -0
- package/src/stage/actor.ts +188 -0
- package/src/stage/index.ts +18 -0
- package/src/stage/single-facade.ts +221 -0
- package/src/stage/stage.ts +388 -0
- package/src/stage/transform.ts +161 -0
- package/dist/l2d.umd.js +0 -1208
- package/dist/l2d.umd.js.map +0 -1
- package/dist/live2d.css +0 -1
- package/lib/cubism2.d.ts +0 -179
- package/lib/cubism2.min.js +0 -2
- package/lib/cubism5.d.ts +0 -367
- package/lib/cubism5.min.js +0 -10
- package/lib/index.d.ts +0 -7
- package/readme.md +0 -60
- package/src/fs/index.ts +0 -93
- package/src/helper/index.ts +0 -25
- package/src/icons/icons.ts +0 -39
- package/src/icons/style.css +0 -44
- package/src/icons/switch-character.svg +0 -1
- package/src/icons/switch-costume.svg +0 -1
- package/src/types/Live2dOptions.ts +0 -56
- package/src/types/ModelOptions.ts +0 -40
- package/src/types/Resolve.ts +0 -16
- package/src/types/index.ts +0 -3
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Motion3Clip, MotionCurve, MotionSegment } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sample all curves of a clip at `timeSeconds` (clamped to [0, duration]
|
|
5
|
+
* unless looping — caller should wrap time for loops).
|
|
6
|
+
*/
|
|
7
|
+
export function evaluateMotion3(
|
|
8
|
+
clip: Motion3Clip,
|
|
9
|
+
timeSeconds: number,
|
|
10
|
+
): Array<{ target: MotionCurve["target"]; id: string; value: number }> {
|
|
11
|
+
const t = clamp(timeSeconds, 0, clip.duration);
|
|
12
|
+
const out: Array<{
|
|
13
|
+
target: MotionCurve["target"];
|
|
14
|
+
id: string;
|
|
15
|
+
value: number;
|
|
16
|
+
}> = [];
|
|
17
|
+
for (const curve of clip.curves) {
|
|
18
|
+
out.push({
|
|
19
|
+
target: curve.target,
|
|
20
|
+
id: curve.id,
|
|
21
|
+
value: evaluateCurve(curve, t, clip.areBeziersRestricted),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function evaluateCurve(
|
|
28
|
+
curve: MotionCurve,
|
|
29
|
+
timeSeconds: number,
|
|
30
|
+
areBeziersRestricted: boolean,
|
|
31
|
+
): number {
|
|
32
|
+
const segs = curve.segments;
|
|
33
|
+
if (segs.length === 0) return 0;
|
|
34
|
+
|
|
35
|
+
if (timeSeconds <= segs[0]!.p0.time) return segs[0]!.p0.value;
|
|
36
|
+
const last = segs[segs.length - 1]!;
|
|
37
|
+
if (timeSeconds >= last.p3.time) return last.p3.value;
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
40
|
+
const seg = segs[i]!;
|
|
41
|
+
const isLast = i === segs.length - 1;
|
|
42
|
+
// At a segment boundary, hand off to the next segment so stepped ends
|
|
43
|
+
// expose their end value as the next key.
|
|
44
|
+
if (
|
|
45
|
+
timeSeconds < seg.p3.time ||
|
|
46
|
+
(isLast && timeSeconds <= seg.p3.time)
|
|
47
|
+
) {
|
|
48
|
+
return evaluateSegment(seg, timeSeconds, areBeziersRestricted);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return last.p3.value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function evaluateSegment(
|
|
55
|
+
seg: MotionSegment,
|
|
56
|
+
time: number,
|
|
57
|
+
areBeziersRestricted: boolean,
|
|
58
|
+
): number {
|
|
59
|
+
const { p0, p3 } = seg;
|
|
60
|
+
switch (seg.kind) {
|
|
61
|
+
case "linear": {
|
|
62
|
+
const span = p3.time - p0.time;
|
|
63
|
+
if (span <= 0) return p3.value;
|
|
64
|
+
const u = (time - p0.time) / span;
|
|
65
|
+
return p0.value + (p3.value - p0.value) * u;
|
|
66
|
+
}
|
|
67
|
+
case "stepped":
|
|
68
|
+
return p0.value;
|
|
69
|
+
case "inverseStepped":
|
|
70
|
+
return p3.value;
|
|
71
|
+
case "bezier": {
|
|
72
|
+
const p1 = seg.p1!;
|
|
73
|
+
const p2 = seg.p2!;
|
|
74
|
+
if (areBeziersRestricted) {
|
|
75
|
+
const span = p3.time - p0.time;
|
|
76
|
+
if (span <= 0) return p3.value;
|
|
77
|
+
const u = (time - p0.time) / span;
|
|
78
|
+
return cubic(p0.value, p1.value, p2.value, p3.value, u);
|
|
79
|
+
}
|
|
80
|
+
// Unrestricted: solve cubic for time, then sample value.
|
|
81
|
+
const u = solveBezierTime(p0.time, p1.time, p2.time, p3.time, time);
|
|
82
|
+
return cubic(p0.value, p1.value, p2.value, p3.value, u);
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
return p3.value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cubic(a: number, b: number, c: number, d: number, t: number): number {
|
|
90
|
+
const u = 1 - t;
|
|
91
|
+
return (
|
|
92
|
+
u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Binary-search parameter u in [0,1] so cubic(time) ~= targetTime. */
|
|
97
|
+
function solveBezierTime(
|
|
98
|
+
t0: number,
|
|
99
|
+
t1: number,
|
|
100
|
+
t2: number,
|
|
101
|
+
t3: number,
|
|
102
|
+
target: number,
|
|
103
|
+
): number {
|
|
104
|
+
let lo = 0;
|
|
105
|
+
let hi = 1;
|
|
106
|
+
for (let i = 0; i < 20; i += 1) {
|
|
107
|
+
const mid = (lo + hi) * 0.5;
|
|
108
|
+
const x = cubic(t0, t1, t2, t3, mid);
|
|
109
|
+
if (x < target) lo = mid;
|
|
110
|
+
else hi = mid;
|
|
111
|
+
}
|
|
112
|
+
return (lo + hi) * 0.5;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clamp(n: number, min: number, max: number): number {
|
|
116
|
+
if (n < min) return min;
|
|
117
|
+
if (n > max) return max;
|
|
118
|
+
return n;
|
|
119
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { evaluateCurve, evaluateMotion3 } from "./evaluate-curve.js";
|
|
2
|
+
export {
|
|
3
|
+
blendMotionLayers,
|
|
4
|
+
MotionPlayer,
|
|
5
|
+
type MotionPlayerHandlers,
|
|
6
|
+
} from "./motion-player.js";
|
|
7
|
+
export { parseMotion3 } from "./parse-motion3.js";
|
|
8
|
+
export type {
|
|
9
|
+
Motion3Clip,
|
|
10
|
+
MotionApplySample,
|
|
11
|
+
MotionCurve,
|
|
12
|
+
MotionCurveTarget,
|
|
13
|
+
MotionPoint,
|
|
14
|
+
MotionPriorityLevel,
|
|
15
|
+
MotionSegment,
|
|
16
|
+
MotionSegmentKind,
|
|
17
|
+
MotionUserData,
|
|
18
|
+
PlayMotionOptions,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
export { MotionPriority } from "./types.js";
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { evaluateMotion3 } from "./evaluate-curve.js";
|
|
2
|
+
import type {
|
|
3
|
+
Motion3Clip,
|
|
4
|
+
MotionApplySample,
|
|
5
|
+
MotionPriorityLevel,
|
|
6
|
+
PlayMotionOptions,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
import { MotionPriority } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export interface MotionPlayerHandlers {
|
|
11
|
+
onStart?: (info: { group: string; index: number; slot: string }) => void;
|
|
12
|
+
onFinish?: (info: { group: string; index: number; slot: string }) => void;
|
|
13
|
+
onEvent?: (info: {
|
|
14
|
+
group: string;
|
|
15
|
+
index: number;
|
|
16
|
+
slot: string;
|
|
17
|
+
time: number;
|
|
18
|
+
value: string;
|
|
19
|
+
}) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ActiveMotion {
|
|
23
|
+
slot: string;
|
|
24
|
+
group: string;
|
|
25
|
+
index: number;
|
|
26
|
+
clip: Motion3Clip;
|
|
27
|
+
priority: MotionPriorityLevel;
|
|
28
|
+
loop: boolean;
|
|
29
|
+
fadeInTime: number;
|
|
30
|
+
fadeOutTime: number;
|
|
31
|
+
time: number;
|
|
32
|
+
fadingOut: boolean;
|
|
33
|
+
fadeOutElapsed: number;
|
|
34
|
+
lastEventIndex: number;
|
|
35
|
+
started: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface QueuedMotion {
|
|
39
|
+
group: string;
|
|
40
|
+
index: number;
|
|
41
|
+
clip: Motion3Clip;
|
|
42
|
+
options: PlayMotionOptions;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Multi-slot motion player with per-slot queue.
|
|
47
|
+
*
|
|
48
|
+
* Default slot is `priority:{n}` so idle (1) and normal (2) can run together.
|
|
49
|
+
* Same slot replaces (with fade-out) unless `queue: true`.
|
|
50
|
+
*/
|
|
51
|
+
export class MotionPlayer {
|
|
52
|
+
#slots = new Map<string, ActiveMotion>();
|
|
53
|
+
#queues = new Map<string, QueuedMotion[]>();
|
|
54
|
+
#handlers: MotionPlayerHandlers;
|
|
55
|
+
|
|
56
|
+
constructor(handlers: MotionPlayerHandlers = {}) {
|
|
57
|
+
this.#handlers = handlers;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get isPlaying(): boolean {
|
|
61
|
+
return this.#slots.size > 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
listPlaying(): ReadonlyArray<{
|
|
65
|
+
slot: string;
|
|
66
|
+
group: string;
|
|
67
|
+
index: number;
|
|
68
|
+
time: number;
|
|
69
|
+
priority: MotionPriorityLevel;
|
|
70
|
+
}> {
|
|
71
|
+
return [...this.#slots.values()].map((a) => ({
|
|
72
|
+
slot: a.slot,
|
|
73
|
+
group: a.group,
|
|
74
|
+
index: a.index,
|
|
75
|
+
time: a.time,
|
|
76
|
+
priority: a.priority,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @deprecated Prefer {@link listPlaying}; returns highest-priority slot. */
|
|
81
|
+
get current(): {
|
|
82
|
+
group: string;
|
|
83
|
+
index: number;
|
|
84
|
+
time: number;
|
|
85
|
+
priority: MotionPriorityLevel;
|
|
86
|
+
} | null {
|
|
87
|
+
const list = [...this.listPlaying()];
|
|
88
|
+
if (!list.length) return null;
|
|
89
|
+
list.sort((a, b) => b.priority - a.priority);
|
|
90
|
+
const top = list[0]!;
|
|
91
|
+
return {
|
|
92
|
+
group: top.group,
|
|
93
|
+
index: top.index,
|
|
94
|
+
time: top.time,
|
|
95
|
+
priority: top.priority,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Start a clip on a slot. Returns false if rejected by priority
|
|
101
|
+
* (and not queued).
|
|
102
|
+
*/
|
|
103
|
+
start(
|
|
104
|
+
group: string,
|
|
105
|
+
index: number,
|
|
106
|
+
clip: Motion3Clip,
|
|
107
|
+
options: PlayMotionOptions = {},
|
|
108
|
+
): boolean {
|
|
109
|
+
const priority = options.priority ?? MotionPriority.normal;
|
|
110
|
+
const slot = options.slot ?? `priority:${priority}`;
|
|
111
|
+
const existing = this.#slots.get(slot);
|
|
112
|
+
|
|
113
|
+
if (existing && priority < existing.priority) {
|
|
114
|
+
if (options.queue) {
|
|
115
|
+
this.#enqueue(slot, { group, index, clip, options });
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (existing && options.queue && !existing.fadingOut) {
|
|
122
|
+
this.#enqueue(slot, { group, index, clip, options });
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (existing) {
|
|
127
|
+
// Replace: fade out current, then start — or hard-swap if no fade.
|
|
128
|
+
if (existing.fadeOutTime > 0 && !existing.fadingOut) {
|
|
129
|
+
existing.fadingOut = true;
|
|
130
|
+
existing.fadeOutElapsed = 0;
|
|
131
|
+
this.#enqueueFront(slot, { group, index, clip, options });
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
this.#finish(existing, false);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.#slots.set(
|
|
138
|
+
slot,
|
|
139
|
+
this.#createActive(slot, group, index, clip, options),
|
|
140
|
+
);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Fade out (default) or hard-stop; `slot` omits → all slots. */
|
|
145
|
+
stop(fade = true, slot?: string): void {
|
|
146
|
+
if (slot !== undefined) {
|
|
147
|
+
const a = this.#slots.get(slot);
|
|
148
|
+
if (!a) return;
|
|
149
|
+
this.#stopOne(a, fade);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
for (const a of [...this.#slots.values()]) {
|
|
153
|
+
this.#stopOne(a, fade);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
clear(): void {
|
|
158
|
+
this.#slots.clear();
|
|
159
|
+
this.#queues.clear();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Advance all slots and return blended samples (weight baked in; apply as absolute).
|
|
164
|
+
*/
|
|
165
|
+
update(deltaTimeSeconds: number): MotionApplySample[] {
|
|
166
|
+
const dt = Math.max(0, deltaTimeSeconds);
|
|
167
|
+
const layerSamples: Array<{
|
|
168
|
+
priority: number;
|
|
169
|
+
samples: MotionApplySample[];
|
|
170
|
+
}> = [];
|
|
171
|
+
|
|
172
|
+
for (const a of [...this.#slots.values()]) {
|
|
173
|
+
const samples = this.#tick(a, dt);
|
|
174
|
+
if (samples) {
|
|
175
|
+
layerSamples.push({ priority: a.priority, samples });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
layerSamples.sort((a, b) => a.priority - b.priority);
|
|
180
|
+
return blendMotionLayers(layerSamples);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#tick(a: ActiveMotion, dt: number): MotionApplySample[] | null {
|
|
184
|
+
if (!a.started) {
|
|
185
|
+
a.started = true;
|
|
186
|
+
this.#handlers.onStart?.({
|
|
187
|
+
group: a.group,
|
|
188
|
+
index: a.index,
|
|
189
|
+
slot: a.slot,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
a.time += dt;
|
|
194
|
+
|
|
195
|
+
if (a.fadingOut) {
|
|
196
|
+
a.fadeOutElapsed += dt;
|
|
197
|
+
if (a.fadeOutElapsed >= a.fadeOutTime) {
|
|
198
|
+
this.#finish(a, true);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
} else if (!a.loop && a.time >= a.clip.duration) {
|
|
202
|
+
if (a.fadeOutTime > 0) {
|
|
203
|
+
a.fadingOut = true;
|
|
204
|
+
a.fadeOutElapsed = 0;
|
|
205
|
+
} else {
|
|
206
|
+
const samples = this.#sample(a, a.clip.duration, 1);
|
|
207
|
+
this.#finish(a, true);
|
|
208
|
+
return samples;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let playTime = a.time;
|
|
213
|
+
if (a.loop && a.clip.duration > 0) {
|
|
214
|
+
playTime = a.time % a.clip.duration;
|
|
215
|
+
} else {
|
|
216
|
+
playTime = Math.min(playTime, a.clip.duration);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this.#emitEvents(a, playTime);
|
|
220
|
+
return this.#sample(a, playTime, this.#fadeWeight(a));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#createActive(
|
|
224
|
+
slot: string,
|
|
225
|
+
group: string,
|
|
226
|
+
index: number,
|
|
227
|
+
clip: Motion3Clip,
|
|
228
|
+
options: PlayMotionOptions,
|
|
229
|
+
): ActiveMotion {
|
|
230
|
+
const fadeIn =
|
|
231
|
+
options.fadeInTime ?? (clip.fadeInTime > 0 ? clip.fadeInTime : 0);
|
|
232
|
+
const fadeOut =
|
|
233
|
+
options.fadeOutTime ??
|
|
234
|
+
(clip.fadeOutTime > 0 ? clip.fadeOutTime : 0);
|
|
235
|
+
return {
|
|
236
|
+
slot,
|
|
237
|
+
group,
|
|
238
|
+
index,
|
|
239
|
+
clip,
|
|
240
|
+
priority: options.priority ?? MotionPriority.normal,
|
|
241
|
+
loop: options.loop ?? clip.loop,
|
|
242
|
+
fadeInTime: Math.max(0, fadeIn),
|
|
243
|
+
fadeOutTime: Math.max(0, fadeOut),
|
|
244
|
+
time: 0,
|
|
245
|
+
fadingOut: false,
|
|
246
|
+
fadeOutElapsed: 0,
|
|
247
|
+
lastEventIndex: -1,
|
|
248
|
+
started: false,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#enqueue(slot: string, item: QueuedMotion): void {
|
|
253
|
+
const q = this.#queues.get(slot) ?? [];
|
|
254
|
+
q.push(item);
|
|
255
|
+
this.#queues.set(slot, q);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#enqueueFront(slot: string, item: QueuedMotion): void {
|
|
259
|
+
const q = this.#queues.get(slot) ?? [];
|
|
260
|
+
q.unshift(item);
|
|
261
|
+
this.#queues.set(slot, q);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#stopOne(a: ActiveMotion, fade: boolean): void {
|
|
265
|
+
if (!fade || a.fadeOutTime <= 0) {
|
|
266
|
+
this.#finish(a, true);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
a.fadingOut = true;
|
|
270
|
+
a.fadeOutElapsed = 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#finish(a: ActiveMotion, promoteQueue: boolean): void {
|
|
274
|
+
if (this.#slots.get(a.slot) !== a) return;
|
|
275
|
+
this.#slots.delete(a.slot);
|
|
276
|
+
this.#handlers.onFinish?.({
|
|
277
|
+
group: a.group,
|
|
278
|
+
index: a.index,
|
|
279
|
+
slot: a.slot,
|
|
280
|
+
});
|
|
281
|
+
if (!promoteQueue) return;
|
|
282
|
+
const q = this.#queues.get(a.slot);
|
|
283
|
+
const next = q?.shift();
|
|
284
|
+
if (next) {
|
|
285
|
+
this.#slots.set(
|
|
286
|
+
a.slot,
|
|
287
|
+
this.#createActive(
|
|
288
|
+
a.slot,
|
|
289
|
+
next.group,
|
|
290
|
+
next.index,
|
|
291
|
+
next.clip,
|
|
292
|
+
next.options,
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#sample(
|
|
299
|
+
a: ActiveMotion,
|
|
300
|
+
playTime: number,
|
|
301
|
+
weight: number,
|
|
302
|
+
): MotionApplySample[] {
|
|
303
|
+
const values = evaluateMotion3(a.clip, playTime);
|
|
304
|
+
return values.map((v) => ({
|
|
305
|
+
target: v.target,
|
|
306
|
+
id: v.id,
|
|
307
|
+
value: v.value,
|
|
308
|
+
weight,
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#fadeWeight(a: ActiveMotion): number {
|
|
313
|
+
let w = 1;
|
|
314
|
+
if (a.fadeInTime > 0 && a.time < a.fadeInTime) {
|
|
315
|
+
w = sineEase(a.time / a.fadeInTime);
|
|
316
|
+
}
|
|
317
|
+
if (a.fadingOut && a.fadeOutTime > 0) {
|
|
318
|
+
const u = 1 - a.fadeOutElapsed / a.fadeOutTime;
|
|
319
|
+
w *= sineEase(Math.max(0, u));
|
|
320
|
+
}
|
|
321
|
+
return w;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#emitEvents(a: ActiveMotion, playTime: number): void {
|
|
325
|
+
const events = a.clip.userData;
|
|
326
|
+
for (let i = a.lastEventIndex + 1; i < events.length; i += 1) {
|
|
327
|
+
const e = events[i]!;
|
|
328
|
+
if (e.time > playTime) break;
|
|
329
|
+
a.lastEventIndex = i;
|
|
330
|
+
this.#handlers.onEvent?.({
|
|
331
|
+
group: a.group,
|
|
332
|
+
index: a.index,
|
|
333
|
+
slot: a.slot,
|
|
334
|
+
time: e.time,
|
|
335
|
+
value: e.value,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (a.loop && a.clip.duration > 0) {
|
|
339
|
+
const prevMod =
|
|
340
|
+
((a.time - 1e-6) % a.clip.duration) +
|
|
341
|
+
(a.time - 1e-6 < 0 ? a.clip.duration : 0);
|
|
342
|
+
if (playTime < prevMod - 1e-4) {
|
|
343
|
+
a.lastEventIndex = -1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Blend layers low→high priority; later layers lerp over earlier by their weight. */
|
|
350
|
+
export function blendMotionLayers(
|
|
351
|
+
layers: ReadonlyArray<{
|
|
352
|
+
priority: number;
|
|
353
|
+
samples: readonly MotionApplySample[];
|
|
354
|
+
}>,
|
|
355
|
+
): MotionApplySample[] {
|
|
356
|
+
const map = new Map<
|
|
357
|
+
string,
|
|
358
|
+
{
|
|
359
|
+
target: MotionApplySample["target"];
|
|
360
|
+
id: string;
|
|
361
|
+
value: number;
|
|
362
|
+
weight: number;
|
|
363
|
+
}
|
|
364
|
+
>();
|
|
365
|
+
for (const layer of layers) {
|
|
366
|
+
for (const s of layer.samples) {
|
|
367
|
+
const key = `${s.target}\0${s.id}`;
|
|
368
|
+
const w = Math.min(1, Math.max(0, s.weight));
|
|
369
|
+
const prev = map.get(key);
|
|
370
|
+
if (!prev) {
|
|
371
|
+
map.set(key, {
|
|
372
|
+
target: s.target,
|
|
373
|
+
id: s.id,
|
|
374
|
+
value: s.value,
|
|
375
|
+
weight: w,
|
|
376
|
+
});
|
|
377
|
+
} else {
|
|
378
|
+
prev.value = prev.value + (s.value - prev.value) * w;
|
|
379
|
+
prev.weight = Math.min(1, prev.weight + w * (1 - prev.weight));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return [...map.values()];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function sineEase(t: number): number {
|
|
387
|
+
const x = Math.min(1, Math.max(0, t));
|
|
388
|
+
return 0.5 - 0.5 * Math.cos(x * Math.PI);
|
|
389
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Motion3Clip,
|
|
3
|
+
MotionCurve,
|
|
4
|
+
MotionCurveTarget,
|
|
5
|
+
MotionPoint,
|
|
6
|
+
MotionSegment,
|
|
7
|
+
MotionSegmentKind,
|
|
8
|
+
MotionUserData,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
|
|
11
|
+
const SEGMENT_KIND: Record<number, MotionSegmentKind> = {
|
|
12
|
+
0: "linear",
|
|
13
|
+
1: "bezier",
|
|
14
|
+
2: "stepped",
|
|
15
|
+
3: "inverseStepped",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse Cubism `motion3.json` (FileFormats/motion3.json.md).
|
|
20
|
+
*/
|
|
21
|
+
export function parseMotion3(json: unknown): Motion3Clip {
|
|
22
|
+
if (!json || typeof json !== "object") {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"@doki-land/live2d: motion3.json root must be an object",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const root = json as Record<string, unknown>;
|
|
28
|
+
const version = Number(root.Version ?? 3);
|
|
29
|
+
const meta = root.Meta;
|
|
30
|
+
if (!meta || typeof meta !== "object") {
|
|
31
|
+
throw new Error("@doki-land/live2d: motion3.json missing Meta");
|
|
32
|
+
}
|
|
33
|
+
const m = meta as Record<string, unknown>;
|
|
34
|
+
const duration = num(m.Duration, "Meta.Duration");
|
|
35
|
+
const fps = num(m.Fps, "Meta.Fps");
|
|
36
|
+
const loop = m.Loop === true;
|
|
37
|
+
const areBeziersRestricted = m.AreBeziersRestricted !== false;
|
|
38
|
+
const fadeInTime = optionalNum(m.FadeInTime) ?? 0;
|
|
39
|
+
const fadeOutTime = optionalNum(m.FadeOutTime) ?? 0;
|
|
40
|
+
|
|
41
|
+
const curvesRaw = root.Curves;
|
|
42
|
+
if (!Array.isArray(curvesRaw)) {
|
|
43
|
+
throw new Error("@doki-land/live2d: motion3.json missing Curves");
|
|
44
|
+
}
|
|
45
|
+
const curves = curvesRaw.map((c, i) => parseCurve(c, i));
|
|
46
|
+
|
|
47
|
+
const userData: MotionUserData[] = [];
|
|
48
|
+
if (Array.isArray(root.UserData)) {
|
|
49
|
+
for (const item of root.UserData) {
|
|
50
|
+
if (!item || typeof item !== "object") continue;
|
|
51
|
+
const u = item as Record<string, unknown>;
|
|
52
|
+
if (typeof u.Time === "number" && typeof u.Value === "string") {
|
|
53
|
+
userData.push({ time: u.Time, value: u.Value });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
userData.sort((a, b) => a.time - b.time);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
version,
|
|
61
|
+
duration,
|
|
62
|
+
fps,
|
|
63
|
+
loop,
|
|
64
|
+
areBeziersRestricted,
|
|
65
|
+
fadeInTime,
|
|
66
|
+
fadeOutTime,
|
|
67
|
+
curves,
|
|
68
|
+
userData,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseCurve(raw: unknown, index: number): MotionCurve {
|
|
73
|
+
if (!raw || typeof raw !== "object") {
|
|
74
|
+
throw new Error(`@doki-land/live2d: Curves[${index}] invalid`);
|
|
75
|
+
}
|
|
76
|
+
const c = raw as Record<string, unknown>;
|
|
77
|
+
const target = c.Target;
|
|
78
|
+
const id = c.Id;
|
|
79
|
+
if (typeof target !== "string" || typeof id !== "string") {
|
|
80
|
+
throw new Error(`@doki-land/live2d: Curves[${index}] needs Target/Id`);
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
target !== "Parameter" &&
|
|
84
|
+
target !== "PartOpacity" &&
|
|
85
|
+
target !== "Model"
|
|
86
|
+
) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`@doki-land/live2d: Curves[${index}] unknown Target ${target}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const segmentsFlat = c.Segments;
|
|
92
|
+
if (!Array.isArray(segmentsFlat) || segmentsFlat.length < 2) {
|
|
93
|
+
throw new Error(`@doki-land/live2d: Curves[${index}] empty Segments`);
|
|
94
|
+
}
|
|
95
|
+
const numbers = segmentsFlat.map((n, j) => {
|
|
96
|
+
if (typeof n !== "number" || !Number.isFinite(n)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`@doki-land/live2d: Curves[${index}].Segments[${j}] not a number`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return n;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
target: target as MotionCurveTarget,
|
|
106
|
+
id,
|
|
107
|
+
fadeInTime: optionalNum(c.FadeInTime),
|
|
108
|
+
fadeOutTime: optionalNum(c.FadeOutTime),
|
|
109
|
+
segments: parseSegments(numbers, index),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseSegments(
|
|
114
|
+
flat: readonly number[],
|
|
115
|
+
curveIndex: number,
|
|
116
|
+
): MotionSegment[] {
|
|
117
|
+
let i = 0;
|
|
118
|
+
const p0: MotionPoint = { time: flat[i++]!, value: flat[i++]! };
|
|
119
|
+
const out: MotionSegment[] = [];
|
|
120
|
+
let prev = p0;
|
|
121
|
+
|
|
122
|
+
while (i < flat.length) {
|
|
123
|
+
const kindId = flat[i++]!;
|
|
124
|
+
const kind = SEGMENT_KIND[kindId];
|
|
125
|
+
if (!kind) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`@doki-land/live2d: Curves[${curveIndex}] unknown segment ${kindId}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (kind === "bezier") {
|
|
131
|
+
if (i + 5 >= flat.length) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`@doki-land/live2d: Curves[${curveIndex}] truncated bezier`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const p1: MotionPoint = { time: flat[i++]!, value: flat[i++]! };
|
|
137
|
+
const p2: MotionPoint = { time: flat[i++]!, value: flat[i++]! };
|
|
138
|
+
const p3: MotionPoint = { time: flat[i++]!, value: flat[i++]! };
|
|
139
|
+
out.push({ kind, p0: prev, p1, p2, p3 });
|
|
140
|
+
prev = p3;
|
|
141
|
+
} else {
|
|
142
|
+
if (i + 1 >= flat.length) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`@doki-land/live2d: Curves[${curveIndex}] truncated ${kind}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const p3: MotionPoint = { time: flat[i++]!, value: flat[i++]! };
|
|
148
|
+
out.push({ kind, p0: prev, p3 });
|
|
149
|
+
prev = p3;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function num(v: unknown, label: string): number {
|
|
156
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
157
|
+
throw new Error(`@doki-land/live2d: motion3 ${label} must be a number`);
|
|
158
|
+
}
|
|
159
|
+
return v;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function optionalNum(v: unknown): number | undefined {
|
|
163
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
164
|
+
}
|