@doki-land/live2d-widget 0.0.22 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { CreateLive2DOptions, Live2DRuntime } from '@doki-land/live2d';
1
+ import { Live2dStage, Live2dActor, RendererKind, Live2DRuntime } from '@doki-land/live2d';
2
2
 
3
3
  /**
4
4
  * Optional page chrome for `@doki-land/live2d-widget`:
@@ -40,42 +40,68 @@ declare function mountChrome(options: {
40
40
  getCanvas: () => HTMLCanvasElement | null;
41
41
  }): ChromeSession | null;
42
42
 
43
- interface WidgetOptions extends CreateLive2DOptions {
44
- /** CSS selector or element to host the canvas. */
43
+ /** Composed widget over an existing Stage + Actor (no second RAF). */
44
+ interface ComposedWidgetOptions {
45
+ target: string | HTMLElement;
46
+ stage: Live2dStage;
47
+ actor: Live2dActor;
48
+ width?: number;
49
+ height?: number;
50
+ /** Drive PARAM_ANGLE_X with a sine while the stage loop runs. Default true. */
51
+ autoSway?: boolean;
52
+ /** Start Stage-owned RAF after mount. Default true. */
53
+ autoplay?: boolean;
54
+ chrome?: boolean | WidgetChromeOptions;
55
+ onHit?: (payload: {
56
+ area: string;
57
+ x: number;
58
+ y: number;
59
+ }) => void;
60
+ }
61
+ /**
62
+ * Legacy bootstrap that still creates its own Stage + Actor.
63
+ * Prefer composing `stage` + `actor` yourself and calling `createLive2dWidget`.
64
+ */
65
+ interface LegacyWidgetOptions {
45
66
  target: string | HTMLElement;
46
- /** Initial model URL (model.json / model3.json / npm:…). */
47
67
  model?: string;
48
68
  width?: number;
49
69
  height?: number;
50
- /** Drive PARAM_ANGLE_X with a sine while playing. Default true. */
70
+ prefer?: RendererKind[];
51
71
  autoSway?: boolean;
52
- /** Start the RAF update loop after mount. Default true. */
53
72
  autoplay?: boolean;
54
- /**
55
- * Optional tips bubble + toolbar (hitokoto / photo / quit).
56
- * Pass `true` for defaults, or a config object.
57
- */
58
73
  chrome?: boolean | WidgetChromeOptions;
59
- /** Fired when canvas hit-test finds a drawable. */
60
74
  onHit?: (payload: {
61
75
  area: string;
62
76
  x: number;
63
77
  y: number;
64
78
  }) => void;
65
79
  }
80
+ type WidgetOptions = ComposedWidgetOptions | LegacyWidgetOptions;
66
81
  /**
67
- * Page widget shell over `@doki-land/live2d`.
68
- * Renderer fallback is `createRenderer({ prefer })` (webgpu webgl2 → canvas2d).
82
+ * Page widget shell — product chrome over Stage + Actor.
83
+ * Does not own renderer selection or a private RAF loop.
69
84
  */
