vue-plugin-live2d 0.0.24 โ†’ 0.0.26

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.
Files changed (3) hide show
  1. package/README.md +3 -4
  2. package/package.json +3 -2
  3. package/src/Live2D.vue +138 -211
package/README.md CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  A Vue 3 compatibility component for `@doki-land/live2d`.
4
4
 
5
- > **Runtime freeze (Developer Preview):** from v0.0.23 onward, do not add new runtime
6
- > logic here (RAF loops, hit test, parameter tracking, motion/expression eval).
7
- > Shared behavior belongs in `@doki-land/live2d` Stage/Actor APIs or CE/widget shells.
5
+ > **Runtime freeze lifted for shell only (v0.0.26):** this adapter is a thin Vue wrapper over
6
+ > `<live-2d>` from `@doki-land/live2d-element`. Shared behavior belongs in CE / Stage / Actor APIs.
8
7
 
9
8
  This adapter helps existing Vue applications mount the browser-native runtime. It is not the architectural center of the
10
9
  project and does not replace the framework-independent facade used by game engines and other hosts.
@@ -23,7 +22,7 @@ project and does not replace the framework-independent facade used by game engin
23
22
  ## ๐Ÿ“ฆ Installation
24
23
 
25
24
  ```bash
26
- pnpm add vue-plugin-live2d @doki-land/live2d vue
25
+ pnpm add vue-plugin-live2d @doki-land/live2d-element vue
27
26
  ```
28
27
 
29
28
  ## ๐Ÿš€ Quick Start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vue-plugin-live2d",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "description": "Vue 3 <Live2D> component โ€” mount @doki-land/live2d with props, progress UI, and lifecycle.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,7 +37,8 @@
37
37
  "vue": "^3.4.0"
38
38
  },
39
39
  "dependencies": {
40
- "@doki-land/live2d": "0.0.24"
40
+ "@doki-land/live2d": "0.0.26",
41
+ "@doki-land/live2d-element": "0.0.26"
41
42
  },
42
43
  "sideEffects": false
43
44
  }
package/src/Live2D.vue CHANGED
@@ -4,7 +4,17 @@
4
4
  class="doki-live2d-root"
5
5
  :style="{ width: `${width}px`, height: `${height}px` }"
6
6
  >
7
- <div ref="hostRef" class="doki-live2d-host"/>
7
+ <live-2d
8
+ ref="actorRef"
9
+ :model="modelAttr"
10
+ :width="width"
11
+ :height="height"
12
+ :renderer="rendererKind"
13
+ :autoplay="autoplay"
14
+ :autosway="autoSway"
15
+ interactive
16
+ tracking="pointer"
17
+ />
8
18
  <div
9
19
  v-if="showProgress && loading"
10
20
  class="doki-live2d-progress"
@@ -27,17 +37,15 @@
27
37
  </template>
28
38
 
29
39
  <script setup lang="ts">
