dsh-pet-live2d 2.1.0 → 2.3.0
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 +75 -0
- package/README.md +44 -2
- package/lib/client.js +690 -39
- package/lib/index.js +175 -13
- package/package.json +1 -1
- package/pets/ds-whale-girl/pet.json +74 -0
package/lib/client.js
CHANGED
|
@@ -281,6 +281,123 @@ 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
|
+
* @returns {number[]|null} drawable 下标;表结构不认识时返回 null(退回旧行为)
|
|
304
|
+
*/
|
|
305
|
+
const drawableIndicesForParts = (parts) => {
|
|
306
|
+
const raw = model?.internalModel?.coreModel?._model;
|
|
307
|
+
const parent = raw?.drawables?.parentPartIndices;
|
|
308
|
+
const partIds = raw?.parts?.ids;
|
|
309
|
+
if (parent === undefined || partIds === undefined) return null;
|
|
310
|
+
const ids = Array.from(partIds).map(String);
|
|
311
|
+
if (ids.length === 0 || parts.length === 0) return null;
|
|
312
|
+
const wanted = new Set(parts);
|
|
313
|
+
const out = [];
|
|
314
|
+
for (let i = 0; i < parent.length; i += 1) {
|
|
315
|
+
const partIndex = parent[i];
|
|
316
|
+
if (partIndex < 0 || partIndex >= ids.length) continue;
|
|
317
|
+
if (!wanted.has(ids[partIndex])) continue;
|
|
318
|
+
out.push(i);
|
|
319
|
+
}
|
|
320
|
+
return out.length > 0 ? out : null;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 点在不在这些 drawable 的**三角面**里(模型空间)。
|
|
325
|
+
*
|
|
326
|
+
* @param {number[]|null} indices drawable 下标(null = 解不出来)
|
|
327
|
+
* @returns {boolean|null} `null` = 读不到顶点(调用方落回旧行为,别把宠物变哑巴)
|
|
328
|
+
*/
|
|
329
|
+
const hitsPartsGeometry = (indices, x, y) => {
|
|
330
|
+
const im = model?.internalModel;
|
|
331
|
+
if (im === undefined || im === null) return null;
|
|
332
|
+
// 顶点在**包装层**、三角形索引在**core 层**(实测:`im` 有 getDrawableVertices,
|
|
333
|
+
// `im.coreModel` 才有 getDrawableVertexIndices)—— 只查一个就会静默返回 null、
|
|
334
|
+
// 悄悄退回方框判定。两边各取所长。
|
|
335
|
+
const verticesOf = typeof im.getDrawableVertices === "function"
|
|
336
|
+
? (index) => im.getDrawableVertices(index)
|
|
337
|
+
: (typeof im.coreModel?.getDrawableVertices === "function"
|
|
338
|
+
? (index) => im.coreModel.getDrawableVertices(index)
|
|
339
|
+
: null);
|
|
340
|
+
const indicesOf = typeof im.getDrawableVertexIndices === "function"
|
|
341
|
+
? (index) => im.getDrawableVertexIndices(index)
|
|
342
|
+
: (typeof im.coreModel?.getDrawableVertexIndices === "function"
|
|
343
|
+
? (index) => im.coreModel.getDrawableVertexIndices(index)
|
|
344
|
+
: null);
|
|
345
|
+
if (verticesOf === null || indicesOf === null) return null;
|
|
346
|
+
if (indices === null) return null;
|
|
347
|
+
let read = false;
|
|
348
|
+
try {
|
|
349
|
+
for (const index of indices) {
|
|
350
|
+
const verts = verticesOf(index);
|
|
351
|
+
if (verts === undefined || verts === null || verts.length < 6) continue;
|
|
352
|
+
read = true;
|
|
353
|
+
// 先用这个 drawable 的包围盒排除(绝大多数部件一眼就出局,不用扫三角形)。
|
|
354
|
+
let minX = Infinity;
|
|
355
|
+
let minY = Infinity;
|
|
356
|
+
let maxX = -Infinity;
|
|
357
|
+
let maxY = -Infinity;
|
|
358
|
+
for (let i = 0; i < verts.length; i += 2) {
|
|
359
|
+
const vx = verts[i];
|
|
360
|
+
const vy = verts[i + 1];
|
|
361
|
+
if (vx < minX) minX = vx;
|
|
362
|
+
if (vx > maxX) maxX = vx;
|
|
363
|
+
if (vy < minY) minY = vy;
|
|
364
|
+
if (vy > maxY) maxY = vy;
|
|
365
|
+
}
|
|
366
|
+
if (x < minX || x > maxX || y < minY || y > maxY) continue;
|
|
367
|
+
const indices = indicesOf(index);
|
|
368
|
+
if (indices === undefined || indices === null) continue;
|
|
369
|
+
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
370
|
+
if (pointInTriangle(x, y, verts, indices[i], indices[i + 1], indices[i + 2])) return true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} catch {
|
|
374
|
+
// 读到一半炸了:当作"读不到",让调用方用方框兜底。
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return read ? false : null;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/** 点 (x,y) 在 verts 的第 i0/i1/i2 号顶点组成的三角形里吗(同向叉积法)。 */
|
|
381
|
+
const pointInTriangle = (x, y, verts, i0, i1, i2) => {
|
|
382
|
+
const ax = verts[i0 * 2];
|
|
383
|
+
const ay = verts[i0 * 2 + 1];
|
|
384
|
+
const bx = verts[i1 * 2];
|
|
385
|
+
const by = verts[i1 * 2 + 1];
|
|
386
|
+
const cx = verts[i2 * 2];
|
|
387
|
+
const cy = verts[i2 * 2 + 1];
|
|
388
|
+
// 退化三角形(三个点几乎重合)**面积为零**,叉积全 0 → 下面的判定会返回 true,
|
|
389
|
+
// 于是"每个点都算命中"。隐藏的部件(缩放成一点)正好是这种,必须直接排掉。
|
|
390
|
+
const area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
|
|
391
|
+
if (Math.abs(area) < 1e-6) return false;
|
|
392
|
+
const d1 = (x - bx) * (ay - by) - (ax - bx) * (y - by);
|
|
393
|
+
const d2 = (x - cx) * (by - cy) - (bx - cx) * (y - cy);
|
|
394
|
+
const d3 = (x - ax) * (cy - ay) - (cx - ax) * (y - ay);
|
|
395
|
+
const hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
|
|
396
|
+
const hasPos = d1 > 0 || d2 > 0 || d3 > 0;
|
|
397
|
+
return !(hasNeg && hasPos);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
|
|
284
401
|
const measureHead = (nextModel) => {
|
|
285
402
|
try {
|
|
286
403
|
const im = nextModel?.internalModel;
|
|
@@ -1302,6 +1419,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
1302
1419
|
ambientCursor = 0;
|
|
1303
1420
|
poseSnapshots.clear();
|
|
1304
1421
|
paramIndexCache.clear();
|
|
1422
|
+
headIndices = null;
|
|
1305
1423
|
const idleDuration = idleName === null ? 0 : (groups[idleName]?.[0]?.duration ?? 0);
|
|
1306
1424
|
ambientPeriodMs = idleDuration > 0 ? idleDuration : 4000;
|
|
1307
1425
|
// Locate the head once, from the model's own geometry; it is stored in
|
|
@@ -1691,28 +1809,47 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
1691
1809
|
}
|
|
1692
1810
|
return false;
|
|
1693
1811
|
},
|
|
1812
|
+
/** Head-part ids, pushed in by the component once the catalog is ready. */
|
|
1813
|
+
setHeadParts(ids) {
|
|
1814
|
+
headParts = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
|
|
1815
|
+
// 换宠物 / 换模型:下标缓存必须作废,否则会拿旧模型的 drawable 判定。
|
|
1816
|
+
headIndices = null;
|
|
1817
|
+
},
|
|
1818
|
+
/** Tail-part ids(同上,摸尾巴用)。 */
|
|
1819
|
+
setTailParts(ids) {
|
|
1820
|
+
tailParts = Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
|
|
1821
|
+
tailIndices = null;
|
|
1822
|
+
},
|
|
1694
1823
|
idleName: () => idleName,
|
|
1695
1824
|
groups: () => groups,
|
|
1696
1825
|
/** Declared playback policy for one motion group (diagnostics). */
|
|
1697
1826
|
optionsFor,
|
|
1698
1827
|
/** Whether the idle fidget is allowed to pick this motion group. */
|
|
1699
1828
|
fidgetAllowed,
|
|
1700
|
-
|
|
1701
|
-
|
|
1829
|
+
/**
|
|
1830
|
+
* Whether a tap landed on the head (requirement #1).
|
|
1702
1831
|
*
|
|
1703
|
-
*
|
|
1704
|
-
*
|
|
1705
|
-
*
|
|
1832
|
+
* 有 cdi3 部件名时**按模型自己的三角面**判定:把点映到模型空间,对每个头部
|
|
1833
|
+
* drawable 先做包围盒快速排除,再做点在三角形内 —— 用的就是模型当前的几何
|
|
1834
|
+
* (发型边缘、脸部轮廓都对),而不是一个手调内边距的方框。顶点每次点击现读,
|
|
1835
|
+
* 所以头歪着、身体摆着也是准的。
|
|
1706
1836
|
*
|
|
1707
|
-
*
|
|
1708
|
-
*
|
|
1837
|
+
* 没有部件名(换宠物、没 cdi3)时退回旧行为:用英文部件名猜出来的方框;
|
|
1838
|
+
* 连那个都测不到就返回 true —— 不认识的模型保持"点哪都算头",而不是变哑巴。
|
|
1709
1839
|
*/
|
|
1710
1840
|
hitsHead(x, y) {
|
|
1711
|
-
if (
|
|
1841
|
+
if (model === null || vendor === null) return true;
|
|
1712
1842
|
try {
|
|
1713
1843
|
// Pass one arg only: the engine then clones into a fresh Point, so
|
|
1714
1844
|
// the stage-space input and the model-space output never alias.
|
|
1715
1845
|
const point = model.toModelPosition(new vendor.Point(x, y));
|
|
1846
|
+
if (headParts.length > 0) {
|
|
1847
|
+
if (headIndices === null) headIndices = drawableIndicesForParts(headParts);
|
|
1848
|
+
const hit = hitsPartsGeometry(headIndices, point.x, point.y);
|
|
1849
|
+
// 几何判定只在"真的能读到顶点"时算数;读不到就落回方框。
|
|
1850
|
+
if (hit !== null) return hit;
|
|
1851
|
+
}
|
|
1852
|
+
if (headBox === null) return true;
|
|
1716
1853
|
return point.x >= headBox.minX && point.x <= headBox.maxX
|
|
1717
1854
|
&& point.y >= headBox.minY && point.y <= headBox.maxY;
|
|
1718
1855
|
} catch {
|
|
@@ -1721,6 +1858,100 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
1721
1858
|
},
|
|
1722
1859
|
/** Diagnostic: the measured head box in model space, or null. */
|
|
1723
1860
|
headBox: () => headBox,
|
|
1861
|
+
/**
|
|
1862
|
+
* 这一下点在尾巴上吗?
|
|
1863
|
+
*
|
|
1864
|
+
* 和摸头同一套判定(模型自己的三角面),只是部件集合换成 cdi3 里命名为
|
|
1865
|
+
* 尾/鳍/翅/翼 的那些。**没有尾巴部件时返回 false**(不是 true):摸尾巴是个
|
|
1866
|
+
* 新增的互动,测不出来就不该乱触发(摸头那边相反,它要兼容旧模型)。
|
|
1867
|
+
*/
|
|
1868
|
+
hitsTail(x, y) {
|
|
1869
|
+
if (model === null || vendor === null) return false;
|
|
1870
|
+
if (tailParts.length === 0) return false;
|
|
1871
|
+
try {
|
|
1872
|
+
const point = model.toModelPosition(new vendor.Point(x, y));
|
|
1873
|
+
if (tailIndices === null) tailIndices = drawableIndicesForParts(tailParts);
|
|
1874
|
+
const hit = hitsPartsGeometry(tailIndices, point.x, point.y);
|
|
1875
|
+
return hit === true;
|
|
1876
|
+
} catch {
|
|
1877
|
+
return false;
|
|
1878
|
+
}
|
|
1879
|
+
},
|
|
1880
|
+
/**
|
|
1881
|
+
* Diagnostic: **旧**方框规则(手调内边距的那个),用来跟几何判定对比。
|
|
1882
|
+
*
|
|
1883
|
+
* 存在的意义是测试:能证明"新规则真的不一样",而不是两条路返回同一个答案。
|
|
1884
|
+
*/
|
|
1885
|
+
hitsHeadBox: (x, y) => {
|
|
1886
|
+
if (headBox === null || model === null || vendor === null) return null;
|
|
1887
|
+
try {
|
|
1888
|
+
const point = model.toModelPosition(new vendor.Point(x, y));
|
|
1889
|
+
return point.x >= headBox.minX && point.x <= headBox.maxX
|
|
1890
|
+
&& point.y >= headBox.minY && point.y <= headBox.maxY;
|
|
1891
|
+
} catch {
|
|
1892
|
+
return null;
|
|
1893
|
+
}
|
|
1894
|
+
},
|
|
1895
|
+
/** Diagnostic: 头部部件(来自 cdi3 的作者命名),空数组 = 退回旧判定。 */
|
|
1896
|
+
headPartIDs: () => headParts.slice(),
|
|
1897
|
+
/** Diagnostic: 模型声明的全部 drawable 名(认"头"只能靠这些名字)。 */
|
|
1898
|
+
drawableIDs: () => {
|
|
1899
|
+
const im = model?.internalModel;
|
|
1900
|
+
return typeof im?.getDrawableIDs === "function" ? Array.from(im.getDrawableIDs()).map(String) : [];
|
|
1901
|
+
},
|
|
1902
|
+
/**
|
|
1903
|
+
* Diagnostic: 引擎原始表里 drawable → 父部件 的对应关系。
|
|
1904
|
+
*
|
|
1905
|
+
* cdi3 的 `Parts` 是**部件** id(`Part46` 这种),`getDrawableIndex()` 认的是
|
|
1906
|
+
* drawable id(`lianhong` 这种)—— 两个命名空间不同名,直接拿部件 id 去查永远是 -1。
|
|
1907
|
+
* 引擎的原始表里带着 `parentPartIndices`,把两边接起来才能用上作者的命名。
|
|
1908
|
+
*/
|
|
1909
|
+
partTables: () => {
|
|
1910
|
+
const core = model?.internalModel?.coreModel;
|
|
1911
|
+
const raw = core?._model;
|
|
1912
|
+
const parts = raw?.parts;
|
|
1913
|
+
const drawables = raw?.drawables;
|
|
1914
|
+
return {
|
|
1915
|
+
partKeys: parts === undefined ? [] : Object.keys(parts),
|
|
1916
|
+
partIds: parts?.ids === undefined ? [] : Array.from(parts.ids).map(String),
|
|
1917
|
+
drawableKeys: drawables === undefined ? [] : Object.keys(drawables),
|
|
1918
|
+
parentPartIndices: drawables?.parentPartIndices === undefined
|
|
1919
|
+
? null
|
|
1920
|
+
: Array.from(drawables.parentPartIndices).slice(0, 12),
|
|
1921
|
+
};
|
|
1922
|
+
},
|
|
1923
|
+
/**
|
|
1924
|
+
* Diagnostic: 摸头判定的内部状态。
|
|
1925
|
+
*
|
|
1926
|
+
* cdi3 的 `Parts` 是**部件**(part)id,而 `getDrawableIndex()` 认的是 **drawable**
|
|
1927
|
+
* id —— 两个命名空间不一定同名(这只模型里就不同)。所以这里要把"挑到的部件"
|
|
1928
|
+
* 和"真的能在模型里解出下标的"分开报,否则判定静默退回方框、外面看不出来。
|
|
1929
|
+
*/
|
|
1930
|
+
headDebug: () => {
|
|
1931
|
+
if (headIndices === null) headIndices = drawableIndicesForParts(headParts);
|
|
1932
|
+
if (tailIndices === null) tailIndices = drawableIndicesForParts(tailParts);
|
|
1933
|
+
const im = model?.internalModel;
|
|
1934
|
+
const core = im?.coreModel;
|
|
1935
|
+
const has = (target) => ["getDrawableVertices", "getDrawableVertexIndices", "getDrawableIndex", "getDrawableBounds", "getDrawableIDs"]
|
|
1936
|
+
.filter((name) => typeof target?.[name] === "function");
|
|
1937
|
+
let vertexProbe = "n/a";
|
|
1938
|
+
try {
|
|
1939
|
+
const v = im?.getDrawableVertices?.(headIndices?.[0] ?? 0);
|
|
1940
|
+
vertexProbe = v === undefined ? "undefined" : (v === null ? "null" : "len=" + v.length);
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
vertexProbe = "throw: " + String(error?.message ?? error);
|
|
1943
|
+
}
|
|
1944
|
+
return {
|
|
1945
|
+
parts: headParts.length,
|
|
1946
|
+
drawableIndices: headIndices === null ? 0 : headIndices.length,
|
|
1947
|
+
tailParts: tailParts.length,
|
|
1948
|
+
tailDrawableIndices: tailIndices === null ? 0 : tailIndices.length,
|
|
1949
|
+
apiOnInternalModel: has(im),
|
|
1950
|
+
apiOnCoreModel: has(core),
|
|
1951
|
+
vertexProbe,
|
|
1952
|
+
box: headBox,
|
|
1953
|
+
};
|
|
1954
|
+
},
|
|
1724
1955
|
/**
|
|
1725
1956
|
* Play a motion with its declared policy applied; used by the panel, the
|
|
1726
1957
|
* tap reaction and the session-phase driver.
|
|
@@ -1908,7 +2139,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
1908
2139
|
+ "--pp-accent:rgba(90,140,230,.15);--pp-accent-2:rgba(90,140,230,.18);"
|
|
1909
2140
|
+ "--pp-accent-3:rgba(90,140,230,.26);--pp-shadow:0 14px 34px rgba(20,30,50,.18);"
|
|
1910
2141
|
+ "--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}",
|
|
2142
|
+
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
2143
|
// Sits outside the pet's box entirely, so it must re-arm itself.
|
|
1913
2144
|
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
2145
|
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 +2271,24 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2040
2271
|
// 滑杆外观(轨道/圆钮/填充)和面板底部那根共用,见 sliderLook。
|
|
2041
2272
|
...sliderLook(scope + " input[type=range]"),
|
|
2042
2273
|
scope + " input[type=checkbox]{accent-color:rgba(120,170,255,.9)}",
|
|
2274
|
+
// ---- 台词 / 互动 ---------------------------------------------------
|
|
2275
|
+
// 台词是**文本**输入(不是数字),要能看清自己写了什么:给足宽度、字色正常,
|
|
2276
|
+
// 只在 hover/focus 时提亮边框 —— 和权重那个"读数旋钮"是两种东西,别共用样式。
|
|
2277
|
+
scope + " [data-line-row]{display:grid;grid-template-columns:120px 1fr;align-items:center;"
|
|
2278
|
+
+ "gap:6px;padding:2px 0}",
|
|
2279
|
+
scope + " input[type=text]{box-sizing:border-box;width:100%;min-width:0;font:inherit;"
|
|
2280
|
+
+ "font-size:11px;color:inherit;background:rgba(127,127,127,.08);"
|
|
2281
|
+
+ "border:1px solid rgba(127,127,127,.28);border-radius:7px;padding:2px 7px}",
|
|
2282
|
+
scope + " input[type=text]:hover{border-color:rgba(127,127,127,.5)}",
|
|
2283
|
+
scope + " input[type=text]:focus-visible{outline:2px solid rgba(120,170,255,.5);outline-offset:1px}",
|
|
2284
|
+
scope + " [data-note-inline]{font-size:10px;opacity:.55;margin-left:6px}",
|
|
2285
|
+
// 反应候选:一行 chips(和槽位选项同一套观感),选中的加底色。
|
|
2286
|
+
scope + " [data-reaction-set]{padding:5px 0 2px}",
|
|
2287
|
+
scope + " [data-reaction-set] [data-chips]{display:flex;flex-wrap:wrap;gap:4px;padding-top:4px}",
|
|
2288
|
+
scope + " [data-reaction-set] [data-chips] button{font-size:10px;padding:2px 8px;"
|
|
2289
|
+
+ "border-radius:999px;border:1px solid rgba(127,127,127,.28);background:rgba(127,127,127,.08)}",
|
|
2290
|
+
scope + " [data-reaction-set] [data-chips] button[data-on]{background:rgba(120,170,255,.28);"
|
|
2291
|
+
+ "border-color:rgba(120,170,255,.6)}",
|
|
2043
2292
|
scope + " code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;"
|
|
2044
2293
|
+ "opacity:.7;font-variant-numeric:tabular-nums}",
|
|
2045
2294
|
|
|
@@ -2138,11 +2387,42 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2138
2387
|
|
|
2139
2388
|
// ------------------------------------------------------------ lines
|
|
2140
2389
|
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2390
|
+
/**
|
|
2391
|
+
* 台词:**宠物默认 + 用户覆盖**。
|
|
2392
|
+
*
|
|
2393
|
+
* 默认值写在 pet.json 的 `live2d.lines` 里(宠物自己的声音),用户在设置页改过的存
|
|
2394
|
+
* 浏览器(`PHASE_OVERRIDES.lines`)。每条都是**一组**(随机挑一句),相位台词是单句。
|
|
2395
|
+
*/
|
|
2396
|
+
const LINE_KEYS = ["greet", "click", "pat", "tail", "spin", "reset", "loadFailed"];
|
|
2397
|
+
const PHASE_LINE_KEYS = ["thinking", "tool", "waiting", "asking", "helper", "queued", "done", "failed"];
|
|
2398
|
+
|
|
2399
|
+
/** 把一条"用 | 分隔"的输入切成台词数组(设置页的输入框就是这个格式)。 */
|
|
2400
|
+
const splitLineInput = (text) =>
|
|
2401
|
+
String(text ?? "")
|
|
2402
|
+
.split("|")
|
|
2403
|
+
.map((part) => part.trim())
|
|
2404
|
+
.filter((part) => part !== "");
|
|
2405
|
+
|
|
2406
|
+
/** 台词数组 -> 输入框里的一行。 */
|
|
2407
|
+
const joinLineInput = (list) => (Array.isArray(list) ? list.join(" | ") : "");
|
|
2408
|
+
|
|
2409
|
+
/** 有效台词:宠物默认 + 用户覆盖(覆盖为空就退回默认)。 */
|
|
2410
|
+
const linesNow = () => {
|
|
2411
|
+
const base = MANIFEST.current?.lines ?? {};
|
|
2412
|
+
const over = PHASE_OVERRIDES.lines ?? {};
|
|
2413
|
+
const out = {};
|
|
2414
|
+
for (const key of LINE_KEYS) {
|
|
2415
|
+
const mine = Array.isArray(over[key]) ? over[key].filter((s) => typeof s === "string" && s !== "") : [];
|
|
2416
|
+
const fallback = Array.isArray(base[key]) ? base[key].filter((s) => typeof s === "string" && s !== "") : [];
|
|
2417
|
+
out[key] = mine.length > 0 ? mine : fallback;
|
|
2418
|
+
}
|
|
2419
|
+
out.phase = {};
|
|
2420
|
+
for (const key of PHASE_LINE_KEYS) {
|
|
2421
|
+
const mine = typeof over.phase?.[key] === "string" ? over.phase[key] : undefined;
|
|
2422
|
+
const fallback = typeof base.phase?.[key] === "string" ? base.phase[key] : "";
|
|
2423
|
+
out.phase[key] = mine !== undefined ? mine : fallback;
|
|
2424
|
+
}
|
|
2425
|
+
return out;
|
|
2146
2426
|
};
|
|
2147
2427
|
|
|
2148
2428
|
function pick(list) {
|
|
@@ -2181,6 +2461,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2181
2461
|
/** 摸鱼:静置多久才算「闲下来」,以及之后每次摸鱼的随机间隔上界(ms)。 */
|
|
2182
2462
|
fidgetQuietMs: 12000,
|
|
2183
2463
|
fidgetGapMs: 26000,
|
|
2464
|
+
/** 气泡相对角色默认位置(头顶)的偏移,px;以及一句话停留多久。 */
|
|
2465
|
+
bubbleOffsetX: 0,
|
|
2466
|
+
bubbleOffsetY: 0,
|
|
2467
|
+
bubbleHoldMs: 4200,
|
|
2468
|
+
/** 鼠标绕圈:在 spinWindowMs 内累计转过 spinTurns 圈就算转晕。 */
|
|
2469
|
+
spinTurns: 2,
|
|
2470
|
+
spinWindowMs: 1600,
|
|
2184
2471
|
};
|
|
2185
2472
|
|
|
2186
2473
|
/** 出厂值快照(「恢复默认」用)。 */
|
|
@@ -2203,12 +2490,22 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2203
2490
|
// 摸鱼那一组单独排,界面上分开展示(见 TUNING_GROUPS)。
|
|
2204
2491
|
{ key: "fidgetQuietMs", label: "静置多久开始", min: 2000, max: 120000, step: 1000, group: "fidget" },
|
|
2205
2492
|
{ key: "fidgetGapMs", label: "之后最长间隔", min: 4000, max: 300000, step: 1000, group: "fidget" },
|
|
2493
|
+
// 互动与气泡(见「互动」那张卡)。
|
|
2494
|
+
{ key: "bubbleOffsetX", label: "气泡左右偏移 px", min: -240, max: 240, step: 2, group: "bubble" },
|
|
2495
|
+
{ key: "bubbleOffsetY", label: "气泡上下偏移 px", min: -240, max: 240, step: 2, group: "bubble" },
|
|
2496
|
+
{ key: "bubbleHoldMs", label: "一句话停留 ms", min: 800, max: 15000, step: 200, group: "bubble" },
|
|
2497
|
+
{ key: "spinTurns", label: "转几圈算晕", min: 1, max: 6, step: 0.5, group: "interact" },
|
|
2498
|
+
{ key: "spinWindowMs", label: "要在多少 ms 内", min: 300, max: 6000, step: 100, group: "interact" },
|
|
2206
2499
|
];
|
|
2207
2500
|
/** 可调项的分组(没写 group 的都归「手感」)。hint 显示在卡片右上角。 */
|
|
2208
2501
|
const TUNING_GROUPS = [
|
|
2209
2502
|
{ id: "feel", label: "手感", hint: "指针 / 嘴 / 眨眼" },
|
|
2210
2503
|
{ id: "fidget", label: "摸鱼节奏", hint: "多久开始、间隔多长" },
|
|
2504
|
+
{ id: "interact", label: "互动", hint: "摸头 / 摸尾巴 / 转圈" },
|
|
2505
|
+
{ id: "bubble", label: "气泡", hint: "显示什么 · 在哪" },
|
|
2211
2506
|
];
|
|
2507
|
+
/** 这几组自己排进了「互动」「气泡」卡里,不要再单独出一张卡。 */
|
|
2508
|
+
const TUNING_GROUPS_INLINE = ["fidget", "interact", "bubble"];
|
|
2212
2509
|
const tuningGroupOf = (field) => field.group ?? "feel";
|
|
2213
2510
|
const TUNING_KEY = "dsh-pet-live2d.settings.v1";
|
|
2214
2511
|
/** 装扮存档的 key。放这里是因为开关(applyFlag)也要用它清存档。 */
|
|
@@ -2247,7 +2544,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2247
2544
|
* 姿势本身的性质 —— 在摸鱼表里改它,右键面板点同一个姿势、相位池里抽到它,
|
|
2248
2545
|
* 行为必须一致。所以它单独存一层,而不是挂在条目上。
|
|
2249
2546
|
*/
|
|
2250
|
-
const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {} };
|
|
2547
|
+
const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {}, lines: {} };
|
|
2251
2548
|
|
|
2252
2549
|
/** 槽位 id -> 中文标签(关系行显示「贴纸」而不是 `sticker`)。 */
|
|
2253
2550
|
const slotLabelOf = (slotId) =>
|
|
@@ -2362,7 +2659,18 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2362
2659
|
*
|
|
2363
2660
|
* 和数字项分开存:它们不是滑杆,校验方式也不同(true/false)。
|
|
2364
2661
|
*/
|
|
2365
|
-
const FLAGS = {
|
|
2662
|
+
const FLAGS = {
|
|
2663
|
+
/** 装扮是否跨启动记住(那六件穿在身上的东西)。 */
|
|
2664
|
+
outfitArchive: true,
|
|
2665
|
+
/** 气泡总开关:关掉之后任何台词都不弹(含问候、摸头、相位)。 */
|
|
2666
|
+
bubbleEnabled: true,
|
|
2667
|
+
/** 摸头互动(判定用的是模型自己的几何,见 hitsHead)。 */
|
|
2668
|
+
patEnabled: true,
|
|
2669
|
+
/** 摸尾巴互动。 */
|
|
2670
|
+
tailEnabled: true,
|
|
2671
|
+
/** 鼠标绕圈转晕。 */
|
|
2672
|
+
spinEnabled: true,
|
|
2673
|
+
};
|
|
2366
2674
|
const FLAG_DEFAULTS = Object.freeze(Object.assign({}, FLAGS));
|
|
2367
2675
|
const OVERRIDE_KEY = "dsh-pet-live2d.settings.v2";
|
|
2368
2676
|
|
|
@@ -2490,6 +2798,36 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2490
2798
|
if (typeof flags[key] === "boolean") FLAGS[key] = flags[key];
|
|
2491
2799
|
}
|
|
2492
2800
|
}
|
|
2801
|
+
// 互动反应候选:三组标签数组。
|
|
2802
|
+
const interactions = saved.interactions;
|
|
2803
|
+
if (interactions !== null && typeof interactions === "object") {
|
|
2804
|
+
const next = {};
|
|
2805
|
+
for (const [key, list] of Object.entries(interactions)) {
|
|
2806
|
+
if (!Array.isArray(list)) continue;
|
|
2807
|
+
const clean = list.filter((label) => typeof label === "string" && label !== "");
|
|
2808
|
+
if (clean.length > 0) next[key] = clean;
|
|
2809
|
+
}
|
|
2810
|
+
if (Object.keys(next).length > 0) PHASE_OVERRIDES.interactions = next;
|
|
2811
|
+
}
|
|
2812
|
+
// 台词覆盖:两层(每条一组 + 相位的单句)。数组要清掉空串,否则 `[""]` 会被
|
|
2813
|
+
// 当成"用户改过",`linesNow()` 就永远拿不到宠物默认了。
|
|
2814
|
+
const lines = saved.lines;
|
|
2815
|
+
if (lines !== null && typeof lines === "object") {
|
|
2816
|
+
const next = {};
|
|
2817
|
+
for (const [key, value] of Object.entries(lines)) {
|
|
2818
|
+
if (key === "phase") {
|
|
2819
|
+
const phases = {};
|
|
2820
|
+
for (const [phase, text] of Object.entries(value ?? {})) {
|
|
2821
|
+
if (typeof text === "string") phases[phase] = text;
|
|
2822
|
+
}
|
|
2823
|
+
if (Object.keys(phases).length > 0) next.phase = phases;
|
|
2824
|
+
} else if (Array.isArray(value)) {
|
|
2825
|
+
const clean = value.filter((text) => typeof text === "string" && text !== "");
|
|
2826
|
+
if (clean.length > 0) next[key] = clean;
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
if (Object.keys(next).length > 0) PHASE_OVERRIDES.lines = next;
|
|
2830
|
+
}
|
|
2493
2831
|
};
|
|
2494
2832
|
|
|
2495
2833
|
/** 改一处覆盖:写进 store、存档、广播(两个设置界面立刻同步)。 */
|
|
@@ -2508,6 +2846,27 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2508
2846
|
};
|
|
2509
2847
|
}
|
|
2510
2848
|
}
|
|
2849
|
+
if (patch.interactions !== undefined) {
|
|
2850
|
+
for (const [key, list] of Object.entries(patch.interactions)) {
|
|
2851
|
+
PHASE_OVERRIDES.interactions[key] = Array.isArray(list) ? list.slice() : [];
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
// 台词是**两层**(每条一组,相位再一层),所以逐层合并 —— 直接 Object.assign 的话
|
|
2855
|
+
// 改一句相位台词会把其它相位整片冲掉。
|
|
2856
|
+
if (patch.lines !== undefined) {
|
|
2857
|
+
const current = PHASE_OVERRIDES.lines ?? {};
|
|
2858
|
+
const next = Object.assign({}, current);
|
|
2859
|
+
for (const [key, value] of Object.entries(patch.lines)) {
|
|
2860
|
+
if (key === "phase") {
|
|
2861
|
+
next.phase = Object.assign({}, current.phase, value);
|
|
2862
|
+
} else if (Array.isArray(value)) {
|
|
2863
|
+
next[key] = value.slice();
|
|
2864
|
+
} else if (typeof value === "string") {
|
|
2865
|
+
next[key] = value;
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
PHASE_OVERRIDES.lines = next;
|
|
2869
|
+
}
|
|
2511
2870
|
saveOverrides();
|
|
2512
2871
|
notifySettings();
|
|
2513
2872
|
};
|
|
@@ -2914,6 +3273,31 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
2914
3273
|
{ expression: "星星眼" },
|
|
2915
3274
|
];
|
|
2916
3275
|
|
|
3276
|
+
/**
|
|
3277
|
+
* 互动的反应候选:宠物默认(pet.json 的 `patReactions` / `tailReactions` /
|
|
3278
|
+
* `spinReactions`)+ 用户覆盖(`PHASE_OVERRIDES.interactions`)。
|
|
3279
|
+
*
|
|
3280
|
+
* 条目是**标签**(「重锤出击」「星星眼」这种):先当动作组找(宠物会给动作起中文名),
|
|
3281
|
+
* 找不到就当表达式名。这样设置界面里可以拿宠物自己的清单当候选,用户不用记 id。
|
|
3282
|
+
*/
|
|
3283
|
+
const interactionReactions = (key) => {
|
|
3284
|
+
const mine = PHASE_OVERRIDES.interactions?.[key];
|
|
3285
|
+
const list = Array.isArray(mine) && mine.length > 0 ? mine : (MANIFEST.current?.[key] ?? []);
|
|
3286
|
+
return Array.isArray(list) ? list.filter((name) => typeof name === "string" && name !== "") : [];
|
|
3287
|
+
};
|
|
3288
|
+
|
|
3289
|
+
/** 跑一条反应(由点击/转圈触发)。 */
|
|
3290
|
+
const runReactionRef = { current: () => false };
|
|
3291
|
+
|
|
3292
|
+
/**
|
|
3293
|
+
* 转圈检测的读数(诊断用)。
|
|
3294
|
+
*
|
|
3295
|
+
* "绕着转圈没反应"这类问题必须能分辨**三种**可能:事件没来(moves 不动)、
|
|
3296
|
+
* 累计不够(total 不涨)、还是触发了但被别的东西盖住(fires 涨了却没效果)。
|
|
3297
|
+
* 只看最后那个"弹没弹台词"是分不出来的。
|
|
3298
|
+
*/
|
|
3299
|
+
const spinStatsRef = { current: { moves: 0, total: 0, fires: 0 } };
|
|
3300
|
+
|
|
2917
3301
|
/**
|
|
2918
3302
|
* How long a session phase keeps replaying its motion.
|
|
2919
3303
|
*
|
|
@@ -3281,6 +3665,27 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3281
3665
|
fidgetSlots: Object.keys(PHASE_OVERRIDES.fidget),
|
|
3282
3666
|
relationKeys: Object.keys(PHASE_OVERRIDES.relations),
|
|
3283
3667
|
phases: Object.keys(PHASE_OVERRIDES.phases),
|
|
3668
|
+
interactions: Object.keys(PHASE_OVERRIDES.interactions ?? {}),
|
|
3669
|
+
lines: Object.keys(PHASE_OVERRIDES.lines ?? {}),
|
|
3670
|
+
linePhases: Object.keys(PHASE_OVERRIDES.lines?.phase ?? {}),
|
|
3671
|
+
flags: Object.assign({}, FLAGS),
|
|
3672
|
+
});
|
|
3673
|
+
// 台词的**有效值**(宠物默认 + 用户覆盖)—— "改文本"这类断言要能直接读到结果,
|
|
3674
|
+
// 而不是去 DOM 里抠 input.value(那是"界面显示了什么",不是"她真的会说什么")。
|
|
3675
|
+
api.effectiveLines = () => linesNow();
|
|
3676
|
+
/** 某个互动的**有效**反应候选(同样是与宠物默认合并后的结果)。 */
|
|
3677
|
+
api.effectiveReactions = (key) => interactionReactions(key);
|
|
3678
|
+
/** 台词的字段清单(测试用它确认"每一个字段都有输入框")。 */
|
|
3679
|
+
api.lineFields = () => ({
|
|
3680
|
+
plain: LINE_FIELDS.map((field) => field.key),
|
|
3681
|
+
phase: PHASE_LINE_FIELDS.map((field) => field.key),
|
|
3682
|
+
});
|
|
3683
|
+
/** 转圈检测的读数:moves(事件到没到)/ total(累计角)/ fires(触发了几次)。 */
|
|
3684
|
+
api.spinDebug = () => Object.assign({}, spinStatsRef.current, {
|
|
3685
|
+
enabled: FLAGS.spinEnabled,
|
|
3686
|
+
turns: TUNING.spinTurns,
|
|
3687
|
+
windowMs: TUNING.spinWindowMs,
|
|
3688
|
+
threshold: TUNING.spinTurns * Math.PI * 2,
|
|
3284
3689
|
});
|
|
3285
3690
|
}, []);
|
|
3286
3691
|
/**
|
|
@@ -3434,10 +3839,19 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3434
3839
|
? (catalog.pets.find((entry) => entry.id === petId) ?? catalog.pets[0])
|
|
3435
3840
|
: undefined;
|
|
3436
3841
|
|
|
3842
|
+
/**
|
|
3843
|
+
* 弹一句气泡。
|
|
3844
|
+
*
|
|
3845
|
+
* 三件可配的事都收在这里(用户要的"所有文本都可配 / 位置可配 / 可以开关"):
|
|
3846
|
+
* 气泡总开关关掉时**直接不弹**(连问候也不弹);位置偏移走 TUNING,渲染时当 CSS 变量;
|
|
3847
|
+
* 文本本身由调用方从 `linesNow()` 取(宠物默认 + 用户覆盖)。
|
|
3848
|
+
*/
|
|
3437
3849
|
const say = useCallback((text) => {
|
|
3850
|
+
if (!FLAGS.bubbleEnabled) return;
|
|
3851
|
+
if (typeof text !== "string" || text === "") return;
|
|
3438
3852
|
setBubble(text);
|
|
3439
3853
|
window.clearTimeout(bubbleTimer.current);
|
|
3440
|
-
bubbleTimer.current = window.setTimeout(() => setBubble(null),
|
|
3854
|
+
bubbleTimer.current = window.setTimeout(() => setBubble(null), TUNING.bubbleHoldMs);
|
|
3441
3855
|
}, []);
|
|
3442
3856
|
|
|
3443
3857
|
useEffect(() => () => window.clearTimeout(bubbleTimer.current), []);
|
|
@@ -3531,6 +3945,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3531
3945
|
phaseExpressionRef.current = Object.assign({}, phaseBaseRef.current.expressions);
|
|
3532
3946
|
// 设置界面(含 DSH 设置页那个独立组件)需要清单里有哪些动作/表情/槽位。
|
|
3533
3947
|
MANIFEST.current = pet;
|
|
3948
|
+
// 头部部件(宿主从 cdi3 的作者命名里挑的,21 个)交给控制器:摸头判定按这些
|
|
3949
|
+
// 部件的**真实三角面**测,而不是一个手调内边距的方框。
|
|
3950
|
+
motion.current.setHeadParts(pet.headParts ?? []);
|
|
3951
|
+
motion.current.setTailParts(pet.tailParts ?? []);
|
|
3534
3952
|
// 清单换了(换宠物 / pet.json 改了槽位结构)就先剪一遍存档:
|
|
3535
3953
|
// 旧槽位的覆盖会让"关系指向不存在的槽位"这类问题**静默**发生。
|
|
3536
3954
|
pruneOverrides(pet);
|
|
@@ -3692,7 +4110,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3692
4110
|
boot().catch((reason) => {
|
|
3693
4111
|
if (!disposed) {
|
|
3694
4112
|
setError(String((reason && reason.message) || reason));
|
|
3695
|
-
say(pick(
|
|
4113
|
+
say(pick(linesNow().loadFailed));
|
|
3696
4114
|
}
|
|
3697
4115
|
});
|
|
3698
4116
|
|
|
@@ -3747,7 +4165,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3747
4165
|
useEffect(() => {
|
|
3748
4166
|
if (!ready || greeted.current) return;
|
|
3749
4167
|
greeted.current = true;
|
|
3750
|
-
say(pick(
|
|
4168
|
+
say(pick(linesNow().greet));
|
|
3751
4169
|
}, [ready, say]);
|
|
3752
4170
|
|
|
3753
4171
|
|
|
@@ -3770,10 +4188,62 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3770
4188
|
let resting = false;
|
|
3771
4189
|
focusDefault();
|
|
3772
4190
|
resting = true;
|
|
4191
|
+
// 转圈检测的状态:上一次的角度、累计转角、这轮累计的起始时刻、上次触发时刻。
|
|
4192
|
+
let spinLastAngle = null;
|
|
4193
|
+
let spinTotal = 0;
|
|
4194
|
+
let spinStartedAt = 0;
|
|
4195
|
+
let spinLastFire = 0;
|
|
4196
|
+
const spinTrack = (rect, x, y) => {
|
|
4197
|
+
if (!FLAGS.spinEnabled) return;
|
|
4198
|
+
spinStatsRef.current.moves += 1;
|
|
4199
|
+
const cx = rect.width / 2;
|
|
4200
|
+
const cy = rect.height / 2;
|
|
4201
|
+
const dx = x - cx;
|
|
4202
|
+
const dy = y - cy;
|
|
4203
|
+
// 太靠近中心时角度会剧烈抖动(半径趋近 0):跳过,不参与累计。
|
|
4204
|
+
if (Math.hypot(dx, dy) < rect.width * 0.12) return;
|
|
4205
|
+
const angle = Math.atan2(dy, dx);
|
|
4206
|
+
if (spinLastAngle === null) {
|
|
4207
|
+
spinLastAngle = angle;
|
|
4208
|
+
spinStartedAt = Date.now();
|
|
4209
|
+
return;
|
|
4210
|
+
}
|
|
4211
|
+
let delta = angle - spinLastAngle;
|
|
4212
|
+
// 归一化到 (-π, π],否则跨越 ±π 时会出现一整个 2π 的假增量。
|
|
4213
|
+
if (delta > Math.PI) delta -= Math.PI * 2;
|
|
4214
|
+
if (delta < -Math.PI) delta += Math.PI * 2;
|
|
4215
|
+
spinLastAngle = angle;
|
|
4216
|
+
const now = Date.now();
|
|
4217
|
+
// "要在多少 ms 内转够 N 圈":窗口从**这轮累计开始**算起,超时就整轮作废、
|
|
4218
|
+
// 从这一下重新开始。原来写的是 `now - spinLastFire > 窗口` —— 那个值一开始
|
|
4219
|
+
// 恒大于窗口,于是每次移动都把累计清零,永远攒不到 π(诊断读口里 total 一直是 0.07)。
|
|
4220
|
+
if (now - spinStartedAt > TUNING.spinWindowMs) {
|
|
4221
|
+
spinTotal = 0;
|
|
4222
|
+
spinStartedAt = now;
|
|
4223
|
+
}
|
|
4224
|
+
spinTotal += delta;
|
|
4225
|
+
spinStatsRef.current.total = spinTotal;
|
|
4226
|
+
if (Math.abs(spinTotal) < TUNING.spinTurns * Math.PI * 2) return;
|
|
4227
|
+
if (now - spinLastFire < 1500) return;
|
|
4228
|
+
spinTotal = 0;
|
|
4229
|
+
spinStartedAt = now;
|
|
4230
|
+
spinLastFire = now;
|
|
4231
|
+
spinStatsRef.current.fires += 1;
|
|
4232
|
+
lastInteraction.current = now;
|
|
4233
|
+
const list = interactionReactions("spinReactions");
|
|
4234
|
+
if (list.length > 0) runReactionRef.current(pick(list));
|
|
4235
|
+
say(pick(linesNow().spin));
|
|
4236
|
+
};
|
|
3773
4237
|
const onMove = (event) => {
|
|
3774
4238
|
const rect = stage.getBoundingClientRect();
|
|
3775
4239
|
const x = event.clientX - rect.left;
|
|
3776
4240
|
const y = event.clientY - rect.top;
|
|
4241
|
+
// ---- 鼠标围着转圈 → 转晕 ------------------------------------------
|
|
4242
|
+
// 判定的是"围绕舞台中心的**累计转角**":每次移动取与上一次的夹角增量
|
|
4243
|
+
// (归一化到 ±180°),在一段时间窗内累计;够 spinTurns 圈就触发一次。
|
|
4244
|
+
// 用累计角而不是"位置绕了几圈",是因为前者对半径不敏感 —— 贴着角色转
|
|
4245
|
+
// 小圈和远远地转大圈都算,符合"逗她"的直觉。
|
|
4246
|
+
spinTrack(rect, x, y);
|
|
3777
4247
|
const near = x >= -TUNING.gazeRange && y >= -TUNING.gazeRange
|
|
3778
4248
|
&& x <= rect.width + TUNING.gazeRange && y <= rect.height + TUNING.gazeRange;
|
|
3779
4249
|
if (near) {
|
|
@@ -4083,6 +4553,30 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4083
4553
|
armExpressionClear();
|
|
4084
4554
|
}, [armExpressionClear]);
|
|
4085
4555
|
|
|
4556
|
+
/**
|
|
4557
|
+
* 跑一条互动反应(点击 / 转圈触发)。
|
|
4558
|
+
*
|
|
4559
|
+
* 标签先当**动作组**找(宠物在 catalog.json 里给动作起了中文名),找不到就当
|
|
4560
|
+
* **表达式**闪一下(闪完由 EXPRESSION_HOLD_MS 自动收,不会永久占着槽位)。
|
|
4561
|
+
*/
|
|
4562
|
+
const runReaction = useCallback((label) => {
|
|
4563
|
+
if (typeof label !== "string" || label === "") return false;
|
|
4564
|
+
const groups = motion.current.groups();
|
|
4565
|
+
const entry = (petRef.current?.motions ?? []).find((item) => item.label === label);
|
|
4566
|
+
const group = entry !== undefined && Array.isArray(groups[entry.group]) ? entry.group : undefined;
|
|
4567
|
+
if (group !== undefined) {
|
|
4568
|
+
motion.current.playOnce(group, 0, { kind: "tap" });
|
|
4569
|
+
return true;
|
|
4570
|
+
}
|
|
4571
|
+
if (Array.isArray(groups[label])) {
|
|
4572
|
+
motion.current.playOnce(label, 0, { kind: "tap" });
|
|
4573
|
+
return true;
|
|
4574
|
+
}
|
|
4575
|
+
flashExpression(label);
|
|
4576
|
+
return true;
|
|
4577
|
+
}, [flashExpression]);
|
|
4578
|
+
runReactionRef.current = runReaction;
|
|
4579
|
+
|
|
4086
4580
|
/**
|
|
4087
4581
|
* Choose an option within one dress-up slot.
|
|
4088
4582
|
*
|
|
@@ -4287,7 +4781,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4287
4781
|
}
|
|
4288
4782
|
commitPinsRef.current();
|
|
4289
4783
|
motion.current.resetToRest();
|
|
4290
|
-
say(pick(
|
|
4784
|
+
say(pick(linesNow().reset));
|
|
4291
4785
|
}, [applyExpressions, say]);
|
|
4292
4786
|
|
|
4293
4787
|
/**
|
|
@@ -4480,6 +4974,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4480
4974
|
phasePinsRef.current[expression] = true;
|
|
4481
4975
|
commitPinsRef.current();
|
|
4482
4976
|
}
|
|
4977
|
+
// 相位台词(pet.json 的 `lines.phase.<相位>`,用户可在设置里改)。
|
|
4978
|
+
// 空字符串 = 这个相位不弹(idle 就没有),所以这里只是"有才弹"。
|
|
4979
|
+
const line = linesNow().phase[phase] ?? "";
|
|
4980
|
+
if (line !== "") say(line);
|
|
4483
4981
|
};
|
|
4484
4982
|
// The sustain loop lives in the controller, but the phase -> group map
|
|
4485
4983
|
// comes from the pet manifest, so hand the resolver over. 池子里抽到过动作的
|
|
@@ -4793,6 +5291,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4793
5291
|
return motion.current.hitsHead(clientX - rect.left, clientY - rect.top);
|
|
4794
5292
|
}, []);
|
|
4795
5293
|
|
|
5294
|
+
/** 摸尾巴的命中(和摸头同一套,坐标换算也一样走舞台矩形)。 */
|
|
5295
|
+
const hitsTail = useCallback((clientX, clientY) => {
|
|
5296
|
+
const stage = stageRef.current;
|
|
5297
|
+
if (stage === null) return false;
|
|
5298
|
+
const rect = stage.getBoundingClientRect();
|
|
5299
|
+
return motion.current.hitsTail(clientX - rect.left, clientY - rect.top);
|
|
5300
|
+
}, []);
|
|
5301
|
+
|
|
4796
5302
|
/**
|
|
4797
5303
|
* Right-click on the pet opens the whole control panel.
|
|
4798
5304
|
*
|
|
@@ -4847,10 +5353,11 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4847
5353
|
// Resolved once, at press time: the model keeps swaying, so asking
|
|
4848
5354
|
// again on release could answer differently than the press did.
|
|
4849
5355
|
onHead: hitsHead(event.clientX, event.clientY),
|
|
5356
|
+
onTail: hitsTail(event.clientX, event.clientY),
|
|
4850
5357
|
};
|
|
4851
5358
|
setDragging(true);
|
|
4852
5359
|
try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* not capturable */ }
|
|
4853
|
-
}, [hitsModel, hitsHead]);
|
|
5360
|
+
}, [hitsModel, hitsHead, hitsTail]);
|
|
4854
5361
|
|
|
4855
5362
|
useEffect(() => {
|
|
4856
5363
|
const onMove = (event) => {
|
|
@@ -4880,25 +5387,23 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4880
5387
|
});
|
|
4881
5388
|
} else if (state.onModel) {
|
|
4882
5389
|
lastInteraction.current = Date.now();
|
|
4883
|
-
if (state.
|
|
4884
|
-
//
|
|
4885
|
-
//
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
}
|
|
4897
|
-
say(pick(LINES.click));
|
|
5390
|
+
if (state.onTail && FLAGS.tailEnabled) {
|
|
5391
|
+
// 摸尾巴:判据和摸头同一套(模型自己的三角面),部件集合是 cdi3 里
|
|
5392
|
+
// 命名为尾/鳍/翅/翼 的那些。反应从 pet.json 的 `tailReactions` 里随机。
|
|
5393
|
+
const list = interactionReactions("tailReactions");
|
|
5394
|
+
if (list.length > 0) runReactionRef.current(pick(list));
|
|
5395
|
+
say(pick(linesNow().tail));
|
|
5396
|
+
} else if (state.onHead && FLAGS.patEnabled) {
|
|
5397
|
+
// 摸头:从 `patReactions` 里随机挑一个(默认是 重锤出击 / 问号 / 星星眼),
|
|
5398
|
+
// 并且**故意不脸红**。表情类反应是"闪一下",到点由自动清理收走,
|
|
5399
|
+
// 所以摸头不会在用户选的槽位上留下永久表情。
|
|
5400
|
+
const list = interactionReactions("patReactions");
|
|
5401
|
+
if (list.length > 0) runReactionRef.current(pick(list));
|
|
5402
|
+
say(pick(linesNow().pat));
|
|
4898
5403
|
} else {
|
|
4899
5404
|
// Anywhere else on the character is a lighter acknowledgement —
|
|
4900
5405
|
// deliberately WITHOUT 重锤出击, which now belongs to the head only.
|
|
4901
|
-
say(pick(
|
|
5406
|
+
say(pick(linesNow().click));
|
|
4902
5407
|
}
|
|
4903
5408
|
}
|
|
4904
5409
|
};
|
|
@@ -5115,7 +5620,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5115
5620
|
...(maskPath === "" ? {} : { onPointerDown, onContextMenu }),
|
|
5116
5621
|
}),
|
|
5117
5622
|
),
|
|
5118
|
-
|
|
5623
|
+
// 气泡的偏移量走 CSS 变量(值,不是布局):位置规则仍然只写在样式表里。
|
|
5624
|
+
bubble === null ? null : h("div", {
|
|
5625
|
+
"data-bubble": "",
|
|
5626
|
+
style: {
|
|
5627
|
+
"--bubble-x": TUNING.bubbleOffsetX + "px",
|
|
5628
|
+
"--bubble-y": TUNING.bubbleOffsetY + "px",
|
|
5629
|
+
},
|
|
5630
|
+
}, bubble),
|
|
5119
5631
|
panel,
|
|
5120
5632
|
);
|
|
5121
5633
|
}
|
|
@@ -5561,6 +6073,129 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5561
6073
|
* 按标签页条件渲染的 —— 一切到"设置",Pet 的 hook 数量就变了,React 会抛
|
|
5562
6074
|
* "Rendered more hooks than during the previous render" 并把**整只宠物**卸载。
|
|
5563
6075
|
*/
|
|
6076
|
+
/** 设置页里每条台词的标题(顺序 = 显示顺序)。 */
|
|
6077
|
+
const LINE_FIELDS = [
|
|
6078
|
+
{ key: "greet", label: "开始的时候" },
|
|
6079
|
+
{ key: "click", label: "点她身上(不是头和尾巴)" },
|
|
6080
|
+
{ key: "pat", label: "摸摸头" },
|
|
6081
|
+
{ key: "tail", label: "摸尾巴" },
|
|
6082
|
+
{ key: "spin", label: "被转晕" },
|
|
6083
|
+
{ key: "reset", label: "点「归位」" },
|
|
6084
|
+
{ key: "loadFailed", label: "模型加载失败" },
|
|
6085
|
+
];
|
|
6086
|
+
const PHASE_LINE_FIELDS = [
|
|
6087
|
+
{ key: "thinking", label: "思考中" },
|
|
6088
|
+
{ key: "tool", label: "用工具" },
|
|
6089
|
+
{ key: "waiting", label: "等你批准" },
|
|
6090
|
+
{ key: "asking", label: "等你回答" },
|
|
6091
|
+
{ key: "helper", label: "叫了帮手" },
|
|
6092
|
+
{ key: "queued", label: "消息排队" },
|
|
6093
|
+
{ key: "done", label: "这一轮完成" },
|
|
6094
|
+
{ key: "failed", label: "出错" },
|
|
6095
|
+
];
|
|
6096
|
+
|
|
6097
|
+
/**
|
|
6098
|
+
* 互动:三个开关 + 三套反应候选。
|
|
6099
|
+
*
|
|
6100
|
+
* 候选直接用**宠物自己的清单**(动作的中文名 + 表情名),用户不用记 id ——
|
|
6101
|
+
* `runReaction()` 也是这个顺序:先当动作组找,找不到就闪一下表情。
|
|
6102
|
+
*/
|
|
6103
|
+
function InteractControls() {
|
|
6104
|
+
useSettings();
|
|
6105
|
+
const listFor = (key) => {
|
|
6106
|
+
const mine = PHASE_OVERRIDES.interactions?.[key];
|
|
6107
|
+
if (Array.isArray(mine) && mine.length > 0) return mine;
|
|
6108
|
+
const base = MANIFEST.current?.[key];
|
|
6109
|
+
return Array.isArray(base) ? base : [];
|
|
6110
|
+
};
|
|
6111
|
+
const toggle = (key, label) => {
|
|
6112
|
+
const next = listFor(key).slice();
|
|
6113
|
+
const at = next.indexOf(label);
|
|
6114
|
+
if (at >= 0) next.splice(at, 1);
|
|
6115
|
+
else next.push(label);
|
|
6116
|
+
applyOverride({ interactions: { [key]: next } });
|
|
6117
|
+
};
|
|
6118
|
+
const pet = MANIFEST.current ?? {};
|
|
6119
|
+
const candidates = Array.from(new Set([
|
|
6120
|
+
...(pet.motions ?? []).map((motion) => motion.label),
|
|
6121
|
+
...(pet.expressions ?? []).map((expression) => expression.label),
|
|
6122
|
+
].filter((label) => typeof label === "string" && label !== "")));
|
|
6123
|
+
const switchRow = (key, label, note) => h("label", { "data-flag-row": key },
|
|
6124
|
+
h("input", {
|
|
6125
|
+
type: "checkbox",
|
|
6126
|
+
checked: FLAGS[key] === true,
|
|
6127
|
+
"data-flag": key,
|
|
6128
|
+
onChange: (event) => applyFlag(key, event.target.checked),
|
|
6129
|
+
}),
|
|
6130
|
+
h("span", { "data-row-label": "" }, label),
|
|
6131
|
+
note === undefined ? null : h("span", { "data-note-inline": "" }, note),
|
|
6132
|
+
);
|
|
6133
|
+
const reactionRow = (key, title, note) => h("div", { "data-reaction-set": key },
|
|
6134
|
+
h("div", { "data-row-label": "" }, title, note === undefined ? null : h("span", { "data-note-inline": "" }, note)),
|
|
6135
|
+
h("div", { "data-chips": "" }, candidates.length === 0
|
|
6136
|
+
? h("span", { "data-note-inline": "" }, "这只宠物没有可选的动作/表情")
|
|
6137
|
+
: candidates.map((label) => {
|
|
6138
|
+
const on = listFor(key).includes(label);
|
|
6139
|
+
return h("button", {
|
|
6140
|
+
key: label,
|
|
6141
|
+
type: "button",
|
|
6142
|
+
"data-reaction-chip": key + ":" + label,
|
|
6143
|
+
...(on ? { "data-on": "" } : {}),
|
|
6144
|
+
onClick: () => toggle(key, label),
|
|
6145
|
+
}, label);
|
|
6146
|
+
})),
|
|
6147
|
+
);
|
|
6148
|
+
return h("div", { "data-settings": "", "data-setting": "interact" },
|
|
6149
|
+
switchRow("patEnabled", "摸头有反应"),
|
|
6150
|
+
switchRow("tailEnabled", "摸尾巴有反应"),
|
|
6151
|
+
switchRow("spinEnabled", "鼠标绕着转圈会晕"),
|
|
6152
|
+
h(TuningControls, { key: "spin", group: "interact" }),
|
|
6153
|
+
reactionRow("patReactions", "摸头时演什么", "(随机一个)"),
|
|
6154
|
+
reactionRow("tailReactions", "摸尾巴时演什么", "(随机一个)"),
|
|
6155
|
+
reactionRow("spinReactions", "转晕时演什么", "(随机一个)"),
|
|
6156
|
+
);
|
|
6157
|
+
}
|
|
6158
|
+
|
|
6159
|
+
/**
|
|
6160
|
+
* 台词:所有气泡文本。
|
|
6161
|
+
*
|
|
6162
|
+
* 一组 = 一行输入,**用 `|` 分隔多个变体**(随机挑一句);相位台词是单句。
|
|
6163
|
+
* 留空就退回宠物默认(`pet.json` 的 `live2d.lines`),所以"看一眼"不会把默认改掉。
|
|
6164
|
+
*/
|
|
6165
|
+
function LineControls() {
|
|
6166
|
+
useSettings();
|
|
6167
|
+
const effective = linesNow();
|
|
6168
|
+
const setLines = (patch) => applyOverride({ lines: patch });
|
|
6169
|
+
const row = (id, label, value, onInput, placeholder) => h("label", { "data-line-row": id },
|
|
6170
|
+
h("span", { "data-row-label": "" }, label),
|
|
6171
|
+
h("input", {
|
|
6172
|
+
type: "text",
|
|
6173
|
+
value,
|
|
6174
|
+
placeholder,
|
|
6175
|
+
"data-line-input": id,
|
|
6176
|
+
onChange: (event) => onInput(event.target.value),
|
|
6177
|
+
}),
|
|
6178
|
+
);
|
|
6179
|
+
return h("div", { "data-settings": "", "data-setting": "lines" },
|
|
6180
|
+
h("div", { "data-note": "" }, "多个变体用 | 分隔(随机挑一句)。留空 = 用宠物默认。"),
|
|
6181
|
+
...LINE_FIELDS.map((field) => row(
|
|
6182
|
+
field.key,
|
|
6183
|
+
field.label,
|
|
6184
|
+
joinLineInput(effective[field.key]),
|
|
6185
|
+
(text) => setLines({ [field.key]: splitLineInput(text) }),
|
|
6186
|
+
joinLineInput(MANIFEST.current?.lines?.[field.key]),
|
|
6187
|
+
)),
|
|
6188
|
+
h("div", { "data-row-label": "", "data-note": "" }, "会话相位"),
|
|
6189
|
+
...PHASE_LINE_FIELDS.map((field) => row(
|
|
6190
|
+
"phase:" + field.key,
|
|
6191
|
+
field.label,
|
|
6192
|
+
effective.phase?.[field.key] ?? "",
|
|
6193
|
+
(text) => setLines({ phase: { [field.key]: text } }),
|
|
6194
|
+
MANIFEST.current?.lines?.phase?.[field.key] ?? "",
|
|
6195
|
+
)),
|
|
6196
|
+
);
|
|
6197
|
+
}
|
|
6198
|
+
|
|
5564
6199
|
function PetSettingsBody() {
|
|
5565
6200
|
useSettings();
|
|
5566
6201
|
const card = (key, title, hint, body) => h("div", { key, "data-card": key },
|
|
@@ -5571,7 +6206,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5571
6206
|
h("div", { "data-card-body": "" }, body),
|
|
5572
6207
|
);
|
|
5573
6208
|
return [
|
|
5574
|
-
...TUNING_GROUPS.filter((group) => group.id
|
|
6209
|
+
...TUNING_GROUPS.filter((group) => !TUNING_GROUPS_INLINE.includes(group.id)).map((group) => card(
|
|
5575
6210
|
"tune-" + group.id, group.label, group.hint, h(TuningControls, { group: group.id }))),
|
|
5576
6211
|
card("phases", "会话相位", "每个相位一组池子", h(PhaseControls, null)),
|
|
5577
6212
|
// 「摸鱼节奏」(多久摸一次)和「摸鱼」(摸鱼做什么)是同一件事的两半,原来
|
|
@@ -5581,6 +6216,22 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5581
6216
|
h(FidgetControls, { key: "pools" }),
|
|
5582
6217
|
]),
|
|
5583
6218
|
card("outfit", "装扮", null, h(OutfitControls, null)),
|
|
6219
|
+
// 互动与气泡:开关、反应候选、以及**所有**气泡文本都在这两张卡里。
|
|
6220
|
+
card("interact", "互动", "摸头 / 摸尾巴 / 转圈", h(InteractControls, null)),
|
|
6221
|
+
card("bubble", "气泡", "显示什么 · 在哪 · 停留多久", [
|
|
6222
|
+
h("label", { "data-flag-row": "bubbleEnabled" },
|
|
6223
|
+
h("input", {
|
|
6224
|
+
type: "checkbox",
|
|
6225
|
+
checked: FLAGS.bubbleEnabled === true,
|
|
6226
|
+
"data-flag": "bubbleEnabled",
|
|
6227
|
+
onChange: (event) => applyFlag("bubbleEnabled", event.target.checked),
|
|
6228
|
+
}),
|
|
6229
|
+
h("span", { "data-row-label": "" }, "显示气泡"),
|
|
6230
|
+
h("span", { "data-note-inline": "" }, FLAGS.bubbleEnabled ? "" : "已关:任何台词都不弹"),
|
|
6231
|
+
),
|
|
6232
|
+
h(TuningControls, { key: "bubble", group: "bubble" }),
|
|
6233
|
+
h(LineControls, { key: "lines" }),
|
|
6234
|
+
]),
|
|
5584
6235
|
];
|
|
5585
6236
|
}
|
|
5586
6237
|
|