@vmz/plugin-live2d 0.0.0 → 0.0.17
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 +24 -1
- package/components/Live2dHost.vmz +159 -0
- package/components/Live2dStage.vmz +96 -0
- package/package.json +36 -4
- package/vmz.plugin.ts +71 -0
package/README.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
1
|
# @vmz/plugin-live2d
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
VMZ plugin for Live2D — `<Live2dHost />` and `<Live2dStage />` over `@doki-land/live2d`.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @vmz/plugin-live2d @vmz/ui @vmz/ui-icons
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
**App** (`vmz.config.ts`):
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { defineConfig } from '@vmz/vmz';
|
|
13
|
+
import live2d from '@vmz/plugin-live2d';
|
|
14
|
+
|
|
15
|
+
export default defineConfig({ plugins: [live2d] });
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Plugin** (`vmz.plugin.ts` in this package) uses `definePlugin` from `@vmz/plugin`.
|
|
19
|
+
|
|
20
|
+
```vmz
|
|
21
|
+
<Live2dHost model="/models/character.model3.json" width={480} height={480} />
|
|
22
|
+
|
|
23
|
+
<Live2dStage sources={actorSources} updateMode="auto" />
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Runtime stays in `@doki-land/live2d*`; site chrome uses `@vmz/ui` / `@vmz/ui-icons`.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="live2d-host" style={hostStyle}>
|
|
3
|
+
<div class="live2d-host__mount" id={hostId}></div>
|
|
4
|
+
<div if={showProgress && loading} class="live2d-host__progress" role="progressbar" aria-valuenow={progressPercent} aria-valuemin="0" aria-valuemax="100">
|
|
5
|
+
<span>{progressLabel} · {progressPercent}%</span>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
</template>
|
|
9
|
+
|
|
10
|
+
<style>
|
|
11
|
+
.live2d-host { position: relative; display: inline-block; }
|
|
12
|
+
.live2d-host__mount { width: 100%; height: 100%; }
|
|
13
|
+
.live2d-host__mount canvas { display: block; width: 100%; height: 100%; }
|
|
14
|
+
.live2d-host__progress {
|
|
15
|
+
position: absolute; inset: auto 0 0; padding: 0.35rem 0.5rem;
|
|
16
|
+
background: rgba(255, 255, 255, 0.92);
|
|
17
|
+
font: 600 0.75rem/1.2 ui-monospace, monospace; color: #377ba8;
|
|
18
|
+
}
|
|
19
|
+
</style>
|
|
20
|
+
|
|
21
|
+
<script client>
|
|
22
|
+
import { createLive2D } from '@doki-land/live2d';
|
|
23
|
+
|
|
24
|
+
let hostSeq = 0;
|
|
25
|
+
|
|
26
|
+
export default class Live2dHost {
|
|
27
|
+
public model = null;
|
|
28
|
+
public width = 320;
|
|
29
|
+
public height = 320;
|
|
30
|
+
public prefer = ['canvas2d', 'webgl2', 'webgpu'];
|
|
31
|
+
public autoplay = true;
|
|
32
|
+
public autoSway = false;
|
|
33
|
+
public showProgress = true;
|
|
34
|
+
public exposeCaptureApi = false;
|
|
35
|
+
public capturePresetId = '';
|
|
36
|
+
|
|
37
|
+
hostId = `live2d-host-${++hostSeq}`;
|
|
38
|
+
hostStyle = '';
|
|
39
|
+
loading = false;
|
|
40
|
+
progressPercent = 0;
|
|
41
|
+
progressLabel = 'Loading…';
|
|
42
|
+
runtime = null;
|
|
43
|
+
_raf = 0;
|
|
44
|
+
_lastTs = 0;
|
|
45
|
+
|
|
46
|
+
onMount() {
|
|
47
|
+
this.hostStyle = `width:${this.width}px;height:${this.height}px`;
|
|
48
|
+
if (this.exposeCaptureApi && typeof window !== 'undefined') {
|
|
49
|
+
window.__LIVE2D_CAPTURE__ = {
|
|
50
|
+
ready: false,
|
|
51
|
+
error: null,
|
|
52
|
+
presetId: this.capturePresetId || '',
|
|
53
|
+
pngBase64: null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
void this.boot();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onDestroy() {
|
|
60
|
+
this.stopLoop();
|
|
61
|
+
void this.runtime?.destroy?.();
|
|
62
|
+
this.runtime = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
stopLoop() {
|
|
66
|
+
if (this._raf) cancelAnimationFrame(this._raf);
|
|
67
|
+
this._raf = 0;
|
|
68
|
+
this._lastTs = 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
tick(ts) {
|
|
72
|
+
if (!this.runtime) return;
|
|
73
|
+
const dt = this._lastTs ? (ts - this._lastTs) / 1000 : 0;
|
|
74
|
+
this._lastTs = ts;
|
|
75
|
+
this.runtime.update(dt);
|
|
76
|
+
this._raf = requestAnimationFrame((t) => this.tick(t));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
ensureCanvas() {
|
|
80
|
+
const host = document.getElementById(this.hostId);
|
|
81
|
+
if (!host) return null;
|
|
82
|
+
host.replaceChildren();
|
|
83
|
+
const canvas = document.createElement('canvas');
|
|
84
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
85
|
+
canvas.width = Math.max(1, Math.round(this.width * dpr));
|
|
86
|
+
canvas.height = Math.max(1, Math.round(this.height * dpr));
|
|
87
|
+
canvas.style.width = `${this.width}px`;
|
|
88
|
+
canvas.style.height = `${this.height}px`;
|
|
89
|
+
host.appendChild(canvas);
|
|
90
|
+
return canvas;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async capturePng(opts = {}) {
|
|
94
|
+
if (!this.runtime) throw new Error('Live2dHost: runtime not ready');
|
|
95
|
+
return this.runtime.capturePng(opts);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async boot() {
|
|
99
|
+
if (!this.model) return;
|
|
100
|
+
this.loading = true;
|
|
101
|
+
try {
|
|
102
|
+
this.stopLoop();
|
|
103
|
+
void this.runtime?.destroy?.();
|
|
104
|
+
const canvas = this.ensureCanvas();
|
|
105
|
+
if (!canvas) return;
|
|
106
|
+
const runtime = createLive2D({ prefer: this.prefer, updateMode: 'manual' });
|
|
107
|
+
runtime.events.on('progress', (p) => this.onProgress(p));
|
|
108
|
+
runtime.events.on('ready', () => {
|
|
109
|
+
this.loading = false;
|
|
110
|
+
if (this.autoplay) {
|
|
111
|
+
this._lastTs = 0;
|
|
112
|
+
this._raf = requestAnimationFrame((t) => this.tick(t));
|
|
113
|
+
}
|
|
114
|
+
if (this.exposeCaptureApi) void this.publishCapture();
|
|
115
|
+
});
|
|
116
|
+
runtime.events.on('error', (p) => {
|
|
117
|
+
this.loading = false;
|
|
118
|
+
if (this.exposeCaptureApi && typeof window !== 'undefined') {
|
|
119
|
+
const api = window.__LIVE2D_CAPTURE__;
|
|
120
|
+
if (api) api.error = String(p?.error || 'load error');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
await runtime.mount(canvas);
|
|
124
|
+
await runtime.load(this.model);
|
|
125
|
+
this.runtime = runtime;
|
|
126
|
+
} catch {
|
|
127
|
+
this.loading = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
onProgress(p) {
|
|
132
|
+
const progress = p?.progress ?? 0;
|
|
133
|
+
this.progressLabel = p?.stage || 'loading';
|
|
134
|
+
this.progressPercent = Math.round(Math.min(1, Math.max(0, progress)) * 100);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async publishCapture() {
|
|
138
|
+
const api = typeof window !== 'undefined' ? window.__LIVE2D_CAPTURE__ : null;
|
|
139
|
+
if (!api) return;
|
|
140
|
+
try {
|
|
141
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
142
|
+
const blob = await this.capturePng();
|
|
143
|
+
const buf = await blob.arrayBuffer();
|
|
144
|
+
const bytes = new Uint8Array(buf);
|
|
145
|
+
let binary = '';
|
|
146
|
+
const chunk = 0x8000;
|
|
147
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
148
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
149
|
+
}
|
|
150
|
+
api.pngBase64 = btoa(binary);
|
|
151
|
+
api.ready = true;
|
|
152
|
+
api.error = null;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
api.error = String(err);
|
|
155
|
+
api.ready = false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
</script>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="live2d-stage" id={hostId}></div>
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<style>
|
|
6
|
+
.live2d-stage {
|
|
7
|
+
position: relative;
|
|
8
|
+
width: 100%;
|
|
9
|
+
min-height: 320px;
|
|
10
|
+
height: 100%;
|
|
11
|
+
background: color-mix(in srgb, var(--vmz-surface-page, #f5f9fc) 88%, #fff);
|
|
12
|
+
border: 1px solid var(--vmz-line-default, rgba(0, 0, 0, 0.08));
|
|
13
|
+
border-radius: 0.5rem;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.live2d-stage canvas {
|
|
18
|
+
display: block;
|
|
19
|
+
width: 100%;
|
|
20
|
+
height: 100%;
|
|
21
|
+
}
|
|
22
|
+
</style>
|
|
23
|
+
|
|
24
|
+
<script client>
|
|
25
|
+
import { createLive2dStage } from '@doki-land/live2d';
|
|
26
|
+
|
|
27
|
+
let stageSeq = 0;
|
|
28
|
+
|
|
29
|
+
export default class Live2dStage {
|
|
30
|
+
/** Append-only model sources; new entries spawn actors on the stage. */
|
|
31
|
+
public sources = [];
|
|
32
|
+
public updateMode = 'auto';
|
|
33
|
+
public prefer = ['canvas2d', 'webgl2', 'webgpu'];
|
|
34
|
+
|
|
35
|
+
hostId = `live2d-stage-${++stageSeq}`;
|
|
36
|
+
stage = null;
|
|
37
|
+
_canvas = null;
|
|
38
|
+
_applied = 0;
|
|
39
|
+
_syncTimer = 0;
|
|
40
|
+
|
|
41
|
+
onMount() {
|
|
42
|
+
void this.boot();
|
|
43
|
+
this._syncTimer = window.setInterval(() => this.syncSources(), 150);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
onDestroy() {
|
|
47
|
+
if (this._syncTimer) clearInterval(this._syncTimer);
|
|
48
|
+
this._syncTimer = 0;
|
|
49
|
+
void this.stage?.destroy?.();
|
|
50
|
+
this.stage = null;
|
|
51
|
+
this._canvas = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
ensureCanvas(host) {
|
|
55
|
+
host.replaceChildren();
|
|
56
|
+
const canvas = document.createElement('canvas');
|
|
57
|
+
const rect = host.getBoundingClientRect();
|
|
58
|
+
const w = Math.max(320, Math.round(rect.width || 640));
|
|
59
|
+
const h = Math.max(320, Math.round(rect.height || 480));
|
|
60
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
61
|
+
canvas.width = Math.max(1, Math.round(w * dpr));
|
|
62
|
+
canvas.height = Math.max(1, Math.round(h * dpr));
|
|
63
|
+
canvas.style.width = '100%';
|
|
64
|
+
canvas.style.height = '100%';
|
|
65
|
+
host.appendChild(canvas);
|
|
66
|
+
this._canvas = canvas;
|
|
67
|
+
return canvas;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async boot() {
|
|
71
|
+
const host = document.getElementById(this.hostId);
|
|
72
|
+
if (!host) return;
|
|
73
|
+
const canvas = this.ensureCanvas(host);
|
|
74
|
+
const stage = createLive2dStage({ updateMode: this.updateMode, prefer: this.prefer });
|
|
75
|
+
await stage.mount(canvas);
|
|
76
|
+
this.stage = stage;
|
|
77
|
+
await this.syncSources();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async syncSources() {
|
|
81
|
+
if (!this.stage) return;
|
|
82
|
+
const list = Array.isArray(this.sources) ? this.sources : [];
|
|
83
|
+
while (this._applied < list.length) {
|
|
84
|
+
const src = list[this._applied];
|
|
85
|
+
this._applied += 1;
|
|
86
|
+
if (!src) continue;
|
|
87
|
+
try {
|
|
88
|
+
const actor = this.stage.createActor();
|
|
89
|
+
await actor.load(src);
|
|
90
|
+
} catch {
|
|
91
|
+
/* skip failed actor; keep stage usable */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
</script>
|
package/package.json
CHANGED
|
@@ -1,10 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vmz/plugin-live2d",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.17",
|
|
4
|
+
"description": "VMZ Live2D adapter — <Live2dHost> for @doki-land/live2d in VMZ applications",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
+
"homepage": "https://github.com/doki-land/live2d.ts",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/doki-land/live2d.ts.git",
|
|
11
|
+
"directory": "projects/adaptors/vmz-plugin-live2d"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"vmz",
|
|
15
|
+
"plugin",
|
|
16
|
+
"live2d",
|
|
17
|
+
"doki-land"
|
|
18
|
+
],
|
|
19
|
+
"main": "./vmz.plugin.ts",
|
|
20
|
+
"types": "./vmz.plugin.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./vmz.plugin.ts",
|
|
24
|
+
"default": "./vmz.plugin.ts"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
7
27
|
"files": [
|
|
28
|
+
"components",
|
|
29
|
+
"vmz.plugin.ts",
|
|
8
30
|
"README.md"
|
|
9
|
-
]
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "vitest run --passWithNoTests"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@doki-land/live2d": "0.0.17",
|
|
40
|
+
"@vmz/plugin": "^0.1.8"
|
|
41
|
+
}
|
|
10
42
|
}
|
package/vmz.plugin.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { definePlugin, loadPluginSource } from "@vmz/plugin";
|
|
2
|
+
|
|
3
|
+
const hostSource = loadPluginSource(
|
|
4
|
+
import.meta.url,
|
|
5
|
+
"components/Live2dHost.vmz",
|
|
6
|
+
);
|
|
7
|
+
const stageSource = loadPluginSource(
|
|
8
|
+
import.meta.url,
|
|
9
|
+
"components/Live2dStage.vmz",
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export default definePlugin({
|
|
13
|
+
name: "@vmz/plugin-live2d",
|
|
14
|
+
version: "0.1.0",
|
|
15
|
+
protocol: "0.1.0",
|
|
16
|
+
stages: ["workspace_resolve", "analyzer"],
|
|
17
|
+
deterministic: true,
|
|
18
|
+
async contribute(ctx) {
|
|
19
|
+
if (ctx.stage === "workspace_resolve") {
|
|
20
|
+
return {
|
|
21
|
+
stage: "workspace_resolve",
|
|
22
|
+
cacheKey: `@vmz/plugin-live2d:${hostSource.contentHash.slice(0, 8)}:${stageSource.contentHash.slice(0, 8)}`,
|
|
23
|
+
items: [
|
|
24
|
+
{
|
|
25
|
+
id: "component-live2d-host",
|
|
26
|
+
kind: "source",
|
|
27
|
+
path: "src/components/Live2dHost.vmz",
|
|
28
|
+
content: hostSource.content,
|
|
29
|
+
contentHash: hostSource.contentHash,
|
|
30
|
+
materialize: true,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "component-live2d-stage",
|
|
34
|
+
kind: "source",
|
|
35
|
+
path: "src/components/Live2dStage.vmz",
|
|
36
|
+
content: stageSource.content,
|
|
37
|
+
contentHash: stageSource.contentHash,
|
|
38
|
+
materialize: true,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (ctx.stage === "analyzer") {
|
|
44
|
+
return {
|
|
45
|
+
stage: "analyzer",
|
|
46
|
+
cacheKey: "@vmz/plugin-live2d:analyzer",
|
|
47
|
+
items: [
|
|
48
|
+
{
|
|
49
|
+
id: "component-live2d-host-online",
|
|
50
|
+
kind: "analyzer",
|
|
51
|
+
path: "src/components/Live2dHost.vmz",
|
|
52
|
+
severity: "advice",
|
|
53
|
+
message:
|
|
54
|
+
"Live2dHost component online (@doki-land/live2d)",
|
|
55
|
+
code: "vmz.plugin.live2d.host",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "component-live2d-stage-online",
|
|
59
|
+
kind: "analyzer",
|
|
60
|
+
path: "src/components/Live2dStage.vmz",
|
|
61
|
+
severity: "advice",
|
|
62
|
+
message:
|
|
63
|
+
"Live2dStage component online (@doki-land/live2d)",
|
|
64
|
+
code: "vmz.plugin.live2d.stage",
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { stage: ctx.stage, items: [] };
|
|
70
|
+
},
|
|
71
|
+
});
|