@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,89 @@
|
|
|
1
|
+
/** Cubism motion3 segment kinds (spec). */
|
|
2
|
+
export type MotionSegmentKind =
|
|
3
|
+
| "linear"
|
|
4
|
+
| "bezier"
|
|
5
|
+
| "stepped"
|
|
6
|
+
| "inverseStepped";
|
|
7
|
+
|
|
8
|
+
export type MotionCurveTarget = "Parameter" | "PartOpacity" | "Model";
|
|
9
|
+
|
|
10
|
+
export interface MotionPoint {
|
|
11
|
+
readonly time: number;
|
|
12
|
+
readonly value: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MotionSegment {
|
|
16
|
+
readonly kind: MotionSegmentKind;
|
|
17
|
+
/** Segment start (inclusive). */
|
|
18
|
+
readonly p0: MotionPoint;
|
|
19
|
+
/** Bezier controls (bezier only). */
|
|
20
|
+
readonly p1?: MotionPoint;
|
|
21
|
+
readonly p2?: MotionPoint;
|
|
22
|
+
/** Segment end. */
|
|
23
|
+
readonly p3: MotionPoint;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MotionCurve {
|
|
27
|
+
readonly target: MotionCurveTarget;
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly fadeInTime?: number;
|
|
30
|
+
readonly fadeOutTime?: number;
|
|
31
|
+
readonly segments: readonly MotionSegment[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MotionUserData {
|
|
35
|
+
readonly time: number;
|
|
36
|
+
readonly value: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Motion3Clip {
|
|
40
|
+
readonly version: number;
|
|
41
|
+
readonly duration: number;
|
|
42
|
+
readonly fps: number;
|
|
43
|
+
readonly loop: boolean;
|
|
44
|
+
readonly areBeziersRestricted: boolean;
|
|
45
|
+
readonly fadeInTime: number;
|
|
46
|
+
readonly fadeOutTime: number;
|
|
47
|
+
readonly curves: readonly MotionCurve[];
|
|
48
|
+
readonly userData: readonly MotionUserData[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Cubism-style priority: higher wins; equal may replace. */
|
|
52
|
+
export const MotionPriority = {
|
|
53
|
+
none: 0,
|
|
54
|
+
idle: 1,
|
|
55
|
+
normal: 2,
|
|
56
|
+
force: 3,
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
export type MotionPriorityLevel =
|
|
60
|
+
(typeof MotionPriority)[keyof typeof MotionPriority];
|
|
61
|
+
|
|
62
|
+
export interface PlayMotionOptions {
|
|
63
|
+
/** Default {@link MotionPriority.normal}. */
|
|
64
|
+
priority?: MotionPriorityLevel;
|
|
65
|
+
/**
|
|
66
|
+
* Parallel layer id. Default `priority:{n}` so different priorities
|
|
67
|
+
* can play together; same slot replaces or queues.
|
|
68
|
+
*/
|
|
69
|
+
slot?: string;
|
|
70
|
+
/**
|
|
71
|
+
* When the slot is busy, enqueue instead of replacing / rejecting.
|
|
72
|
+
* Default false.
|
|
73
|
+
*/
|
|
74
|
+
queue?: boolean;
|
|
75
|
+
/** Override clip Meta.Loop. */
|
|
76
|
+
loop?: boolean;
|
|
77
|
+
/** Override fade-in seconds (clip / definition). */
|
|
78
|
+
fadeInTime?: number;
|
|
79
|
+
/** Override fade-out seconds. */
|
|
80
|
+
fadeOutTime?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface MotionApplySample {
|
|
84
|
+
readonly target: MotionCurveTarget;
|
|
85
|
+
readonly id: string;
|
|
86
|
+
readonly value: number;
|
|
87
|
+
/** 0..1 fade weight for this frame. */
|
|
88
|
+
readonly weight: number;
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "@doki-land/live2d-core";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "@doki-land/live2d-loader";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "@doki-land/live2d-renderer";
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssetResolver,
|
|
3
|
+
InternalModel,
|
|
4
|
+
LoadProgress,
|
|
5
|
+
ModelSource,
|
|
6
|
+
} from "@doki-land/live2d-core";
|
|
7
|
+
import { modelSourceUrl } from "@doki-land/live2d-core";
|
|
8
|
+
import {
|
|
9
|
+
createUrlAssetResolver,
|
|
10
|
+
fetchModelJson,
|
|
11
|
+
normalizeModelSettings,
|
|
12
|
+
resolveModelSourceUrl,
|
|
13
|
+
} from "@doki-land/live2d-loader";
|
|
14
|
+
import type {
|
|
15
|
+
DrawableMesh,
|
|
16
|
+
ModelBackend,
|
|
17
|
+
ParameterBinding,
|
|
18
|
+
Renderer,
|
|
19
|
+
TextureData,
|
|
20
|
+
} from "@doki-land/live2d-renderer";
|
|
21
|
+
import { selectModelBackend } from "@doki-land/live2d-renderer";
|
|
22
|
+
import { loadTextureData, releaseTextureData } from "../load-textures.js";
|
|
23
|
+
import {
|
|
24
|
+
type Motion3Clip,
|
|
25
|
+
MotionPlayer,
|
|
26
|
+
MotionPriority,
|
|
27
|
+
type PlayMotionOptions,
|
|
28
|
+
parseMotion3,
|
|
29
|
+
} from "../motion/index.js";
|
|
30
|
+
|
|
31
|
+
function lerp(a: number, b: number, t: number): number {
|
|
32
|
+
return a + (b - a) * Math.min(1, Math.max(0, t));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ActorModelSlotOptions {
|
|
36
|
+
backends: readonly ModelBackend[];
|
|
37
|
+
renderer: Renderer;
|
|
38
|
+
onProgress?: (payload: LoadProgress) => void;
|
|
39
|
+
onMotionStart?: (payload: {
|
|
40
|
+
group: string;
|
|
41
|
+
index: number;
|
|
42
|
+
slot: string;
|
|
43
|
+
}) => void;
|
|
44
|
+
onMotionFinish?: (payload: {
|
|
45
|
+
group: string;
|
|
46
|
+
index: number;
|
|
47
|
+
slot: string;
|
|
48
|
+
}) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One loaded model + draw pass owned by an actor (not a renderer). */
|
|
52
|
+
export class ActorModelSlot {
|
|
53
|
+
readonly #backends: readonly ModelBackend[];
|
|
54
|
+
readonly #renderer: Renderer;
|
|
55
|
+
readonly #onProgress?: (payload: LoadProgress) => void;
|
|
56
|
+
readonly #motionPlayer: MotionPlayer;
|
|
57
|
+
readonly #motionCache = new Map<string, Motion3Clip>();
|
|
58
|
+
|
|
59
|
+
#drawPass: ReturnType<Renderer["createModelDrawPass"]> | null = null;
|
|
60
|
+
#model: InternalModel | null = null;
|
|
61
|
+
#backend: ModelBackend | null = null;
|
|
62
|
+
#textures: TextureData[] = [];
|
|
63
|
+
#resolver: AssetResolver | null = null;
|
|
64
|
+
#loadGeneration = 0;
|
|
65
|
+
|
|
66
|
+
constructor(options: ActorModelSlotOptions) {
|
|
67
|
+
this.#backends = options.backends;
|
|
68
|
+
this.#renderer = options.renderer;
|
|
69
|
+
this.#onProgress = options.onProgress;
|
|
70
|
+
this.#motionPlayer = new MotionPlayer({
|
|
71
|
+
onStart: options.onMotionStart,
|
|
72
|
+
onFinish: options.onMotionFinish,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get model(): InternalModel | null {
|
|
77
|
+
return this.#model;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get drawPass(): ReturnType<Renderer["createModelDrawPass"]> | null {
|
|
81
|
+
return this.#drawPass;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
ensureDrawPass(): ReturnType<Renderer["createModelDrawPass"]> {
|
|
85
|
+
if (!this.#drawPass) {
|
|
86
|
+
this.#drawPass = this.#renderer.createModelDrawPass();
|
|
87
|
+
}
|
|
88
|
+
return this.#drawPass;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#report(payload: LoadProgress): void {
|
|
92
|
+
this.#onProgress?.(payload);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#clearTextures(): void {
|
|
96
|
+
if (this.#textures.length > 0) {
|
|
97
|
+
releaseTextureData(this.#textures);
|
|
98
|
+
this.#textures = [];
|
|
99
|
+
}
|
|
100
|
+
this.#drawPass?.setTextures([]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#applyMotionSamples(samples: ReturnType<MotionPlayer["update"]>): void {
|
|
104
|
+
if (!this.#model || !this.#backend) return;
|
|
105
|
+
for (const s of samples) {
|
|
106
|
+
if (s.weight <= 0) continue;
|
|
107
|
+
if (s.target === "PartOpacity") {
|
|
108
|
+
if (!this.#backend.setPartOpacity) continue;
|
|
109
|
+
if (s.weight >= 1) {
|
|
110
|
+
this.#backend.setPartOpacity(this.#model, s.id, s.value);
|
|
111
|
+
} else {
|
|
112
|
+
const cur = 1;
|
|
113
|
+
this.#backend.setPartOpacity(
|
|
114
|
+
this.#model,
|
|
115
|
+
s.id,
|
|
116
|
+
cur + (s.value - cur) * s.weight,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (s.target !== "Parameter" || !this.#backend.setParameter)
|
|
122
|
+
continue;
|
|
123
|
+
if (s.weight >= 1) {
|
|
124
|
+
this.#backend.setParameter(this.#model, s.id, s.value);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const cur =
|
|
128
|
+
this.#backend
|
|
129
|
+
.listParameters?.(this.#model)
|
|
130
|
+
.find((p) => p.id === s.id)?.value ?? s.value;
|
|
131
|
+
this.#backend.setParameter(
|
|
132
|
+
this.#model,
|
|
133
|
+
s.id,
|
|
134
|
+
cur + (s.value - cur) * s.weight,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async load(
|
|
140
|
+
source: ModelSource,
|
|
141
|
+
resolver?: AssetResolver,
|
|
142
|
+
): Promise<InternalModel> {
|
|
143
|
+
const gen = ++this.#loadGeneration;
|
|
144
|
+
this.#report({
|
|
145
|
+
stage: "mounting",
|
|
146
|
+
progress: 0.01,
|
|
147
|
+
detail: "prepare draw pass",
|
|
148
|
+
});
|
|
149
|
+
const drawPass = this.ensureDrawPass();
|
|
150
|
+
|
|
151
|
+
this.#report({
|
|
152
|
+
stage: "resolve",
|
|
153
|
+
progress: 0.02,
|
|
154
|
+
detail: "resolve source",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
let json: unknown;
|
|
158
|
+
let baseUrl: string;
|
|
159
|
+
let settingsUrl: string;
|
|
160
|
+
|
|
161
|
+
if (typeof source === "object" && source.kind === "json") {
|
|
162
|
+
json = source.json;
|
|
163
|
+
baseUrl = source.baseUrl;
|
|
164
|
+
settingsUrl = source.baseUrl;
|
|
165
|
+
this.#report({
|
|
166
|
+
stage: "settings",
|
|
167
|
+
progress: 0.2,
|
|
168
|
+
detail: "inline settings",
|
|
169
|
+
});
|
|
170
|
+
} else {
|
|
171
|
+
const raw =
|
|
172
|
+
typeof source === "string"
|
|
173
|
+
? source
|
|
174
|
+
: source.kind === "npm"
|
|
175
|
+
? modelSourceUrl(source)
|
|
176
|
+
: source.url;
|
|
177
|
+
const cdnBase =
|
|
178
|
+
typeof source === "object" && source.kind === "npm"
|
|
179
|
+
? source.cdnBase
|
|
180
|
+
: undefined;
|
|
181
|
+
const fetchUrl = resolveModelSourceUrl(raw, {
|
|
182
|
+
npmCdnBase: cdnBase,
|
|
183
|
+
});
|
|
184
|
+
this.#report({
|
|
185
|
+
stage: "settings",
|
|
186
|
+
progress: 0.05,
|
|
187
|
+
detail: fetchUrl,
|
|
188
|
+
});
|
|
189
|
+
json = await fetchModelJson(fetchUrl, (u) => {
|
|
190
|
+
const ratio =
|
|
191
|
+
u.bytesTotal && u.bytesTotal > 0
|
|
192
|
+
? u.bytesLoaded / u.bytesTotal
|
|
193
|
+
: 0;
|
|
194
|
+
this.#report({
|
|
195
|
+
stage: "settings",
|
|
196
|
+
progress: lerp(0.05, 0.22, ratio),
|
|
197
|
+
detail: fetchUrl,
|
|
198
|
+
bytesLoaded: u.bytesLoaded,
|
|
199
|
+
bytesTotal: u.bytesTotal,
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
baseUrl = fetchUrl;
|
|
203
|
+
settingsUrl = fetchUrl;
|
|
204
|
+
}
|
|
205
|
+
if (gen !== this.#loadGeneration) {
|
|
206
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const settings = normalizeModelSettings(json, settingsUrl);
|
|
210
|
+
this.#report({
|
|
211
|
+
stage: "moc",
|
|
212
|
+
progress: 0.25,
|
|
213
|
+
detail: settings.moc,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const assetResolver =
|
|
217
|
+
resolver ??
|
|
218
|
+
createUrlAssetResolver(baseUrl, {
|
|
219
|
+
onBytesProgress: (key, u) => {
|
|
220
|
+
const isMoc = key === settings.moc;
|
|
221
|
+
const ratio =
|
|
222
|
+
u.bytesTotal && u.bytesTotal > 0
|
|
223
|
+
? u.bytesLoaded / u.bytesTotal
|
|
224
|
+
: 0;
|
|
225
|
+
if (isMoc) {
|
|
226
|
+
this.#report({
|
|
227
|
+
stage: "moc",
|
|
228
|
+
progress: lerp(0.25, 0.8, ratio),
|
|
229
|
+
detail: key,
|
|
230
|
+
bytesLoaded: u.bytesLoaded,
|
|
231
|
+
bytesTotal: u.bytesTotal,
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
this.#report({
|
|
235
|
+
stage: "textures",
|
|
236
|
+
progress: lerp(0.8, 0.9, ratio),
|
|
237
|
+
detail: key,
|
|
238
|
+
bytesLoaded: u.bytesLoaded,
|
|
239
|
+
bytesTotal: u.bytesTotal,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
this.#resolver = assetResolver;
|
|
245
|
+
this.#motionPlayer.clear();
|
|
246
|
+
this.#motionCache.clear();
|
|
247
|
+
|
|
248
|
+
const backend = selectModelBackend([...this.#backends], json);
|
|
249
|
+
this.#report({
|
|
250
|
+
stage: "decode",
|
|
251
|
+
progress: 0.85,
|
|
252
|
+
detail: `decode ${settings.format}`,
|
|
253
|
+
});
|
|
254
|
+
const next = await backend.createModel(settings, {
|
|
255
|
+
renderer: this.#renderer,
|
|
256
|
+
resolver: assetResolver,
|
|
257
|
+
});
|
|
258
|
+
if (gen !== this.#loadGeneration) {
|
|
259
|
+
backend.destroyModel(next);
|
|
260
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
this.#clearTextures();
|
|
264
|
+
if (settings.textures.length > 0) {
|
|
265
|
+
this.#report({
|
|
266
|
+
stage: "textures",
|
|
267
|
+
progress: 0.88,
|
|
268
|
+
detail: `${settings.textures.length} textures`,
|
|
269
|
+
});
|
|
270
|
+
const textures = await loadTextureData(
|
|
271
|
+
assetResolver,
|
|
272
|
+
settings.textures,
|
|
273
|
+
{
|
|
274
|
+
onProgress: (u) => {
|
|
275
|
+
const ratio = u.total > 0 ? (u.index + 1) / u.total : 1;
|
|
276
|
+
this.#report({
|
|
277
|
+
stage: "textures",
|
|
278
|
+
progress: lerp(0.88, 0.96, ratio),
|
|
279
|
+
detail: u.key,
|
|
280
|
+
bytesLoaded: u.bytesLoaded,
|
|
281
|
+
bytesTotal: u.bytesTotal,
|
|
282
|
+
});
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
);
|
|
286
|
+
if (gen !== this.#loadGeneration) {
|
|
287
|
+
releaseTextureData(textures);
|
|
288
|
+
backend.destroyModel(next);
|
|
289
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
290
|
+
}
|
|
291
|
+
this.#textures = textures;
|
|
292
|
+
drawPass.setTextures(textures);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (this.#model && this.#backend) {
|
|
296
|
+
this.#backend.destroyModel(this.#model);
|
|
297
|
+
}
|
|
298
|
+
this.#model = next;
|
|
299
|
+
this.#backend = backend;
|
|
300
|
+
this.#report({
|
|
301
|
+
stage: "ready",
|
|
302
|
+
progress: 1,
|
|
303
|
+
detail: next.id,
|
|
304
|
+
});
|
|
305
|
+
return next;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
setParameter(id: string, value: number): void {
|
|
309
|
+
if (!this.#model || !this.#backend?.setParameter) return;
|
|
310
|
+
this.#backend.setParameter(this.#model, id, value);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
listParameters(): readonly ParameterBinding[] {
|
|
314
|
+
if (!this.#model || !this.#backend?.listParameters) return [];
|
|
315
|
+
return this.#backend.listParameters(this.#model);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
listMotionGroups(): Record<
|
|
319
|
+
string,
|
|
320
|
+
readonly import("@doki-land/live2d-core").MotionDefinition[]
|
|
321
|
+
> {
|
|
322
|
+
return this.#model?.settings.motionGroups ?? {};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async playMotion(
|
|
326
|
+
group: string,
|
|
327
|
+
index = 0,
|
|
328
|
+
options: PlayMotionOptions = {},
|
|
329
|
+
): Promise<boolean> {
|
|
330
|
+
if (!this.#model || !this.#resolver) return false;
|
|
331
|
+
const list = this.#model.settings.motionGroups[group];
|
|
332
|
+
const def = list?.[index];
|
|
333
|
+
if (!def) return false;
|
|
334
|
+
|
|
335
|
+
let clip = this.#motionCache.get(def.file);
|
|
336
|
+
if (!clip) {
|
|
337
|
+
const json = await this.#resolver.fetchJson(def.file);
|
|
338
|
+
clip = parseMotion3(json);
|
|
339
|
+
this.#motionCache.set(def.file, clip);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const fadeInTime =
|
|
343
|
+
options.fadeInTime ?? def.fadeInTime ?? clip.fadeInTime;
|
|
344
|
+
const fadeOutTime =
|
|
345
|
+
options.fadeOutTime ?? def.fadeOutTime ?? clip.fadeOutTime;
|
|
346
|
+
|
|
347
|
+
return this.#motionPlayer.start(group, index, clip, {
|
|
348
|
+
priority: options.priority ?? MotionPriority.normal,
|
|
349
|
+
slot: options.slot,
|
|
350
|
+
queue: options.queue,
|
|
351
|
+
loop: options.loop,
|
|
352
|
+
fadeInTime,
|
|
353
|
+
fadeOutTime,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
stopMotion(opts?: { fade?: boolean; slot?: string }): void {
|
|
358
|
+
this.#motionPlayer.stop(opts?.fade !== false, opts?.slot);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
listPlayingMotions(): ReadonlyArray<{
|
|
362
|
+
slot: string;
|
|
363
|
+
group: string;
|
|
364
|
+
index: number;
|
|
365
|
+
time: number;
|
|
366
|
+
priority: number;
|
|
367
|
+
}> {
|
|
368
|
+
return this.#motionPlayer.listPlaying();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
update(deltaTimeSeconds: number): DrawableMesh[] | null {
|
|
372
|
+
if (!this.#model || !this.#backend || !this.#drawPass) return null;
|
|
373
|
+
this.#applyMotionSamples(this.#motionPlayer.update(deltaTimeSeconds));
|
|
374
|
+
this.#backend.updateModel(this.#model, deltaTimeSeconds);
|
|
375
|
+
return this.#backend.getDrawables(this.#model);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
hitTestModelCoords(modelX: number, modelY: number): string | null {
|
|
379
|
+
if (!this.#model || !this.#backend) return null;
|
|
380
|
+
const drawables = this.#backend.getDrawables(this.#model);
|
|
381
|
+
for (let n = drawables.length - 1; n >= 0; n -= 1) {
|
|
382
|
+
const d = drawables[n]!;
|
|
383
|
+
if (!d.visible || d.opacity <= 0) continue;
|
|
384
|
+
const p = d.vertexPositions;
|
|
385
|
+
const idx = d.indices;
|
|
386
|
+
for (let i = 0; i + 2 < idx.length; i += 3) {
|
|
387
|
+
const a = idx[i]! * 2,
|
|
388
|
+
b = idx[i + 1]! * 2,
|
|
389
|
+
c = idx[i + 2]! * 2;
|
|
390
|
+
const ax = p[a]!,
|
|
391
|
+
ay = p[a + 1]!;
|
|
392
|
+
const bx = p[b]!,
|
|
393
|
+
by = p[b + 1]!;
|
|
394
|
+
const cx = p[c]!,
|
|
395
|
+
cy = p[c + 1]!;
|
|
396
|
+
const s = (ax - cx) * (modelY - cy) - (ay - cy) * (modelX - cx);
|
|
397
|
+
const s1 =
|
|
398
|
+
(bx - ax) * (modelY - ay) - (by - ay) * (modelX - ax);
|
|
399
|
+
const s2 =
|
|
400
|
+
(cx - bx) * (modelY - by) - (cy - by) * (modelX - bx);
|
|
401
|
+
if (
|
|
402
|
+
(s >= 0 && s1 >= 0 && s2 >= 0) ||
|
|
403
|
+
(s <= 0 && s1 <= 0 && s2 <= 0)
|
|
404
|
+
) {
|
|
405
|
+
const hitArea = this.#model.settings.hitAreas.find(
|
|
406
|
+
(h) => h.id === `D_${d.index}` || h.id === `${d.index}`,
|
|
407
|
+
);
|
|
408
|
+
return hitArea?.name ?? `drawable:${d.index}`;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
destroy(): void {
|
|
416
|
+
this.#loadGeneration += 1;
|
|
417
|
+
this.#motionPlayer.clear();
|
|
418
|
+
this.#motionCache.clear();
|
|
419
|
+
this.#resolver = null;
|
|
420
|
+
if (this.#model && this.#backend) {
|
|
421
|
+
this.#backend.destroyModel(this.#model);
|
|
422
|
+
}
|
|
423
|
+
this.#model = null;
|
|
424
|
+
this.#backend = null;
|
|
425
|
+
this.#clearTextures();
|
|
426
|
+
this.#drawPass?.destroy();
|
|
427
|
+
this.#drawPass = null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActorHit,
|
|
3
|
+
ActorTransform,
|
|
4
|
+
CreateActorOptions,
|
|
5
|
+
InternalModel,
|
|
6
|
+
Live2dActor,
|
|
7
|
+
} from "@doki-land/live2d-core";
|
|
8
|
+
import type {
|
|
9
|
+
DrawableMesh,
|
|
10
|
+
ModelBackend,
|
|
11
|
+
Renderer,
|
|
12
|
+
} from "@doki-land/live2d-renderer";
|
|
13
|
+
import { focusParameterUpdates } from "../focus.js";
|
|
14
|
+
import { ActorModelSlot } from "./actor-model-slot.js";
|
|
15
|
+
import {
|
|
16
|
+
resolveActorTransform,
|
|
17
|
+
stageFocusDrag,
|
|
18
|
+
stageToModelNdc,
|
|
19
|
+
} from "./transform.js";
|
|
20
|
+
|
|
21
|
+
let nextActorId = 0;
|
|
22
|
+
|
|
23
|
+
export interface Live2dActorImplOptions {
|
|
24
|
+
id: string;
|
|
25
|
+
creationIndex: number;
|
|
26
|
+
backends: readonly ModelBackend[];
|
|
27
|
+
renderer: Renderer;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class Live2dActorImpl implements Live2dActor {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly creationIndex: number;
|
|
33
|
+
#transform: ActorTransform;
|
|
34
|
+
#visible = true;
|
|
35
|
+
#opacity = 1;
|
|
36
|
+
#layer = "characters";
|
|
37
|
+
#order = 0;
|
|
38
|
+
#destroyed = false;
|
|
39
|
+
#lastDrawables: DrawableMesh[] | null = null;
|
|
40
|
+
readonly #slot: ActorModelSlot;
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
options: CreateActorOptions | undefined,
|
|
44
|
+
shared: Live2dActorImplOptions,
|
|
45
|
+
) {
|
|
46
|
+
this.id = options?.id ?? shared.id;
|
|
47
|
+
this.creationIndex = shared.creationIndex;
|
|
48
|
+
this.#transform = resolveActorTransform(options?.transform);
|
|
49
|
+
this.#visible = options?.visible ?? true;
|
|
50
|
+
this.#opacity = options?.opacity ?? 1;
|
|
51
|
+
this.#layer = options?.layer ?? "characters";
|
|
52
|
+
this.#order = options?.order ?? 0;
|
|
53
|
+
this.#slot = new ActorModelSlot({
|
|
54
|
+
backends: shared.backends,
|
|
55
|
+
renderer: shared.renderer,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get model(): InternalModel | null {
|
|
60
|
+
return this.#slot.model;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get visible(): boolean {
|
|
64
|
+
return this.#visible;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
set visible(value: boolean) {
|
|
68
|
+
this.#visible = value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get opacity(): number {
|
|
72
|
+
return this.#opacity;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
set opacity(value: number) {
|
|
76
|
+
this.#opacity = Math.min(1, Math.max(0, value));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get layer(): string {
|
|
80
|
+
return this.#layer;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
set layer(value: string) {
|
|
84
|
+
this.#layer = value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get order(): number {
|
|
88
|
+
return this.#order;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
set order(value: number) {
|
|
92
|
+
this.#order = value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getTransform(): ActorTransform {
|
|
96
|
+
return { ...this.#transform };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setTransform(patch: Partial<ActorTransform>): void {
|
|
100
|
+
this.#transform = resolveActorTransform({
|
|
101
|
+
...this.#transform,
|
|
102
|
+
...patch,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async load(
|
|
107
|
+
source: Parameters<Live2dActor["load"]>[0],
|
|
108
|
+
resolver?: Parameters<Live2dActor["load"]>[1],
|
|
109
|
+
): Promise<InternalModel> {
|
|
110
|
+
if (this.#destroyed) {
|
|
111
|
+
throw new Error("@doki-land/live2d: actor destroyed");
|
|
112
|
+
}
|
|
113
|
+
return await this.#slot.load(source, resolver);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
setParameter(id: string, value: number): void {
|
|
117
|
+
this.#slot.setParameter(id, value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
lookAt(stageX: number, stageY: number): void {
|
|
121
|
+
const { dragX, dragY } = stageFocusDrag(
|
|
122
|
+
stageX,
|
|
123
|
+
stageY,
|
|
124
|
+
this.#transform,
|
|
125
|
+
);
|
|
126
|
+
for (const u of focusParameterUpdates(
|
|
127
|
+
this.#slot.listParameters(),
|
|
128
|
+
dragX,
|
|
129
|
+
dragY,
|
|
130
|
+
)) {
|
|
131
|
+
this.#slot.setParameter(u.id, u.value);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Internal: evaluate motion/physics and cache drawables for render. */
|
|
136
|
+
update(deltaTimeSeconds: number): DrawableMesh[] | null {
|
|
137
|
+
if (!this.#visible || this.#opacity <= 0) {
|
|
138
|
+
this.#lastDrawables = null;
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
this.#lastDrawables = this.#slot.update(deltaTimeSeconds);
|
|
142
|
+
return this.#lastDrawables;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get lastDrawables(): DrawableMesh[] | null {
|
|
146
|
+
return this.#lastDrawables;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get slot(): ActorModelSlot {
|
|
150
|
+
return this.#slot;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
hitTestStage(
|
|
154
|
+
stageX: number,
|
|
155
|
+
stageY: number,
|
|
156
|
+
): Omit<ActorHit, "actor"> | null {
|
|
157
|
+
if (!this.#visible || this.#opacity <= 0 || !this.#slot.model)
|
|
158
|
+
return null;
|
|
159
|
+
const { modelX, modelY } = stageToModelNdc(
|
|
160
|
+
stageX,
|
|
161
|
+
stageY,
|
|
162
|
+
this.#transform,
|
|
163
|
+
);
|
|
164
|
+
const area = this.#slot.hitTestModelCoords(modelX, modelY);
|
|
165
|
+
if (!area) return null;
|
|
166
|
+
const drawableMatch = /^drawable:(\d+)$/.exec(area);
|
|
167
|
+
return {
|
|
168
|
+
actorId: this.id,
|
|
169
|
+
area,
|
|
170
|
+
drawableIndex: drawableMatch ? Number(drawableMatch[1]) : -1,
|
|
171
|
+
stageX,
|
|
172
|
+
stageY,
|
|
173
|
+
localX: modelX,
|
|
174
|
+
localY: modelY,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
destroy(): void {
|
|
179
|
+
if (this.#destroyed) return;
|
|
180
|
+
this.#destroyed = true;
|
|
181
|
+
this.#slot.destroy();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function allocateActorId(prefix = "actor"): string {
|
|
186
|
+
nextActorId += 1;
|
|
187
|
+
return `${prefix}-${nextActorId}`;
|
|
188
|
+
}
|