dsh-pet-live2d 3.1.3 → 3.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 +24 -0
- package/README.md +39 -10
- package/icon.svg +45 -0
- package/lib/client.js +669 -62
- package/lib/index.js +86 -1
- package/lib/sound-files.js +231 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +7 -3
- package/pets/ds-whale-girl/README.md +2 -0
- package/pets/ds-whale-girl/pet.json +7 -1
package/lib/client.js
CHANGED
|
@@ -26,6 +26,15 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
26
26
|
const { useCallback, useEffect, useRef, useState } = react;
|
|
27
27
|
|
|
28
28
|
const name = "live2d-pet";
|
|
29
|
+
/**
|
|
30
|
+
* 本包的 npm 包名。
|
|
31
|
+
*
|
|
32
|
+
* 官方「插件」页按**包名**派发组合包的配置区(`plugins.bundle.config` 的 key 就是
|
|
33
|
+
* 组合包的包名,见 `dsh-client-ui-plugin-manager` 的 `configLedgerSource`),所以这个
|
|
34
|
+
* 字符串必须与 `package.json` 的 `name` 逐字相同 —— 写错的表现是"注册没报错、
|
|
35
|
+
* 页面上什么都没多出来"(页面按包名查不到这个键就不渲染那一节)。
|
|
36
|
+
*/
|
|
37
|
+
const PACKAGE_NAME = "dsh-pet-live2d";
|
|
29
38
|
// "slots" 是 DSH 客户端界面给插件的扩展点(设置页就是这么挂进去的)。
|
|
30
39
|
const inject = ["slots"];
|
|
31
40
|
|
|
@@ -3456,32 +3465,73 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3456
3465
|
scope + " [data-relation-remove]:hover{opacity:1;background:rgba(127,127,127,.22)}",
|
|
3457
3466
|
|
|
3458
3467
|
// ---- × 删除 --------------------------------------------------------
|
|
3459
|
-
scope + " [data-pool-remove]," + scope + " [data-
|
|
3460
|
-
+ scope + " [data-pool-remove-slot]{justify-self:center;"
|
|
3468
|
+
scope + " [data-pool-remove]," + scope + " [data-pool-remove-slot]{justify-self:center;"
|
|
3461
3469
|
+ "width:20px;height:20px;line-height:1;font-size:13px;border-radius:6px;"
|
|
3462
3470
|
+ "opacity:.4;color:inherit}",
|
|
3463
|
-
scope + " [data-pool-remove]:hover," + scope + " [data-
|
|
3464
|
-
+ " [data-pool-remove-slot]:hover{opacity:1;"
|
|
3471
|
+
scope + " [data-pool-remove]:hover," + scope + " [data-pool-remove-slot]:hover{opacity:1;"
|
|
3465
3472
|
+ "background:rgba(232,120,120,.2);color:#e87878}",
|
|
3466
3473
|
// 槽位那一行的 × 贴在右边(它是"整张表"的动作,不是"这一条"的)。
|
|
3467
3474
|
scope + " [data-pool-remove-slot]{margin-left:auto}",
|
|
3468
3475
|
|
|
3469
|
-
// ----
|
|
3476
|
+
// ---- 相位:概览、台词/音符、动作槽位分层 ----------------------------
|
|
3470
3477
|
scope + " [data-phase]{border:1px solid rgba(127,127,127,.24);border-radius:10px;"
|
|
3471
|
-
+ "margin:0 0
|
|
3472
|
-
scope + " [data-phase-head]{
|
|
3473
|
-
+ "
|
|
3478
|
+
+ "margin:0 0 9px;overflow:hidden}",
|
|
3479
|
+
scope + " [data-phase-head]{padding:7px 10px 8px;background:rgba(127,127,127,.07);"
|
|
3480
|
+
+ "border-bottom:1px solid rgba(127,127,127,.16)}",
|
|
3474
3481
|
scope + " [data-phase-head][data-collapsed]{border-bottom:0}",
|
|
3482
|
+
scope + " [data-phase-head-main]{display:flex;align-items:center;gap:6px;min-width:0}",
|
|
3483
|
+
scope + " [data-phase-remove]{flex:none;font-size:10px;color:inherit;opacity:.75;"
|
|
3484
|
+
+ "border:1px solid rgba(127,127,127,.3);border-radius:999px;padding:1px 7px}",
|
|
3485
|
+
scope + " [data-phase-remove]:hover{opacity:1;border-color:rgba(120,170,255,.7);"
|
|
3486
|
+
+ "background:rgba(120,170,255,.12)}",
|
|
3475
3487
|
scope + " [data-phase-toggle]{display:flex;align-items:center;gap:7px;flex:1;min-width:0;"
|
|
3476
3488
|
+ "font-size:12px;font-weight:600;text-align:left;color:inherit}",
|
|
3477
|
-
scope + " [data-
|
|
3478
|
-
|
|
3479
|
-
|
|
3489
|
+
scope + " [data-phase-toggle]:focus-visible{outline:2px solid rgba(120,170,255,.7);"
|
|
3490
|
+
+ "outline-offset:2px;border-radius:4px}",
|
|
3491
|
+
scope + " [data-caret]{font-size:10px;opacity:.7;width:9px;flex:none}",
|
|
3492
|
+
scope + " [data-phase-name]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
3493
|
+
scope + " [data-phase-toggle] code{font:400 10px/1.4 inherit;opacity:.55;flex:none}",
|
|
3494
|
+
scope + " [data-phase-meta]{display:flex;flex-wrap:wrap;gap:4px;margin:5px 0 0 16px;"
|
|
3495
|
+
+ "font-size:10px;font-variant-numeric:tabular-nums}",
|
|
3496
|
+
scope + " [data-phase-chip]{display:inline-block;padding:0 6px;border-radius:999px;"
|
|
3497
|
+
+ "border:1px solid rgba(127,127,127,.2);background:rgba(127,127,127,.05);opacity:.7}",
|
|
3498
|
+
scope + " [data-phase-chip][data-custom]{border-color:rgba(120,170,255,.5);"
|
|
3499
|
+
+ "background:rgba(120,170,255,.13);opacity:1}",
|
|
3500
|
+
scope + " [data-phase-details]{padding:8px 10px 9px;background:rgba(127,127,127,.025);"
|
|
3501
|
+
+ "border-bottom:1px solid rgba(127,127,127,.16)}",
|
|
3502
|
+
scope + " [data-phase-section-title]{font-size:10px;font-weight:600;opacity:.65;margin-bottom:3px}",
|
|
3503
|
+
scope + " [data-phase-line]{display:flex;align-items:center;gap:6px;min-width:0}",
|
|
3504
|
+
scope + " [data-phase-line] [data-line-row]{grid-template-columns:84px minmax(0,1fr);"
|
|
3505
|
+
+ "flex:1;min-width:0}",
|
|
3506
|
+
scope + " [data-phase-line] [data-reset]{flex:none;white-space:nowrap}",
|
|
3507
|
+
scope + " [data-phase-sound]{display:grid;grid-template-columns:84px minmax(0,1fr);"
|
|
3508
|
+
+ "align-items:center;gap:6px;padding:3px 0;min-width:0}",
|
|
3509
|
+
scope + " [data-phase-sound-control]{display:flex;align-items:center;gap:7px;min-width:0}",
|
|
3510
|
+
scope + " [data-phase-sound] input{margin:0;flex:none}",
|
|
3511
|
+
scope + " [data-phase-sound] [data-note-inline]{min-width:0;margin-left:0;"
|
|
3512
|
+
+ "line-height:1.5;overflow-wrap:anywhere}",
|
|
3513
|
+
scope + " [data-phase-upload]{display:flex;align-items:center;flex-wrap:wrap;gap:6px;"
|
|
3514
|
+
+ "min-width:0;margin:6px 0 0 90px}",
|
|
3515
|
+
scope + " [data-upload-picker]," + scope + " [data-upload-reset],"
|
|
3516
|
+
+ scope + " [data-phase-preview]{position:relative;"
|
|
3517
|
+
+ "border:1px solid rgba(120,170,255,.5);border-radius:7px;"
|
|
3518
|
+
+ "background:rgba(120,170,255,.1);color:inherit;font-size:10px;"
|
|
3519
|
+
+ "padding:2px 8px;line-height:1.7;cursor:pointer}",
|
|
3520
|
+
// 没有可试听的来源时按钮就该点不动 —— 别让它装作可点,那只会变成"点了没反应"。
|
|
3521
|
+
scope + " [data-phase-preview]:disabled{opacity:.45;cursor:default}",
|
|
3522
|
+
scope + " [data-upload-picker]:focus-within{outline:2px solid rgba(120,170,255,.7);outline-offset:2px}",
|
|
3523
|
+
scope + " [data-upload-picker] input[type=file]{position:absolute;inset:0;opacity:0;"
|
|
3524
|
+
+ "width:100%;height:100%;cursor:pointer}",
|
|
3525
|
+
scope + " [data-upload-hint]{font-size:10px;opacity:.55}",
|
|
3526
|
+
scope + " [data-upload-error]{flex-basis:100%;font-size:10px;"
|
|
3527
|
+
+ "background:rgba(232,120,120,.16);border-radius:5px;padding:2px 6px}",
|
|
3528
|
+
scope + " [data-phase-motion-head]{display:flex;align-items:center;gap:8px;"
|
|
3529
|
+
+ "padding:7px 10px 5px;font-size:10px;font-weight:600;opacity:.8}",
|
|
3530
|
+
scope + " [data-phase-motion-hint]{font-weight:400;opacity:.65}",
|
|
3531
|
+
scope + " [data-phase-motion-head]+[data-pool-empty]{padding:4px 10px}",
|
|
3480
3532
|
scope + " [data-pool]{padding:7px 10px 8px 12px;"
|
|
3481
3533
|
+ "border-top:1px solid rgba(127,127,127,.13)}",
|
|
3482
|
-
|
|
3483
|
-
// 不可靠:空态那个 div 也是 div,会把它顶掉)。
|
|
3484
|
-
scope + " [data-phase-head]+[data-pool]{border-top:0}",
|
|
3534
|
+
scope + " [data-phase-motion-head]+[data-pool]{border-top:0}",
|
|
3485
3535
|
scope + " [data-pool-head]{display:flex;align-items:center;gap:8px;padding:0 0 3px}",
|
|
3486
3536
|
scope + " [data-pool-title]{font-size:11px;font-weight:600;opacity:.88}",
|
|
3487
3537
|
scope + " [data-pool-meta]{font-size:10px;opacity:.42;font-variant-numeric:tabular-nums}",
|
|
@@ -3492,8 +3542,37 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3492
3542
|
scope + " [data-note]{font-size:10.5px;opacity:.55;padding-top:4px}",
|
|
3493
3543
|
scope + " label{display:flex;align-items:center;gap:7px}",
|
|
3494
3544
|
]));
|
|
3495
|
-
//
|
|
3496
|
-
const
|
|
3545
|
+
// DSH 设置页用两列概览;右键窄面板仍沿用单列,以免池子表格被挤窄。
|
|
3546
|
+
const PAGE_PHASE_CSS = [
|
|
3547
|
+
SETTINGS_SEL + "{max-width:880px;container:pet-settings/inline-size}",
|
|
3548
|
+
SETTINGS_SEL + " [data-setting=phases]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}",
|
|
3549
|
+
SETTINGS_SEL + " [data-setting=phases] [data-phase]{margin:0;min-width:0}",
|
|
3550
|
+
SETTINGS_SEL + " [data-setting=phases] [data-phase][data-expanded]{grid-column:1/-1}",
|
|
3551
|
+
SETTINGS_SEL + " [data-phase-head]{padding:10px 12px 11px}",
|
|
3552
|
+
SETTINGS_SEL + " [data-phase-toggle]{font-size:13px}",
|
|
3553
|
+
SETTINGS_SEL + " [data-phase-meta]{font-size:11px;gap:5px;margin-top:7px}",
|
|
3554
|
+
SETTINGS_SEL + " [data-phase-chip]{padding:1px 7px}",
|
|
3555
|
+
SETTINGS_SEL + " [data-phase-details]{padding:11px 14px}",
|
|
3556
|
+
SETTINGS_SEL + " [data-phase-section-title]{font-size:11px;opacity:.8}",
|
|
3557
|
+
SETTINGS_SEL + " [data-phase][data-expanded] [data-phase-head]{background:rgba(120,170,255,.1)}",
|
|
3558
|
+
"@container pet-settings (max-width:620px){"
|
|
3559
|
+
+ SETTINGS_SEL + " [data-setting=phases]{grid-template-columns:minmax(0,1fr)}",
|
|
3560
|
+
"}",
|
|
3561
|
+
];
|
|
3562
|
+
/**
|
|
3563
|
+
* 官方「插件」页那一节的微调(作用域 = 两个属性同时在的那个根节点)。
|
|
3564
|
+
*
|
|
3565
|
+
* 只有两条,都是有理由的:
|
|
3566
|
+
* * 状态行("她现在在哪")本来借的是 `[data-note]` 的灰度 —— 那是给补充说明用的,
|
|
3567
|
+
* 而这一行是这一节**最先要回答**的问题,按 55% 透明度画就太虚了;
|
|
3568
|
+
* * 插件页是窄栏,卡片间距收一档,四张卡不至于要滚半天。
|
|
3569
|
+
*/
|
|
3570
|
+
const PLUGIN_PAGE_CSS = [
|
|
3571
|
+
SETTINGS_SEL + "[data-pet-plugin-page]>[data-plugin-status]{font-size:11px;opacity:.85;padding:0 0 9px}",
|
|
3572
|
+
SETTINGS_SEL + "[data-pet-plugin-page] [data-card]{margin-bottom:8px}",
|
|
3573
|
+
];
|
|
3574
|
+
const STYLE_TEXT = CSS + "\n" + SETTINGS_CSS.join("\n") + "\n" + PLUGIN_PAGE_CSS.join("\n")
|
|
3575
|
+
+ "\n" + PAGE_PHASE_CSS.join("\n");
|
|
3497
3576
|
|
|
3498
3577
|
function ensureStyle() {
|
|
3499
3578
|
if (document.getElementById(STYLE_ID) !== null) return;
|
|
@@ -3617,6 +3696,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3617
3696
|
/** 鼠标绕圈:在 spinWindowMs 内累计转过 spinTurns 圈就算转晕。 */
|
|
3618
3697
|
spinTurns: 2,
|
|
3619
3698
|
spinWindowMs: 1600,
|
|
3699
|
+
/** 会话提示音音量,0 为无声。 */
|
|
3700
|
+
soundVolume: 0.35,
|
|
3620
3701
|
};
|
|
3621
3702
|
|
|
3622
3703
|
/** 出厂值快照(「恢复默认」用)。 */
|
|
@@ -3648,6 +3729,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3648
3729
|
{ key: "bubbleHoldMs", label: "一句话停留 ms", min: 800, max: 15000, step: 200, group: "bubble" },
|
|
3649
3730
|
{ key: "spinTurns", label: "转几圈算晕", min: 1, max: 6, step: 0.5, group: "interact" },
|
|
3650
3731
|
{ key: "spinWindowMs", label: "要在多少 ms 内", min: 300, max: 6000, step: 100, group: "interact" },
|
|
3732
|
+
{ key: "soundVolume", label: "提示音音量", min: 0, max: 1, step: 0.05, group: "sound" },
|
|
3651
3733
|
];
|
|
3652
3734
|
/** 可调项的分组(没写 group 的都归「手感」)。hint 显示在卡片右上角。 */
|
|
3653
3735
|
const TUNING_GROUPS = [
|
|
@@ -3825,6 +3907,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3825
3907
|
* relations: { "<槽位>:<标签>": { pairs, requires } }
|
|
3826
3908
|
* interactions: { <patReactions|tailReactions|spinReactions>: [标签] }
|
|
3827
3909
|
* lines: { <键>: [台词] , phase: { <相位>: 台词 } }
|
|
3910
|
+
* sounds: { <相位>: [[频率 Hz, 起始秒数], ...] }(空数组 = 静音)
|
|
3828
3911
|
*/
|
|
3829
3912
|
/** 当前宠物的清单,给设置界面用(DSH 设置页拿不到组件里的 pet)。 */
|
|
3830
3913
|
const MANIFEST = { current: null };
|
|
@@ -3839,10 +3922,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3839
3922
|
* 姿势本身的性质 —— 在摸鱼表里改它,右键面板点同一个姿势、相位池里抽到它,
|
|
3840
3923
|
* 行为必须一致。所以它单独存一层,而不是挂在条目上。
|
|
3841
3924
|
*/
|
|
3842
|
-
//
|
|
3925
|
+
// 六个键都要在这里给出来(哪怕只是空壳):`applyOverride()` 是**原地往这个对象上写**的,
|
|
3843
3926
|
// 少一个键就等于那条路径第一次改设置时静默丢掉 —— 互动反应候选原来就是这么漏的
|
|
3844
3927
|
// (读的地方全写着 `?.`,所以"能读、写不进去",只有正面写它的界面会踩到)。
|
|
3845
|
-
const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {}, interactions: {}, lines: {} };
|
|
3928
|
+
const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {}, interactions: {}, lines: {}, sounds: {} };
|
|
3846
3929
|
|
|
3847
3930
|
/** 槽位 id -> 中文标签(关系行显示「贴纸」而不是 `sticker`)。 */
|
|
3848
3931
|
const slotLabelOf = (slotId) =>
|
|
@@ -3968,10 +4051,264 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3968
4051
|
tailEnabled: true,
|
|
3969
4052
|
/** 鼠标绕圈转晕。 */
|
|
3970
4053
|
spinEnabled: true,
|
|
4054
|
+
/** 首次使用默认静音,避免装上插件后突然响铃。 */
|
|
4055
|
+
soundEnabled: false,
|
|
3971
4056
|
};
|
|
3972
4057
|
const FLAG_DEFAULTS = Object.freeze(Object.assign({}, FLAGS));
|
|
3973
4058
|
const OVERRIDE_KEY = "dsh-pet-live2d.settings.v2";
|
|
3974
4059
|
|
|
4060
|
+
// 浏览器只允许用户操作后播放声音;文件由宿主按宠物和相位保存在独立目录。
|
|
4061
|
+
const SOUND_URL = "/api/live2d-pet/sound";
|
|
4062
|
+
const UPLOADED_SOUNDS = { petId: null, sounds: {} };
|
|
4063
|
+
let playingUploaded = null;
|
|
4064
|
+
let playingUploadedPhase = null;
|
|
4065
|
+
const stopUploadedSound = () => {
|
|
4066
|
+
if (playingUploaded !== null) playingUploaded.pause();
|
|
4067
|
+
playingUploaded = null;
|
|
4068
|
+
playingUploadedPhase = null;
|
|
4069
|
+
};
|
|
4070
|
+
const uploadedSoundFor = (phase) => UPLOADED_SOUNDS.petId === MANIFEST.current?.id
|
|
4071
|
+
? UPLOADED_SOUNDS.sounds[phase] : undefined;
|
|
4072
|
+
const pullUploadedSounds = async () => {
|
|
4073
|
+
const id = MANIFEST.current?.id;
|
|
4074
|
+
if (typeof id !== "string") return false;
|
|
4075
|
+
try {
|
|
4076
|
+
const response = await fetch(SOUND_URL + "/" + encodeURIComponent(id), { cache: "no-store" });
|
|
4077
|
+
if (!response.ok) return false;
|
|
4078
|
+
const payload = await response.json();
|
|
4079
|
+
if (MANIFEST.current?.id !== id || payload?.ok !== true) return false;
|
|
4080
|
+
const sounds = {};
|
|
4081
|
+
for (const phase of PHASE_LINE_KEYS) {
|
|
4082
|
+
const entry = payload.sounds?.[phase];
|
|
4083
|
+
if (entry !== null && typeof entry === "object" && /^[a-f0-9]{64}$/.test(entry.token)
|
|
4084
|
+
&& ["audio/wav", "audio/mpeg", "audio/ogg"].includes(entry.mime)) sounds[phase] = entry;
|
|
4085
|
+
}
|
|
4086
|
+
if (UPLOADED_SOUNDS.petId !== id || JSON.stringify(UPLOADED_SOUNDS.sounds) !== JSON.stringify(sounds)) {
|
|
4087
|
+
UPLOADED_SOUNDS.petId = id;
|
|
4088
|
+
UPLOADED_SOUNDS.sounds = sounds;
|
|
4089
|
+
notifySettings();
|
|
4090
|
+
}
|
|
4091
|
+
return true;
|
|
4092
|
+
} catch { return false; }
|
|
4093
|
+
};
|
|
4094
|
+
const changeUploadedSound = async (phase, file) => {
|
|
4095
|
+
const id = MANIFEST.current?.id;
|
|
4096
|
+
if (typeof id !== "string" || !PHASE_LINE_KEYS.includes(phase)) throw new Error("宠物尚未加载");
|
|
4097
|
+
if (file && file.size > 1024 * 1024) throw new Error("音频文件不能超过 1 MB");
|
|
4098
|
+
if (file !== null) {
|
|
4099
|
+
const previewUrl = URL.createObjectURL(file);
|
|
4100
|
+
const preview = new window.Audio();
|
|
4101
|
+
let timer;
|
|
4102
|
+
try {
|
|
4103
|
+
await new Promise((resolve, reject) => {
|
|
4104
|
+
preview.preload = "metadata";
|
|
4105
|
+
preview.onloadedmetadata = () => resolve();
|
|
4106
|
+
preview.onerror = () => reject(new Error("音频文件损坏或格式不受支持"));
|
|
4107
|
+
timer = window.setTimeout(() => reject(new Error("读取音频超时,请换一个文件")), 5000);
|
|
4108
|
+
preview.src = previewUrl;
|
|
4109
|
+
preview.load();
|
|
4110
|
+
});
|
|
4111
|
+
} finally {
|
|
4112
|
+
window.clearTimeout(timer);
|
|
4113
|
+
preview.removeAttribute("src");
|
|
4114
|
+
preview.load();
|
|
4115
|
+
URL.revokeObjectURL(previewUrl);
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
const base64 = file === null ? null : await new Promise((resolve, reject) => {
|
|
4119
|
+
const reader = new FileReader();
|
|
4120
|
+
reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
|
|
4121
|
+
reader.onerror = () => reject(new Error("读取音频文件失败"));
|
|
4122
|
+
reader.readAsDataURL(file);
|
|
4123
|
+
});
|
|
4124
|
+
const response = await fetch(SOUND_URL + "/" + encodeURIComponent(id) + "/" + phase, {
|
|
4125
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
4126
|
+
body: JSON.stringify(file === null ? { action: "reset" } : { base64 }),
|
|
4127
|
+
});
|
|
4128
|
+
if (!response.ok) {
|
|
4129
|
+
const result = await response.json().catch(() => ({}));
|
|
4130
|
+
throw new Error(result.error === "invalid-audio" ? "仅支持 MP3、WAV、OGG 音频(最大 1 MB)"
|
|
4131
|
+
: result.error === "body-too-large" ? "音频文件不能超过 1 MB"
|
|
4132
|
+
: "保存音频失败(" + response.status + ")");
|
|
4133
|
+
}
|
|
4134
|
+
await pullUploadedSounds();
|
|
4135
|
+
};
|
|
4136
|
+
let soundContext = null;
|
|
4137
|
+
/**
|
|
4138
|
+
* 按需建/恢复 AudioContext。相位提示音与设置页的「试听」共用同一个实例。
|
|
4139
|
+
*
|
|
4140
|
+
* 返回 null 表示这台机器没有 Web Audio(或浏览器禁用了)——调用方各自决定怎么退。
|
|
4141
|
+
*/
|
|
4142
|
+
const ensureSoundContext = () => {
|
|
4143
|
+
if (soundContext === null) {
|
|
4144
|
+
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
4145
|
+
if (AudioContext === undefined) return null;
|
|
4146
|
+
soundContext = new AudioContext();
|
|
4147
|
+
}
|
|
4148
|
+
if (soundContext.state === "suspended") void soundContext.resume().catch(() => {});
|
|
4149
|
+
return soundContext;
|
|
4150
|
+
};
|
|
4151
|
+
const unlockSound = () => {
|
|
4152
|
+
if (!FLAGS.soundEnabled) return;
|
|
4153
|
+
try {
|
|
4154
|
+
ensureSoundContext();
|
|
4155
|
+
} catch {
|
|
4156
|
+
/* 无音频设备或浏览器禁用 Web Audio:只保留视觉状态 */
|
|
4157
|
+
}
|
|
4158
|
+
};
|
|
4159
|
+
// 未声明 sounds 的旧宠物沿用原来的四声;声明了 sounds 的宠物只响自己列出的相位。
|
|
4160
|
+
const BUILTIN_SOUNDS = {
|
|
4161
|
+
done: [[660, 0], [880, 0.13]],
|
|
4162
|
+
failed: [[370, 0], [277, 0.16]],
|
|
4163
|
+
waiting: [[520, 0], [660, 0.16]],
|
|
4164
|
+
asking: [[520, 0], [780, 0.16]],
|
|
4165
|
+
};
|
|
4166
|
+
const cleanSoundNotes = (raw) => Array.isArray(raw) && raw.length <= 8
|
|
4167
|
+
&& raw.every((note) => Array.isArray(note) && note.length === 2
|
|
4168
|
+
&& typeof note[0] === "number" && Number.isFinite(note[0]) && note[0] >= 80 && note[0] <= 4000
|
|
4169
|
+
&& typeof note[1] === "number" && Number.isFinite(note[1]) && note[1] >= 0 && note[1] <= 2)
|
|
4170
|
+
? raw.map((note) => note.slice()) : [];
|
|
4171
|
+
const petSoundNotesFor = (phase) => cleanSoundNotes(
|
|
4172
|
+
MANIFEST.current?.sounds === null || MANIFEST.current?.sounds === undefined
|
|
4173
|
+
? BUILTIN_SOUNDS[phase] : MANIFEST.current.sounds[phase]);
|
|
4174
|
+
const soundNotesFor = (phase) => Object.prototype.hasOwnProperty.call(PHASE_OVERRIDES.sounds, phase)
|
|
4175
|
+
? PHASE_OVERRIDES.sounds[phase] : petSoundNotesFor(phase);
|
|
4176
|
+
const setPhaseSoundMuted = (phase, muted) => {
|
|
4177
|
+
if (muted) {
|
|
4178
|
+
PHASE_OVERRIDES.sounds[phase] = [];
|
|
4179
|
+
if (playingUploadedPhase === phase) stopUploadedSound();
|
|
4180
|
+
}
|
|
4181
|
+
else delete PHASE_OVERRIDES.sounds[phase];
|
|
4182
|
+
saveOverrides();
|
|
4183
|
+
notifySettings();
|
|
4184
|
+
};
|
|
4185
|
+
/**
|
|
4186
|
+
* 播一段上传音频。真实相位与设置页的「试听」共用它 —— 两条路必须发出同一种声音,
|
|
4187
|
+
* 分开写迟早会漂移(试听听到的和真响的不一样,比没有试听更糟)。
|
|
4188
|
+
*/
|
|
4189
|
+
const playUploadedSound = (phase, entry, volume) => {
|
|
4190
|
+
try {
|
|
4191
|
+
const audio = new window.Audio(SOUND_URL + "/" + encodeURIComponent(MANIFEST.current.id)
|
|
4192
|
+
+ "/" + phase + "?token=" + entry.token);
|
|
4193
|
+
audio.volume = volume;
|
|
4194
|
+
playingUploaded = audio;
|
|
4195
|
+
playingUploadedPhase = phase;
|
|
4196
|
+
void audio.play().catch(() => { /* 浏览器阻止自动播放时不影响相位动画 */ });
|
|
4197
|
+
return true;
|
|
4198
|
+
} catch { return false; /* 音频设备不可用时不影响相位动画 */ }
|
|
4199
|
+
};
|
|
4200
|
+
/** 把一个相位的音符排进 AudioContext。同样是与「试听」共用的那一份。 */
|
|
4201
|
+
const scheduleSoundNotes = (context, notes, volume) => {
|
|
4202
|
+
try {
|
|
4203
|
+
const now = context.currentTime;
|
|
4204
|
+
for (const [frequency, offset] of notes) {
|
|
4205
|
+
const oscillator = context.createOscillator();
|
|
4206
|
+
const gain = context.createGain();
|
|
4207
|
+
oscillator.type = "sine";
|
|
4208
|
+
oscillator.frequency.value = frequency;
|
|
4209
|
+
gain.gain.setValueAtTime(0, now + offset);
|
|
4210
|
+
gain.gain.linearRampToValueAtTime(volume * 0.12, now + offset + 0.015);
|
|
4211
|
+
gain.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.15);
|
|
4212
|
+
oscillator.connect(gain);
|
|
4213
|
+
gain.connect(context.destination);
|
|
4214
|
+
oscillator.start(now + offset);
|
|
4215
|
+
oscillator.stop(now + offset + 0.16);
|
|
4216
|
+
}
|
|
4217
|
+
return true;
|
|
4218
|
+
} catch { return false; /* 音频异常不能中断相位动画 */ }
|
|
4219
|
+
};
|
|
4220
|
+
const playPhaseSound = (phase) => {
|
|
4221
|
+
stopUploadedSound();
|
|
4222
|
+
if (!FLAGS.soundEnabled || !(TUNING.soundVolume > 0)) return;
|
|
4223
|
+
if (Object.prototype.hasOwnProperty.call(PHASE_OVERRIDES.sounds, phase)
|
|
4224
|
+
&& PHASE_OVERRIDES.sounds[phase].length === 0) return;
|
|
4225
|
+
const uploaded = uploadedSoundFor(phase);
|
|
4226
|
+
if (uploaded !== undefined) { playUploadedSound(phase, uploaded, TUNING.soundVolume); return; }
|
|
4227
|
+
const notes = soundNotesFor(phase);
|
|
4228
|
+
if (notes.length === 0 || soundContext?.state !== "running") return;
|
|
4229
|
+
scheduleSoundNotes(soundContext, notes, TUNING.soundVolume);
|
|
4230
|
+
};
|
|
4231
|
+
|
|
4232
|
+
/**
|
|
4233
|
+
* 试听:设置页里"我现在就要听一下这个相位"。(`PREVIEW_VOLUME` 是音量被拉到 0 时的兜底。)
|
|
4234
|
+
*
|
|
4235
|
+
* 与真实播放**只差两点,且都是故意的**:
|
|
4236
|
+
* 1. 不看总开关、不看单相位静音 —— 默认总开关就是关的(`soundEnabled: false`),
|
|
4237
|
+
* 跟着它走的话点试听必然没反应,等于没做;
|
|
4238
|
+
* 2. 取音源时跳过"静音覆盖"(`sounds[phase] = []`),仍按"上传音频 > 宠物音符"拿
|
|
4239
|
+
* **有效来源**,这样静音着的相位也能先听再决定要不要开。
|
|
4240
|
+
* 返回是否真的发出了声音(设置页与驱动都靠它判断,不靠听)。
|
|
4241
|
+
*/
|
|
4242
|
+
const PREVIEW_VOLUME = 0.35;
|
|
4243
|
+
const previewNotesFor = (phase) => {
|
|
4244
|
+
const override = PHASE_OVERRIDES.sounds[phase];
|
|
4245
|
+
return Array.isArray(override) && override.length > 0 ? override : petSoundNotesFor(phase);
|
|
4246
|
+
};
|
|
4247
|
+
const canPreviewPhaseSound = (phase) => uploadedSoundFor(phase) !== undefined
|
|
4248
|
+
|| previewNotesFor(phase).length > 0;
|
|
4249
|
+
const previewPhaseSound = (phase) => {
|
|
4250
|
+
try {
|
|
4251
|
+
if (typeof MANIFEST.current?.id !== "string" || !PHASE_LINE_KEYS.includes(phase)) return false;
|
|
4252
|
+
stopUploadedSound();
|
|
4253
|
+
const volume = TUNING.soundVolume > 0 ? TUNING.soundVolume : PREVIEW_VOLUME;
|
|
4254
|
+
const uploaded = uploadedSoundFor(phase);
|
|
4255
|
+
if (uploaded !== undefined) return playUploadedSound(phase, uploaded, volume);
|
|
4256
|
+
const notes = previewNotesFor(phase);
|
|
4257
|
+
if (notes.length === 0) return false;
|
|
4258
|
+
const context = ensureSoundContext();
|
|
4259
|
+
if (context === null) return false;
|
|
4260
|
+
// 首次点击时 AudioContext 常常还是 suspended,而 resume() 是异步的:
|
|
4261
|
+
// 等它落地再排音符,否则会像真实相位那样被 state 检查挡掉、又变成"点了没反应"。
|
|
4262
|
+
if (context.state === "suspended") {
|
|
4263
|
+
void context.resume().then(() => { scheduleSoundNotes(context, notes, volume); }).catch(() => {});
|
|
4264
|
+
return true;
|
|
4265
|
+
}
|
|
4266
|
+
return scheduleSoundNotes(context, notes, volume);
|
|
4267
|
+
} catch { return false; }
|
|
4268
|
+
};
|
|
4269
|
+
|
|
4270
|
+
/**
|
|
4271
|
+
* 拖音量滑杆时的即时试听:音量只有数字时,用户只能靠"下一次相位切换"猜它有多大。
|
|
4272
|
+
*
|
|
4273
|
+
* 与上面那个「试听」按钮**只差音量处理**:这里按**真实音量**播(音量 0 就是静音),
|
|
4274
|
+
* 因为"0 听起来是什么样"的答案本来就是"没声";按钮那边用兜底音量,避免点了没反应。
|
|
4275
|
+
* 音源取这只宠物"最像提示音"的那段(优先 `done`,否则第一个有音的相位)。
|
|
4276
|
+
*/
|
|
4277
|
+
const volumePreviewNotes = () => {
|
|
4278
|
+
const done = previewNotesFor("done");
|
|
4279
|
+
if (done.length > 0) return done;
|
|
4280
|
+
for (const phase of PHASE_LINE_KEYS) {
|
|
4281
|
+
const notes = previewNotesFor(phase);
|
|
4282
|
+
if (notes.length > 0) return notes;
|
|
4283
|
+
}
|
|
4284
|
+
return [];
|
|
4285
|
+
};
|
|
4286
|
+
const previewVolumeSound = () => {
|
|
4287
|
+
try {
|
|
4288
|
+
const volume = TUNING.soundVolume;
|
|
4289
|
+
if (!(volume > 0)) return false; // 0 = 静音,这里就该听不到,不许兜底
|
|
4290
|
+
const notes = volumePreviewNotes();
|
|
4291
|
+
if (notes.length === 0) return false; // 这只宠物没有任何音源:滑杆只给数字反馈
|
|
4292
|
+
const context = ensureSoundContext();
|
|
4293
|
+
if (context === null) return false;
|
|
4294
|
+
if (context.state === "suspended") {
|
|
4295
|
+
void context.resume().then(() => { scheduleSoundNotes(context, notes, volume); }).catch(() => {});
|
|
4296
|
+
return true;
|
|
4297
|
+
}
|
|
4298
|
+
return scheduleSoundNotes(context, notes, volume);
|
|
4299
|
+
} catch { return false; }
|
|
4300
|
+
};
|
|
4301
|
+
/** 拖滑杆会连着来几十次 onChange:防抖到停手再响,否则是电音。 */
|
|
4302
|
+
const VOLUME_PREVIEW_DEBOUNCE_MS = 160;
|
|
4303
|
+
let volumePreviewTimer = 0;
|
|
4304
|
+
const scheduleVolumePreview = () => {
|
|
4305
|
+
if (volumePreviewTimer !== 0) window.clearTimeout(volumePreviewTimer);
|
|
4306
|
+
volumePreviewTimer = window.setTimeout(() => {
|
|
4307
|
+
volumePreviewTimer = 0;
|
|
4308
|
+
previewVolumeSound();
|
|
4309
|
+
}, VOLUME_PREVIEW_DEBOUNCE_MS);
|
|
4310
|
+
};
|
|
4311
|
+
|
|
3975
4312
|
const saveOverrides = () => {
|
|
3976
4313
|
// 走共享落点(宿主优先):相位池子覆盖与开关两个窗口共用同一份。
|
|
3977
4314
|
persistShared({ overrides: Object.assign({}, PHASE_OVERRIDES, { flags: FLAGS }) });
|
|
@@ -3981,6 +4318,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3981
4318
|
const applyFlag = (key, value) => {
|
|
3982
4319
|
if (!Object.prototype.hasOwnProperty.call(FLAGS, key)) return;
|
|
3983
4320
|
FLAGS[key] = value === true;
|
|
4321
|
+
if (key === "soundEnabled") {
|
|
4322
|
+
if (FLAGS[key]) unlockSound();
|
|
4323
|
+
else {
|
|
4324
|
+
if (soundContext !== null) void soundContext.suspend().catch(() => {});
|
|
4325
|
+
stopUploadedSound();
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
3984
4328
|
if (key === "outfitArchive" && FLAGS[key] === false) {
|
|
3985
4329
|
try {
|
|
3986
4330
|
storage.removeItem(OUTFIT_KEY);
|
|
@@ -4095,11 +4439,21 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4095
4439
|
if (Object.keys(next).length > 0) PHASE_OVERRIDES.relations[key] = next;
|
|
4096
4440
|
}
|
|
4097
4441
|
}
|
|
4442
|
+
// 每次重建这一层:另一个窗口恢复默认后,不能把本窗口上次的静音覆盖留下。
|
|
4443
|
+
PHASE_OVERRIDES.sounds = {};
|
|
4444
|
+
if (saved.sounds !== null && typeof saved.sounds === "object" && !Array.isArray(saved.sounds)) {
|
|
4445
|
+
for (const phase of PHASE_LINE_KEYS) {
|
|
4446
|
+
if (Object.prototype.hasOwnProperty.call(saved.sounds, phase) && Array.isArray(saved.sounds[phase])) {
|
|
4447
|
+
PHASE_OVERRIDES.sounds[phase] = cleanSoundNotes(saved.sounds[phase]);
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4450
|
+
}
|
|
4098
4451
|
const flags = saved.flags;
|
|
4099
4452
|
if (flags !== null && typeof flags === "object") {
|
|
4100
4453
|
for (const key of Object.keys(FLAGS)) {
|
|
4101
4454
|
if (typeof flags[key] === "boolean") FLAGS[key] = flags[key];
|
|
4102
4455
|
}
|
|
4456
|
+
if (!FLAGS.soundEnabled) stopUploadedSound();
|
|
4103
4457
|
}
|
|
4104
4458
|
// 互动反应候选:三组标签数组。
|
|
4105
4459
|
const interactions = saved.interactions;
|
|
@@ -4114,6 +4468,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4114
4468
|
}
|
|
4115
4469
|
// 台词覆盖:两层(每条一组 + 相位的单句)。数组要清掉空串,否则 `[""]` 会被
|
|
4116
4470
|
// 当成"用户改过",`linesNow()` 就永远拿不到宠物默认了。
|
|
4471
|
+
PHASE_OVERRIDES.lines = {};
|
|
4117
4472
|
const lines = saved.lines;
|
|
4118
4473
|
if (lines !== null && typeof lines === "object") {
|
|
4119
4474
|
const next = {};
|
|
@@ -4361,6 +4716,12 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4361
4716
|
const field = TUNING_FIELDS.find((entry) => entry.key === key);
|
|
4362
4717
|
TUNING[key] = field === undefined ? value : clampSetting(field, value);
|
|
4363
4718
|
}
|
|
4719
|
+
if (playingUploaded !== null) {
|
|
4720
|
+
if (!(TUNING.soundVolume > 0)) stopUploadedSound();
|
|
4721
|
+
else playingUploaded.volume = TUNING.soundVolume;
|
|
4722
|
+
}
|
|
4723
|
+
// 动的是音量就顺手试听一小段 —— 否则"这个音量到底多大声"只能等下一次相位切换才知道。
|
|
4724
|
+
if (Object.prototype.hasOwnProperty.call(patch, "soundVolume")) scheduleVolumePreview();
|
|
4364
4725
|
// 走共享落点:有宿主就写宿主(DSH 与桌面端两个窗口都能看见),本地留一份兜底。
|
|
4365
4726
|
persistShared({ tuning: Object.assign({}, TUNING) });
|
|
4366
4727
|
notifySettings();
|
|
@@ -4388,6 +4749,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4388
4749
|
TUNING[field.key] = clampSetting(field, value);
|
|
4389
4750
|
restored = true;
|
|
4390
4751
|
}
|
|
4752
|
+
if (playingUploaded !== null) {
|
|
4753
|
+
if (!(TUNING.soundVolume > 0)) stopUploadedSound();
|
|
4754
|
+
else playingUploaded.volume = TUNING.soundVolume;
|
|
4755
|
+
}
|
|
4391
4756
|
if (restored) notifySettings();
|
|
4392
4757
|
};
|
|
4393
4758
|
|
|
@@ -4409,8 +4774,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4409
4774
|
function TuningControls(props) {
|
|
4410
4775
|
useSettings();
|
|
4411
4776
|
const only = props?.group;
|
|
4412
|
-
|
|
4413
|
-
|
|
4777
|
+
// 也能按**具体键**挑几根滑杆(官方插件页那一节只放最常用的四项,
|
|
4778
|
+
// 不想把八根手感滑杆整套搬过去)。给了 keys 就按 keys 过滤,忽略 group。
|
|
4779
|
+
const keys = Array.isArray(props?.keys) ? props.keys : null;
|
|
4780
|
+
const fields = TUNING_FIELDS.filter((field) => keys === null
|
|
4781
|
+
? (only === undefined || tuningGroupOf(field) === only)
|
|
4782
|
+
: keys.includes(field.key));
|
|
4783
|
+
return h("div", { "data-settings": "", "data-setting": only ?? (keys === null ? "all" : "subset") },
|
|
4414
4784
|
fields.map((field) => h("label", {
|
|
4415
4785
|
key: field.key,
|
|
4416
4786
|
"data-field": field.key,
|
|
@@ -4430,13 +4800,14 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4430
4800
|
h("code", { "data-value": field.key }, String(TUNING[field.key])),
|
|
4431
4801
|
)),
|
|
4432
4802
|
// 恢复默认单独一行:它是"这一组"的动作,混在滑杆行里会看着像又一个控件。
|
|
4803
|
+
// 只挑了四项时按钮说"全部" —— 它恢复的确实是整份 TUNING,不含糊。
|
|
4433
4804
|
only === undefined || only === "feel"
|
|
4434
4805
|
? h("div", { "data-add-row": "" },
|
|
4435
4806
|
h("button", {
|
|
4436
4807
|
type: "button",
|
|
4437
4808
|
"data-reset": "tuning",
|
|
4438
4809
|
onClick: () => applyTuning(Object.assign({}, TUNING_DEFAULTS)),
|
|
4439
|
-
}, "恢复默认"))
|
|
4810
|
+
}, keys === null ? "恢复默认" : "全部恢复默认"))
|
|
4440
4811
|
: null,
|
|
4441
4812
|
);
|
|
4442
4813
|
}
|
|
@@ -5366,6 +5737,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5366
5737
|
phaseExpressionRef.current = Object.assign({}, phaseBaseRef.current.expressions);
|
|
5367
5738
|
// 设置界面(含 DSH 设置页那个独立组件)需要清单里有哪些动作/表情/槽位。
|
|
5368
5739
|
MANIFEST.current = pet;
|
|
5740
|
+
UPLOADED_SOUNDS.petId = null;
|
|
5741
|
+
UPLOADED_SOUNDS.sounds = {};
|
|
5742
|
+
stopUploadedSound();
|
|
5743
|
+
void pullUploadedSounds();
|
|
5369
5744
|
// 头部部件(宿主从 cdi3 的作者命名里挑的,21 个)交给控制器:摸头判定按这些
|
|
5370
5745
|
// 部件的**真实三角面**测,而不是一个手调内边距的方框。
|
|
5371
5746
|
motion.current.setHeadParts(pet.headParts ?? []);
|
|
@@ -6720,6 +7095,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
6720
7095
|
});
|
|
6721
7096
|
// The motion subscription (declared above) flushes a deferred phase here.
|
|
6722
7097
|
flushPhaseRef.current = applyPhase;
|
|
7098
|
+
let receivedInitialPhase = false;
|
|
6723
7099
|
const onMessage = (event) => {
|
|
6724
7100
|
let payload;
|
|
6725
7101
|
try {
|
|
@@ -6729,9 +7105,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
6729
7105
|
}
|
|
6730
7106
|
const phase = payload?.phase;
|
|
6731
7107
|
if (typeof phase !== "string") return;
|
|
7108
|
+
const initial = !receivedInitialPhase;
|
|
7109
|
+
receivedInitialPhase = true;
|
|
6732
7110
|
setPhaseState(phase);
|
|
6733
7111
|
if (phase === phaseRef.current) return;
|
|
6734
7112
|
phaseRef.current = phase;
|
|
7113
|
+
// 只提示真正的新相位;首次 SSE 快照和相同相位重发都不响。
|
|
7114
|
+
if (!initial) playPhaseSound(phase);
|
|
6735
7115
|
// A phase animation may replace another phase animation, but must never
|
|
6736
7116
|
// cut off something the user just triggered (tap / fidget / panel).
|
|
6737
7117
|
const owner = motion.current.kind();
|
|
@@ -6748,6 +7128,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
6748
7128
|
source.addEventListener("message", onMessage);
|
|
6749
7129
|
return () => {
|
|
6750
7130
|
source.close();
|
|
7131
|
+
stopUploadedSound();
|
|
6751
7132
|
phaseRef.current = "idle";
|
|
6752
7133
|
phasePinsRef.current = {};
|
|
6753
7134
|
phaseSlotsRef.current = [];
|
|
@@ -7391,7 +7772,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
7391
7772
|
// 桌面端专属:设置正文(和 DSH 设置页那一节是同一个组件、同一份 store)。
|
|
7392
7773
|
// 外面这层 `data-settings` 把作用域带进来 —— 面板只有 270→340px 宽,
|
|
7393
7774
|
// 样式表里已经为窄容器收过一档列宽。
|
|
7394
|
-
? h("div", { "data-settings": "", "data-panel-settings": "" }, h(PetSettingsBody,
|
|
7775
|
+
? h("div", { "data-settings": "", "data-panel-settings": "" }, h(PetSettingsBody, { compact: true }))
|
|
7395
7776
|
: null,
|
|
7396
7777
|
),
|
|
7397
7778
|
h("div", { "data-hintrow": "" }, "在宠物身上点右键打开这里 · Esc 或点空白处关闭"),
|
|
@@ -7528,19 +7909,36 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
7528
7909
|
* 列表里只出现**被定制过**的相位(一行 = 一条定制);新增一个相位会先按 pet.json
|
|
7529
7910
|
* 的 `looksByPhase` 把默认池子显示出来,改哪张表就物化哪张表。
|
|
7530
7911
|
*/
|
|
7531
|
-
function PhaseControls() {
|
|
7912
|
+
function PhaseControls({ compact = false } = {}) {
|
|
7532
7913
|
useSettings();
|
|
7533
|
-
//
|
|
7534
|
-
// 折叠是给"配好之后收起来"用的,不是默认藏起来。
|
|
7914
|
+
// 设置页先概览全部相位,一次展开一个;窄面板保持原有逐行折叠行为。
|
|
7535
7915
|
const [folded, setFolded] = useState({});
|
|
7916
|
+
const [selected, setSelected] = useState(null);
|
|
7917
|
+
const [uploading, setUploading] = useState({});
|
|
7918
|
+
const [uploadErrors, setUploadErrors] = useState({});
|
|
7919
|
+
const updateUploaded = async (phase, file) => {
|
|
7920
|
+
setUploading((previous) => Object.assign({}, previous, { [phase]: true }));
|
|
7921
|
+
setUploadErrors((previous) => Object.assign({}, previous, { [phase]: "" }));
|
|
7922
|
+
try {
|
|
7923
|
+
await changeUploadedSound(phase, file);
|
|
7924
|
+
if (file !== null && PHASE_OVERRIDES.sounds[phase]?.length === 0) setPhaseSoundMuted(phase, false);
|
|
7925
|
+
} catch (error) {
|
|
7926
|
+
setUploadErrors((previous) => Object.assign({}, previous, {
|
|
7927
|
+
[phase]: error instanceof Error ? error.message : "保存音频失败",
|
|
7928
|
+
}));
|
|
7929
|
+
} finally {
|
|
7930
|
+
setUploading((previous) => Object.assign({}, previous, { [phase]: false }));
|
|
7931
|
+
}
|
|
7932
|
+
};
|
|
7536
7933
|
const pet = MANIFEST.current;
|
|
7537
7934
|
if (pet === null) return h("div", { "data-empty": "phases" }, "宠物还没加载好");
|
|
7538
7935
|
const slots = pet.expressionSlots ?? [];
|
|
7539
7936
|
const known = Array.from(new Set([
|
|
7937
|
+
...PHASE_LINE_KEYS,
|
|
7540
7938
|
...Object.keys(PHASE_MOTION),
|
|
7541
7939
|
...Object.keys(pet.looksByPhase ?? {}),
|
|
7542
7940
|
...Object.keys(PHASE_OVERRIDES.phases),
|
|
7543
|
-
]))
|
|
7941
|
+
]));
|
|
7544
7942
|
// **全部相位都列出来**,不再是"只列定制过的"。
|
|
7545
7943
|
//
|
|
7546
7944
|
// 用户问过:"会话相位里是空的,但点了添加有默认值,这正常吗?应该初始就带上默认值吧。"
|
|
@@ -7549,7 +7947,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
7549
7947
|
// **看不出宠物默认会演什么**,而且和「摸鱼」那张卡不一致(摸鱼永远列着它的池子)。
|
|
7550
7948
|
//
|
|
7551
7949
|
// 现在每行显示的是**有效池子**(有覆盖用覆盖,没有就用 pet.json 的默认),行头标出
|
|
7552
|
-
// 「默认 /
|
|
7950
|
+
// 「默认 / 已改过」,只有改过的那行才显示「重置动作」。覆盖依然**只在真的编辑时**
|
|
7553
7951
|
// 才落盘(`setPhasePool` 会把有效池子整份物化),所以"看一眼"不留痕迹、
|
|
7554
7952
|
// 以后默认值改进了也照样能吃到。
|
|
7555
7953
|
return h("div", { "data-settings": "", "data-setting": "phases" },
|
|
@@ -7561,32 +7959,127 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
7561
7959
|
const pools = phasePoolsFor(phase);
|
|
7562
7960
|
const used = Object.keys(pools);
|
|
7563
7961
|
const free = slots.filter((slot) => used.indexOf(slot.id) === -1);
|
|
7564
|
-
const open = folded[phase] !== true;
|
|
7962
|
+
const open = compact ? folded[phase] !== true : selected === phase;
|
|
7565
7963
|
const candidates = used.reduce((sum, slotId) => sum + (pools[slotId] ?? []).length, 0);
|
|
7566
|
-
|
|
7964
|
+
const soundFile = uploadedSoundFor(phase);
|
|
7965
|
+
return h("div", { key: phase, "data-phase": phase,
|
|
7966
|
+
...(open ? { "data-expanded": "" } : {}),
|
|
7967
|
+
...(custom ? { "data-phase-custom": "" } : {}) },
|
|
7567
7968
|
h("div", { "data-phase-head": "", ...(open ? {} : { "data-collapsed": "" }) },
|
|
7568
|
-
h("
|
|
7569
|
-
|
|
7570
|
-
|
|
7571
|
-
|
|
7572
|
-
|
|
7573
|
-
|
|
7574
|
-
|
|
7575
|
-
|
|
7576
|
-
|
|
7577
|
-
|
|
7969
|
+
h("div", { "data-phase-head-main": "" },
|
|
7970
|
+
h("button", {
|
|
7971
|
+
type: "button",
|
|
7972
|
+
"data-phase-toggle": phase,
|
|
7973
|
+
"aria-expanded": open,
|
|
7974
|
+
title: open ? "收起" : "展开",
|
|
7975
|
+
onClick: () => compact
|
|
7976
|
+
? setFolded((prev) => Object.assign({}, prev, { [phase]: !(prev[phase] === true) }))
|
|
7977
|
+
: setSelected((prev) => prev === phase ? null : phase),
|
|
7978
|
+
},
|
|
7979
|
+
h("span", { "data-caret": "" }, open ? "▾" : "▸"),
|
|
7980
|
+
h("span", { "data-phase-name": "" }, PHASE_LINE_FIELDS.find((field) => field.key === phase)?.label ?? phase),
|
|
7981
|
+
h("code", null, phase),
|
|
7982
|
+
),
|
|
7983
|
+
custom ? h("button", {
|
|
7984
|
+
type: "button",
|
|
7985
|
+
"data-phase-remove": phase,
|
|
7986
|
+
title: "恢复此相位的动作槽位默认值",
|
|
7987
|
+
"aria-label": "恢复" + phase + "的动作槽位默认值",
|
|
7988
|
+
onClick: () => removePhaseRow(phase),
|
|
7989
|
+
}, "重置动作") : null,
|
|
7990
|
+
),
|
|
7991
|
+
h("div", { "data-phase-meta": "" },
|
|
7992
|
+
h("span", { "data-phase-chip": "motion", ...(custom ? { "data-custom": "" } : {}) },
|
|
7993
|
+
"动作 " + used.length + " 槽 · " + candidates + " 候选" + (custom ? " · 已改" : "")),
|
|
7994
|
+
h("span", { "data-phase-chip": "line",
|
|
7995
|
+
...(PHASE_OVERRIDES.lines.phase?.[phase] !== undefined ? { "data-custom": "" } : {}) },
|
|
7996
|
+
"台词 " + (PHASE_OVERRIDES.lines.phase?.[phase] !== undefined ? "已改" : "默认")),
|
|
7997
|
+
h("span", { "data-phase-chip": "sound",
|
|
7998
|
+
...(PHASE_OVERRIDES.sounds[phase] !== undefined || soundFile !== undefined ? { "data-custom": "" } : {}) },
|
|
7999
|
+
"音频 " + (PHASE_OVERRIDES.sounds[phase] !== undefined ? "已静音"
|
|
8000
|
+
: soundFile !== undefined ? "已上传" : petSoundNotesFor(phase).length === 0 ? "未配置" : "宠物默认")),
|
|
7578
8001
|
),
|
|
7579
|
-
// × 只在**改过**的行上出现:它就是"恢复默认"(删掉整个覆盖)。
|
|
7580
|
-
custom ? h("button", {
|
|
7581
|
-
type: "button",
|
|
7582
|
-
"data-phase-remove": phase,
|
|
7583
|
-
title: "恢复默认(删掉这个相位的全部改动)",
|
|
7584
|
-
onClick: () => removePhaseRow(phase),
|
|
7585
|
-
}, "×") : null,
|
|
7586
8002
|
),
|
|
7587
8003
|
open ? [
|
|
8004
|
+
h("div", { key: "details", "data-phase-details": "" },
|
|
8005
|
+
h("div", { "data-phase-section-title": "" }, "气泡与提示音"),
|
|
8006
|
+
h("div", { "data-phase-line": phase },
|
|
8007
|
+
h("label", { "data-line-row": "phase:" + phase },
|
|
8008
|
+
h("span", { "data-row-label": "" }, "气泡台词"),
|
|
8009
|
+
h("input", {
|
|
8010
|
+
type: "text",
|
|
8011
|
+
"data-line-input": "phase:" + phase,
|
|
8012
|
+
value: linesNow().phase?.[phase] ?? "",
|
|
8013
|
+
placeholder: pet.lines?.phase?.[phase] ?? "",
|
|
8014
|
+
onChange: (event) => applyOverride({ lines: { phase: { [phase]: event.target.value } } }),
|
|
8015
|
+
})),
|
|
8016
|
+
Object.prototype.hasOwnProperty.call(PHASE_OVERRIDES.lines.phase ?? {}, phase)
|
|
8017
|
+
? h("button", {
|
|
8018
|
+
type: "button",
|
|
8019
|
+
"data-reset": "",
|
|
8020
|
+
"data-phase-line-reset": phase,
|
|
8021
|
+
onClick: () => {
|
|
8022
|
+
delete PHASE_OVERRIDES.lines.phase[phase];
|
|
8023
|
+
saveOverrides();
|
|
8024
|
+
notifySettings();
|
|
8025
|
+
},
|
|
8026
|
+
}, "恢复默认") : null,
|
|
8027
|
+
),
|
|
8028
|
+
h("label", { "data-phase-sound": phase },
|
|
8029
|
+
h("span", { "data-row-label": "" }, "提示音"),
|
|
8030
|
+
petSoundNotesFor(phase).length === 0 && soundFile === undefined
|
|
8031
|
+
? h("span", { "data-note-inline": "" }, "未配置;可上传音频")
|
|
8032
|
+
: h("span", { "data-phase-sound-control": "" },
|
|
8033
|
+
h("input", {
|
|
8034
|
+
type: "checkbox",
|
|
8035
|
+
"aria-label": (PHASE_LINE_FIELDS.find((field) => field.key === phase)?.label ?? phase) + "提示音",
|
|
8036
|
+
checked: PHASE_OVERRIDES.sounds[phase] === undefined
|
|
8037
|
+
? soundFile !== undefined || soundNotesFor(phase).length > 0
|
|
8038
|
+
: soundNotesFor(phase).length > 0,
|
|
8039
|
+
onChange: (event) => setPhaseSoundMuted(phase, !event.target.checked),
|
|
8040
|
+
}),
|
|
8041
|
+
h("span", { "data-note-inline": "" },
|
|
8042
|
+
soundFile === undefined
|
|
8043
|
+
? petSoundNotesFor(phase).map((note) => note[0] + "Hz").join(" → ")
|
|
8044
|
+
+ (PHASE_OVERRIDES.sounds[phase] === undefined ? " · 宠物默认" : " · 已静音")
|
|
8045
|
+
: "已上传 " + (soundFile.mime === "audio/mpeg" ? "MP3"
|
|
8046
|
+
: soundFile.mime.replace("audio/", "").toUpperCase())
|
|
8047
|
+
+ " · " + (soundFile.bytes < 1024 ? "<1" : Math.round(soundFile.bytes / 1024)) + " KB"
|
|
8048
|
+
+ (PHASE_OVERRIDES.sounds[phase] === undefined ? "" : " · 已静音")))),
|
|
8049
|
+
h("div", { "data-phase-upload": phase },
|
|
8050
|
+
// 「试听」放在上传这一行:它既不为某个来源独占,也不该塞进上面那个
|
|
8051
|
+
// `<label>`(label 里放 button,点试听会连带把静音勾选框点掉)。
|
|
8052
|
+
h("button", {
|
|
8053
|
+
type: "button",
|
|
8054
|
+
"data-phase-preview": phase,
|
|
8055
|
+
disabled: !canPreviewPhaseSound(phase),
|
|
8056
|
+
title: "试听这个相位的有效来源(不受总开关与单相位静音影响,音量用当前提示音音量)",
|
|
8057
|
+
onClick: () => { previewPhaseSound(phase); },
|
|
8058
|
+
}, "试听"),
|
|
8059
|
+
h("label", { "data-upload-picker": phase, "data-busy": uploading[phase] === true ? "" : undefined },
|
|
8060
|
+
h("input", {
|
|
8061
|
+
type: "file", accept: ".mp3,.wav,.ogg,audio/mpeg,audio/wav,audio/ogg",
|
|
8062
|
+
"data-upload-input": phase, disabled: uploading[phase] === true,
|
|
8063
|
+
onChange: (event) => {
|
|
8064
|
+
const file = event.target.files?.[0];
|
|
8065
|
+
event.target.value = "";
|
|
8066
|
+
if (file) void updateUploaded(phase, file);
|
|
8067
|
+
},
|
|
8068
|
+
}),
|
|
8069
|
+
uploading[phase] ? "上传中…" : soundFile === undefined ? "上传音频" : "替换音频"),
|
|
8070
|
+
h("span", { "data-upload-hint": "" }, "MP3 / WAV / OGG,最多 1 MB"),
|
|
8071
|
+
soundFile === undefined ? null : h("button", {
|
|
8072
|
+
type: "button", "data-upload-reset": phase, disabled: uploading[phase] === true,
|
|
8073
|
+
onClick: () => { void updateUploaded(phase, null); },
|
|
8074
|
+
}, "移除上传"),
|
|
8075
|
+
uploadErrors[phase] ? h("span", { role: "alert", "data-upload-error": phase }, uploadErrors[phase]) : null,
|
|
8076
|
+
),
|
|
8077
|
+
),
|
|
8078
|
+
h("div", { key: "motion-head", "data-phase-motion-head": "" },
|
|
8079
|
+
h("span", null, "动作槽位"),
|
|
8080
|
+
h("span", { "data-phase-motion-hint": "" }, "按候选权重抽取")),
|
|
7588
8081
|
used.length === 0
|
|
7589
|
-
? h("div", { "data-pool-empty": "", key: "empty" }, "
|
|
8082
|
+
? h("div", { "data-pool-empty": "", key: "empty" }, "没有动作槽位,可在下方添加")
|
|
7590
8083
|
: null,
|
|
7591
8084
|
...used.map((slotId) => {
|
|
7592
8085
|
// 槽位 id 来自池子的键:清单里没有它(换了宠物)就退化成一个空槽位,
|
|
@@ -8014,7 +8507,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8014
8507
|
}
|
|
8015
8508
|
|
|
8016
8509
|
/**
|
|
8017
|
-
*
|
|
8510
|
+
* 台词:非会话相位的气泡文本;相位台词在对应相位行。
|
|
8018
8511
|
*
|
|
8019
8512
|
* 一组 = 一行输入,**用 `|` 分隔多个变体**(随机挑一句);相位台词是单句。
|
|
8020
8513
|
* 留空就退回宠物默认(`pet.json` 的 `live2d.lines`),所以"看一眼"不会把默认改掉。
|
|
@@ -8042,14 +8535,6 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8042
8535
|
(text) => setLines({ [field.key]: splitLineInput(text) }),
|
|
8043
8536
|
joinLineInput(MANIFEST.current?.lines?.[field.key]),
|
|
8044
8537
|
)),
|
|
8045
|
-
h("div", { "data-row-label": "", "data-note": "" }, "会话相位"),
|
|
8046
|
-
...PHASE_LINE_FIELDS.map((field) => row(
|
|
8047
|
-
"phase:" + field.key,
|
|
8048
|
-
field.label,
|
|
8049
|
-
effective.phase?.[field.key] ?? "",
|
|
8050
|
-
(text) => setLines({ phase: { [field.key]: text } }),
|
|
8051
|
-
MANIFEST.current?.lines?.phase?.[field.key] ?? "",
|
|
8052
|
-
)),
|
|
8053
8538
|
);
|
|
8054
8539
|
}
|
|
8055
8540
|
|
|
@@ -8164,20 +8649,44 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8164
8649
|
);
|
|
8165
8650
|
}
|
|
8166
8651
|
|
|
8167
|
-
|
|
8168
|
-
|
|
8652
|
+
/**
|
|
8653
|
+
* 一张设置卡片:标题条(+ 右上角提示)+ 内容区。
|
|
8654
|
+
*
|
|
8655
|
+
* 提到模块作用域是因为现在**两个地方**要画同一种卡片:DSH 设置页那一节,和官方
|
|
8656
|
+
* 「插件」页上这一组合包的配置区。各写一份的下场是两边慢慢长得不一样。
|
|
8657
|
+
*/
|
|
8658
|
+
function settingsCard(key, title, hint, body) {
|
|
8659
|
+
return h("div", { key, "data-card": key },
|
|
8169
8660
|
h("div", { "data-card-head": "" },
|
|
8170
8661
|
h("span", { "data-card-title": "" }, title),
|
|
8171
8662
|
hint === null || hint === undefined ? null : h("span", { "data-card-hint": "" }, hint),
|
|
8172
8663
|
),
|
|
8173
8664
|
h("div", { "data-card-body": "" }, body),
|
|
8174
8665
|
);
|
|
8666
|
+
}
|
|
8667
|
+
|
|
8668
|
+
function PetSettingsBody({ compact = false } = {}) {
|
|
8669
|
+
useSettings();
|
|
8670
|
+
const card = settingsCard;
|
|
8175
8671
|
return [
|
|
8176
8672
|
...TUNING_GROUPS.filter((group) => !TUNING_GROUPS_INLINE.includes(group.id)).map((group) => card(
|
|
8177
8673
|
"tune-" + group.id, group.label, group.hint, h(TuningControls, { group: group.id }))),
|
|
8178
8674
|
// 显示层放最上面:它是"这只宠物在哪"的问题,比手感/池子更先要回答。
|
|
8179
8675
|
card("layer", "显示位置", "页面内 / 桌面上", h(LayerControls, null)),
|
|
8180
|
-
card("
|
|
8676
|
+
card("sound", "会话提示音", "总开关 · 音量(拖滑杆会试听)", [
|
|
8677
|
+
h("label", { key: "enabled", "data-flag-row": "soundEnabled" },
|
|
8678
|
+
h("input", {
|
|
8679
|
+
type: "checkbox",
|
|
8680
|
+
checked: FLAGS.soundEnabled === true,
|
|
8681
|
+
"data-flag": "soundEnabled",
|
|
8682
|
+
onChange: (event) => applyFlag("soundEnabled", event.target.checked),
|
|
8683
|
+
}),
|
|
8684
|
+
h("span", { "data-row-label": "" }, "开启会话提示音"),
|
|
8685
|
+
h("span", { "data-note-inline": "" }, FLAGS.soundEnabled ? "" : "默认静音"),
|
|
8686
|
+
),
|
|
8687
|
+
h(TuningControls, { key: "volume", group: "sound" }),
|
|
8688
|
+
]),
|
|
8689
|
+
card("phases", "会话相位", "点开相位调整台词 · 音频 · 动作", h(PhaseControls, { compact })),
|
|
8181
8690
|
// 「摸鱼节奏」(多久摸一次)和「摸鱼」(摸鱼做什么)是同一件事的两半,原来
|
|
8182
8691
|
// 被「会话相位」隔成两张卡,调摸鱼要上下跳。合成一张:节奏在上、池子在下。
|
|
8183
8692
|
card("pools", "摸鱼", "多久摸一次 · 摸鱼做什么", [
|
|
@@ -8185,10 +8694,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8185
8694
|
h(FidgetControls, { key: "pools" }),
|
|
8186
8695
|
]),
|
|
8187
8696
|
card("outfit", "装扮", null, h(OutfitControls, null)),
|
|
8188
|
-
//
|
|
8697
|
+
// 相位台词在相位卡;气泡卡只保留互动台词、开关和位置。
|
|
8189
8698
|
card("interact", "互动", "摸头 / 摸尾巴 / 转圈", h(InteractControls, null)),
|
|
8190
8699
|
card("bubble", "气泡", "显示什么 · 在哪 · 停留多久", [
|
|
8191
|
-
h("label", { "data-flag-row": "bubbleEnabled" },
|
|
8700
|
+
h("label", { key: "enabled", "data-flag-row": "bubbleEnabled" },
|
|
8192
8701
|
h("input", {
|
|
8193
8702
|
type: "checkbox",
|
|
8194
8703
|
checked: FLAGS.bubbleEnabled === true,
|
|
@@ -8209,6 +8718,61 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8209
8718
|
return h("div", { "data-pet-settings": "" }, h(PetSettingsBody, null));
|
|
8210
8719
|
}
|
|
8211
8720
|
|
|
8721
|
+
/**
|
|
8722
|
+
* 侧栏「插件」页上这一组合包的配置区(`plugins.bundle.config`,键 = 包名)。
|
|
8723
|
+
*
|
|
8724
|
+
* 为什么要有它:官方插件页把"插件自带配置"渲染在**组合包详情页**的描述与行之间
|
|
8725
|
+
* (`dsh-client-ui-plugin-manager`;页面只在有人注册了这个键时才画那一节)。我们的
|
|
8726
|
+
* 组合包只有一行(`live2d-pet`),所以"这一行的配置"就是"这只宠物的配置"。
|
|
8727
|
+
*
|
|
8728
|
+
* 它**不是**设置正文的第二个家:相位台词、池子编辑器、装扮与文案仍然只在
|
|
8729
|
+
* 「设置 → 桌宠」里改(正文只写一份,这条是 2.0.0 定下来的)。这里是插件页上最常用的
|
|
8730
|
+
* 那几项 —— 开关、四根滑杆、显示位置 —— 写进去的还是**同一份共享设置**
|
|
8731
|
+
* (`persistShared` → `%DSH_HOME%\pet-settings.json`),所以两个界面永远一致。
|
|
8732
|
+
*
|
|
8733
|
+
* 样式直接复用设置正文那一套(`[data-pet-settings]` 作用域):卡片、行、滑杆、药丸
|
|
8734
|
+
* 按钮的观感与设置页逐字相同,不需要新写一份 CSS。
|
|
8735
|
+
*/
|
|
8736
|
+
function PetPluginConfig() {
|
|
8737
|
+
useSettings();
|
|
8738
|
+
const layer = useLayerState();
|
|
8739
|
+
const pet = MANIFEST.current ?? {};
|
|
8740
|
+
const petLabel = typeof pet.label === "string" && pet.label !== ""
|
|
8741
|
+
? pet.label
|
|
8742
|
+
: (typeof pet.id === "string" && pet.id !== "" ? pet.id : "还没加载");
|
|
8743
|
+
const modeLabel = layer.mode === "inline" ? "页面内" : (layer.mode === "desktop" ? "桌面" : "自动");
|
|
8744
|
+
const ownerLabel = layer.owner === "desktop" ? "现在在桌面上" : "现在在页面里";
|
|
8745
|
+
const flagRow = (key, label, note) => h("label", { key, "data-flag-row": key },
|
|
8746
|
+
h("input", {
|
|
8747
|
+
type: "checkbox",
|
|
8748
|
+
checked: FLAGS[key] === true,
|
|
8749
|
+
"data-flag": key,
|
|
8750
|
+
onChange: (event) => applyFlag(key, event.target.checked),
|
|
8751
|
+
}),
|
|
8752
|
+
h("span", { "data-row-label": "" }, label),
|
|
8753
|
+
note === undefined ? null : h("span", { "data-note-inline": "" }, note),
|
|
8754
|
+
);
|
|
8755
|
+
return h("div", { "data-pet-settings": "", "data-pet-plugin-page": "" },
|
|
8756
|
+
// 第一行先说"这只宠物现在在哪":它比任何开关都更该先回答,
|
|
8757
|
+
// 而且换过显示位置的人回来第一眼就是找这一句。
|
|
8758
|
+
h("div", { "data-plugin-status": "" },
|
|
8759
|
+
"当前宠物:" + petLabel + " · 显示位置:" + modeLabel + "(" + ownerLabel + ")"),
|
|
8760
|
+
settingsCard("plugin-flags", "常用开关", "点一下立刻生效", [
|
|
8761
|
+
flagRow("bubbleEnabled", "显示气泡", FLAGS.bubbleEnabled ? undefined : "已关:任何台词都不弹"),
|
|
8762
|
+
flagRow("soundEnabled", "会话提示音", FLAGS.soundEnabled ? undefined : "默认静音"),
|
|
8763
|
+
flagRow("patEnabled", "摸头有反应", undefined),
|
|
8764
|
+
flagRow("tailEnabled", "摸尾巴有反应", undefined),
|
|
8765
|
+
flagRow("spinEnabled", "鼠标绕着转圈会晕", undefined),
|
|
8766
|
+
flagRow("outfitArchive", "跨启动记住装扮", FLAGS.outfitArchive ? undefined : "已关:也不再记录"),
|
|
8767
|
+
]),
|
|
8768
|
+
settingsCard("plugin-tuning", "手感", "注视范围 · 摸鱼节奏 · 音量",
|
|
8769
|
+
h(TuningControls, { keys: ["gazeRangePx", "gazeWatchingRatio", "fidgetQuietMs", "soundVolume"] })),
|
|
8770
|
+
settingsCard("plugin-layer", "显示位置", "页面内 / 桌面上", h(LayerControls, null)),
|
|
8771
|
+
h("div", { "data-note": "" },
|
|
8772
|
+
"相位台词、池子编辑器、装扮与台词文案在「设置 → 桌宠」里改。"),
|
|
8773
|
+
);
|
|
8774
|
+
}
|
|
8775
|
+
|
|
8212
8776
|
function applySettings(ctx) {
|
|
8213
8777
|
if (ctx === null || ctx === undefined) return;
|
|
8214
8778
|
const slots = ctx.slots;
|
|
@@ -8225,11 +8789,50 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8225
8789
|
}
|
|
8226
8790
|
}
|
|
8227
8791
|
|
|
8792
|
+
/**
|
|
8793
|
+
* 侧栏「插件」页里的配置区。
|
|
8794
|
+
*
|
|
8795
|
+
* 两个容易踩的点:
|
|
8796
|
+
*
|
|
8797
|
+
* * **键必须写包名**(`PACKAGE_NAME`)—— 页面按组合包的包名派发这一格,写错的表现是
|
|
8798
|
+
* "注册成功、页面什么都不多"。
|
|
8799
|
+
* * **`slots.inject` 而不是"现在就注册"**:插件页是懒挂载的面板,它的子 slot 在页面
|
|
8800
|
+
* 注册时才声明;`inject` 会等到那一刻再跑(页面重开/销毁也跟着走)。
|
|
8801
|
+
*/
|
|
8802
|
+
function applyPluginPage(ctx) {
|
|
8803
|
+
if (ctx === null || ctx === undefined) return;
|
|
8804
|
+
const slots = ctx.slots;
|
|
8805
|
+
if (slots === undefined || slots === null) return;
|
|
8806
|
+
try {
|
|
8807
|
+
slots.inject("plugins.bundle.config", () => slots.register({
|
|
8808
|
+
name: "plugins.bundle.config",
|
|
8809
|
+
key: PACKAGE_NAME,
|
|
8810
|
+
}, () => h(PetPluginConfig, null)));
|
|
8811
|
+
} catch {
|
|
8812
|
+
/* 老版本 DSH 没有这条 slot:设置页那一节还在,不影响使用 */
|
|
8813
|
+
}
|
|
8814
|
+
}
|
|
8815
|
+
|
|
8228
8816
|
function apply(ctx) {
|
|
8229
8817
|
ensureStyle();
|
|
8230
8818
|
// 先按本地存档起来(宿主不在时这就是全部)。
|
|
8231
8819
|
restoreTuning();
|
|
8232
8820
|
restoreOverrides();
|
|
8821
|
+
// 刷新后浏览器会锁住声音;下次用户点击页面时再解锁,不自动播放。
|
|
8822
|
+
window.addEventListener("pointerdown", unlockSound);
|
|
8823
|
+
ctx.effect(() => () => {
|
|
8824
|
+
window.removeEventListener("pointerdown", unlockSound);
|
|
8825
|
+
// 卸载前把还没触发的音量试听定时器清掉:否则它 160ms 后醒来会**新建**一个
|
|
8826
|
+
// AudioContext 并放出一声 —— 宠物都已经走了。
|
|
8827
|
+
if (volumePreviewTimer !== 0) {
|
|
8828
|
+
window.clearTimeout(volumePreviewTimer);
|
|
8829
|
+
volumePreviewTimer = 0;
|
|
8830
|
+
}
|
|
8831
|
+
if (soundContext !== null) {
|
|
8832
|
+
void soundContext.close().catch(() => {});
|
|
8833
|
+
soundContext = null;
|
|
8834
|
+
}
|
|
8835
|
+
}, "live2d-pet: notification audio");
|
|
8233
8836
|
// 再问宿主要"共享的那一份":桌面端与 DSH 是两个 origin,localStorage 互不可见,
|
|
8234
8837
|
// 所以跨窗口一致只能靠宿主。拉回来会覆盖本地(宿主是权威),并广播给两个界面。
|
|
8235
8838
|
const applyRemote = (payload) => {
|
|
@@ -8250,9 +8853,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8250
8853
|
void pullShared(applyRemote);
|
|
8251
8854
|
// 3 秒轮询:另一个窗口改了,这个窗口跟着变。`storage` 事件不跨 origin,只能轮询。
|
|
8252
8855
|
if (sharedPoll === 0) {
|
|
8253
|
-
sharedPoll = window.setInterval(() => {
|
|
8856
|
+
sharedPoll = window.setInterval(() => {
|
|
8857
|
+
void pullShared(applyRemote);
|
|
8858
|
+
void pullUploadedSounds();
|
|
8859
|
+
}, SHARED_POLL_MS);
|
|
8254
8860
|
}
|
|
8255
8861
|
applySettings(ctx);
|
|
8862
|
+
applyPluginPage(ctx);
|
|
8256
8863
|
// Takeover: an earlier instance — a hot reload, or one left behind by a
|
|
8257
8864
|
// crashed reload — must not leave a second floating pet on the page.
|
|
8258
8865
|
teardown();
|