@doki-land/live2d-element 0.0.23 → 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/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # `@doki-land/live2d-element`
2
2
 
3
- Official `<live-2d>` Custom Element — thin host over `@doki-land/live2d` with Stage-owned RAF.
3
+ Official `<live-2d>` / `<live-2d-widget>` Custom Elements — thin hosts over
4
+ `@doki-land/live2d` with Stage-owned RAF.
4
5
 
5
6
  ## Status (Developer Preview)
6
7
 
7
- v0.0.22 thin gate: pure TypeScript `customElements.define('live-2d', …)`. Zero VMZ runtime dependency on this package or on `@doki-land/live2d*`.
8
+ Pure TypeScript `customElements.define`. Zero VMZ runtime dependency.
9
+ Primary TS symbols use the `Live2d*` / `createLive2d*` spelling (see design `00` §4.4).
8
10
 
9
- HTML Custom Elements require a hyphen; the registered tag is **`live-2d`** (not unhyphenated `live2d`). Design `01` still targets eventual `vmz build --target custom-element` when that CLI target ships.
11
+ HTML Custom Elements require a hyphen; tags are **`live-2d`** and **`live-2d-widget`**.
10
12
 
11
13
  ## Install
12
14
 
@@ -26,12 +28,19 @@ pnpm add @doki-land/live2d-element
26
28
  width="320"
27
29
  height="320"
28
30
  autoplay
31
+ interactive
32
+ tracking="pointer"
29
33
  ></live-2d>
30
34
  <script type="module">
31
35
  const el = document.querySelector("live-2d");
32
36
  el.addEventListener("live2d-ready", () => console.log("ready"));
37
+ el.addEventListener("live2d-hit", (e) => console.log(e.detail));
33
38
  el.addEventListener("live2d-error", (e) => console.error(e.detail));
39
+ // Methods: loadModel / playMotion / setExpression / lookAt / pause / resume
34
40
  </script>
