feat(wave21): Live2D renderer ported from AIRI — Haru model bundled
Some checks failed
Release / Build aarch64-apple-darwin (push) Has been cancelled
Release / Build x86_64-apple-darwin (push) Has been cancelled
Release / Build aarch64-unknown-linux-gnu (push) Has been cancelled
Release / Build x86_64-unknown-linux-gnu (push) Has been cancelled
Release / Build mcp-server darwin-arm64 (push) Has been cancelled
Release / Build mcp-server linux-arm64 (push) Has been cancelled
Release / Build mcp-server darwin-x64 (push) Has been cancelled
Release / Build mcp-server linux-x64 (push) Has been cancelled
Release / Build mcp-server windows-x64 (push) Has been cancelled
Release / Publish GitHub Release (push) Has been cancelled
Release / Publish npm packages (optional) (push) Has been cancelled
Some checks failed
Release / Build aarch64-apple-darwin (push) Has been cancelled
Release / Build x86_64-apple-darwin (push) Has been cancelled
Release / Build aarch64-unknown-linux-gnu (push) Has been cancelled
Release / Build x86_64-unknown-linux-gnu (push) Has been cancelled
Release / Build mcp-server darwin-arm64 (push) Has been cancelled
Release / Build mcp-server linux-arm64 (push) Has been cancelled
Release / Build mcp-server darwin-x64 (push) Has been cancelled
Release / Build mcp-server linux-x64 (push) Has been cancelled
Release / Build mcp-server windows-x64 (push) Has been cancelled
Release / Publish GitHub Release (push) Has been cancelled
Release / Publish npm packages (optional) (push) Has been cancelled
cortex-ui gains a Live2D anime-character renderer alongside existing
32px pixel sprites. User chooses via Setup; switch stored in localStorage
(pet.toml `meta.renderer` field will be wired to daemon in Wave 22).
## Ports from AIRI (MIT, attribution in each file header)
- `src/lib/live2d/emotions.ts` (72 LOC) — 9-value Emotion enum + motion
name + VRM expression maps. Copy-verbatim + CortexMood→Emotion adapter.
- `src/lib/live2d/motion-manager.ts` (194 LOC) — motion state machine
(Vue composable → Svelte 5 factory). Named-group preference, fallback
on throw, replay, reset.
- `src/lib/live2d/expression-controller.ts` (180 LOC) — expression blend
math + transition reset.
- `src/lib/live2d/beat-sync.ts` (63 LOC) — audio-reactive STUB; full
port deferred to Wave 22.
- `src/lib/live2d/types.ts` (62 LOC) — shared interfaces.
## New component
- `src/components/Live2DPet.svelte` (169 LOC) — wraps pixi-live2d-display.
Props `{ modelPath, mood, width, height }`. Fallback message in jsdom
(no WebGL) — tests mount without throwing.
## PetEditor + Setup integration
- `PetEditor.svelte` reads `pet.toml` `meta.renderer` → localStorage
fallback → defaults `sprite32`. Toggle button swaps renderer mid-session.
- `Setup.svelte` adds Renderer radio (sprite32 / live2d), persists to
localStorage.
## Model bundled
Haru (`haru_greeter_t03`), Live2D Cubism official sample, free sample
data license with attribution. 3.4 MB on disk (384 KB moc3 + 2.7 MB
textures + 8 expressions + 5 motions). Served at
`./live2d-models/haru/haru_greeter_t03.model3.json`.
## Deps
- Added `pixi.js ^7.4.0` + `pixi-live2d-display ^0.4.0` to
_ts_packages/packages/cortex-ui/package.json
- AIRI's `pixi-live2d-display.patch` copied to patches/ with README
(manual apply, not needed for default model path)
- **Known risk**: pixi 7 vs library peer-dep 6 mismatch — silent at
install time, untested at runtime. Fallback path: downgrade to pixi ^6
OR swap to `pixi-live2d-display-lipsyncpatch`. Will validate during
live browser test; fix in Wave 22 if breaks.
## Tests
30 pass / 0 fail (was 10):
- live2d-emotions.test.ts (6 new)
- live2d-motion-manager.test.ts (6 new)
- live2d-expression-controller.test.ts (5 new)
- live2d-pet-mount.test.ts (3 new)
- pre-existing api + config tests unchanged (10)
## Bundle size
- Main JS: 56 KB (22 KB gzip) — SPA shell unchanged
- pixi.js: 428 KB (129 KB gzip) — code-split, lazy-loaded on `live2d` pick
- pixi-live2d-display + Cubism4: 308 KB (82 KB gzip) — code-split too
- Haru model assets: 3.4 MB public/, served on-demand
- dist total: 7.5 MB with sourcemaps
## Constructor Pattern
All source files ≤194 LOC (motion-manager largest). All leaf fns ≤18
LOC. Factory-composer pattern avoids Vue's Composition API wrapping
(no mixins, no DI).
## Svelte 5 config touch
- `vite.config.ts` adds `resolve.conditions: ['browser']` +
`server.deps.inline` for vitest — Svelte 5 runes resolve to client
entry under jsdom correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a42847063f
commit
dc196dc325
36 changed files with 11996 additions and 12 deletions
1059
_ts_packages/package-lock.json
generated
1059
_ts_packages/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,10 @@
|
|||
"test": "vitest run",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"pixi-live2d-display": "^0.4.0",
|
||||
"pixi.js": "^7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"svelte": "^5.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
|
|
|
|||
38
_ts_packages/packages/cortex-ui/patches/README.md
Normal file
38
_ts_packages/packages/cortex-ui/patches/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# patches/
|
||||
|
||||
## pixi-live2d-display.patch
|
||||
|
||||
Copied verbatim from AIRI (https://github.com/moeru-ai/airi), MIT License.
|
||||
|
||||
### What it does
|
||||
|
||||
Both `FileLoader` and `ZipLoader` in `pixi-live2d-display@0.4.0` pick the first
|
||||
settings file ending in `model.json` / `model3.json`. Newer Live2D authoring
|
||||
tools emit `items_pinned_to_model.json` as a side-car, which accidentally
|
||||
matches the suffix and derails the loader. The patch filters it out.
|
||||
|
||||
### Is it applied?
|
||||
|
||||
Not automatically. This project uses vanilla `npm install`, so we cannot
|
||||
pre-apply patches the way `pnpm` + `patchedDependencies` or
|
||||
`patch-package` would. Two options if you need it:
|
||||
|
||||
1. **Manual**, once per clone, from `_ts_packages/`:
|
||||
```sh
|
||||
patch -p1 -d node_modules/pixi-live2d-display < packages/cortex-ui/patches/pixi-live2d-display.patch
|
||||
```
|
||||
2. **Automated**, wire up `patch-package`:
|
||||
```sh
|
||||
npm i -D patch-package
|
||||
# add "postinstall": "patch-package" to root package.json
|
||||
```
|
||||
(This would touch `_ts_packages/package.json` which is out of scope for
|
||||
the current wave; leaving as a follow-up.)
|
||||
|
||||
### Do we need it here?
|
||||
|
||||
Not for the bundled Haru model — `Live2DPet.svelte` loads the explicit
|
||||
`model3.json` URL straight from `/public/live2d-models/…`, bypassing the
|
||||
filename-scanning code paths the patch touches. The patch becomes relevant
|
||||
only when (a) user-uploaded ZIPs are supported or (b) a model folder
|
||||
contains an `items_pinned_to_model.json` sidecar.
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
diff --git a/core/README.md b/core/README.md
|
||||
deleted file mode 100644
|
||||
index ad383747237ee1a22ce39d01fbc7e77ac94b8e47..0000000000000000000000000000000000000000
|
||||
diff --git a/core/live2d.d.ts b/core/live2d.d.ts
|
||||
deleted file mode 100644
|
||||
index 0283512ed1c9ea01d7dd1b67b76d660237b453e8..0000000000000000000000000000000000000000
|
||||
diff --git a/cubism/.vscode/extensions.json b/cubism/.vscode/extensions.json
|
||||
deleted file mode 100644
|
||||
index fda5ad57b9567b939382ba15fb1d3b9f1fecf77e..0000000000000000000000000000000000000000
|
||||
diff --git a/cubism/.vscode/tasks.json b/cubism/.vscode/tasks.json
|
||||
deleted file mode 100644
|
||||
index 7cd3fffed85da69d5af154f63480bce8766a038f..0000000000000000000000000000000000000000
|
||||
diff --git a/dist/cubism2.es.js b/dist/cubism2.es.js
|
||||
index 0d40d5d40533881d154e3c2cdda9b360c57eaaa6..8dbdb1b36e2da42da264dc3533523e61745102f7 100644
|
||||
--- a/dist/cubism2.es.js
|
||||
+++ b/dist/cubism2.es.js
|
||||
@@ -1373,7 +1373,7 @@ const _FileLoader = class {
|
||||
}
|
||||
static createSettings(files) {
|
||||
return __async(this, null, function* () {
|
||||
- const settingsFile = files.find((file) => file.name.endsWith("model.json") || file.name.endsWith("model3.json"));
|
||||
+ const settingsFile = files.find((file) => !file.name.endsWith("items_pinned_to_model.json") && (file.name.endsWith("model.json") || file.name.endsWith("model3.json")));
|
||||
if (!settingsFile) {
|
||||
throw new TypeError("Settings file not found");
|
||||
}
|
||||
@@ -1458,7 +1458,7 @@ const _ZipLoader = class {
|
||||
static createSettings(reader) {
|
||||
return __async(this, null, function* () {
|
||||
const filePaths = yield _ZipLoader.getFilePaths(reader);
|
||||
- const settingsFilePath = filePaths.find((path) => path.endsWith("model.json") || path.endsWith("model3.json"));
|
||||
+ const settingsFilePath = filePaths.find((path) => !path.endsWith("items_pinned_to_model.json") && (path.endsWith("model.json") || path.endsWith("model3.json")));
|
||||
if (!settingsFilePath) {
|
||||
throw new Error("Settings file not found");
|
||||
}
|
||||
diff --git a/dist/cubism2.js b/dist/cubism2.js
|
||||
index c3db490f8dbfdd63ad40648fe0e0325604f22e95..4b452db0b109ba7deaddf397ef351ae7bcfe145c 100644
|
||||
--- a/dist/cubism2.js
|
||||
+++ b/dist/cubism2.js
|
||||
@@ -1373,7 +1373,7 @@ var __async = (__this, __arguments, generator) => {
|
||||
}
|
||||
static createSettings(files) {
|
||||
return __async(this, null, function* () {
|
||||
- const settingsFile = files.find((file) => file.name.endsWith("model.json") || file.name.endsWith("model3.json"));
|
||||
+ const settingsFile = files.find((file) => !file.name.endsWith("items_pinned_to_model.json") && (file.name.endsWith("model.json") || file.name.endsWith("model3.json")));
|
||||
if (!settingsFile) {
|
||||
throw new TypeError("Settings file not found");
|
||||
}
|
||||
@@ -1458,7 +1458,7 @@ var __async = (__this, __arguments, generator) => {
|
||||
static createSettings(reader) {
|
||||
return __async(this, null, function* () {
|
||||
const filePaths = yield _ZipLoader.getFilePaths(reader);
|
||||
- const settingsFilePath = filePaths.find((path) => path.endsWith("model.json") || path.endsWith("model3.json"));
|
||||
+ const settingsFilePath = filePaths.find((path) => !path.endsWith("items_pinned_to_model.json") && (path.endsWith("model.json") || path.endsWith("model3.json")));
|
||||
if (!settingsFilePath) {
|
||||
throw new Error("Settings file not found");
|
||||
}
|
||||
diff --git a/dist/cubism4.es.js b/dist/cubism4.es.js
|
||||
index f21619f5794c00542bb2a9df340e50d4453a0367..38bd069a5d1ec37380c376c2ff2d7fed9c0042b2 100644
|
||||
--- a/dist/cubism4.es.js
|
||||
+++ b/dist/cubism4.es.js
|
||||
@@ -5012,7 +5012,7 @@ const _FileLoader = class {
|
||||
}
|
||||
static createSettings(files) {
|
||||
return __async(this, null, function* () {
|
||||
- const settingsFile = files.find((file) => file.name.endsWith("model.json") || file.name.endsWith("model3.json"));
|
||||
+ const settingsFile = files.find((file) => !file.name.endsWith("items_pinned_to_model.json") && (file.name.endsWith("model.json") || file.name.endsWith("model3.json")));
|
||||
if (!settingsFile) {
|
||||
throw new TypeError("Settings file not found");
|
||||
}
|
||||
@@ -5097,7 +5097,7 @@ const _ZipLoader = class {
|
||||
static createSettings(reader) {
|
||||
return __async(this, null, function* () {
|
||||
const filePaths = yield _ZipLoader.getFilePaths(reader);
|
||||
- const settingsFilePath = filePaths.find((path) => path.endsWith("model.json") || path.endsWith("model3.json"));
|
||||
+ const settingsFilePath = filePaths.find((path) => !path.endsWith("items_pinned_to_model.json") && (path.endsWith("model.json") || path.endsWith("model3.json")));
|
||||
if (!settingsFilePath) {
|
||||
throw new Error("Settings file not found");
|
||||
}
|
||||
diff --git a/dist/cubism4.js b/dist/cubism4.js
|
||||
index 03c1fb2b32b3b908a8b0e604aa3ee50aa9130169..99fc587e3ca7e6f433099e13f33e0e480a10c25a 100644
|
||||
--- a/dist/cubism4.js
|
||||
+++ b/dist/cubism4.js
|
||||
@@ -5012,7 +5012,7 @@ var __async = (__this, __arguments, generator) => {
|
||||
}
|
||||
static createSettings(files) {
|
||||
return __async(this, null, function* () {
|
||||
- const settingsFile = files.find((file) => file.name.endsWith("model.json") || file.name.endsWith("model3.json"));
|
||||
+ const settingsFile = files.find((file) => !file.name.endsWith("items_pinned_to_model.json") && (file.name.endsWith("model.json") || file.name.endsWith("model3.json")));
|
||||
if (!settingsFile) {
|
||||
throw new TypeError("Settings file not found");
|
||||
}
|
||||
@@ -5097,7 +5097,7 @@ var __async = (__this, __arguments, generator) => {
|
||||
static createSettings(reader) {
|
||||
return __async(this, null, function* () {
|
||||
const filePaths = yield _ZipLoader.getFilePaths(reader);
|
||||
- const settingsFilePath = filePaths.find((path) => path.endsWith("model.json") || path.endsWith("model3.json"));
|
||||
+ const settingsFilePath = filePaths.find((path) => !path.endsWith("items_pinned_to_model.json") && (path.endsWith("model.json") || path.endsWith("model3.json")));
|
||||
if (!settingsFilePath) {
|
||||
throw new Error("Settings file not found");
|
||||
}
|
||||
diff --git a/dist/index.es.js b/dist/index.es.js
|
||||
index f969d304f346c4e420b6798dbd4dec81b902a5e4..7e6b4d3ef53f1ea0648030899de9debd291fc551 100644
|
||||
--- a/dist/index.es.js
|
||||
+++ b/dist/index.es.js
|
||||
@@ -1373,7 +1373,7 @@ const _FileLoader = class {
|
||||
}
|
||||
static createSettings(files) {
|
||||
return __async(this, null, function* () {
|
||||
- const settingsFile = files.find((file) => file.name.endsWith("model.json") || file.name.endsWith("model3.json"));
|
||||
+ const settingsFile = files.find((file) => !file.name.endsWith("items_pinned_to_model.json") && (file.name.endsWith("model.json") || file.name.endsWith("model3.json")));
|
||||
if (!settingsFile) {
|
||||
throw new TypeError("Settings file not found");
|
||||
}
|
||||
@@ -1458,7 +1458,7 @@ const _ZipLoader = class {
|
||||
static createSettings(reader) {
|
||||
return __async(this, null, function* () {
|
||||
const filePaths = yield _ZipLoader.getFilePaths(reader);
|
||||
- const settingsFilePath = filePaths.find((path) => path.endsWith("model.json") || path.endsWith("model3.json"));
|
||||
+ const settingsFilePath = filePaths.find((path) => !path.endsWith("items_pinned_to_model.json") && (path.endsWith("model.json") || path.endsWith("model3.json")));
|
||||
if (!settingsFilePath) {
|
||||
throw new Error("Settings file not found");
|
||||
}
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index bc3d800c45889979190175cc88cdd8251feb9b5e..d1a660708ff81e1379f173709a32c6f4aa16696a 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -1373,7 +1373,7 @@ var __async = (__this, __arguments, generator) => {
|
||||
}
|
||||
static createSettings(files) {
|
||||
return __async(this, null, function* () {
|
||||
- const settingsFile = files.find((file) => file.name.endsWith("model.json") || file.name.endsWith("model3.json"));
|
||||
+ const settingsFile = files.find((file) => !file.name.endsWith("items_pinned_to_model.json") && (file.name.endsWith("model.json") || file.name.endsWith("model3.json")));
|
||||
if (!settingsFile) {
|
||||
throw new TypeError("Settings file not found");
|
||||
}
|
||||
@@ -1458,7 +1458,7 @@ var __async = (__this, __arguments, generator) => {
|
||||
static createSettings(reader) {
|
||||
return __async(this, null, function* () {
|
||||
const filePaths = yield _ZipLoader.getFilePaths(reader);
|
||||
- const settingsFilePath = filePaths.find((path) => path.endsWith("model.json") || path.endsWith("model3.json"));
|
||||
+ const settingsFilePath = filePaths.find((path) => !path.endsWith("items_pinned_to_model.json") && (path.endsWith("model.json") || path.endsWith("model3.json")));
|
||||
if (!settingsFilePath) {
|
||||
throw new Error("Settings file not found");
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"Type": "Live2D Expression",
|
||||
"Parameters": [
|
||||
{
|
||||
"Id": "ParamMouthOpenY",
|
||||
"Value": 0.27,
|
||||
"Blend": "Add"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"Type": "Live2D Expression",
|
||||
"Parameters": [
|
||||
{
|
||||
"Id": "ParamBrowLY",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRY",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLForm",
|
||||
"Value": 1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRForm",
|
||||
"Value": 1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamMouthOpenY",
|
||||
"Value": 1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamEyeForm",
|
||||
"Value": 0.54,
|
||||
"Blend": "Add"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"Type": "Live2D Expression",
|
||||
"Parameters": [
|
||||
{
|
||||
"Id": "ParamBrowLY",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRY",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLX",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRX",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRAngle",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLForm",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRForm",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamMouthForm",
|
||||
"Value": -2,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamMouthOpenY",
|
||||
"Value": 1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamEyeForm",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"Type": "Live2D Expression",
|
||||
"Parameters": [
|
||||
{
|
||||
"Id": "ParamEyeLOpen",
|
||||
"Value": 0.8,
|
||||
"Blend": "Multiply"
|
||||
},
|
||||
{
|
||||
"Id": "ParamEyeROpen",
|
||||
"Value": 0.8,
|
||||
"Blend": "Multiply"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLY",
|
||||
"Value": -0.56,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRY",
|
||||
"Value": -0.56,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRX",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLAngle",
|
||||
"Value": 0.35,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRAngle",
|
||||
"Value": 0.35,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLForm",
|
||||
"Value": -0.74,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRForm",
|
||||
"Value": -0.74,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamMouthForm",
|
||||
"Value": -1.76,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamEyeForm",
|
||||
"Value": 1,
|
||||
"Blend": "Add"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"Type": "Live2D Expression",
|
||||
"Parameters": [
|
||||
{
|
||||
"Id": "ParamEyeLOpen",
|
||||
"Value": 0,
|
||||
"Blend": "Multiply"
|
||||
},
|
||||
{
|
||||
"Id": "ParamEyeLSmile",
|
||||
"Value": 1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamEyeROpen",
|
||||
"Value": 0,
|
||||
"Blend": "Multiply"
|
||||
},
|
||||
{
|
||||
"Id": "ParamEyeRSmile",
|
||||
"Value": 1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLY",
|
||||
"Value": 0.32,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRY",
|
||||
"Value": 0.32,
|
||||
"Blend": "Add"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"Type": "Live2D Expression",
|
||||
"Parameters": [
|
||||
{
|
||||
"Id": "ParamEyeLOpen",
|
||||
"Value": 2,
|
||||
"Blend": "Multiply"
|
||||
},
|
||||
{
|
||||
"Id": "ParamEyeROpen",
|
||||
"Value": 2,
|
||||
"Blend": "Multiply"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLY",
|
||||
"Value": 1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRY",
|
||||
"Value": 1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamMouthForm",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamEyeBallForm",
|
||||
"Value": -0.65,
|
||||
"Blend": "Add"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"Type": "Live2D Expression",
|
||||
"Parameters": [
|
||||
{
|
||||
"Id": "ParamEyeLOpen",
|
||||
"Value": 0.89,
|
||||
"Blend": "Multiply"
|
||||
},
|
||||
{
|
||||
"Id": "ParamEyeROpen",
|
||||
"Value": 0.89,
|
||||
"Blend": "Multiply"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLY",
|
||||
"Value": -0.56,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRY",
|
||||
"Value": -0.56,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLX",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRX",
|
||||
"Value": -1,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLAngle",
|
||||
"Value": 0.35,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRAngle",
|
||||
"Value": 0.35,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLForm",
|
||||
"Value": -0.74,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRForm",
|
||||
"Value": -0.74,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamMouthForm",
|
||||
"Value": -0.46,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamTere",
|
||||
"Value": 1,
|
||||
"Blend": "Add"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"Type": "Live2D Expression",
|
||||
"Parameters": [
|
||||
{
|
||||
"Id": "ParamEyeLOpen",
|
||||
"Value": 0.8,
|
||||
"Blend": "Multiply"
|
||||
},
|
||||
{
|
||||
"Id": "ParamEyeROpen",
|
||||
"Value": 0.8,
|
||||
"Blend": "Multiply"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowLForm",
|
||||
"Value": -0.33,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamBrowRForm",
|
||||
"Value": -0.33,
|
||||
"Blend": "Add"
|
||||
},
|
||||
{
|
||||
"Id": "ParamMouthForm",
|
||||
"Value": -1.76,
|
||||
"Blend": "Add"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
_ts_packages/packages/cortex-ui/public/live2d-models/haru/haru_greeter_t03.2048/texture_00.png
vendored
Normal file
BIN
_ts_packages/packages/cortex-ui/public/live2d-models/haru/haru_greeter_t03.2048/texture_00.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
_ts_packages/packages/cortex-ui/public/live2d-models/haru/haru_greeter_t03.2048/texture_01.png
vendored
Normal file
BIN
_ts_packages/packages/cortex-ui/public/live2d-models/haru/haru_greeter_t03.2048/texture_01.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"Version": 3,
|
||||
"FileReferences": {
|
||||
"Moc": "haru_greeter_t03.moc3",
|
||||
"Textures": [
|
||||
"haru_greeter_t03.2048/texture_00.png",
|
||||
"haru_greeter_t03.2048/texture_01.png"
|
||||
],
|
||||
"Physics": "haru_greeter_t03.physics3.json",
|
||||
"Pose": "haru_greeter_t03.pose3.json",
|
||||
"Expressions": [
|
||||
{ "Name": "f00", "File": "expressions/F01.exp3.json" },
|
||||
{ "Name": "f01", "File": "expressions/F02.exp3.json" },
|
||||
{ "Name": "f02", "File": "expressions/F03.exp3.json" },
|
||||
{ "Name": "f03", "File": "expressions/F04.exp3.json" },
|
||||
{ "Name": "f04", "File": "expressions/F05.exp3.json" },
|
||||
{ "Name": "f05", "File": "expressions/F06.exp3.json" },
|
||||
{ "Name": "f06", "File": "expressions/F07.exp3.json" },
|
||||
{ "Name": "f07", "File": "expressions/F08.exp3.json" }
|
||||
],
|
||||
"Motions": {
|
||||
"Idle": [
|
||||
{ "File": "motion/haru_g_idle.motion3.json" },
|
||||
{ "File": "motion/haru_g_m07.motion3.json" },
|
||||
{ "File": "motion/haru_g_m15.motion3.json" }
|
||||
],
|
||||
"Tap": [
|
||||
{ "File": "motion/haru_g_m14.motion3.json" },
|
||||
{ "File": "motion/haru_g_m05.motion3.json" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"Groups": [
|
||||
{
|
||||
"Target": "Parameter",
|
||||
"Name": "EyeBlink",
|
||||
"Ids": ["ParamEyeLOpen", "ParamEyeROpen"]
|
||||
},
|
||||
{
|
||||
"Target": "Parameter",
|
||||
"Name": "LipSync",
|
||||
"Ids": ["ParamMouthOpenY"]
|
||||
}
|
||||
],
|
||||
"HitAreas": [
|
||||
{ "Id": "HitArea", "Name": "Head" },
|
||||
{ "Id": "HitArea2", "Name": "Body" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,373 @@
|
|||
{
|
||||
"Version": 3,
|
||||
"Meta": {
|
||||
"PhysicsSettingCount": 4,
|
||||
"TotalInputCount": 14,
|
||||
"TotalOutputCount": 4,
|
||||
"VertexCount": 8,
|
||||
"EffectiveForces": {
|
||||
"Gravity": {
|
||||
"X": 0,
|
||||
"Y": -1
|
||||
},
|
||||
"Wind": {
|
||||
"X": 0,
|
||||
"Y": 0
|
||||
}
|
||||
},
|
||||
"PhysicsDictionary": [
|
||||
{
|
||||
"Id": "PhysicsSetting1",
|
||||
"Name": "前髪"
|
||||
},
|
||||
{
|
||||
"Id": "PhysicsSetting2",
|
||||
"Name": "横髪"
|
||||
},
|
||||
{
|
||||
"Id": "PhysicsSetting3",
|
||||
"Name": "後ろ髪"
|
||||
},
|
||||
{
|
||||
"Id": "PhysicsSetting4",
|
||||
"Name": "スカーフ"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PhysicsSettings": [
|
||||
{
|
||||
"Id": "PhysicsSetting1",
|
||||
"Input": [
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamAngleX"
|
||||
},
|
||||
"Weight": 60,
|
||||
"Type": "X",
|
||||
"Reflect": false
|
||||
},
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamAngleZ"
|
||||
},
|
||||
"Weight": 60,
|
||||
"Type": "Angle",
|
||||
"Reflect": false
|
||||
},
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamBodyAngleX"
|
||||
},
|
||||
"Weight": 40,
|
||||
"Type": "X",
|
||||
"Reflect": false
|
||||
},
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamBodyAngleZ"
|
||||
},
|
||||
"Weight": 40,
|
||||
"Type": "Angle",
|
||||
"Reflect": false
|
||||
}
|
||||
],
|
||||
"Output": [
|
||||
{
|
||||
"Destination": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamHairFront"
|
||||
},
|
||||
"VertexIndex": 1,
|
||||
"Scale": 1.821,
|
||||
"Weight": 100,
|
||||
"Type": "Angle",
|
||||
"Reflect": false
|
||||
}
|
||||
],
|
||||
"Vertices": [
|
||||
{
|
||||
"Position": {
|
||||
"X": 0,
|
||||
"Y": 0
|
||||
},
|
||||
"Mobility": 1,
|
||||
"Delay": 1,
|
||||
"Acceleration": 1,
|
||||
"Radius": 0
|
||||
},
|
||||
{
|
||||
"Position": {
|
||||
"X": 0,
|
||||
"Y": 8
|
||||
},
|
||||
"Mobility": 0.95,
|
||||
"Delay": 0.8,
|
||||
"Acceleration": 1.5,
|
||||
"Radius": 8
|
||||
}
|
||||
],
|
||||
"Normalization": {
|
||||
"Position": {
|
||||
"Minimum": -10,
|
||||
"Default": 0,
|
||||
"Maximum": 10
|
||||
},
|
||||
"Angle": {
|
||||
"Minimum": -10,
|
||||
"Default": 0,
|
||||
"Maximum": 10
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Id": "PhysicsSetting2",
|
||||
"Input": [
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamAngleX"
|
||||
},
|
||||
"Weight": 60,
|
||||
"Type": "X",
|
||||
"Reflect": false
|
||||
},
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamAngleZ"
|
||||
},
|
||||
"Weight": 60,
|
||||
"Type": "Angle",
|
||||
"Reflect": false
|
||||
},
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamBodyAngleX"
|
||||
},
|
||||
"Weight": 40,
|
||||
"Type": "X",
|
||||
"Reflect": false
|
||||
},
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamBodyAngleZ"
|
||||
},
|
||||
"Weight": 40,
|
||||
"Type": "Angle",
|
||||
"Reflect": false
|
||||
}
|
||||
],
|
||||
"Output": [
|
||||
{
|
||||
"Destination": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamHairSide"
|
||||
},
|
||||
"VertexIndex": 1,
|
||||
"Scale": 1.593,
|
||||
"Weight": 100,
|
||||
"Type": "Angle",
|
||||
"Reflect": false
|
||||
}
|
||||
],
|
||||
"Vertices": [
|
||||
{
|
||||
"Position": {
|
||||
"X": 0,
|
||||
"Y": 0
|
||||
},
|
||||
"Mobility": 1,
|
||||
"Delay": 1,
|
||||
"Acceleration": 1,
|
||||
"Radius": 0
|
||||
},
|
||||
{
|
||||
"Position": {
|
||||
"X": 0,
|
||||
"Y": 8
|
||||
},
|
||||
"Mobility": 0.95,
|
||||
"Delay": 0.8,
|
||||
"Acceleration": 1,
|
||||
"Radius": 8
|
||||
}
|
||||
],
|
||||
"Normalization": {
|
||||
"Position": {
|
||||
"Minimum": -10,
|
||||
"Default": 0,
|
||||
"Maximum": 10
|
||||
},
|
||||
"Angle": {
|
||||
"Minimum": -10,
|
||||
"Default": 0,
|
||||
"Maximum": 10
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Id": "PhysicsSetting3",
|
||||
"Input": [
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamAngleX"
|
||||
},
|
||||
"Weight": 60,
|
||||
"Type": "X",
|
||||
"Reflect": false
|
||||
},
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamAngleZ"
|
||||
},
|
||||
"Weight": 60,
|
||||
"Type": "Angle",
|
||||
"Reflect": false
|
||||
},
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamBodyAngleX"
|
||||
},
|
||||
"Weight": 40,
|
||||
"Type": "X",
|
||||
"Reflect": false
|
||||
},
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamBodyAngleZ"
|
||||
},
|
||||
"Weight": 40,
|
||||
"Type": "Angle",
|
||||
"Reflect": false
|
||||
}
|
||||
],
|
||||
"Output": [
|
||||
{
|
||||
"Destination": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamHairBack"
|
||||
},
|
||||
"VertexIndex": 1,
|
||||
"Scale": 1.943,
|
||||
"Weight": 100,
|
||||
"Type": "Angle",
|
||||
"Reflect": false
|
||||
}
|
||||
],
|
||||
"Vertices": [
|
||||
{
|
||||
"Position": {
|
||||
"X": 0,
|
||||
"Y": 0
|
||||
},
|
||||
"Mobility": 1,
|
||||
"Delay": 1,
|
||||
"Acceleration": 1,
|
||||
"Radius": 0
|
||||
},
|
||||
{
|
||||
"Position": {
|
||||
"X": 0,
|
||||
"Y": 8
|
||||
},
|
||||
"Mobility": 0.95,
|
||||
"Delay": 0.8,
|
||||
"Acceleration": 1.5,
|
||||
"Radius": 8
|
||||
}
|
||||
],
|
||||
"Normalization": {
|
||||
"Position": {
|
||||
"Minimum": -10,
|
||||
"Default": 0,
|
||||
"Maximum": 10
|
||||
},
|
||||
"Angle": {
|
||||
"Minimum": -10,
|
||||
"Default": 0,
|
||||
"Maximum": 10
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Id": "PhysicsSetting4",
|
||||
"Input": [
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamBodyAngleX"
|
||||
},
|
||||
"Weight": 100,
|
||||
"Type": "X",
|
||||
"Reflect": false
|
||||
},
|
||||
{
|
||||
"Source": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamBodyAngleZ"
|
||||
},
|
||||
"Weight": 100,
|
||||
"Type": "Angle",
|
||||
"Reflect": false
|
||||
}
|
||||
],
|
||||
"Output": [
|
||||
{
|
||||
"Destination": {
|
||||
"Target": "Parameter",
|
||||
"Id": "ParamScarf"
|
||||
},
|
||||
"VertexIndex": 1,
|
||||
"Scale": 0.873,
|
||||
"Weight": 100,
|
||||
"Type": "Angle",
|
||||
"Reflect": false
|
||||
}
|
||||
],
|
||||
"Vertices": [
|
||||
{
|
||||
"Position": {
|
||||
"X": 0,
|
||||
"Y": 0
|
||||
},
|
||||
"Mobility": 1,
|
||||
"Delay": 1,
|
||||
"Acceleration": 1,
|
||||
"Radius": 0
|
||||
},
|
||||
{
|
||||
"Position": {
|
||||
"X": 0,
|
||||
"Y": 10
|
||||
},
|
||||
"Mobility": 0.9,
|
||||
"Delay": 0.6,
|
||||
"Acceleration": 1.5,
|
||||
"Radius": 10
|
||||
}
|
||||
],
|
||||
"Normalization": {
|
||||
"Position": {
|
||||
"Minimum": -10,
|
||||
"Default": 0,
|
||||
"Maximum": 10
|
||||
},
|
||||
"Angle": {
|
||||
"Minimum": -10,
|
||||
"Default": 0,
|
||||
"Maximum": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"Type": "Live2D Pose",
|
||||
"Groups": [
|
||||
[
|
||||
{
|
||||
"Id": "Part01ArmRA001",
|
||||
"Link": []
|
||||
},
|
||||
{
|
||||
"Id": "Part01ArmRB001",
|
||||
"Link": []
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"Id": "Part01ArmLA001",
|
||||
"Link": []
|
||||
},
|
||||
{
|
||||
"Id": "Part01ArmLB001",
|
||||
"Link": []
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
169
_ts_packages/packages/cortex-ui/src/components/Live2DPet.svelte
Normal file
169
_ts_packages/packages/cortex-ui/src/components/Live2DPet.svelte
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<script lang="ts">
|
||||
// Ported from AIRI (https://github.com/moeru-ai/airi), MIT License.
|
||||
// Original: packages/stage-ui-live2d/src/components/scenes/Live2D.vue
|
||||
// Adapted from Vue 3 <script setup> + Pinia stores to Svelte 5 runes.
|
||||
// Only the model-loading + emotion-switching + breathing/blink loop is
|
||||
// reproduced; Canvas.vue + Model.vue's richer feature-set (hot reload,
|
||||
// theme tint, focus tracking, render-scale probes) is deferred.
|
||||
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Emotion } from '../lib/live2d/emotions';
|
||||
import {
|
||||
createEmotionMotionManager,
|
||||
type EmotionMotionManager,
|
||||
} from '../lib/live2d/motion-manager';
|
||||
import type { Live2DPetProps } from '../lib/live2d/types';
|
||||
|
||||
const {
|
||||
modelPath,
|
||||
mood = Emotion.Neutral,
|
||||
width = 256,
|
||||
height = 256,
|
||||
}: Live2DPetProps = $props();
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | null>(null);
|
||||
let load_error = $state<string | null>(null);
|
||||
let ready = $state(false);
|
||||
|
||||
// Non-reactive runtime refs (PIXI objects cannot be wrapped in $state).
|
||||
let app: { destroy?: (r?: boolean, opts?: unknown) => void } | null = null;
|
||||
let live2d_model: { destroy?: (opts?: unknown) => void } | null = null;
|
||||
let motion_mgr: EmotionMotionManager | null = null;
|
||||
|
||||
onMount(() => {
|
||||
// Guard: tests run under jsdom which has no WebGL. Skip boot and leave
|
||||
// the canvas in place so `@testing-library` can assert a clean mount.
|
||||
if (!has_webgl()) {
|
||||
load_error = 'WebGL not available; renderer disabled (tests/jsdom).';
|
||||
return;
|
||||
}
|
||||
void boot();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
try {
|
||||
if (live2d_model?.destroy) {
|
||||
live2d_model.destroy({ children: true, texture: true, baseTexture: true });
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
try {
|
||||
app?.destroy?.(true, { children: true, texture: true, baseTexture: true });
|
||||
} catch { /* noop */ }
|
||||
live2d_model = null;
|
||||
app = null;
|
||||
motion_mgr = null;
|
||||
});
|
||||
|
||||
// Reactive bridge: mood prop → motion manager.
|
||||
$effect(() => {
|
||||
if (!ready || !motion_mgr) return;
|
||||
void motion_mgr.setEmotion(mood);
|
||||
});
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
try {
|
||||
const { Application } = await import('pixi.js');
|
||||
// pixi-live2d-display imports the Cubism 4 runtime + registers the
|
||||
// motion-manager plugin onto PIXI when loaded.
|
||||
const plugin = await import('pixi-live2d-display/cubism4');
|
||||
const Live2DModel = plugin.Live2DModel;
|
||||
if (!canvas) throw new Error('canvas element missing');
|
||||
|
||||
app = new Application({
|
||||
view: canvas,
|
||||
width,
|
||||
height,
|
||||
backgroundAlpha: 0,
|
||||
antialias: true,
|
||||
autoStart: true,
|
||||
});
|
||||
|
||||
const model = await Live2DModel.from(modelPath, { autoInteract: false });
|
||||
fit_model(model, width, height);
|
||||
// @ts-expect-error — PIXI container.addChild signature.
|
||||
app!.stage.addChild(model);
|
||||
|
||||
live2d_model = model as unknown as typeof live2d_model;
|
||||
motion_mgr = createEmotionMotionManager(
|
||||
(model as { internalModel: { motionManager: unknown } }).internalModel
|
||||
.motionManager as Parameters<typeof createEmotionMotionManager>[0],
|
||||
);
|
||||
ready = true;
|
||||
// Initial emotion.
|
||||
await motion_mgr.setEmotion(mood);
|
||||
} catch (err) {
|
||||
load_error =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `failed to load Live2D model: ${String(err)}`;
|
||||
// Keep ready=false so the $effect above doesn't fire on a null mgr.
|
||||
}
|
||||
}
|
||||
|
||||
function has_webgl(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
// jsdom ships `HTMLCanvasElement.getContext` as a "not implemented"
|
||||
// stub that logs to stderr. Short-circuit when we can detect jsdom.
|
||||
const nav = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||
if (nav.includes('jsdom')) return false;
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
return !!(
|
||||
c.getContext('webgl2') ||
|
||||
c.getContext('webgl') ||
|
||||
c.getContext('experimental-webgl')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fit_model(
|
||||
m: { scale: { set: (v: number) => void }; x: number; y: number; width: number; height: number },
|
||||
cw: number,
|
||||
ch: number,
|
||||
): void {
|
||||
const scale = Math.min(cw / m.width, ch / m.height) * 0.95;
|
||||
m.scale.set(scale);
|
||||
m.x = (cw - m.width) / 2;
|
||||
m.y = (ch - m.height) / 2;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="live2d-pet" style:width={`${width}px`} style:height={`${height}px`}>
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="live2d-canvas"
|
||||
width={width}
|
||||
height={height}
|
||||
aria-label="Live2D pet ({mood})"
|
||||
></canvas>
|
||||
{#if load_error}
|
||||
<div class="live2d-error muted" role="status">{load_error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.live2d-pet {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-radius: 8px;
|
||||
background: var(--card, #f7f7f8);
|
||||
overflow: hidden;
|
||||
}
|
||||
.live2d-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.live2d-error {
|
||||
position: absolute;
|
||||
inset: auto 0 8px 0;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--muted, #666);
|
||||
padding: 2px 6px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
</style>
|
||||
63
_ts_packages/packages/cortex-ui/src/lib/live2d/beat-sync.ts
Normal file
63
_ts_packages/packages/cortex-ui/src/lib/live2d/beat-sync.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Ported from AIRI (https://github.com/moeru-ai/airi), MIT License.
|
||||
// Original: packages/stage-ui-live2d/src/composables/live2d/beat-sync.ts
|
||||
// Adapted: STUB for Wave 21. The full 359-LOC AIRI implementation drives
|
||||
// head yaw/roll from audio BPM with semi-implicit Euler springs and four
|
||||
// stylistic patterns ('punchy-v' | 'balanced-v' | 'swing-lr' | 'sway-sine').
|
||||
// cortex-ui does not yet have TTS/audio wired in, so every method here is
|
||||
// a no-op that matches the AIRI shape so call-sites compile unchanged.
|
||||
// Full port scheduled for Wave 22 (TTS integration).
|
||||
|
||||
export type BeatSyncStyleName =
|
||||
| 'punchy-v'
|
||||
| 'balanced-v'
|
||||
| 'swing-lr'
|
||||
| 'sway-sine';
|
||||
|
||||
export interface BeatBaseAngles {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface BeatSyncController {
|
||||
readonly targetX: number;
|
||||
readonly targetY: number;
|
||||
readonly targetZ: number;
|
||||
readonly velocityX: number;
|
||||
readonly velocityY: number;
|
||||
readonly velocityZ: number;
|
||||
updateTargets(now: number): void;
|
||||
scheduleBeat(timestamp?: number | null): void;
|
||||
setStyle(style: BeatSyncStyleName): void;
|
||||
getStyle(): BeatSyncStyleName;
|
||||
setAutoStyleShift(enabled: boolean): void;
|
||||
debugState(): {
|
||||
primed: boolean;
|
||||
patternStarted: boolean;
|
||||
bpm: number | null;
|
||||
style: BeatSyncStyleName;
|
||||
};
|
||||
}
|
||||
|
||||
export function createBeatSyncController(): BeatSyncController {
|
||||
let style: BeatSyncStyleName = 'balanced-v';
|
||||
return {
|
||||
targetX: 0,
|
||||
targetY: 0,
|
||||
targetZ: 0,
|
||||
velocityX: 0,
|
||||
velocityY: 0,
|
||||
velocityZ: 0,
|
||||
updateTargets: () => { /* stub: Wave 22 will drive head sway. */ },
|
||||
scheduleBeat: () => { /* stub: Wave 22 will consume TTS beats. */ },
|
||||
setStyle: (s) => { style = s; },
|
||||
getStyle: () => style,
|
||||
setAutoStyleShift: () => { /* stub */ },
|
||||
debugState: () => ({
|
||||
primed: false,
|
||||
patternStarted: false,
|
||||
bpm: null,
|
||||
style,
|
||||
}),
|
||||
};
|
||||
}
|
||||
72
_ts_packages/packages/cortex-ui/src/lib/live2d/emotions.ts
Normal file
72
_ts_packages/packages/cortex-ui/src/lib/live2d/emotions.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Ported from AIRI (https://github.com/moeru-ai/airi), MIT License.
|
||||
// Original: packages/stage-ui-live2d/src/constants/emotions.ts
|
||||
// Copy-as-is; no Vue/Svelte API surface here, just an enum + lookup tables.
|
||||
|
||||
export enum Emotion {
|
||||
Happy = 'happy',
|
||||
Sad = 'sad',
|
||||
Angry = 'angry',
|
||||
Think = 'think',
|
||||
Surprise = 'surprised',
|
||||
Awkward = 'awkward',
|
||||
Question = 'question',
|
||||
Curious = 'curious',
|
||||
Neutral = 'neutral',
|
||||
}
|
||||
|
||||
export const EMOTION_VALUES = Object.values(Emotion);
|
||||
|
||||
export const EmotionHappyMotionName = 'Happy';
|
||||
export const EmotionSadMotionName = 'Sad';
|
||||
export const EmotionAngryMotionName = 'Angry';
|
||||
export const EmotionAwkwardMotionName = 'Awkward';
|
||||
export const EmotionThinkMotionName = 'Think';
|
||||
export const EmotionSurpriseMotionName = 'Surprise';
|
||||
export const EmotionQuestionMotionName = 'Question';
|
||||
export const EmotionNeutralMotionName = 'Idle';
|
||||
export const EmotionCuriousMotionName = 'Curious';
|
||||
|
||||
export const EMOTION_EmotionMotionName_value: Record<Emotion, string> = {
|
||||
[Emotion.Happy]: EmotionHappyMotionName,
|
||||
[Emotion.Sad]: EmotionSadMotionName,
|
||||
[Emotion.Angry]: EmotionAngryMotionName,
|
||||
[Emotion.Think]: EmotionThinkMotionName,
|
||||
[Emotion.Surprise]: EmotionSurpriseMotionName,
|
||||
[Emotion.Awkward]: EmotionAwkwardMotionName,
|
||||
[Emotion.Question]: EmotionQuestionMotionName,
|
||||
[Emotion.Neutral]: EmotionNeutralMotionName,
|
||||
[Emotion.Curious]: EmotionCuriousMotionName,
|
||||
};
|
||||
|
||||
export const EMOTION_VRMExpressionName_value: Record<Emotion, string | undefined> = {
|
||||
[Emotion.Happy]: 'happy',
|
||||
[Emotion.Sad]: 'sad',
|
||||
[Emotion.Angry]: 'angry',
|
||||
[Emotion.Think]: undefined,
|
||||
[Emotion.Surprise]: 'surprised',
|
||||
[Emotion.Awkward]: undefined,
|
||||
[Emotion.Question]: undefined,
|
||||
[Emotion.Neutral]: undefined,
|
||||
[Emotion.Curious]: 'surprised',
|
||||
};
|
||||
|
||||
/**
|
||||
* Cortex mood (4 buckets used by PetEditor) → Live2D Emotion.
|
||||
* This is the bridge from the existing pet-editor sprite moods to the
|
||||
* finer-grained AIRI emotion enum.
|
||||
*/
|
||||
export type CortexMood = 'idle' | 'happy' | 'think' | 'sleep';
|
||||
|
||||
export function moodToEmotion(mood: CortexMood): Emotion {
|
||||
switch (mood) {
|
||||
case 'happy':
|
||||
return Emotion.Happy;
|
||||
case 'think':
|
||||
return Emotion.Think;
|
||||
case 'sleep':
|
||||
return Emotion.Neutral; // no "Sleep" emotion; closest resting state.
|
||||
case 'idle':
|
||||
default:
|
||||
return Emotion.Neutral;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
// Ported from AIRI (https://github.com/moeru-ai/airi), MIT License.
|
||||
// Original: packages/stage-ui-live2d/src/composables/live2d/expression-controller.ts
|
||||
// Adapted: AIRI's version maintains its own Pinia-backed parameter blend store
|
||||
// to support authoring UI (ExpressionPanel) on top of exp3 files. In
|
||||
// cortex-ui we delegate exp3 parsing to pixi-live2d-display's own
|
||||
// ExpressionManager — the Svelte component merely drives emotion→expression
|
||||
// selection. This controller tracks the lookup layer (Emotion → exp name)
|
||||
// plus a small active-parameter registry for optional per-frame overrides.
|
||||
|
||||
import type {
|
||||
CubismCoreModelLike,
|
||||
ExpressionManagerLike,
|
||||
Live2DModelLike,
|
||||
} from './types';
|
||||
|
||||
export type ExpressionBlendMode = 'Add' | 'Multiply' | 'Overwrite';
|
||||
|
||||
export interface ExpressionEntry {
|
||||
name: string;
|
||||
parameterId: string;
|
||||
blend: ExpressionBlendMode;
|
||||
/** Runtime-mutable: current blended target. */
|
||||
currentValue: number;
|
||||
/** The Cubism parameter's initial value at load time. */
|
||||
modelDefault: number;
|
||||
/** The "intended activation value" from the exp3 file. */
|
||||
targetValue: number;
|
||||
}
|
||||
|
||||
export interface ExpressionController {
|
||||
setActive(name: string): boolean;
|
||||
clear(): void;
|
||||
listAvailable(): string[];
|
||||
/** Apply any registered manual overrides every frame. */
|
||||
applyExpressions(coreModel: CubismCoreModelLike): void;
|
||||
registerOverride(entry: ExpressionEntry): void;
|
||||
removeOverride(parameterId: string): void;
|
||||
debugActive(): string | null;
|
||||
}
|
||||
|
||||
interface ControllerState {
|
||||
active: string | null;
|
||||
overrides: Map<string, ExpressionEntry>;
|
||||
// Previous-frame active param IDs, so we can explicitly reset stale writes
|
||||
// on the inactive→active transition (port of AIRI's `activeLastFrame`).
|
||||
lastFrame: Set<string>;
|
||||
}
|
||||
|
||||
function initialState(): ControllerState {
|
||||
return {
|
||||
active: null,
|
||||
overrides: new Map(),
|
||||
lastFrame: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an expression controller bound to a Live2D model.
|
||||
*
|
||||
* For Cubism 4 models loaded via pixi-live2d-display, the `expressionManager`
|
||||
* is populated from `model3.json.FileReferences.Expressions`. This wrapper
|
||||
* adds:
|
||||
* - `setActive(name)`: idempotent, logs on unknown name.
|
||||
* - `applyExpressions(coreModel)`: apply manual parameter overrides on top
|
||||
* of whatever the native expression manager has already written.
|
||||
*/
|
||||
export function createExpressionController(
|
||||
model: Live2DModelLike,
|
||||
): ExpressionController {
|
||||
const state = initialState();
|
||||
|
||||
function getManager(): ExpressionManagerLike | null {
|
||||
return model.internalModel?.motionManager?.expressionManager ?? null;
|
||||
}
|
||||
|
||||
function listAvailable(): string[] {
|
||||
const em = getManager();
|
||||
if (!em?.definitions) return [];
|
||||
return em.definitions.map((d, i) => d.Name ?? d.name ?? String(i));
|
||||
}
|
||||
|
||||
function setActive(name: string): boolean {
|
||||
const em = getManager();
|
||||
if (!em) return false;
|
||||
const available = listAvailable();
|
||||
if (!available.includes(name)) {
|
||||
// Try numeric index as fallback (pixi-live2d-display accepts both).
|
||||
const asNum = Number(name);
|
||||
if (!Number.isInteger(asNum)) return false;
|
||||
}
|
||||
try {
|
||||
const r = em.setExpression(name);
|
||||
if (r === false) return false;
|
||||
state.active = name;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
const em = getManager();
|
||||
if (em?.resetExpression) {
|
||||
try { em.resetExpression(); } catch { /* noop */ }
|
||||
}
|
||||
state.active = null;
|
||||
state.overrides.clear();
|
||||
state.lastFrame.clear();
|
||||
}
|
||||
|
||||
function registerOverride(entry: ExpressionEntry): void {
|
||||
state.overrides.set(entry.parameterId, entry);
|
||||
}
|
||||
|
||||
function removeOverride(parameterId: string): void {
|
||||
state.overrides.delete(parameterId);
|
||||
}
|
||||
|
||||
// ---- Per-frame --------------------------------------------------------
|
||||
|
||||
function applyExpressions(coreModel: CubismCoreModelLike): void {
|
||||
const activeThisFrame = new Set<string>();
|
||||
|
||||
for (const entry of state.overrides.values()) {
|
||||
if (isNoop(entry)) continue;
|
||||
const value = computeBlend(entry, coreModel);
|
||||
coreModel.setParameterValueById(entry.parameterId, value);
|
||||
activeThisFrame.add(entry.parameterId);
|
||||
}
|
||||
|
||||
for (const paramId of state.lastFrame) {
|
||||
if (!activeThisFrame.has(paramId)) {
|
||||
const entry = state.overrides.get(paramId);
|
||||
if (entry) coreModel.setParameterValueById(paramId, entry.modelDefault);
|
||||
}
|
||||
}
|
||||
|
||||
state.lastFrame.clear();
|
||||
activeThisFrame.forEach((p) => state.lastFrame.add(p));
|
||||
}
|
||||
|
||||
return {
|
||||
setActive,
|
||||
clear,
|
||||
listAvailable,
|
||||
applyExpressions,
|
||||
registerOverride,
|
||||
removeOverride,
|
||||
debugActive: () => state.active,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Internal pure helpers (blend math, ported verbatim) ------------------
|
||||
|
||||
function isNoop(entry: ExpressionEntry): boolean {
|
||||
switch (entry.blend) {
|
||||
case 'Add':
|
||||
return entry.currentValue === 0;
|
||||
case 'Multiply':
|
||||
return entry.currentValue === 1;
|
||||
default:
|
||||
return entry.currentValue === entry.modelDefault;
|
||||
}
|
||||
}
|
||||
|
||||
function computeBlend(
|
||||
entry: ExpressionEntry,
|
||||
coreModel: CubismCoreModelLike,
|
||||
): number {
|
||||
switch (entry.blend) {
|
||||
case 'Add':
|
||||
return entry.modelDefault + entry.currentValue;
|
||||
case 'Multiply': {
|
||||
const frameValue = coreModel.getParameterValueById(entry.parameterId);
|
||||
return frameValue * entry.currentValue;
|
||||
}
|
||||
default:
|
||||
return entry.currentValue;
|
||||
}
|
||||
}
|
||||
194
_ts_packages/packages/cortex-ui/src/lib/live2d/motion-manager.ts
Normal file
194
_ts_packages/packages/cortex-ui/src/lib/live2d/motion-manager.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// Ported from AIRI (https://github.com/moeru-ai/airi), MIT License.
|
||||
// Original: packages/stage-ui-live2d/src/composables/live2d/motion-manager.ts
|
||||
// Adapted from Vue Composition API (Ref + composables + plugin registry) to
|
||||
// a plain TS state machine + closure. The AIRI version runs a plugin chain
|
||||
// each frame hooked into pixi-live2d-display's `MotionManager.update`. Our
|
||||
// cortex-ui use case is coarser: switch emotions on user action, and let
|
||||
// pixi-live2d-display's built-in idle + eyeBlink loops handle breathing.
|
||||
// Only idle/Tap motion groups + F01-F08 expressions are mapped.
|
||||
|
||||
import {
|
||||
Emotion,
|
||||
EMOTION_EmotionMotionName_value,
|
||||
} from './emotions';
|
||||
import type { MotionManagerLike } from './types';
|
||||
|
||||
/**
|
||||
* Which motion group + expression index the AIRI emotion maps to on the
|
||||
* bundled Haru model. Models with richer per-emotion motion groups
|
||||
* (`Happy` / `Sad` / ...) are handled by `MotionManager.startMotion`
|
||||
* falling through to the declared group if present.
|
||||
*/
|
||||
export interface EmotionBinding {
|
||||
/** Motion group name used by `manager.startMotion(group, index)`. */
|
||||
group: string;
|
||||
/** Optional index inside the group; defaults to 0 (first clip). */
|
||||
index?: number;
|
||||
/** Optional expression name inside the model's Expressions array. */
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
/** Haru-specific fallback bindings used when the model lacks emotion-named groups. */
|
||||
export const HARU_EMOTION_BINDINGS: Record<Emotion, EmotionBinding> = {
|
||||
[Emotion.Neutral]: { group: 'Idle', index: 0, expression: 'f00' },
|
||||
[Emotion.Happy]: { group: 'Tap', index: 0, expression: 'f01' },
|
||||
[Emotion.Sad]: { group: 'Idle', index: 1, expression: 'f02' },
|
||||
[Emotion.Angry]: { group: 'Tap', index: 1, expression: 'f03' },
|
||||
[Emotion.Surprise]: { group: 'Tap', index: 0, expression: 'f04' },
|
||||
[Emotion.Awkward]: { group: 'Idle', index: 2, expression: 'f05' },
|
||||
[Emotion.Think]: { group: 'Idle', index: 0, expression: 'f06' },
|
||||
[Emotion.Question]: { group: 'Idle', index: 0, expression: 'f07' },
|
||||
[Emotion.Curious]: { group: 'Tap', index: 1, expression: 'f07' },
|
||||
};
|
||||
|
||||
export interface MotionManagerState {
|
||||
currentEmotion: Emotion;
|
||||
lastGroup: string | null;
|
||||
lastIndex: number;
|
||||
}
|
||||
|
||||
function initialState(): MotionManagerState {
|
||||
return {
|
||||
currentEmotion: Emotion.Neutral,
|
||||
lastGroup: null,
|
||||
lastIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmotionMotionManagerOptions {
|
||||
/** Per-emotion bindings. Defaults to `HARU_EMOTION_BINDINGS`. */
|
||||
bindings?: Record<Emotion, EmotionBinding>;
|
||||
/**
|
||||
* Preferred motion group for a named emotion (e.g. `'Happy'`, `'Sad'`).
|
||||
* If the pixi manager reports a group by this name, it's used in place
|
||||
* of the generic binding. Defaults to the AIRI convention from
|
||||
* `EMOTION_EmotionMotionName_value`.
|
||||
*/
|
||||
preferNamedGroups?: boolean;
|
||||
/** Priority passed to `startMotion`. Default 2 (NORMAL in pixi). */
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return value of {@link createEmotionMotionManager}.
|
||||
*/
|
||||
export interface EmotionMotionManager {
|
||||
setEmotion(emotion: Emotion): Promise<boolean>;
|
||||
getCurrentEmotion(): Emotion;
|
||||
/** Re-play the last picked motion (e.g. after model reload). */
|
||||
replay(): Promise<boolean>;
|
||||
/** Reset state to Neutral. Does NOT call the manager. */
|
||||
reset(): void;
|
||||
/** Expose internal state for tests. */
|
||||
debugState(): Readonly<MotionManagerState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an emotion-driven motion manager that wraps a
|
||||
* pixi-live2d-display `MotionManager`.
|
||||
*
|
||||
* The returned object is the minimum surface the Svelte component needs:
|
||||
* `setEmotion(e)` during user interaction, `getCurrentEmotion()` for
|
||||
* display, `replay()` after reload.
|
||||
*/
|
||||
export function createEmotionMotionManager(
|
||||
manager: MotionManagerLike,
|
||||
opts: EmotionMotionManagerOptions = {},
|
||||
): EmotionMotionManager {
|
||||
const bindings = opts.bindings ?? HARU_EMOTION_BINDINGS;
|
||||
const preferNamed = opts.preferNamedGroups ?? true;
|
||||
const priority = opts.priority ?? 2;
|
||||
const state: MotionManagerState = initialState();
|
||||
|
||||
async function setEmotion(emotion: Emotion): Promise<boolean> {
|
||||
const binding = pickBinding(manager, emotion, bindings, preferNamed);
|
||||
state.currentEmotion = emotion;
|
||||
state.lastGroup = binding.group;
|
||||
state.lastIndex = binding.index ?? 0;
|
||||
|
||||
const ok = await playMotion(manager, binding, priority);
|
||||
if (ok && binding.expression) {
|
||||
await applyExpression(manager, binding.expression);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
function replay(): Promise<boolean> {
|
||||
return setEmotion(state.currentEmotion);
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
state.currentEmotion = Emotion.Neutral;
|
||||
state.lastGroup = null;
|
||||
state.lastIndex = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
setEmotion,
|
||||
getCurrentEmotion: () => state.currentEmotion,
|
||||
replay,
|
||||
reset,
|
||||
debugState: () => state,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (kept small to honour the 30-line-per-function rule).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pickBinding(
|
||||
manager: MotionManagerLike,
|
||||
emotion: Emotion,
|
||||
bindings: Record<Emotion, EmotionBinding>,
|
||||
preferNamed: boolean,
|
||||
): EmotionBinding {
|
||||
const fallback = bindings[emotion];
|
||||
if (!preferNamed) return fallback;
|
||||
|
||||
const namedGroup = EMOTION_EmotionMotionName_value[emotion];
|
||||
if (namedGroup && managerHasGroup(manager, namedGroup)) {
|
||||
return { group: namedGroup, index: 0, expression: fallback?.expression };
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function managerHasGroup(manager: MotionManagerLike, group: string): boolean {
|
||||
const groups = manager.groups;
|
||||
if (!groups) return false;
|
||||
return groups.idle === group || groups.tap === group;
|
||||
}
|
||||
|
||||
async function playMotion(
|
||||
manager: MotionManagerLike,
|
||||
binding: EmotionBinding,
|
||||
priority: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await Promise.resolve(
|
||||
manager.startMotion(binding.group, binding.index ?? 0, priority),
|
||||
);
|
||||
return result !== false;
|
||||
} catch (err) {
|
||||
// Model may lack the requested group/index; fall back to Idle.
|
||||
try {
|
||||
await Promise.resolve(manager.startMotion('Idle', 0, priority));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applyExpression(
|
||||
manager: MotionManagerLike,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const em = manager.expressionManager;
|
||||
if (!em) return false;
|
||||
try {
|
||||
const r = await Promise.resolve(em.setExpression(name));
|
||||
return r !== false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
62
_ts_packages/packages/cortex-ui/src/lib/live2d/types.ts
Normal file
62
_ts_packages/packages/cortex-ui/src/lib/live2d/types.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Ported from AIRI (https://github.com/moeru-ai/airi), MIT License.
|
||||
// Original: packages/stage-ui-live2d/src/composables/live2d/motion-manager.ts (types)
|
||||
// Adapted: Vue Ref types removed; pure structural types only.
|
||||
|
||||
import type { Emotion } from './emotions';
|
||||
|
||||
/**
|
||||
* Minimal shape we need from the Cubism 4 core model at runtime.
|
||||
* We deliberately keep this abstract so the component can run against a
|
||||
* stub (tests) or the real pixi-live2d-display Cubism4InternalModel.coreModel.
|
||||
*/
|
||||
export interface CubismCoreModelLike {
|
||||
getParameterValueById(id: string): number;
|
||||
setParameterValueById(id: string, value: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the motion manager exposed by pixi-live2d-display.
|
||||
* Structural typing only; we never construct one of these ourselves.
|
||||
*/
|
||||
export interface MotionManagerLike {
|
||||
startMotion(group: string, index?: number, priority?: number): Promise<boolean> | boolean;
|
||||
stopAllMotions(): void;
|
||||
groups?: { idle?: string; tap?: string };
|
||||
state?: { currentGroup?: string | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the expression manager exposed by pixi-live2d-display.
|
||||
*/
|
||||
export interface ExpressionManagerLike {
|
||||
setExpression(name: string | number): Promise<boolean> | boolean;
|
||||
resetExpression?(): void;
|
||||
definitions?: Array<{ Name?: string; name?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the Live2DModel instance we wrap.
|
||||
* We use a `Live2DModelLike` interface so the Svelte component can be
|
||||
* tested with a tiny stub instead of requiring a real WebGL canvas.
|
||||
*/
|
||||
export interface Live2DModelLike {
|
||||
internalModel: {
|
||||
motionManager: MotionManagerLike & {
|
||||
expressionManager?: ExpressionManagerLike;
|
||||
};
|
||||
coreModel: CubismCoreModelLike;
|
||||
};
|
||||
on?(event: string, handler: () => void): void;
|
||||
destroy?(options?: { children?: boolean; texture?: boolean; baseTexture?: boolean }): void;
|
||||
}
|
||||
|
||||
export interface Live2DPetProps {
|
||||
/** Absolute or relative URL to the `*.model3.json` settings file. */
|
||||
modelPath: string;
|
||||
/** Current emotion; re-evaluated reactively. */
|
||||
mood?: Emotion;
|
||||
/** Width of the PIXI canvas in CSS pixels. Defaults to 256. */
|
||||
width?: number;
|
||||
/** Height of the PIXI canvas in CSS pixels. Defaults to 256. */
|
||||
height?: number;
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
import type { CortexConfig } from '../lib/config';
|
||||
import type { PetManifest } from '../lib/types';
|
||||
import { pet as fetch_pet } from '../lib/api';
|
||||
import Live2DPet from '../components/Live2DPet.svelte';
|
||||
import { moodToEmotion, type CortexMood } from '../lib/live2d/emotions';
|
||||
|
||||
interface Props {
|
||||
config: CortexConfig;
|
||||
|
|
@ -11,10 +13,15 @@
|
|||
|
||||
const { config, user_id }: Props = $props();
|
||||
|
||||
const KEY_RENDERER = 'kei-cortex-renderer';
|
||||
const DEFAULT_LIVE2D_MODEL =
|
||||
'./live2d-models/haru/haru_greeter_t03.model3.json';
|
||||
|
||||
let manifest = $state<PetManifest | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
let mood = $state<'idle' | 'happy' | 'think' | 'sleep'>('idle');
|
||||
let mood = $state<CortexMood>('idle');
|
||||
let renderer = $state<'sprite32' | 'live2d'>('sprite32');
|
||||
|
||||
const AVAILABLE = {
|
||||
cat: ['idle', 'happy', 'think', 'sleep'] as const,
|
||||
|
|
@ -23,7 +30,6 @@
|
|||
blob: ['idle'] as const,
|
||||
};
|
||||
|
||||
// Pick species from pet name first letter, defaulting to cat (most states available)
|
||||
function species_for(pet_name: string): 'cat' | 'dog' | 'owl' | 'blob' {
|
||||
const first = pet_name.trim().toLowerCase().charAt(0);
|
||||
if (first === 'd') return 'dog';
|
||||
|
|
@ -32,13 +38,32 @@
|
|||
return 'cat';
|
||||
}
|
||||
|
||||
function sprite_src(pet_name: string, m: typeof mood): string {
|
||||
function sprite_src(pet_name: string, m: CortexMood): string {
|
||||
const sp = species_for(pet_name);
|
||||
const states = AVAILABLE[sp] as readonly string[];
|
||||
const state = states.includes(m) ? m : 'idle';
|
||||
return `./sprites/32px/${sp}-${state}.png`;
|
||||
}
|
||||
|
||||
function load_renderer_pref(m: PetManifest | null): 'sprite32' | 'live2d' {
|
||||
// Preferred source of truth (future): `pet.toml` `renderer` field.
|
||||
const tomlField = (m?.meta as { renderer?: unknown } | undefined)?.renderer;
|
||||
if (tomlField === 'live2d' || tomlField === 'sprite32') return tomlField;
|
||||
// Fallback: localStorage set by Setup wizard.
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const stored = localStorage.getItem(KEY_RENDERER);
|
||||
if (stored === 'live2d' || stored === 'sprite32') return stored;
|
||||
}
|
||||
return 'sprite32';
|
||||
}
|
||||
|
||||
function toggle_renderer(): void {
|
||||
renderer = renderer === 'live2d' ? 'sprite32' : 'live2d';
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(KEY_RENDERER, renderer);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!user_id) {
|
||||
error = 'missing user_id in route';
|
||||
|
|
@ -48,6 +73,7 @@
|
|||
try {
|
||||
const res = await fetch_pet(config, user_id);
|
||||
manifest = res.pet;
|
||||
renderer = load_renderer_pref(manifest);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
|
|
@ -60,23 +86,39 @@
|
|||
|
||||
{#if manifest}
|
||||
<div class="pet-sprite-box">
|
||||
<img
|
||||
class="pet-sprite"
|
||||
src={sprite_src(manifest.identity.pet_name, mood)}
|
||||
alt="{manifest.identity.pet_name} ({mood})"
|
||||
width="128"
|
||||
height="128"
|
||||
/>
|
||||
{#if renderer === 'live2d'}
|
||||
<Live2DPet
|
||||
modelPath={DEFAULT_LIVE2D_MODEL}
|
||||
mood={moodToEmotion(mood)}
|
||||
width={256}
|
||||
height={256}
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
class="pet-sprite"
|
||||
src={sprite_src(manifest.identity.pet_name, mood)}
|
||||
alt="{manifest.identity.pet_name} ({mood})"
|
||||
width="128"
|
||||
height="128"
|
||||
/>
|
||||
{/if}
|
||||
<div class="pet-sprite-name">{manifest.identity.pet_name}</div>
|
||||
<div class="pet-sprite-moods">
|
||||
{#each AVAILABLE[species_for(manifest.identity.pet_name)] as m}
|
||||
<button
|
||||
class="mood-btn"
|
||||
class:active={mood === m}
|
||||
onclick={() => (mood = m as typeof mood)}
|
||||
onclick={() => (mood = m as CortexMood)}
|
||||
>{m}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
class="secondary renderer-toggle"
|
||||
onclick={toggle_renderer}
|
||||
title="Toggle between pixel sprite and Live2D renderer"
|
||||
>
|
||||
Switch to {renderer === 'live2d' ? 'pixel' : 'Live2D'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -109,3 +151,11 @@
|
|||
<button class="secondary" disabled>Tune (coming soon)</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.renderer-toggle {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,11 +7,21 @@
|
|||
|
||||
const { on_saved }: Props = $props();
|
||||
|
||||
const KEY_RENDERER = 'kei-cortex-renderer';
|
||||
|
||||
const existing = load_config();
|
||||
let daemon_url = $state(existing?.daemon_url ?? 'http://localhost:9797');
|
||||
let token = $state(existing?.token ?? '');
|
||||
let renderer = $state<'sprite32' | 'live2d'>(load_renderer());
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
function load_renderer(): 'sprite32' | 'live2d' {
|
||||
if (typeof localStorage === 'undefined') return 'sprite32';
|
||||
const stored = localStorage.getItem(KEY_RENDERER);
|
||||
if (stored === 'live2d' || stored === 'sprite32') return stored;
|
||||
return 'sprite32';
|
||||
}
|
||||
|
||||
function submit(event: Event): void {
|
||||
event.preventDefault();
|
||||
if (!token.trim()) {
|
||||
|
|
@ -19,6 +29,9 @@
|
|||
return;
|
||||
}
|
||||
save_config({ daemon_url: daemon_url.trim(), token: token.trim() });
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(KEY_RENDERER, renderer);
|
||||
}
|
||||
error = null;
|
||||
on_saved();
|
||||
window.location.hash = '#/';
|
||||
|
|
@ -44,6 +57,30 @@
|
|||
<label for="token">Bearer token</label>
|
||||
<input id="token" type="password" bind:value={token} required />
|
||||
|
||||
<fieldset class="renderer-group">
|
||||
<legend>Pet renderer</legend>
|
||||
<label class="radio-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="renderer"
|
||||
value="sprite32"
|
||||
checked={renderer === 'sprite32'}
|
||||
onchange={() => (renderer = 'sprite32')}
|
||||
/>
|
||||
<span>32px pixel sprite (default, fast)</span>
|
||||
</label>
|
||||
<label class="radio-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="renderer"
|
||||
value="live2d"
|
||||
checked={renderer === 'live2d'}
|
||||
onchange={() => (renderer = 'live2d')}
|
||||
/>
|
||||
<span>Live2D (animated, breathing + blinking)</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
|
@ -52,3 +89,29 @@
|
|||
<button type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.renderer-group {
|
||||
margin: 14px 0 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.renderer-group legend {
|
||||
padding: 0 6px;
|
||||
font-size: 13px;
|
||||
color: var(--muted, #666);
|
||||
}
|
||||
.radio-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 4px 0;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.radio-row input[type='radio'] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
Emotion,
|
||||
EMOTION_VALUES,
|
||||
EMOTION_EmotionMotionName_value,
|
||||
EMOTION_VRMExpressionName_value,
|
||||
EmotionNeutralMotionName,
|
||||
moodToEmotion,
|
||||
} from '../src/lib/live2d/emotions';
|
||||
|
||||
describe('emotions enum', () => {
|
||||
it('contains all 9 AIRI-canonical emotions', () => {
|
||||
expect(EMOTION_VALUES).toHaveLength(9);
|
||||
expect(EMOTION_VALUES).toEqual(
|
||||
expect.arrayContaining([
|
||||
Emotion.Happy,
|
||||
Emotion.Sad,
|
||||
Emotion.Angry,
|
||||
Emotion.Think,
|
||||
Emotion.Surprise,
|
||||
Emotion.Awkward,
|
||||
Emotion.Question,
|
||||
Emotion.Curious,
|
||||
Emotion.Neutral,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps every emotion to a non-empty motion-name string', () => {
|
||||
for (const e of EMOTION_VALUES) {
|
||||
const name = EMOTION_EmotionMotionName_value[e];
|
||||
expect(typeof name).toBe('string');
|
||||
expect(name.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('Neutral maps to Idle (AIRI-canonical group name)', () => {
|
||||
expect(EMOTION_EmotionMotionName_value[Emotion.Neutral]).toBe('Idle');
|
||||
expect(EmotionNeutralMotionName).toBe('Idle');
|
||||
});
|
||||
|
||||
it('VRM table leaves non-VRM-mappable emotions undefined', () => {
|
||||
expect(EMOTION_VRMExpressionName_value[Emotion.Think]).toBeUndefined();
|
||||
expect(EMOTION_VRMExpressionName_value[Emotion.Awkward]).toBeUndefined();
|
||||
expect(EMOTION_VRMExpressionName_value[Emotion.Question]).toBeUndefined();
|
||||
expect(EMOTION_VRMExpressionName_value[Emotion.Neutral]).toBeUndefined();
|
||||
expect(EMOTION_VRMExpressionName_value[Emotion.Happy]).toBe('happy');
|
||||
});
|
||||
|
||||
it('Curious falls back to surprised in VRM map (AIRI convention)', () => {
|
||||
expect(EMOTION_VRMExpressionName_value[Emotion.Curious]).toBe('surprised');
|
||||
});
|
||||
});
|
||||
|
||||
describe('moodToEmotion bridge', () => {
|
||||
it('maps cortex mood → AIRI emotion deterministically', () => {
|
||||
expect(moodToEmotion('idle')).toBe(Emotion.Neutral);
|
||||
expect(moodToEmotion('happy')).toBe(Emotion.Happy);
|
||||
expect(moodToEmotion('think')).toBe(Emotion.Think);
|
||||
expect(moodToEmotion('sleep')).toBe(Emotion.Neutral);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
createExpressionController,
|
||||
type ExpressionEntry,
|
||||
} from '../src/lib/live2d/expression-controller';
|
||||
import type { Live2DModelLike } from '../src/lib/live2d/types';
|
||||
|
||||
function makeStubModel() {
|
||||
const paramValues = new Map<string, number>();
|
||||
const getParameterValueById = vi.fn((id: string) => paramValues.get(id) ?? 0);
|
||||
const setParameterValueById = vi.fn((id: string, v: number) => {
|
||||
paramValues.set(id, v);
|
||||
});
|
||||
const setExpression = vi.fn(() => true);
|
||||
const resetExpression = vi.fn();
|
||||
const model: Live2DModelLike = {
|
||||
internalModel: {
|
||||
motionManager: {
|
||||
startMotion: vi.fn(() => true),
|
||||
stopAllMotions: vi.fn(),
|
||||
expressionManager: {
|
||||
setExpression,
|
||||
resetExpression,
|
||||
definitions: [{ Name: 'f00' }, { Name: 'f01' }],
|
||||
},
|
||||
},
|
||||
coreModel: { getParameterValueById, setParameterValueById },
|
||||
},
|
||||
};
|
||||
return { model, setExpression, resetExpression, paramValues, setParameterValueById };
|
||||
}
|
||||
|
||||
describe('expression-controller', () => {
|
||||
it('lists expression definitions from the manager', () => {
|
||||
const { model } = makeStubModel();
|
||||
const c = createExpressionController(model);
|
||||
expect(c.listAvailable()).toEqual(['f00', 'f01']);
|
||||
});
|
||||
|
||||
it('setActive records the active name and calls setExpression', () => {
|
||||
const { model, setExpression } = makeStubModel();
|
||||
const c = createExpressionController(model);
|
||||
expect(c.setActive('f01')).toBe(true);
|
||||
expect(c.debugActive()).toBe('f01');
|
||||
expect(setExpression).toHaveBeenCalledWith('f01');
|
||||
});
|
||||
|
||||
it('clear() invokes resetExpression and drops all overrides', () => {
|
||||
const { model, resetExpression } = makeStubModel();
|
||||
const c = createExpressionController(model);
|
||||
c.setActive('f00');
|
||||
c.registerOverride(sampleEntry('ParamAngleX', 'Overwrite', 10, 0));
|
||||
c.clear();
|
||||
expect(resetExpression).toHaveBeenCalled();
|
||||
expect(c.debugActive()).toBeNull();
|
||||
});
|
||||
|
||||
it('applyExpressions writes Add-blended value on top of modelDefault', () => {
|
||||
const { model, setParameterValueById } = makeStubModel();
|
||||
const c = createExpressionController(model);
|
||||
c.registerOverride(sampleEntry('ParamMouthForm', 'Add', 0.3, 0));
|
||||
c.applyExpressions(model.internalModel.coreModel);
|
||||
// Expected write: default (0) + currentValue (0.3) = 0.3
|
||||
expect(setParameterValueById).toHaveBeenCalledWith('ParamMouthForm', 0.3);
|
||||
});
|
||||
|
||||
it('applyExpressions resets param on active→inactive transition', () => {
|
||||
const { model, setParameterValueById } = makeStubModel();
|
||||
const c = createExpressionController(model);
|
||||
const entry = sampleEntry('ParamEyeLOpen', 'Multiply', 0.3, 1);
|
||||
c.registerOverride(entry);
|
||||
c.applyExpressions(model.internalModel.coreModel); // active
|
||||
// Now turn the entry into a noop (Multiply * 1 = noop)
|
||||
entry.currentValue = 1;
|
||||
c.applyExpressions(model.internalModel.coreModel); // should reset to 1 (default)
|
||||
const calls = setParameterValueById.mock.calls.filter(
|
||||
([id]) => id === 'ParamEyeLOpen',
|
||||
);
|
||||
// Last call on ParamEyeLOpen must be the reset-to-modelDefault (1).
|
||||
expect(calls.at(-1)?.[1]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
function sampleEntry(
|
||||
id: string,
|
||||
blend: ExpressionEntry['blend'],
|
||||
current: number,
|
||||
def: number,
|
||||
): ExpressionEntry {
|
||||
return {
|
||||
name: id,
|
||||
parameterId: id,
|
||||
blend,
|
||||
currentValue: current,
|
||||
modelDefault: def,
|
||||
targetValue: current,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Emotion } from '../src/lib/live2d/emotions';
|
||||
import {
|
||||
createEmotionMotionManager,
|
||||
HARU_EMOTION_BINDINGS,
|
||||
} from '../src/lib/live2d/motion-manager';
|
||||
import type { MotionManagerLike, ExpressionManagerLike } from '../src/lib/live2d/types';
|
||||
|
||||
function makeStub() {
|
||||
const calls: Array<{ group: string; index: number; priority: number }> = [];
|
||||
const expressionCalls: string[] = [];
|
||||
const expression: ExpressionManagerLike = {
|
||||
setExpression: vi.fn((name: string) => {
|
||||
expressionCalls.push(name);
|
||||
return true;
|
||||
}),
|
||||
resetExpression: vi.fn(),
|
||||
definitions: [
|
||||
{ Name: 'f00' },
|
||||
{ Name: 'f01' },
|
||||
{ Name: 'f02' },
|
||||
{ Name: 'f03' },
|
||||
{ Name: 'f04' },
|
||||
{ Name: 'f05' },
|
||||
{ Name: 'f06' },
|
||||
{ Name: 'f07' },
|
||||
],
|
||||
};
|
||||
const mgr: MotionManagerLike & { expressionManager: ExpressionManagerLike } = {
|
||||
startMotion: vi.fn((group: string, index = 0, priority = 0) => {
|
||||
calls.push({ group, index, priority });
|
||||
return true;
|
||||
}),
|
||||
stopAllMotions: vi.fn(),
|
||||
groups: { idle: 'Idle', tap: 'Tap' },
|
||||
state: { currentGroup: null },
|
||||
expressionManager: expression,
|
||||
};
|
||||
return { mgr, calls, expressionCalls };
|
||||
}
|
||||
|
||||
describe('motion-manager state machine', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('starts in Neutral with no prior calls', () => {
|
||||
const { mgr } = makeStub();
|
||||
const m = createEmotionMotionManager(mgr);
|
||||
expect(m.getCurrentEmotion()).toBe(Emotion.Neutral);
|
||||
expect(m.debugState().lastGroup).toBeNull();
|
||||
});
|
||||
|
||||
it('transitions idle → happy → idle, calling startMotion each step', async () => {
|
||||
const { mgr, calls, expressionCalls } = makeStub();
|
||||
const m = createEmotionMotionManager(mgr);
|
||||
|
||||
await m.setEmotion(Emotion.Happy);
|
||||
expect(m.getCurrentEmotion()).toBe(Emotion.Happy);
|
||||
const happyBinding = HARU_EMOTION_BINDINGS[Emotion.Happy];
|
||||
// Haru has no 'Happy' group so binding falls back to 'Tap'.
|
||||
expect(calls.at(-1)?.group).toBe(happyBinding.group);
|
||||
expect(expressionCalls.at(-1)).toBe(happyBinding.expression);
|
||||
|
||||
await m.setEmotion(Emotion.Neutral);
|
||||
expect(m.getCurrentEmotion()).toBe(Emotion.Neutral);
|
||||
expect(calls.at(-1)?.group).toBe('Idle');
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('replay() re-invokes startMotion with the last emotion', async () => {
|
||||
const { mgr, calls } = makeStub();
|
||||
const m = createEmotionMotionManager(mgr);
|
||||
await m.setEmotion(Emotion.Surprise);
|
||||
const before = calls.length;
|
||||
await m.replay();
|
||||
expect(calls.length).toBe(before + 1);
|
||||
expect(calls.at(-1)?.group).toBe(calls.at(-2)?.group);
|
||||
});
|
||||
|
||||
it('reset() clears state without touching the manager', () => {
|
||||
const { mgr } = makeStub();
|
||||
const m = createEmotionMotionManager(mgr);
|
||||
m.reset();
|
||||
expect(m.getCurrentEmotion()).toBe(Emotion.Neutral);
|
||||
expect(mgr.startMotion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses named group when model exposes matching group name', async () => {
|
||||
const { mgr, calls } = makeStub();
|
||||
// Pretend this model has a dedicated "Happy" group.
|
||||
mgr.groups = { idle: 'Idle', tap: 'Happy' };
|
||||
const m = createEmotionMotionManager(mgr);
|
||||
await m.setEmotion(Emotion.Happy);
|
||||
expect(calls.at(-1)?.group).toBe('Happy');
|
||||
});
|
||||
|
||||
it('falls back to Idle if startMotion throws', async () => {
|
||||
const calls: string[] = [];
|
||||
const mgr: MotionManagerLike = {
|
||||
startMotion: vi.fn((group: string) => {
|
||||
calls.push(group);
|
||||
if (group !== 'Idle') throw new Error('missing group');
|
||||
return true;
|
||||
}),
|
||||
stopAllMotions: vi.fn(),
|
||||
groups: { idle: 'Idle' },
|
||||
};
|
||||
const m = createEmotionMotionManager(mgr, { preferNamedGroups: false });
|
||||
await m.setEmotion(Emotion.Angry);
|
||||
expect(calls).toContain('Idle');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, cleanup } from '@testing-library/svelte';
|
||||
import Live2DPet from '../src/components/Live2DPet.svelte';
|
||||
import { Emotion } from '../src/lib/live2d/emotions';
|
||||
|
||||
// Stub pixi-live2d-display/cubism4: Live2DModel.from() returns a fake model
|
||||
// with the structural shape the motion manager needs.
|
||||
vi.mock('pixi-live2d-display/cubism4', () => {
|
||||
const fakeModel = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
scale: { set: vi.fn() },
|
||||
internalModel: {
|
||||
motionManager: {
|
||||
startMotion: vi.fn(() => true),
|
||||
stopAllMotions: vi.fn(),
|
||||
groups: { idle: 'Idle', tap: 'Tap' },
|
||||
state: { currentGroup: null },
|
||||
expressionManager: {
|
||||
setExpression: vi.fn(() => true),
|
||||
resetExpression: vi.fn(),
|
||||
definitions: [],
|
||||
},
|
||||
},
|
||||
coreModel: {
|
||||
getParameterValueById: vi.fn(() => 0),
|
||||
setParameterValueById: vi.fn(),
|
||||
},
|
||||
},
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
return {
|
||||
Live2DModel: {
|
||||
from: vi.fn(async () => fakeModel),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Stub pixi.js Application — no WebGL in jsdom.
|
||||
vi.mock('pixi.js', () => ({
|
||||
Application: vi.fn().mockImplementation(() => ({
|
||||
stage: { addChild: vi.fn() },
|
||||
destroy: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Live2DPet component', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('mounts without throwing and renders a canvas element', () => {
|
||||
const { container } = render(Live2DPet, {
|
||||
props: {
|
||||
modelPath: './live2d-models/haru/haru_greeter_t03.model3.json',
|
||||
mood: Emotion.Neutral,
|
||||
width: 128,
|
||||
height: 128,
|
||||
},
|
||||
});
|
||||
const canvas = container.querySelector('canvas.live2d-canvas');
|
||||
expect(canvas).not.toBeNull();
|
||||
// jsdom exposes no WebGL → component sets load_error and renders hint.
|
||||
// The canvas still mounts either way; that's the contract we need.
|
||||
const width = canvas?.getAttribute('width');
|
||||
expect(width).toBe('128');
|
||||
});
|
||||
|
||||
it('includes an accessible aria-label on the canvas', () => {
|
||||
const { container } = render(Live2DPet, {
|
||||
props: {
|
||||
modelPath: './live2d-models/haru/haru_greeter_t03.model3.json',
|
||||
mood: Emotion.Happy,
|
||||
},
|
||||
});
|
||||
const canvas = container.querySelector('canvas') as HTMLCanvasElement | null;
|
||||
expect(canvas?.getAttribute('aria-label')).toContain('happy');
|
||||
});
|
||||
|
||||
it('renders a graceful error hint when WebGL is unavailable (jsdom path)', async () => {
|
||||
const { container, findByRole } = render(Live2DPet, {
|
||||
props: {
|
||||
modelPath: './live2d-models/haru/haru_greeter_t03.model3.json',
|
||||
mood: Emotion.Neutral,
|
||||
width: 256,
|
||||
height: 256,
|
||||
},
|
||||
});
|
||||
const hint = await findByRole('status');
|
||||
expect(hint.textContent).toMatch(/WebGL/i);
|
||||
expect(container.querySelector('canvas')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,13 +2,19 @@ import { defineConfig } from 'vite';
|
|||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
plugins: [svelte({ hot: false })],
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
// vitest picks the browser-side Svelte entry, matching how @testing-library/svelte
|
||||
// mounts components in jsdom. Without this Svelte 5 resolves to the SSR build
|
||||
// and `mount()` throws `lifecycle_function_unavailable`.
|
||||
resolve: {
|
||||
conditions: ['browser'],
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
environmentOptions: {
|
||||
|
|
@ -18,5 +24,10 @@ export default defineConfig({
|
|||
},
|
||||
globals: false,
|
||||
include: ['tests/**/*.test.ts'],
|
||||
server: {
|
||||
deps: {
|
||||
inline: ['@testing-library/svelte'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue