@doki-land/live2d 0.0.22 → 0.0.24
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/dist/index.d.ts +128 -11
- package/dist/index.js +406 -44
- package/package.json +4 -4
- package/src/expression/apply-expression.ts +41 -0
- package/src/expression/index.ts +7 -0
- package/src/expression/parse-expression3.ts +45 -0
- package/src/expression/types.ts +13 -0
- package/src/facade/create-live2d.ts +10 -3
- package/src/index.ts +32 -4
- package/src/physics/apply-physics3.ts +26 -0
- package/src/physics/index.ts +7 -0
- package/src/physics/parse-physics3.ts +52 -0
- package/src/physics/types.ts +20 -0
- package/src/pose/apply-pose3.ts +19 -0
- package/src/pose/index.ts +3 -0
- package/src/pose/parse-pose3.ts +33 -0
- package/src/pose/types.ts +4 -0
- package/src/stage/actor-model-slot.ts +172 -21
- package/src/stage/actor.ts +36 -1
- package/src/stage/hit-area.ts +24 -0
- package/src/stage/index.ts +5 -1
- package/src/stage/model-asset-registry.ts +49 -15
- package/src/stage/single-facade.ts +27 -7
- package/src/stage/stage.ts +12 -0
|
@@ -11,12 +11,28 @@ import type {
|
|
|
11
11
|
ParameterBinding,
|
|
12
12
|
Renderer,
|
|
13
13
|
} from "@doki-land/live2d-renderer";
|
|
14
|
+
import {
|
|
15
|
+
applyExpression3Clip,
|
|
16
|
+
type Expression3Clip,
|
|
17
|
+
parseExpression3,
|
|
18
|
+
} from "../expression/index.js";
|
|
14
19
|
import {
|
|
15
20
|
MotionPlayer,
|
|
16
21
|
MotionPriority,
|
|
17
22
|
type PlayMotionOptions,
|
|
18
23
|
parseMotion3,
|
|
19
24
|
} from "../motion/index.js";
|
|
25
|
+
import {
|
|
26
|
+
applyPhysics3,
|
|
27
|
+
type Physics3Clip,
|
|
28
|
+
parsePhysics3,
|
|
29
|
+
} from "../physics/index.js";
|
|
30
|
+
import {
|
|
31
|
+
applyPose3Activation,
|
|
32
|
+
type Pose3Clip,
|
|
33
|
+
parsePose3,
|
|
34
|
+
} from "../pose/index.js";
|
|
35
|
+
import { resolveHitAreaName } from "./hit-area.js";
|
|
20
36
|
import type {
|
|
21
37
|
ModelAssetLease,
|
|
22
38
|
ModelAssetRegistry,
|
|
@@ -54,6 +70,14 @@ export class ActorModelSlot {
|
|
|
54
70
|
#loadGeneration = 0;
|
|
55
71
|
readonly #paramById = new Map<string, ParameterBinding>();
|
|
56
72
|
readonly #paramIndexById = new Map<string, number>();
|
|
73
|
+
#expressionCache = new Map<string, Expression3Clip>();
|
|
74
|
+
#activeExpression: {
|
|
75
|
+
name: string;
|
|
76
|
+
clip: Expression3Clip;
|
|
77
|
+
weight: number;
|
|
78
|
+
} | null = null;
|
|
79
|
+
#poseClip: Pose3Clip | null = null;
|
|
80
|
+
#physicsClip: Physics3Clip | null = null;
|
|
57
81
|
|
|
58
82
|
constructor(options: ActorModelSlotOptions) {
|
|
59
83
|
this.#assets = options.assets;
|
|
@@ -95,16 +119,9 @@ export class ActorModelSlot {
|
|
|
95
119
|
if (s.weight <= 0) continue;
|
|
96
120
|
if (s.target === "PartOpacity") {
|
|
97
121
|
if (!this.#backend.setPartOpacity) continue;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const cur = 1;
|
|
102
|
-
this.#backend.setPartOpacity(
|
|
103
|
-
this.#model,
|
|
104
|
-
s.id,
|
|
105
|
-
cur + (s.value - cur) * s.weight,
|
|
106
|
-
);
|
|
107
|
-
}
|
|
122
|
+
const value =
|
|
123
|
+
s.weight >= 1 ? s.value : 1 + (s.value - 1) * s.weight;
|
|
124
|
+
this.#setPartOpacityWithPose(s.id, value);
|
|
108
125
|
continue;
|
|
109
126
|
}
|
|
110
127
|
if (s.target !== "Parameter" || !this.#backend.setParameter)
|
|
@@ -120,6 +137,84 @@ export class ActorModelSlot {
|
|
|
120
137
|
cur + (s.value - cur) * s.weight,
|
|
121
138
|
);
|
|
122
139
|
}
|
|
140
|
+
this.#syncParamCacheFromBackend();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#setPartOpacityWithPose(partId: string, opacity: number): void {
|
|
144
|
+
if (!this.#model || !this.#backend?.setPartOpacity) return;
|
|
145
|
+
if (this.#poseClip && opacity > 0) {
|
|
146
|
+
applyPose3Activation(this.#poseClip, partId, (id, value) => {
|
|
147
|
+
this.#backend?.setPartOpacity?.(this.#model!, id, value);
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.#backend.setPartOpacity(this.#model, partId, opacity);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#syncParamCacheFromBackend(): void {
|
|
155
|
+
if (!this.#model || !this.#backend?.listParameters) return;
|
|
156
|
+
for (const p of this.#backend.listParameters(this.#model)) {
|
|
157
|
+
const cached = this.#paramById.get(p.id);
|
|
158
|
+
if (cached) (cached as { value: number }).value = p.value;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#tickExpression(deltaTimeSeconds: number): void {
|
|
163
|
+
if (!this.#activeExpression) return;
|
|
164
|
+
const fadeSeconds = 0.25;
|
|
165
|
+
const step = deltaTimeSeconds / Math.max(0.001, fadeSeconds);
|
|
166
|
+
this.#activeExpression.weight = Math.min(
|
|
167
|
+
1,
|
|
168
|
+
this.#activeExpression.weight + step,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#applyExpressionLayer(): void {
|
|
173
|
+
if (!this.#activeExpression || !this.#model || !this.#backend) return;
|
|
174
|
+
applyExpression3Clip(
|
|
175
|
+
this.#activeExpression.clip,
|
|
176
|
+
this.#activeExpression.weight,
|
|
177
|
+
this.#paramById,
|
|
178
|
+
(id, value) =>
|
|
179
|
+
this.#backend?.setParameter?.(this.#model!, id, value),
|
|
180
|
+
);
|
|
181
|
+
this.#syncParamCacheFromBackend();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async #loadPoseClip(): Promise<void> {
|
|
185
|
+
this.#poseClip = null;
|
|
186
|
+
const posePath = this.#model?.settings.pose;
|
|
187
|
+
if (!posePath || !this.#lease) return;
|
|
188
|
+
try {
|
|
189
|
+
const json = await this.#lease.resolver.fetchJson(posePath);
|
|
190
|
+
this.#poseClip = parsePose3(json);
|
|
191
|
+
} catch {
|
|
192
|
+
this.#poseClip = null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async #loadPhysicsClip(): Promise<void> {
|
|
197
|
+
this.#physicsClip = null;
|
|
198
|
+
const physicsPath = this.#model?.settings.physics;
|
|
199
|
+
if (!physicsPath || !this.#lease) return;
|
|
200
|
+
try {
|
|
201
|
+
const json = await this.#lease.resolver.fetchJson(physicsPath);
|
|
202
|
+
this.#physicsClip = parsePhysics3(json);
|
|
203
|
+
} catch {
|
|
204
|
+
this.#physicsClip = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#applyPhysicsLayer(deltaTimeSeconds: number): void {
|
|
209
|
+
if (!this.#physicsClip || !this.#model || !this.#backend) return;
|
|
210
|
+
applyPhysics3(
|
|
211
|
+
this.#physicsClip,
|
|
212
|
+
deltaTimeSeconds,
|
|
213
|
+
this.#paramById,
|
|
214
|
+
(id, value) =>
|
|
215
|
+
this.#backend?.setParameter?.(this.#model!, id, value),
|
|
216
|
+
);
|
|
217
|
+
this.#syncParamCacheFromBackend();
|
|
123
218
|
}
|
|
124
219
|
|
|
125
220
|
#rebuildParamCache(): void {
|
|
@@ -158,8 +253,17 @@ export class ActorModelSlot {
|
|
|
158
253
|
async load(
|
|
159
254
|
source: ModelSource,
|
|
160
255
|
resolver?: AssetResolver,
|
|
256
|
+
options?: { signal?: AbortSignal },
|
|
161
257
|
): Promise<InternalModel> {
|
|
162
258
|
const gen = ++this.#loadGeneration;
|
|
259
|
+
const signal = options?.signal;
|
|
260
|
+
if (signal?.aborted) {
|
|
261
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
262
|
+
}
|
|
263
|
+
const onAbort = () => {
|
|
264
|
+
this.#loadGeneration += 1;
|
|
265
|
+
};
|
|
266
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
163
267
|
this.#report({
|
|
164
268
|
stage: "mounting",
|
|
165
269
|
progress: 0.01,
|
|
@@ -167,15 +271,22 @@ export class ActorModelSlot {
|
|
|
167
271
|
});
|
|
168
272
|
const drawPass = this.ensureDrawPass();
|
|
169
273
|
|
|
170
|
-
|
|
171
|
-
this.#
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
274
|
+
try {
|
|
275
|
+
const lease = await this.#assets.acquire(
|
|
276
|
+
source,
|
|
277
|
+
resolver,
|
|
278
|
+
(p) => this.#report(p),
|
|
279
|
+
{ signal },
|
|
280
|
+
);
|
|
281
|
+
if (gen !== this.#loadGeneration) {
|
|
282
|
+
lease.release();
|
|
283
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
284
|
+
}
|
|
177
285
|
|
|
178
|
-
|
|
286
|
+
return await this.#attachLease(lease, drawPass, gen);
|
|
287
|
+
} finally {
|
|
288
|
+
signal?.removeEventListener("abort", onAbort);
|
|
289
|
+
}
|
|
179
290
|
}
|
|
180
291
|
|
|
181
292
|
async loadAsset(asset: ModelAsset): Promise<InternalModel> {
|
|
@@ -208,6 +319,9 @@ export class ActorModelSlot {
|
|
|
208
319
|
gen: number,
|
|
209
320
|
): Promise<InternalModel> {
|
|
210
321
|
this.#motionPlayer.clear();
|
|
322
|
+
this.#activeExpression = null;
|
|
323
|
+
this.#poseClip = null;
|
|
324
|
+
this.#physicsClip = null;
|
|
211
325
|
this.#releaseLease();
|
|
212
326
|
|
|
213
327
|
const { model, backend } = await lease.createInstance(this.#renderer);
|
|
@@ -226,6 +340,8 @@ export class ActorModelSlot {
|
|
|
226
340
|
this.#model = model;
|
|
227
341
|
this.#backend = backend;
|
|
228
342
|
this.#rebuildParamCache();
|
|
343
|
+
await this.#loadPoseClip();
|
|
344
|
+
await this.#loadPhysicsClip();
|
|
229
345
|
this.#report({
|
|
230
346
|
stage: "ready",
|
|
231
347
|
progress: 1,
|
|
@@ -251,6 +367,30 @@ export class ActorModelSlot {
|
|
|
251
367
|
return this.#model?.settings.motionGroups ?? {};
|
|
252
368
|
}
|
|
253
369
|
|
|
370
|
+
listExpressions(): readonly import("@doki-land/live2d-core").ExpressionDefinition[] {
|
|
371
|
+
return this.#model?.settings.expressions ?? [];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async setExpression(name: string | null): Promise<boolean> {
|
|
375
|
+
if (!this.#model || !this.#lease) return false;
|
|
376
|
+
if (name === null) {
|
|
377
|
+
this.#activeExpression = null;
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
const def = this.#model.settings.expressions.find(
|
|
381
|
+
(item) => item.name === name,
|
|
382
|
+
);
|
|
383
|
+
if (!def) return false;
|
|
384
|
+
let clip = this.#expressionCache.get(def.file);
|
|
385
|
+
if (!clip) {
|
|
386
|
+
const json = await this.#lease.resolver.fetchJson(def.file);
|
|
387
|
+
clip = parseExpression3(json);
|
|
388
|
+
this.#expressionCache.set(def.file, clip);
|
|
389
|
+
}
|
|
390
|
+
this.#activeExpression = { name, clip, weight: 0 };
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
|
|
254
394
|
async playMotion(
|
|
255
395
|
group: string,
|
|
256
396
|
index = 0,
|
|
@@ -301,6 +441,9 @@ export class ActorModelSlot {
|
|
|
301
441
|
update(deltaTimeSeconds: number): DrawableMesh[] | null {
|
|
302
442
|
if (!this.#model || !this.#backend || !this.#drawPass) return null;
|
|
303
443
|
this.#applyMotionSamples(this.#motionPlayer.update(deltaTimeSeconds));
|
|
444
|
+
this.#tickExpression(deltaTimeSeconds);
|
|
445
|
+
this.#applyExpressionLayer();
|
|
446
|
+
this.#applyPhysicsLayer(deltaTimeSeconds);
|
|
304
447
|
this.#backend.updateModel(this.#model, deltaTimeSeconds);
|
|
305
448
|
return this.#backend.getDrawables(this.#model);
|
|
306
449
|
}
|
|
@@ -332,10 +475,15 @@ export class ActorModelSlot {
|
|
|
332
475
|
(s >= 0 && s1 >= 0 && s2 >= 0) ||
|
|
333
476
|
(s <= 0 && s1 <= 0 && s2 <= 0)
|
|
334
477
|
) {
|
|
335
|
-
const
|
|
336
|
-
|
|
478
|
+
const artMeshId = this.#backend.getDrawableArtMeshId?.(
|
|
479
|
+
this.#model,
|
|
480
|
+
d.index,
|
|
337
481
|
);
|
|
338
|
-
return
|
|
482
|
+
return resolveHitAreaName({
|
|
483
|
+
hitAreas: this.#model.settings.hitAreas,
|
|
484
|
+
drawableIndex: d.index,
|
|
485
|
+
artMeshId,
|
|
486
|
+
});
|
|
339
487
|
}
|
|
340
488
|
}
|
|
341
489
|
}
|
|
@@ -345,6 +493,9 @@ export class ActorModelSlot {
|
|
|
345
493
|
destroy(): void {
|
|
346
494
|
this.#loadGeneration += 1;
|
|
347
495
|
this.#motionPlayer.clear();
|
|
496
|
+
this.#activeExpression = null;
|
|
497
|
+
this.#poseClip = null;
|
|
498
|
+
this.#physicsClip = null;
|
|
348
499
|
if (this.#model && this.#backend) {
|
|
349
500
|
this.#backend.destroyModel(this.#model);
|
|
350
501
|
}
|
package/src/stage/actor.ts
CHANGED
|
@@ -53,9 +53,35 @@ export class Live2dActorImpl implements Live2dActor {
|
|
|
53
53
|
this.#slot = new ActorModelSlot({
|
|
54
54
|
assets: shared.assets,
|
|
55
55
|
renderer: shared.renderer,
|
|
56
|
+
onMotionStart: (payload) => this.#onMotionStart?.(payload),
|
|
57
|
+
onMotionFinish: (payload) => this.#onMotionFinish?.(payload),
|
|
56
58
|
});
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
#onMotionStart:
|
|
62
|
+
| ((payload: { group: string; index: number; slot: string }) => void)
|
|
63
|
+
| null = null;
|
|
64
|
+
#onMotionFinish:
|
|
65
|
+
| ((payload: { group: string; index: number; slot: string }) => void)
|
|
66
|
+
| null = null;
|
|
67
|
+
|
|
68
|
+
/** Bridge MotionPlayer lifecycle into Stage/facade event buses. */
|
|
69
|
+
setMotionEventHandlers(handlers: {
|
|
70
|
+
onStart?: (payload: {
|
|
71
|
+
group: string;
|
|
72
|
+
index: number;
|
|
73
|
+
slot: string;
|
|
74
|
+
}) => void;
|
|
75
|
+
onFinish?: (payload: {
|
|
76
|
+
group: string;
|
|
77
|
+
index: number;
|
|
78
|
+
slot: string;
|
|
79
|
+
}) => void;
|
|
80
|
+
}): void {
|
|
81
|
+
this.#onMotionStart = handlers.onStart ?? null;
|
|
82
|
+
this.#onMotionFinish = handlers.onFinish ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
get model(): InternalModel | null {
|
|
60
86
|
return this.#slot.model;
|
|
61
87
|
}
|
|
@@ -106,11 +132,12 @@ export class Live2dActorImpl implements Live2dActor {
|
|
|
106
132
|
async load(
|
|
107
133
|
source: Parameters<Live2dActor["load"]>[0],
|
|
108
134
|
resolver?: Parameters<Live2dActor["load"]>[1],
|
|
135
|
+
options?: { signal?: AbortSignal },
|
|
109
136
|
): Promise<InternalModel> {
|
|
110
137
|
if (this.#destroyed) {
|
|
111
138
|
throw new Error("@doki-land/live2d: actor destroyed");
|
|
112
139
|
}
|
|
113
|
-
return await this.#slot.load(source, resolver);
|
|
140
|
+
return await this.#slot.load(source, resolver, options);
|
|
114
141
|
}
|
|
115
142
|
|
|
116
143
|
async loadAsset(asset: ModelAsset): Promise<InternalModel> {
|
|
@@ -160,6 +187,14 @@ export class Live2dActorImpl implements Live2dActor {
|
|
|
160
187
|
return this.#slot.listPlayingMotions();
|
|
161
188
|
}
|
|
162
189
|
|
|
190
|
+
listExpressions() {
|
|
191
|
+
return this.#slot.listExpressions();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setExpression(name: string | null) {
|
|
195
|
+
return this.#slot.setExpression(name);
|
|
196
|
+
}
|
|
197
|
+
|
|
163
198
|
lookAt(stageX: number, stageY: number): void {
|
|
164
199
|
const { dragX, dragY } = stageFocusDrag(
|
|
165
200
|
stageX,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HitAreaDefinition } from "@doki-land/live2d-core";
|
|
2
|
+
|
|
3
|
+
export interface ResolveHitAreaInput {
|
|
4
|
+
readonly hitAreas: readonly HitAreaDefinition[];
|
|
5
|
+
readonly drawableIndex: number;
|
|
6
|
+
/** Art-mesh / drawable id from MOC when available. */
|
|
7
|
+
readonly artMeshId?: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a triangle hit to a named HitArea when settings id matches the drawable.
|
|
12
|
+
* Falls back to `drawable:N` when no mapping exists.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveHitAreaName(input: ResolveHitAreaInput): string {
|
|
15
|
+
const { hitAreas, drawableIndex, artMeshId } = input;
|
|
16
|
+
const candidates = new Set<string>();
|
|
17
|
+
if (artMeshId) candidates.add(artMeshId);
|
|
18
|
+
candidates.add(`D_${drawableIndex}`);
|
|
19
|
+
candidates.add(`${drawableIndex}`);
|
|
20
|
+
for (const area of hitAreas) {
|
|
21
|
+
if (candidates.has(area.id)) return area.name;
|
|
22
|
+
}
|
|
23
|
+
return `drawable:${drawableIndex}`;
|
|
24
|
+
}
|
package/src/stage/index.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
export { allocateActorId, Live2dActorImpl } from "./actor.js";
|
|
2
2
|
export { ActorModelSlot } from "./actor-model-slot.js";
|
|
3
3
|
export {
|
|
4
|
-
type
|
|
4
|
+
type CreateLive2dOptions,
|
|
5
5
|
createSingleActorFacade,
|
|
6
|
+
type Live2dRuntime,
|
|
7
|
+
/** @deprecated Use `CreateLive2dOptions`. */
|
|
8
|
+
type CreateLive2DOptions,
|
|
9
|
+
/** @deprecated Use `Live2dRuntime`. */
|
|
6
10
|
type Live2DRuntime,
|
|
7
11
|
} from "./single-facade.js";
|
|
8
12
|
export { createLive2dStage, Live2dStageImpl } from "./stage.js";
|
|
@@ -103,8 +103,14 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
103
103
|
source: ModelSource,
|
|
104
104
|
resolver: AssetResolver | undefined,
|
|
105
105
|
onProgress?: (payload: LoadProgress) => void,
|
|
106
|
+
options?: { signal?: AbortSignal },
|
|
106
107
|
): Promise<ModelAssetLease> {
|
|
107
|
-
const entry = await this.#ensureEntry(
|
|
108
|
+
const entry = await this.#ensureEntry(
|
|
109
|
+
source,
|
|
110
|
+
resolver,
|
|
111
|
+
onProgress,
|
|
112
|
+
options,
|
|
113
|
+
);
|
|
108
114
|
entry.refCount += 1;
|
|
109
115
|
return this.#leaseFromEntry(entry);
|
|
110
116
|
}
|
|
@@ -166,6 +172,7 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
166
172
|
source: ModelSource,
|
|
167
173
|
resolver?: AssetResolver,
|
|
168
174
|
onProgress?: (payload: LoadProgress) => void,
|
|
175
|
+
options?: { signal?: AbortSignal },
|
|
169
176
|
): Promise<SharedModelAssetEntry> {
|
|
170
177
|
const key = resolveModelAssetKey(source);
|
|
171
178
|
const existing = this.#entries.get(key);
|
|
@@ -173,7 +180,13 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
173
180
|
|
|
174
181
|
let pending = this.#inFlight.get(key);
|
|
175
182
|
if (!pending) {
|
|
176
|
-
pending = this.#compileEntry(
|
|
183
|
+
pending = this.#compileEntry(
|
|
184
|
+
key,
|
|
185
|
+
source,
|
|
186
|
+
resolver,
|
|
187
|
+
onProgress,
|
|
188
|
+
options,
|
|
189
|
+
);
|
|
177
190
|
this.#inFlight.set(key, pending);
|
|
178
191
|
}
|
|
179
192
|
try {
|
|
@@ -190,8 +203,13 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
190
203
|
source: ModelSource,
|
|
191
204
|
resolver: AssetResolver | undefined,
|
|
192
205
|
onProgress?: (payload: LoadProgress) => void,
|
|
206
|
+
options?: { signal?: AbortSignal },
|
|
193
207
|
): Promise<SharedModelAssetEntry> {
|
|
194
208
|
const notify = (payload: LoadProgress) => onProgress?.(payload);
|
|
209
|
+
const signal = options?.signal;
|
|
210
|
+
if (signal?.aborted) {
|
|
211
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
212
|
+
}
|
|
195
213
|
|
|
196
214
|
notify({
|
|
197
215
|
stage: "resolve",
|
|
@@ -231,23 +249,31 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
231
249
|
progress: 0.05,
|
|
232
250
|
detail: fetchUrl,
|
|
233
251
|
});
|
|
234
|
-
json = await fetchModelJson(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
252
|
+
json = await fetchModelJson(
|
|
253
|
+
fetchUrl,
|
|
254
|
+
(u) => {
|
|
255
|
+
const ratio =
|
|
256
|
+
u.bytesTotal && u.bytesTotal > 0
|
|
257
|
+
? u.bytesLoaded / u.bytesTotal
|
|
258
|
+
: 0;
|
|
259
|
+
notify({
|
|
260
|
+
stage: "settings",
|
|
261
|
+
progress: lerp(0.05, 0.22, ratio),
|
|
262
|
+
detail: fetchUrl,
|
|
263
|
+
bytesLoaded: u.bytesLoaded,
|
|
264
|
+
bytesTotal: u.bytesTotal,
|
|
265
|
+
});
|
|
266
|
+
},
|
|
267
|
+
{ signal },
|
|
268
|
+
);
|
|
247
269
|
baseUrl = fetchUrl;
|
|
248
270
|
settingsUrl = fetchUrl;
|
|
249
271
|
}
|
|
250
272
|
|
|
273
|
+
if (signal?.aborted) {
|
|
274
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
275
|
+
}
|
|
276
|
+
|
|
251
277
|
const settings = normalizeModelSettings(json, settingsUrl);
|
|
252
278
|
notify({
|
|
253
279
|
stage: "moc",
|
|
@@ -258,6 +284,7 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
258
284
|
const assetResolver =
|
|
259
285
|
resolver ??
|
|
260
286
|
createUrlAssetResolver(baseUrl, {
|
|
287
|
+
signal,
|
|
261
288
|
onBytesProgress: (assetKey, u) => {
|
|
262
289
|
const isMoc = assetKey === settings.moc;
|
|
263
290
|
const ratio =
|
|
@@ -292,6 +319,9 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
292
319
|
});
|
|
293
320
|
|
|
294
321
|
const mocBytes = await assetResolver.fetchBytes(settings.moc);
|
|
322
|
+
if (signal?.aborted) {
|
|
323
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
324
|
+
}
|
|
295
325
|
const sharedCompile = compileSharedModelCompile(settings, mocBytes);
|
|
296
326
|
|
|
297
327
|
const textures: TextureData[] = [];
|
|
@@ -317,6 +347,10 @@ export class ModelAssetRegistry implements Live2dStageAssets {
|
|
|
317
347
|
);
|
|
318
348
|
}
|
|
319
349
|
|
|
350
|
+
if (signal?.aborted) {
|
|
351
|
+
throw new Error("@doki-land/live2d: load cancelled");
|
|
352
|
+
}
|
|
353
|
+
|
|
320
354
|
notify({
|
|
321
355
|
stage: "ready",
|
|
322
356
|
progress: 1,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
FrameSnapshot,
|
|
3
|
-
|
|
3
|
+
Live2dSession,
|
|
4
4
|
LoadProgress,
|
|
5
5
|
ModelSource,
|
|
6
6
|
SessionPhase,
|
|
@@ -18,14 +18,14 @@ import { MotionPriority } from "../motion/index.js";
|
|
|
18
18
|
import type { Live2dActorImpl } from "./actor.js";
|
|
19
19
|
import type { Live2dStageImpl } from "./stage.js";
|
|
20
20
|
|
|
21
|
-
export interface
|
|
21
|
+
export interface CreateLive2dOptions {
|
|
22
22
|
backends?: ModelBackend[];
|
|
23
23
|
renderer?: Renderer;
|
|
24
24
|
prefer?: RendererKind[];
|
|
25
25
|
updateMode?: "auto" | "manual";
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export interface
|
|
28
|
+
export interface Live2dRuntime extends Live2dSession {
|
|
29
29
|
readonly renderer: Renderer;
|
|
30
30
|
readonly backends: readonly ModelBackend[];
|
|
31
31
|
readonly stage: Live2dStageImpl;
|
|
@@ -54,6 +54,10 @@ export interface Live2DRuntime extends Live2DSession {
|
|
|
54
54
|
time: number;
|
|
55
55
|
priority: number;
|
|
56
56
|
}>;
|
|
57
|
+
listExpressions(): ReadonlyArray<
|
|
58
|
+
import("@doki-land/live2d-core").ExpressionDefinition
|
|
59
|
+
>;
|
|
60
|
+
setExpression(name: string | null): Promise<boolean>;
|
|
57
61
|
capturePng(opts?: {
|
|
58
62
|
mimeType?: "image/png";
|
|
59
63
|
quality?: number;
|
|
@@ -69,7 +73,7 @@ export function createSingleActorFacade(
|
|
|
69
73
|
stage: Live2dStageImpl,
|
|
70
74
|
actor: Live2dActorImpl,
|
|
71
75
|
backends: readonly ModelBackend[],
|
|
72
|
-
):
|
|
76
|
+
): Live2dRuntime {
|
|
73
77
|
const events = new EventEmitter();
|
|
74
78
|
let canvas: HTMLCanvasElement | null = null;
|
|
75
79
|
let phase: SessionPhase = "idle";
|
|
@@ -88,7 +92,7 @@ export function createSingleActorFacade(
|
|
|
88
92
|
generation,
|
|
89
93
|
});
|
|
90
94
|
|
|
91
|
-
const runtime:
|
|
95
|
+
const runtime: Live2dRuntime = {
|
|
92
96
|
events,
|
|
93
97
|
backends,
|
|
94
98
|
renderer: stage.renderer,
|
|
@@ -113,10 +117,10 @@ export function createSingleActorFacade(
|
|
|
113
117
|
},
|
|
114
118
|
);
|
|
115
119
|
},
|
|
116
|
-
async loadModel(source: ModelSource, resolver) {
|
|
120
|
+
async loadModel(source: ModelSource, resolver, options) {
|
|
117
121
|
setPhase("loading");
|
|
118
122
|
try {
|
|
119
|
-
const model = await actor.load(source, resolver);
|
|
123
|
+
const model = await actor.load(source, resolver, options);
|
|
120
124
|
lastError = null;
|
|
121
125
|
setPhase("live");
|
|
122
126
|
events.emit("ready", { modelId: model.id });
|
|
@@ -162,6 +166,12 @@ export function createSingleActorFacade(
|
|
|
162
166
|
listPlayingMotions() {
|
|
163
167
|
return actor.listPlayingMotions();
|
|
164
168
|
},
|
|
169
|
+
listExpressions() {
|
|
170
|
+
return actor.listExpressions();
|
|
171
|
+
},
|
|
172
|
+
setExpression(name) {
|
|
173
|
+
return actor.setExpression(name);
|
|
174
|
+
},
|
|
165
175
|
async capturePng(opts = {}) {
|
|
166
176
|
if (!canvas) {
|
|
167
177
|
throw new Error(
|
|
@@ -224,7 +234,17 @@ export function createSingleActorFacade(
|
|
|
224
234
|
},
|
|
225
235
|
};
|
|
226
236
|
|
|
237
|
+
actor.setMotionEventHandlers({
|
|
238
|
+
onStart: (payload) => events.emit("motion:start", payload),
|
|
239
|
+
onFinish: (payload) => events.emit("motion:finish", payload),
|
|
240
|
+
});
|
|
241
|
+
|
|
227
242
|
return runtime;
|
|
228
243
|
}
|
|
229
244
|
|
|
230
245
|
export { MotionPriority, type PlayMotionOptions };
|
|
246
|
+
|
|
247
|
+
/** @deprecated Use `CreateLive2dOptions`. */
|
|
248
|
+
export type CreateLive2DOptions = CreateLive2dOptions;
|
|
249
|
+
/** @deprecated Use `Live2dRuntime`. */
|
|
250
|
+
export type Live2DRuntime = Live2dRuntime;
|
package/src/stage/stage.ts
CHANGED
|
@@ -67,6 +67,7 @@ export class Live2dStageImpl implements Live2dStage {
|
|
|
67
67
|
]);
|
|
68
68
|
readonly #drawScratch = new StageDrawableScratch();
|
|
69
69
|
readonly #sortedActors: Live2dActorImpl[] = [];
|
|
70
|
+
readonly #frameListeners = new Set<(deltaTimeSeconds: number) => void>();
|
|
70
71
|
|
|
71
72
|
#canvas: HTMLCanvasElement | null = null;
|
|
72
73
|
#initPromise: Promise<void> | null = null;
|
|
@@ -182,12 +183,22 @@ export class Live2dStageImpl implements Live2dStage {
|
|
|
182
183
|
|
|
183
184
|
update(deltaTimeSeconds: number): void {
|
|
184
185
|
if (this.#destroyed) return;
|
|
186
|
+
for (const listener of this.#frameListeners) {
|
|
187
|
+
listener(deltaTimeSeconds);
|
|
188
|
+
}
|
|
185
189
|
for (const actor of this.#actors.values()) {
|
|
186
190
|
actor.update(deltaTimeSeconds);
|
|
187
191
|
}
|
|
188
192
|
this.#applyPointerTracking();
|
|
189
193
|
}
|
|
190
194
|
|
|
195
|
+
onFrame(listener: (deltaTimeSeconds: number) => void): () => void {
|
|
196
|
+
this.#frameListeners.add(listener);
|
|
197
|
+
return () => {
|
|
198
|
+
this.#frameListeners.delete(listener);
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
191
202
|
render(): void {
|
|
192
203
|
if (this.#destroyed || !this.#canvas) return;
|
|
193
204
|
const sorted = this.#sortedActors;
|
|
@@ -291,6 +302,7 @@ export class Live2dStageImpl implements Live2dStage {
|
|
|
291
302
|
if (this.#destroyed) return;
|
|
292
303
|
this.#destroyed = true;
|
|
293
304
|
this.stop();
|
|
305
|
+
this.#frameListeners.clear();
|
|
294
306
|
this.#detachPointerListeners();
|
|
295
307
|
for (const actor of this.#actors.values()) {
|
|
296
308
|
actor.destroy();
|