35
41
  ```
36
42
 
43
+ `<live-2d-widget>` nests an inner `<live-2d>` and forwards model/size attributes
44
+ (product-shell chrome POC; no second Stage clock).
45
+
37
46
  See `fixtures/index.html` for a native HTML dogfood page.
package/dist/index.d.ts CHANGED
@@ -1,12 +1,17 @@
1
- import { Live2DRuntime } from '@doki-land/live2d';
1
+ import { Live2dRuntime, PlayMotionOptions } from '@doki-land/live2d';
2
2
 
3
3
  /** Idempotent `customElements.define('live-2d', …)`. */
4
4
  declare function defineLive2dElement(): void;
5
+ /** Idempotent `customElements.define('live-2d-widget', …)`. */
6
+ declare function defineLive2dWidgetElement(): void;
7
+ /** Register both official CE tags. */
8
+ declare function defineLive2dElements(): void;
5
9
 
6
10
  declare const LIVE2D_ELEMENT_TAG: "live-2d";
7
11
  type Live2dElementRenderer = "auto" | "webgpu" | "webgl2" | "canvas2d";
12
+ type Live2dElementTracking = "pointer" | "none";
8
13
  /**
9
- * Thin `<live-2d>` host: attribute → createLive2D → Stage RAF → live2d-* events.
14
+ * Thin `<live-2d>` host: attribute → createLive2d → Stage RAF → live2d-* events.
10
15
  */
11
16
  declare class Live2dElement extends HTMLElement {
12
17
  #private;
@@ -21,12 +26,50 @@ declare class Live2dElement extends HTMLElement {
21
26
  set height(value: number);
22
27
  get autoplay(): boolean;
23
28
  set autoplay(value: boolean);
24
- get runtime(): Live2DRuntime | null;
29
+ get interactive(): boolean;
30
+ set interactive(value: boolean);
31
+ get tracking(): Live2dElementTracking;
32
+ set tracking(value: Live2dElementTracking);
33
+ get runtime(): Live2dRuntime | null;
25
34
  /** Imperative reload (also used when `model` attribute changes). */
26
- loadModel(source?: string): Promise<void>;
35
+ loadModel(source?: string, opts?: {
36
+ signal?: AbortSignal;
37
+ }): Promise<void>;
38
+ playMotion(group: string, index?: number, options?: PlayMotionOptions): Promise<boolean>;
39
+ setExpression(name: string | null): Promise<boolean>;
40
+ /** Stage-normalized look-at in [-1, 1] client-mapped coords (x right, y up). */
41
+ lookAt(x: number, y: number): void;
42
+ pause(): void;
43
+ resume(): void;
27
44
  connectedCallback(): void;
28
45
  disconnectedCallback(): void;
29
46
  attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
30
47
  }
31
48
 
32
- export { LIVE2D_ELEMENT_TAG, Live2dElement, type Live2dElementRenderer, defineLive2dElement };
49
+ declare const LIVE2D_WIDGET_ELEMENT_TAG: "live-2d-widget";
50
+ /**
51
+ * Thin `<live-2d-widget>` product-shell CE (v0.0.24 POC).
52
+ *
53
+ * Owns chrome layout only; delegates model/RAF/hit to an inner `<live-2d>`.
54
+ * Does not create a second Stage clock.
55
+ */
56
+ declare class Live2dWidgetElement extends HTMLElement {
57
+ #private;
58
+ static get observedAttributes(): string[];
59
+ get model(): string;
60
+ set model(value: string);
61
+ get renderer(): string;
62
+ set renderer(value: string);
63
+ get width(): number;
64
+ set width(value: number);
65
+ get height(): number;
66
+ set height(value: number);
67
+ get autoplay(): boolean;
68
+ set autoplay(value: boolean);
69
+ /** Inner `<live-2d>` when present. */
70
+ get actor(): Live2dElement | null;
71
+ connectedCallback(): void;
72
+ attributeChangedCallback(): void;
73
+ }
74
+
75
+ export { LIVE2D_ELEMENT_TAG, LIVE2D_WIDGET_ELEMENT_TAG, Live2dElement, type Live2dElementRenderer, type Live2dElementTracking, Live2dWidgetElement, defineLive2dElement, defineLive2dElements, defineLive2dWidgetElement };
package/dist/index.js CHANGED
@@ -1,7 +1,17 @@
1
1
  // src/live2d-element.ts
2
- import { createLive2D } from "@doki-land/live2d";
2
+ import {
3
+ createLive2d
4
+ } from "@doki-land/live2d";
3
5
  var LIVE2D_ELEMENT_TAG = "live-2d";
4
- var OBSERVED = ["model", "renderer", "width", "height", "autoplay"];
6
+ var OBSERVED = [
7
+ "model",
8
+ "renderer",
9
+ "width",
10
+ "height",
11
+ "autoplay",
12
+ "interactive",
13
+ "tracking"
14
+ ];
5
15
  function preferFromRenderer(renderer) {
6
16
  if (renderer === "webgpu") return ["webgpu", "webgl2", "canvas2d"];
7
17
  if (renderer === "webgl2") return ["webgl2", "canvas2d", "webgpu"];
@@ -27,7 +37,11 @@ var Live2dElement = class extends HTMLElement {
27
37
  #width = 320;
28
38
  #height = 320;
29
39
  #autoplay = true;
40
+ #interactive = false;
41
+ #tracking = "none";
30
42
  #connected = false;
43
+ #boundPointer = null;
44
+ #unsubs = [];
31
45
  get model() {
32
46
  return this.#model;
33
47
  }
@@ -61,15 +75,52 @@ var Live2dElement = class extends HTMLElement {
61
75
  if (value) this.setAttribute("autoplay", "");
62
76
  else this.removeAttribute("autoplay");
63
77
  }
78
+ get interactive() {
79
+ return this.#interactive;
80
+ }
81
+ set interactive(value) {
82
+ if (value) this.setAttribute("interactive", "");
83
+ else this.removeAttribute("interactive");
84
+ }
85
+ get tracking() {
86
+ return this.#tracking;
87
+ }
88
+ set tracking(value) {
89
+ this.setAttribute("tracking", value === "pointer" ? "pointer" : "none");
90
+ }
64
91
  get runtime() {
65
92
  return this.#runtime;
66
93
  }
67
94
  /** Imperative reload (also used when `model` attribute changes). */
68
- async loadModel(source) {
95
+ async loadModel(source, opts) {
69
96
  if (source !== void 0) {
70
97
  this.model = String(source);
71
98
  }
72
- await this.#boot();
99
+ await this.#boot(opts?.signal);
100
+ }
101
+ playMotion(group, index, options) {
102
+ return this.#runtime?.playMotion(group, index, options) ?? Promise.resolve(false);
103
+ }
104
+ setExpression(name) {
105
+ return this.#runtime?.setExpression(name) ?? Promise.resolve(false);
106
+ }
107
+ /** Stage-normalized look-at in [-1, 1] client-mapped coords (x right, y up). */
108
+ lookAt(x, y) {
109
+ const stageX = (Number(x) + 1) / 2;
110
+ const stageY = (1 - Number(y)) / 2;
111
+ this.#runtime?.actor.lookAt(stageX, stageY);
112
+ }
113
+ pause() {
114
+ this.#runtime?.stage.pause();
115
+ }
116
+ resume() {
117
+ this.#runtime?.stage.resume();
118
+ if (this.#autoplay) {
119
+ try {
120
+ this.#runtime?.stage.start();
121
+ } catch {
122
+ }
123
+ }
73
124
  }
74
125
  connectedCallback() {
75
126
  this.#connected = true;
@@ -109,6 +160,16 @@ var Live2dElement = class extends HTMLElement {
109
160
  if (this.#autoplay) this.#runtime.stage.start();
110
161
  else this.#runtime.stage.pause();
111
162
  }
163
+ return;
164
+ }
165
+ if (name === "interactive") {
166
+ this.#interactive = parseBoolAttr(this, "interactive", false);
167
+ this.#syncPointer();
168
+ return;
169
+ }
170
+ if (name === "tracking") {
171
+ this.#tracking = value === "pointer" ? "pointer" : "none";
172
+ this.#syncPointer();
112
173
  }
113
174
  }
114
175
  #readAttributes() {
@@ -131,6 +192,8 @@ var Live2dElement = class extends HTMLElement {
131
192
  );
132
193
  }
133
194
  this.#autoplay = parseBoolAttr(this, "autoplay", true);
195
+ this.#interactive = parseBoolAttr(this, "interactive", false);
196
+ this.#tracking = this.getAttribute("tracking") === "pointer" ? "pointer" : "none";
134
197
  }
135
198
  #ensureCanvas() {
136
199
  if (this.#canvas?.isConnected) return this.#canvas;
@@ -153,7 +216,62 @@ var Live2dElement = class extends HTMLElement {
153
216
  this.style.display = this.style.display || "inline-block";
154
217
  this.#runtime?.stage.resize?.(this.#width, this.#height);
155
218
  }
219
+ #clearUnsubs() {
220
+ for (const off of this.#unsubs) {
221
+ try {
222
+ off();
223
+ } catch {
224
+ }
225
+ }
226
+ this.#unsubs = [];
227
+ }
228
+ #detachPointer() {
229
+ if (this.#canvas && this.#boundPointer) {
230
+ this.#canvas.removeEventListener("pointerdown", this.#boundPointer);
231
+ this.#canvas.removeEventListener("pointermove", this.#boundPointer);
232
+ }
233
+ this.#boundPointer = null;
234
+ }
235
+ #syncPointer() {
236
+ this.#detachPointer();
237
+ const canvas = this.#canvas;
238
+ const runtime = this.#runtime;
239
+ if (!canvas || !runtime) return;
240
+ if (!this.#interactive && this.#tracking !== "pointer") return;
241
+ this.#boundPointer = (event) => {
242
+ const rect = canvas.getBoundingClientRect();
243
+ if (rect.width <= 0 || rect.height <= 0) return;
244
+ const nx = (event.clientX - rect.left) / rect.width * 2 - 1;
245
+ const ny = 1 - (event.clientY - rect.top) / rect.height * 2;
246
+ if (this.#tracking === "pointer" || event.type === "pointermove") {
247
+ this.lookAt(nx, ny);
248
+ }
249
+ if (this.#interactive && event.type === "pointerdown") {
250
+ const area = runtime.hitTest(nx, ny);
251
+ this.dispatchEvent(
252
+ new CustomEvent("live2d-hit", {
253
+ bubbles: true,
254
+ composed: true,
255
+ detail: {
256
+ area,
257
+ modelX: nx,
258
+ modelY: ny,
259
+ originalEvent: event
260
+ }
261
+ })
262
+ );
263
+ }
264
+ };
265
+ if (this.#interactive) {
266
+ canvas.addEventListener("pointerdown", this.#boundPointer);
267
+ }
268
+ if (this.#tracking === "pointer") {
269
+ canvas.addEventListener("pointermove", this.#boundPointer);
270
+ }
271
+ }
156
272
  #destroyRuntime() {
273
+ this.#clearUnsubs();
274
+ this.#detachPointer();
157
275
  try {
158
276
  this.#runtime?.stage.stop?.();
159
277
  } catch {
@@ -163,17 +281,26 @@ var Live2dElement = class extends HTMLElement {
163
281
  this.removeAttribute("data-phase");
164
282
  this.removeAttribute("aria-busy");
165
283
  }
166
- async #boot() {
284
+ async #boot(signal) {
167
285
  if (!this.#connected) return;
168
286
  const model = this.#model.trim();
169
287
  if (!model) return;
170
288
  const gen = ++this.#mountGen;
171
289
  this.#destroyRuntime();
290
+ if (signal?.aborted) {
291
+ this.#emitError(signal.reason ?? new Error("aborted"));
292
+ return;
293
+ }
294
+ const onAbort = () => {
295
+ this.#mountGen += 1;
296
+ this.#destroyRuntime();
297
+ };
298
+ signal?.addEventListener("abort", onAbort, { once: true });
172
299
  const canvas = this.#ensureCanvas();
173
300
  this.setAttribute("data-phase", "loading");
174
301
  this.setAttribute("aria-busy", "true");
175
302
  try {
176
- const runtime = createLive2D({
303
+ const runtime = createLive2d({
177
304
  prefer: preferFromRenderer(this.#renderer),
178
305
  updateMode: "auto"
179
306
  });
@@ -181,38 +308,70 @@ var Live2dElement = class extends HTMLElement {
181
308
  runtime.destroy();
182
309
  return;
183
310
  }
184
- runtime.events.on("ready", () => {
185
- if (gen !== this.#mountGen) return;
186
- this.setAttribute("data-phase", "live");
187
- this.setAttribute("aria-busy", "false");
188
- if (this.#autoplay) {
189
- try {
190
- runtime.stage.start();
191
- } catch {
311
+ this.#unsubs.push(
312
+ runtime.events.on("ready", () => {
313
+ if (gen !== this.#mountGen) return;
314
+ this.setAttribute("data-phase", "live");
315
+ this.setAttribute("aria-busy", "false");
316
+ if (this.#autoplay) {
317
+ try {
318
+ runtime.stage.start();
319
+ } catch {
320
+ }
192
321
  }
193
- }
194
- this.dispatchEvent(
195
- new CustomEvent("live2d-ready", {
196
- bubbles: true,
197
- composed: true,
198
- detail: { model }
199
- })
200
- );
201
- });
202
- runtime.events.on("error", (payload) => {
203
- if (gen !== this.#mountGen) return;
204
- this.#emitError(payload?.error ?? "load error");
205
- });
322
+ this.#syncPointer();
323
+ this.dispatchEvent(
324
+ new CustomEvent("live2d-ready", {
325
+ bubbles: true,
326
+ composed: true,
327
+ detail: { model }
328
+ })
329
+ );
330
+ })
331
+ );
332
+ this.#unsubs.push(
333
+ runtime.events.on("error", (payload) => {
334
+ if (gen !== this.#mountGen) return;
335
+ this.#emitError(payload?.error ?? "load error");
336
+ })
337
+ );
338
+ this.#unsubs.push(
339
+ runtime.events.on("motion:start", (payload) => {
340
+ if (gen !== this.#mountGen) return;
341
+ this.dispatchEvent(
342
+ new CustomEvent("live2d-motion-start", {
343
+ bubbles: true,
344
+ composed: true,
345
+ detail: payload
346
+ })
347
+ );
348
+ })
349
+ );
350
+ this.#unsubs.push(
351
+ runtime.events.on("motion:finish", (payload) => {
352
+ if (gen !== this.#mountGen) return;
353
+ this.dispatchEvent(
354
+ new CustomEvent("live2d-motion-finish", {
355
+ bubbles: true,
356
+ composed: true,
357
+ detail: payload
358
+ })
359
+ );
360
+ })
361
+ );
206
362
  runtime.mount(canvas);
207
- await runtime.loadModel(model);
363
+ await runtime.loadModel(model, void 0, { signal });
208
364
  if (gen !== this.#mountGen) {
209
365
  runtime.destroy();
210
366
  return;
211
367
  }
212
368
  this.#runtime = runtime;
369
+ this.#syncPointer();
213
370
  } catch (err) {
214
371
  if (gen !== this.#mountGen) return;
215
372
  this.#emitError(err);
373
+ } finally {
374
+ signal?.removeEventListener("abort", onAbort);
216
375
  }
217
376
  }
218
377
  #emitError(error) {
@@ -231,23 +390,137 @@ var Live2dElement = class extends HTMLElement {
231
390
  }
232
391
  };
233
392
 
393
+ // src/live2d-widget-element.ts
394
+ var LIVE2D_WIDGET_ELEMENT_TAG = "live-2d-widget";
395
+ var OBSERVED2 = ["model", "renderer", "width", "height", "autoplay"];
396
+ var Live2dWidgetElement = class extends HTMLElement {
397
+ static get observedAttributes() {
398
+ return [...OBSERVED2];
399
+ }
400
+ #host = null;
401
+ #actor = null;
402
+ get model() {
403
+ return this.getAttribute("model") ?? "";
404
+ }
405
+ set model(value) {
406
+ const next = String(value ?? "");
407
+ if (next) this.setAttribute("model", next);
408
+ else this.removeAttribute("model");
409
+ }
410
+ get renderer() {
411
+ return this.getAttribute("renderer") ?? "auto";
412
+ }
413
+ set renderer(value) {
414
+ this.setAttribute("renderer", value || "auto");
415
+ }
416
+ get width() {
417
+ return Math.max(1, Number(this.getAttribute("width")) || 320);
418
+ }
419
+ set width(value) {
420
+ this.setAttribute("width", String(Math.max(1, Number(value) || 320)));
421
+ }
422
+ get height() {
423
+ return Math.max(1, Number(this.getAttribute("height")) || 320);
424
+ }
425
+ set height(value) {
426
+ this.setAttribute("height", String(Math.max(1, Number(value) || 320)));
427
+ }
428
+ get autoplay() {
429
+ if (!this.hasAttribute("autoplay")) return true;
430
+ const v = this.getAttribute("autoplay");
431
+ if (v === null || v === "" || v === "autoplay") return true;
432
+ return v !== "false" && v !== "0";
433
+ }
434
+ set autoplay(value) {
435
+ if (value) this.setAttribute("autoplay", "");
436
+ else this.removeAttribute("autoplay");
437
+ }
438
+ /** Inner `<live-2d>` when present. */
439
+ get actor() {
440
+ return this.#actor;
441
+ }
442
+ connectedCallback() {
443
+ defineLive2dElement();
444
+ this.#ensureDom();
445
+ this.#syncActorFromAttributes();
446
+ }
447
+ attributeChangedCallback() {
448
+ if (!this.isConnected) return;
449
+ this.#syncActorFromAttributes();
450
+ }
451
+ #ensureDom() {
452
+ if (this.#host?.isConnected) return;
453
+ let actor = this.querySelector(
454
+ LIVE2D_ELEMENT_TAG
455
+ );
456
+ if (!actor) {
457
+ actor = document.createElement(LIVE2D_ELEMENT_TAG);
458
+ } else {
459
+ actor.remove();
460
+ }
461
+ this.replaceChildren();
462
+ const host = document.createElement("div");
463
+ host.setAttribute("part", "chrome");
464
+ host.className = "live2d-widget-chrome";
465
+ host.appendChild(actor);
466
+ this.appendChild(host);
467
+ this.#host = host;
468
+ this.#actor = actor;
469
+ }
470
+ #syncActorFromAttributes() {
471
+ this.#ensureDom();
472
+ const actor = this.#actor;
473
+ if (!actor) return;
474
+ if (this.hasAttribute("model")) {
475
+ actor.setAttribute("model", this.model);
476
+ }
477
+ if (this.hasAttribute("renderer")) {
478
+ actor.setAttribute("renderer", this.renderer);
479
+ }
480
+ actor.setAttribute("width", String(this.width));
481
+ actor.setAttribute("height", String(this.height));
482
+ if (this.autoplay) actor.setAttribute("autoplay", "");
483
+ else actor.removeAttribute("autoplay");
484
+ }
485
+ };
486
+
234
487
  // src/define.ts
235
- var defined = false;
488
+ var elementDefined = false;
489
+ var widgetDefined = false;
236
490
  function defineLive2dElement() {
237
- if (defined) return;
491
+ if (elementDefined) return;
238
492
  if (typeof customElements === "undefined") return;
239
493
  if (customElements.get(LIVE2D_ELEMENT_TAG)) {
240
- defined = true;
494
+ elementDefined = true;
241
495
  return;
242
496
  }
243
497
  customElements.define(LIVE2D_ELEMENT_TAG, Live2dElement);
244
- defined = true;
498
+ elementDefined = true;
499
+ }
500
+ function defineLive2dWidgetElement() {
501
+ defineLive2dElement();
502
+ if (widgetDefined) return;
503
+ if (typeof customElements === "undefined") return;
504
+ if (customElements.get(LIVE2D_WIDGET_ELEMENT_TAG)) {
505
+ widgetDefined = true;
506
+ return;
507
+ }
508
+ customElements.define(LIVE2D_WIDGET_ELEMENT_TAG, Live2dWidgetElement);
509
+ widgetDefined = true;
510
+ }
511
+ function defineLive2dElements() {
512
+ defineLive2dElement();
513
+ defineLive2dWidgetElement();
245
514
  }
246
515
 
247
516
  // src/index.ts
248
- defineLive2dElement();
517
+ defineLive2dElements();
249
518
  export {
250
519
  LIVE2D_ELEMENT_TAG,
520
+ LIVE2D_WIDGET_ELEMENT_TAG,
251
521
  Live2dElement,
252
- defineLive2dElement
522
+ Live2dWidgetElement,
523
+ defineLive2dElement,
524
+ defineLive2dElements,
525
+ defineLive2dWidgetElement
253
526
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doki-land/live2d-element",
3
- "version": "0.0.23",
3
+ "version": "0.0.24",
4
4
  "description": "Official <live-2d> Custom Element — thin host over @doki-land/live2d (Stage-owned RAF).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  "test": "vitest run --passWithNoTests"
46
46
  },
47
47
  "dependencies": {
48
- "@doki-land/live2d": "0.0.23"
48
+ "@doki-land/live2d": "0.0.24"
49
49
  },
50
50
  "sideEffects": [
51
51
  "./src/index.ts",
package/src/define.ts CHANGED
@@ -1,15 +1,39 @@
1
1
  import { LIVE2D_ELEMENT_TAG, Live2dElement } from "./live2d-element.js";
2
+ import {
3
+ LIVE2D_WIDGET_ELEMENT_TAG,
4
+ Live2dWidgetElement,
5
+ } from "./live2d-widget-element.js";
2
6
 
3
- let defined = false;
7
+ let elementDefined = false;
8
+ let widgetDefined = false;
4
9
 
5
10
  /** Idempotent `customElements.define('live-2d', …)`. */
6
11
  export function defineLive2dElement(): void {
7
- if (defined) return;
12
+ if (elementDefined) return;
8
13
  if (typeof customElements === "undefined") return;
9
14
  if (customElements.get(LIVE2D_ELEMENT_TAG)) {
10
- defined = true;
15
+ elementDefined = true;
11
16
  return;
12
17
  }
13
18
  customElements.define(LIVE2D_ELEMENT_TAG, Live2dElement);
14
- defined = true;
19
+ elementDefined = true;
20
+ }
21
+
22
+ /** Idempotent `customElements.define('live-2d-widget', …)`. */
23
+ export function defineLive2dWidgetElement(): void {
24
+ defineLive2dElement();
25
+ if (widgetDefined) return;
26
+ if (typeof customElements === "undefined") return;
27
+ if (customElements.get(LIVE2D_WIDGET_ELEMENT_TAG)) {
28
+ widgetDefined = true;
29
+ return;
30
+ }
31
+ customElements.define(LIVE2D_WIDGET_ELEMENT_TAG, Live2dWidgetElement);
32
+ widgetDefined = true;
33
+ }
34
+
35
+ /** Register both official CE tags. */
36
+ export function defineLive2dElements(): void {
37
+ defineLive2dElement();
38
+ defineLive2dWidgetElement();
15
39
  }
package/src/index.ts CHANGED
@@ -1,21 +1,28 @@
1
1
  /**
2
- * `@doki-land/live2d-element` — official `<live-2d>` Custom Element.
2
+ * `@doki-land/live2d-element` — official `<live-2d>` / `<live-2d-widget>` CEs.
3
3
  *
4
- * v0.0.22 thin gate: pure TypeScript CE wrapping `@doki-land/live2d`
5
- * (`createLive2D` + Stage-owned RAF). Zero VMZ runtime dependency.
6
- * Design `01` still targets eventual `vmz build --target custom-element`
7
- * when that CLI target ships in published `@vmz/vmz`; until then this
8
- * package is the cross-ecosystem ABI skeleton.
4
+ * Pure TypeScript CE wrapping `@doki-land/live2d` (`createLive2d` + Stage-owned
5
+ * RAF). Zero VMZ runtime dependency. Design `01` still targets eventual
6
+ * `vmz build --target custom-element` when that CLI target ships.
9
7
  */
10
8
 
11
- export { defineLive2dElement } from "./define.js";
9
+ export {
10
+ defineLive2dElement,
11
+ defineLive2dElements,
12
+ defineLive2dWidgetElement,
13
+ } from "./define.js";
12
14
  export {
13
15
  LIVE2D_ELEMENT_TAG,
14
16
  Live2dElement,
15
17
  type Live2dElementRenderer,
18
+ type Live2dElementTracking,
16
19
  } from "./live2d-element.js";
20
+ export {
21
+ LIVE2D_WIDGET_ELEMENT_TAG,
22
+ Live2dWidgetElement,
23
+ } from "./live2d-widget-element.js";
17
24
 
18
- import { defineLive2dElement } from "./define.js";
25
+ import { defineLive2dElements } from "./define.js";
19
26
 
20
27
  /** Side-effect registration for `<script type="module">` consumers. */
21
- defineLive2dElement();
28
+ defineLive2dElements();
@@ -1,10 +1,23 @@
1
- import { createLive2D, type Live2DRuntime } from "@doki-land/live2d";
1
+ import {
2
+ createLive2d,
3
+ type Live2dRuntime,
4
+ type PlayMotionOptions,
5
+ } from "@doki-land/live2d";
2
6
 
3
7
  export const LIVE2D_ELEMENT_TAG = "live-2d" as const;
4
8
 
5
9
  export type Live2dElementRenderer = "auto" | "webgpu" | "webgl2" | "canvas2d";
10
+ export type Live2dElementTracking = "pointer" | "none";
6
11
 
7
- const OBSERVED = ["model", "renderer", "width", "height", "autoplay"] as const;
12
+ const OBSERVED = [
13
+ "model",
14
+ "renderer",
15
+ "width",
16
+ "height",
17
+ "autoplay",
18
+ "interactive",
19
+ "tracking",
20
+ ] as const;
8
21
 
9
22
  function preferFromRenderer(
10
23
  renderer: Live2dElementRenderer,
@@ -28,14 +41,14 @@ function parseBoolAttr(
28
41
  }
29
42
 
30
43
  /**
31
- * Thin `<live-2d>` host: attribute → createLive2D → Stage RAF → live2d-* events.
44
+ * Thin `<live-2d>` host: attribute → createLive2d → Stage RAF → live2d-* events.
32
45
  */
33
46
  export class Live2dElement extends HTMLElement {
34
47
  static get observedAttributes(): string[] {
35
48
  return [...OBSERVED];
36
49
  }
37
50
 
38
- #runtime: Live2DRuntime | null = null;
51
+ #runtime: Live2dRuntime | null = null;
39
52
  #canvas: HTMLCanvasElement | null = null;
40
53
  #mountGen = 0;
41
54
  #model = "";
@@ -43,7 +56,11 @@ export class Live2dElement extends HTMLElement {
43
56
  #width = 320;
44
57
  #height = 320;
45
58
  #autoplay = true;
59
+ #interactive = false;
60
+ #tracking: Live2dElementTracking = "none";
46
61
  #connected = false;
62
+ #boundPointer: ((event: PointerEvent) => void) | null = null;
63
+ #unsubs: Array<() => void> = [];
47
64
 
48
65
  get model(): string {
49
66
  return this.#model;
@@ -83,16 +100,71 @@ export class Live2dElement extends HTMLElement {
83
100
  else this.removeAttribute("autoplay");
84
101
  }
85
102
 
86
- get runtime(): Live2DRuntime | null {
103
+ get interactive(): boolean {
104
+ return this.#interactive;
105
+ }
106
+ set interactive(value: boolean) {
107
+ if (value) this.setAttribute("interactive", "");
108
+ else this.removeAttribute("interactive");
109
+ }
110
+
111
+ get tracking(): Live2dElementTracking {
112
+ return this.#tracking;
113
+ }
114
+ set tracking(value: Live2dElementTracking) {
115
+ this.setAttribute("tracking", value === "pointer" ? "pointer" : "none");
116
+ }
117
+
118
+ get runtime(): Live2dRuntime | null {
87
119
  return this.#runtime;
88
120
  }
89
121
 
90
122
  /** Imperative reload (also used when `model` attribute changes). */
91
- async loadModel(source?: string): Promise<void> {
123
+ async loadModel(
124
+ source?: string,
125
+ opts?: { signal?: AbortSignal },
126
+ ): Promise<void> {
92
127
  if (source !== undefined) {
93
128
  this.model = String(source);
94
129
  }
95
- await this.#boot();
130
+ await this.#boot(opts?.signal);
131
+ }
132
+
133
+ playMotion(
134
+ group: string,
135
+ index?: number,
136
+ options?: PlayMotionOptions,
137
+ ): Promise<boolean> {
138
+ return (
139
+ this.#runtime?.playMotion(group, index, options) ??
140
+ Promise.resolve(false)
141
+ );
142
+ }
143
+
144
+ setExpression(name: string | null): Promise<boolean> {
145
+ return this.#runtime?.setExpression(name) ?? Promise.resolve(false);
146
+ }
147
+
148
+ /** Stage-normalized look-at in [-1, 1] client-mapped coords (x right, y up). */
149
+ lookAt(x: number, y: number): void {
150
+ const stageX = (Number(x) + 1) / 2;
151
+ const stageY = (1 - Number(y)) / 2;
152
+ this.#runtime?.actor.lookAt(stageX, stageY);
153
+ }
154
+
155
+ pause(): void {
156
+ this.#runtime?.stage.pause();
157
+ }
158
+
159
+ resume(): void {
160
+ this.#runtime?.stage.resume();
161
+ if (this.#autoplay) {
162
+ try {
163
+ this.#runtime?.stage.start();
164
+ } catch {
165
+ /* already running */
166
+ }
167
+ }
96
168
  }
97
169
 
98
170
  connectedCallback(): void {
@@ -139,6 +211,16 @@ export class Live2dElement extends HTMLElement {
139
211
  if (this.#autoplay) this.#runtime.stage.start();
140
212
  else this.#runtime.stage.pause();
141
213
  }
214
+ return;
215
+ }
216
+ if (name === "interactive") {
217
+ this.#interactive = parseBoolAttr(this, "interactive", false);
218
+ this.#syncPointer();
219
+ return;
220
+ }
221
+ if (name === "tracking") {
222
+ this.#tracking = value === "pointer" ? "pointer" : "none";
223
+ this.#syncPointer();
142
224
  }
143
225
  }
144
226
 
@@ -163,6 +245,9 @@ export class Live2dElement extends HTMLElement {
163
245
  );
164
246
  }
165
247
  this.#autoplay = parseBoolAttr(this, "autoplay", true);
248
+ this.#interactive = parseBoolAttr(this, "interactive", false);
249
+ this.#tracking =
250
+ this.getAttribute("tracking") === "pointer" ? "pointer" : "none";
166
251
  }
167
252
 
168
253
  #ensureCanvas(): HTMLCanvasElement {
@@ -191,7 +276,67 @@ export class Live2dElement extends HTMLElement {
191
276
  this.#runtime?.stage.resize?.(this.#width, this.#height);
192
277
  }
193
278
 
279
+ #clearUnsubs(): void {
280
+ for (const off of this.#unsubs) {
281
+ try {
282
+ off();
283
+ } catch {
284
+ /* ignore */
285
+ }
286
+ }
287
+ this.#unsubs = [];
288
+ }
289
+
290
+ #detachPointer(): void {
291
+ if (this.#canvas && this.#boundPointer) {
292
+ this.#canvas.removeEventListener("pointerdown", this.#boundPointer);
293
+ this.#canvas.removeEventListener("pointermove", this.#boundPointer);
294
+ }
295
+ this.#boundPointer = null;
296
+ }
297
+
298
+ #syncPointer(): void {
299
+ this.#detachPointer();
300
+ const canvas = this.#canvas;
301
+ const runtime = this.#runtime;
302
+ if (!canvas || !runtime) return;
303
+ if (!this.#interactive && this.#tracking !== "pointer") return;
304
+
305
+ this.#boundPointer = (event: PointerEvent) => {
306
+ const rect = canvas.getBoundingClientRect();
307
+ if (rect.width <= 0 || rect.height <= 0) return;
308
+ const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
309
+ const ny = 1 - ((event.clientY - rect.top) / rect.height) * 2;
310
+ if (this.#tracking === "pointer" || event.type === "pointermove") {
311
+ this.lookAt(nx, ny);
312
+ }
313
+ if (this.#interactive && event.type === "pointerdown") {
314
+ const area = runtime.hitTest(nx, ny);
315
+ this.dispatchEvent(
316
+ new CustomEvent("live2d-hit", {
317
+ bubbles: true,
318
+ composed: true,
319
+ detail: {
320
+ area,
321
+ modelX: nx,
322
+ modelY: ny,
323
+ originalEvent: event,
324
+ },
325
+ }),
326
+ );
327
+ }
328
+ };
329
+ if (this.#interactive) {
330
+ canvas.addEventListener("pointerdown", this.#boundPointer);
331
+ }
332
+ if (this.#tracking === "pointer") {
333
+ canvas.addEventListener("pointermove", this.#boundPointer);
334
+ }
335
+ }
336
+
194
337
  #destroyRuntime(): void {
338
+ this.#clearUnsubs();
339
+ this.#detachPointer();
195
340
  try {
196
341
  this.#runtime?.stage.stop?.();
197
342
  } catch {
@@ -203,7 +348,7 @@ export class Live2dElement extends HTMLElement {
203
348
  this.removeAttribute("aria-busy");
204
349
  }
205
350
 
206
- async #boot(): Promise<void> {
351
+ async #boot(signal?: AbortSignal): Promise<void> {
207
352
  if (!this.#connected) return;
208
353
  const model = this.#model.trim();
209
354
  if (!model) return;
@@ -211,12 +356,23 @@ export class Live2dElement extends HTMLElement {
211
356
  const gen = ++this.#mountGen;
212
357
  this.#destroyRuntime();
213
358
 
359
+ if (signal?.aborted) {
360
+ this.#emitError(signal.reason ?? new Error("aborted"));
361
+ return;
362
+ }
363
+
364
+ const onAbort = () => {
365
+ this.#mountGen += 1;
366
+ this.#destroyRuntime();
367
+ };
368
+ signal?.addEventListener("abort", onAbort, { once: true });
369
+
214
370
  const canvas = this.#ensureCanvas();
215
371
  this.setAttribute("data-phase", "loading");
216
372
  this.setAttribute("aria-busy", "true");
217
373
 
218
374
  try {
219
- const runtime = createLive2D({
375
+ const runtime = createLive2d({
220
376
  prefer: preferFromRenderer(this.#renderer),
221
377
  updateMode: "auto",
222
378
  });
@@ -225,41 +381,75 @@ export class Live2dElement extends HTMLElement {
225
381
  return;
226
382
  }
227
383
 
228
- runtime.events.on("ready", () => {
229
- if (gen !== this.#mountGen) return;
230
- this.setAttribute("data-phase", "live");
231
- this.setAttribute("aria-busy", "false");
232
- if (this.#autoplay) {
233
- try {
234
- runtime.stage.start();
235
- } catch {
236
- /* already running */
384
+ this.#unsubs.push(
385
+ runtime.events.on("ready", () => {
386
+ if (gen !== this.#mountGen) return;
387
+ this.setAttribute("data-phase", "live");
388
+ this.setAttribute("aria-busy", "false");
389
+ if (this.#autoplay) {
390
+ try {
391
+ runtime.stage.start();
392
+ } catch {
393
+ /* already running */
394
+ }
237
395
  }
238
- }
239
- this.dispatchEvent(
240
- new CustomEvent("live2d-ready", {
241
- bubbles: true,
242
- composed: true,
243
- detail: { model },
244
- }),
245
- );
246
- });
396
+ this.#syncPointer();
397
+ this.dispatchEvent(
398
+ new CustomEvent("live2d-ready", {
399
+ bubbles: true,
400
+ composed: true,
401
+ detail: { model },
402
+ }),
403
+ );
404
+ }),
405
+ );
247
406
 
248
- runtime.events.on("error", (payload) => {
249
- if (gen !== this.#mountGen) return;
250
- this.#emitError(payload?.error ?? "load error");
251
- });
407
+ this.#unsubs.push(
408
+ runtime.events.on("error", (payload) => {
409
+ if (gen !== this.#mountGen) return;
410
+ this.#emitError(payload?.error ?? "load error");
411
+ }),
412
+ );
413
+
414
+ this.#unsubs.push(
415
+ runtime.events.on("motion:start", (payload) => {
416
+ if (gen !== this.#mountGen) return;
417
+ this.dispatchEvent(
418
+ new CustomEvent("live2d-motion-start", {
419
+ bubbles: true,
420
+ composed: true,
421
+ detail: payload,
422
+ }),
423
+ );
424
+ }),
425
+ );
426
+
427
+ this.#unsubs.push(
428
+ runtime.events.on("motion:finish", (payload) => {
429
+ if (gen !== this.#mountGen) return;
430
+ this.dispatchEvent(
431
+ new CustomEvent("live2d-motion-finish", {
432
+ bubbles: true,
433
+ composed: true,
434
+ detail: payload,
435
+ }),
436
+ );
437
+ }),
438
+ );
252
439
 
253
440
  runtime.mount(canvas);
254
- await runtime.loadModel(model);
441
+ await runtime.loadModel(model, undefined, { signal });
255
442
  if (gen !== this.#mountGen) {
256
443
  runtime.destroy();
257
444
  return;
258
445
  }
259
446
  this.#runtime = runtime;
447
+ this.#syncPointer();
260
448
  } catch (err) {
261
449
  if (gen !== this.#mountGen) return;
262
450
  this.#emitError(err);
451
+ } finally {
452
+ signal?.removeEventListener("abort", onAbort);
263
453
  }
264
454
  }
265
455
 
@@ -0,0 +1,117 @@
1
+ import { defineLive2dElement } from "./define.js";
2
+ import { LIVE2D_ELEMENT_TAG, type Live2dElement } from "./live2d-element.js";
3
+
4
+ export const LIVE2D_WIDGET_ELEMENT_TAG = "live-2d-widget" as const;
5
+
6
+ const OBSERVED = ["model", "renderer", "width", "height", "autoplay"] as const;
7
+
8
+ /**
9
+ * Thin `<live-2d-widget>` product-shell CE (v0.0.24 POC).
10
+ *
11
+ * Owns chrome layout only; delegates model/RAF/hit to an inner `<live-2d>`.
12
+ * Does not create a second Stage clock.
13
+ */
14
+ export class Live2dWidgetElement extends HTMLElement {
15
+ static get observedAttributes(): string[] {
16
+ return [...OBSERVED];
17
+ }
18
+
19
+ #host: HTMLElement | null = null;
20
+ #actor: Live2dElement | null = null;
21
+
22
+ get model(): string {
23
+ return this.getAttribute("model") ?? "";
24
+ }
25
+ set model(value: string) {
26
+ const next = String(value ?? "");
27
+ if (next) this.setAttribute("model", next);
28
+ else this.removeAttribute("model");
29
+ }
30
+
31
+ get renderer(): string {
32
+ return this.getAttribute("renderer") ?? "auto";
33
+ }
34
+ set renderer(value: string) {
35
+ this.setAttribute("renderer", value || "auto");
36
+ }
37
+
38
+ get width(): number {
39
+ return Math.max(1, Number(this.getAttribute("width")) || 320);
40
+ }
41
+ set width(value: number) {
42
+ this.setAttribute("width", String(Math.max(1, Number(value) || 320)));
43
+ }
44
+
45
+ get height(): number {
46
+ return Math.max(1, Number(this.getAttribute("height")) || 320);
47
+ }
48
+ set height(value: number) {
49
+ this.setAttribute("height", String(Math.max(1, Number(value) || 320)));
50
+ }
51
+
52
+ get autoplay(): boolean {
53
+ if (!this.hasAttribute("autoplay")) return true;
54
+ const v = this.getAttribute("autoplay");
55
+ if (v === null || v === "" || v === "autoplay") return true;
56
+ return v !== "false" && v !== "0";
57
+ }
58
+ set autoplay(value: boolean) {
59
+ if (value) this.setAttribute("autoplay", "");
60
+ else this.removeAttribute("autoplay");
61
+ }
62
+
63
+ /** Inner `<live-2d>` when present. */
64
+ get actor(): Live2dElement | null {
65
+ return this.#actor;
66
+ }
67
+
68
+ connectedCallback(): void {
69
+ defineLive2dElement();
70
+ this.#ensureDom();
71
+ this.#syncActorFromAttributes();
72
+ }
73
+
74
+ attributeChangedCallback(): void {
75
+ if (!this.isConnected) return;
76
+ this.#syncActorFromAttributes();
77
+ }
78
+
79
+ #ensureDom(): void {
80
+ if (this.#host?.isConnected) return;
81
+
82
+ let actor = this.querySelector(
83
+ LIVE2D_ELEMENT_TAG,
84
+ ) as Live2dElement | null;
85
+ if (!actor) {
86
+ actor = document.createElement(LIVE2D_ELEMENT_TAG) as Live2dElement;
87
+ } else {
88
+ actor.remove();
89
+ }
90
+
91
+ this.replaceChildren();
92
+ const host = document.createElement("div");
93
+ host.setAttribute("part", "chrome");
94
+ host.className = "live2d-widget-chrome";
95
+ host.appendChild(actor);
96
+ this.appendChild(host);
97
+ this.#host = host;
98
+ this.#actor = actor;
99
+ }
100
+
101
+ #syncActorFromAttributes(): void {
102
+ this.#ensureDom();
103
+ const actor = this.#actor;
104
+ if (!actor) return;
105
+
106
+ if (this.hasAttribute("model")) {
107
+ actor.setAttribute("model", this.model);
108
+ }
109
+ if (this.hasAttribute("renderer")) {
110
+ actor.setAttribute("renderer", this.renderer);
111
+ }
112
+ actor.setAttribute("width", String(this.width));
113
+ actor.setAttribute("height", String(this.height));
114
+ if (this.autoplay) actor.setAttribute("autoplay", "");
115
+ else actor.removeAttribute("autoplay");
116
+ }
117
+ }