70
- declare class Live2DWidget {
85
+ declare class Live2dWidget {
71
86
  #private;
72
87
  mount(options: WidgetOptions): Promise<void>;
73
88
  destroy(): void;
89
+ get stage(): Live2dStage | null;
90
+ get actor(): Live2dActor | null;
91
+ /** Legacy accessor when mounted via `createLive2D`. */
74
92
  getRuntime(): Live2DRuntime | null;
75
- /** Show a tips bubble when chrome tips are enabled. */
76
93
  showMessage(text: string | string[], timeoutMs?: number, priority?: number): void;
77
94
  }
78
- declare function mountWidget(options: WidgetOptions): Promise<Live2DWidget>;
95
+ /** @deprecated Use `Live2dWidget` — alias kept for hexo / legacy imports. */
96
+ declare const Live2DWidget: typeof Live2dWidget;
97
+ declare function createLive2dWidget(options: ComposedWidgetOptions): Promise<Live2dWidget>;
98
+ declare function mountWidget(options: WidgetOptions): Promise<Live2dWidget>;
99
+ /** Convenience bootstrap: Stage + default Actor + widget chrome. */
100
+ declare function mountWidgetWithStage(options: LegacyWidgetOptions): Promise<{
101
+ widget: Live2dWidget;
102
+ stage: Live2dStage;
103
+ actor: Live2dActor;
104
+ }>;
79
105
 
80
106
  /**
81
107
  * `@doki-land/live2d-widget` — browser embed helper.
@@ -83,4 +109,4 @@ declare function mountWidget(options: WidgetOptions): Promise<Live2DWidget>;
83
109
 
84
110
  declare const LIVE2D_WIDGET_VERSION: "0.0.0";
85
111
 
86
- export { DEFAULT_WELCOME, LIVE2D_WIDGET_VERSION, Live2DWidget, type WidgetChromeOptions, type WidgetOptions, type WidgetToolId, createTipMessage, ensureChromeStyles, mountChrome, mountWidget };
112
+ export { type ComposedWidgetOptions, DEFAULT_WELCOME, LIVE2D_WIDGET_VERSION, type LegacyWidgetOptions, Live2DWidget, Live2dWidget, type WidgetChromeOptions, type WidgetOptions, type WidgetToolId, createLive2dWidget, createTipMessage, ensureChromeStyles, mountChrome, mountWidget, mountWidgetWithStage };
package/dist/index.js CHANGED
@@ -222,16 +222,24 @@ function mountChrome(options) {
222
222
 
223
223
  // src/shell/widget.ts
224
224
  import {
225
+ allocateActorId,
225
226
  createLive2D,
227
+ createLive2dStage,
228
+ createRenderer,
226
229
  focusParameterUpdates
227
230
  } from "@doki-land/live2d";
228
- var Live2DWidget = class {
231
+ function isLegacyOptions(options) {
232
+ return !("stage" in options && "actor" in options);
233
+ }
234
+ var Live2dWidget = class {
229
235
  #canvas = null;
236
+ #stage = null;
237
+ #actor = null;
230
238
  #runtime = null;
231
239
  #chrome = null;
232
- #raf = 0;
233
- #lastTs = 0;
240
+ #unsubFrame = null;
234
241
  #autoSway = true;
242
+ #swayPhase = 0;
235
243
  #onHit;
236
244
  async mount(options) {
237
245
  const host = typeof options.target === "string" ? document.querySelector(options.target) : options.target;
@@ -249,15 +257,29 @@ var Live2DWidget = class {
249
257
  canvas.style.cssText = "display:block;width:100%;height:auto;pointer-events:auto;touch-action:none;background:transparent;cursor:grab;";
250
258
  canvas.addEventListener("pointermove", this.#onPointerMove);
251
259
  canvas.addEventListener("pointerdown", this.#onPointerDown);
252
- const prefer = normalizePrefer(options.prefer);
253
- const runtime = createLive2D({
254
- backends: options.backends,
255
- renderer: options.renderer,
256
- prefer
257
- });
258
- runtime.mount(canvas);
260
+ let stage;
261
+ let actor;
262
+ if (isLegacyOptions(options)) {
263
+ const prefer = normalizePrefer(options.prefer);
264
+ const runtime = createLive2D({
265
+ prefer,
266
+ updateMode: "auto"
267
+ });
268
+ await runtime.mount(canvas);
269
+ if (options.model) {
270
+ await runtime.loadModel(options.model);
271
+ }
272
+ this.#runtime = runtime;
273
+ stage = runtime.stage;
274
+ actor = runtime.actor;
275
+ } else {
276
+ stage = options.stage;
277
+ actor = options.actor;
278
+ await stage.mount(canvas);
279
+ }
259
280
  this.#canvas = canvas;
260
- this.#runtime = runtime;
281
+ this.#stage = stage;
282
+ this.#actor = actor;
261
283
  this.#autoSway = options.autoSway !== false;
262
284
  this.#onHit = options.onHit;
263
285
  this.#chrome = mountChrome({
@@ -269,17 +291,26 @@ var Live2DWidget = class {
269
291
  if (!this.#chrome) {
270
292
  host.replaceChildren(canvas);
271
293
  }
272
- if (options.model) {
273
- await runtime.loadModel(options.model);
294
+ if (this.#autoSway) {
295
+ this.#unsubFrame = stage.onFrame((dt) => {
296
+ this.#swayPhase += dt;
297
+ const binding = actor.parameterMap().get("PARAM_ANGLE_X");
298
+ if (!binding) return;
299
+ const normalized = Math.sin(this.#swayPhase) * 0.25;
300
+ const value = normalized >= 0 ? binding.defaultValue + (binding.max - binding.defaultValue) * normalized : binding.defaultValue + (binding.defaultValue - binding.min) * normalized;
301
+ actor.setParameter("PARAM_ANGLE_X", value);
302
+ });
274
303
  }
275
304
  if (options.autoplay !== false) {
276
- this.#startLoop();
305
+ stage.start();
277
306
  } else {
278
- runtime.update(0);
307
+ stage.update(0);
308
+ stage.render();
279
309
  }
280
310
  }
281
311
  destroy() {
282
- this.#stopLoop();
312
+ this.#unsubFrame?.();
313
+ this.#unsubFrame = null;
283
314
  if (this.#canvas) {
284
315
  this.#canvas.removeEventListener(
285
316
  "pointermove",
@@ -292,24 +323,31 @@ var Live2DWidget = class {
292
323
  }
293
324
  this.#chrome?.destroy();
294
325
  this.#chrome = null;
295
- this.#runtime?.destroy();
326
+ if (this.#runtime) {
327
+ this.#runtime.destroy();
328
+ } else {
329
+ this.#stage?.stop();
330
+ }
296
331
  this.#canvas?.remove();
297
- this.#runtime = null;
298
332
  this.#canvas = null;
333
+ this.#stage = null;
334
+ this.#actor = null;
335
+ this.#runtime = null;
299
336
  this.#onHit = void 0;
300
337
  }
338
+ get stage() {
339
+ return this.#stage;
340
+ }
341
+ get actor() {
342
+ return this.#actor;
343
+ }
344
+ /** Legacy accessor when mounted via `createLive2D`. */
301
345
  getRuntime() {
302
346
  return this.#runtime;
303
347
  }
304
- /** Show a tips bubble when chrome tips are enabled. */
305
348
  showMessage(text, timeoutMs, priority) {
306
349
  this.#chrome?.tips?.show(text, timeoutMs, priority);
307
350
  }
308
- #parameterFromNormalized(id, normalized) {
309
- const binding = this.#runtime?.parameterMap().get(id);
310
- if (!binding) return normalized;
311
- return normalized >= 0 ? binding.defaultValue + (binding.max - binding.defaultValue) * normalized : binding.defaultValue + (binding.defaultValue - binding.min) * normalized;
312
- }
313
351
  #modelPoint(event) {
