cortico-world-ellen-voice 0.2.1 → 0.2.2
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/CHANGELOG.md +18 -5
- package/README.md +5 -2
- package/describe.json +32 -0
- package/package.json +1 -1
- package/src/figure-pack.ts +47 -6
- package/src/world.ts +16 -3
- package/template/figure.js +361 -0
- package/template/figure.json +603 -616
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.2.
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
- 修好形象包加载失败:0.2.0/0.2.1 装出来的包缺 `figure.js`(figure.json 的 `entry` 指向它),
|
|
6
|
+
Coopanion 一 import 就报错、静默退回默认角色。现在模板带上 `template/figure.js`,
|
|
7
|
+
安装时原样拷进包,并在写文件前校验入口文件存在(缺了直接报错,不再装出半个包)。
|
|
8
|
+
- 覆盖安装先整包删除再写:之前只写不删,上一版多出来的文件(含旧 figure.js、旧贴图)会留在包里。
|
|
9
|
+
- 拒绝内置形象 id(`coo`、`whale`):这类包会被内置形象盖住,永远选不中,现在直接报错。
|
|
10
|
+
- 模板元信息改成通用文案:不再把原作者的署名、角色介绍写进别人生成的包。
|
|
11
|
+
- 超长文字按 emoji 边界截断:之前硬切会把 emoji 切成孤立代理,合成后出现乱码方块。
|
|
12
|
+
- OneBot 连接被对端立刻断开时立即报错(之前要等满 30 秒才超时)。
|
|
13
|
+
- TTS 返回非音频 content-type 时直接报错(避免把「HTTP 200 的错误网页」当成语音)。
|
|
14
|
+
- `describe.json` 补上 `ellen_install_figure`,之前那里只有两个工具。
|
|
15
|
+
|
|
16
|
+
## 0.2.1
|
|
17
|
+
|
|
18
|
+
- PNG 解码补齐灰度+透明(colorType 4),之前这类截图会直接报「不支持的颜色类型 4」。
|
|
19
|
+
- 修正 0.2.0 教程里没提到的导出建议:Photoshop「导出为 PNG-24」或「另存为 PNG」都可,不要用 8 位索引色+透明。
|
|
20
|
+
|
|
8
21
|
## 0.2.0
|
|
9
22
|
|
|
10
23
|
- 新增 `ellen_install_figure`:把一张透明底 PNG 立绘装成 Coopanion 形象包(自动生成 figure.json、
|
package/README.md
CHANGED
|
@@ -23,13 +23,16 @@ TTS 代理可以是任何返回音频字节的 HTTP 服务:GPT-SoVITS 微调
|
|
|
23
23
|
- **ellen_install_figure** `{ path, id, name, overwrite? }`:把一张**透明底 PNG** 立绘装成形象包。
|
|
24
24
|
装出来的包放在 `data\home\companion\data\figures\<id>\`,装完在「装扮」页就能选。
|
|
25
25
|
做法是把图等比缩到 847×859 的画布,骨架贴图全部写成透明占位——所以是静态立绘,不会换姿势。
|
|
26
|
+
包里会带一份入口脚本 `figure.js`(模板 `template/figure.js`),Coopanion 靠它把这张图挂到骨架上;
|
|
27
|
+
少了它,形象会被判为加载失败、静默退回默认角色。`overwrite` 覆盖时会先把旧包整个删掉再写,
|
|
28
|
+
不会把上一版的文件留在里面。`coo`、`whale` 是内置形象的 id,装成同名包永远不会被选中,工具会直接拒绝。
|
|
26
29
|
|
|
27
30
|
语言缺省 `auto`,支持 `zh` / `ko` / `en` / `ja`。
|
|
28
31
|
|
|
29
32
|
## 关于形象
|
|
30
33
|
|
|
31
|
-
本扩展**不附带任何角色美术**。`template/` 里只有两份 JSON
|
|
32
|
-
|
|
34
|
+
本扩展**不附带任何角色美术**。`template/` 里只有两份 JSON、一份贴图清单和入口脚本 `figure.js`
|
|
35
|
+
(只画你给的那张图,本身不含任何画作),立绘由使用者自己提供(自绘、委托画师,或按所在地区的同人规则使用官方素材——后者请自行确认授权)。
|
|
33
36
|
|
|
34
37
|
白底图需要先抠成透明底再喂给 `ellen_install_figure`,工具不替你做抠图。
|
|
35
38
|
|
package/describe.json
CHANGED
|
@@ -57,6 +57,38 @@
|
|
|
57
57
|
"zh": "合成语音并直接以语音条发到 QQ 私聊或群。",
|
|
58
58
|
"en": "Synthesise speech and send it as a voice bar to QQ."
|
|
59
59
|
}
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "ellen_install_figure",
|
|
63
|
+
"description": "Install a transparent-background PNG as a Coopanion figure pack (writes figure.json, model.json, the texture placeholders and the entry script figure.js). The pack then shows up in Coopanion's dress-up list. The extension ships no character art: the image comes from the user, and it must be a transparent PNG — cut the background out first.",
|
|
64
|
+
"parameters": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"properties": {
|
|
67
|
+
"path": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"description": "Absolute path of the figure PNG."
|
|
70
|
+
},
|
|
71
|
+
"id": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "Pack id: lowercase letters and digits, for example ellen."
|
|
74
|
+
},
|
|
75
|
+
"name": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": "Display name, for example Ellen Joe."
|
|
78
|
+
},
|
|
79
|
+
"overwrite": {
|
|
80
|
+
"type": "boolean",
|
|
81
|
+
"description": "Replace the existing pack with the same id."
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"required": ["path", "id", "name"],
|
|
85
|
+
"additionalProperties": false
|
|
86
|
+
},
|
|
87
|
+
"tags": ["setup"],
|
|
88
|
+
"summary": {
|
|
89
|
+
"zh": "把一张透明底 PNG 立绘装成 Coopanion 形象包。",
|
|
90
|
+
"en": "Turns a transparent-background PNG into a Coopanion figure pack."
|
|
91
|
+
}
|
|
60
92
|
}
|
|
61
93
|
],
|
|
62
94
|
"events": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortico-world-ellen-voice",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ellen Joe voice world for Coopanion: speak through a local GPT-SoVITS service, deliver voice bars to QQ, and install a figure pack from your own PNG",
|
|
6
6
|
"author": "deepseek study",
|
package/src/figure-pack.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 形象包生成器:把用户给的一张透明底 PNG 变成 Coopanion 能读的 figure 包。
|
|
3
3
|
*
|
|
4
|
-
* 包的结构照抄本目录 template/(figure.json、model.json、textures.json
|
|
5
|
-
*
|
|
4
|
+
* 包的结构照抄本目录 template/(figure.json、model.json、textures.json 里列的骨架贴图名称,
|
|
5
|
+
* 以及 figure.json 的 entry 指名的入口脚本 figure.js),其中 tex/figure.png 换成用户那张图,
|
|
6
|
+
* 其余贴图写成 1×1 透明像素——骨架还在,但只看得见图本身。
|
|
6
7
|
* 扩展不附带任何角色美术,图由使用者自己提供。
|
|
7
8
|
*/
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
10
|
import { dirname, join, resolve } from 'node:path';
|
|
10
11
|
import { fileURLToPath } from 'node:url';
|
|
11
12
|
import { blankPng, decodePng, encodePng, fitIntoCanvas } from './png.ts';
|
|
12
13
|
|
|
13
14
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
|
|
16
|
+
/** Coopanion 自带、按 id 优先返回的内置形象:占用同名 id 装出来的包永远不会被显示。 */
|
|
17
|
+
const BUILTIN_IDS = new Set(['coo', 'whale']);
|
|
18
|
+
|
|
15
19
|
/** 骨架画布尺寸,与模板里的 model.view(268×272)× 3.16 像素单位对齐。 */
|
|
16
20
|
const CANVAS = { width: 847, height: 859 };
|
|
17
21
|
const THUMB = 144;
|
|
@@ -32,6 +36,8 @@ export interface InstallOptions {
|
|
|
32
36
|
export interface InstallResult {
|
|
33
37
|
dir: string;
|
|
34
38
|
files: number;
|
|
39
|
+
/** 写进包里的入口模块(figure.json 的 entry),通常是 figure.js。 */
|
|
40
|
+
entry: string;
|
|
35
41
|
canvas: { width: number; height: number };
|
|
36
42
|
sourceSize: { width: number; height: number };
|
|
37
43
|
}
|
|
@@ -44,7 +50,22 @@ function templateDir(): string {
|
|
|
44
50
|
for (const dir of candidates) {
|
|
45
51
|
if (existsSync(join(dir, 'figure.json'))) return dir;
|
|
46
52
|
}
|
|
47
|
-
throw new Error('扩展缺少 template/(figure.json、model.json、textures.json)');
|
|
53
|
+
throw new Error('扩展缺少 template/(figure.json、model.json、textures.json、figure.js)');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* figure.json 的入口模块:Coopanion 会 `import(entry)` 再取 `export` 指名的工厂函数。
|
|
58
|
+
* 这个文件必须真的写进包里——0.2.1 及之前漏了它,装出来的形象包一律加载失败
|
|
59
|
+
* (日志 `形象包没加载`、聊天里 `[figure] … 没能显示出来`),自动退回默认角色。
|
|
60
|
+
*/
|
|
61
|
+
function entryOf(figureJson: Record<string, unknown>): string {
|
|
62
|
+
const raw = figureJson.entry;
|
|
63
|
+
const entry = typeof raw === 'string' ? raw.trim() : '';
|
|
64
|
+
if (!entry) throw new Error('模板 figure.json 缺少 entry(应指向 figure.js)');
|
|
65
|
+
if (entry.startsWith('/') || entry.includes('..') || /^[a-z][a-z0-9+.-]*:/i.test(entry)) {
|
|
66
|
+
throw new Error(`模板 figure.json 的 entry 不是包内相对路径:${entry}`);
|
|
67
|
+
}
|
|
68
|
+
return entry;
|
|
48
69
|
}
|
|
49
70
|
|
|
50
71
|
function readJson<T>(path: string): T {
|
|
@@ -61,6 +82,9 @@ export function normalizeId(raw: string): string {
|
|
|
61
82
|
|
|
62
83
|
export function installFigure(options: InstallOptions): InstallResult {
|
|
63
84
|
const id = normalizeId(options.id);
|
|
85
|
+
if (BUILTIN_IDS.has(id)) {
|
|
86
|
+
throw new Error(`「${id}」是 Coopanion 内置形象的 id:装上去也会被内置的盖住、永远选不中,换一个 id 吧`);
|
|
87
|
+
}
|
|
64
88
|
const name = String(options.name ?? '').trim() || id;
|
|
65
89
|
const sourcePath = resolve(options.source);
|
|
66
90
|
if (!existsSync(sourcePath)) throw new Error(`找不到图片:${sourcePath}`);
|
|
@@ -70,8 +94,12 @@ export function installFigure(options: InstallOptions): InstallResult {
|
|
|
70
94
|
const thumb = fitIntoCanvas(sourceImage, THUMB, THUMB, 0.92);
|
|
71
95
|
|
|
72
96
|
const packDir = join(options.figuresDir, id);
|
|
73
|
-
if (existsSync(packDir)
|
|
74
|
-
|
|
97
|
+
if (existsSync(packDir)) {
|
|
98
|
+
if (!options.overwrite) {
|
|
99
|
+
throw new Error(`形象包已存在:${packDir}(overwrite=true 可覆盖)`);
|
|
100
|
+
}
|
|
101
|
+
// 覆盖要整包重来:只写不删会把上一版多出来的文件(含旧 figure.js)留在包里
|
|
102
|
+
rmSync(packDir, { recursive: true, force: true });
|
|
75
103
|
}
|
|
76
104
|
|
|
77
105
|
const tpl = templateDir();
|
|
@@ -79,6 +107,13 @@ export function installFigure(options: InstallOptions): InstallResult {
|
|
|
79
107
|
const modelJson = readJson<Record<string, unknown>>(join(tpl, 'model.json'));
|
|
80
108
|
const textures = readJson<string[]>(join(tpl, 'textures.json'));
|
|
81
109
|
|
|
110
|
+
// 入口模块先查一遍:模板缺 figure.js 时要在写文件之前就报错,别留下半个包
|
|
111
|
+
const entry = entryOf(figureJson);
|
|
112
|
+
const entrySource = join(tpl, entry);
|
|
113
|
+
if (!existsSync(entrySource)) {
|
|
114
|
+
throw new Error(`模板缺少入口模块 ${entry}(${entrySource}):装出来的形象包会加载失败`);
|
|
115
|
+
}
|
|
116
|
+
|
|
82
117
|
const thumbRel = `thumbs/${id}.png`;
|
|
83
118
|
figureJson.id = id;
|
|
84
119
|
figureJson.name = { zh: name, en: name };
|
|
@@ -114,6 +149,11 @@ export function installFigure(options: InstallOptions): InstallResult {
|
|
|
114
149
|
files += 1;
|
|
115
150
|
writeFileSync(join(packDir, thumbRel), encodePng(thumb));
|
|
116
151
|
files += 1;
|
|
152
|
+
// 入口模块原样拷进包:它是 Coopanion 真正要 import 的那份代码
|
|
153
|
+
const entryTarget = join(packDir, entry);
|
|
154
|
+
mkdirSync(dirname(entryTarget), { recursive: true });
|
|
155
|
+
writeFileSync(entryTarget, readFileSync(entrySource));
|
|
156
|
+
files += 1;
|
|
117
157
|
writeFileSync(join(packDir, 'figure.json'), JSON.stringify(figureJson, null, 2), 'utf8');
|
|
118
158
|
writeFileSync(join(packDir, 'model.json'), JSON.stringify(modelJson), 'utf8');
|
|
119
159
|
files += 2;
|
|
@@ -121,6 +161,7 @@ export function installFigure(options: InstallOptions): InstallResult {
|
|
|
121
161
|
return {
|
|
122
162
|
dir: packDir,
|
|
123
163
|
files,
|
|
164
|
+
entry,
|
|
124
165
|
canvas: CANVAS,
|
|
125
166
|
sourceSize: { width: sourceImage.width, height: sourceImage.height },
|
|
126
167
|
};
|
package/src/world.ts
CHANGED
|
@@ -222,7 +222,7 @@ export class EllenVoiceWorld implements World {
|
|
|
222
222
|
`[形象已安装] ${name}(id=${id})`,
|
|
223
223
|
`目录:${result.dir}`,
|
|
224
224
|
`源图:${result.sourceSize.width}×${result.sourceSize.height} → 画布 ${result.canvas.width}×${result.canvas.height}`,
|
|
225
|
-
`写入 ${result.files}
|
|
225
|
+
`写入 ${result.files} 个文件(含入口脚本 ${result.entry},骨架贴图全部为透明占位)。`,
|
|
226
226
|
'',
|
|
227
227
|
'下一步:打开「装扮」页刷新,形象列表里就会出现它;如果已经打开着,切走再切回以重新读盘。',
|
|
228
228
|
'说明:这是静态贴图,不会换姿势;人物大小沿用形象包里的画布比例。',
|
|
@@ -250,7 +250,11 @@ export class EllenVoiceWorld implements World {
|
|
|
250
250
|
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
251
251
|
if (!text) return '';
|
|
252
252
|
const max = Number.isFinite(this.cfg.maxChars) && this.cfg.maxChars > 0 ? this.cfg.maxChars : 200;
|
|
253
|
-
|
|
253
|
+
if (text.length <= max) return text;
|
|
254
|
+
let cut = text.slice(0, max);
|
|
255
|
+
// 按 UTF-16 硬切会把 emoji 切成孤立代理,URL 编码后变成 %EF%BF%BD(乱码方块)
|
|
256
|
+
if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1);
|
|
257
|
+
return cut;
|
|
254
258
|
}
|
|
255
259
|
|
|
256
260
|
private cleanLang(raw: unknown): string {
|
|
@@ -285,7 +289,11 @@ export class EllenVoiceWorld implements World {
|
|
|
285
289
|
}
|
|
286
290
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
287
291
|
if (bytes.byteLength < 1024) throw new Error('合成结果为空');
|
|
288
|
-
const mime = (res.headers.get('content-type') ?? 'audio/wav').split(';')[0].trim();
|
|
292
|
+
const mime = (res.headers.get('content-type') ?? 'audio/wav').split(';')[0].trim().toLowerCase();
|
|
293
|
+
// 地址填错时常常是「HTTP 200 + 一张网页」,只比大小会把它当语音存下来
|
|
294
|
+
if (!mime.startsWith('audio/') && mime !== 'application/octet-stream') {
|
|
295
|
+
throw new Error(`语音服务返回的不是音频(content-type: ${mime}),检查设置里的 ttsUrl`);
|
|
296
|
+
}
|
|
289
297
|
const value = { bytes, mime };
|
|
290
298
|
this.memo.set(key, value);
|
|
291
299
|
return value;
|
|
@@ -352,6 +360,11 @@ export class EllenVoiceWorld implements World {
|
|
|
352
360
|
ws.addEventListener('error', () => {
|
|
353
361
|
finish(() => reject(new Error('连不上 OneBot 协议端')));
|
|
354
362
|
});
|
|
363
|
+
// 协议端可能先把连接下一秒再断开(端口对但 token 错、或连的不是 OneBot)。
|
|
364
|
+
// 只听 error 的话这种情况不报错,要白等到 MAX_WAIT_MS 才超时,所以这里也接 close。
|
|
365
|
+
ws.addEventListener('close', () => {
|
|
366
|
+
finish(() => reject(new Error(`OneBot 连上后立刻断开:检查地址(${this.cfg.oneBotUrl})和 access_token 是否正确`)));
|
|
367
|
+
});
|
|
355
368
|
});
|
|
356
369
|
}
|
|
357
370
|
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 静态立绘形象包 —— figure.js 最小实现草案(Coopanion pet figure api 2)
|
|
3
|
+
*
|
|
4
|
+
* 用途:扩展只生成了 model.json + tex/figure.png(一张整图),没有生成 figure.js,
|
|
5
|
+
* 于是 figure-frame.js 里 `await import(m.entry)` 失败 → 形象加载失败、回退默认角色。
|
|
6
|
+
* 这份文件就是补上那个入口模块的最小版本:*只画一张静态图*,不依赖任何第三方库。
|
|
7
|
+
*
|
|
8
|
+
* 契约(见 web/figure-frame.js 第 9-42 行注释):
|
|
9
|
+
* - 本模块必须导出 figure.json 里 `export` 字段指名的函数(这里是 createWhaleBody)。
|
|
10
|
+
* - 宿主调用:`createWhaleBody(base, { model, scheme, kit, loadImage, asset, host })`
|
|
11
|
+
* base: URL 包的根目录 URL(已解析好的绝对地址)
|
|
12
|
+
* model: object model.json 解析后的对象(可能为 undefined,此时自己 fetch)
|
|
13
|
+
* scheme: string 初始配色 id(静态包只有一个,可忽略)
|
|
14
|
+
* kit: module web/kit/body.js 整个命名空间(含 createBody / createPet)
|
|
15
|
+
* loadImage(url): Promise<HTMLImageElement>
|
|
16
|
+
* asset(path): URL 把包内相对路径解析成绝对 URL
|
|
17
|
+
* host: { root, bounds(), emit(kind,detail), sound(name,kind,...args), start }
|
|
18
|
+
* - 返回值必须有 step(dt)、layout()、do(word) 三个函数,否则宿主抛 figure.missingMethod。
|
|
19
|
+
*
|
|
20
|
+
* 坐标系(kit 的约定,非常重要):
|
|
21
|
+
* 一个"logo 单位"方框,256×256;地面线 y = 256;x = 128 是身体中心;角色默认朝右(+x)。
|
|
22
|
+
* 图里所有元素都用 logo 单位画,宿主的 petG 已经带上了 translate/rotate/scale(1 单位 = S 像素、
|
|
23
|
+
* 朝左时用负 scale 镜像),所以这里只管在 0..256 的坐标系里画,不用关心像素。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/* ------------------------------------------------------------------ *
|
|
27
|
+
* 0. 小工具
|
|
28
|
+
* ------------------------------------------------------------------ */
|
|
29
|
+
|
|
30
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
31
|
+
const XLINKNS = 'http://www.w3.org/1999/xlink';
|
|
32
|
+
|
|
33
|
+
/** 真机对位微调:立绘在 logo 单位里的额外平移/缩放。默认不动,对不齐时只改这里。 */
|
|
34
|
+
const TWEAK = { dx: 0, dy: 0, scale: 1 };
|
|
35
|
+
|
|
36
|
+
/** 只有有限数字才用,否则退回默认值(防止 NaN 把整张图画没) */
|
|
37
|
+
const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
|
38
|
+
|
|
39
|
+
/** 校验一个 [a,b,c,d] 形状的数组 */
|
|
40
|
+
const quad = (v) => (Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n)) ? v : null);
|
|
41
|
+
|
|
42
|
+
/** 宿主没给 loadImage 时的兜底实现(正常情况下 figure-frame.js 一定会给) */
|
|
43
|
+
function defaultLoadImage(url) {
|
|
44
|
+
return new Promise((ok, bad) => {
|
|
45
|
+
const im = new Image();
|
|
46
|
+
im.crossOrigin = 'anonymous';
|
|
47
|
+
im.onload = () => ok(im);
|
|
48
|
+
im.onerror = () => bad(new Error('image failed: ' + url));
|
|
49
|
+
im.src = String(url);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** model 可能是对象,也可能是相对/绝对 URL 字符串;都没有就自己去包根目录取 model.json */
|
|
54
|
+
async function resolveModel(base, given) {
|
|
55
|
+
if (given && typeof given === 'object') return given;
|
|
56
|
+
try {
|
|
57
|
+
const url = typeof given === 'string' ? new URL(given, base) : new URL('model.json', base);
|
|
58
|
+
const res = await fetch(url);
|
|
59
|
+
return await res.json();
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 从 model.json 里求出「整张立绘应该铺进 logo 单位坐标系里的哪个矩形」。
|
|
67
|
+
* 优先级:
|
|
68
|
+
* 1) model.parts 里 id/tex 为 'figure' 的那个 part 的 box=[x,y,w,h](静态构建脚本写入的,最准)
|
|
69
|
+
* 2) model.view:模板里是 [-12,-8,268,272],按 [x,y,w,h] 读(与 part.box 同一种读法;
|
|
70
|
+
* rig.js 里把它当 [x0,y0,x1,y1] 用,差 12 个单位,见 report.md「未验证」)
|
|
71
|
+
* 3) 硬编码模板默认值
|
|
72
|
+
*/
|
|
73
|
+
function artRect(model) {
|
|
74
|
+
const parts = Array.isArray(model?.parts) ? model.parts : [];
|
|
75
|
+
const part = parts.find((p) => p && (p.id === 'figure' || p.tex === 'figure'));
|
|
76
|
+
const box = quad(part?.box);
|
|
77
|
+
if (box) return { x: box[0], y: box[1], w: box[2], h: box[3] };
|
|
78
|
+
const view = quad(model?.view);
|
|
79
|
+
if (view) return { x: view[0], y: view[1], w: view[2], h: view[3] };
|
|
80
|
+
return { x: -12, y: -8, w: 268, h: 272 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* ------------------------------------------------------------------ *
|
|
84
|
+
* 1. createWhaleFigure:返回 kit 认识的那个 "figure"(只负责画)
|
|
85
|
+
*
|
|
86
|
+
* 这是给 opts.kit.createBody(host, { figure }) 用的"画笔"对象。
|
|
87
|
+
* kit 的可选钩子(web/kit/body.js 第 119-132 行):
|
|
88
|
+
* draw(petG, face, frame) 必填:把元素画进 petG(logo 空间,每帧调用)
|
|
89
|
+
* extent [x0,y0,x1,y1]:包围盒,宿主拿它算 layout().box(每帧读,也可是 getter)
|
|
90
|
+
* hits [[x,y,r],...]:点击热区,宿主拿它算 layout().hit
|
|
91
|
+
* anchors { gaze, tear, z, hearts, bubble }:特效挂点,会覆盖 kit 的默认值
|
|
92
|
+
* colors.z 睡觉 z 的颜色
|
|
93
|
+
* setSkin(skin) 换皮肤时通知
|
|
94
|
+
* dispose() 卸载
|
|
95
|
+
* ------------------------------------------------------------------ */
|
|
96
|
+
|
|
97
|
+
export async function createWhaleFigure(base = new URL('./', import.meta.url), opts = {}) {
|
|
98
|
+
const model = await resolveModel(base, opts.model);
|
|
99
|
+
const asset = opts.asset ?? ((p) => new URL(p, base));
|
|
100
|
+
const load = typeof opts.loadImage === 'function' ? opts.loadImage : defaultLoadImage;
|
|
101
|
+
|
|
102
|
+
const rect = artRect(model);
|
|
103
|
+
const url = String(asset('tex/figure.png'));
|
|
104
|
+
|
|
105
|
+
// 先真正加载一次:贴图缺失/路径错时在 ready 之前就抛错,
|
|
106
|
+
// 宿主会回报 figure.imageFailed 一类的错误,比"画不出东西"好排查得多。
|
|
107
|
+
const img = await load(url);
|
|
108
|
+
|
|
109
|
+
// 静态包只有一个配色;取 model.json 里的 accent 当 z 的颜色
|
|
110
|
+
const schemes = Array.isArray(model?.schemes) ? model.schemes : [];
|
|
111
|
+
const accent = typeof schemes[0]?.accent === 'string' ? schemes[0].accent : '#C8102E';
|
|
112
|
+
|
|
113
|
+
let hostEl = null; // 当前挂载的 petG
|
|
114
|
+
let rootG = null; // 平移/呼吸用的外层 <g>
|
|
115
|
+
let artG = null; // 装 <image> 的 <g>(承载 TWEAK)
|
|
116
|
+
|
|
117
|
+
/** 把 <image> 挂进 petG;换宿主或元素被清掉时重建(elen 的实现里同样会 petG.textContent='') */
|
|
118
|
+
function mount(petG) {
|
|
119
|
+
petG.textContent = '';
|
|
120
|
+
rootG = document.createElementNS(SVGNS, 'g');
|
|
121
|
+
artG = document.createElementNS(SVGNS, 'g');
|
|
122
|
+
const im = document.createElementNS(SVGNS, 'image');
|
|
123
|
+
im.setAttribute('x', String(rect.x));
|
|
124
|
+
im.setAttribute('y', String(rect.y));
|
|
125
|
+
im.setAttribute('width', String(rect.w));
|
|
126
|
+
im.setAttribute('height', String(rect.h));
|
|
127
|
+
// 贴图是被切成固定画布再拉满这个矩形的,所以直接拉伸(与 rig.js 的行为一致)
|
|
128
|
+
im.setAttribute('preserveAspectRatio', 'none');
|
|
129
|
+
im.setAttribute('href', url);
|
|
130
|
+
im.setAttributeNS(XLINKNS, 'xlink:href', url); // 老一点的内核兜底
|
|
131
|
+
artG.appendChild(im);
|
|
132
|
+
rootG.appendChild(artG);
|
|
133
|
+
petG.appendChild(rootG);
|
|
134
|
+
hostEl = petG;
|
|
135
|
+
applyArtTransform();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** TWEAK 需要在 rect 基础上做以脚底为基准的缩放,单独算一个 transform 贴上 */
|
|
139
|
+
function applyArtTransform() {
|
|
140
|
+
if (!artG) return;
|
|
141
|
+
const s = num(TWEAK.scale, 1);
|
|
142
|
+
const cx = rect.x + rect.w / 2;
|
|
143
|
+
const by = rect.y + rect.h; // 以图片底边为缩放基准
|
|
144
|
+
const dx = num(TWEAK.dx, 0);
|
|
145
|
+
const dy = num(TWEAK.dy, 0);
|
|
146
|
+
if (s === 1 && dx === 0 && dy === 0) {
|
|
147
|
+
artG.removeAttribute('transform');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
artG.setAttribute('transform', `translate(${dx} ${dy}) translate(${cx} ${by}) scale(${s}) translate(${-cx} ${-by})`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 每帧被 kit 调用一次(顺序:先 step 改状态,再 render 调 draw)。
|
|
155
|
+
* 静态图只做两件小事:行走/奔跑时轻微上下浮,其余保持不动。
|
|
156
|
+
* 注意:不要在这里重建元素,也不要把 petG 整个清空(会打掉 kit 自己塞进去的东西)。
|
|
157
|
+
*/
|
|
158
|
+
function draw(petG, _face, frame) {
|
|
159
|
+
if (hostEl !== petG || !rootG || !rootG.isConnected) mount(petG);
|
|
160
|
+
const mode = frame?.mode ?? 'idle';
|
|
161
|
+
const t = num(frame?.t, 0);
|
|
162
|
+
// 走路时的细小起伏,幅度只有 2 个 logo 单位(≈ 0.8 px @S=0.42),不破坏"静态"观感
|
|
163
|
+
const bob = mode === 'walk' || mode === 'run' ? Math.sin(t * 9) * 2 : 0;
|
|
164
|
+
rootG.setAttribute('transform', `translate(0 ${bob.toFixed(2)})`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 包围盒:就是这张图覆盖的矩形(裁剪过后宿主会拿它当 hover/halo 的框)
|
|
168
|
+
const extent = [rect.x, rect.y, rect.x + rect.w, rect.y + rect.h];
|
|
169
|
+
|
|
170
|
+
// 点击热区:图片中下部一个圆(身体),比默认的 [[128,128,108]] 更贴合
|
|
171
|
+
const hitCx = rect.x + rect.w / 2;
|
|
172
|
+
const hitCy = rect.y + rect.h * 0.58;
|
|
173
|
+
const hits = [[hitCx, hitCy, Math.min(rect.w, rect.h) * 0.36]];
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
draw,
|
|
177
|
+
|
|
178
|
+
/** 睡眠 z / 音符的颜色(kit 用 figure.colors.z) */
|
|
179
|
+
get colors() {
|
|
180
|
+
return { z: accent };
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/** 特效挂点:按矩形比例估算,脸部在偏上位置 */
|
|
184
|
+
get anchors() {
|
|
185
|
+
const cx = rect.x + rect.w / 2;
|
|
186
|
+
return {
|
|
187
|
+
gaze: [cx, rect.y + rect.h * 0.42],
|
|
188
|
+
tear: [cx + rect.w * 0.16, rect.y + rect.h * 0.5],
|
|
189
|
+
z: [rect.x + rect.w * 0.74, rect.y + rect.h * 0.06],
|
|
190
|
+
hearts: [rect.x + rect.w * 0.3, rect.y + rect.h * 0.55, rect.w * 0.16],
|
|
191
|
+
bubble: [cx, rect.y + rect.h * 0.04],
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
get extent() {
|
|
196
|
+
return extent;
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
get hits() {
|
|
200
|
+
return hits;
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
/** 静态包只有一张图:任何配色 id 都直接成功返回(宿主 await 它,且会读 body.z) */
|
|
204
|
+
async setScheme(_id, _o) {
|
|
205
|
+
return true;
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
/** 换皮肤对静态图没有影响,留空即可 */
|
|
209
|
+
setSkin(_skin) {},
|
|
210
|
+
|
|
211
|
+
dispose() {
|
|
212
|
+
hostEl = null;
|
|
213
|
+
rootG = null;
|
|
214
|
+
artG = null;
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
// 以下两个字段不是契约要求的,但方便调试/他人理解
|
|
218
|
+
model,
|
|
219
|
+
art: { url, rect, natural: { w: img.naturalWidth, h: img.naturalHeight } },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* 便于直接以 createWhaleBody 的语义复用 */
|
|
224
|
+
export const createWhaleFigureStatic = createWhaleFigure;
|
|
225
|
+
|
|
226
|
+
/* ------------------------------------------------------------------ *
|
|
227
|
+
* 2. createWhaleBody:figure.json 的 "export" 指向这里
|
|
228
|
+
*
|
|
229
|
+
* 正常路径:拿 kit.createBody 包一层 —— step/layout/do/words/walk/pointer/... 全部由 kit 提供,
|
|
230
|
+
* 我们只贡献"怎么画一张图"。这样坐、睡、走、摸头、说话口型、地板、拖拽都免费获得。
|
|
231
|
+
* ------------------------------------------------------------------ */
|
|
232
|
+
|
|
233
|
+
export async function createWhaleBody(base, opts) {
|
|
234
|
+
const kit = opts?.kit;
|
|
235
|
+
if (kit && typeof kit.createBody === 'function' && opts?.host?.root) {
|
|
236
|
+
const figure = await createWhaleFigure(base, opts);
|
|
237
|
+
// 只传 figure:不传 words/moves,manifest 的 vocab 由 kit 内置的 38 个词覆盖
|
|
238
|
+
return kit.createBody(opts.host, { figure });
|
|
239
|
+
}
|
|
240
|
+
// 兜底:宿主没给 kit(或 kit 版本对不上)时,自己实现最小三件套
|
|
241
|
+
return createStaticBody(base, opts);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* ------------------------------------------------------------------ *
|
|
245
|
+
* 3. 兜底实现:完全没有 kit 时的最小 step/layout/do
|
|
246
|
+
*
|
|
247
|
+
* 只在 opts.kit.createBody 缺失时走到这里。行为刻意做得很笨:
|
|
248
|
+
* 站着不动、不会走、不会坐,只保证宿主能拿到合法的 layout。
|
|
249
|
+
* 坐标系换算:stage 像素 = 脚底基准
|
|
250
|
+
* 屏幕x = 身体x + (logo x - 128) * S
|
|
251
|
+
* 屏幕y = floorY + (logo y - 256) * S
|
|
252
|
+
* ------------------------------------------------------------------ */
|
|
253
|
+
|
|
254
|
+
async function createStaticBody(base, opts) {
|
|
255
|
+
const host = opts?.host ?? {};
|
|
256
|
+
const root = host.root;
|
|
257
|
+
if (!root) throw new Error('static figure: host.root missing');
|
|
258
|
+
|
|
259
|
+
const model = await resolveModel(base, opts.model);
|
|
260
|
+
const asset = opts.asset ?? ((p) => new URL(p, base));
|
|
261
|
+
const load = typeof opts.loadImage === 'function' ? opts.loadImage : defaultLoadImage;
|
|
262
|
+
const rect = artRect(model);
|
|
263
|
+
const url = String(asset('tex/figure.png'));
|
|
264
|
+
await load(url);
|
|
265
|
+
|
|
266
|
+
const doc = root.ownerDocument;
|
|
267
|
+
const svg = doc.createElementNS(SVGNS, 'svg');
|
|
268
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
269
|
+
svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none';
|
|
270
|
+
const g = doc.createElementNS(SVGNS, 'g');
|
|
271
|
+
const im = doc.createElementNS(SVGNS, 'image');
|
|
272
|
+
im.setAttribute('x', String(rect.x));
|
|
273
|
+
im.setAttribute('y', String(rect.y));
|
|
274
|
+
im.setAttribute('width', String(rect.w));
|
|
275
|
+
im.setAttribute('height', String(rect.h));
|
|
276
|
+
im.setAttribute('preserveAspectRatio', 'none');
|
|
277
|
+
im.setAttribute('href', url);
|
|
278
|
+
im.setAttributeNS(XLINKNS, 'xlink:href', url);
|
|
279
|
+
g.appendChild(im);
|
|
280
|
+
svg.appendChild(g);
|
|
281
|
+
root.appendChild(svg);
|
|
282
|
+
|
|
283
|
+
const bounds = () => (typeof host.bounds === 'function' ? host.bounds() : { W: 0, H: 0, floorY: 0, S: 0.42 });
|
|
284
|
+
const start = host.start ?? {};
|
|
285
|
+
const st = {
|
|
286
|
+
x: num(start.x, bounds().W / 2), // 身体中心的 stage x
|
|
287
|
+
facing: start.facing === -1 ? -1 : 1,
|
|
288
|
+
mode: 'idle',
|
|
289
|
+
t: 0,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const toStageX = (lx) => st.x + (lx - 128) * bounds().S;
|
|
293
|
+
const toStageY = (ly) => bounds().floorY + (ly - 256) * bounds().S;
|
|
294
|
+
|
|
295
|
+
function paint() {
|
|
296
|
+
const b = bounds();
|
|
297
|
+
const S = num(b.S, 0.42);
|
|
298
|
+
const bob = 0;
|
|
299
|
+
g.setAttribute('transform', `translate(${st.x.toFixed(2)} ${(b.floorY + bob).toFixed(2)}) scale(${(S * st.facing).toFixed(5)} ${S.toFixed(5)}) translate(-128 -256)`);
|
|
300
|
+
svg.setAttribute('width', String(b.W));
|
|
301
|
+
svg.setAttribute('height', String(b.H));
|
|
302
|
+
svg.setAttribute('viewBox', `0 0 ${b.W} ${b.H}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function layout() {
|
|
306
|
+
const S = num(bounds().S, 0.42);
|
|
307
|
+
const x0 = toStageX(rect.x);
|
|
308
|
+
const y0 = toStageY(rect.y);
|
|
309
|
+
const box = { x: x0, y: y0, w: rect.w * S, h: rect.h * S };
|
|
310
|
+
return {
|
|
311
|
+
x: st.x,
|
|
312
|
+
facing: st.facing,
|
|
313
|
+
mode: st.mode,
|
|
314
|
+
busy: false,
|
|
315
|
+
moving: false,
|
|
316
|
+
pressing: false,
|
|
317
|
+
cursor: '',
|
|
318
|
+
box,
|
|
319
|
+
hit: [{ x: toStageX(rect.x + rect.w / 2), y: toStageY(rect.y + rect.h * 0.58), r: Math.min(rect.w, rect.h) * 0.36 * S }],
|
|
320
|
+
bubble: { x: toStageX(rect.x + rect.w / 2), y: y0 },
|
|
321
|
+
side: { x: st.x, y: toStageY(256), reach: 104 * S },
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
paint();
|
|
326
|
+
return {
|
|
327
|
+
step(dt) {
|
|
328
|
+
st.t += num(dt, 0);
|
|
329
|
+
paint();
|
|
330
|
+
},
|
|
331
|
+
layout,
|
|
332
|
+
/**
|
|
333
|
+
* 只认识 turn;其余单词安静地返回 true,避免宿主以为它"忙"。
|
|
334
|
+
* 这里刻意**不**返回 words:宿主没收到 words 时按 manifest 的 vocab 全信,
|
|
335
|
+
* 如果只声明 ['turn'],World 会警告"词表里有身体不认得的词"。
|
|
336
|
+
*/
|
|
337
|
+
do(word) {
|
|
338
|
+
if (word === 'turn') st.facing = -st.facing;
|
|
339
|
+
return true;
|
|
340
|
+
},
|
|
341
|
+
resize() {
|
|
342
|
+
paint();
|
|
343
|
+
},
|
|
344
|
+
async setScheme() {
|
|
345
|
+
return true;
|
|
346
|
+
},
|
|
347
|
+
get z() {
|
|
348
|
+
const schemes = Array.isArray(model?.schemes) ? model.schemes : [];
|
|
349
|
+
return typeof schemes[0]?.accent === 'string' ? schemes[0].accent : null;
|
|
350
|
+
},
|
|
351
|
+
dispose() {
|
|
352
|
+
svg.remove();
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* ------------------------------------------------------------------ *
|
|
358
|
+
* 4. 默认导出:有些加载器会尝试 mod.default,顺手给它一个同样的工厂
|
|
359
|
+
* ------------------------------------------------------------------ */
|
|
360
|
+
|
|
361
|
+
export default createWhaleBody;
|