30
- import {
31
- createLive2D,
32
- createRenderer,
33
- type FrameProfile,
34
- focusParameterUpdates,
35
- type Live2DRuntime,
36
- type LoadProgress,
37
- type ModelSource,
38
- type PlayMotionOptions,
39
- type RendererKind,
40
+ import "@doki-land/live2d-element";
41
+ import type {
42
+ FrameProfile,
43
+ LoadProgress,
44
+ ModelSource,
45
+ PlayMotionOptions,
46
+ RendererKind,
40
47
  } from "@doki-land/live2d";
48
+ import type { Live2dElement } from "@doki-land/live2d-element";
41
49
  import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
42
50
 
43
51
  const props = withDefaults(
@@ -73,16 +81,21 @@ const emit = defineEmits<{
73
81
  hit: [payload: { area: string; x: number; y: number }];
74
82
  }>();
75
83
 
76
- const hostRef = ref<HTMLDivElement | null>(null);
84
+ const actorRef = ref<Live2dElement | null>(null);
77
85
  const loadProgress = ref<LoadProgress | null>(null);
78
86
  const loading = ref(false);
79
87
 
80
- let _canvas: HTMLCanvasElement | null = null;
81
- let runtime: Live2DRuntime | null = null;
82
- let raf = 0;
83
- let lastTs = 0;
84
- let manualAngleX: number | null = null;
85
- let mountGeneration = 0;
88
+ const modelAttr = computed(() =>
89
+ typeof props.model === "string" ? props.model : "",
90
+ );
91
+
92
+ const rendererKind = computed(() => {
93
+ const first = props.prefer?.[0];
94
+ if (first === "webgpu" || first === "webgl2" || first === "canvas2d") {
95
+ return first;
96
+ }
97
+ return "auto";
98
+ });
86
99
 
87
100
  const progressPercent = computed(() => {
88
101
  const p = loadProgress.value?.progress ?? 0;
@@ -100,215 +113,131 @@ const progressLabel = computed(() => {
100
113
  return p.detail ? `${stage} ยท ${p.detail}` : stage;
101
114
  });
102
115
 
103
- function stopLoop() {
104
- if (raf) cancelAnimationFrame(raf);
105
- raf = 0;
106
- lastTs = 0;
116
+ function actor(): Live2dElement | null {
117
+ return actorRef.value;
107
118
  }
108
119
 
109
- function tick(ts: number) {
110
- if (!runtime) return;
111
- const dt = lastTs ? (ts - lastTs) / 1000 : 0;
112
- lastTs = ts;
113
- if (manualAngleX !== null) {
114
- runtime.setParameter("PARAM_ANGLE_X", manualAngleX);
115
- } else if (props.autoSway) {
116
- const t = ts / 1000;
117
- runtime.setParameter(
118
- "PARAM_ANGLE_X",
119
- parameterFromNormalized("PARAM_ANGLE_X", Math.sin(t) * 0.25),
120
- );
120
+ function syncSource(): void {
121
+ const el = actor();
122
+ if (!el) return;
123
+ if (props.model == null) {
124
+ el.removeAttribute("model");
125
+ el.source = null;
126
+ loading.value = false;
127
+ loadProgress.value = null;
128
+ return;
121
129
  }
122
- runtime.update(dt);
123
- raf = requestAnimationFrame(tick);
130
+ if (typeof props.model === "string") {
131
+ el.source = null;
132
+ el.model = props.model;
133
+ } else {
134
+ el.removeAttribute("model");
135
+ el.source = props.model;
136
+ }
137
+ loading.value = true;
138
+ loadProgress.value = { stage: "mounting", progress: 0.01 };
124
139
  }
125
140
 
126
- function ensureFreshCanvas(): HTMLCanvasElement | null {
127
- const host = hostRef.value;
128
- if (!host) return null;
129
- // Drop any previous canvas so GPU context families never stack in the DOM.
130
- host.replaceChildren();
131
- const next = document.createElement("canvas");
132
- next.className = "doki-live2d-canvas";
133
- // Backing store follows devicePixelRatio so WebGL/WebGPU edges stay sharp.
134
- const dpr = Math.min(
135
- typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
136
- 2,
137
- );
138
- next.width = Math.max(1, Math.round(props.width * dpr));
139
- next.height = Math.max(1, Math.round(props.height * dpr));
140
- next.style.width = `${props.width}px`;
141
- next.style.height = `${props.height}px`;
142
- next.addEventListener("pointermove", onPointerMove);
143
- next.addEventListener("pointerdown", onPointerDown);
144
- host.appendChild(next);
145
- _canvas = next;
146
- return next;
141
+ function syncRenderOptions(): void {
142
+ const el = actor();
143
+ if (!el) return;
144
+ el.renderOptions = { prefer: [...props.prefer] };
147
145
  }
148
146
 
149
- function bindRuntimeEvents(r: Live2DRuntime) {
150
- r.events.on("ready", (p) => {
151
- loading.value = false;
152
- loadProgress.value = {
153
- stage: "ready",
154
- progress: 1,
155
- detail: p.modelId,
156
- };
157
- emit("ready", p.modelId);
158
- });
159
- r.events.on("error", (p) => {
160
- loading.value = false;
161
- emit("error", p.error);
162
- });
163
- r.events.on("progress", (p) => {
164
- loadProgress.value = p;
165
- emit("progress", p);
166
- });
167
- r.events.on("profile", (p) => {
168
- emit("profile", p);
169
- });
147
+ function bindActorEvents(el: Live2dElement): void {
148
+ el.addEventListener("live2d-ready", onReady);
149
+ el.addEventListener("live2d-error", onError);
150
+ el.addEventListener("live2d-progress", onProgress);
151
+ el.addEventListener("live2d-profile", onProfile);
152
+ el.addEventListener("live2d-hit", onHit);
170
153
  }
171
154
 
172
- async function remount() {
173
- const gen = ++mountGeneration;
174
- stopLoop();
175
- runtime?.destroy();
176
- runtime = null;
177
- loadProgress.value = props.model
178
- ? { stage: "mounting", progress: 0.01, detail: "initialize renderer" }
179
- : null;
180
- loading.value = Boolean(props.model);
181
-
182
- let next = ensureFreshCanvas();
183
- if (!next) {
184
- await new Promise<void>((r) => requestAnimationFrame(() => r()));
185
- next = ensureFreshCanvas();
186
- }
187
- if (!next) {
188
- loading.value = false;
189
- emit("error", new Error("vue-plugin-live2d: host element missing"));
190
- return;
191
- }
192
-
193
- runtime = createLive2D({
194
- renderer: createRenderer({ prefer: props.prefer }),
195
- });
196
- bindRuntimeEvents(runtime);
197
- runtime.mount(next);
198
-
199
- if (props.model) {
200
- try {
201
- await runtime.loadModel(props.model);
202
- if (gen !== mountGeneration) return;
203
- if (props.autoplay) {
204
- raf = requestAnimationFrame(tick);
205
- } else {
206
- runtime.update(0);
207
- }
208
- } catch {
209
- // error already emitted via runtime events
210
- }
211
- } else {
212
- loading.value = false;
213
- }
155
+ function unbindActorEvents(el: Live2dElement): void {
156
+ el.removeEventListener("live2d-ready", onReady);
157
+ el.removeEventListener("live2d-error", onError);
158
+ el.removeEventListener("live2d-progress", onProgress);
159
+ el.removeEventListener("live2d-profile", onProfile);
160
+ el.removeEventListener("live2d-hit", onHit);
214
161
  }
215
162
 
216
- /**
217
- * Reload model on the existing runtime/renderer.
218
- * Prefer this over remounting โ€” full remount races GPU init and fetch.
219
- */
220
- async function reload() {
221
- if (!runtime || !props.model) {
222
- await remount();
223
- return;
224
- }
225
- const gen = mountGeneration;
226
- stopLoop();
227
- loading.value = true;
163
+ function onReady(event: Event): void {
164
+ loading.value = false;
165
+ const detail = (event as CustomEvent<{ model?: string }>).detail;
228
166
  loadProgress.value = {
229
- stage: "resolve",
230
- progress: 0.02,
231
- detail: "resolve source",
167
+ stage: "ready",
168
+ progress: 1,
169
+ detail: detail?.model,
232
170
  };
233
- try {
234
- await runtime.loadModel(props.model);
235
- if (gen !== mountGeneration) return;
236
- if (props.autoplay) {
237
- raf = requestAnimationFrame(tick);
238
- } else {
239
- runtime.update(0);
240
- }
241
- } catch {
242
- // error already emitted via runtime events
243
- }
171
+ emit("ready", detail?.model ?? "");
244
172
  }
245
173
 
246
- function setParameter(id: string, value: number) {
247
- if (id === "PARAM_ANGLE_X") {
248
- manualAngleX = value;
249
- }
250
- runtime?.setParameter(id, value);
251
- if (!props.autoplay) {
252
- runtime?.update(0);
253
- }
174
+ function onError(event: Event): void {
175
+ loading.value = false;
176
+ const detail = (event as CustomEvent<{ cause?: unknown; error?: string }>)
177
+ .detail;
178
+ emit("error", detail?.cause ?? detail?.error ?? "live2d-error");
254
179
  }
255
180
 
256
- function clearManualAngleX() {
257
- manualAngleX = null;
181
+ function onProgress(event: Event): void {
182
+ const payload = (event as CustomEvent<LoadProgress>).detail;
183
+ loadProgress.value = payload;
184
+ emit("progress", payload);
258
185
  }
259
186
 
260
- function modelPoint(event: PointerEvent) {
261
- const canvas = _canvas;
262
- if (!canvas) return null;
263
- const rect = canvas.getBoundingClientRect();
264
- if (!rect.width || !rect.height) return null;
265
- return {
266
- x: ((event.clientX - rect.left) / rect.width) * 2 - 1,
267
- y: 1 - ((event.clientY - rect.top) / rect.height) * 2,
268
- };
187
+ function onProfile(event: Event): void {
188
+ emit("profile", (event as CustomEvent<FrameProfile>).detail);
269
189
  }
270
190
 
271
- function parameterFromNormalized(id: string, normalized: number) {
272
- const binding = runtime?.listParameters().find((p) => p.id === id);
273
- if (!binding) return normalized;
274
- return normalized >= 0
275
- ? binding.defaultValue +
276
- (binding.max - binding.defaultValue) * normalized
277
- : binding.defaultValue +
278
- (binding.defaultValue - binding.min) * normalized;
191
+ function onHit(event: Event): void {
192
+ const detail = (
193
+ event as CustomEvent<{
194
+ area: string | null;
195
+ modelX: number;
196
+ modelY: number;
197
+ }>
198
+ ).detail;
199
+ if (detail?.area) {
200
+ emit("hit", {
201
+ area: detail.area,
202
+ x: detail.modelX,
203
+ y: detail.modelY,
204
+ });
205
+ }
279
206
  }
280
207
 
281
- function applyPointerFocus(x: number, y: number) {
282
- if (!runtime) return;
283
- for (const { id, value } of focusParameterUpdates(
284
- runtime.listParameters(),
285
- x,
286
- y,
287
- )) {
288
- if (id === "PARAM_ANGLE_X") {
289
- // Pointer owns ANGLE_X until cleared; stops auto-sway fighting it.
290
- manualAngleX = value;
291
- }
292
- runtime.setParameter(id, value);
208
+ function setParameter(id: string, value: number): void {
209
+ actor()?.runtime?.setParameter(id, value);
210
+ if (!props.autoplay) {
211
+ actor()?.runtime?.update(0);
293
212
  }
294
213
  }
295
214
 
296
- function onPointerMove(event: PointerEvent) {
297
- const p = modelPoint(event);
298
- if (!p || !runtime) return;
299
- applyPointerFocus(p.x, p.y);
300
- if (!props.autoplay) runtime.update(0);
215
+ function clearManualAngleX(): void {
216
+ /* autosway lives on <live-2d>; callers may set PARAM_ANGLE_X directly. */
301
217
  }
302
218
 
303
- function onPointerDown(event: PointerEvent) {
304
- const p = modelPoint(event);
305
- if (!p || !runtime) return;
306
- const area = runtime.hitTest(p.x, p.y);
307
- if (area) emit("hit", { area, x: p.x, y: p.y });
219
+ async function reload(): Promise<void> {
220
+ const el = actor();
221
+ if (!el || props.model == null) {
222
+ syncSource();
223
+ return;
224
+ }
225
+ loading.value = true;
226
+ loadProgress.value = {
227
+ stage: "resolve",
228
+ progress: 0.02,
229
+ detail: "resolve source",
230
+ };
231
+ syncSource();
232
+ await el.loadModel();
308
233
  }
309
234
 
310
235
  onMounted(() => {
311
- void remount();
236
+ const el = actor();
237
+ if (!el) return;
238
+ bindActorEvents(el);
239
+ syncRenderOptions();
240
+ syncSource();
312
241
  });
313
242
 
314
243
  watch(
@@ -319,32 +248,31 @@ watch(
319
248
  props.height,
320
249
  props.prefer?.join(","),
321
250
  props.autoplay,
251
+ props.autoSway,
322
252
  ] as const,
323
253
  () => {
324
- void remount();
254
+ syncRenderOptions();
255
+ syncSource();
325
256
  },
326
257
  );
327
258
 
328
259
  onBeforeUnmount(() => {
329
- mountGeneration += 1;
330
- stopLoop();
331
- runtime?.destroy();
332
- runtime = null;
333
- _canvas = null;
334
- hostRef.value?.replaceChildren();
260
+ const el = actor();
261
+ if (el) unbindActorEvents(el);
335
262
  });
336
263
 
337
264
  defineExpose({
338
- getRuntime: () => runtime,
265
+ getRuntime: () => actor()?.runtime ?? null,
339
266
  setParameter,
340
267
  clearManualAngleX,
341
- listParameters: () => runtime?.listParameters() ?? [],
268
+ listParameters: () => actor()?.runtime?.listParameters() ?? [],
342
269
  playMotion: (group: string, index?: number, options?: PlayMotionOptions) =>
343
- runtime?.playMotion(group, index, options) ?? Promise.resolve(false),
270
+ actor()?.playMotion(group, index, options) ?? Promise.resolve(false),
344
271
  stopMotion: (opts?: { fade?: boolean; slot?: string }) =>
345
- runtime?.stopMotion(opts),
346
- listPlayingMotions: () => runtime?.listPlayingMotions() ?? [],
272
+ actor()?.runtime?.stopMotion(opts),
273
+ listPlayingMotions: () => actor()?.runtime?.listPlayingMotions() ?? [],
347
274
  capturePng: (opts?: { mimeType?: "image/png"; quality?: number }) => {
275
+ const runtime = actor()?.runtime;
348
276
  if (!runtime) {
349
277
  return Promise.reject(
350
278
  new Error("vue-plugin-live2d: runtime not mounted"),
@@ -363,14 +291,13 @@ defineExpose({
363
291
  line-height: 0;
364
292
  }
365
293
 
366
- .doki-live2d-host {
294
+ live-2d {
367
295
  display: block;
368
296
  width: 100%;
369
297
  height: 100%;
370
- line-height: 0;
371
298
  }
372
299
 
373
- .doki-live2d-host :deep(canvas) {
300
+ live-2d::part(canvas) {
374
301
  display: block;
375
302
  width: 100%;
376
303
  height: 100%;