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 +10 -10
- package/package.json +9 -5
- package/src/Live2d.vue +339 -0
- package/src/index.ts +4 -3
- package/src/Live2D.vue +0 -415
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.
|
|
@@ -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 {
|
|
33
|
+
import { Live2d } from "vue-plugin-live2d";
|
|
34
34
|
</script>
|
|
35
35
|
|
|
36
36
|
<template>
|
|
37
|
-
<
|
|
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 {
|
|
58
|
+
import { Live2d } from "vue-plugin-live2d";
|
|
59
59
|
|
|
60
|
-
const actor = ref<InstanceType<typeof
|
|
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
|
-
<
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Vue 3 <
|
|
3
|
+
"version": "0.0.27",
|
|
4
|
+
"description": "Vue 3 <Live2d> component โ thin wrapper over <live-2d> Custom Element.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"license": "
|
|
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.
|
|
40
|
+
"@doki-land/live2d": "0.0.27",
|
|
41
|
+
"@doki-land/live2d-element": "0.0.27"
|
|
41
42
|
},
|
|
42
|
-
"sideEffects":
|
|
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
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>
|