vue-plugin-live2d 0.0.25 โ†’ 0.0.27

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
@@ -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.
@@ -18,23 +17,24 @@ project and does not replace the framework-independent facade used by game engin
18
17
  - Ready, error, progress, profile, and hit events.
19
18
  - Optional browser animation loop.
20
19
  - Pointer tracking.
20
+ - Optional `interactive` / `tracking` props (forwarded to `<live-2d>`).
21
21
  - Parameter inspection and mutation through the exposed component API.
22
22
 
23
23
  ## ๐Ÿ“ฆ Installation
24
24
 
25
25
  ```bash
26
- pnpm add vue-plugin-live2d @doki-land/live2d vue
26
+ pnpm add vue-plugin-live2d @doki-land/live2d-element vue
27
27
  ```
28
28
 
29
29
  ## ๐Ÿš€ Quick Start
30
30
 
31
31
  ```vue
32
32
  <script setup lang="ts">
33
- import { Live2D } from "vue-plugin-live2d";
33
+ import { Live2d } from "vue-plugin-live2d";
34
34
  </script>
35
35
 
36
36
  <template>
37
- <Live2D
37
+ <Live2d
38
38
  model="/models/character.model3.json"
39
39
  :width="480"
40
40
  :height="640"
@@ -55,9 +55,9 @@ Use a template ref for runtime-level controls:
55
55
  ```vue
56
56
  <script setup lang="ts">
57
57
  import { ref } from "vue";
58
- import { Live2D } from "vue-plugin-live2d";
58
+ import { Live2d } from "vue-plugin-live2d";
59
59
 
60
- const actor = ref<InstanceType<typeof Live2D> | null>(null);
60
+ const actor = ref<InstanceType<typeof Live2d> | null>(null);
61
61
 