314
352
  const canvas = this.#canvas;
315
353
  if (!canvas) return null;
@@ -322,69 +360,60 @@ var Live2DWidget = class {
322
360
  }
323
361
  #onPointerMove = (event) => {
324
362
  const p = this.#modelPoint(event);
325
- const runtime = this.#runtime;
326
- if (!p || !runtime) return;
327
- this.#autoSwayPausedByPointer = true;
363
+ const actor = this.#actor;
364
+ if (!p || !actor) return;
328
365
  for (const { id, value } of focusParameterUpdates(
329
- runtime.parameterMap(),
366
+ actor.parameterMap(),
330
367
  p.x,
331
368
  p.y
332
369
  )) {
333
- runtime.setParameter(id, value);
370
+ actor.setParameter(id, value);
334
371
  }
335
372
  };
336
373
  #onPointerDown = (event) => {
337
374
  const p = this.#modelPoint(event);
338
- const runtime = this.#runtime;
339
- if (!p || !runtime) return;
340
- const area = runtime.hitTest(p.x, p.y);
341
- if (area) {
342
- this.#onHit?.({ area, x: p.x, y: p.y });
343
- this.#chrome?.tips?.show("\u78B0\u5230\u6211\u5566\uFF5E", 2500, 4);
344
- }
375
+ const stage = this.#stage;
376
+ const actor = this.#actor;
377
+ if (!p || !stage || !actor) return;
378
+ const stageX = (p.x + 1) / 2;
379
+ const stageY = (1 - p.y) / 2;
380
+ const hit = stage.hitTest(stageX, stageY);
381
+ if (!hit || hit.actorId !== actor.id) return;
382
+ this.#onHit?.({ area: hit.area, x: p.x, y: p.y });
383
+ this.#chrome?.tips?.show("\u78B0\u5230\u6211\u5566\uFF5E", 2500, 4);
345
384
  };
