@doki-land/live2d 0.0.22 → 0.0.23
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 +57 -2
- package/dist/index.js +236 -13
- 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/index.ts +14 -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 +114 -13
- package/src/stage/actor.ts +8 -0
- package/src/stage/hit-area.ts +24 -0
- package/src/stage/single-facade.ts +10 -0
- package/src/stage/stage.ts +12 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,32 @@
|
|
|
1
1
|
import * as _doki_land_live2d_core from '@doki-land/live2d-core';
|
|
2
|
-
import { Live2dStageAssets, ModelSource, AssetResolver, ModelAsset, LoadProgress, ModelSettings, InternalModel, Live2dActor, CreateActorOptions, ActorTransform, PlayMotionActorOptions, ActorHit, CreateLive2dStageOptions, Live2dStage, PointerTrackingPolicy, StagePointerEvent, Live2DSession } from '@doki-land/live2d-core';
|
|
2
|
+
import { Live2dStageAssets, ModelSource, AssetResolver, ModelAsset, LoadProgress, ModelSettings, InternalModel, Live2dActor, CreateActorOptions, ActorTransform, PlayMotionActorOptions, ActorHit, CreateLive2dStageOptions, Live2dStage, PointerTrackingPolicy, StagePointerEvent, Live2DSession, HitAreaDefinition } from '@doki-land/live2d-core';
|
|
3
3
|
export { ActorHit, ActorInstance, ActorTransform, AssetResolver, CreateActorOptions, CreateLive2dStageOptions, DEFAULT_ACTOR_TRANSFORM, EventEmitter, FrameProfile, FrameSnapshot, InternalModel, Live2DSession, Live2dActor, Live2dStage, Live2dStageAssets, LoadProgress, LoadProgressStage, ModelAsset, ModelFormat, ModelInstance, ModelProgram, ModelSettings, ModelSource, PlayMotionActorOptions, PointerTrackingMode, PointerTrackingPolicy, SessionPhase, SessionState, StagePointerEvent, StageUpdateMode } from '@doki-land/live2d-core';
|
|
4
4
|
export { DEFAULT_NPM_CDN, resolveModelSourceUrl, resolveNpmSpecifier } from '@doki-land/live2d-loader';
|
|
5
5
|
import * as _doki_land_live2d_renderer from '@doki-land/live2d-renderer';
|
|
6
6
|
import { ModelBackend, compileSharedModelCompile, TextureData, Renderer, ParameterBinding, DrawableMesh, RendererKind } from '@doki-land/live2d-renderer';
|
|
7
7
|
export { ModelBackend, ParameterBinding, Renderer, RendererKind, createCanvas2DRenderer, createMoc2Backend, createMoc3Backend, createQuadProgram, createRenderer, createWebGl2Renderer, createWebGpuRenderer, decodeMoc3, evaluateFrame, fingerprintSnapshot, parseCpuProgram, serializeCpuProgram } from '@doki-land/live2d-renderer';
|
|
8
8
|
|
|
9
|
+
type ExpressionBlendMode = "Add" | "Multiply" | "Override";
|
|
10
|
+
interface Expression3Parameter {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly value: number;
|
|
13
|
+
readonly blend: ExpressionBlendMode;
|
|
14
|
+
}
|
|
15
|
+
/** Parsed Cubism `exp3.json`. */
|
|
16
|
+
interface Expression3Clip {
|
|
17
|
+
readonly version: number;
|
|
18
|
+
readonly parameters: readonly Expression3Parameter[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ExpressionApplyBinding {
|
|
22
|
+
readonly value: number;
|
|
23
|
+
}
|
|
24
|
+
/** Apply expression parameters on top of the current parameter state. */
|
|
25
|
+
declare function applyExpression3Clip(clip: Expression3Clip, weight: number, bindings: ReadonlyMap<string, ExpressionApplyBinding>, setParameter: (id: string, value: number) => void): void;
|
|
26
|
+
|
|
27
|
+
/** Parse Cubism `exp3.json` (FileFormats/exp3.json.md subset). */
|
|
28
|
+
declare function parseExpression3(json: unknown): Expression3Clip;
|
|
29
|
+
|
|
9
30
|
/** Cubism motion3 segment kinds (spec). */
|
|
10
31
|
type MotionSegmentKind = "linear" | "bezier" | "stepped" | "inverseStepped";
|
|
11
32
|
type MotionCurveTarget = "Parameter" | "PartOpacity" | "Model";
|
|
@@ -234,6 +255,8 @@ declare class ActorModelSlot {
|
|
|
234
255
|
setParameter(id: string, value: number): void;
|
|
235
256
|
listParameters(): readonly ParameterBinding[];
|
|
236
257
|
listMotionGroups(): Record<string, readonly _doki_land_live2d_core.MotionDefinition[]>;
|
|
258
|
+
listExpressions(): readonly _doki_land_live2d_core.ExpressionDefinition[];
|
|
259
|
+
setExpression(name: string | null): Promise<boolean>;
|
|
237
260
|
playMotion(group: string, index?: number, options?: PlayMotionOptions): Promise<boolean>;
|
|
238
261
|
stopMotion(opts?: {
|
|
239
262
|
fade?: boolean;
|
|
@@ -292,6 +315,8 @@ declare class Live2dActorImpl implements Live2dActor {
|
|
|
292
315
|
time: number;
|
|
293
316
|
priority: number;
|
|
294
317
|
}[];
|
|
318
|
+
listExpressions(): readonly _doki_land_live2d_core.ExpressionDefinition[];
|
|
319
|
+
setExpression(name: string | null): Promise<boolean>;
|
|
295
320
|
lookAt(stageX: number, stageY: number): void;
|
|
296
321
|
/** Internal: evaluate motion/physics and cache drawables for render. */
|
|
297
322
|
update(deltaTimeSeconds: number): DrawableMesh[] | null;
|
|
@@ -300,6 +325,7 @@ declare class Live2dActorImpl implements Live2dActor {
|
|
|
300
325
|
hitTestStage(stageX: number, stageY: number): Omit<ActorHit, "actor"> | null;
|
|
301
326
|
destroy(): void;
|
|
302
327
|
}
|
|
328
|
+
declare function allocateActorId(prefix?: string): string;
|
|
303
329
|
|
|
304
330
|
interface CreateLive2dStageFullOptions extends CreateLive2dStageOptions {
|
|
305
331
|
backends?: ModelBackend[];
|
|
@@ -322,6 +348,7 @@ declare class Live2dStageImpl implements Live2dStage {
|
|
|
322
348
|
removeActor(actorOrId: Live2dActor | string): void;
|
|
323
349
|
defineLayers(layers: readonly string[]): void;
|
|
324
350
|
update(deltaTimeSeconds: number): void;
|
|
351
|
+
onFrame(listener: (deltaTimeSeconds: number) => void): () => void;
|
|
325
352
|
render(): void;
|
|
326
353
|
start(): void;
|
|
327
354
|
pause(): void;
|
|
@@ -365,6 +392,8 @@ interface Live2DRuntime extends Live2DSession {
|
|
|
365
392
|
time: number;
|
|
366
393
|
priority: number;
|
|
367
394
|
}>;
|
|
395
|
+
listExpressions(): ReadonlyArray<_doki_land_live2d_core.ExpressionDefinition>;
|
|
396
|
+
setExpression(name: string | null): Promise<boolean>;
|
|
368
397
|
capturePng(opts?: {
|
|
369
398
|
mimeType?: "image/png";
|
|
370
399
|
quality?: number;
|
|
@@ -374,6 +403,20 @@ interface Live2DRuntime extends Live2DSession {
|
|
|
374
403
|
/** Wire moc backends and a renderer into one single-actor session. */
|
|
375
404
|
declare function createLive2D(options?: CreateLive2DOptions): Live2DRuntime;
|
|
376
405
|
|
|
406
|
+
/** Parsed Cubism `pose3.json` / legacy `pose.json` groups. */
|
|
407
|
+
interface Pose3Clip {
|
|
408
|
+
readonly groups: readonly (readonly string[])[];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* When a part in a pose group becomes visible, hide sibling parts in that group.
|
|
413
|
+
* Mirrors Cubism Pose minimum semantics for part-opacity switching.
|
|
414
|
+
*/
|
|
415
|
+
declare function applyPose3Activation(clip: Pose3Clip, activatedPartId: string, setPartOpacity: (partId: string, opacity: number) => void): void;
|
|
416
|
+
|
|
417
|
+
/** Parse Cubism pose file (`pose3.json` or legacy `pose.json`). */
|
|
418
|
+
declare function parsePose3(json: unknown): Pose3Clip;
|
|
419
|
+
|
|
377
420
|
/**
|
|
378
421
|
* Map canvas focus (-1..1, Y-up) onto commonly used model parameter IDs.
|
|
379
422
|
*
|
|
@@ -388,6 +431,18 @@ declare function focusParameterUpdates(parameters: readonly ParameterBinding[] |
|
|
|
388
431
|
value: number;
|
|
389
432
|
}>;
|
|
390
433
|
|
|
434
|
+
interface ResolveHitAreaInput {
|
|
435
|
+
readonly hitAreas: readonly HitAreaDefinition[];
|
|
436
|
+
readonly drawableIndex: number;
|
|
437
|
+
/** Art-mesh / drawable id from MOC when available. */
|
|
438
|
+
readonly artMeshId?: string | null;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Resolve a triangle hit to a named HitArea when settings id matches the drawable.
|
|
442
|
+
* Falls back to `drawable:N` when no mapping exists.
|
|
443
|
+
*/
|
|
444
|
+
declare function resolveHitAreaName(input: ResolveHitAreaInput): string;
|
|
445
|
+
|
|
391
446
|
/**
|
|
392
447
|
* `@doki-land/live2d` — public facade.
|
|
393
448
|
*
|
|
@@ -404,4 +459,4 @@ declare function focusParameterUpdates(parameters: readonly ParameterBinding[] |
|
|
|
404
459
|
|
|
405
460
|
declare const LIVE2D_VERSION: "0.0.0";
|
|
406
461
|
|
|
407
|
-
export { type CreateLive2DOptions, type CreateLive2dStageFullOptions, LIVE2D_VERSION, type Live2DRuntime, type Motion3Clip, type MotionApplySample, MotionPlayer, MotionPriority, type PlayMotionOptions, blendMotionLayers, createLive2D, createLive2dStage, evaluateCurve, evaluateMotion3, focusParameterUpdates, parseMotion3 };
|
|
462
|
+
export { type CreateLive2DOptions, type CreateLive2dStageFullOptions, type Expression3Clip, type Expression3Parameter, type ExpressionBlendMode, LIVE2D_VERSION, type Live2DRuntime, type Motion3Clip, type MotionApplySample, MotionPlayer, MotionPriority, type PlayMotionOptions, type Pose3Clip, allocateActorId, applyExpression3Clip, applyPose3Activation, blendMotionLayers, createLive2D, createLive2dStage, evaluateCurve, evaluateMotion3, focusParameterUpdates, parseExpression3, parseMotion3, parsePose3, resolveHitAreaName };
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,67 @@ import {
|
|
|
20
20
|
serializeCpuProgram
|
|
21
21
|
} from "@doki-land/live2d-renderer";
|
|
22
22
|
|
|
23
|
+
// src/expression/apply-expression.ts
|
|
24
|
+
function blendValue(current, target, mode, weight) {
|
|
25
|
+
if (weight <= 0) return current;
|
|
26
|
+
if (weight >= 1) {
|
|
27
|
+
if (mode === "Add") return current + target;
|
|
28
|
+
if (mode === "Multiply") return current * target;
|
|
29
|
+
return target;
|
|
30
|
+
}
|
|
31
|
+
const full = mode === "Add" ? current + target : mode === "Multiply" ? current * target : target;
|
|
32
|
+
return current + (full - current) * weight;
|
|
33
|
+
}
|
|
34
|
+
function applyExpression3Clip(clip, weight, bindings, setParameter) {
|
|
35
|
+
if (weight <= 0) return;
|
|
36
|
+
for (const p of clip.parameters) {
|
|
37
|
+
const binding = bindings.get(p.id);
|
|
38
|
+
if (!binding) continue;
|
|
39
|
+
setParameter(p.id, blendValue(binding.value, p.value, p.blend, weight));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/expression/parse-expression3.ts
|
|
44
|
+
var BLENDS = /* @__PURE__ */ new Set(["Add", "Multiply", "Override"]);
|
|
45
|
+
function parseBlend(raw) {
|
|
46
|
+
if (typeof raw !== "string") return "Add";
|
|
47
|
+
if (raw === "Overwrite" || raw === "Override") return "Override";
|
|
48
|
+
if (BLENDS.has(raw)) {
|
|
49
|
+
return raw;
|
|
50
|
+
}
|
|
51
|
+
return "Add";
|
|
52
|
+
}
|
|
53
|
+
function parseExpression3(json) {
|
|
54
|
+
if (!json || typeof json !== "object") {
|
|
55
|
+
throw new Error("@doki-land/live2d: exp3.json root must be an object");
|
|
56
|
+
}
|
|
57
|
+
const root = json;
|
|
58
|
+
const version = Number(root.Version ?? 3);
|
|
59
|
+
const paramsRaw = root.Parameters;
|
|
60
|
+
if (!Array.isArray(paramsRaw)) {
|
|
61
|
+
throw new Error("@doki-land/live2d: exp3.json missing Parameters");
|
|
62
|
+
}
|
|
63
|
+
const parameters = paramsRaw.map((item, index) => {
|
|
64
|
+
if (!item || typeof item !== "object") {
|
|
65
|
+
throw new Error(`@doki-land/live2d: Parameters[${index}] invalid`);
|
|
66
|
+
}
|
|
67
|
+
const p = item;
|
|
68
|
+
const id = p.Id;
|
|
69
|
+
const value = p.Value;
|
|
70
|
+
if (typeof id !== "string" || typeof value !== "number") {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`@doki-land/live2d: Parameters[${index}] needs Id/Value`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
value,
|
|
78
|
+
blend: parseBlend(p.Blend)
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
return { version, parameters };
|
|
82
|
+
}
|
|
83
|
+
|
|
23
84
|
// src/facade/create-live2d.ts
|
|
24
85
|
import {
|
|
25
86
|
createMoc2Backend as createMoc2Backend2,
|
|
@@ -512,6 +573,62 @@ function optionalNum(v) {
|
|
|
512
573
|
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
513
574
|
}
|
|
514
575
|
|
|
576
|
+
// src/pose/apply-pose3.ts
|
|
577
|
+
function applyPose3Activation(clip, activatedPartId, setPartOpacity) {
|
|
578
|
+
for (const group of clip.groups) {
|
|
579
|
+
if (!group.includes(activatedPartId)) continue;
|
|
580
|
+
for (const partId of group) {
|
|
581
|
+
setPartOpacity(partId, partId === activatedPartId ? 1 : 0);
|
|
582
|
+
}
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// src/pose/parse-pose3.ts
|
|
588
|
+
function parsePose3(json) {
|
|
589
|
+
if (!json || typeof json !== "object") {
|
|
590
|
+
throw new Error("@doki-land/live2d: pose json root must be an object");
|
|
591
|
+
}
|
|
592
|
+
const root = json;
|
|
593
|
+
const groupsRaw = root.Groups;
|
|
594
|
+
if (!Array.isArray(groupsRaw)) {
|
|
595
|
+
throw new Error("@doki-land/live2d: pose json missing Groups");
|
|
596
|
+
}
|
|
597
|
+
const groups = groupsRaw.map((group, gi) => {
|
|
598
|
+
if (!Array.isArray(group)) {
|
|
599
|
+
throw new Error(`@doki-land/live2d: Groups[${gi}] must be array`);
|
|
600
|
+
}
|
|
601
|
+
return group.map((entry, ei) => {
|
|
602
|
+
if (!entry || typeof entry !== "object") {
|
|
603
|
+
throw new Error(
|
|
604
|
+
`@doki-land/live2d: Groups[${gi}][${ei}] invalid`
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
const id = entry.Id;
|
|
608
|
+
if (typeof id !== "string" || !id) {
|
|
609
|
+
throw new Error(
|
|
610
|
+
`@doki-land/live2d: Groups[${gi}][${ei}] missing Id`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
return id;
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
return { groups };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/stage/hit-area.ts
|
|
620
|
+
function resolveHitAreaName(input) {
|
|
621
|
+
const { hitAreas, drawableIndex, artMeshId } = input;
|
|
622
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
623
|
+
if (artMeshId) candidates.add(artMeshId);
|
|
624
|
+
candidates.add(`D_${drawableIndex}`);
|
|
625
|
+
candidates.add(`${drawableIndex}`);
|
|
626
|
+
for (const area of hitAreas) {
|
|
627
|
+
if (candidates.has(area.id)) return area.name;
|
|
628
|
+
}
|
|
629
|
+
return `drawable:${drawableIndex}`;
|
|
630
|
+
}
|
|
631
|
+
|
|
515
632
|
// src/stage/actor-model-slot.ts
|
|
516
633
|
var ActorModelSlot = class {
|
|
517
634
|
#assets;
|
|
@@ -525,6 +642,9 @@ var ActorModelSlot = class {
|
|
|
525
642
|
#loadGeneration = 0;
|
|
526
643
|
#paramById = /* @__PURE__ */ new Map();
|
|
527
644
|
#paramIndexById = /* @__PURE__ */ new Map();
|
|
645
|
+
#expressionCache = /* @__PURE__ */ new Map();
|
|
646
|
+
#activeExpression = null;
|
|
647
|
+
#poseClip = null;
|
|
528
648
|
constructor(options) {
|
|
529
649
|
this.#assets = options.assets;
|
|
530
650
|
this.#renderer = options.renderer;
|
|
@@ -559,16 +679,8 @@ var ActorModelSlot = class {
|
|
|
559
679
|
if (s.weight <= 0) continue;
|
|
560
680
|
if (s.target === "PartOpacity") {
|
|
561
681
|
if (!this.#backend.setPartOpacity) continue;
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
} else {
|
|
565
|
-
const cur2 = 1;
|
|
566
|
-
this.#backend.setPartOpacity(
|
|
567
|
-
this.#model,
|
|
568
|
-
s.id,
|
|
569
|
-
cur2 + (s.value - cur2) * s.weight
|
|
570
|
-
);
|
|
571
|
-
}
|
|
682
|
+
const value = s.weight >= 1 ? s.value : 1 + (s.value - 1) * s.weight;
|
|
683
|
+
this.#setPartOpacityWithPose(s.id, value);
|
|
572
684
|
continue;
|
|
573
685
|
}
|
|
574
686
|
if (s.target !== "Parameter" || !this.#backend.setParameter)
|
|
@@ -584,6 +696,54 @@ var ActorModelSlot = class {
|
|
|
584
696
|
cur + (s.value - cur) * s.weight
|
|
585
697
|
);
|
|
586
698
|
}
|
|
699
|
+
this.#syncParamCacheFromBackend();
|
|
700
|
+
}
|
|
701
|
+
#setPartOpacityWithPose(partId, opacity) {
|
|
702
|
+
if (!this.#model || !this.#backend?.setPartOpacity) return;
|
|
703
|
+
if (this.#poseClip && opacity > 0) {
|
|
704
|
+
applyPose3Activation(this.#poseClip, partId, (id, value) => {
|
|
705
|
+
this.#backend?.setPartOpacity?.(this.#model, id, value);
|
|
706
|
+
});
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
this.#backend.setPartOpacity(this.#model, partId, opacity);
|
|
710
|
+
}
|
|
711
|
+
#syncParamCacheFromBackend() {
|
|
712
|
+
if (!this.#model || !this.#backend?.listParameters) return;
|
|
713
|
+
for (const p of this.#backend.listParameters(this.#model)) {
|
|
714
|
+
const cached = this.#paramById.get(p.id);
|
|
715
|
+
if (cached) cached.value = p.value;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
#tickExpression(deltaTimeSeconds) {
|
|
719
|
+
if (!this.#activeExpression) return;
|
|
720
|
+
const fadeSeconds = 0.25;
|
|
721
|
+
const step = deltaTimeSeconds / Math.max(1e-3, fadeSeconds);
|
|
722
|
+
this.#activeExpression.weight = Math.min(
|
|
723
|
+
1,
|
|
724
|
+
this.#activeExpression.weight + step
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
#applyExpressionLayer() {
|
|
728
|
+
if (!this.#activeExpression || !this.#model || !this.#backend) return;
|
|
729
|
+
applyExpression3Clip(
|
|
730
|
+
this.#activeExpression.clip,
|
|
731
|
+
this.#activeExpression.weight,
|
|
732
|
+
this.#paramById,
|
|
733
|
+
(id, value) => this.#backend?.setParameter?.(this.#model, id, value)
|
|
734
|
+
);
|
|
735
|
+
this.#syncParamCacheFromBackend();
|
|
736
|
+
}
|
|
737
|
+
async #loadPoseClip() {
|
|
738
|
+
this.#poseClip = null;
|
|
739
|
+
const posePath = this.#model?.settings.pose;
|
|
740
|
+
if (!posePath || !this.#lease) return;
|
|
741
|
+
try {
|
|
742
|
+
const json = await this.#lease.resolver.fetchJson(posePath);
|
|
743
|
+
this.#poseClip = parsePose3(json);
|
|
744
|
+
} catch {
|
|
745
|
+
this.#poseClip = null;
|
|
746
|
+
}
|
|
587
747
|
}
|
|
588
748
|
#rebuildParamCache() {
|
|
589
749
|
this.#paramById.clear();
|
|
@@ -655,6 +815,8 @@ var ActorModelSlot = class {
|
|
|
655
815
|
}
|
|
656
816
|
async #attachLease(lease, drawPass, gen) {
|
|
657
817
|
this.#motionPlayer.clear();
|
|
818
|
+
this.#activeExpression = null;
|
|
819
|
+
this.#poseClip = null;
|
|
658
820
|
this.#releaseLease();
|
|
659
821
|
const { model, backend } = await lease.createInstance(this.#renderer);
|
|
660
822
|
if (gen !== this.#loadGeneration) {
|
|
@@ -670,6 +832,7 @@ var ActorModelSlot = class {
|
|
|
670
832
|
this.#model = model;
|
|
671
833
|
this.#backend = backend;
|
|
672
834
|
this.#rebuildParamCache();
|
|
835
|
+
await this.#loadPoseClip();
|
|
673
836
|
this.#report({
|
|
674
837
|
stage: "ready",
|
|
675
838
|
progress: 1,
|
|
@@ -688,6 +851,28 @@ var ActorModelSlot = class {
|
|
|
688
851
|
listMotionGroups() {
|
|
689
852
|
return this.#model?.settings.motionGroups ?? {};
|
|
690
853
|
}
|
|
854
|
+
listExpressions() {
|
|
855
|
+
return this.#model?.settings.expressions ?? [];
|
|
856
|
+
}
|
|
857
|
+
async setExpression(name) {
|
|
858
|
+
if (!this.#model || !this.#lease) return false;
|
|
859
|
+
if (name === null) {
|
|
860
|
+
this.#activeExpression = null;
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
863
|
+
const def = this.#model.settings.expressions.find(
|
|
864
|
+
(item) => item.name === name
|
|
865
|
+
);
|
|
866
|
+
if (!def) return false;
|
|
867
|
+
let clip = this.#expressionCache.get(def.file);
|
|
868
|
+
if (!clip) {
|
|
869
|
+
const json = await this.#lease.resolver.fetchJson(def.file);
|
|
870
|
+
clip = parseExpression3(json);
|
|
871
|
+
this.#expressionCache.set(def.file, clip);
|
|
872
|
+
}
|
|
873
|
+
this.#activeExpression = { name, clip, weight: 0 };
|
|
874
|
+
return true;
|
|
875
|
+
}
|
|
691
876
|
async playMotion(group, index = 0, options = {}) {
|
|
692
877
|
if (!this.#model || !this.#lease) return false;
|
|
693
878
|
const list = this.#model.settings.motionGroups[group];
|
|
@@ -720,6 +905,8 @@ var ActorModelSlot = class {
|
|
|
720
905
|
update(deltaTimeSeconds) {
|
|
721
906
|
if (!this.#model || !this.#backend || !this.#drawPass) return null;
|
|
722
907
|
this.#applyMotionSamples(this.#motionPlayer.update(deltaTimeSeconds));
|
|
908
|
+
this.#tickExpression(deltaTimeSeconds);
|
|
909
|
+
this.#applyExpressionLayer();
|
|
723
910
|
this.#backend.updateModel(this.#model, deltaTimeSeconds);
|
|
724
911
|
return this.#backend.getDrawables(this.#model);
|
|
725
912
|
}
|
|
@@ -740,10 +927,15 @@ var ActorModelSlot = class {
|
|
|
740
927
|
const s1 = (bx - ax) * (modelY - ay) - (by - ay) * (modelX - ax);
|
|
741
928
|
const s2 = (cx - bx) * (modelY - by) - (cy - by) * (modelX - bx);
|
|
742
929
|
if (s >= 0 && s1 >= 0 && s2 >= 0 || s <= 0 && s1 <= 0 && s2 <= 0) {
|
|
743
|
-
const
|
|
744
|
-
|
|
930
|
+
const artMeshId = this.#backend.getDrawableArtMeshId?.(
|
|
931
|
+
this.#model,
|
|
932
|
+
d.index
|
|
745
933
|
);
|
|
746
|
-
return
|
|
934
|
+
return resolveHitAreaName({
|
|
935
|
+
hitAreas: this.#model.settings.hitAreas,
|
|
936
|
+
drawableIndex: d.index,
|
|
937
|
+
artMeshId
|
|
938
|
+
});
|
|
747
939
|
}
|
|
748
940
|
}
|
|
749
941
|
}
|
|
@@ -752,6 +944,8 @@ var ActorModelSlot = class {
|
|
|
752
944
|
destroy() {
|
|
753
945
|
this.#loadGeneration += 1;
|
|
754
946
|
this.#motionPlayer.clear();
|
|
947
|
+
this.#activeExpression = null;
|
|
948
|
+
this.#poseClip = null;
|
|
755
949
|
if (this.#model && this.#backend) {
|
|
756
950
|
this.#backend.destroyModel(this.#model);
|
|
757
951
|
}
|
|
@@ -1056,6 +1250,12 @@ var Live2dActorImpl = class {
|
|
|
1056
1250
|
listPlayingMotions() {
|
|
1057
1251
|
return this.#slot.listPlayingMotions();
|
|
1058
1252
|
}
|
|
1253
|
+
listExpressions() {
|
|
1254
|
+
return this.#slot.listExpressions();
|
|
1255
|
+
}
|
|
1256
|
+
setExpression(name) {
|
|
1257
|
+
return this.#slot.setExpression(name);
|
|
1258
|
+
}
|
|
1059
1259
|
lookAt(stageX, stageY) {
|
|
1060
1260
|
const { dragX, dragY } = stageFocusDrag(
|
|
1061
1261
|
stageX,
|
|
@@ -1212,6 +1412,12 @@ function createSingleActorFacade(stage, actor, backends) {
|
|
|
1212
1412
|
listPlayingMotions() {
|
|
1213
1413
|
return actor.listPlayingMotions();
|
|
1214
1414
|
},
|
|
1415
|
+
listExpressions() {
|
|
1416
|
+
return actor.listExpressions();
|
|
1417
|
+
},
|
|
1418
|
+
setExpression(name) {
|
|
1419
|
+
return actor.setExpression(name);
|
|
1420
|
+
},
|
|
1215
1421
|
async capturePng(opts = {}) {
|
|
1216
1422
|
if (!canvas) {
|
|
1217
1423
|
throw new Error(
|
|
@@ -1620,6 +1826,7 @@ var Live2dStageImpl = class {
|
|
|
1620
1826
|
]);
|
|
1621
1827
|
#drawScratch = new StageDrawableScratch();
|
|
1622
1828
|
#sortedActors = [];
|
|
1829
|
+
#frameListeners = /* @__PURE__ */ new Set();
|
|
1623
1830
|
#canvas = null;
|
|
1624
1831
|
#initPromise = null;
|
|
1625
1832
|
#rafId = null;
|
|
@@ -1717,11 +1924,20 @@ var Live2dStageImpl = class {
|
|
|
1717
1924
|
}
|
|
1718
1925
|
update(deltaTimeSeconds) {
|
|
1719
1926
|
if (this.#destroyed) return;
|
|
1927
|
+
for (const listener of this.#frameListeners) {
|
|
1928
|
+
listener(deltaTimeSeconds);
|
|
1929
|
+
}
|
|
1720
1930
|
for (const actor of this.#actors.values()) {
|
|
1721
1931
|
actor.update(deltaTimeSeconds);
|
|
1722
1932
|
}
|
|
1723
1933
|
this.#applyPointerTracking();
|
|
1724
1934
|
}
|
|
1935
|
+
onFrame(listener) {
|
|
1936
|
+
this.#frameListeners.add(listener);
|
|
1937
|
+
return () => {
|
|
1938
|
+
this.#frameListeners.delete(listener);
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1725
1941
|
render() {
|
|
1726
1942
|
if (this.#destroyed || !this.#canvas) return;
|
|
1727
1943
|
const sorted = this.#sortedActors;
|
|
@@ -1809,6 +2025,7 @@ var Live2dStageImpl = class {
|
|
|
1809
2025
|
if (this.#destroyed) return;
|
|
1810
2026
|
this.#destroyed = true;
|
|
1811
2027
|
this.stop();
|
|
2028
|
+
this.#frameListeners.clear();
|
|
1812
2029
|
this.#detachPointerListeners();
|
|
1813
2030
|
for (const actor of this.#actors.values()) {
|
|
1814
2031
|
actor.destroy();
|
|
@@ -1956,6 +2173,9 @@ export {
|
|
|
1956
2173
|
LIVE2D_VERSION,
|
|
1957
2174
|
MotionPlayer,
|
|
1958
2175
|
MotionPriority,
|
|
2176
|
+
allocateActorId,
|
|
2177
|
+
applyExpression3Clip,
|
|
2178
|
+
applyPose3Activation,
|
|
1959
2179
|
blendMotionLayers,
|
|
1960
2180
|
createCanvas2DRenderer,
|
|
1961
2181
|
createLive2D,
|
|
@@ -1973,7 +2193,10 @@ export {
|
|
|
1973
2193
|
fingerprintSnapshot,
|
|
1974
2194
|
focusParameterUpdates,
|
|
1975
2195
|
parseCpuProgram,
|
|
2196
|
+
parseExpression3,
|
|
1976
2197
|
parseMotion3,
|
|
2198
|
+
parsePose3,
|
|
2199
|
+
resolveHitAreaName,
|
|
1977
2200
|
resolveModelSourceUrl2 as resolveModelSourceUrl,
|
|
1978
2201
|
resolveNpmSpecifier,
|
|
1979
2202
|
serializeCpuProgram
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doki-land/live2d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.23",
|
|
4
4
|
"description": "Live2D in the browser — load moc2/moc3 models, Stage + multi-actor, motion; WebGPU/WebGL2/Canvas2D. Main entry for live2d.ts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"test": "vitest run --passWithNoTests"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@doki-land/live2d-core": "0.0.
|
|
63
|
-
"@doki-land/live2d-loader": "0.0.
|
|
64
|
-
"@doki-land/live2d-renderer": "0.0.
|
|
62
|
+
"@doki-land/live2d-core": "0.0.23",
|
|
63
|
+
"@doki-land/live2d-loader": "0.0.23",
|
|
64
|
+
"@doki-land/live2d-renderer": "0.0.23"
|
|
65
65
|
},
|
|
66
66
|
"sideEffects": false
|
|
67
67
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Expression3Clip, ExpressionBlendMode } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface ExpressionApplyBinding {
|
|
4
|
+
readonly value: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function blendValue(
|
|
8
|
+
current: number,
|
|
9
|
+
target: number,
|
|
10
|
+
mode: ExpressionBlendMode,
|
|
11
|
+
weight: number,
|
|
12
|
+
): number {
|
|
13
|
+
if (weight <= 0) return current;
|
|
14
|
+
if (weight >= 1) {
|
|
15
|
+
if (mode === "Add") return current + target;
|
|
16
|
+
if (mode === "Multiply") return current * target;
|
|
17
|
+
return target;
|
|
18
|
+
}
|
|
19
|
+
const full =
|
|
20
|
+
mode === "Add"
|
|
21
|
+
? current + target
|
|
22
|
+
: mode === "Multiply"
|
|
23
|
+
? current * target
|
|
24
|
+
: target;
|
|
25
|
+
return current + (full - current) * weight;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Apply expression parameters on top of the current parameter state. */
|
|
29
|
+
export function applyExpression3Clip(
|
|
30
|
+
clip: Expression3Clip,
|
|
31
|
+
weight: number,
|
|
32
|
+
bindings: ReadonlyMap<string, ExpressionApplyBinding>,
|
|
33
|
+
setParameter: (id: string, value: number) => void,
|
|
34
|
+
): void {
|
|
35
|
+
if (weight <= 0) return;
|
|
36
|
+
for (const p of clip.parameters) {
|
|
37
|
+
const binding = bindings.get(p.id);
|
|
38
|
+
if (!binding) continue;
|
|
39
|
+
setParameter(p.id, blendValue(binding.value, p.value, p.blend, weight));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Expression3Clip, ExpressionBlendMode } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const BLENDS = new Set<ExpressionBlendMode>(["Add", "Multiply", "Override"]);
|
|
4
|
+
|
|
5
|
+
function parseBlend(raw: unknown): ExpressionBlendMode {
|
|
6
|
+
if (typeof raw !== "string") return "Add";
|
|
7
|
+
// Cubism official spelling is `Overwrite`; keep `Override` as an alias.
|
|
8
|
+
if (raw === "Overwrite" || raw === "Override") return "Override";
|
|
9
|
+
if (BLENDS.has(raw as ExpressionBlendMode)) {
|
|
10
|
+
return raw as ExpressionBlendMode;
|
|
11
|
+
}
|
|
12
|
+
return "Add";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Parse Cubism `exp3.json` (FileFormats/exp3.json.md subset). */
|
|
16
|
+
export function parseExpression3(json: unknown): Expression3Clip {
|
|
17
|
+
if (!json || typeof json !== "object") {
|
|
18
|
+
throw new Error("@doki-land/live2d: exp3.json root must be an object");
|
|
19
|
+
}
|
|
20
|
+
const root = json as Record<string, unknown>;
|
|
21
|
+
const version = Number(root.Version ?? 3);
|
|
22
|
+
const paramsRaw = root.Parameters;
|
|
23
|
+
if (!Array.isArray(paramsRaw)) {
|
|
24
|
+
throw new Error("@doki-land/live2d: exp3.json missing Parameters");
|
|
25
|
+
}
|
|
26
|
+
const parameters = paramsRaw.map((item, index) => {
|
|
27
|
+
if (!item || typeof item !== "object") {
|
|
28
|
+
throw new Error(`@doki-land/live2d: Parameters[${index}] invalid`);
|
|
29
|
+
}
|
|
30
|
+
const p = item as Record<string, unknown>;
|
|
31
|
+
const id = p.Id;
|
|
32
|
+
const value = p.Value;
|
|
33
|
+
if (typeof id !== "string" || typeof value !== "number") {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`@doki-land/live2d: Parameters[${index}] needs Id/Value`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
id,
|
|
40
|
+
value,
|
|
41
|
+
blend: parseBlend(p.Blend),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
return { version, parameters };
|
|
45
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type ExpressionBlendMode = "Add" | "Multiply" | "Override";
|
|
2
|
+
|
|
3
|
+
export interface Expression3Parameter {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly value: number;
|
|
6
|
+
readonly blend: ExpressionBlendMode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Parsed Cubism `exp3.json`. */
|
|
10
|
+
export interface Expression3Clip {
|
|
11
|
+
readonly version: number;
|
|
12
|
+
readonly parameters: readonly Expression3Parameter[];
|
|
13
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -66,6 +66,13 @@ export {
|
|
|
66
66
|
type RendererKind,
|
|
67
67
|
serializeCpuProgram,
|
|
68
68
|
} from "@doki-land/live2d-renderer";
|
|
69
|
+
export {
|
|
70
|
+
applyExpression3Clip,
|
|
71
|
+
type Expression3Clip,
|
|
72
|
+
type Expression3Parameter,
|
|
73
|
+
type ExpressionBlendMode,
|
|
74
|
+
parseExpression3,
|
|
75
|
+
} from "./expression/index.js";
|
|
69
76
|
export {
|
|
70
77
|
type CreateLive2DOptions,
|
|
71
78
|
createLive2D,
|
|
@@ -82,7 +89,14 @@ export {
|
|
|
82
89
|
MotionPlayer,
|
|
83
90
|
parseMotion3,
|
|
84
91
|
} from "./motion/index.js";
|
|
92
|
+
export {
|
|
93
|
+
applyPose3Activation,
|
|
94
|
+
type Pose3Clip,
|
|
95
|
+
parsePose3,
|
|
96
|
+
} from "./pose/index.js";
|
|
97
|
+
export { allocateActorId } from "./stage/actor.js";
|
|
85
98
|
export { focusParameterUpdates } from "./stage/assets/focus.js";
|
|
99
|
+
export { resolveHitAreaName } from "./stage/hit-area.js";
|
|
86
100
|
export {
|
|
87
101
|
type CreateLive2dStageFullOptions,
|
|
88
102
|
createLive2dStage,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Pose3Clip } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* When a part in a pose group becomes visible, hide sibling parts in that group.
|
|
5
|
+
* Mirrors Cubism Pose minimum semantics for part-opacity switching.
|
|
6
|
+
*/
|
|
7
|
+
export function applyPose3Activation(
|
|
8
|
+
clip: Pose3Clip,
|
|
9
|
+
activatedPartId: string,
|
|
10
|
+
setPartOpacity: (partId: string, opacity: number) => void,
|
|
11
|
+
): void {
|
|
12
|
+
for (const group of clip.groups) {
|
|
13
|
+
if (!group.includes(activatedPartId)) continue;
|
|
14
|
+
for (const partId of group) {
|
|
15
|
+
setPartOpacity(partId, partId === activatedPartId ? 1 : 0);
|
|
16
|
+
}
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Pose3Clip } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Parse Cubism pose file (`pose3.json` or legacy `pose.json`). */
|
|
4
|
+
export function parsePose3(json: unknown): Pose3Clip {
|
|
5
|
+
if (!json || typeof json !== "object") {
|
|
6
|
+
throw new Error("@doki-land/live2d: pose json root must be an object");
|
|
7
|
+
}
|
|
8
|
+
const root = json as Record<string, unknown>;
|
|
9
|
+
const groupsRaw = root.Groups;
|
|
10
|
+
if (!Array.isArray(groupsRaw)) {
|
|
11
|
+
throw new Error("@doki-land/live2d: pose json missing Groups");
|
|
12
|
+
}
|
|
13
|
+
const groups = groupsRaw.map((group, gi) => {
|
|
14
|
+
if (!Array.isArray(group)) {
|
|
15
|
+
throw new Error(`@doki-land/live2d: Groups[${gi}] must be array`);
|
|
16
|
+
}
|
|
17
|
+
return group.map((entry, ei) => {
|
|
18
|
+
if (!entry || typeof entry !== "object") {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`@doki-land/live2d: Groups[${gi}][${ei}] invalid`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const id = (entry as Record<string, unknown>).Id;
|
|
24
|
+
if (typeof id !== "string" || !id) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`@doki-land/live2d: Groups[${gi}][${ei}] missing Id`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return id;
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
return { groups };
|
|
33
|
+
}
|
|
@@ -11,12 +11,23 @@ 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
|
+
applyPose3Activation,
|
|
27
|
+
type Pose3Clip,
|
|
28
|
+
parsePose3,
|
|
29
|
+
} from "../pose/index.js";
|
|
30
|
+
import { resolveHitAreaName } from "./hit-area.js";
|
|
20
31
|
import type {
|
|
21
32
|
ModelAssetLease,
|
|
22
33
|
ModelAssetRegistry,
|
|
@@ -54,6 +65,13 @@ export class ActorModelSlot {
|
|
|
54
65
|
#loadGeneration = 0;
|
|
55
66
|
readonly #paramById = new Map<string, ParameterBinding>();
|
|
56
67
|
readonly #paramIndexById = new Map<string, number>();
|
|
68
|
+
#expressionCache = new Map<string, Expression3Clip>();
|
|
69
|
+
#activeExpression: {
|
|
70
|
+
name: string;
|
|
71
|
+
clip: Expression3Clip;
|
|
72
|
+
weight: number;
|
|
73
|
+
} | null = null;
|
|
74
|
+
#poseClip: Pose3Clip | null = null;
|
|
57
75
|
|
|
58
76
|
constructor(options: ActorModelSlotOptions) {
|
|
59
77
|
this.#assets = options.assets;
|
|
@@ -95,16 +113,9 @@ export class ActorModelSlot {
|
|
|
95
113
|
if (s.weight <= 0) continue;
|
|
96
114
|
if (s.target === "PartOpacity") {
|
|
97
115
|
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
|
-
}
|
|
116
|
+
const value =
|
|
117
|
+
s.weight >= 1 ? s.value : 1 + (s.value - 1) * s.weight;
|
|
118
|
+
this.#setPartOpacityWithPose(s.id, value);
|
|
108
119
|
continue;
|
|
109
120
|
}
|
|
110
121
|
if (s.target !== "Parameter" || !this.#backend.setParameter)
|
|
@@ -120,6 +131,60 @@ export class ActorModelSlot {
|
|
|
120
131
|
cur + (s.value - cur) * s.weight,
|
|
121
132
|
);
|
|
122
133
|
}
|
|
134
|
+
this.#syncParamCacheFromBackend();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#setPartOpacityWithPose(partId: string, opacity: number): void {
|
|
138
|
+
if (!this.#model || !this.#backend?.setPartOpacity) return;
|
|
139
|
+
if (this.#poseClip && opacity > 0) {
|
|
140
|
+
applyPose3Activation(this.#poseClip, partId, (id, value) => {
|
|
141
|
+
this.#backend?.setPartOpacity?.(this.#model!, id, value);
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.#backend.setPartOpacity(this.#model, partId, opacity);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#syncParamCacheFromBackend(): void {
|
|
149
|
+
if (!this.#model || !this.#backend?.listParameters) return;
|
|
150
|
+
for (const p of this.#backend.listParameters(this.#model)) {
|
|
151
|
+
const cached = this.#paramById.get(p.id);
|
|
152
|
+
if (cached) (cached as { value: number }).value = p.value;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#tickExpression(deltaTimeSeconds: number): void {
|
|
157
|
+
if (!this.#activeExpression) return;
|
|
158
|
+
const fadeSeconds = 0.25;
|
|
159
|
+
const step = deltaTimeSeconds / Math.max(0.001, fadeSeconds);
|
|
160
|
+
this.#activeExpression.weight = Math.min(
|
|
161
|
+
1,
|
|
162
|
+
this.#activeExpression.weight + step,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#applyExpressionLayer(): void {
|
|
167
|
+
if (!this.#activeExpression || !this.#model || !this.#backend) return;
|
|
168
|
+
applyExpression3Clip(
|
|
169
|
+
this.#activeExpression.clip,
|
|
170
|
+
this.#activeExpression.weight,
|
|
171
|
+
this.#paramById,
|
|
172
|
+
(id, value) =>
|
|
173
|
+
this.#backend?.setParameter?.(this.#model!, id, value),
|
|
174
|
+
);
|
|
175
|
+
this.#syncParamCacheFromBackend();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async #loadPoseClip(): Promise<void> {
|
|
179
|
+
this.#poseClip = null;
|
|
180
|
+
const posePath = this.#model?.settings.pose;
|
|
181
|
+
if (!posePath || !this.#lease) return;
|
|
182
|
+
try {
|
|
183
|
+
const json = await this.#lease.resolver.fetchJson(posePath);
|
|
184
|
+
this.#poseClip = parsePose3(json);
|
|
185
|
+
} catch {
|
|
186
|
+
this.#poseClip = null;
|
|
187
|
+
}
|
|
123
188
|
}
|
|
124
189
|
|
|
125
190
|
#rebuildParamCache(): void {
|
|
@@ -208,6 +273,8 @@ export class ActorModelSlot {
|
|
|
208
273
|
gen: number,
|
|
209
274
|
): Promise<InternalModel> {
|
|
210
275
|
this.#motionPlayer.clear();
|
|
276
|
+
this.#activeExpression = null;
|
|
277
|
+
this.#poseClip = null;
|
|
211
278
|
this.#releaseLease();
|
|
212
279
|
|
|
213
280
|
const { model, backend } = await lease.createInstance(this.#renderer);
|
|
@@ -226,6 +293,7 @@ export class ActorModelSlot {
|
|
|
226
293
|
this.#model = model;
|
|
227
294
|
this.#backend = backend;
|
|
228
295
|
this.#rebuildParamCache();
|
|
296
|
+
await this.#loadPoseClip();
|
|
229
297
|
this.#report({
|
|
230
298
|
stage: "ready",
|
|
231
299
|
progress: 1,
|
|
@@ -251,6 +319,30 @@ export class ActorModelSlot {
|
|
|
251
319
|
return this.#model?.settings.motionGroups ?? {};
|
|
252
320
|
}
|
|
253
321
|
|
|
322
|
+
listExpressions(): readonly import("@doki-land/live2d-core").ExpressionDefinition[] {
|
|
323
|
+
return this.#model?.settings.expressions ?? [];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async setExpression(name: string | null): Promise<boolean> {
|
|
327
|
+
if (!this.#model || !this.#lease) return false;
|
|
328
|
+
if (name === null) {
|
|
329
|
+
this.#activeExpression = null;
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
const def = this.#model.settings.expressions.find(
|
|
333
|
+
(item) => item.name === name,
|
|
334
|
+
);
|
|
335
|
+
if (!def) return false;
|
|
336
|
+
let clip = this.#expressionCache.get(def.file);
|
|
337
|
+
if (!clip) {
|
|
338
|
+
const json = await this.#lease.resolver.fetchJson(def.file);
|
|
339
|
+
clip = parseExpression3(json);
|
|
340
|
+
this.#expressionCache.set(def.file, clip);
|
|
341
|
+
}
|
|
342
|
+
this.#activeExpression = { name, clip, weight: 0 };
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
254
346
|
async playMotion(
|
|
255
347
|
group: string,
|
|
256
348
|
index = 0,
|
|
@@ -301,6 +393,8 @@ export class ActorModelSlot {
|
|
|
301
393
|
update(deltaTimeSeconds: number): DrawableMesh[] | null {
|
|
302
394
|
if (!this.#model || !this.#backend || !this.#drawPass) return null;
|
|
303
395
|
this.#applyMotionSamples(this.#motionPlayer.update(deltaTimeSeconds));
|
|
396
|
+
this.#tickExpression(deltaTimeSeconds);
|
|
397
|
+
this.#applyExpressionLayer();
|
|
304
398
|
this.#backend.updateModel(this.#model, deltaTimeSeconds);
|
|
305
399
|
return this.#backend.getDrawables(this.#model);
|
|
306
400
|
}
|
|
@@ -332,10 +426,15 @@ export class ActorModelSlot {
|
|
|
332
426
|
(s >= 0 && s1 >= 0 && s2 >= 0) ||
|
|
333
427
|
(s <= 0 && s1 <= 0 && s2 <= 0)
|
|
334
428
|
) {
|
|
335
|
-
const
|
|
336
|
-
|
|
429
|
+
const artMeshId = this.#backend.getDrawableArtMeshId?.(
|
|
430
|
+
this.#model,
|
|
431
|
+
d.index,
|
|
337
432
|
);
|
|
338
|
-
return
|
|
433
|
+
return resolveHitAreaName({
|
|
434
|
+
hitAreas: this.#model.settings.hitAreas,
|
|
435
|
+
drawableIndex: d.index,
|
|
436
|
+
artMeshId,
|
|
437
|
+
});
|
|
339
438
|
}
|
|
340
439
|
}
|
|
341
440
|
}
|
|
@@ -345,6 +444,8 @@ export class ActorModelSlot {
|
|
|
345
444
|
destroy(): void {
|
|
346
445
|
this.#loadGeneration += 1;
|
|
347
446
|
this.#motionPlayer.clear();
|
|
447
|
+
this.#activeExpression = null;
|
|
448
|
+
this.#poseClip = null;
|
|
348
449
|
if (this.#model && this.#backend) {
|
|
349
450
|
this.#backend.destroyModel(this.#model);
|
|
350
451
|
}
|
package/src/stage/actor.ts
CHANGED
|
@@ -160,6 +160,14 @@ export class Live2dActorImpl implements Live2dActor {
|
|
|
160
160
|
return this.#slot.listPlayingMotions();
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
listExpressions() {
|
|
164
|
+
return this.#slot.listExpressions();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
setExpression(name: string | null) {
|
|
168
|
+
return this.#slot.setExpression(name);
|
|
169
|
+
}
|
|
170
|
+
|
|
163
171
|
lookAt(stageX: number, stageY: number): void {
|
|
164
172
|
const { dragX, dragY } = stageFocusDrag(
|
|
165
173
|
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
|
+
}
|
|
@@ -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;
|
|
@@ -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(
|
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();
|