@doki-land/live2d 0.0.21 → 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 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
- if (s.weight >= 1) {
563
- this.#backend.setPartOpacity(this.#model, s.id, s.value);
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 hitArea = this.#model.settings.hitAreas.find(
744
- (h) => h.id === `D_${d.index}` || h.id === `${d.index}`
930
+ const artMeshId = this.#backend.getDrawableArtMeshId?.(
931
+ this.#model,
932
+ d.index
745
933
  );
746
- return hitArea?.name ?? `drawable:${d.index}`;
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.21",
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.21",
63
- "@doki-land/live2d-loader": "0.0.21",
64
- "@doki-land/live2d-renderer": "0.0.21"
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,7 @@
1
+ export { applyExpression3Clip } from "./apply-expression.js";
2
+ export { parseExpression3 } from "./parse-expression3.js";
3
+ export type {
4
+ Expression3Clip,
5
+ Expression3Parameter,
6
+ ExpressionBlendMode,
7
+ } from "./types.js";
@@ -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,3 @@
1
+ export { applyPose3Activation } from "./apply-pose3.js";
2
+ export { parsePose3 } from "./parse-pose3.js";
3
+ export type { Pose3Clip } from "./types.js";
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ /** Parsed Cubism `pose3.json` / legacy `pose.json` groups. */
2
+ export interface Pose3Clip {
3
+ readonly groups: readonly (readonly string[])[];
4
+ }
@@ -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
- 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
- }
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 hitArea = this.#model.settings.hitAreas.find(
336
- (h) => h.id === `D_${d.index}` || h.id === `${d.index}`,
429
+ const artMeshId = this.#backend.getDrawableArtMeshId?.(
430
+ this.#model,
431
+ d.index,
337
432
  );
338
- return hitArea?.name ?? `drawable:${d.index}`;
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
  }
@@ -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(
@@ -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();