dsh-pet-live2d 2.3.1 → 2.3.3
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 +99 -0
- package/README.md +16 -1
- package/lib/client.js +246 -45
- package/lib/index.js +183 -6
- package/package.json +1 -1
- package/pets/ds-whale-girl/pet.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,104 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 2.3.3
|
|
4
|
+
|
|
5
|
+
### 修:互动反应补上**内置默认值** —— 宠物没声明那三组也演得出来
|
|
6
|
+
|
|
7
|
+
「摸头 / 摸尾巴 / 转圈」演什么,之前**只**认宠物 `pet.json` 里的 `patReactions` /
|
|
8
|
+
`tailReactions` / `spinReactions`,插件代码里一个默认值都没有。于是**自己做的宠物**
|
|
9
|
+
(最小 `pet.json` 里根本没有这三个键)上:
|
|
10
|
+
|
|
11
|
+
| 互动 | 以前 | 现在(没声明时) |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| 摸头 | 只有台词,**不演重锤出击** | 重锤出击 / 问号 / 星星眼 随机一个 |
|
|
14
|
+
| 摸尾巴 | 只有台词 | 吐魂 / 问号 |
|
|
15
|
+
| 转圈转晕 | 演不出来 | 晕晕 |
|
|
16
|
+
|
|
17
|
+
兜底顺序和台词、摸鱼池是同一套:**用户改过的 <- 宠物声明的 <- 内置默认**。
|
|
18
|
+
宠物自己声明了就一直用宠物那份(这只鲸鱼娘一字未变),只是"没写"不再等于"没反应"。
|
|
19
|
+
|
|
20
|
+
设置页「互动」卡里那三组候选现在显示的就是**有效那一组**(和运行时会演的完全一致),
|
|
21
|
+
行尾会标明来历:`(宠物默认)` / `(内置默认)` / `(已改过)` —— 看到的是内置值时
|
|
22
|
+
你就知道"改宠物也能改这一组"。
|
|
23
|
+
|
|
24
|
+
### 修:随包宠物的默认值**终于能到老用户手里**(原来装过一次就再也不会更新)
|
|
25
|
+
|
|
26
|
+
`installBundledPets()` 以前是"目标目录存在就跳过"。插件目录随 `dsh plugin add` / npm
|
|
27
|
+
更新,用户的宠物目录 `%DSH_HOME%\pets\<id>\` **不会** —— 于是宠物**自己的默认值永远停在
|
|
28
|
+
装它的那一天**:2.3.0 往 `pet.json` 里加的那批(台词 / 互动反应候选 / 摸鱼槽位 /
|
|
29
|
+
`asking`·`helper`·`queued` 三个相位、自拍等三个槽位)一个都没到过桌面。
|
|
30
|
+
|
|
31
|
+
症状和你这次报的一模一样,而且**看起来完全像插件的 bug**:互动只有台词不演反应、
|
|
32
|
+
相位台词一句不弹、装扮里少三个槽位、`会话相位` 列表里少三个状态。
|
|
33
|
+
|
|
34
|
+
现在按**内容**判定,三条:
|
|
35
|
+
|
|
36
|
+
| 宠物目录里那份 | 行为 |
|
|
37
|
+
|---|---|
|
|
38
|
+
| 不存在 | 装一份,并把版本与内容指纹记进 `pets/.synced.json` |
|
|
39
|
+
| 就是我们装下去的那一份(指纹逐字节相同) | 整份更新到随包版本 |
|
|
40
|
+
| 你自己改过(指纹对不上) | **一个字都不碰**(日志里会说一句"跳过更新") |
|
|
41
|
+
|
|
42
|
+
冷启动(同步记录出现之前装的副本,也就是所有老装机)靠一张**历史内容指纹表**认人:
|
|
43
|
+
与历史上任何一次随包分发逐字节相同才升级 —— 改过名字、槽位、任何一个字,哈希就不同,
|
|
44
|
+
所以"用户自己改过的宠物永远优先"这条没有被削弱。
|
|
45
|
+
|
|
46
|
+
升级会在宿主日志里说清楚:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
[live2d-pet] ds-whale-girl:宠物默认值更新到 1.1.0(原先 1.0.1)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
> 顺带把随包宠物的版本抬到 **1.1.0**。升级判定只看内容指纹、不看版本号 ——
|
|
53
|
+
> 同一个版本号下补默认值也能发出去(这次就是)。
|
|
54
|
+
|
|
55
|
+
### 修:互动候选「改不动」的隐患
|
|
56
|
+
|
|
57
|
+
`PHASE_OVERRIDES`(设置覆盖的存放处)初始化时**漏了 `interactions` 这个键**,而
|
|
58
|
+
`applyOverride()` 是直接往这个对象上写的 —— 也就是说第一次点候选时覆盖会被静默丢掉。
|
|
59
|
+
读的地方都写了 `?.`,所以它一直"读得出来、写不进去",只有正面写它的设置界面会踩到。
|
|
60
|
+
|
|
61
|
+
现在五个键(`phases` / `fidget` / `relations` / `interactions` / `lines`)都在初始化时给出来,
|
|
62
|
+
并加了一条断言盯着"点一下候选 → 覆盖真的落盘"。
|
|
63
|
+
|
|
64
|
+
### 诊断:新增 `reactionDiagnostics()`
|
|
65
|
+
|
|
66
|
+
三组候选各自的**来历**(`user` / `pet` / `builtin` / `none`)、内置默认值、以及最终生效值。
|
|
67
|
+
"点了她没反应"这类问题一眼就能分出是"用户覆盖成了空数组"、"宠物没声明"还是"连兜底都没了"
|
|
68
|
+
—— 只看 `effectiveReactions()` 是分不出来的。
|
|
69
|
+
|
|
70
|
+
## 2.3.2
|
|
71
|
+
|
|
72
|
+
### 修:摸尾巴又摸不到了(这次是真的)
|
|
73
|
+
|
|
74
|
+
用户报的:"现在又摸不到尾巴了。"(还发了一张圈出尾巴位置的截图,帮了大忙 —— 尾巴是
|
|
75
|
+
**右侧那只鲸鱼尾鳍**,不是翅膀。)
|
|
76
|
+
|
|
77
|
+
**真正的根因**:判定每块 drawable 时用的是**映射时算好并存下来的下标**(`entry.index`),
|
|
78
|
+
而不是**调用时按 id 现查的下标**。这两者只要有一处对不上,取到的顶点就是别人的 ——
|
|
79
|
+
表现是"每块单独测都能命中,聚合起来一个都不中"。
|
|
80
|
+
|
|
81
|
+
单块测(`drawableProbe`)之所以一直正常,正是因为它**每次都按 id 现查下标**。
|
|
82
|
+
现在聚合路径也这么做了:`hitsPartsGeometry` 在循环里用 `im.getDrawableIndex(entry.id)`
|
|
83
|
+
重新解一次,解不出来才退回缓存值。
|
|
84
|
+
|
|
85
|
+
修好后同一网格上的读数:`hitsTail` 从 **0 → 509** 命中(48×48),头部 1113、重叠 689
|
|
86
|
+
—— 重叠仍然存在(翅膀的几何本来就伸到头顶上方),所以**摸头优先于摸尾巴**那条继续保留:
|
|
87
|
+
点头部给摸头,尾巴自己的区域照样摸得到。
|
|
88
|
+
|
|
89
|
+
### 一段该记下来的教训:坏掉的诊断比没有诊断更糟
|
|
90
|
+
|
|
91
|
+
这轮我查了很久,很大一部分时间是被**自己写的诊断函数**骗了:`partHitCounts` 里调了
|
|
92
|
+
`api.drawableProbe(...)`,而 `api` 是控制器**返回出去**的对象,控制器内部根本看不到它
|
|
93
|
+
—— 每次调用都抛 `api is not defined`,被外面的 try/catch 吞掉,于是 `hitsAll`/`hitsVisible`
|
|
94
|
+
**恒为 0**。外面看到的却是"这个部件完全没有几何",一条**看起来像产品坏了**的读数。
|
|
95
|
+
|
|
96
|
+
代价:我据此排除了三个正确假设(透明度过滤、下标空间、包围盒预筛),还改坏过一次
|
|
97
|
+
(把顶点整条换成 core 层,连头部判定都变成 0 命中,已还原)。
|
|
98
|
+
|
|
99
|
+
教训:**诊断函数自己也要先自证**(拿一个已知能命中的样本去测它),读数与事实矛盾时
|
|
100
|
+
先怀疑测量工具,再怀疑被测对象。
|
|
101
|
+
|
|
3
102
|
## 2.3.1
|
|
4
103
|
|
|
5
104
|
### 修:摸头会出摸尾巴的效果
|
package/README.md
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
给 **DSH**(DeepSeek Harness)Web GUI 用的 **Live2D 桌宠插件**:一只可以拖动、跟着鼠标看、
|
|
4
4
|
点她会害羞、还会跟着会话状态换动作的桌宠。
|
|
5
5
|
|
|
6
|
-
自带 **DS鲸鱼娘**(8 组动作 + 44
|
|
6
|
+
自带 **DS鲸鱼娘**(8 组动作 + 44 个表情/道具),开箱即用。
|
|
7
|
+
|
|
8
|
+
> **宠物会跟着插件一起更新**:随包的那只(以及它的默认设置:台词、互动反应、
|
|
9
|
+
> 会话相位、摸鱼槽位)在插件升级时**自动同步**到 `%DSH_HOME%\pets\ds-whale-girl\`。
|
|
10
|
+
> 你自己改过的那份**不会被覆盖**(判定是内容指纹,改过就一个字都不碰),
|
|
11
|
+
> 想拿回随包版本就把目录删掉、重启 `dsh web`,它会重新装一份。更新日志见 [CHANGELOG.md](CHANGELOG.md)。
|
|
7
12
|
|
|
8
13
|

|
|
9
14
|
|
|
@@ -131,6 +136,11 @@ dsh plugin --profile web add "link:./dsh-pet-live2d/dsh-live2d-pet"
|
|
|
131
136
|
- 转圈看的是**累计转角**(不是位置绕了几圈),所以贴着角色转小圈、远远转大圈都算。
|
|
132
137
|
- 三个互动各自可开关,反应也能改(见下)。共用的三句台词在「气泡」设置里。
|
|
133
138
|
|
|
139
|
+
> 上表那三组是**默认反应**,来源分两层:宠物 `pet.json` 里声明了就用它的;**没声明就用
|
|
140
|
+
> 插件内置的这几个**(重锤出击 / 问号 / 星星眼、吐魂 / 问号、晕晕)—— 自己做的宠物不写
|
|
141
|
+
> 这三个键也有反应。设置页「互动」卡的行尾会标明当前用的是哪一层(宠物默认 / 内置默认 /
|
|
142
|
+
> 已改过),候选勾选本来就是最高优先级。
|
|
143
|
+
|
|
134
144
|
### 气泡:说什么、在哪、要不要
|
|
135
145
|
|
|
136
146
|
她说话时会弹一个小气泡(问候、点击、摸头、摸尾巴、转晕、归位,以及**每个会话相位一句**:
|
|
@@ -237,6 +247,11 @@ dsh plugin --profile web add "link:./dsh-pet-live2d/dsh-live2d-pet"
|
|
|
237
247
|
"looksByPhase": { "done": { "whale": "头顶鲸", "mood": "开心兴奋" } },
|
|
238
248
|
// 摸鱼默认盯哪些槽位(用户还能再加;不写就是"手/情绪/脸红/嘴/眼"那五个兜底)
|
|
239
249
|
"fidgetSlots": ["rhand", "lhand", "mood", "cheek", "mouth", "eyes", "selfie"],
|
|
250
|
+
// 互动反应候选(标签 = 动作的中文名或表情名)。**三个键都可以不写** ——
|
|
251
|
+
// 不写就用插件内置的:摸头 重锤出击/问号/星星眼、摸尾巴 吐魂/问号、转晕 晕晕
|
|
252
|
+
"patReactions": ["重锤出击", "问号", "星星眼"],
|
|
253
|
+
"tailReactions": ["吐魂", "问号"],
|
|
254
|
+
"spinReactions": ["晕晕"],
|
|
240
255
|
// 可选;模型自己表达不了的「作者意图」
|
|
241
256
|
"motionOptions": {
|
|
242
257
|
"OpenCase": { "hold": true },
|
package/lib/client.js
CHANGED
|
@@ -307,18 +307,40 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
307
307
|
*/
|
|
308
308
|
const drawableIndicesForParts = (parts) => {
|
|
309
309
|
const raw = model?.internalModel?.coreModel?._model;
|
|
310
|
+
const im = model?.internalModel;
|
|
310
311
|
const parent = raw?.drawables?.parentPartIndices;
|
|
311
312
|
const partIds = raw?.parts?.ids;
|
|
312
|
-
|
|
313
|
+
const coreIds = raw?.drawables?.ids;
|
|
314
|
+
if (parent === undefined || partIds === undefined || coreIds === undefined) return null;
|
|
313
315
|
const ids = Array.from(partIds).map(String);
|
|
314
316
|
if (ids.length === 0 || parts.length === 0) return null;
|
|
315
317
|
const wanted = new Set(parts);
|
|
318
|
+
const coreIndexById = new Map();
|
|
319
|
+
for (let i = 0; i < coreIds.length; i += 1) coreIndexById.set(String(coreIds[i]), i);
|
|
320
|
+
// **以包装层的顺序为主**:顶点就是按 `internalModel.getDrawableIDs()` 的顺序读的
|
|
321
|
+
// (单块 `drawableProbe` 走得通的正是这条路)。反过来"遍历原始表再翻译下标",
|
|
322
|
+
// 只要两套顺序有一处不一致,取到的顶点就是别的 drawable —— 表现就是"几何明明
|
|
323
|
+
// 又大又真,聚合判定一个都不中"。这里逐个 id 反查它属于哪个部件,不存在歧义。
|
|
324
|
+
const wrapperIds = typeof im?.getDrawableIDs === "function"
|
|
325
|
+
? Array.from(im.getDrawableIDs()).map(String)
|
|
326
|
+
: null;
|
|
316
327
|
const out = [];
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
328
|
+
if (wrapperIds !== null) {
|
|
329
|
+
for (let w = 0; w < wrapperIds.length; w += 1) {
|
|
330
|
+
const coreIndex = coreIndexById.get(wrapperIds[w]);
|
|
331
|
+
if (coreIndex === undefined) continue;
|
|
332
|
+
const partIndex = parent[coreIndex];
|
|
333
|
+
if (partIndex === undefined || partIndex < 0 || partIndex >= ids.length) continue;
|
|
334
|
+
if (!wanted.has(ids[partIndex])) continue;
|
|
335
|
+
out.push({ id: wrapperIds[w], index: w, coreIndex, part: partIndex });
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
for (let i = 0; i < parent.length; i += 1) {
|
|
339
|
+
const partIndex = parent[i];
|
|
340
|
+
if (partIndex < 0 || partIndex >= ids.length) continue;
|
|
341
|
+
if (!wanted.has(ids[partIndex])) continue;
|
|
342
|
+
out.push({ id: String(coreIds[i]), index: i, coreIndex: i, part: partIndex });
|
|
343
|
+
}
|
|
322
344
|
}
|
|
323
345
|
return out.length > 0 ? out : null;
|
|
324
346
|
};
|
|
@@ -359,25 +381,30 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
359
381
|
const hitsPartsGeometry = (indices, x, y) => {
|
|
360
382
|
const im = model?.internalModel;
|
|
361
383
|
if (im === undefined || im === null) return null;
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
384
|
+
// 顶点用**包装层**的(core 的 `getDrawableVertices` 不在同一个坐标空间里:整条换成
|
|
385
|
+
// core 之后连头部判定都变成 0 命中)。三角形索引只有 core 有;两套按**同一个下标**
|
|
386
|
+
// 取用时是自洽的(单块 `drawableProbe` 用同一组合能命中,已实测)。
|
|
387
|
+
const core = im.coreModel;
|
|
365
388
|
const verticesOf = typeof im.getDrawableVertices === "function"
|
|
366
389
|
? (index) => im.getDrawableVertices(index)
|
|
367
|
-
: (typeof
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
? (index) => im.getDrawableVertexIndices(index)
|
|
372
|
-
: (typeof im.coreModel?.getDrawableVertexIndices === "function"
|
|
373
|
-
? (index) => im.coreModel.getDrawableVertexIndices(index)
|
|
374
|
-
: null);
|
|
390
|
+
: (typeof core?.getDrawableVertices === "function" ? (index) => core.getDrawableVertices(index) : null);
|
|
391
|
+
const indicesOf = typeof core?.getDrawableVertexIndices === "function"
|
|
392
|
+
? (index) => core.getDrawableVertexIndices(index)
|
|
393
|
+
: (typeof im.getDrawableVertexIndices === "function" ? (index) => im.getDrawableVertexIndices(index) : null);
|
|
375
394
|
if (verticesOf === null || indicesOf === null) return null;
|
|
376
395
|
if (indices === null) return null;
|
|
377
396
|
let read = false;
|
|
378
397
|
try {
|
|
379
398
|
for (const entry of indices) {
|
|
380
|
-
|
|
399
|
+
// **调用时用 id 重新解一次下标**,别信缓存的 `entry.index`:
|
|
400
|
+
// 单块 `drawableProbe` 走得通、聚合路径走不通,两者剩下的唯一差别就是这个
|
|
401
|
+
// (它每次都用 id 现查,聚合路径用映射时算好并存下来的那份)。
|
|
402
|
+
let index = entry.index;
|
|
403
|
+
if (typeof entry.id === "string" && typeof im.getDrawableIndex === "function") {
|
|
404
|
+
const fresh = im.getDrawableIndex(entry.id);
|
|
405
|
+
if (fresh >= 0) index = fresh;
|
|
406
|
+
}
|
|
407
|
+
const verts = verticesOf(index);
|
|
381
408
|
if (verts === undefined || verts === null || verts.length < 6) continue;
|
|
382
409
|
read = true;
|
|
383
410
|
// 先用这个 drawable 的包围盒排除(绝大多数部件一眼就出局,不用扫三角形)。
|
|
@@ -2114,10 +2141,130 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2114
2141
|
box: maxX > minX ? { minX: Math.round(minX), maxX: Math.round(maxX), minY: Math.round(minY), maxY: Math.round(maxY) } : null,
|
|
2115
2142
|
hitsAll,
|
|
2116
2143
|
hitsVisible,
|
|
2144
|
+
// 每块报:id / 包装层下标 / 原始表下标。
|
|
2145
|
+
// **别再在这里调 `api.xxx`**:`api` 是控制器返回出去的那个对象,控制器内部
|
|
2146
|
+
// 看不到它 —— 调了就是 `api is not defined`,整个函数抛异常、所有读数变 0,
|
|
2147
|
+
// 而外面看起来像"这个部件完全没有几何"(我为此白查了三轮)。
|
|
2148
|
+
entries: entries.map((entry) => ({
|
|
2149
|
+
id: entry.id ?? null,
|
|
2150
|
+
index: entry.index,
|
|
2151
|
+
core: entry.coreIndex,
|
|
2152
|
+
part: entry.part,
|
|
2153
|
+
})),
|
|
2117
2154
|
});
|
|
2118
2155
|
}
|
|
2119
2156
|
return out;
|
|
2120
2157
|
},
|
|
2158
|
+
/**
|
|
2159
|
+
* Diagnostic: 全部 drawable 的 id / 所属部件名 / 顶点数 / 包围盒(模型空间)。
|
|
2160
|
+
*
|
|
2161
|
+
* 找"她身上真正在画尾巴的那一个"用这个:作者给**部件**起了中文名(尾巴翅膀/猫尾/
|
|
2162
|
+
* 大翅膀…),但 drawable 本身多半叫 `ArtMesh123`。按包围盒的位置就能认出来
|
|
2163
|
+
* (尾巴在身体下后方、翅膀在两侧)。
|
|
2164
|
+
*/
|
|
2165
|
+
drawableTable: () => {
|
|
2166
|
+
const im = model?.internalModel;
|
|
2167
|
+
const raw = im?.coreModel?._model;
|
|
2168
|
+
const ids = typeof im?.getDrawableIDs === "function" ? Array.from(im.getDrawableIDs()).map(String) : [];
|
|
2169
|
+
const partIds = raw?.parts?.ids === undefined ? [] : Array.from(raw.parts.ids).map(String);
|
|
2170
|
+
const parent = raw?.drawables?.parentPartIndices;
|
|
2171
|
+
const cdi3Names = MANIFEST.current?.partNames ?? {};
|
|
2172
|
+
return ids.map((id, index) => {
|
|
2173
|
+
const partIndex = parent === undefined ? -1 : parent[index];
|
|
2174
|
+
const partId = partIndex >= 0 && partIndex < partIds.length ? partIds[partIndex] : null;
|
|
2175
|
+
let minX = Infinity;
|
|
2176
|
+
let minY = Infinity;
|
|
2177
|
+
let maxX = -Infinity;
|
|
2178
|
+
let maxY = -Infinity;
|
|
2179
|
+
let vertexCount = 0;
|
|
2180
|
+
try {
|
|
2181
|
+
const verts = im?.getDrawableVertices?.(index);
|
|
2182
|
+
if (verts !== undefined && verts !== null) {
|
|
2183
|
+
vertexCount = Math.floor(verts.length / 2);
|
|
2184
|
+
for (let k = 0; k < verts.length; k += 2) {
|
|
2185
|
+
if (verts[k] < minX) minX = verts[k];
|
|
2186
|
+
if (verts[k] > maxX) maxX = verts[k];
|
|
2187
|
+
if (verts[k + 1] < minY) minY = verts[k + 1];
|
|
2188
|
+
if (verts[k + 1] > maxY) maxY = verts[k + 1];
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
} catch {
|
|
2192
|
+
/* 跳过 */
|
|
2193
|
+
}
|
|
2194
|
+
return {
|
|
2195
|
+
id,
|
|
2196
|
+
index,
|
|
2197
|
+
partId,
|
|
2198
|
+
partName: partId === null ? "(无部件)" : (cdi3Names[partId] ?? partId),
|
|
2199
|
+
vertexCount,
|
|
2200
|
+
box: maxX > minX ? { minX: Math.round(minX), maxX: Math.round(maxX), minY: Math.round(minY), maxY: Math.round(maxY) } : null,
|
|
2201
|
+
};
|
|
2202
|
+
});
|
|
2203
|
+
},
|
|
2204
|
+
/**
|
|
2205
|
+
* Diagnostic: 单看一个 drawable 的三角面判定为什么命中/不命中。
|
|
2206
|
+
*
|
|
2207
|
+
* 头部判定好用、尾巴判定一个都不中(而它的几何明明又大又真)——这种"同一段代码
|
|
2208
|
+
* 对不同 drawable 表现不同"的问题,只能把中间量摊开看:顶点数、索引数、索引范围、
|
|
2209
|
+
* 以及用**它自己的重心**去测的结果。
|
|
2210
|
+
*/
|
|
2211
|
+
drawableProbe: (id) => {
|
|
2212
|
+
const im = model?.internalModel;
|
|
2213
|
+
const index = typeof im?.getDrawableIndex === "function" ? im.getDrawableIndex(id) : -1;
|
|
2214
|
+
if (index < 0) return { id, found: false };
|
|
2215
|
+
const vertices = im?.getDrawableVertices?.(index);
|
|
2216
|
+
const indices = (typeof im?.getDrawableVertexIndices === "function"
|
|
2217
|
+
? im.getDrawableVertexIndices(index)
|
|
2218
|
+
: im?.coreModel?.getDrawableVertexIndices?.(index));
|
|
2219
|
+
const verts = vertices === undefined || vertices === null ? [] : Array.from(vertices);
|
|
2220
|
+
const idx = indices === undefined || indices === null ? [] : Array.from(indices);
|
|
2221
|
+
let minIndex = Infinity;
|
|
2222
|
+
let maxIndex = -Infinity;
|
|
2223
|
+
for (const value of idx) {
|
|
2224
|
+
if (value < minIndex) minIndex = value;
|
|
2225
|
+
if (value > maxIndex) maxIndex = value;
|
|
2226
|
+
}
|
|
2227
|
+
const vertexCount = Math.floor(verts.length / 2);
|
|
2228
|
+
// 用顶点的平均位置当查询点:它一定在几何内部(凸的情况下)。
|
|
2229
|
+
let sumX = 0;
|
|
2230
|
+
let sumY = 0;
|
|
2231
|
+
for (let i = 0; i < verts.length; i += 2) {
|
|
2232
|
+
sumX += verts[i];
|
|
2233
|
+
sumY += verts[i + 1];
|
|
2234
|
+
}
|
|
2235
|
+
const cx = vertexCount === 0 ? 0 : sumX / vertexCount;
|
|
2236
|
+
const cy = vertexCount === 0 ? 0 : sumY / vertexCount;
|
|
2237
|
+
let triangles = 0;
|
|
2238
|
+
let degenerate = 0;
|
|
2239
|
+
let contains = 0;
|
|
2240
|
+
for (let i = 0; i + 2 < idx.length; i += 3) {
|
|
2241
|
+
triangles += 1;
|
|
2242
|
+
const a = idx[i];
|
|
2243
|
+
const b = idx[i + 1];
|
|
2244
|
+
const c = idx[i + 2];
|
|
2245
|
+
if (a * 2 + 1 >= verts.length || b * 2 + 1 >= verts.length || c * 2 + 1 >= verts.length) continue;
|
|
2246
|
+
const area = (verts[b * 2] - verts[a * 2]) * (verts[c * 2 + 1] - verts[a * 2 + 1])
|
|
2247
|
+
- (verts[c * 2] - verts[a * 2]) * (verts[b * 2 + 1] - verts[a * 2 + 1]);
|
|
2248
|
+
if (Math.abs(area) < 1e-6) {
|
|
2249
|
+
degenerate += 1;
|
|
2250
|
+
continue;
|
|
2251
|
+
}
|
|
2252
|
+
if (pointInTriangle(cx, cy, verts, a, b, c)) contains += 1;
|
|
2253
|
+
}
|
|
2254
|
+
return {
|
|
2255
|
+
id,
|
|
2256
|
+
found: true,
|
|
2257
|
+
index,
|
|
2258
|
+
vertexCount,
|
|
2259
|
+
indexCount: idx.length,
|
|
2260
|
+
minIndex: Number.isFinite(minIndex) ? minIndex : null,
|
|
2261
|
+
maxIndex: Number.isFinite(maxIndex) ? maxIndex : null,
|
|
2262
|
+
triangles,
|
|
2263
|
+
degenerate,
|
|
2264
|
+
centroidHits: contains,
|
|
2265
|
+
centroid: { x: Math.round(cx), y: Math.round(cy) },
|
|
2266
|
+
};
|
|
2267
|
+
},
|
|
2121
2268
|
/**
|
|
2122
2269
|
* Play a motion with its declared policy applied; used by the panel, the
|
|
2123
2270
|
* tap reaction and the session-phase driver.
|
|
@@ -2694,8 +2841,11 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2694
2841
|
* fidgetNone 与各选项的 fidgetWeight);这里只放用户改过的部分,
|
|
2695
2842
|
* 键都按名字存,换宠物时对不上的覆盖会被忽略(和装扮存档同一套思路)。
|
|
2696
2843
|
*
|
|
2697
|
-
* phases: { <相位>: {
|
|
2698
|
-
* fidget: { <槽位>: {
|
|
2844
|
+
* phases: { <相位>: { pools: { <槽位>: 条目表 } } }
|
|
2845
|
+
* fidget: { <槽位>: { entries: 条目表 } }
|
|
2846
|
+
* relations: { "<槽位>:<标签>": { pairs, requires } }
|
|
2847
|
+
* interactions: { <patReactions|tailReactions|spinReactions>: [标签] }
|
|
2848
|
+
* lines: { <键>: [台词] , phase: { <相位>: 台词 } }
|
|
2699
2849
|
*/
|
|
2700
2850
|
/** 当前宠物的清单,给设置界面用(DSH 设置页拿不到组件里的 pet)。 */
|
|
2701
2851
|
const MANIFEST = { current: null };
|
|
@@ -2710,7 +2860,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2710
2860
|
* 姿势本身的性质 —— 在摸鱼表里改它,右键面板点同一个姿势、相位池里抽到它,
|
|
2711
2861
|
* 行为必须一致。所以它单独存一层,而不是挂在条目上。
|
|
2712
2862
|
*/
|
|
2713
|
-
|
|
2863
|
+
// 五个键都要在这里给出来(哪怕只是空壳):`applyOverride()` 是**原地往这个对象上写**的,
|
|
2864
|
+
// 少一个键就等于那条路径第一次改设置时静默丢掉 —— 互动反应候选原来就是这么漏的
|
|
2865
|
+
// (读的地方全写着 `?.`,所以"能读、写不进去",只有正面写它的界面会踩到)。
|
|
2866
|
+
const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {}, interactions: {}, lines: {} };
|
|
2714
2867
|
|
|
2715
2868
|
/** 槽位 id -> 中文标签(关系行显示「贴纸」而不是 `sticker`)。 */
|
|
2716
2869
|
const slotLabelOf = (slotId) =>
|
|
@@ -3428,28 +3581,60 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3428
3581
|
|
|
3429
3582
|
|
|
3430
3583
|
/**
|
|
3431
|
-
*
|
|
3584
|
+
* 三个互动**内置的**反应候选(用户覆盖 <- 宠物声明 <- 这里)。
|
|
3585
|
+
*
|
|
3586
|
+
* 这三组原来只在 pet.json 里声明,代码侧没有任何默认值(`HEAD_PAT_REACTIONS`
|
|
3587
|
+
* 是上一版实现留下的死常量,谁都没读它)—— 于是**没声明这三组的宠物,互动看着
|
|
3588
|
+
* 是好的、其实什么都不演**:摸头/摸尾巴只有台词,转圈连台词都不弹。而这恰恰是
|
|
3589
|
+
* 「做一只自己的宠物」最常见的状态(最小 pet.json 里根本没有这几个键)。
|
|
3432
3590
|
*
|
|
3433
|
-
*
|
|
3434
|
-
*
|
|
3591
|
+
* 标签按同一个顺序解读:先当**动作组的中文名**找(catalog.json 里作者起的名),
|
|
3592
|
+
* 找不到就当**表达式**闪一下。所以这里写的是用户看得懂的名字,不是 group / file id。
|
|
3435
3593
|
*/
|
|
3436
|
-
const
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3594
|
+
const DEFAULT_REACTIONS = {
|
|
3595
|
+
/** 摸头:随机一个,并且**故意不脸红** —— 脸红是以前每次摸头都加的东西,让每一下都一样。 */
|
|
3596
|
+
patReactions: ["重锤出击", "问号", "星星眼"],
|
|
3597
|
+
/** 摸尾巴:吐魂是表情,问号是符号表情。 */
|
|
3598
|
+
tailReactions: ["吐魂", "问号"],
|
|
3599
|
+
/** 转圈转晕:演「晕晕」。 */
|
|
3600
|
+
spinReactions: ["晕晕"],
|
|
3601
|
+
};
|
|
3602
|
+
|
|
3603
|
+
/** 一组反应候选的来历(诊断用,见 reactionSource)。 */
|
|
3604
|
+
const REACTION_KEYS = Object.keys(DEFAULT_REACTIONS);
|
|
3605
|
+
|
|
3606
|
+
/** 洗一遍候选:只留非空字符串(那份清单可能来自 localStorage,不可信)。 */
|
|
3607
|
+
const cleanReactionList = (list) =>
|
|
3608
|
+
(Array.isArray(list) ? list : []).filter((name) => typeof name === "string" && name !== "");
|
|
3441
3609
|
|
|
3442
3610
|
/**
|
|
3443
|
-
*
|
|
3444
|
-
* `spinReactions`)+ 用户覆盖(`PHASE_OVERRIDES.interactions`)。
|
|
3611
|
+
* 互动的反应候选:**用户覆盖 <- 宠物声明 <- 内置默认**。
|
|
3445
3612
|
*
|
|
3446
3613
|
* 条目是**标签**(「重锤出击」「星星眼」这种):先当动作组找(宠物会给动作起中文名),
|
|
3447
3614
|
* 找不到就当表达式名。这样设置界面里可以拿宠物自己的清单当候选,用户不用记 id。
|
|
3615
|
+
*
|
|
3616
|
+
* 这里的兜底顺序和台词(`linesNow`)、摸鱼池(`fidgetEntriesFor`)是同一套:
|
|
3617
|
+
* 用户没改过就用宠物声明的,宠物没声明就用内置的 —— **任何一层为空都不能变成
|
|
3618
|
+
* "这个互动没反应"**。
|
|
3448
3619
|
*/
|
|
3449
3620
|
const interactionReactions = (key) => {
|
|
3450
|
-
const mine = PHASE_OVERRIDES.interactions?.[key];
|
|
3451
|
-
|
|
3452
|
-
|
|
3621
|
+
const mine = cleanReactionList(PHASE_OVERRIDES.interactions?.[key]);
|
|
3622
|
+
if (mine.length > 0) return mine;
|
|
3623
|
+
const declared = cleanReactionList(MANIFEST.current?.[key]);
|
|
3624
|
+
if (declared.length > 0) return declared;
|
|
3625
|
+
return cleanReactionList(DEFAULT_REACTIONS[key]);
|
|
3626
|
+
};
|
|
3627
|
+
|
|
3628
|
+
/**
|
|
3629
|
+
* 这一组候选是从哪一层来的("没反应"这类问题要一眼看出是哪一层空了)。
|
|
3630
|
+
*
|
|
3631
|
+
* 诊断读口,不参与行为:`user` / `pet` / `builtin` / `none`。
|
|
3632
|
+
*/
|
|
3633
|
+
const reactionSource = (key) => {
|
|
3634
|
+
if (cleanReactionList(PHASE_OVERRIDES.interactions?.[key]).length > 0) return "user";
|
|
3635
|
+
if (cleanReactionList(MANIFEST.current?.[key]).length > 0) return "pet";
|
|
3636
|
+
if (cleanReactionList(DEFAULT_REACTIONS[key]).length > 0) return "builtin";
|
|
3637
|
+
return "none";
|
|
3453
3638
|
};
|
|
3454
3639
|
|
|
3455
3640
|
/** 跑一条反应(由点击/转圈触发)。 */
|
|
@@ -3841,6 +4026,17 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3841
4026
|
api.effectiveLines = () => linesNow();
|
|
3842
4027
|
/** 某个互动的**有效**反应候选(同样是与宠物默认合并后的结果)。 */
|
|
3843
4028
|
api.effectiveReactions = (key) => interactionReactions(key);
|
|
4029
|
+
/**
|
|
4030
|
+
* 诊断:三组候选各自的**来历**(user / pet / builtin / none)与内置默认值。
|
|
4031
|
+
*
|
|
4032
|
+
* "点了她没反应"要先分清是哪一层空了:用户覆盖成了空数组、宠物没声明、
|
|
4033
|
+
* 还是连内置默认都没有。只看 `effectiveReactions()` 是分不出来的。
|
|
4034
|
+
*/
|
|
4035
|
+
api.reactionDiagnostics = () => ({
|
|
4036
|
+
source: Object.fromEntries(REACTION_KEYS.map((key) => [key, reactionSource(key)])),
|
|
4037
|
+
builtin: Object.fromEntries(REACTION_KEYS.map((key) => [key, DEFAULT_REACTIONS[key].slice()])),
|
|
4038
|
+
effective: Object.fromEntries(REACTION_KEYS.map((key) => [key, interactionReactions(key)])),
|
|
4039
|
+
});
|
|
3844
4040
|
/** 台词的字段清单(测试用它确认"每一个字段都有输入框")。 */
|
|
3845
4041
|
api.lineFields = () => ({
|
|
3846
4042
|
plain: LINE_FIELDS.map((field) => field.key),
|
|
@@ -6271,12 +6467,9 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
6271
6467
|
*/
|
|
6272
6468
|
function InteractControls() {
|
|
6273
6469
|
useSettings();
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
const base = MANIFEST.current?.[key];
|
|
6278
|
-
return Array.isArray(base) ? base : [];
|
|
6279
|
-
};
|
|
6470
|
+
// 候选显示的是**有效那一组**(用户覆盖 <- 宠物声明 <- 内置默认),和运行时
|
|
6471
|
+
// `interactionReactions()` 同一个函数:界面上没勾的选项就不该演,勾着的必须真演。
|
|
6472
|
+
const listFor = (key) => interactionReactions(key);
|
|
6280
6473
|
const toggle = (key, label) => {
|
|
6281
6474
|
const next = listFor(key).slice();
|
|
6282
6475
|
const at = next.indexOf(label);
|
|
@@ -6299,8 +6492,16 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
6299
6492
|
h("span", { "data-row-label": "" }, label),
|
|
6300
6493
|
note === undefined ? null : h("span", { "data-note-inline": "" }, note),
|
|
6301
6494
|
);
|
|
6302
|
-
|
|
6303
|
-
|
|
6495
|
+
// 从哪里来的写在行尾:**这只宠物没声明这一组**时,用户看到的默认值其实是插件
|
|
6496
|
+
// 内置的那几个 —— 不说清楚他会以为"这是宠物自己的设定,改宠物就能改默认"。
|
|
6497
|
+
const sourceNote = (key) => {
|
|
6498
|
+
const source = reactionSource(key);
|
|
6499
|
+
if (source === "user") return "(已改过)";
|
|
6500
|
+
return source === "pet" ? "(宠物默认)" : "(内置默认)";
|
|
6501
|
+
};
|
|
6502
|
+
const reactionRow = (key, title) => h("div", { "data-reaction-set": key, ...(reactionSource(key) === "builtin" ? { "data-reaction-builtin": "" } : {}) },
|
|
6503
|
+
h("div", { "data-row-label": "" }, title,
|
|
6504
|
+
h("span", { "data-note-inline": "" }, sourceNote(key), "(随机一个)")),
|
|
6304
6505
|
h("div", { "data-chips": "" }, candidates.length === 0
|
|
6305
6506
|
? h("span", { "data-note-inline": "" }, "这只宠物没有可选的动作/表情")
|
|
6306
6507
|
: candidates.map((label) => {
|
|
@@ -6319,9 +6520,9 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
6319
6520
|
switchRow("tailEnabled", "摸尾巴有反应"),
|
|
6320
6521
|
switchRow("spinEnabled", "鼠标绕着转圈会晕"),
|
|
6321
6522
|
h(TuningControls, { key: "spin", group: "interact" }),
|
|
6322
|
-
reactionRow("patReactions", "摸头时演什么"
|
|
6323
|
-
reactionRow("tailReactions", "摸尾巴时演什么"
|
|
6324
|
-
reactionRow("spinReactions", "转晕时演什么"
|
|
6523
|
+
reactionRow("patReactions", "摸头时演什么"),
|
|
6524
|
+
reactionRow("tailReactions", "摸尾巴时演什么"),
|
|
6525
|
+
reactionRow("spinReactions", "转晕时演什么"),
|
|
6325
6526
|
);
|
|
6326
6527
|
}
|
|
6327
6528
|
|
package/lib/index.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { createHash } from 'node:crypto'
|
|
24
25
|
import { readFile } from 'node:fs/promises'
|
|
25
26
|
import { homedir } from 'node:os'
|
|
26
27
|
import { dirname, extname, join, sep } from 'node:path'
|
|
@@ -356,6 +357,31 @@ function readReactionList(value) {
|
|
|
356
357
|
return Array.isArray(value) ? value.filter((name) => typeof name === 'string' && name !== '') : []
|
|
357
358
|
}
|
|
358
359
|
|
|
360
|
+
/**
|
|
361
|
+
* 部件 id → 作者给的中文名(`Part46` → `脸蛋`)。
|
|
362
|
+
*
|
|
363
|
+
* 诊断用(`drawableTable()`):把 drawable 归到有意义的部件名下,才能一眼看出
|
|
364
|
+
* "她身上真正在画尾巴的是哪个 drawable" —— 光看 `ArtMesh123` 是认不出来的。
|
|
365
|
+
*/
|
|
366
|
+
function readPartNames(dir, modelPath) {
|
|
367
|
+
try {
|
|
368
|
+
const base = modelPath.replace(/\.model3\.json$/i, '')
|
|
369
|
+
const file = findCdi3(dir, base)
|
|
370
|
+
if (file === undefined) return {}
|
|
371
|
+
const parts = readJson(file)?.Parts
|
|
372
|
+
if (!Array.isArray(parts)) return {}
|
|
373
|
+
const out = {}
|
|
374
|
+
for (const part of parts) {
|
|
375
|
+
if (part === null || typeof part !== 'object') continue
|
|
376
|
+
if (typeof part.Id !== 'string' || typeof part.Name !== 'string') continue
|
|
377
|
+
if (part.Name !== '') out[part.Id] = part.Name
|
|
378
|
+
}
|
|
379
|
+
return out
|
|
380
|
+
} catch {
|
|
381
|
+
return {}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
359
385
|
/** Scan one pet directory into a catalog entry, or undefined when unusable. */
|
|
360
386
|
export function scanPet(dir, id) {
|
|
361
387
|
const manifestFile = join(dir, 'pet.json')
|
|
@@ -512,6 +538,8 @@ export function scanPet(dir, id) {
|
|
|
512
538
|
// 空数组 = 没 cdi3 或没挑到,浏览器半区退回旧行为。
|
|
513
539
|
headParts: readPartsMatching(dir, modelPath, HEAD_PART_NAME_HINTS),
|
|
514
540
|
tailParts: readPartsMatching(dir, modelPath, TAIL_PART_NAME_HINTS),
|
|
541
|
+
// 部件 id → 中文名(诊断用,见客户端 drawableTable())。
|
|
542
|
+
partNames: readPartNames(dir, modelPath),
|
|
515
543
|
// 台词:宠物自己的声音(问候 / 点击 / 摸头 / 摸尾巴 / 转晕 / 归位 / 每个相位)。
|
|
516
544
|
// 用户能在设置里逐条改,改过的存浏览器;这里是**默认值**。
|
|
517
545
|
lines: typeof block.lines === 'object' && block.lines !== null ? block.lines : {},
|
|
@@ -540,9 +568,89 @@ export function scanPet(dir, id) {
|
|
|
540
568
|
}
|
|
541
569
|
}
|
|
542
570
|
|
|
543
|
-
/** Build the live catalog from disk (fresh per request, so installs are picked up). */
|
|
544
571
|
/**
|
|
545
|
-
*
|
|
572
|
+
* 随包宠物 pet.json 的**历史内容指纹**(SHA-256),一份一行。
|
|
573
|
+
*
|
|
574
|
+
* 只为一件事存在:认出"用户宠物目录里那份是我们**某一次发出去的**原样副本"。
|
|
575
|
+
* 认出来才敢升级(`syncBundledPet`),认不出来就一个字都不碰。
|
|
576
|
+
*
|
|
577
|
+
* 为什么必须从 git 历史里现算、不能手抄:抄错一位的两种后果都是静默的 ——
|
|
578
|
+
* 要么永远不升级(老用户拿不到新的默认值),要么把用户自己改过的副本覆盖掉。
|
|
579
|
+
* 生成命令:`node tools/print-pet-hashes.mjs dsh-live2d-pet/pets/ds-whale-girl/pet.json --at <提交> --code`
|
|
580
|
+
* (每份内容取它**最后**出现的那个提交,否则第一次安装与仓库当时状态对不上)。
|
|
581
|
+
*
|
|
582
|
+
* 这是**冷启动**用的表:同步记录(`pets/.synced.json`)建立之后,判定就只看记录,
|
|
583
|
+
* 这个表只在"目标没有记录"时才参与 —— 也就是所有老装机,以及第一次装新版本的人。
|
|
584
|
+
* 因此每改一次随包宠物,都要把**改动之前那一版**的哈希补进来(改动之后那一版是目标,
|
|
585
|
+
* 不需要进表)。
|
|
586
|
+
*/
|
|
587
|
+
const BUNDLED_PET_HASHES = {
|
|
588
|
+
'ds-whale-girl': [
|
|
589
|
+
// ef7fb1a:模型第一次随包分发(1.0.x,17 个槽位、5 个相位)
|
|
590
|
+
'5421dee9d13ee60b91c341ab515bbef3fb8702a9791279c9a2f61124d4478b34',
|
|
591
|
+
// 3f18b195:面板跟随宿主主题 + 自拍可配置
|
|
592
|
+
'707049e3b8a05f3de5ddd49299bcb47fdd2ac1494209b7c710c1371be718bb27',
|
|
593
|
+
// 9b62d727:自拍独立成槽、氛围拆三个、摸鱼不再擦掉手选
|
|
594
|
+
'071a7bfe18b4882703a43f8a14dc7eded8d5aa08671dc3ecc4db220258e5be23',
|
|
595
|
+
// 6c6b61e1(2.0.0):把作者调好的值烘成宠物默认
|
|
596
|
+
'e931e44a6589932688d7507b0d5e62ba4c0f9618492ec7fe644885117e61f12a',
|
|
597
|
+
// 4b594ed3(2.2.0):多接三个会话状态
|
|
598
|
+
'88b86f32833882893e8ec10a9320bb4d9899a0211811d5a96544e4b97b804234',
|
|
599
|
+
// 25c4d285(2.3.0 前):台词 / 反应候选 / 摸鱼槽位
|
|
600
|
+
'18840cd90fe70aa68632c45f77b4af1254595b5f963d261d2cbf34f6fbcaf579',
|
|
601
|
+
// 1.0.1(2.3.0 ~ 2.3.2 随包的那份):内容与上面那条相同,只是换了插件版本号
|
|
602
|
+
'7c6cdb9c9f3d92636c388bffb3229c439cf01a65fe6a8a88499a8e4071f7884a',
|
|
603
|
+
],
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** 同步记录:哪个宠物是我们装的、装的是哪一版、装下去那份长什么样。 */
|
|
607
|
+
const SYNC_RECORD = '.synced.json'
|
|
608
|
+
|
|
609
|
+
/** 文件和 buffer 的 SHA-256(判定"这是不是我们发出去的原样")。 */
|
|
610
|
+
function sha256(bytes) {
|
|
611
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** 读同步记录;读不出来就当"没有"(老装机本来就没有)。 */
|
|
615
|
+
function readSyncRecord() {
|
|
616
|
+
const parsed = readJson(join(petsRoot(), SYNC_RECORD))
|
|
617
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function writeSyncRecord(record) {
|
|
621
|
+
mkdirSync(petsRoot(), { recursive: true })
|
|
622
|
+
writeFileSync(join(petsRoot(), SYNC_RECORD), JSON.stringify(record, null, 2) + '\n')
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** 点分数字比较:'1.1.0' > '1.0.1'。非数字段(rc / beta)只取数字部分。 */
|
|
626
|
+
export function compareVersions(a, b) {
|
|
627
|
+
const parts = (raw) => String(raw ?? '').split('.').map((piece) => {
|
|
628
|
+
const n = Number.parseInt(piece, 10)
|
|
629
|
+
return Number.isFinite(n) ? n : 0
|
|
630
|
+
})
|
|
631
|
+
const left = parts(a)
|
|
632
|
+
const right = parts(b)
|
|
633
|
+
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
|
634
|
+
const one = left[i] ?? 0
|
|
635
|
+
const two = right[i] ?? 0
|
|
636
|
+
if (one !== two) return one > two ? 1 : -1
|
|
637
|
+
}
|
|
638
|
+
return 0
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** 这份宠物目录里的 pet.json 是不是"某一次随包发出去的原样"。 */
|
|
642
|
+
function isPristineBundledManifest(petId, manifestPath, fingerprints) {
|
|
643
|
+
let hash
|
|
644
|
+
try {
|
|
645
|
+
hash = sha256(readFileSync(manifestPath))
|
|
646
|
+
} catch {
|
|
647
|
+
return false
|
|
648
|
+
}
|
|
649
|
+
return (fingerprints[petId] ?? []).includes(hash)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* 把随包宠物装进用户的宠物目录,并在**确实是我们装的那份**过期时更新它。
|
|
546
654
|
*
|
|
547
655
|
* 以前宠物是仓库里单独一份,装插件的人还得自己再拷一次 —— 从插件市场装完
|
|
548
656
|
* 「看不见宠物」就是这么来的(插件包里有代码,没有模型)。
|
|
@@ -551,31 +659,99 @@ export function scanPet(dir, id) {
|
|
|
551
659
|
* 已明确授权本项目转载与开源(见 pets/ds-whale-girl/LICENSE);Core 是 Live2D Inc.
|
|
552
660
|
* 的专有软件,只允许从官方渠道取,所以它走 CDN 兜底那条路。
|
|
553
661
|
*
|
|
554
|
-
*
|
|
662
|
+
* **升级这一半是补上的**(原来只有"目标不存在才拷"):插件目录会随
|
|
663
|
+
* `dsh plugin add` / npm 更新,用户的宠物目录不会 —— 于是宠物**自己的默认值
|
|
664
|
+
* 永远停在装它的那一天**。25c4d285 往 pet.json 里加的那批(台词 / 互动反应候选 /
|
|
665
|
+
* 摸鱼槽位 / 新增的三个相位)就这么一直没到过老用户的桌面,症状是"互动只有台词
|
|
666
|
+
* 不演反应、相位不弹台词、装扮少三个槽位",而且**看起来完全像插件的 bug**。
|
|
667
|
+
*
|
|
668
|
+
* 三条判定,缺一条都会变成"静默覆盖用户数据":
|
|
669
|
+
* 1. 目标不存在 → 装一份(并把版本与内容指纹记进 `pets/.synced.json`);
|
|
670
|
+
* 2. 目标就是我们上次装下去的那一份(**内容指纹**对得上)→ 整份更新;
|
|
671
|
+
* 3. 用户动过(指纹对不上)→ **一个字都不碰**。
|
|
672
|
+
*
|
|
673
|
+
* 第 2 条在冷启动时(记录出现之前装的副本)靠 `BUNDLED_PET_HASHES` 认人:
|
|
674
|
+
* 内容与历史上任何一次随包分发**逐字节相同**才升级。改过名字/槽位/任何东西的副本
|
|
675
|
+
* 哈希必然不同,所以"用户自己改过的宠物永远优先,绝不覆盖"这条没有被削弱。
|
|
555
676
|
*/
|
|
556
|
-
export function installBundledPets() {
|
|
677
|
+
export function installBundledPets(options = {}) {
|
|
557
678
|
const bundled = join(pluginRoot(), 'pets')
|
|
558
679
|
if (!existsSync(bundled)) return
|
|
680
|
+
// 冷启动指纹表可以被注入:测试要造一份"我们以前发过的旧副本",但测试**不能**
|
|
681
|
+
// 依赖生产表里的具体哈希(那样改一次表就得改测试,而且改错了也没人发现)。
|
|
682
|
+
const fingerprints = options.fingerprints ?? BUNDLED_PET_HASHES
|
|
559
683
|
let names = []
|
|
560
684
|
try {
|
|
561
685
|
names = readdirSync(bundled).filter((entry) => !entry.startsWith('.'))
|
|
562
686
|
} catch {
|
|
563
687
|
return
|
|
564
688
|
}
|
|
689
|
+
const record = readSyncRecord()
|
|
690
|
+
const notes = []
|
|
691
|
+
let dirty = false
|
|
565
692
|
for (const name of names) {
|
|
566
693
|
const source = join(bundled, name)
|
|
567
694
|
const target = join(petsRoot(), name)
|
|
568
695
|
try {
|
|
569
696
|
if (!statSync(source).isDirectory()) continue
|
|
570
|
-
|
|
571
|
-
|
|
697
|
+
const manifestPath = join(source, 'pet.json')
|
|
698
|
+
if (!existsSync(manifestPath)) continue
|
|
699
|
+
const version = String(readJson(manifestPath)?.version ?? '')
|
|
700
|
+
const targetManifest = join(target, 'pet.json')
|
|
701
|
+
|
|
702
|
+
if (!existsSync(target)) {
|
|
703
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
704
|
+
cpSync(source, target, { recursive: true })
|
|
705
|
+
record[name] = { version, hash: sha256(readFileSync(targetManifest)) }
|
|
706
|
+
dirty = true
|
|
707
|
+
continue
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const synced = record[name]
|
|
711
|
+
const same = existsSync(targetManifest) ? sha256(readFileSync(targetManifest)) : undefined
|
|
712
|
+
const ours = (synced !== undefined && synced.hash === same)
|
|
713
|
+
|| isPristineBundledManifest(name, targetManifest, fingerprints)
|
|
714
|
+
if (!ours) {
|
|
715
|
+
// 用户自己改过:不动它。**要说出来** —— 沉默地不升级,用户看到的是
|
|
716
|
+
// "插件更新了但没有任何变化",比报错更难查。
|
|
717
|
+
if (compareVersions(version, readJson(targetManifest)?.version) > 0) {
|
|
718
|
+
notes.push(name + ':宠物目录里这份被改过,跳过更新(随包版本 ' + version
|
|
719
|
+
+ ',本地 ' + String(readJson(targetManifest)?.version ?? '?') + ')')
|
|
720
|
+
}
|
|
721
|
+
continue
|
|
722
|
+
}
|
|
723
|
+
// **升级与否只看内容**,不看版本号:版本号是宠物自己声明的,随包那份在同一个
|
|
724
|
+
// 版本号下改过(补默认值就是这么发生的),只比版本号会让这次修订永远发不出去。
|
|
725
|
+
// 反过来,"版本相同 + 内容相同"在这里就短路了,不会做无谓的整份重写。
|
|
726
|
+
const bundledHash = sha256(readFileSync(manifestPath))
|
|
727
|
+
if (same === bundledHash) continue
|
|
728
|
+
|
|
729
|
+
// cpSync 是逐文件覆盖 —— 用户额外放进去的文件(自己的贴图、备注)不在随包
|
|
730
|
+
// 那份里,所以留得住。
|
|
731
|
+
// 「原先」那个版本号要**在覆盖之前**读:覆盖之后再读只会读到新的,日志就成了
|
|
732
|
+
// "更新到 1.1.0(原先 1.1.0)"(第一次跑就写成了这样)。
|
|
733
|
+
const was = String(readJson(targetManifest)?.version ?? '?')
|
|
572
734
|
cpSync(source, target, { recursive: true })
|
|
735
|
+
record[name] = { version, hash: bundledHash }
|
|
736
|
+
dirty = true
|
|
737
|
+
notes.push(name + ':宠物默认值更新到 ' + version + '(原先 ' + was + ')')
|
|
573
738
|
} catch {
|
|
574
739
|
/* 只读文件系统之类:让用户自己拷,别让整个目录扫描失败 */
|
|
575
740
|
}
|
|
576
741
|
}
|
|
742
|
+
if (dirty) {
|
|
743
|
+
try {
|
|
744
|
+
writeSyncRecord(record)
|
|
745
|
+
} catch {
|
|
746
|
+
/* 记录写不进去只影响"下次少一次判定",不影响这次已经装好的宠物 */
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (notes.length > 0 && options.quiet !== true) {
|
|
750
|
+
for (const note of notes) console.log('[live2d-pet] ' + note)
|
|
751
|
+
}
|
|
577
752
|
}
|
|
578
753
|
|
|
754
|
+
/** Build the live catalog from disk (fresh per request, so installs are picked up). */
|
|
579
755
|
export function buildCatalog() {
|
|
580
756
|
installBundledPets()
|
|
581
757
|
const root = petsRoot()
|
|
@@ -719,6 +895,7 @@ function catalogRoute() {
|
|
|
719
895
|
motionGuards: pet.motionGuards,
|
|
720
896
|
headParts: pet.headParts,
|
|
721
897
|
tailParts: pet.tailParts,
|
|
898
|
+
partNames: pet.partNames,
|
|
722
899
|
lines: pet.lines,
|
|
723
900
|
patReactions: pet.patReactions,
|
|
724
901
|
tailReactions: pet.tailReactions,
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"id": "ds-whale-girl",
|
|
4
4
|
"displayName": "DS鲸鱼娘",
|
|
5
5
|
"description": "氵六青的 DS 鲸鱼娘 Live2D 桌宠:8 组动作 + 44 个表情/道具,点她会有反应。",
|
|
6
|
-
"version": "1.0
|
|
6
|
+
"version": "1.1.0",
|
|
7
7
|
"author": "模型:上善无形 / ZipZipPipe / 氵六青 · 插件:A8Chann",
|
|
8
8
|
"license": "CC BY-NC-SA 4.0 (attribution, NON-COMMERCIAL, share-alike). Character: original OC by 上善无形 (https://www.pixiv.net/users/62155430); DeepSeek maid redesign by ZipZipPipe (https://www.pixiv.net/users/18604994); this Live2D model by 氵六青 (https://space.bilibili.com/11272072), who authorised redistribution here — which does NOT lift the base NC/SA terms. Commercial use requires permission from each rights holder. See NOTICE.md.",
|
|
9
9
|
"homepage": "https://space.bilibili.com/11272072",
|