346
- #autoSwayPausedByPointer = false;
347
- #startLoop() {
348
- this.#stopLoop();
349
- const tick = (ts) => {
350
- const runtime = this.#runtime;
351
- if (!runtime) return;
352
- const dt = this.#lastTs ? (ts - this.#lastTs) / 1e3 : 0;
353
- this.#lastTs = ts;
354
- if (this.#autoSway && !this.#autoSwayPausedByPointer) {
355
- runtime.setParameter(
356
- "PARAM_ANGLE_X",
357
- this.#parameterFromNormalized(
358
- "PARAM_ANGLE_X",
359
- Math.sin(ts / 1e3) * 0.25
360
- )
361
- );
362
- }
363
- if (this.#autoSwayPausedByPointer) {
364
- this.#autoSwayPausedByPointer = false;
365
- }
366
- runtime.update(dt);
367
- this.#raf = requestAnimationFrame(tick);
368
- };
369
- this.#raf = requestAnimationFrame(tick);
370
- }
371
- #stopLoop() {
372
- if (this.#raf) cancelAnimationFrame(this.#raf);
373
- this.#raf = 0;
374
- this.#lastTs = 0;
375
- }
376
385
  };
386
+ var Live2DWidget = Live2dWidget;
377
387
  function normalizePrefer(prefer) {
378
388
  if (!prefer?.length) return void 0;
379
389
  const allowed = /* @__PURE__ */ new Set(["webgpu", "webgl2", "canvas2d"]);
380
390
  const out = prefer.filter((k) => allowed.has(k));
381
391
  return out.length ? out : void 0;
382
392
  }
393
+ async function createLive2dWidget(options) {
394
+ const widget = new Live2dWidget();
395
+ await widget.mount(options);
396
+ return widget;
397
+ }
383
398
  async function mountWidget(options) {
384
- const widget = new Live2DWidget();
399
+ const widget = new Live2dWidget();
385
400
  await widget.mount(options);
386
401
  return widget;
387
402
  }
403
+ async function mountWidgetWithStage(options) {
404
+ const prefer = normalizePrefer(options.prefer);
405
+ const stage = createLive2dStage({
406
+ renderer: createRenderer({ prefer }),
407
+ updateMode: "auto"
408
+ });
409
+ const actor = stage.createActor({ id: allocateActorId("widget") });
410
+ const widget = new Live2dWidget();
411
+ await widget.mount({ ...options, stage, actor });
412
+ if (options.model) {
413
+ await actor.load(options.model);
414
+ }
415
+ return { widget, stage, actor };
416
+ }
388
417
 
389
418
  // src/index.ts
390
419
  var LIVE2D_WIDGET_VERSION = "0.0.0";
