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.
- package/README.md +3 -4
- package/package.json +3 -2
- 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
|
|
6
|
-
>
|
|
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.
|
|
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.
|
|
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
|
-
<
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
84
|
+
const actorRef = ref<Live2dElement | null>(null);
|
|
77
85
|
const loadProgress = ref<LoadProgress | null>(null);
|
|
78
86
|
const loading = ref(false);
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
raf = 0;
|
|
106
|
-
lastTs = 0;
|
|
116
|
+
function actor(): Live2dElement | null {
|
|
117
|
+
return actorRef.value;
|
|
107
118
|
}
|
|
108
119
|
|
|
109
|
-
function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
129
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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: "
|
|
230
|
-
progress:
|
|
231
|
-
detail:
|
|
167
|
+
stage: "ready",
|
|
168
|
+
progress: 1,
|
|
169
|
+
detail: detail?.model,
|
|
232
170
|
};
|
|
233
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
257
|
-
|
|
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
|
|
261
|
-
|
|
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
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
runtime
|
|
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
|
|
297
|
-
|
|
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
|
|
304
|
-
const
|
|
305
|
-
if (!
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
+
syncRenderOptions();
|
|
255
|
+
syncSource();
|
|
325
256
|
},
|
|
326
257
|
);
|
|
327
258
|
|
|
328
259
|
onBeforeUnmount(() => {
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
+
live-2d {
|
|
367
295
|
display: block;
|
|
368
296
|
width: 100%;
|
|
369
297
|
height: 100%;
|
|
370
|
-
line-height: 0;
|
|
371
298
|
}
|
|
372
299
|
|
|
373
|
-
|
|
300
|
+
live-2d::part(canvas) {
|
|
374
301
|
display: block;
|
|
375
302
|
width: 100%;
|
|
376
303
|
height: 100%;
|