@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.
@@ -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
- if (s.weight >= 1) {
99
- this.#backend.setPartOpacity(this.#model, s.id, s.value);
100
- } else {
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
- const lease = await this.#assets.acquire(source, resolver, (p) =>
171
- this.#report(p),
172
- );
173
- if (gen !== this.#loadGeneration) {
174
- lease.release();
175
- throw new Error("@doki-land/live2d: load cancelled");
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
- return await this.#attachLease(lease, drawPass, gen);
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 hitArea = this.#model.settings.hitAreas.find(
336
- (h) => h.id === `D_${d.index}` || h.id === `${d.index}`,
478
+ const artMeshId = this.#backend.getDrawableArtMeshId?.(
479
+ this.#model,
480
+ d.index,
337
481
  );
338
- return hitArea?.name ?? `drawable:${d.index}`;
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
  }
@@ -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
+ }
@@ -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 CreateLive2DOptions,
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(source, resolver, onProgress);
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(key, source, resolver, onProgress);
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(fetchUrl, (u) => {
235
- const ratio =
236
- u.bytesTotal && u.bytesTotal > 0
237
- ? u.bytesLoaded / u.bytesTotal
238
- : 0;
239
- notify({
240
- stage: "settings",
241
- progress: lerp(0.05, 0.22, ratio),
242
- detail: fetchUrl,
243
- bytesLoaded: u.bytesLoaded,
244
- bytesTotal: u.bytesTotal,
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
- Live2DSession,
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 CreateLive2DOptions {
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 Live2DRuntime extends Live2DSession {
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
- ): Live2DRuntime {
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: Live2DRuntime = {
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;
@@ -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();