vue-plugin-live2d 0.0.0 → 0.0.12
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 +1 -1
- package/package.json +37 -3
- package/src/Live2D.vue +415 -0
- package/src/index.ts +4 -0
- package/src/vite-env.d.ts +8 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vue-plugin-live2d",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.12",
|
|
4
|
+
"description": "Vue plugin for @doki-land/live2d — <Live2D> component",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
+
"author": "Doki Land",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"live2d",
|
|
10
|
+
"vue",
|
|
11
|
+
"plugin",
|
|
12
|
+
"doki-land"
|
|
13
|
+
],
|
|
14
|
+
"main": "./src/index.ts",
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"typecheck": "vue-tsc --noEmit",
|
|
28
|
+
"test": "vitest run --passWithNoTests"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"vue": "^3.4.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@doki-land/live2d": "0.0.12"
|
|
35
|
+
},
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/doki-land/live2d.ts.git"
|
|
40
|
+
}
|
|
7
41
|
}
|
package/src/Live2D.vue
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
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>
|
package/src/index.ts
ADDED