@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,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
+ }
@@ -0,0 +1,18 @@
1
+ export { allocateActorId, Live2dActorImpl } from "./actor.js";
2
+ export { ActorModelSlot } from "./actor-model-slot.js";
3
+ export {
4
+ type CreateLive2DOptions,
5
+ createSingleActorFacade,
6
+ type Live2DRuntime,
7
+ } from "./single-facade.js";
8
+ export { createLive2dStage, Live2dStageImpl } from "./stage.js";
9
+ export {
10
+ clientToStage,
11
+ compareActorsForDraw,
12
+ compareActorsForHit,
13
+ modelNdcToStage,
14
+ resolveActorTransform,
15
+ stageFocusDrag,
16
+ stageToModelNdc,
17
+ transformDrawablesForStage,
18
+ } from "./transform.js";