dsh-pet-live2d 2.2.0 → 2.3.1
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 +90 -0
- package/README.md +30 -0
- package/lib/client.js +859 -39
- package/lib/index.js +99 -0
- package/package.json +1 -1
- package/pets/ds-whale-girl/pet.json +58 -0
package/lib/client.js
CHANGED
|
@@ -281,6 +281,153 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
281
281
|
* Runs once per attach. The values are model-space, so they stay valid
|
|
282
282
|
* across resizes and drags; `hitsHead` maps through the live transform.
|
|
283
283
|
*/
|
|
284
|
+
/** 头部 / 尾巴部件 id(宿主从 cdi3 的作者命名里挑的)。空 = 该互动退回旧行为。 */
|
|
285
|
+
let headParts = [];
|
|
286
|
+
let tailParts = [];
|
|
287
|
+
/** 由 parts 解出来的 drawable 下标(换模型要重算,缓存起来)。 */
|
|
288
|
+
let headIndices = null;
|
|
289
|
+
let tailIndices = null;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 部件 id → drawable 下标(带所属部件下标):cdi3 的部件名 → 引擎原始表。
|
|
293
|
+
*
|
|
294
|
+
* 为什么不直接用部件 id 去 `getDrawableIndex()`:cdi3 的 `Parts` 是**部件** id
|
|
295
|
+
* (`Part46`、`neck_m` 这种),而那个 API 认的是 **drawable** id(`lianhong`、
|
|
296
|
+
* `Face_line` 这种)—— 两个命名空间不同名,拿部件 id 查永远是 -1(实测这只模型
|
|
297
|
+
* 21 个头部部件一个都解不出来,判定静默退回方框)。
|
|
298
|
+
*
|
|
299
|
+
* 引擎的原始表里有 `drawables.parentPartIndices` 和 `parts.ids`,两边一接就得到
|
|
300
|
+
* "哪些 drawable 属于作者命名为头/脸/眼/眉/嘴/耳/发(或尾/翅/鳍)的那些部件" ——
|
|
301
|
+
* 用的是作者自己的分类,不是猜名字。
|
|
302
|
+
*
|
|
303
|
+
* 返回的是 `[{ index, part }]`:`part` 留着,因为**判定时要按部件透明度过滤**
|
|
304
|
+
* (见 isDrawableVisible)—— 隐藏的配件几何还在原地。
|
|
305
|
+
*
|
|
306
|
+
* @returns {Array<{index:number, part:number}>|null} 表结构不认识时返回 null
|
|
307
|
+
*/
|
|
308
|
+
const drawableIndicesForParts = (parts) => {
|
|
309
|
+
const raw = model?.internalModel?.coreModel?._model;
|
|
310
|
+
const parent = raw?.drawables?.parentPartIndices;
|
|
311
|
+
const partIds = raw?.parts?.ids;
|
|
312
|
+
if (parent === undefined || partIds === undefined) return null;
|
|
313
|
+
const ids = Array.from(partIds).map(String);
|
|
314
|
+
if (ids.length === 0 || parts.length === 0) return null;
|
|
315
|
+
const wanted = new Set(parts);
|
|
316
|
+
const out = [];
|
|
317
|
+
for (let i = 0; i < parent.length; i += 1) {
|
|
318
|
+
const partIndex = parent[i];
|
|
319
|
+
if (partIndex < 0 || partIndex >= ids.length) continue;
|
|
320
|
+
if (!wanted.has(ids[partIndex])) continue;
|
|
321
|
+
out.push({ index: i, part: partIndex });
|
|
322
|
+
}
|
|
323
|
+
return out.length > 0 ? out : null;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 这个 drawable 现在"看得见"吗?
|
|
328
|
+
*
|
|
329
|
+
* **注意:判定里不拿它当闸门。** 我试过用透明度过滤隐藏配件,结果是"真尾巴一起
|
|
330
|
+
* 被滤掉"—— 这只宠物的尾巴/翅膀全是**可选配件**,同一时刻只有一个显形,其余靠
|
|
331
|
+
* **缩放成一点**藏起来(被面积下限排掉就够了);而 `opacities` 表在渲染期未必是
|
|
332
|
+
* 最终值,拿它当闸门会误杀正在显形的那一个。
|
|
333
|
+
*
|
|
334
|
+
* 现在只留给诊断用(`partsDebug` / `partHitCounts` 报"过滤前 vs 过滤后")。
|
|
335
|
+
* "隐藏翅膀抢走摸头"这个真问题改由**路由优先级**解决:摸头优先于摸尾巴。
|
|
336
|
+
*/
|
|
337
|
+
const isDrawableVisible = (entry) => {
|
|
338
|
+
const im = model?.internalModel;
|
|
339
|
+
try {
|
|
340
|
+
if (typeof im?.getDrawableDynamicFlagIsVisible === "function"
|
|
341
|
+
&& im.getDrawableDynamicFlagIsVisible(entry.index) === false) return false;
|
|
342
|
+
} catch {
|
|
343
|
+
/* 没有这个 API 就继续看透明度 */
|
|
344
|
+
}
|
|
345
|
+
const raw = im?.coreModel?._model;
|
|
346
|
+
const drawOpacity = raw?.drawables?.opacities?.[entry.index];
|
|
347
|
+
if (typeof drawOpacity === "number" && drawOpacity <= 0.001) return false;
|
|
348
|
+
const partOpacity = raw?.parts?.opacities?.[entry.part];
|
|
349
|
+
if (typeof partOpacity === "number" && partOpacity <= 0.001) return false;
|
|
350
|
+
return true;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 点在不在这些 drawable 的**三角面**里(模型空间)。
|
|
355
|
+
*
|
|
356
|
+
* @param {number[]|null} indices drawable 下标(null = 解不出来)
|
|
357
|
+
* @returns {boolean|null} `null` = 读不到顶点(调用方落回旧行为,别把宠物变哑巴)
|
|
358
|
+
*/
|
|
359
|
+
const hitsPartsGeometry = (indices, x, y) => {
|
|
360
|
+
const im = model?.internalModel;
|
|
361
|
+
if (im === undefined || im === null) return null;
|
|
362
|
+
// 顶点在**包装层**、三角形索引在**core 层**(实测:`im` 有 getDrawableVertices,
|
|
363
|
+
// `im.coreModel` 才有 getDrawableVertexIndices)—— 只查一个就会静默返回 null、
|
|
364
|
+
// 悄悄退回方框判定。两边各取所长。
|
|
365
|
+
const verticesOf = typeof im.getDrawableVertices === "function"
|
|
366
|
+
? (index) => im.getDrawableVertices(index)
|
|
367
|
+
: (typeof im.coreModel?.getDrawableVertices === "function"
|
|
368
|
+
? (index) => im.coreModel.getDrawableVertices(index)
|
|
369
|
+
: null);
|
|
370
|
+
const indicesOf = typeof im.getDrawableVertexIndices === "function"
|
|
371
|
+
? (index) => im.getDrawableVertexIndices(index)
|
|
372
|
+
: (typeof im.coreModel?.getDrawableVertexIndices === "function"
|
|
373
|
+
? (index) => im.coreModel.getDrawableVertexIndices(index)
|
|
374
|
+
: null);
|
|
375
|
+
if (verticesOf === null || indicesOf === null) return null;
|
|
376
|
+
if (indices === null) return null;
|
|
377
|
+
let read = false;
|
|
378
|
+
try {
|
|
379
|
+
for (const entry of indices) {
|
|
380
|
+
const verts = verticesOf(entry.index);
|
|
381
|
+
if (verts === undefined || verts === null || verts.length < 6) continue;
|
|
382
|
+
read = true;
|
|
383
|
+
// 先用这个 drawable 的包围盒排除(绝大多数部件一眼就出局,不用扫三角形)。
|
|
384
|
+
let minX = Infinity;
|
|
385
|
+
let minY = Infinity;
|
|
386
|
+
let maxX = -Infinity;
|
|
387
|
+
let maxY = -Infinity;
|
|
388
|
+
for (let i = 0; i < verts.length; i += 2) {
|
|
389
|
+
const vx = verts[i];
|
|
390
|
+
const vy = verts[i + 1];
|
|
391
|
+
if (vx < minX) minX = vx;
|
|
392
|
+
if (vx > maxX) maxX = vx;
|
|
393
|
+
if (vy < minY) minY = vy;
|
|
394
|
+
if (vy > maxY) maxY = vy;
|
|
395
|
+
}
|
|
396
|
+
if (x < minX || x > maxX || y < minY || y > maxY) continue;
|
|
397
|
+
const indices = indicesOf(index);
|
|
398
|
+
if (indices === undefined || indices === null) continue;
|
|
399
|
+
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
400
|
+
if (pointInTriangle(x, y, verts, indices[i], indices[i + 1], indices[i + 2])) return true;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
// 读到一半炸了:当作"读不到",让调用方用方框兜底。
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
return read ? false : null;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/** 点 (x,y) 在 verts 的第 i0/i1/i2 号顶点组成的三角形里吗(同向叉积法)。 */
|
|
411
|
+
const pointInTriangle = (x, y, verts, i0, i1, i2) => {
|
|
412
|
+
const ax = verts[i0 * 2];
|
|
413
|
+
const ay = verts[i0 * 2 + 1];
|
|
414
|
+
const bx = verts[i1 * 2];
|
|
415
|
+
const by = verts[i1 * 2 + 1];
|
|
416
|
+
const cx = verts[i2 * 2];
|
|
417
|
+
const cy = verts[i2 * 2 + 1];
|
|
418
|
+
// 退化三角形(三个点几乎重合)**面积为零**,叉积全 0 → 下面的判定会返回 true,
|
|
419
|
+
// 于是"每个点都算命中"。隐藏的部件(缩放成一点)正好是这种,必须直接排掉。
|
|
420
|
+
const area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
|
|
421
|
+
if (Math.abs(area) < 1e-6) return false;
|
|
422
|
+
const d1 = (x - bx) * (ay - by) - (ax - bx) * (y - by);
|
|
423
|
+
const d2 = (x - cx) * (by - cy) - (bx - cx) * (y - cy);
|
|
424
|
+
const d3 = (x - ax) * (cy - ay) - (cx - ax) * (y - ay);
|
|
425
|
+
const hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
|
|
426
|
+
const hasPos = d1 > 0 || d2 > 0 || d3 > 0;
|
|
427
|
+
return !(hasNeg && hasPos);
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
|
|
284
431
|
const measureHead = (nextModel) => {
|
|
285
432
|
try {
|
|
286
433
|
const im = nextModel?.internalModel;
|
|
@@ -1302,6 +1449,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
1302
1449
|
ambientCursor = 0;
|
|
1303
1450
|
poseSnapshots.clear();
|
|
1304
1451
|
paramIndexCache.clear();
|
|
1452
|
+
headIndices = null;
|
|
1305
1453
|
const idleDuration = idleName === null ? 0 : (groups[idleName]?.[0]?.duration ?? 0);
|
|
1306
1454
|
ambientPeriodMs = idleDuration > 0 ? idleDuration : 4000;
|
|
1307
1455
|
// Locate the head once, from the model's own geometry; it is stored in
|
|
@@ -1691,28 +1839,47 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
1691
1839
|
}
|
|
1692
1840
|
return false;
|
|
1693
1841
|
},
|
|
1842
|
+
/** Head-part ids, pushed in by the component once the catalog is ready. */
|
|
1843
|
+
setHeadParts(ids) {
|
|
1844
|
+
headParts = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
|
|
1845
|
+
// 换宠物 / 换模型:下标缓存必须作废,否则会拿旧模型的 drawable 判定。
|
|
1846
|
+
headIndices = null;
|
|
1847
|
+
},
|
|
1848
|
+
/** Tail-part ids(同上,摸尾巴用)。 */
|
|
1849
|
+
setTailParts(ids) {
|
|
1850
|
+
tailParts = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
|
|
1851
|
+
tailIndices = null;
|
|
1852
|
+
},
|
|
1694
1853
|
idleName: () => idleName,
|
|
1695
1854
|
groups: () => groups,
|
|
1696
1855
|
/** Declared playback policy for one motion group (diagnostics). */
|
|
1697
1856
|
optionsFor,
|
|
1698
1857
|
/** Whether the idle fidget is allowed to pick this motion group. */
|
|
1699
1858
|
fidgetAllowed,
|
|
1700
|
-
|
|
1701
|
-
|
|
1859
|
+
/**
|
|
1860
|
+
* Whether a tap landed on the head (requirement #1).
|
|
1702
1861
|
*
|
|
1703
|
-
*
|
|
1704
|
-
*
|
|
1705
|
-
*
|
|
1862
|
+
* 有 cdi3 部件名时**按模型自己的三角面**判定:把点映到模型空间,对每个头部
|
|
1863
|
+
* drawable 先做包围盒快速排除,再做点在三角形内 —— 用的就是模型当前的几何
|
|
1864
|
+
* (发型边缘、脸部轮廓都对),而不是一个手调内边距的方框。顶点每次点击现读,
|
|
1865
|
+
* 所以头歪着、身体摆着也是准的。
|
|
1706
1866
|
*
|
|
1707
|
-
*
|
|
1708
|
-
*
|
|
1867
|
+
* 没有部件名(换宠物、没 cdi3)时退回旧行为:用英文部件名猜出来的方框;
|
|
1868
|
+
* 连那个都测不到就返回 true —— 不认识的模型保持"点哪都算头",而不是变哑巴。
|
|
1709
1869
|
*/
|
|
1710
1870
|
hitsHead(x, y) {
|
|
1711
|
-
if (
|
|
1871
|
+
if (model === null || vendor === null) return true;
|
|
1712
1872
|
try {
|
|
1713
1873
|
// Pass one arg only: the engine then clones into a fresh Point, so
|
|
1714
1874
|
// the stage-space input and the model-space output never alias.
|
|
1715
1875
|
const point = model.toModelPosition(new vendor.Point(x, y));
|
|
1876
|
+
if (headParts.length > 0) {
|
|
1877
|
+
if (headIndices === null) headIndices = drawableIndicesForParts(headParts);
|
|
1878
|
+
const hit = hitsPartsGeometry(headIndices, point.x, point.y);
|
|
1879
|
+
// 几何判定只在"真的能读到顶点"时算数;读不到就落回方框。
|
|
1880
|
+
if (hit !== null) return hit;
|
|
1881
|
+
}
|
|
1882
|
+
if (headBox === null) return true;
|
|
1716
1883
|
return point.x >= headBox.minX && point.x <= headBox.maxX
|
|
1717
1884
|
&& point.y >= headBox.minY && point.y <= headBox.maxY;
|
|
1718
1885
|
} catch {
|
|
@@ -1721,6 +1888,236 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
1721
1888
|
},
|
|
1722
1889
|
/** Diagnostic: the measured head box in model space, or null. */
|
|
1723
1890
|
headBox: () => headBox,
|
|
1891
|
+
/**
|
|
1892
|
+
* 这一下点在尾巴上吗?
|
|
1893
|
+
*
|
|
1894
|
+
* 和摸头同一套判定(模型自己的三角面),只是部件集合换成 cdi3 里命名为
|
|
1895
|
+
* 尾/鳍/翅/翼 的那些。**没有尾巴部件时返回 false**(不是 true):摸尾巴是个
|
|
1896
|
+
* 新增的互动,测不出来就不该乱触发(摸头那边相反,它要兼容旧模型)。
|
|
1897
|
+
*/
|
|
1898
|
+
hitsTail(x, y) {
|
|
1899
|
+
if (model === null || vendor === null) return false;
|
|
1900
|
+
if (tailParts.length === 0) return false;
|
|
1901
|
+
try {
|
|
1902
|
+
const point = model.toModelPosition(new vendor.Point(x, y));
|
|
1903
|
+
if (tailIndices === null) tailIndices = drawableIndicesForParts(tailParts);
|
|
1904
|
+
const hit = hitsPartsGeometry(tailIndices, point.x, point.y);
|
|
1905
|
+
return hit === true;
|
|
1906
|
+
} catch {
|
|
1907
|
+
return false;
|
|
1908
|
+
}
|
|
1909
|
+
},
|
|
1910
|
+
/**
|
|
1911
|
+
* Diagnostic: **旧**方框规则(手调内边距的那个),用来跟几何判定对比。
|
|
1912
|
+
*
|
|
1913
|
+
* 存在的意义是测试:能证明"新规则真的不一样",而不是两条路返回同一个答案。
|
|
1914
|
+
*/
|
|
1915
|
+
hitsHeadBox: (x, y) => {
|
|
1916
|
+
if (headBox === null || model === null || vendor === null) return null;
|
|
1917
|
+
try {
|
|
1918
|
+
const point = model.toModelPosition(new vendor.Point(x, y));
|
|
1919
|
+
return point.x >= headBox.minX && point.x <= headBox.maxX
|
|
1920
|
+
&& point.y >= headBox.minY && point.y <= headBox.maxY;
|
|
1921
|
+
} catch {
|
|
1922
|
+
return null;
|
|
1923
|
+
}
|
|
1924
|
+
},
|
|
1925
|
+
/** Diagnostic: 头部部件(来自 cdi3 的作者命名),空数组 = 退回旧判定。 */
|
|
1926
|
+
headPartIDs: () => headParts.slice(),
|
|
1927
|
+
/** Diagnostic: 模型声明的全部 drawable 名(认"头"只能靠这些名字)。 */
|
|
1928
|
+
drawableIDs: () => {
|
|
1929
|
+
const im = model?.internalModel;
|
|
1930
|
+
return typeof im?.getDrawableIDs === "function" ? Array.from(im.getDrawableIDs()).map(String) : [];
|
|
1931
|
+
},
|
|
1932
|
+
/**
|
|
1933
|
+
* Diagnostic: 引擎原始表里 drawable → 父部件 的对应关系。
|
|
1934
|
+
*
|
|
1935
|
+
* cdi3 的 `Parts` 是**部件** id(`Part46` 这种),`getDrawableIndex()` 认的是
|
|
1936
|
+
* drawable id(`lianhong` 这种)—— 两个命名空间不同名,直接拿部件 id 去查永远是 -1。
|
|
1937
|
+
* 引擎的原始表里带着 `parentPartIndices`,把两边接起来才能用上作者的命名。
|
|
1938
|
+
*/
|
|
1939
|
+
partTables: () => {
|
|
1940
|
+
const core = model?.internalModel?.coreModel;
|
|
1941
|
+
const raw = core?._model;
|
|
1942
|
+
const parts = raw?.parts;
|
|
1943
|
+
const drawables = raw?.drawables;
|
|
1944
|
+
return {
|
|
1945
|
+
partKeys: parts === undefined ? [] : Object.keys(parts),
|
|
1946
|
+
partIds: parts?.ids === undefined ? [] : Array.from(parts.ids).map(String),
|
|
1947
|
+
drawableKeys: drawables === undefined ? [] : Object.keys(drawables),
|
|
1948
|
+
parentPartIndices: drawables?.parentPartIndices === undefined
|
|
1949
|
+
? null
|
|
1950
|
+
: Array.from(drawables.parentPartIndices).slice(0, 12),
|
|
1951
|
+
};
|
|
1952
|
+
},
|
|
1953
|
+
/**
|
|
1954
|
+
* Diagnostic: 摸头判定的内部状态。
|
|
1955
|
+
*
|
|
1956
|
+
* cdi3 的 `Parts` 是**部件**(part)id,而 `getDrawableIndex()` 认的是 **drawable**
|
|
1957
|
+
* id —— 两个命名空间不一定同名(这只模型里就不同)。所以这里要把"挑到的部件"
|
|
1958
|
+
* 和"真的能在模型里解出下标的"分开报,否则判定静默退回方框、外面看不出来。
|
|
1959
|
+
*/
|
|
1960
|
+
headDebug: () => {
|
|
1961
|
+
if (headIndices === null) headIndices = drawableIndicesForParts(headParts);
|
|
1962
|
+
if (tailIndices === null) tailIndices = drawableIndicesForParts(tailParts);
|
|
1963
|
+
const im = model?.internalModel;
|
|
1964
|
+
const core = im?.coreModel;
|
|
1965
|
+
const has = (target) => ["getDrawableVertices", "getDrawableVertexIndices", "getDrawableIndex", "getDrawableBounds", "getDrawableIDs"]
|
|
1966
|
+
.filter((name) => typeof target?.[name] === "function");
|
|
1967
|
+
let vertexProbe = "n/a";
|
|
1968
|
+
try {
|
|
1969
|
+
const v = im?.getDrawableVertices?.(headIndices?.[0]?.index ?? 0);
|
|
1970
|
+
vertexProbe = v === undefined ? "undefined" : (v === null ? "null" : "len=" + v.length);
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
vertexProbe = "throw: " + String(error?.message ?? error);
|
|
1973
|
+
}
|
|
1974
|
+
const visible = (list) => (list === null ? 0 : list.filter((entry) => isDrawableVisible(entry)).length);
|
|
1975
|
+
return {
|
|
1976
|
+
parts: headParts.length,
|
|
1977
|
+
drawableIndices: headIndices === null ? 0 : headIndices.length,
|
|
1978
|
+
headVisible: visible(headIndices),
|
|
1979
|
+
tailParts: tailParts.length,
|
|
1980
|
+
tailDrawableIndices: tailIndices === null ? 0 : tailIndices.length,
|
|
1981
|
+
tailVisible: visible(tailIndices),
|
|
1982
|
+
apiOnInternalModel: has(im),
|
|
1983
|
+
apiOnCoreModel: has(core),
|
|
1984
|
+
vertexProbe,
|
|
1985
|
+
box: headBox,
|
|
1986
|
+
};
|
|
1987
|
+
},
|
|
1988
|
+
/**
|
|
1989
|
+
* Diagnostic: 逐部件的透明度 / 几何范围 / drawable 数。
|
|
1990
|
+
*
|
|
1991
|
+
* "摸头顺带把摸尾巴也触发"这类问题要先分清是**隐藏的配件**(同一时刻只有一个
|
|
1992
|
+
* 显形:狐狸尾 / 猫尾 / 狼尾 / 天使翅膀…)几何还留在原地,还是判定本身写错了。
|
|
1993
|
+
*
|
|
1994
|
+
* **必须挂在控制器上**:`tailParts` 是这一层的闭包变量,放到组件的 api 里会
|
|
1995
|
+
* `tailParts is not defined`(我就这么错过一次,调用直接抛异常、外面只看到 undefined)。
|
|
1996
|
+
*/
|
|
1997
|
+
partsDebug: (which) => {
|
|
1998
|
+
const list = which === "tail" ? tailParts : headParts;
|
|
1999
|
+
const raw = model?.internalModel?.coreModel?._model;
|
|
2000
|
+
const im = model?.internalModel;
|
|
2001
|
+
const partIds = raw?.parts?.ids === undefined ? [] : Array.from(raw.parts.ids).map(String);
|
|
2002
|
+
const parent = raw?.drawables?.parentPartIndices;
|
|
2003
|
+
const out = [];
|
|
2004
|
+
for (const id of list) {
|
|
2005
|
+
const index = partIds.indexOf(id);
|
|
2006
|
+
const opacity = raw?.parts?.opacities?.[index];
|
|
2007
|
+
let minX = Infinity;
|
|
2008
|
+
let minY = Infinity;
|
|
2009
|
+
let maxX = -Infinity;
|
|
2010
|
+
let maxY = -Infinity;
|
|
2011
|
+
let drawables = 0;
|
|
2012
|
+
let visibleDrawables = 0;
|
|
2013
|
+
if (parent !== undefined) {
|
|
2014
|
+
for (let i = 0; i < parent.length; i += 1) {
|
|
2015
|
+
if (parent[i] !== index) continue;
|
|
2016
|
+
drawables += 1;
|
|
2017
|
+
if (isDrawableVisible({ index: i, part: index })) visibleDrawables += 1;
|
|
2018
|
+
try {
|
|
2019
|
+
const verts = im?.getDrawableVertices?.(i);
|
|
2020
|
+
if (verts === undefined || verts === null) continue;
|
|
2021
|
+
for (let k = 0; k < verts.length; k += 2) {
|
|
2022
|
+
if (verts[k] < minX) minX = verts[k];
|
|
2023
|
+
if (verts[k] > maxX) maxX = verts[k];
|
|
2024
|
+
if (verts[k + 1] < minY) minY = verts[k + 1];
|
|
2025
|
+
if (verts[k + 1] > maxY) maxY = verts[k + 1];
|
|
2026
|
+
}
|
|
2027
|
+
} catch {
|
|
2028
|
+
/* 读不到就跳过 */
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
out.push({
|
|
2033
|
+
id,
|
|
2034
|
+
partIndex: index,
|
|
2035
|
+
opacity: typeof opacity === "number" ? Math.round(opacity * 1000) / 1000 : null,
|
|
2036
|
+
drawables,
|
|
2037
|
+
visibleDrawables,
|
|
2038
|
+
box: maxX > minX
|
|
2039
|
+
? { minX: Math.round(minX), maxX: Math.round(maxX), minY: Math.round(minY), maxY: Math.round(maxY) }
|
|
2040
|
+
: null,
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
return out;
|
|
2044
|
+
},
|
|
2045
|
+
/**
|
|
2046
|
+
* Diagnostic: 每个部件在模型空间的命中点数(过滤前 / 过滤后)+ 它的 drawable
|
|
2047
|
+
* 透明度范围。
|
|
2048
|
+
*
|
|
2049
|
+
* 采样**直接在模型空间**做,不经过 `vendor.Point` —— 第一版在 api 闭包里用了
|
|
2050
|
+
* `vendor`(那是控制器里的变量,这里根本看不到),每次调用都抛异常被 catch 吞掉,
|
|
2051
|
+
* 于是所有部件都报 0,看着像"这个部件完全没几何"。
|
|
2052
|
+
*/
|
|
2053
|
+
partHitCounts: (which, cols = 48, rows = 36) => {
|
|
2054
|
+
const parts = which === "tail" ? tailParts : headParts;
|
|
2055
|
+
const im = model?.internalModel;
|
|
2056
|
+
const raw = im?.coreModel?._model;
|
|
2057
|
+
const partIds = raw?.parts?.ids === undefined ? [] : Array.from(raw.parts.ids).map(String);
|
|
2058
|
+
const parent = raw?.drawables?.parentPartIndices;
|
|
2059
|
+
const out = [];
|
|
2060
|
+
for (const id of parts) {
|
|
2061
|
+
const partIndex = partIds.indexOf(id);
|
|
2062
|
+
const entries = [];
|
|
2063
|
+
if (parent !== undefined) {
|
|
2064
|
+
for (let i = 0; i < parent.length; i += 1) {
|
|
2065
|
+
if (partIndex >= 0) {
|
|
2066
|
+
if (parent[i] === partIndex) entries.push({ index: i, part: partIndex });
|
|
2067
|
+
} else if (typeof im?.getDrawableIndex === "function" && im.getDrawableIndex(id) === i) {
|
|
2068
|
+
// id 不在 parts.ids 里(它其实是个 drawable id):按 drawable 找它自己。
|
|
2069
|
+
entries.push({ index: i, part: -1 });
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
const opacities = [];
|
|
2074
|
+
let minX = Infinity;
|
|
2075
|
+
let minY = Infinity;
|
|
2076
|
+
let maxX = -Infinity;
|
|
2077
|
+
let maxY = -Infinity;
|
|
2078
|
+
for (const entry of entries) {
|
|
2079
|
+
const value = raw?.drawables?.opacities?.[entry.index];
|
|
2080
|
+
if (typeof value === "number") opacities.push(Math.round(value * 1000) / 1000);
|
|
2081
|
+
try {
|
|
2082
|
+
const verts = im?.getDrawableVertices?.(entry.index);
|
|
2083
|
+
if (verts === undefined || verts === null) continue;
|
|
2084
|
+
for (let k = 0; k < verts.length; k += 2) {
|
|
2085
|
+
if (verts[k] < minX) minX = verts[k];
|
|
2086
|
+
if (verts[k] > maxX) maxX = verts[k];
|
|
2087
|
+
if (verts[k + 1] < minY) minY = verts[k + 1];
|
|
2088
|
+
if (verts[k + 1] > maxY) maxY = verts[k + 1];
|
|
2089
|
+
}
|
|
2090
|
+
} catch {
|
|
2091
|
+
/* 跳过 */
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
let hitsAll = 0;
|
|
2095
|
+
let hitsVisible = 0;
|
|
2096
|
+
if (entries.length > 0 && maxX > minX && maxY > minY) {
|
|
2097
|
+
const visible = entries.filter((entry) => isDrawableVisible(entry));
|
|
2098
|
+
const pad = 20;
|
|
2099
|
+
for (let iy = 0; iy < rows; iy += 1) {
|
|
2100
|
+
for (let ix = 0; ix < cols; ix += 1) {
|
|
2101
|
+
const px = (minX - pad) + (maxX - minX + pad * 2) * (ix + 0.5) / cols;
|
|
2102
|
+
const py = (minY - pad) + (maxY - minY + pad * 2) * (iy + 0.5) / rows;
|
|
2103
|
+
if (hitsPartsGeometry(entries, px, py) === true) hitsAll += 1;
|
|
2104
|
+
if (visible.length > 0 && hitsPartsGeometry(visible, px, py) === true) hitsVisible += 1;
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
out.push({
|
|
2109
|
+
id,
|
|
2110
|
+
partIndex,
|
|
2111
|
+
drawables: entries.length,
|
|
2112
|
+
opacityMin: opacities.length === 0 ? null : Math.min(...opacities),
|
|
2113
|
+
opacityMax: opacities.length === 0 ? null : Math.max(...opacities),
|
|
2114
|
+
box: maxX > minX ? { minX: Math.round(minX), maxX: Math.round(maxX), minY: Math.round(minY), maxY: Math.round(maxY) } : null,
|
|
2115
|
+
hitsAll,
|
|
2116
|
+
hitsVisible,
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
return out;
|
|
2120
|
+
},
|
|
1724
2121
|
/**
|
|
1725
2122
|
* Play a motion with its declared policy applied; used by the panel, the
|
|
1726
2123
|
* tap reaction and the session-phase driver.
|
|
@@ -1908,7 +2305,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
1908
2305
|
+ "--pp-accent:rgba(90,140,230,.15);--pp-accent-2:rgba(90,140,230,.18);"
|
|
1909
2306
|
+ "--pp-accent-3:rgba(90,140,230,.26);--pp-shadow:0 14px 34px rgba(20,30,50,.18);"
|
|
1910
2307
|
+ "--pp-shadow-sm:0 8px 20px rgba(20,30,50,.14)}",
|
|
1911
|
-
ROOT_SEL + " [data-bubble]{position:absolute;left:50%;bottom:100%;transform:translateX(-50%);margin-bottom:6px;max-width:min(240px,60vw);width:max-content;padding:7px 11px;border-radius:12px;background:linear-gradient(160deg,var(--pp-bubble-a),var(--pp-bubble-b));border:1px solid var(--pp-line);box-shadow:var(--pp-shadow-sm);color:var(--pp-ink);font:400 12px/1.5 inherit;white-space:pre-wrap;pointer-events:none}",
|
|
2308
|
+
ROOT_SEL + " [data-bubble]{position:absolute;left:50%;bottom:100%;transform:translateX(-50%) translate(var(--bubble-x,0px),var(--bubble-y,0px));margin-bottom:6px;max-width:min(240px,60vw);width:max-content;padding:7px 11px;border-radius:12px;background:linear-gradient(160deg,var(--pp-bubble-a),var(--pp-bubble-b));border:1px solid var(--pp-line);box-shadow:var(--pp-shadow-sm);color:var(--pp-ink);font:400 12px/1.5 inherit;white-space:pre-wrap;pointer-events:none}",
|
|
1912
2309
|
// Sits outside the pet's box entirely, so it must re-arm itself.
|
|
1913
2310
|
ROOT_SEL + " [data-panel]{position:absolute;right:calc(100% + 10px);bottom:0;width:270px;max-height:min(440px,72vh);display:flex;flex-direction:column;border-radius:14px;overflow:hidden;background:var(--pp-surface);backdrop-filter:blur(14px);border:1px solid var(--pp-line);box-shadow:var(--pp-shadow);color:var(--pp-ink);font:400 12px/1.5 inherit;pointer-events:auto}",
|
|
1914
2311
|
ROOT_SEL + " [data-panel] header{display:flex;align-items:center;gap:6px;padding:9px 11px;border-bottom:1px solid var(--pp-line-soft);font-weight:600}",
|
|
@@ -2040,6 +2437,24 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2040
2437
|
// 滑杆外观(轨道/圆钮/填充)和面板底部那根共用,见 sliderLook。
|
|
2041
2438
|
...sliderLook(scope + " input[type=range]"),
|
|
2042
2439
|
scope + " input[type=checkbox]{accent-color:rgba(120,170,255,.9)}",
|
|
2440
|
+
// ---- 台词 / 互动 ---------------------------------------------------
|
|
2441
|
+
// 台词是**文本**输入(不是数字),要能看清自己写了什么:给足宽度、字色正常,
|
|
2442
|
+
// 只在 hover/focus 时提亮边框 —— 和权重那个"读数旋钮"是两种东西,别共用样式。
|
|
2443
|
+
scope + " [data-line-row]{display:grid;grid-template-columns:120px 1fr;align-items:center;"
|
|
2444
|
+
+ "gap:6px;padding:2px 0}",
|
|
2445
|
+
scope + " input[type=text]{box-sizing:border-box;width:100%;min-width:0;font:inherit;"
|
|
2446
|
+
+ "font-size:11px;color:inherit;background:rgba(127,127,127,.08);"
|
|
2447
|
+
+ "border:1px solid rgba(127,127,127,.28);border-radius:7px;padding:2px 7px}",
|
|
2448
|
+
scope + " input[type=text]:hover{border-color:rgba(127,127,127,.5)}",
|
|
2449
|
+
scope + " input[type=text]:focus-visible{outline:2px solid rgba(120,170,255,.5);outline-offset:1px}",
|
|
2450
|
+
scope + " [data-note-inline]{font-size:10px;opacity:.55;margin-left:6px}",
|
|
2451
|
+
// 反应候选:一行 chips(和槽位选项同一套观感),选中的加底色。
|
|
2452
|
+
scope + " [data-reaction-set]{padding:5px 0 2px}",
|
|
2453
|
+
scope + " [data-reaction-set] [data-chips]{display:flex;flex-wrap:wrap;gap:4px;padding-top:4px}",
|
|
2454
|
+
scope + " [data-reaction-set] [data-chips] button{font-size:10px;padding:2px 8px;"
|
|
2455
|
+
+ "border-radius:999px;border:1px solid rgba(127,127,127,.28);background:rgba(127,127,127,.08)}",
|
|
2456
|
+
scope + " [data-reaction-set] [data-chips] button[data-on]{background:rgba(120,170,255,.28);"
|
|
2457
|
+
+ "border-color:rgba(120,170,255,.6)}",
|
|
2043
2458
|
scope + " code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;"
|
|
2044
2459
|
+ "opacity:.7;font-variant-numeric:tabular-nums}",
|
|
2045
2460
|
|
|
@@ -2138,11 +2553,42 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2138
2553
|
|
|
2139
2554
|
// ------------------------------------------------------------ lines
|
|
2140
2555
|
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2556
|
+
/**
|
|
2557
|
+
* 台词:**宠物默认 + 用户覆盖**。
|
|
2558
|
+
*
|
|
2559
|
+
* 默认值写在 pet.json 的 `live2d.lines` 里(宠物自己的声音),用户在设置页改过的存
|
|
2560
|
+
* 浏览器(`PHASE_OVERRIDES.lines`)。每条都是**一组**(随机挑一句),相位台词是单句。
|
|
2561
|
+
*/
|
|
2562
|
+
const LINE_KEYS = ["greet", "click", "pat", "tail", "spin", "reset", "loadFailed"];
|
|
2563
|
+
const PHASE_LINE_KEYS = ["thinking", "tool", "waiting", "asking", "helper", "queued", "done", "failed"];
|
|
2564
|
+
|
|
2565
|
+
/** 把一条"用 | 分隔"的输入切成台词数组(设置页的输入框就是这个格式)。 */
|
|
2566
|
+
const splitLineInput = (text) =>
|
|
2567
|
+
String(text ?? "")
|
|
2568
|
+
.split("|")
|
|
2569
|
+
.map((part) => part.trim())
|
|
2570
|
+
.filter((part) => part !== "");
|
|
2571
|
+
|
|
2572
|
+
/** 台词数组 -> 输入框里的一行。 */
|
|
2573
|
+
const joinLineInput = (list) => (Array.isArray(list) ? list.join(" | ") : "");
|
|
2574
|
+
|
|
2575
|
+
/** 有效台词:宠物默认 + 用户覆盖(覆盖为空就退回默认)。 */
|
|
2576
|
+
const linesNow = () => {
|
|
2577
|
+
const base = MANIFEST.current?.lines ?? {};
|
|
2578
|
+
const over = PHASE_OVERRIDES.lines ?? {};
|
|
2579
|
+
const out = {};
|
|
2580
|
+
for (const key of LINE_KEYS) {
|
|
2581
|
+
const mine = Array.isArray(over[key]) ? over[key].filter((s) => typeof s === "string" && s !== "") : [];
|
|
2582
|
+
const fallback = Array.isArray(base[key]) ? base[key].filter((s) => typeof s === "string" && s !== "") : [];
|
|
2583
|
+
out[key] = mine.length > 0 ? mine : fallback;
|
|
2584
|
+
}
|
|
2585
|
+
out.phase = {};
|
|
2586
|
+
for (const key of PHASE_LINE_KEYS) {
|
|
2587
|
+
const mine = typeof over.phase?.[key] === "string" ? over.phase[key] : undefined;
|
|
2588
|
+
const fallback = typeof base.phase?.[key] === "string" ? base.phase[key] : "";
|
|
2589
|
+
out.phase[key] = mine !== undefined ? mine : fallback;
|
|
2590
|
+
}
|
|
2591
|
+
return out;
|
|
2146
2592
|
};
|
|
2147
2593
|
|
|
2148
2594
|
function pick(list) {
|
|
@@ -2181,6 +2627,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2181
2627
|
/** 摸鱼:静置多久才算「闲下来」,以及之后每次摸鱼的随机间隔上界(ms)。 */
|
|
2182
2628
|
fidgetQuietMs: 12000,
|
|
2183
2629
|
fidgetGapMs: 26000,
|
|
2630
|
+
/** 气泡相对角色默认位置(头顶)的偏移,px;以及一句话停留多久。 */
|
|
2631
|
+
bubbleOffsetX: 0,
|
|
2632
|
+
bubbleOffsetY: 0,
|
|
2633
|
+
bubbleHoldMs: 4200,
|
|
2634
|
+
/** 鼠标绕圈:在 spinWindowMs 内累计转过 spinTurns 圈就算转晕。 */
|
|
2635
|
+
spinTurns: 2,
|
|
2636
|
+
spinWindowMs: 1600,
|
|
2184
2637
|
};
|
|
2185
2638
|
|
|
2186
2639
|
/** 出厂值快照(「恢复默认」用)。 */
|
|
@@ -2203,12 +2656,22 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2203
2656
|
// 摸鱼那一组单独排,界面上分开展示(见 TUNING_GROUPS)。
|
|
2204
2657
|
{ key: "fidgetQuietMs", label: "静置多久开始", min: 2000, max: 120000, step: 1000, group: "fidget" },
|
|
2205
2658
|
{ key: "fidgetGapMs", label: "之后最长间隔", min: 4000, max: 300000, step: 1000, group: "fidget" },
|
|
2659
|
+
// 互动与气泡(见「互动」那张卡)。
|
|
2660
|
+
{ key: "bubbleOffsetX", label: "气泡左右偏移 px", min: -240, max: 240, step: 2, group: "bubble" },
|
|
2661
|
+
{ key: "bubbleOffsetY", label: "气泡上下偏移 px", min: -240, max: 240, step: 2, group: "bubble" },
|
|
2662
|
+
{ key: "bubbleHoldMs", label: "一句话停留 ms", min: 800, max: 15000, step: 200, group: "bubble" },
|
|
2663
|
+
{ key: "spinTurns", label: "转几圈算晕", min: 1, max: 6, step: 0.5, group: "interact" },
|
|
2664
|
+
{ key: "spinWindowMs", label: "要在多少 ms 内", min: 300, max: 6000, step: 100, group: "interact" },
|
|
2206
2665
|
];
|
|
2207
2666
|
/** 可调项的分组(没写 group 的都归「手感」)。hint 显示在卡片右上角。 */
|
|
2208
2667
|
const TUNING_GROUPS = [
|
|
2209
2668
|
{ id: "feel", label: "手感", hint: "指针 / 嘴 / 眨眼" },
|
|
2210
2669
|
{ id: "fidget", label: "摸鱼节奏", hint: "多久开始、间隔多长" },
|
|
2670
|
+
{ id: "interact", label: "互动", hint: "摸头 / 摸尾巴 / 转圈" },
|
|
2671
|
+
{ id: "bubble", label: "气泡", hint: "显示什么 · 在哪" },
|
|
2211
2672
|
];
|
|
2673
|
+
/** 这几组自己排进了「互动」「气泡」卡里,不要再单独出一张卡。 */
|
|
2674
|
+
const TUNING_GROUPS_INLINE = ["fidget", "interact", "bubble"];
|
|
2212
2675
|
const tuningGroupOf = (field) => field.group ?? "feel";
|
|
2213
2676
|
const TUNING_KEY = "dsh-pet-live2d.settings.v1";
|
|
2214
2677
|
/** 装扮存档的 key。放这里是因为开关(applyFlag)也要用它清存档。 */
|
|
@@ -2247,7 +2710,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2247
2710
|
* 姿势本身的性质 —— 在摸鱼表里改它,右键面板点同一个姿势、相位池里抽到它,
|
|
2248
2711
|
* 行为必须一致。所以它单独存一层,而不是挂在条目上。
|
|
2249
2712
|
*/
|
|
2250
|
-
const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {} };
|
|
2713
|
+
const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {}, lines: {} };
|
|
2251
2714
|
|
|
2252
2715
|
/** 槽位 id -> 中文标签(关系行显示「贴纸」而不是 `sticker`)。 */
|
|
2253
2716
|
const slotLabelOf = (slotId) =>
|
|
@@ -2362,7 +2825,18 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2362
2825
|
*
|
|
2363
2826
|
* 和数字项分开存:它们不是滑杆,校验方式也不同(true/false)。
|
|
2364
2827
|
*/
|
|
2365
|
-
const FLAGS = {
|
|
2828
|
+
const FLAGS = {
|
|
2829
|
+
/** 装扮是否跨启动记住(那六件穿在身上的东西)。 */
|
|
2830
|
+
outfitArchive: true,
|
|
2831
|
+
/** 气泡总开关:关掉之后任何台词都不弹(含问候、摸头、相位)。 */
|
|
2832
|
+
bubbleEnabled: true,
|
|
2833
|
+
/** 摸头互动(判定用的是模型自己的几何,见 hitsHead)。 */
|
|
2834
|
+
patEnabled: true,
|
|
2835
|
+
/** 摸尾巴互动。 */
|
|
2836
|
+
tailEnabled: true,
|
|
2837
|
+
/** 鼠标绕圈转晕。 */
|
|
2838
|
+
spinEnabled: true,
|
|
2839
|
+
};
|
|
2366
2840
|
const FLAG_DEFAULTS = Object.freeze(Object.assign({}, FLAGS));
|
|
2367
2841
|
const OVERRIDE_KEY = "dsh-pet-live2d.settings.v2";
|
|
2368
2842
|
|
|
@@ -2490,6 +2964,36 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2490
2964
|
if (typeof flags[key] === "boolean") FLAGS[key] = flags[key];
|
|
2491
2965
|
}
|
|
2492
2966
|
}
|
|
2967
|
+
// 互动反应候选:三组标签数组。
|
|
2968
|
+
const interactions = saved.interactions;
|
|
2969
|
+
if (interactions !== null && typeof interactions === "object") {
|
|
2970
|
+
const next = {};
|
|
2971
|
+
for (const [key, list] of Object.entries(interactions)) {
|
|
2972
|
+
if (!Array.isArray(list)) continue;
|
|
2973
|
+
const clean = list.filter((label) => typeof label === "string" && label !== "");
|
|
2974
|
+
if (clean.length > 0) next[key] = clean;
|
|
2975
|
+
}
|
|
2976
|
+
if (Object.keys(next).length > 0) PHASE_OVERRIDES.interactions = next;
|
|
2977
|
+
}
|
|
2978
|
+
// 台词覆盖:两层(每条一组 + 相位的单句)。数组要清掉空串,否则 `[""]` 会被
|
|
2979
|
+
// 当成"用户改过",`linesNow()` 就永远拿不到宠物默认了。
|
|
2980
|
+
const lines = saved.lines;
|
|
2981
|
+
if (lines !== null && typeof lines === "object") {
|
|
2982
|
+
const next = {};
|
|
2983
|
+
for (const [key, value] of Object.entries(lines)) {
|
|
2984
|
+
if (key === "phase") {
|
|
2985
|
+
const phases = {};
|
|
2986
|
+
for (const [phase, text] of Object.entries(value ?? {})) {
|
|
2987
|
+
if (typeof text === "string") phases[phase] = text;
|
|
2988
|
+
}
|
|
2989
|
+
if (Object.keys(phases).length > 0) next.phase = phases;
|
|
2990
|
+
} else if (Array.isArray(value)) {
|
|
2991
|
+
const clean = value.filter((text) => typeof text === "string" && text !== "");
|
|
2992
|
+
if (clean.length > 0) next[key] = clean;
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
if (Object.keys(next).length > 0) PHASE_OVERRIDES.lines = next;
|
|
2996
|
+
}
|
|
2493
2997
|
};
|
|
2494
2998
|
|
|
2495
2999
|
/** 改一处覆盖:写进 store、存档、广播(两个设置界面立刻同步)。 */
|
|
@@ -2508,6 +3012,27 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2508
3012
|
};
|
|
2509
3013
|
}
|
|
2510
3014
|
}
|
|
3015
|
+
if (patch.interactions !== undefined) {
|
|
3016
|
+
for (const [key, list] of Object.entries(patch.interactions)) {
|
|
3017
|
+
PHASE_OVERRIDES.interactions[key] = Array.isArray(list) ? list.slice() : [];
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
// 台词是**两层**(每条一组,相位再一层),所以逐层合并 —— 直接 Object.assign 的话
|
|
3021
|
+
// 改一句相位台词会把其它相位整片冲掉。
|
|
3022
|
+
if (patch.lines !== undefined) {
|
|
3023
|
+
const current = PHASE_OVERRIDES.lines ?? {};
|
|
3024
|
+
const next = Object.assign({}, current);
|
|
3025
|
+
for (const [key, value] of Object.entries(patch.lines)) {
|
|
3026
|
+
if (key === "phase") {
|
|
3027
|
+
next.phase = Object.assign({}, current.phase, value);
|
|
3028
|
+
} else if (Array.isArray(value)) {
|
|
3029
|
+
next[key] = value.slice();
|
|
3030
|
+
} else if (typeof value === "string") {
|
|
3031
|
+
next[key] = value;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
PHASE_OVERRIDES.lines = next;
|
|
3035
|
+
}
|
|
2511
3036
|
saveOverrides();
|
|
2512
3037
|
notifySettings();
|
|
2513
3038
|
};
|
|
@@ -2914,6 +3439,31 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2914
3439
|
{ expression: "星星眼" },
|
|
2915
3440
|
];
|
|
2916
3441
|
|
|
3442
|
+
/**
|
|
3443
|
+
* 互动的反应候选:宠物默认(pet.json 的 `patReactions` / `tailReactions` /
|
|
3444
|
+
* `spinReactions`)+ 用户覆盖(`PHASE_OVERRIDES.interactions`)。
|
|
3445
|
+
*
|
|
3446
|
+
* 条目是**标签**(「重锤出击」「星星眼」这种):先当动作组找(宠物会给动作起中文名),
|
|
3447
|
+
* 找不到就当表达式名。这样设置界面里可以拿宠物自己的清单当候选,用户不用记 id。
|
|
3448
|
+
*/
|
|
3449
|
+
const interactionReactions = (key) => {
|
|
3450
|
+
const mine = PHASE_OVERRIDES.interactions?.[key];
|
|
3451
|
+
const list = Array.isArray(mine) && mine.length > 0 ? mine : (MANIFEST.current?.[key] ?? []);
|
|
3452
|
+
return Array.isArray(list) ? list.filter((name) => typeof name === "string" && name !== "") : [];
|
|
3453
|
+
};
|
|
3454
|
+
|
|
3455
|
+
/** 跑一条反应(由点击/转圈触发)。 */
|
|
3456
|
+
const runReactionRef = { current: () => false };
|
|
3457
|
+
|
|
3458
|
+
/**
|
|
3459
|
+
* 转圈检测的读数(诊断用)。
|
|
3460
|
+
*
|
|
3461
|
+
* "绕着转圈没反应"这类问题必须能分辨**三种**可能:事件没来(moves 不动)、
|
|
3462
|
+
* 累计不够(total 不涨)、还是触发了但被别的东西盖住(fires 涨了却没效果)。
|
|
3463
|
+
* 只看最后那个"弹没弹台词"是分不出来的。
|
|
3464
|
+
*/
|
|
3465
|
+
const spinStatsRef = { current: { moves: 0, total: 0, fires: 0 } };
|
|
3466
|
+
|
|
2917
3467
|
/**
|
|
2918
3468
|
* How long a session phase keeps replaying its motion.
|
|
2919
3469
|
*
|
|
@@ -3281,6 +3831,27 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3281
3831
|
fidgetSlots: Object.keys(PHASE_OVERRIDES.fidget),
|
|
3282
3832
|
relationKeys: Object.keys(PHASE_OVERRIDES.relations),
|
|
3283
3833
|
phases: Object.keys(PHASE_OVERRIDES.phases),
|
|
3834
|
+
interactions: Object.keys(PHASE_OVERRIDES.interactions ?? {}),
|
|
3835
|
+
lines: Object.keys(PHASE_OVERRIDES.lines ?? {}),
|
|
3836
|
+
linePhases: Object.keys(PHASE_OVERRIDES.lines?.phase ?? {}),
|
|
3837
|
+
flags: Object.assign({}, FLAGS),
|
|
3838
|
+
});
|
|
3839
|
+
// 台词的**有效值**(宠物默认 + 用户覆盖)—— "改文本"这类断言要能直接读到结果,
|
|
3840
|
+
// 而不是去 DOM 里抠 input.value(那是"界面显示了什么",不是"她真的会说什么")。
|
|
3841
|
+
api.effectiveLines = () => linesNow();
|
|
3842
|
+
/** 某个互动的**有效**反应候选(同样是与宠物默认合并后的结果)。 */
|
|
3843
|
+
api.effectiveReactions = (key) => interactionReactions(key);
|
|
3844
|
+
/** 台词的字段清单(测试用它确认"每一个字段都有输入框")。 */
|
|
3845
|
+
api.lineFields = () => ({
|
|
3846
|
+
plain: LINE_FIELDS.map((field) => field.key),
|
|
3847
|
+
phase: PHASE_LINE_FIELDS.map((field) => field.key),
|
|
3848
|
+
});
|
|
3849
|
+
/** 转圈检测的读数:moves(事件到没到)/ total(累计角)/ fires(触发了几次)。 */
|
|
3850
|
+
api.spinDebug = () => Object.assign({}, spinStatsRef.current, {
|
|
3851
|
+
enabled: FLAGS.spinEnabled,
|
|
3852
|
+
turns: TUNING.spinTurns,
|
|
3853
|
+
windowMs: TUNING.spinWindowMs,
|
|
3854
|
+
threshold: TUNING.spinTurns * Math.PI * 2,
|
|
3284
3855
|
});
|
|
3285
3856
|
}, []);
|
|
3286
3857
|
/**
|
|
@@ -3434,10 +4005,19 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3434
4005
|
? (catalog.pets.find((entry) => entry.id === petId) ?? catalog.pets[0])
|
|
3435
4006
|
: undefined;
|
|
3436
4007
|
|
|
4008
|
+
/**
|
|
4009
|
+
* 弹一句气泡。
|
|
4010
|
+
*
|
|
4011
|
+
* 三件可配的事都收在这里(用户要的"所有文本都可配 / 位置可配 / 可以开关"):
|
|
4012
|
+
* 气泡总开关关掉时**直接不弹**(连问候也不弹);位置偏移走 TUNING,渲染时当 CSS 变量;
|
|
4013
|
+
* 文本本身由调用方从 `linesNow()` 取(宠物默认 + 用户覆盖)。
|
|
4014
|
+
*/
|
|
3437
4015
|
const say = useCallback((text) => {
|
|
4016
|
+
if (!FLAGS.bubbleEnabled) return;
|
|
4017
|
+
if (typeof text !== "string" || text === "") return;
|
|
3438
4018
|
setBubble(text);
|
|
3439
4019
|
window.clearTimeout(bubbleTimer.current);
|
|
3440
|
-
bubbleTimer.current = window.setTimeout(() => setBubble(null),
|
|
4020
|
+
bubbleTimer.current = window.setTimeout(() => setBubble(null), TUNING.bubbleHoldMs);
|
|
3441
4021
|
}, []);
|
|
3442
4022
|
|
|
3443
4023
|
useEffect(() => () => window.clearTimeout(bubbleTimer.current), []);
|
|
@@ -3531,6 +4111,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3531
4111
|
phaseExpressionRef.current = Object.assign({}, phaseBaseRef.current.expressions);
|
|
3532
4112
|
// 设置界面(含 DSH 设置页那个独立组件)需要清单里有哪些动作/表情/槽位。
|
|
3533
4113
|
MANIFEST.current = pet;
|
|
4114
|
+
// 头部部件(宿主从 cdi3 的作者命名里挑的,21 个)交给控制器:摸头判定按这些
|
|
4115
|
+
// 部件的**真实三角面**测,而不是一个手调内边距的方框。
|
|
4116
|
+
motion.current.setHeadParts(pet.headParts ?? []);
|
|
4117
|
+
motion.current.setTailParts(pet.tailParts ?? []);
|
|
3534
4118
|
// 清单换了(换宠物 / pet.json 改了槽位结构)就先剪一遍存档:
|
|
3535
4119
|
// 旧槽位的覆盖会让"关系指向不存在的槽位"这类问题**静默**发生。
|
|
3536
4120
|
pruneOverrides(pet);
|
|
@@ -3692,7 +4276,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3692
4276
|
boot().catch((reason) => {
|
|
3693
4277
|
if (!disposed) {
|
|
3694
4278
|
setError(String((reason && reason.message) || reason));
|
|
3695
|
-
say(pick(
|
|
4279
|
+
say(pick(linesNow().loadFailed));
|
|
3696
4280
|
}
|
|
3697
4281
|
});
|
|
3698
4282
|
|
|
@@ -3747,7 +4331,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3747
4331
|
useEffect(() => {
|
|
3748
4332
|
if (!ready || greeted.current) return;
|
|
3749
4333
|
greeted.current = true;
|
|
3750
|
-
say(pick(
|
|
4334
|
+
say(pick(linesNow().greet));
|
|
3751
4335
|
}, [ready, say]);
|
|
3752
4336
|
|
|
3753
4337
|
|
|
@@ -3770,10 +4354,62 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3770
4354
|
let resting = false;
|
|
3771
4355
|
focusDefault();
|
|
3772
4356
|
resting = true;
|
|
4357
|
+
// 转圈检测的状态:上一次的角度、累计转角、这轮累计的起始时刻、上次触发时刻。
|
|
4358
|
+
let spinLastAngle = null;
|
|
4359
|
+
let spinTotal = 0;
|
|
4360
|
+
let spinStartedAt = 0;
|
|
4361
|
+
let spinLastFire = 0;
|
|
4362
|
+
const spinTrack = (rect, x, y) => {
|
|
4363
|
+
if (!FLAGS.spinEnabled) return;
|
|
4364
|
+
spinStatsRef.current.moves += 1;
|
|
4365
|
+
const cx = rect.width / 2;
|
|
4366
|
+
const cy = rect.height / 2;
|
|
4367
|
+
const dx = x - cx;
|
|
4368
|
+
const dy = y - cy;
|
|
4369
|
+
// 太靠近中心时角度会剧烈抖动(半径趋近 0):跳过,不参与累计。
|
|
4370
|
+
if (Math.hypot(dx, dy) < rect.width * 0.12) return;
|
|
4371
|
+
const angle = Math.atan2(dy, dx);
|
|
4372
|
+
if (spinLastAngle === null) {
|
|
4373
|
+
spinLastAngle = angle;
|
|
4374
|
+
spinStartedAt = Date.now();
|
|
4375
|
+
return;
|
|
4376
|
+
}
|
|
4377
|
+
let delta = angle - spinLastAngle;
|
|
4378
|
+
// 归一化到 (-π, π],否则跨越 ±π 时会出现一整个 2π 的假增量。
|
|
4379
|
+
if (delta > Math.PI) delta -= Math.PI * 2;
|
|
4380
|
+
if (delta < -Math.PI) delta += Math.PI * 2;
|
|
4381
|
+
spinLastAngle = angle;
|
|
4382
|
+
const now = Date.now();
|
|
4383
|
+
// "要在多少 ms 内转够 N 圈":窗口从**这轮累计开始**算起,超时就整轮作废、
|
|
4384
|
+
// 从这一下重新开始。原来写的是 `now - spinLastFire > 窗口` —— 那个值一开始
|
|
4385
|
+
// 恒大于窗口,于是每次移动都把累计清零,永远攒不到 π(诊断读口里 total 一直是 0.07)。
|
|
4386
|
+
if (now - spinStartedAt > TUNING.spinWindowMs) {
|
|
4387
|
+
spinTotal = 0;
|
|
4388
|
+
spinStartedAt = now;
|
|
4389
|
+
}
|
|
4390
|
+
spinTotal += delta;
|
|
4391
|
+
spinStatsRef.current.total = spinTotal;
|
|
4392
|
+
if (Math.abs(spinTotal) < TUNING.spinTurns * Math.PI * 2) return;
|
|
4393
|
+
if (now - spinLastFire < 1500) return;
|
|
4394
|
+
spinTotal = 0;
|
|
4395
|
+
spinStartedAt = now;
|
|
4396
|
+
spinLastFire = now;
|
|
4397
|
+
spinStatsRef.current.fires += 1;
|
|
4398
|
+
lastInteraction.current = now;
|
|
4399
|
+
const list = interactionReactions("spinReactions");
|
|
4400
|
+
if (list.length > 0) runReactionRef.current(pick(list));
|
|
4401
|
+
say(pick(linesNow().spin));
|
|
4402
|
+
};
|
|
3773
4403
|
const onMove = (event) => {
|
|
3774
4404
|
const rect = stage.getBoundingClientRect();
|
|
3775
4405
|
const x = event.clientX - rect.left;
|
|
3776
4406
|
const y = event.clientY - rect.top;
|
|
4407
|
+
// ---- 鼠标围着转圈 → 转晕 ------------------------------------------
|
|
4408
|
+
// 判定的是"围绕舞台中心的**累计转角**":每次移动取与上一次的夹角增量
|
|
4409
|
+
// (归一化到 ±180°),在一段时间窗内累计;够 spinTurns 圈就触发一次。
|
|
4410
|
+
// 用累计角而不是"位置绕了几圈",是因为前者对半径不敏感 —— 贴着角色转
|
|
4411
|
+
// 小圈和远远地转大圈都算,符合"逗她"的直觉。
|
|
4412
|
+
spinTrack(rect, x, y);
|
|
3777
4413
|
const near = x >= -TUNING.gazeRange && y >= -TUNING.gazeRange
|
|
3778
4414
|
&& x <= rect.width + TUNING.gazeRange && y <= rect.height + TUNING.gazeRange;
|
|
3779
4415
|
if (near) {
|
|
@@ -4083,6 +4719,30 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4083
4719
|
armExpressionClear();
|
|
4084
4720
|
}, [armExpressionClear]);
|
|
4085
4721
|
|
|
4722
|
+
/**
|
|
4723
|
+
* 跑一条互动反应(点击 / 转圈触发)。
|
|
4724
|
+
*
|
|
4725
|
+
* 标签先当**动作组**找(宠物在 catalog.json 里给动作起了中文名),找不到就当
|
|
4726
|
+
* **表达式**闪一下(闪完由 EXPRESSION_HOLD_MS 自动收,不会永久占着槽位)。
|
|
4727
|
+
*/
|
|
4728
|
+
const runReaction = useCallback((label) => {
|
|
4729
|
+
if (typeof label !== "string" || label === "") return false;
|
|
4730
|
+
const groups = motion.current.groups();
|
|
4731
|
+
const entry = (petRef.current?.motions ?? []).find((item) => item.label === label);
|
|
4732
|
+
const group = entry !== undefined && Array.isArray(groups[entry.group]) ? entry.group : undefined;
|
|
4733
|
+
if (group !== undefined) {
|
|
4734
|
+
motion.current.playOnce(group, 0, { kind: "tap" });
|
|
4735
|
+
return true;
|
|
4736
|
+
}
|
|
4737
|
+
if (Array.isArray(groups[label])) {
|
|
4738
|
+
motion.current.playOnce(label, 0, { kind: "tap" });
|
|
4739
|
+
return true;
|
|
4740
|
+
}
|
|
4741
|
+
flashExpression(label);
|
|
4742
|
+
return true;
|
|
4743
|
+
}, [flashExpression]);
|
|
4744
|
+
runReactionRef.current = runReaction;
|
|
4745
|
+
|
|
4086
4746
|
/**
|
|
4087
4747
|
* Choose an option within one dress-up slot.
|
|
4088
4748
|
*
|
|
@@ -4287,7 +4947,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4287
4947
|
}
|
|
4288
4948
|
commitPinsRef.current();
|
|
4289
4949
|
motion.current.resetToRest();
|
|
4290
|
-
say(pick(
|
|
4950
|
+
say(pick(linesNow().reset));
|
|
4291
4951
|
}, [applyExpressions, say]);
|
|
4292
4952
|
|
|
4293
4953
|
/**
|
|
@@ -4480,6 +5140,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4480
5140
|
phasePinsRef.current[expression] = true;
|
|
4481
5141
|
commitPinsRef.current();
|
|
4482
5142
|
}
|
|
5143
|
+
// 相位台词(pet.json 的 `lines.phase.<相位>`,用户可在设置里改)。
|
|
5144
|
+
// 空字符串 = 这个相位不弹(idle 就没有),所以这里只是"有才弹"。
|
|
5145
|
+
const line = linesNow().phase[phase] ?? "";
|
|
5146
|
+
if (line !== "") say(line);
|
|
4483
5147
|
};
|
|
4484
5148
|
// The sustain loop lives in the controller, but the phase -> group map
|
|
4485
5149
|
// comes from the pet manifest, so hand the resolver over. 池子里抽到过动作的
|
|
@@ -4793,6 +5457,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4793
5457
|
return motion.current.hitsHead(clientX - rect.left, clientY - rect.top);
|
|
4794
5458
|
}, []);
|
|
4795
5459
|
|
|
5460
|
+
/** 摸尾巴的命中(和摸头同一套,坐标换算也一样走舞台矩形)。 */
|
|
5461
|
+
const hitsTail = useCallback((clientX, clientY) => {
|
|
5462
|
+
const stage = stageRef.current;
|
|
5463
|
+
if (stage === null) return false;
|
|
5464
|
+
const rect = stage.getBoundingClientRect();
|
|
5465
|
+
return motion.current.hitsTail(clientX - rect.left, clientY - rect.top);
|
|
5466
|
+
}, []);
|
|
5467
|
+
|
|
4796
5468
|
/**
|
|
4797
5469
|
* Right-click on the pet opens the whole control panel.
|
|
4798
5470
|
*
|
|
@@ -4847,10 +5519,11 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4847
5519
|
// Resolved once, at press time: the model keeps swaying, so asking
|
|
4848
5520
|
// again on release could answer differently than the press did.
|
|
4849
5521
|
onHead: hitsHead(event.clientX, event.clientY),
|
|
5522
|
+
onTail: hitsTail(event.clientX, event.clientY),
|
|
4850
5523
|
};
|
|
4851
5524
|
setDragging(true);
|
|
4852
5525
|
try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* not capturable */ }
|
|
4853
|
-
}, [hitsModel, hitsHead]);
|
|
5526
|
+
}, [hitsModel, hitsHead, hitsTail]);
|
|
4854
5527
|
|
|
4855
5528
|
useEffect(() => {
|
|
4856
5529
|
const onMove = (event) => {
|
|
@@ -4880,25 +5553,26 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4880
5553
|
});
|
|
4881
5554
|
} else if (state.onModel) {
|
|
4882
5555
|
lastInteraction.current = Date.now();
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
//
|
|
4888
|
-
//
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
5556
|
+
// **摸头优先于摸尾巴**:两者区域可能重叠(尾巴/翅膀挂件在身后,几何上会伸到
|
|
5557
|
+
// 头部附近),先判头才符合直觉 —— 用户报的"摸头出的是摸尾巴的效果"就是
|
|
5558
|
+
// 原来先判尾巴造成的。隐藏配件的问题另有 isDrawableVisible 兜着。
|
|
5559
|
+
if (state.onHead && FLAGS.patEnabled) {
|
|
5560
|
+
// 摸头:从 `patReactions` 里随机挑一个(默认是 重锤出击 / 问号 / 星星眼),
|
|
5561
|
+
// 并且**故意不脸红**。表情类反应是"闪一下",到点由自动清理收走,
|
|
5562
|
+
// 所以摸头不会在用户选的槽位上留下永久表情。
|
|
5563
|
+
const list = interactionReactions("patReactions");
|
|
5564
|
+
if (list.length > 0) runReactionRef.current(pick(list));
|
|
5565
|
+
say(pick(linesNow().pat));
|
|
5566
|
+
} else if (state.onTail && FLAGS.tailEnabled) {
|
|
5567
|
+
// 摸尾巴:判据和摸头同一套(模型自己的三角面),部件集合是 cdi3 里
|
|
5568
|
+
// 命名为尾/鳍/翅/翼 的那些。反应从 pet.json 的 `tailReactions` 里随机。
|
|
5569
|
+
const list = interactionReactions("tailReactions");
|
|
5570
|
+
if (list.length > 0) runReactionRef.current(pick(list));
|
|
5571
|
+
say(pick(linesNow().tail));
|
|
4898
5572
|
} else {
|
|
4899
5573
|
// Anywhere else on the character is a lighter acknowledgement —
|
|
4900
5574
|
// deliberately WITHOUT 重锤出击, which now belongs to the head only.
|
|
4901
|
-
say(pick(
|
|
5575
|
+
say(pick(linesNow().click));
|
|
4902
5576
|
}
|
|
4903
5577
|
}
|
|
4904
5578
|
};
|
|
@@ -5115,7 +5789,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5115
5789
|
...(maskPath === "" ? {} : { onPointerDown, onContextMenu }),
|
|
5116
5790
|
}),
|
|
5117
5791
|
),
|
|
5118
|
-
|
|
5792
|
+
// 气泡的偏移量走 CSS 变量(值,不是布局):位置规则仍然只写在样式表里。
|
|
5793
|
+
bubble === null ? null : h("div", {
|
|
5794
|
+
"data-bubble": "",
|
|
5795
|
+
style: {
|
|
5796
|
+
"--bubble-x": TUNING.bubbleOffsetX + "px",
|
|
5797
|
+
"--bubble-y": TUNING.bubbleOffsetY + "px",
|
|
5798
|
+
},
|
|
5799
|
+
}, bubble),
|
|
5119
5800
|
panel,
|
|
5120
5801
|
);
|
|
5121
5802
|
}
|
|
@@ -5561,6 +6242,129 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5561
6242
|
* 按标签页条件渲染的 —— 一切到"设置",Pet 的 hook 数量就变了,React 会抛
|
|
5562
6243
|
* "Rendered more hooks than during the previous render" 并把**整只宠物**卸载。
|
|
5563
6244
|
*/
|
|
6245
|
+
/** 设置页里每条台词的标题(顺序 = 显示顺序)。 */
|
|
6246
|
+
const LINE_FIELDS = [
|
|
6247
|
+
{ key: "greet", label: "开始的时候" },
|
|
6248
|
+
{ key: "click", label: "点她身上(不是头和尾巴)" },
|
|
6249
|
+
{ key: "pat", label: "摸摸头" },
|
|
6250
|
+
{ key: "tail", label: "摸尾巴" },
|
|
6251
|
+
{ key: "spin", label: "被转晕" },
|
|
6252
|
+
{ key: "reset", label: "点「归位」" },
|
|
6253
|
+
{ key: "loadFailed", label: "模型加载失败" },
|
|
6254
|
+
];
|
|
6255
|
+
const PHASE_LINE_FIELDS = [
|
|
6256
|
+
{ key: "thinking", label: "思考中" },
|
|
6257
|
+
{ key: "tool", label: "用工具" },
|
|
6258
|
+
{ key: "waiting", label: "等你批准" },
|
|
6259
|
+
{ key: "asking", label: "等你回答" },
|
|
6260
|
+
{ key: "helper", label: "叫了帮手" },
|
|
6261
|
+
{ key: "queued", label: "消息排队" },
|
|
6262
|
+
{ key: "done", label: "这一轮完成" },
|
|
6263
|
+
{ key: "failed", label: "出错" },
|
|
6264
|
+
];
|
|
6265
|
+
|
|
6266
|
+
/**
|
|
6267
|
+
* 互动:三个开关 + 三套反应候选。
|
|
6268
|
+
*
|
|
6269
|
+
* 候选直接用**宠物自己的清单**(动作的中文名 + 表情名),用户不用记 id ——
|
|
6270
|
+
* `runReaction()` 也是这个顺序:先当动作组找,找不到就闪一下表情。
|
|
6271
|
+
*/
|
|
6272
|
+
function InteractControls() {
|
|
6273
|
+
useSettings();
|
|
6274
|
+
const listFor = (key) => {
|
|
6275
|
+
const mine = PHASE_OVERRIDES.interactions?.[key];
|
|
6276
|
+
if (Array.isArray(mine) && mine.length > 0) return mine;
|
|
6277
|
+
const base = MANIFEST.current?.[key];
|
|
6278
|
+
return Array.isArray(base) ? base : [];
|
|
6279
|
+
};
|
|
6280
|
+
const toggle = (key, label) => {
|
|
6281
|
+
const next = listFor(key).slice();
|
|
6282
|
+
const at = next.indexOf(label);
|
|
6283
|
+
if (at >= 0) next.splice(at, 1);
|
|
6284
|
+
else next.push(label);
|
|
6285
|
+
applyOverride({ interactions: { [key]: next } });
|
|
6286
|
+
};
|
|
6287
|
+
const pet = MANIFEST.current ?? {};
|
|
6288
|
+
const candidates = Array.from(new Set([
|
|
6289
|
+
...(pet.motions ?? []).map((motion) => motion.label),
|
|
6290
|
+
...(pet.expressions ?? []).map((expression) => expression.label),
|
|
6291
|
+
].filter((label) => typeof label === "string" && label !== "")));
|
|
6292
|
+
const switchRow = (key, label, note) => h("label", { "data-flag-row": key },
|
|
6293
|
+
h("input", {
|
|
6294
|
+
type: "checkbox",
|
|
6295
|
+
checked: FLAGS[key] === true,
|
|
6296
|
+
"data-flag": key,
|
|
6297
|
+
onChange: (event) => applyFlag(key, event.target.checked),
|
|
6298
|
+
}),
|
|
6299
|
+
h("span", { "data-row-label": "" }, label),
|
|
6300
|
+
note === undefined ? null : h("span", { "data-note-inline": "" }, note),
|
|
6301
|
+
);
|
|
6302
|
+
const reactionRow = (key, title, note) => h("div", { "data-reaction-set": key },
|
|
6303
|
+
h("div", { "data-row-label": "" }, title, note === undefined ? null : h("span", { "data-note-inline": "" }, note)),
|
|
6304
|
+
h("div", { "data-chips": "" }, candidates.length === 0
|
|
6305
|
+
? h("span", { "data-note-inline": "" }, "这只宠物没有可选的动作/表情")
|
|
6306
|
+
: candidates.map((label) => {
|
|
6307
|
+
const on = listFor(key).includes(label);
|
|
6308
|
+
return h("button", {
|
|
6309
|
+
key: label,
|
|
6310
|
+
type: "button",
|
|
6311
|
+
"data-reaction-chip": key + ":" + label,
|
|
6312
|
+
...(on ? { "data-on": "" } : {}),
|
|
6313
|
+
onClick: () => toggle(key, label),
|
|
6314
|
+
}, label);
|
|
6315
|
+
})),
|
|
6316
|
+
);
|
|
6317
|
+
return h("div", { "data-settings": "", "data-setting": "interact" },
|
|
6318
|
+
switchRow("patEnabled", "摸头有反应"),
|
|
6319
|
+
switchRow("tailEnabled", "摸尾巴有反应"),
|
|
6320
|
+
switchRow("spinEnabled", "鼠标绕着转圈会晕"),
|
|
6321
|
+
h(TuningControls, { key: "spin", group: "interact" }),
|
|
6322
|
+
reactionRow("patReactions", "摸头时演什么", "(随机一个)"),
|
|
6323
|
+
reactionRow("tailReactions", "摸尾巴时演什么", "(随机一个)"),
|
|
6324
|
+
reactionRow("spinReactions", "转晕时演什么", "(随机一个)"),
|
|
6325
|
+
);
|
|
6326
|
+
}
|
|
6327
|
+
|
|
6328
|
+
/**
|
|
6329
|
+
* 台词:所有气泡文本。
|
|
6330
|
+
*
|
|
6331
|
+
* 一组 = 一行输入,**用 `|` 分隔多个变体**(随机挑一句);相位台词是单句。
|
|
6332
|
+
* 留空就退回宠物默认(`pet.json` 的 `live2d.lines`),所以"看一眼"不会把默认改掉。
|
|
6333
|
+
*/
|
|
6334
|
+
function LineControls() {
|
|
6335
|
+
useSettings();
|
|
6336
|
+
const effective = linesNow();
|
|
6337
|
+
const setLines = (patch) => applyOverride({ lines: patch });
|
|
6338
|
+
const row = (id, label, value, onInput, placeholder) => h("label", { "data-line-row": id },
|
|
6339
|
+
h("span", { "data-row-label": "" }, label),
|
|
6340
|
+
h("input", {
|
|
6341
|
+
type: "text",
|
|
6342
|
+
value,
|
|
6343
|
+
placeholder,
|
|
6344
|
+
"data-line-input": id,
|
|
6345
|
+
onChange: (event) => onInput(event.target.value),
|
|
6346
|
+
}),
|
|
6347
|
+
);
|
|
6348
|
+
return h("div", { "data-settings": "", "data-setting": "lines" },
|
|
6349
|
+
h("div", { "data-note": "" }, "多个变体用 | 分隔(随机挑一句)。留空 = 用宠物默认。"),
|
|
6350
|
+
...LINE_FIELDS.map((field) => row(
|
|
6351
|
+
field.key,
|
|
6352
|
+
field.label,
|
|
6353
|
+
joinLineInput(effective[field.key]),
|
|
6354
|
+
(text) => setLines({ [field.key]: splitLineInput(text) }),
|
|
6355
|
+
joinLineInput(MANIFEST.current?.lines?.[field.key]),
|
|
6356
|
+
)),
|
|
6357
|
+
h("div", { "data-row-label": "", "data-note": "" }, "会话相位"),
|
|
6358
|
+
...PHASE_LINE_FIELDS.map((field) => row(
|
|
6359
|
+
"phase:" + field.key,
|
|
6360
|
+
field.label,
|
|
6361
|
+
effective.phase?.[field.key] ?? "",
|
|
6362
|
+
(text) => setLines({ phase: { [field.key]: text } }),
|
|
6363
|
+
MANIFEST.current?.lines?.phase?.[field.key] ?? "",
|
|
6364
|
+
)),
|
|
6365
|
+
);
|
|
6366
|
+
}
|
|
6367
|
+
|
|
5564
6368
|
function PetSettingsBody() {
|
|
5565
6369
|
useSettings();
|
|
5566
6370
|
const card = (key, title, hint, body) => h("div", { key, "data-card": key },
|
|
@@ -5571,7 +6375,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5571
6375
|
h("div", { "data-card-body": "" }, body),
|
|
5572
6376
|
);
|
|
5573
6377
|
return [
|
|
5574
|
-
...TUNING_GROUPS.filter((group) => group.id
|
|
6378
|
+
...TUNING_GROUPS.filter((group) => !TUNING_GROUPS_INLINE.includes(group.id)).map((group) => card(
|
|
5575
6379
|
"tune-" + group.id, group.label, group.hint, h(TuningControls, { group: group.id }))),
|
|
5576
6380
|
card("phases", "会话相位", "每个相位一组池子", h(PhaseControls, null)),
|
|
5577
6381
|
// 「摸鱼节奏」(多久摸一次)和「摸鱼」(摸鱼做什么)是同一件事的两半,原来
|
|
@@ -5581,6 +6385,22 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5581
6385
|
h(FidgetControls, { key: "pools" }),
|
|
5582
6386
|
]),
|
|
5583
6387
|
card("outfit", "装扮", null, h(OutfitControls, null)),
|
|
6388
|
+
// 互动与气泡:开关、反应候选、以及**所有**气泡文本都在这两张卡里。
|
|
6389
|
+
card("interact", "互动", "摸头 / 摸尾巴 / 转圈", h(InteractControls, null)),
|
|
6390
|
+
card("bubble", "气泡", "显示什么 · 在哪 · 停留多久", [
|
|
6391
|
+
h("label", { "data-flag-row": "bubbleEnabled" },
|
|
6392
|
+
h("input", {
|
|
6393
|
+
type: "checkbox",
|
|
6394
|
+
checked: FLAGS.bubbleEnabled === true,
|
|
6395
|
+
"data-flag": "bubbleEnabled",
|
|
6396
|
+
onChange: (event) => applyFlag("bubbleEnabled", event.target.checked),
|
|
6397
|
+
}),
|
|
6398
|
+
h("span", { "data-row-label": "" }, "显示气泡"),
|
|
6399
|
+
h("span", { "data-note-inline": "" }, FLAGS.bubbleEnabled ? "" : "已关:任何台词都不弹"),
|
|
6400
|
+
),
|
|
6401
|
+
h(TuningControls, { key: "bubble", group: "bubble" }),
|
|
6402
|
+
h(LineControls, { key: "lines" }),
|
|
6403
|
+
]),
|
|
5584
6404
|
];
|
|
5585
6405
|
}
|
|
5586
6406
|
|