@@ -392,8 +421,11 @@ export {
392
421
  DEFAULT_WELCOME,
393
422
  LIVE2D_WIDGET_VERSION,
394
423
  Live2DWidget,
424
+ Live2dWidget,
425
+ createLive2dWidget,
395
426
  createTipMessage,
396
427
  ensureChromeStyles,
397
428
  mountChrome,
398
- mountWidget
429
+ mountWidget,
430
+ mountWidgetWithStage
399
431
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@doki-land/live2d-widget",
3
- "version": "0.0.22",
4
- "description": "Live2D page widget — canvas shell, autoplay, optional tips chrome; built on @doki-land/live2d.",
3
+ "version": "0.0.24",
4
+ "description": "Live2D page widget — product chrome over Stage + Actor (no private RAF); built on @doki-land/live2d.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/doki-land/live2d.ts",
@@ -39,10 +39,10 @@
39
39
  "scripts": {
40
40
  "build": "tsup src/index.ts --format esm --dts",
41
41
  "typecheck": "tsc --noEmit",
42
- "test": "vitest run --passWithNoTests"
42
+ "test": "vitest run"
43
43
  },
44
44
  "dependencies": {
45
- "@doki-land/live2d": "0.0.22"
45
+ "@doki-land/live2d": "0.0.24"
46
46
  },
47
47
  "sideEffects": false
48
48
  }
package/src/index.ts CHANGED
@@ -9,8 +9,14 @@ export {
9
9
  mountChrome,
10
10
  } from "./chrome/chrome.js";
