@doki-land/live2d 0.0.11 → 0.0.13

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.
@@ -0,0 +1,221 @@
1
+ import type {
2
+ FrameSnapshot,
3
+ Live2DSession,
4
+ LoadProgress,
5
+ ModelSource,
6
+ SessionPhase,
7
+ SessionState,
8
+ } from "@doki-land/live2d-core";
9
+ import { EventEmitter } from "@doki-land/live2d-core";
10
+ import type {
11
+ ModelBackend,
12
+ ParameterBinding,
13
+ Renderer,
14
+ RendererKind,
15
+ } from "@doki-land/live2d-renderer";
16
+ import type { PlayMotionOptions } from "../motion/index.js";
17
+ import { MotionPriority } from "../motion/index.js";
18
+ import type { Live2dActorImpl } from "./actor.js";
19
+ import type { Live2dStageImpl } from "./stage.js";
20
+
21
+ export interface CreateLive2DOptions {
22
+ backends?: ModelBackend[];
23
+ renderer?: Renderer;
24
+ prefer?: RendererKind[];
25
+ updateMode?: "auto" | "manual";
26
+ }
27
+
28
+ export interface Live2DRuntime extends Live2DSession {
29
+ readonly renderer: Renderer;
30
+ readonly backends: readonly ModelBackend[];
31
+ readonly stage: Live2dStageImpl;
32
+ readonly actor: Live2dActorImpl;
33
+
34
+ setParameter(id: string, value: number): void;
35
+ listParameters(): readonly ParameterBinding[];
36
+ hitTest(x: number, y: number): string | null;
37
+ listMotionGroups(): Record<
38
+ string,
39
+ readonly import("@doki-land/live2d-core").MotionDefinition[]
40
+ >;
41
+ playMotion(
42
+ group: string,
43
+ index?: number,
44
+ options?: PlayMotionOptions,
45
+ ): Promise<boolean>;
46
+ stopMotion(opts?: { fade?: boolean; slot?: string }): void;
47
+ listPlayingMotions(): ReadonlyArray<{
48
+ slot: string;
49
+ group: string;
50
+ index: number;
51
+ time: number;
52
+ priority: number;
53
+ }>;
54
+ capturePng(opts?: {
55
+ mimeType?: "image/png";
56
+ quality?: number;
57
+ }): Promise<Blob>;
58
+ }
59
+
60
+ function nowMs(): number {
61
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
62
+ }
63
+
64
+ /** Single-actor facade over a default stage + actor (backward compatible API). */
65
+ export function createSingleActorFacade(
66
+ stage: Live2dStageImpl,
67
+ actor: Live2dActorImpl,
68
+ backends: readonly ModelBackend[],
69
+ ): Live2DRuntime {
70
+ const events = new EventEmitter();
71
+ let canvas: HTMLCanvasElement | null = null;
72
+ let phase: SessionPhase = "idle";
73
+ let lastError: unknown | null = null;
74
+ let generation = 0;
75
+ let fpsSmooth = 0;
76
+
77
+ const setPhase = (next: SessionPhase) => {
78
+ phase = next;
79
+ events.emit("phase", { phase, generation });
80
+ };
81
+
82
+ const state = (): SessionState => ({
83
+ phase,
84
+ lastError,
85
+ generation,
86
+ });
87
+
88
+ const runtime: Live2DRuntime = {
89
+ events,
90
+ backends,
91
+ renderer: stage.renderer,
92
+ stage,
93
+ actor,
94
+ get model() {
95
+ return actor.model;
96
+ },
97
+ get state() {
98
+ return state();
99
+ },
100
+ mount(target) {
101
+ generation += 1;
102
+ canvas = target;
103
+ setPhase("mounting");
104
+ void stage.mount(target).then(
105
+ () => setPhase(actor.model ? "live" : "ready"),
106
+ (err) => {
107
+ lastError = err;
108
+ setPhase("error");
109
+ events.emit("error", { error: err });
110
+ },
111
+ );
112
+ },
113
+ async loadModel(source: ModelSource, resolver) {
114
+ setPhase("loading");
115
+ try {
116
+ const model = await actor.load(source, resolver);
117
+ lastError = null;
118
+ setPhase("live");
119
+ events.emit("ready", { modelId: model.id });
120
+ return model;
121
+ } catch (err) {
122
+ lastError = err;
123
+ setPhase("error");
124
+ events.emit("error", { error: err });
125
+ throw err;
126
+ }
127
+ },
128
+ captureFrame(): FrameSnapshot | null {
129
+ return null;
130
+ },
131
+ setParameter(id, value) {
132
+ actor.setParameter(id, value);
133
+ },
134
+ hitTest(x, y) {
135
+ const stageX = (x + 1) / 2;
136
+ const stageY = (1 - y) / 2;
137
+ const hit = stage.hitTest(stageX, stageY);
138
+ if (!hit || hit.actorId !== actor.id) return null;
139
+ return hit.area;
140
+ },
141
+ listParameters() {
142
+ return actor.slot.listParameters();
143
+ },
144
+ listMotionGroups() {
145
+ return actor.slot.listMotionGroups();
146
+ },
147
+ playMotion(group, index, options) {
148
+ return actor.slot.playMotion(group, index, options);
149
+ },
150
+ stopMotion(opts) {
151
+ actor.slot.stopMotion(opts);
152
+ },
153
+ listPlayingMotions() {
154
+ return actor.slot.listPlayingMotions();
155
+ },
156
+ async capturePng(opts = {}) {
157
+ if (!canvas) {
158
+ throw new Error(
159
+ "@doki-land/live2d: mount(canvas) before capturePng",
160
+ );
161
+ }
162
+ if (phase === "live") {
163
+ runtime.update(0);
164
+ }
165
+ const mime = opts.mimeType ?? "image/png";
166
+ return await new Promise<Blob>((resolve, reject) => {
167
+ canvas!.toBlob(
168
+ (blob) => {
169
+ if (blob) resolve(blob);
170
+ else
171
+ reject(
172
+ new Error(
173
+ "@doki-land/live2d: canvas.toBlob returned null",
174
+ ),
175
+ );
176
+ },
177
+ mime,
178
+ opts.quality,
179
+ );
180
+ });
181
+ },
182
+ update(deltaTimeSeconds) {
183
+ if (phase !== "live" && phase !== "ready") return;
184
+ const t0 = nowMs();
185
+ stage.update(deltaTimeSeconds);
186
+ stage.render();
187
+ const t1 = nowMs();
188
+ const drawables = actor.lastDrawables ?? [];
189
+ let vertexCount = 0;
190
+ let indexCount = 0;
191
+ for (const d of drawables) {
192
+ vertexCount += d.vertexPositions.length / 2;
193
+ indexCount += d.indices.length;
194
+ }
195
+ const frameMs = t1 - t0;
196
+ const fps = deltaTimeSeconds > 0 ? 1 / deltaTimeSeconds : 0;
197
+ fpsSmooth = fpsSmooth <= 0 ? fps : fpsSmooth * 0.85 + fps * 0.15;
198
+ events.emit("profile", {
199
+ fps,
200
+ fpsSmooth,
201
+ frameMs,
202
+ evaluateMs: frameMs,
203
+ drawMs: 0,
204
+ drawableCount: drawables.length,
205
+ vertexCount,
206
+ indexCount,
207
+ });
208
+ },
209
+ destroy() {
210
+ generation += 1;
211
+ stage.destroy();
212
+ canvas = null;
213
+ setPhase("destroyed");
214
+ events.clear();
215
+ },
216
+ };
217
+
218
+ return runtime;
219
+ }
220
+
221
+ export { MotionPriority, type PlayMotionOptions };
@@ -0,0 +1,388 @@
1
+ import type {
2
+ ActorHit,
3
+ CreateActorOptions,
4
+ CreateLive2dStageOptions,
5
+ Live2dActor,
6
+ Live2dStage,
7
+ PointerTrackingPolicy,
8
+ StagePointerEvent,
9
+ } from "@doki-land/live2d-core";
10
+ import type {
11
+ ModelBackend,
12
+ Renderer,
13
+ RendererKind,
14
+ } from "@doki-land/live2d-renderer";
15
+ import {
16
+ createMoc2Backend,
17
+ createMoc3Backend,
18
+ createRenderer,
19
+ } from "@doki-land/live2d-renderer";
20
+ import { allocateActorId, Live2dActorImpl } from "./actor.js";
21
+ import {
22
+ clientToStage,
23
+ compareActorsForDraw,
24
+ compareActorsForHit,
25
+ transformDrawablesForStage,
26
+ } from "./transform.js";
27
+
28
+ export interface CreateLive2dStageFullOptions extends CreateLive2dStageOptions {
29
+ backends?: ModelBackend[];
30
+ renderer?: Renderer;
31
+ prefer?: RendererKind[];
32
+ }
33
+
34
+ type PointerListener = (event: StagePointerEvent) => void;
35
+
36
+ export class Live2dStageImpl implements Live2dStage {
37
+ readonly #backends: readonly ModelBackend[];
38
+ readonly #renderer: Renderer;
39
+ readonly #updateMode: "auto" | "manual";
40
+ readonly #actors = new Map<string, Live2dActorImpl>();
41
+ readonly #definedLayers: string[] = [
42
+ "background",
43
+ "characters-back",
44
+ "characters",
45
+ "characters-front",
46
+ "effects",
47
+ ];
48
+ readonly #pointerListeners = new Map<
49
+ "pointerdown" | "pointermove" | "pointerup",
50
+ Set<PointerListener>
51
+ >([
52
+ ["pointerdown", new Set()],
53
+ ["pointermove", new Set()],
54
+ ["pointerup", new Set()],
55
+ ]);
56
+
57
+ #canvas: HTMLCanvasElement | null = null;
58
+ #initPromise: Promise<void> | null = null;
59
+ #rafId: number | null = null;
60
+ #running = false;
61
+ #paused = false;
62
+ #lastFrameMs = 0;
63
+ #creationCounter = 0;
64
+ #focusedActorId: string | null = null;
65
+ #lastPointer: { stageX: number; stageY: number } | null = null;
66
+ #pointerTracking: PointerTrackingPolicy = { mode: "focused" };
67
+ #boundPointerDown?: (e: PointerEvent) => void;
68
+ #boundPointerMove?: (e: PointerEvent) => void;
69
+ #boundPointerUp?: (e: PointerEvent) => void;
70
+ #destroyed = false;
71
+
72
+ constructor(options: CreateLive2dStageFullOptions = {}) {
73
+ this.#backends = options.backends ?? [
74
+ createMoc2Backend(),
75
+ createMoc3Backend(),
76
+ ];
77
+ this.#renderer =
78
+ options.renderer ?? createRenderer({ prefer: options.prefer });
79
+ this.#updateMode = options.updateMode ?? "auto";
80
+ }
81
+
82
+ get actors(): readonly Live2dActor[] {
83
+ return [...this.#actors.values()];
84
+ }
85
+
86
+ get pointerTracking(): PointerTrackingPolicy {
87
+ return this.#pointerTracking;
88
+ }
89
+
90
+ set pointerTracking(policy: PointerTrackingPolicy) {
91
+ this.#pointerTracking = policy;
92
+ }
93
+
94
+ get renderer(): Renderer {
95
+ return this.#renderer;
96
+ }
97
+
98
+ async mount(canvas: HTMLCanvasElement): Promise<void> {
99
+ if (this.#destroyed) {
100
+ throw new Error("@doki-land/live2d: stage destroyed");
101
+ }
102
+ this.#canvas = canvas;
103
+ this.#initPromise = this.#renderer.initialize(canvas);
104
+ await this.#initPromise;
105
+ this.#attachPointerListeners(canvas);
106
+ }
107
+
108
+ createActor(options?: CreateActorOptions): Live2dActor {
109
+ if (this.#destroyed) {
110
+ throw new Error("@doki-land/live2d: stage destroyed");
111
+ }
112
+ const id = options?.id ?? allocateActorId();
113
+ if (this.#actors.has(id)) {
114
+ throw new Error(`@doki-land/live2d: duplicate actor id "${id}"`);
115
+ }
116
+ const creationIndex = this.#creationCounter++;
117
+ const actor = new Live2dActorImpl(options, {
118
+ id,
119
+ creationIndex,
120
+ backends: this.#backends,
121
+ renderer: this.#renderer,
122
+ });
123
+ this.#actors.set(id, actor);
124
+ if (!this.#focusedActorId) {
125
+ this.#focusedActorId = id;
126
+ }
127
+ return actor;
128
+ }
129
+
130
+ removeActor(actorOrId: Live2dActor | string): void {
131
+ const id = typeof actorOrId === "string" ? actorOrId : actorOrId.id;
132
+ const actor = this.#actors.get(id);
133
+ if (!actor) return;
134
+ actor.destroy();
135
+ this.#actors.delete(id);
136
+ if (this.#focusedActorId === id) {
137
+ this.#focusedActorId = this.#actors.keys().next().value ?? null;
138
+ }
139
+ }
140
+
141
+ defineLayers(layers: readonly string[]): void {
142
+ this.#definedLayers.length = 0;
143
+ this.#definedLayers.push(...layers);
144
+ }
145
+
146
+ update(deltaTimeSeconds: number): void {
147
+ if (this.#destroyed) return;
148
+ for (const actor of this.#actors.values()) {
149
+ actor.update(deltaTimeSeconds);
150
+ }
151
+ this.#applyPointerTracking();
152
+ }
153
+
154
+ render(): void {
155
+ if (this.#destroyed || !this.#canvas) return;
156
+ const sorted = [...this.#actors.values()].sort((a, b) =>
157
+ compareActorsForDraw(a, b, this.#definedLayers),
158
+ );
159
+
160
+ this.#renderer.beginFrame();
161
+ for (const actor of sorted) {
162
+ if (!actor.visible || actor.opacity <= 0) continue;
163
+ const drawables = actor.lastDrawables;
164
+ const pass = actor.slot.drawPass;
165
+ if (!drawables || !pass) continue;
166
+ const placed = transformDrawablesForStage(
167
+ drawables,
168
+ actor.getTransform(),
169
+ actor.opacity,
170
+ );
171
+ pass.draw(placed, new Float32Array(16));
172
+ }
173
+ this.#renderer.endFrame();
174
+ }
175
+
176
+ start(): void {
177
+ if (this.#updateMode === "manual") {
178
+ throw new Error(
179
+ "@doki-land/live2d: start() is not available when updateMode is manual",
180
+ );
181
+ }
182
+ if (this.#running) return;
183
+ this.#running = true;
184
+ this.#paused = false;
185
+ this.#lastFrameMs = nowMs();
186
+ const tick = () => {
187
+ if (!this.#running) return;
188
+ if (!this.#paused) {
189
+ const t = nowMs();
190
+ const dt = Math.min(0.1, (t - this.#lastFrameMs) / 1000);
191
+ this.#lastFrameMs = t;
192
+ this.update(dt);
193
+ this.render();
194
+ }
195
+ this.#rafId = requestAnimationFrame(tick);
196
+ };
197
+ this.#rafId = requestAnimationFrame(tick);
198
+ }
199
+
200
+ pause(): void {
201
+ this.#paused = true;
202
+ }
203
+
204
+ resume(): void {
205
+ this.#paused = false;
206
+ this.#lastFrameMs = nowMs();
207
+ }
208
+
209
+ stop(): void {
210
+ this.#running = false;
211
+ this.#paused = false;
212
+ if (this.#rafId !== null) {
213
+ cancelAnimationFrame(this.#rafId);
214
+ this.#rafId = null;
215
+ }
216
+ }
217
+
218
+ hitTest(stageX: number, stageY: number): ActorHit | null {
219
+ const hits = this.hitTestAll(stageX, stageY);
220
+ return hits[0] ?? null;
221
+ }
222
+
223
+ hitTestAll(stageX: number, stageY: number): readonly ActorHit[] {
224
+ const sorted = [...this.#actors.values()].sort((a, b) =>
225
+ compareActorsForHit(a, b, this.#definedLayers),
226
+ );
227
+ const hits: ActorHit[] = [];
228
+ for (const actor of sorted) {
229
+ const partial = actor.hitTestStage(stageX, stageY);
230
+ if (!partial) continue;
231
+ hits.push({ ...partial, actor });
232
+ }
233
+ return hits;
234
+ }
235
+
236
+ addEventListener(
237
+ type: "pointerdown" | "pointermove" | "pointerup",
238
+ listener: PointerListener,
239
+ ): void {
240
+ this.#pointerListeners.get(type)?.add(listener);
241
+ }
242
+
243
+ removeEventListener(
244
+ type: "pointerdown" | "pointermove" | "pointerup",
245
+ listener: PointerListener,
246
+ ): void {
247
+ this.#pointerListeners.get(type)?.delete(listener);
248
+ }
249
+
250
+ destroy(): void {
251
+ if (this.#destroyed) return;
252
+ this.#destroyed = true;
253
+ this.stop();
254
+ this.#detachPointerListeners();
255
+ for (const actor of this.#actors.values()) {
256
+ actor.destroy();
257
+ }
258
+ this.#actors.clear();
259
+ this.#renderer.destroy();
260
+ this.#canvas = null;
261
+ this.#initPromise = null;
262
+ for (const set of this.#pointerListeners.values()) {
263
+ set.clear();
264
+ }
265
+ }
266
+
267
+ #attachPointerListeners(canvas: HTMLCanvasElement): void {
268
+ this.#detachPointerListeners();
269
+ this.#boundPointerDown = (e) => this.#onPointer("pointerdown", e);
270
+ this.#boundPointerMove = (e) => this.#onPointer("pointermove", e);
271
+ this.#boundPointerUp = (e) => this.#onPointer("pointerup", e);
272
+ canvas.addEventListener("pointerdown", this.#boundPointerDown);
273
+ canvas.addEventListener("pointermove", this.#boundPointerMove);
274
+ canvas.addEventListener("pointerup", this.#boundPointerUp);
275
+ }
276
+
277
+ #detachPointerListeners(): void {
278
+ if (!this.#canvas) return;
279
+ if (this.#boundPointerDown) {
280
+ this.#canvas.removeEventListener(
281
+ "pointerdown",
282
+ this.#boundPointerDown,
283
+ );
284
+ }
285
+ if (this.#boundPointerMove) {
286
+ this.#canvas.removeEventListener(
287
+ "pointermove",
288
+ this.#boundPointerMove,
289
+ );
290
+ }
291
+ if (this.#boundPointerUp) {
292
+ this.#canvas.removeEventListener("pointerup", this.#boundPointerUp);
293
+ }
294
+ this.#boundPointerDown = undefined;
295
+ this.#boundPointerMove = undefined;
296
+ this.#boundPointerUp = undefined;
297
+ }
298
+
299
+ #onPointer(
300
+ type: "pointerdown" | "pointermove" | "pointerup",
301
+ event: PointerEvent,
302
+ ): void {
303
+ if (!this.#canvas) return;
304
+ const { stageX, stageY } = clientToStage(
305
+ event.clientX,
306
+ event.clientY,
307
+ this.#canvas,
308
+ );
309
+ this.#lastPointer = { stageX, stageY };
310
+ const hit = this.hitTest(stageX, stageY);
311
+ if (type === "pointerdown" && hit) {
312
+ this.#focusedActorId = hit.actorId;
313
+ }
314
+ const payload: StagePointerEvent = {
315
+ actor: hit?.actor ?? null,
316
+ actorId: hit?.actorId ?? null,
317
+ area: hit?.area ?? null,
318
+ stageX,
319
+ stageY,
320
+ clientX: event.clientX,
321
+ clientY: event.clientY,
322
+ hit,
323
+ };
324
+ for (const listener of this.#pointerListeners.get(type) ?? []) {
325
+ listener(payload);
326
+ }
327
+ }
328
+
329
+ #applyPointerTracking(): void {
330
+ if (!this.#canvas || !this.#lastPointer) return;
331
+ const { stageX, stageY } = this.#lastPointer;
332
+ const mode = this.#pointerTracking.mode;
333
+ if (mode === "none") return;
334
+
335
+ if (mode === "all") {
336
+ for (const actor of this.#actors.values()) {
337
+ actor.lookAt(stageX, stageY);
338
+ }
339
+ return;
340
+ }
341
+
342
+ if (mode === "custom" && this.#pointerTracking.targetActorId) {
343
+ const actor = this.#actors.get(this.#pointerTracking.targetActorId);
344
+ actor?.lookAt(stageX, stageY);
345
+ return;
346
+ }
347
+
348
+ if (mode === "hovered") {
349
+ for (const actor of this.#actors.values()) {
350
+ if (actor.hitTestStage(stageX, stageY)) {
351
+ actor.lookAt(stageX, stageY);
352
+ }
353
+ }
354
+ return;
355
+ }
356
+
357
+ if (mode === "nearest") {
358
+ let best: Live2dActorImpl | null = null;
359
+ let bestDist = Number.POSITIVE_INFINITY;
360
+ for (const actor of this.#actors.values()) {
361
+ const t = actor.getTransform();
362
+ const dx = t.x - stageX;
363
+ const dy = t.y - stageY;
364
+ const dist = dx * dx + dy * dy;
365
+ if (dist < bestDist) {
366
+ bestDist = dist;
367
+ best = actor;
368
+ }
369
+ }
370
+ best?.lookAt(stageX, stageY);
371
+ return;
372
+ }
373
+
374
+ if (mode === "focused" && this.#focusedActorId) {
375
+ this.#actors.get(this.#focusedActorId)?.lookAt(stageX, stageY);
376
+ }
377
+ }
378
+ }
379
+
380
+ function nowMs(): number {
381
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
382
+ }
383
+
384
+ export function createLive2dStage(
385
+ options?: CreateLive2dStageFullOptions,
386
+ ): Live2dStage {
387
+ return new Live2dStageImpl(options);
388
+ }