62
62
  function lookLeft() {
63
63
  actor.value?.setParameter("PARAM_ANGLE_X", -15);
@@ -65,7 +65,7 @@ function lookLeft() {
65
65
  </script>
66
66
 
67
67
  <template>
68
- <Live2D ref="actor" model="/models/character.model3.json" />
68
+ <Live2d ref="actor" model="/models/character.model3.json" />
69
69
  <button type="button" @click="lookLeft">Look left</button>
70
70
  </template>
71
71
  ```
@@ -104,4 +104,4 @@ than being reimplemented for Vue.
104
104
 
105
105
  ## ๐Ÿ“„ License
106
106
 
107
- See the repository license.
107
+ Source code is dedicated to the public domain under **CC0 1.0 Universal** โ€” see [`License.md`](../../../License.md).
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "vue-plugin-live2d",
3
- "version": "0.0.25",
4
- "description": "Vue 3 <Live2D> component โ€” mount @doki-land/live2d with props, progress UI, and lifecycle.",
3
+ "version": "0.0.27",
4
+ "description": "Vue 3 <Live2d> component โ€” thin wrapper over <live-2d> Custom Element.",
5
5
  "type": "module",
6
- "license": "MIT",
6
+ "license": "CC0-1.0",
7
7
  "homepage": "https://github.com/doki-land/live2d.ts",
8
8
  "repository": {
9
9
  "type": "git",
@@ -37,7 +37,11 @@
37
37
  "vue": "^3.4.0"
38
38
  },
39
39
  "dependencies": {
40
- "@doki-land/live2d": "0.0.25"
40
+ "@doki-land/live2d": "0.0.27",
41
+ "@doki-land/live2d-element": "0.0.27"
41
42
  },
42
- "sideEffects": false
43
+ "sideEffects": [
44
+ "./src/index.ts",
45
+ "./src/Live2d.vue"
46
+ ]
43
47
  }
package/src/Live2d.vue ADDED
@@ -0,0 +1,339 @@
1
+
2
+ <template>
3
+ <div
4
+ class="doki-live2d-root"
5
+ :style="{ width: `${width}px`, height: `${height}px` }"
6
+ >
7
+ <live-2d
8
+ ref="actorRef"
9
+ :model="modelAttr"
10
+ :width="width"
11
+ :height="height"
12
+ :autoplay="autoplay"
13
+ :autosway="autoSway"
14
+ :interactive="interactive"
15
+ :tracking="tracking"
16
+ />
17
+ <div
18
+ v-if="showProgress && loading"
19
+ class="doki-live2d-progress"
20
+ role="progressbar"
21
+ :aria-valuenow="progressPercent"
22
+ aria-valuemin="0"
23
+ aria-valuemax="100"
24
+ >
25
+ <div class="doki-live2d-progress__track">
26
+ <div
27
+ class="doki-live2d-progress__fill"
28
+ :style="{ width: `${progressPercent}%` }"
29
+ />
30
+ </div>
31
+ <div class="doki-live2d-progress__label">
32
+ {{ progressLabel }} ยท {{ progressPercent }}%
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import "@doki-land/live2d-element";
40
+ import type {
41
+ FrameProfile,
42
+ LoadProgress,
43
+ ModelSource,
44
+ PlayMotionOptions,
45
+ RendererKind,
46
+ } from "@doki-land/live2d";
47
+ import type { Live2dElement } from "@doki-land/live2d-element";
48
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
49
+
50
+ const props = withDefaults(
51
+ defineProps<{
52
+ /** model3.json URL or ModelSource. */
53
+ model?: ModelSource | null;
54
+ width?: number;
55
+ height?: number;
56
+ /** Renderer try order. Default prefers Canvas2D for reliable preview. */
57
+ prefer?: RendererKind[];
58
+ autoplay?: boolean;
59
+ /** Animate PARAM_ANGLE_X automatically while playing. */
60
+ autoSway?: boolean;
61
+ /** Show built-in loading overlay with progress bar. */
62
+ showProgress?: boolean;
63
+ interactive?: boolean;
64
+ tracking?: "pointer" | "none";
65
+ }>(),
66
+ {
67
+ model: null,
68
+ width: 320,
69
+ height: 320,
70
+ prefer: () => ["canvas2d", "webgl2", "webgpu"],
71
+ autoplay: true,
72
+ autoSway: true,
73
+ showProgress: true,
74
+ interactive: true,
75
+ tracking: "pointer",
76
+ },
77
+ );
78
+
79
+ const emit = defineEmits<{
80
+ ready: [modelId: string];
81
+ error: [error: unknown];
82
+ progress: [progress: LoadProgress];
83
+ profile: [profile: FrameProfile];
84
+ hit: [payload: { area: string; x: number; y: number }];
85
+ }>();
86
+
87
+ const actorRef = ref<Live2dElement | null>(null);
88
+ const loadProgress = ref<LoadProgress | null>(null);
89
+ const loading = ref(false);
90
+
91
+ const modelAttr = computed(() =>
92
+ typeof props.model === "string" ? props.model : "",
93
+ );
94
+
95
+ const progressPercent = computed(() => {
96
+ const p = loadProgress.value?.progress ?? 0;
97
+ return Math.round(Math.min(1, Math.max(0, p)) * 100);
98
+ });
99
+
100
+ const progressLabel = computed(() => {
101
+ const p = loadProgress.value;
102
+ if (!p) return "Loadingโ€ฆ";
103
+ const stage = p.stage;
104
+ if (p.bytesLoaded != null && p.bytesTotal != null && p.bytesTotal > 0) {
105
+ const kb = (n: number) => `${(n / 1024).toFixed(0)} KB`;
106
+ return `${stage} ยท ${kb(p.bytesLoaded)} / ${kb(p.bytesTotal)}`;
107
+ }
108
+ return p.detail ? `${stage} ยท ${p.detail}` : stage;
109
+ });
110
+
111
+ function actor(): Live2dElement | null {
112
+ return actorRef.value;
113
+ }
114
+
115
+ function syncSource(): void {
116
+ const el = actor();
117
+ if (!el) return;
118
+ if (props.model == null) {
119
+ el.removeAttribute("model");
120
+ el.source = null;
121
+ loading.value = false;
122
+ loadProgress.value = null;
123
+ return;
124
+ }
125
+ if (typeof props.model === "string") {
126
+ el.source = null;
127
+ el.model = props.model;
128
+ } else {
129
+ el.removeAttribute("model");
130
+ el.source = props.model;
131
+ }
132
+ loading.value = true;
133
+ loadProgress.value = { stage: "mounting", progress: 0.01 };
134
+ }
135
+
136
+ function syncRenderOptions(): void {
137
+ const el = actor();
138
+ if (!el) return;
139
+ el.renderOptions = { prefer: [...props.prefer] };
140
+ }
141
+
142
+ function bindActorEvents(el: Live2dElement): void {
143
+ el.addEventListener("live2d-ready", onReady);
144
+ el.addEventListener("live2d-error", onError);
145
+ el.addEventListener("live2d-progress", onProgress);
146
+ el.addEventListener("live2d-profile", onProfile);
147
+ el.addEventListener("live2d-hit", onHit);
148
+ }
149
+
150
+ function unbindActorEvents(el: Live2dElement): void {
151
+ el.removeEventListener("live2d-ready", onReady);
152
+ el.removeEventListener("live2d-error", onError);
153
+ el.removeEventListener("live2d-progress", onProgress);
154
+ el.removeEventListener("live2d-profile", onProfile);
155
+ el.removeEventListener("live2d-hit", onHit);
156
+ }
157
+
158
+ function onReady(event: Event): void {
159
+ loading.value = false;
160
+ const detail = (event as CustomEvent<{ model?: string }>).detail;
161
+ loadProgress.value = {
162
+ stage: "ready",
163
+ progress: 1,
164
+ detail: detail?.model,
165
+ };
166
+ emit("ready", detail?.model ?? "");
167
+ }
168
+
169
+ function onError(event: Event): void {
170
+ loading.value = false;
171
+ const detail = (event as CustomEvent<{ cause?: unknown; error?: string }>)
172
+ .detail;
173
+ emit("error", detail?.cause ?? detail?.error ?? "live2d-error");
174
+ }
175
+
176
+ function onProgress(event: Event): void {
177
+ const payload = (event as CustomEvent<LoadProgress>).detail;
178
+ loadProgress.value = payload;
179
+ emit("progress", payload);
180
+ }
181
+
182
+ function onProfile(event: Event): void {
183
+ emit("profile", (event as CustomEvent<FrameProfile>).detail);
184
+ }
185
+
186
+ function onHit(event: Event): void {
187
+ const detail = (
188
+ event as CustomEvent<{
189
+ area: string | null;
190
+ modelX: number;
191
+ modelY: number;
192
+ }>
193
+ ).detail;
194
+ if (detail?.area) {
195
+ emit("hit", {
196
+ area: detail.area,
197
+ x: detail.modelX,
198
+ y: detail.modelY,
199
+ });
200
+ }
201
+ }
202
+
203
+ function setParameter(id: string, value: number): void {
204
+ actor()?.runtime?.setParameter(id, value);
205
+ if (!props.autoplay) {
206
+ actor()?.runtime?.update(0);
207
+ }
208
+ }
209
+
210
+ function clearManualAngleX(): void {
211
+ /* autosway lives on <live-2d>; callers may set PARAM_ANGLE_X directly. */
212
+ }
213
+
214
+ async function reload(): Promise<void> {
215
+ const el = actor();
216
+ if (!el || props.model == null) {
217
+ syncSource();
218
+ return;
219
+ }
220
+ loading.value = true;
221
+ loadProgress.value = {
222
+ stage: "resolve",
223
+ progress: 0.02,
224
+ detail: "resolve source",
225
+ };
226
+ syncSource();
227
+ await el.loadModel();
228
+ }
229
+
230
+ onMounted(() => {
231
+ const el = actor();
232
+ if (!el) return;
233
+ bindActorEvents(el);
234
+ syncRenderOptions();
235
+ syncSource();
236
+ });
237
+
238
+ watch(
239
+ () =>
240
+ [
241
+ props.model,
242
+ props.width,
243
+ props.height,
244
+ props.prefer?.join(","),
245
+ props.autoplay,
246
+ props.autoSway,
247
+ props.interactive,
248
+ props.tracking,
249
+ ] as const,
250
+ () => {
251
+ syncRenderOptions();
252
+ syncSource();
253
+ },
254
+ );
255
+
256
+ onBeforeUnmount(() => {
257
+ const el = actor();
258
+ if (el) unbindActorEvents(el);
259
+ });
260
+
261
+ defineExpose({
262
+ getRuntime: () => actor()?.runtime ?? null,
263
+ setParameter,
264
+ clearManualAngleX,
265
+ listParameters: () => actor()?.runtime?.listParameters() ?? [],
266
+ playMotion: (group: string, index?: number, options?: PlayMotionOptions) =>
267
+ actor()?.playMotion(group, index, options) ?? Promise.resolve(false),
268
+ stopMotion: (opts?: { fade?: boolean; slot?: string }) =>
269
+ actor()?.runtime?.stopMotion(opts),
270
+ listPlayingMotions: () => actor()?.runtime?.listPlayingMotions() ?? [],
271
+ capturePng: (opts?: { mimeType?: "image/png"; quality?: number }) => {
272
+ const runtime = actor()?.runtime;
273
+ if (!runtime) {
274
+ return Promise.reject(
275
+ new Error("vue-plugin-live2d: runtime not mounted"),
276
+ );
277
+ }
278
+ return runtime.capturePng(opts);
279
+ },
280
+ reload,
281
+ });
282
+ </script>
283
+
284
+ <style scoped>
285
+ .doki-live2d-root {
286
+ position: relative;
287
+ display: inline-block;
288
+ line-height: 0;
289
+ }
290
+
291
+ live-2d {
292
+ display: block;
293
+ width: 100%;
294
+ height: 100%;
295
+ }
296
+
297
+ live-2d::part(canvas) {
298
+ display: block;
299
+ width: 100%;
300
+ height: 100%;
301
+ background: transparent;
302
+ pointer-events: auto;
303
+ touch-action: none;
304
+ }
305
+
306
+ .doki-live2d-progress {
307
+ position: absolute;
308
+ inset: 0;
309
+ display: grid;
310
+ align-content: center;
311
+ justify-items: stretch;
312
+ gap: 0.55rem;
313
+ padding: 1.25rem;
314
+ box-sizing: border-box;
315
+ background: color-mix(in srgb, #0f1b2d 55%, transparent);
316
+ pointer-events: none;
317
+ }
318
+
319
+ .doki-live2d-progress__track {
320
+ height: 0.45rem;
321
+ border-radius: 999px;
322
+ background: color-mix(in srgb, #fff 22%, transparent);
323
+ overflow: hidden;
324
+ }
325
+
326
+ .doki-live2d-progress__fill {
327
+ height: 100%;
328
+ border-radius: inherit;
329
+ background: #7eb6ff;
330
+ transition: width 80ms linear;
331
+ }
332
+
333
+ .doki-live2d-progress__label {
334
+ color: #f4f8ff;
335
+ font: 0.78rem/1.3 ui-sans-serif, system-ui, sans-serif;
336
+ text-align: center;
337
+ word-break: break-all;
338
+ }
339
+ </style>
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
- import Live2D from "./Live2D.vue";
1
+ import "@doki-land/live2d-element";
2
+ import Live2d from "./Live2d.vue";
2
3
 
3
- export { Live2D };
4
- export default Live2D;
4
+ export { Live2d };
5
+ export default Live2d;
package/src/Live2D.vue DELETED
@@ -1,415 +0,0 @@
1
-
2
- <template>
3
- <div
4
- class="doki-live2d-root"
5
- :style="{ width: `${width}px`, height: `${height}px` }"
6
- >
7
- <div ref="hostRef" class="doki-live2d-host"/>
8
- <div
9
- v-if="showProgress && loading"
10
- class="doki-live2d-progress"
11
- role="progressbar"
12
- :aria-valuenow="progressPercent"
13
- aria-valuemin="0"
14
- aria-valuemax="100"
15
- >
16
- <div class="doki-live2d-progress__track">
17
- <div
18
- class="doki-live2d-progress__fill"
19
- :style="{ width: `${progressPercent}%` }"
20
- />
21
- </div>
22
- <div class="doki-live2d-progress__label">
23
- {{ progressLabel }} ยท {{ progressPercent }}%
24
- </div>
25
- </div>
26
- </div>
27
- </template>
28
-
29
- <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
- } from "@doki-land/live2d";
41
- import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
42
-
43
- const props = withDefaults(
44
- defineProps<{
45
- /** model3.json URL or ModelSource. */
46
- model?: ModelSource | null;
47
- width?: number;
48
- height?: number;
49
- /** Renderer try order. Default prefers Canvas2D for reliable preview. */
50
- prefer?: RendererKind[];
51
- autoplay?: boolean;
52
- /** Animate PARAM_ANGLE_X automatically while playing. */
53
- autoSway?: boolean;
54
- /** Show built-in loading overlay with progress bar. */
55
- showProgress?: boolean;
56
- }>(),
57
- {
58
- model: null,
59
- width: 320,
60
- height: 320,
61
- prefer: () => ["canvas2d", "webgl2", "webgpu"],
62
- autoplay: true,
63
- autoSway: true,
64
- showProgress: true,
65
- },
66
- );
67
-
68
- const emit = defineEmits<{
69
- ready: [modelId: string];
70
- error: [error: unknown];
71
- progress: [progress: LoadProgress];
72
- profile: [profile: FrameProfile];
73
- hit: [payload: { area: string; x: number; y: number }];
74
- }>();
75
-
76
- const hostRef = ref<HTMLDivElement | null>(null);
77
- const loadProgress = ref<LoadProgress | null>(null);
78
- const loading = ref(false);
79
-
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;
86
-
87
- const progressPercent = computed(() => {
88
- const p = loadProgress.value?.progress ?? 0;
89
- return Math.round(Math.min(1, Math.max(0, p)) * 100);
90
- });
91
-
92
- const progressLabel = computed(() => {
93
- const p = loadProgress.value;
94
- if (!p) return "Loadingโ€ฆ";
95
- const stage = p.stage;
96
- if (p.bytesLoaded != null && p.bytesTotal != null && p.bytesTotal > 0) {
97
- const kb = (n: number) => `${(n / 1024).toFixed(0)} KB`;
98
- return `${stage} ยท ${kb(p.bytesLoaded)} / ${kb(p.bytesTotal)}`;
99
- }
100
- return p.detail ? `${stage} ยท ${p.detail}` : stage;
101
- });
102
-
103
- function stopLoop() {
104
- if (raf) cancelAnimationFrame(raf);
105
- raf = 0;
106
- lastTs = 0;
107
- }
108
-
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
- );
121
- }
122
- runtime.update(dt);
123
- raf = requestAnimationFrame(tick);
124
- }
125
-
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;
147
- }
148
-
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
- });
170
- }
171
-
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
- }
214
- }
215
-
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;
228
- loadProgress.value = {
229
- stage: "resolve",
230
- progress: 0.02,
231
- detail: "resolve source",
232
- };
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
- }
244
- }
245
-
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
- }
254
- }
255
-
256
- function clearManualAngleX() {
257
- manualAngleX = null;
258
- }
259
-
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
- };
269
- }
270
-
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;
279
- }
280
-
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);
293
- }
294
- }
295
-
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);
301
- }
302
-
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 });
308
- }
309
-
310
- onMounted(() => {
311
- void remount();
312
- });
313
-
314
- watch(
315
- () =>
316
- [
317
- props.model,
318
- props.width,
319
- props.height,
320
- props.prefer?.join(","),
321
- props.autoplay,
322
- ] as const,
323
- () => {
324
- void remount();
325
- },
326
- );
327
-
328
- onBeforeUnmount(() => {
329
- mountGeneration += 1;
330
- stopLoop();
331
- runtime?.destroy();
332
- runtime = null;
333
- _canvas = null;
334
- hostRef.value?.replaceChildren();
335
- });
336
-
337
- defineExpose({
338
- getRuntime: () => runtime,
339
- setParameter,
340
- clearManualAngleX,
341
- listParameters: () => runtime?.listParameters() ?? [],
342
- playMotion: (group: string, index?: number, options?: PlayMotionOptions) =>
343
- runtime?.playMotion(group, index, options) ?? Promise.resolve(false),
344
- stopMotion: (opts?: { fade?: boolean; slot?: string }) =>
345
- runtime?.stopMotion(opts),
346
- listPlayingMotions: () => runtime?.listPlayingMotions() ?? [],
347
- capturePng: (opts?: { mimeType?: "image/png"; quality?: number }) => {
348
- if (!runtime) {
349
- return Promise.reject(
350
- new Error("vue-plugin-live2d: runtime not mounted"),
351
- );
352
- }
353
- return runtime.capturePng(opts);
354
- },
355
- reload,
356
- });
357
- </script>
358
-
359
- <style scoped>
360
- .doki-live2d-root {
361
- position: relative;
362
- display: inline-block;
363
- line-height: 0;
364
- }
365
-
366
- .doki-live2d-host {
367
- display: block;
368
- width: 100%;
369
- height: 100%;
370
- line-height: 0;
371
- }
372
-
373
- .doki-live2d-host :deep(canvas) {
374
- display: block;
375
- width: 100%;
376
- height: 100%;
377
- background: transparent;
378
- pointer-events: auto;
379
- touch-action: none;
380
- }
381
-
382
- .doki-live2d-progress {
383
- position: absolute;
384
- inset: 0;
385
- display: grid;
386
- align-content: center;
387
- justify-items: stretch;
388
- gap: 0.55rem;
389
- padding: 1.25rem;
390
- box-sizing: border-box;
391
- background: color-mix(in srgb, #0f1b2d 55%, transparent);
392
- pointer-events: none;
393
- }
394
-
395
- .doki-live2d-progress__track {
396
- height: 0.45rem;
397
- border-radius: 999px;
398
- background: color-mix(in srgb, #fff 22%, transparent);
399
- overflow: hidden;
400
- }
401
-
402
- .doki-live2d-progress__fill {
403
- height: 100%;
404
- border-radius: inherit;
405
- background: #7eb6ff;
406
- transition: width 80ms linear;
407
- }
408
-
409
- .doki-live2d-progress__label {
410
- color: #f4f8ff;
411
- font: 0.78rem/1.3 ui-sans-serif, system-ui, sans-serif;
412
- text-align: center;
413
- word-break: break-all;
414
- }
415
- </style>