11
11
  export {
12
+ type ComposedWidgetOptions,
13
+ createLive2dWidget,
14
+ type LegacyWidgetOptions,
15
+ /** @deprecated Use `Live2dWidget`. */
12
16
  Live2DWidget,
17
+ Live2dWidget,
13
18
  mountWidget,
19
+ mountWidgetWithStage,
14
20
  type WidgetChromeOptions,
15
21
  type WidgetOptions,
16
22
  type WidgetToolId,
@@ -1,8 +1,12 @@
1
1
  import {
2
- type CreateLive2DOptions,
2
+ allocateActorId,
3
3
  createLive2D,
4
+ createLive2dStage,
5
+ createRenderer,
4
6
  focusParameterUpdates,
5
7
  type Live2DRuntime,
8
+ type Live2dActor,
9
+ type Live2dStage,
6
10
  type RendererKind,
7
11
  } from "@doki-land/live2d";
8
12
  import {
@@ -13,38 +17,59 @@ import {
13
17
 
14
18
  export type { WidgetChromeOptions, WidgetToolId } from "../chrome/chrome.js";
15
19
 
16
- export interface WidgetOptions extends CreateLive2DOptions {
17
- /** CSS selector or element to host the canvas. */
20
+ /** Composed widget over an existing Stage + Actor (no second RAF). */
21
+ export interface ComposedWidgetOptions {
22
+ target: string | HTMLElement;
23
+ stage: Live2dStage;
24
+ actor: Live2dActor;
25
+ width?: number;
26
+ height?: number;
27
+ /** Drive PARAM_ANGLE_X with a sine while the stage loop runs. Default true. */
28
+ autoSway?: boolean;
29
+ /** Start Stage-owned RAF after mount. Default true. */
30
+ autoplay?: boolean;
31
+ chrome?: boolean | WidgetChromeOptions;
32
+ onHit?: (payload: { area: string; x: number; y: number }) => void;
33
+ }
34
+
35
+ /**
36
+ * Legacy bootstrap that still creates its own Stage + Actor.
37
+ * Prefer composing `stage` + `actor` yourself and calling `createLive2dWidget`.
38
+ */
39
+ export interface LegacyWidgetOptions {
18
40
  target: string | HTMLElement;
19
- /** Initial model URL (model.json / model3.json / npm:…). */
20
41
  model?: string;
21
42
  width?: number;
22
43
  height?: number;
23
- /** Drive PARAM_ANGLE_X with a sine while playing. Default true. */
44
+ prefer?: RendererKind[];
24
45
  autoSway?: boolean;
25
- /** Start the RAF update loop after mount. Default true. */
26
46
  autoplay?: boolean;
27
- /**
28
- * Optional tips bubble + toolbar (hitokoto / photo / quit).
29
- * Pass `true` for defaults, or a config object.
30
- */
31
47
  chrome?: boolean | WidgetChromeOptions;
32
- /** Fired when canvas hit-test finds a drawable. */
33
48
  onHit?: (payload: { area: string; x: number; y: number }) => void;
34
49
  }
35
50
 
51
+ export type WidgetOptions = ComposedWidgetOptions | LegacyWidgetOptions;
52
+
53
+ function isLegacyOptions(
54
+ options: WidgetOptions,
55
+ ): options is LegacyWidgetOptions {
56
+ return !("stage" in options && "actor" in options);
57
+ }
58
+
36
59
  /**
37
- * Page widget shell over `@doki-land/live2d`.
38
- * Renderer fallback is `createRenderer({ prefer })` (webgpu webgl2 → canvas2d).
60
+ * Page widget shell — product chrome over Stage + Actor.
61
+ * Does not own renderer selection or a private RAF loop.
39
62
  */
40
- export class Live2DWidget {
63
+ export class Live2dWidget {
41
64
  #canvas: HTMLCanvasElement | null = null;
65
+ #stage: Live2dStage | null = null;
66
+ #actor: Live2dActor | null = null;
42
67
  #runtime: Live2DRuntime | null = null;
43
68
  #chrome: ChromeSession | null = null;
44
- #raf = 0;
45
- #lastTs = 0;
69
+ #unsubFrame: (() => void) | null = null;
46
70
  #autoSway = true;
47
- #onHit: WidgetOptions["onHit"];
71
+ #swayPhase = 0;
72
+ #onHit: ComposedWidgetOptions["onHit"];
48
73
 
49
74
  async mount(options: WidgetOptions): Promise<void> {
50
75
  const host =
@@ -69,16 +94,30 @@ export class Live2DWidget {
69
94
  canvas.addEventListener("pointermove", this.#onPointerMove);
70
95
  canvas.addEventListener("pointerdown", this.#onPointerDown);
71
96
 
72
- const prefer = normalizePrefer(options.prefer);
73
- const runtime = createLive2D({
74
- backends: options.backends,
75
- renderer: options.renderer,
76
- prefer,
77
- });
78
- runtime.mount(canvas);
97
+ let stage: Live2dStage;
98
+ let actor: Live2dActor;
99
+ if (isLegacyOptions(options)) {
100
+ const prefer = normalizePrefer(options.prefer);
101
+ const runtime = createLive2D({
102
+ prefer,
103
+ updateMode: "auto",
104
+ });
105
+ await runtime.mount(canvas);
106
+ if (options.model) {
107
+ await runtime.loadModel(options.model);
108
+ }
109
+ this.#runtime = runtime;
110
+ stage = runtime.stage;
111
+ actor = runtime.actor;
112
+ } else {
113
+ stage = options.stage;
114
+ actor = options.actor;
115
+ await stage.mount(canvas);
116
+ }
79
117
 
80
118
  this.#canvas = canvas;
81
- this.#runtime = runtime;
119
+ this.#stage = stage;
120
+ this.#actor = actor;
82
121
  this.#autoSway = options.autoSway !== false;
83
122
  this.#onHit = options.onHit;
84
123
  this.#chrome = mountChrome({
@@ -91,19 +130,33 @@ export class Live2DWidget {
91
130
  host.replaceChildren(canvas);
92
131
  }
93
132
 
94
- if (options.model) {
95
- await runtime.loadModel(options.model);
133
+ if (this.#autoSway) {
134
+ this.#unsubFrame = stage.onFrame((dt) => {
135
+ this.#swayPhase += dt;
136
+ const binding = actor.parameterMap().get("PARAM_ANGLE_X");
137
+ if (!binding) return;
138
+ const normalized = Math.sin(this.#swayPhase) * 0.25;
139
+ const value =
140
+ normalized >= 0
141
+ ? binding.defaultValue +
142
+ (binding.max - binding.defaultValue) * normalized
143
+ : binding.defaultValue +
144
+ (binding.defaultValue - binding.min) * normalized;
145
+ actor.setParameter("PARAM_ANGLE_X", value);
146
+ });
96
147
  }
97
148
 
98
149
  if (options.autoplay !== false) {
99
- this.#startLoop();
150
+ stage.start();
100
151
  } else {
101
- runtime.update(0);
152
+ stage.update(0);
153
+ stage.render();
102
154
  }
103
155
  }
104
156
 
105
157
  destroy(): void {
106
- this.#stopLoop();
158
+ this.#unsubFrame?.();
159
+ this.#unsubFrame = null;
107
160
  if (this.#canvas) {
108
161
  this.#canvas.removeEventListener(
109
162
  "pointermove",
@@ -116,18 +169,32 @@ export class Live2DWidget {
116
169
  }
117
170
  this.#chrome?.destroy();
118
171
  this.#chrome = null;
119
- this.#runtime?.destroy();
172
+ if (this.#runtime) {
173
+ this.#runtime.destroy();
174
+ } else {
175
+ this.#stage?.stop();
176
+ }
120
177
  this.#canvas?.remove();
121
- this.#runtime = null;
122
178
  this.#canvas = null;
179
+ this.#stage = null;
180
+ this.#actor = null;
181
+ this.#runtime = null;
123
182
  this.#onHit = undefined;
124
183
  }
125
184
 
185
+ get stage(): Live2dStage | null {
186
+ return this.#stage;
187
+ }
188
+
189
+ get actor(): Live2dActor | null {
190
+ return this.#actor;
191
+ }
192
+
193
+ /** Legacy accessor when mounted via `createLive2D`. */
126
194
  getRuntime(): Live2DRuntime | null {
127
195
  return this.#runtime;
128
196
  }
129
197
 
130
- /** Show a tips bubble when chrome tips are enabled. */
131
198
  showMessage(
132
199
  text: string | string[],
133
200
  timeoutMs?: number,
@@ -136,16 +203,6 @@ export class Live2DWidget {
136
203
  this.#chrome?.tips?.show(text, timeoutMs, priority);
137
204
  }
138
205
 
139
- #parameterFromNormalized(id: string, normalized: number): number {
140
- const binding = this.#runtime?.parameterMap().get(id);
141
- if (!binding) return normalized;
142
- return normalized >= 0
143
- ? binding.defaultValue +
144
- (binding.max - binding.defaultValue) * normalized
145
- : binding.defaultValue +
146
- (binding.defaultValue - binding.min) * normalized;
147
- }
148
-
149
206
  #modelPoint(event: PointerEvent): { x: number; y: number } | null {
150
207
  const canvas = this.#canvas;
151
208
  if (!canvas) return null;
@@ -159,65 +216,34 @@ export class Live2DWidget {
159
216
 
160
217
  #onPointerMove = (event: PointerEvent): void => {
161
218
  const p = this.#modelPoint(event);
162
- const runtime = this.#runtime;
163
- if (!p || !runtime) return;
164
- // Pointer tracking overrides auto-sway for ANGLE_X while moving.
165
- this.#autoSwayPausedByPointer = true;
219
+ const actor = this.#actor;
220
+ if (!p || !actor) return;
166
221
  for (const { id, value } of focusParameterUpdates(
167
- runtime.parameterMap(),
222
+ actor.parameterMap(),
168
223
  p.x,
169
224
  p.y,
170
225
  )) {
171
- runtime.setParameter(id, value);
226
+ actor.setParameter(id, value);
172
227
  }
173
228
  };
174
229
 
175
230
  #onPointerDown = (event: PointerEvent): void => {
176
231
  const p = this.#modelPoint(event);
177
- const runtime = this.#runtime;
178
- if (!p || !runtime) return;
179
- const area = runtime.hitTest(p.x, p.y);
180
- if (area) {
181
- this.#onHit?.({ area, x: p.x, y: p.y });
182
- this.#chrome?.tips?.show("碰到我啦~", 2500, 4);
183
- }
232
+ const stage = this.#stage;
233
+ const actor = this.#actor;
234
+ if (!p || !stage || !actor) return;
235
+ const stageX = (p.x + 1) / 2;
236
+ const stageY = (1 - p.y) / 2;
237
+ const hit = stage.hitTest(stageX, stageY);
238
+ if (!hit || hit.actorId !== actor.id) return;
239
+ this.#onHit?.({ area: hit.area, x: p.x, y: p.y });
240
+ this.#chrome?.tips?.show("碰到我啦~", 2500, 4);
184
241
  };
185
-
186
- #autoSwayPausedByPointer = false;
187
-
188
- #startLoop(): void {
189
- this.#stopLoop();
190
- const tick = (ts: number) => {
191
- const runtime = this.#runtime;
192
- if (!runtime) return;
193
- const dt = this.#lastTs ? (ts - this.#lastTs) / 1000 : 0;
194
- this.#lastTs = ts;
195
- if (this.#autoSway && !this.#autoSwayPausedByPointer) {
196
- runtime.setParameter(
197
- "PARAM_ANGLE_X",
198
- this.#parameterFromNormalized(
199
- "PARAM_ANGLE_X",
200
- Math.sin(ts / 1000) * 0.25,
201
- ),
202
- );
203
- }
204
- // Resume sway shortly after the last pointer sample.
205
- if (this.#autoSwayPausedByPointer) {
206
- this.#autoSwayPausedByPointer = false;
207
- }
208
- runtime.update(dt);
209
- this.#raf = requestAnimationFrame(tick);
210
- };
211
- this.#raf = requestAnimationFrame(tick);
212
- }
213
-
214
- #stopLoop(): void {
215
- if (this.#raf) cancelAnimationFrame(this.#raf);
216
- this.#raf = 0;
217
- this.#lastTs = 0;
218
- }
219
242
  }
220
243
 
244
+ /** @deprecated Use `Live2dWidget` — alias kept for hexo / legacy imports. */
245
+ export const Live2DWidget = Live2dWidget;
246
+
221
247
  function normalizePrefer(
222
248
  prefer: RendererKind[] | undefined,
223
249
  ): RendererKind[] | undefined {
@@ -227,10 +253,36 @@ function normalizePrefer(
227
253
  return out.length ? out : undefined;
228
254
  }
229
255
 
256
+ export async function createLive2dWidget(
257
+ options: ComposedWidgetOptions,
258
+ ): Promise<Live2dWidget> {
259
+ const widget = new Live2dWidget();
260
+ await widget.mount(options);
261
+ return widget;
262
+ }
263
+
230
264
  export async function mountWidget(
231
265
  options: WidgetOptions,
232
- ): Promise<Live2DWidget> {
233
- const widget = new Live2DWidget();
266
+ ): Promise<Live2dWidget> {
267
+ const widget = new Live2dWidget();
234
268
  await widget.mount(options);
235
269
  return widget;
236
270
  }
271
+
272
+ /** Convenience bootstrap: Stage + default Actor + widget chrome. */
273
+ export async function mountWidgetWithStage(
274
+ options: LegacyWidgetOptions,
275
+ ): Promise<{ widget: Live2dWidget; stage: Live2dStage; actor: Live2dActor }> {
276
+ const prefer = normalizePrefer(options.prefer);
277
+ const stage = createLive2dStage({
278
+ renderer: createRenderer({ prefer }),
279
+ updateMode: "auto",
280
+ });
281
+ const actor = stage.createActor({ id: allocateActorId("widget") });
282
+ const widget = new Live2dWidget();
283
+ await widget.mount({ ...options, stage, actor });
284
+ if (options.model) {
285
+ await actor.load(options.model);
286
+ }
287
+ return { widget, stage, actor };
288
+ }