dsh-pet-live2d 3.1.3 → 3.2.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 +14 -0
- package/README.md +26 -9
- package/lib/client.js +547 -58
- package/lib/index.js +86 -1
- package/lib/sound-files.js +231 -0
- package/package.json +3 -3
- package/pets/ds-whale-girl/README.md +2 -0
- package/pets/ds-whale-girl/pet.json +7 -1
package/lib/client.js
CHANGED
|
@@ -3456,32 +3456,73 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3456
3456
|
scope + " [data-relation-remove]:hover{opacity:1;background:rgba(127,127,127,.22)}",
|
|
3457
3457
|
|
|
3458
3458
|
// ---- × 删除 --------------------------------------------------------
|
|
3459
|
-
scope + " [data-pool-remove]," + scope + " [data-
|
|
3460
|
-
+ scope + " [data-pool-remove-slot]{justify-self:center;"
|
|
3459
|
+
scope + " [data-pool-remove]," + scope + " [data-pool-remove-slot]{justify-self:center;"
|
|
3461
3460
|
+ "width:20px;height:20px;line-height:1;font-size:13px;border-radius:6px;"
|
|
3462
3461
|
+ "opacity:.4;color:inherit}",
|
|
3463
|
-
scope + " [data-pool-remove]:hover," + scope + " [data-
|
|
3464
|
-
+ " [data-pool-remove-slot]:hover{opacity:1;"
|
|
3462
|
+
scope + " [data-pool-remove]:hover," + scope + " [data-pool-remove-slot]:hover{opacity:1;"
|
|
3465
3463
|
+ "background:rgba(232,120,120,.2);color:#e87878}",
|
|
3466
3464
|
// 槽位那一行的 × 贴在右边(它是"整张表"的动作,不是"这一条"的)。
|
|
3467
3465
|
scope + " [data-pool-remove-slot]{margin-left:auto}",
|
|
3468
3466
|
|
|
3469
|
-
// ----
|
|
3467
|
+
// ---- 相位:概览、台词/音符、动作槽位分层 ----------------------------
|
|
3470
3468
|
scope + " [data-phase]{border:1px solid rgba(127,127,127,.24);border-radius:10px;"
|
|
3471
|
-
+ "margin:0 0
|
|
3472
|
-
scope + " [data-phase-head]{
|
|
3473
|
-
+ "
|
|
3469
|
+
+ "margin:0 0 9px;overflow:hidden}",
|
|
3470
|
+
scope + " [data-phase-head]{padding:7px 10px 8px;background:rgba(127,127,127,.07);"
|
|
3471
|
+
+ "border-bottom:1px solid rgba(127,127,127,.16)}",
|
|
3474
3472
|
scope + " [data-phase-head][data-collapsed]{border-bottom:0}",
|
|
3473
|
+
scope + " [data-phase-head-main]{display:flex;align-items:center;gap:6px;min-width:0}",
|
|
3474
|
+
scope + " [data-phase-remove]{flex:none;font-size:10px;color:inherit;opacity:.75;"
|
|
3475
|
+
+ "border:1px solid rgba(127,127,127,.3);border-radius:999px;padding:1px 7px}",
|
|
3476
|
+
scope + " [data-phase-remove]:hover{opacity:1;border-color:rgba(120,170,255,.7);"
|
|
3477
|
+
+ "background:rgba(120,170,255,.12)}",
|
|
3475
3478
|
scope + " [data-phase-toggle]{display:flex;align-items:center;gap:7px;flex:1;min-width:0;"
|
|
3476
3479
|
+ "font-size:12px;font-weight:600;text-align:left;color:inherit}",
|
|
3477
|
-
scope + " [data-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
+
scope + " [data-phase-toggle]:focus-visible{outline:2px solid rgba(120,170,255,.7);"
|
|
3481
|
+
+ "outline-offset:2px;border-radius:4px}",
|
|
3482
|
+
scope + " [data-caret]{font-size:10px;opacity:.7;width:9px;flex:none}",
|
|
3483
|
+
scope + " [data-phase-name]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
3484
|
+
scope + " [data-phase-toggle] code{font:400 10px/1.4 inherit;opacity:.55;flex:none}",
|
|
3485
|
+
scope + " [data-phase-meta]{display:flex;flex-wrap:wrap;gap:4px;margin:5px 0 0 16px;"
|
|
3486
|
+
+ "font-size:10px;font-variant-numeric:tabular-nums}",
|
|
3487
|
+
scope + " [data-phase-chip]{display:inline-block;padding:0 6px;border-radius:999px;"
|
|
3488
|
+
+ "border:1px solid rgba(127,127,127,.2);background:rgba(127,127,127,.05);opacity:.7}",
|
|
3489
|
+
scope + " [data-phase-chip][data-custom]{border-color:rgba(120,170,255,.5);"
|
|
3490
|
+
+ "background:rgba(120,170,255,.13);opacity:1}",
|
|
3491
|
+
scope + " [data-phase-details]{padding:8px 10px 9px;background:rgba(127,127,127,.025);"
|
|
3492
|
+
+ "border-bottom:1px solid rgba(127,127,127,.16)}",
|
|
3493
|
+
scope + " [data-phase-section-title]{font-size:10px;font-weight:600;opacity:.65;margin-bottom:3px}",
|
|
3494
|
+
scope + " [data-phase-line]{display:flex;align-items:center;gap:6px;min-width:0}",
|
|
3495
|
+
scope + " [data-phase-line] [data-line-row]{grid-template-columns:84px minmax(0,1fr);"
|
|
3496
|
+
+ "flex:1;min-width:0}",
|
|
3497
|
+
scope + " [data-phase-line] [data-reset]{flex:none;white-space:nowrap}",
|
|
3498
|
+
scope + " [data-phase-sound]{display:grid;grid-template-columns:84px minmax(0,1fr);"
|
|
3499
|
+
+ "align-items:center;gap:6px;padding:3px 0;min-width:0}",
|
|
3500
|
+
scope + " [data-phase-sound-control]{display:flex;align-items:center;gap:7px;min-width:0}",
|
|
3501
|
+
scope + " [data-phase-sound] input{margin:0;flex:none}",
|
|
3502
|
+
scope + " [data-phase-sound] [data-note-inline]{min-width:0;margin-left:0;"
|
|
3503
|
+
+ "line-height:1.5;overflow-wrap:anywhere}",
|
|
3504
|
+
scope + " [data-phase-upload]{display:flex;align-items:center;flex-wrap:wrap;gap:6px;"
|
|
3505
|
+
+ "min-width:0;margin:6px 0 0 90px}",
|
|
3506
|
+
scope + " [data-upload-picker]," + scope + " [data-upload-reset],"
|
|
3507
|
+
+ scope + " [data-phase-preview]{position:relative;"
|
|
3508
|
+
+ "border:1px solid rgba(120,170,255,.5);border-radius:7px;"
|
|
3509
|
+
+ "background:rgba(120,170,255,.1);color:inherit;font-size:10px;"
|
|
3510
|
+
+ "padding:2px 8px;line-height:1.7;cursor:pointer}",
|
|
3511
|
+
// 没有可试听的来源时按钮就该点不动 —— 别让它装作可点,那只会变成"点了没反应"。
|
|
3512
|
+
scope + " [data-phase-preview]:disabled{opacity:.45;cursor:default}",
|
|
3513
|
+
scope + " [data-upload-picker]:focus-within{outline:2px solid rgba(120,170,255,.7);outline-offset:2px}",
|
|
3514
|
+
scope + " [data-upload-picker] input[type=file]{position:absolute;inset:0;opacity:0;"
|
|
3515
|
+
+ "width:100%;height:100%;cursor:pointer}",
|
|
3516
|
+
scope + " [data-upload-hint]{font-size:10px;opacity:.55}",
|
|
3517
|
+
scope + " [data-upload-error]{flex-basis:100%;font-size:10px;"
|
|
3518
|
+
+ "background:rgba(232,120,120,.16);border-radius:5px;padding:2px 6px}",
|
|
3519
|
+
scope + " [data-phase-motion-head]{display:flex;align-items:center;gap:8px;"
|
|
3520
|
+
+ "padding:7px 10px 5px;font-size:10px;font-weight:600;opacity:.8}",
|
|
3521
|
+
scope + " [data-phase-motion-hint]{font-weight:400;opacity:.65}",
|
|
3522
|
+
scope + " [data-phase-motion-head]+[data-pool-empty]{padding:4px 10px}",
|
|
3480
3523
|
scope + " [data-pool]{padding:7px 10px 8px 12px;"
|
|
3481
3524
|
+ "border-top:1px solid rgba(127,127,127,.13)}",
|
|
3482
|
-
|
|
3483
|
-
// 不可靠:空态那个 div 也是 div,会把它顶掉)。
|
|
3484
|
-
scope + " [data-phase-head]+[data-pool]{border-top:0}",
|
|
3525
|
+
scope + " [data-phase-motion-head]+[data-pool]{border-top:0}",
|
|
3485
3526
|
scope + " [data-pool-head]{display:flex;align-items:center;gap:8px;padding:0 0 3px}",
|
|
3486
3527
|
scope + " [data-pool-title]{font-size:11px;font-weight:600;opacity:.88}",
|
|
3487
3528
|
scope + " [data-pool-meta]{font-size:10px;opacity:.42;font-variant-numeric:tabular-nums}",
|
|
@@ -3492,8 +3533,24 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3492
3533
|
scope + " [data-note]{font-size:10.5px;opacity:.55;padding-top:4px}",
|
|
3493
3534
|
scope + " label{display:flex;align-items:center;gap:7px}",
|
|
3494
3535
|
]));
|
|
3495
|
-
//
|
|
3496
|
-
const
|
|
3536
|
+
// DSH 设置页用两列概览;右键窄面板仍沿用单列,以免池子表格被挤窄。
|
|
3537
|
+
const PAGE_PHASE_CSS = [
|
|
3538
|
+
SETTINGS_SEL + "{max-width:880px;container:pet-settings/inline-size}",
|
|
3539
|
+
SETTINGS_SEL + " [data-setting=phases]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}",
|
|
3540
|
+
SETTINGS_SEL + " [data-setting=phases] [data-phase]{margin:0;min-width:0}",
|
|
3541
|
+
SETTINGS_SEL + " [data-setting=phases] [data-phase][data-expanded]{grid-column:1/-1}",
|
|
3542
|
+
SETTINGS_SEL + " [data-phase-head]{padding:10px 12px 11px}",
|
|
3543
|
+
SETTINGS_SEL + " [data-phase-toggle]{font-size:13px}",
|
|
3544
|
+
SETTINGS_SEL + " [data-phase-meta]{font-size:11px;gap:5px;margin-top:7px}",
|
|
3545
|
+
SETTINGS_SEL + " [data-phase-chip]{padding:1px 7px}",
|
|
3546
|
+
SETTINGS_SEL + " [data-phase-details]{padding:11px 14px}",
|
|
3547
|
+
SETTINGS_SEL + " [data-phase-section-title]{font-size:11px;opacity:.8}",
|
|
3548
|
+
SETTINGS_SEL + " [data-phase][data-expanded] [data-phase-head]{background:rgba(120,170,255,.1)}",
|
|
3549
|
+
"@container pet-settings (max-width:620px){"
|
|
3550
|
+
+ SETTINGS_SEL + " [data-setting=phases]{grid-template-columns:minmax(0,1fr)}",
|
|
3551
|
+
"}",
|
|
3552
|
+
];
|
|
3553
|
+
const STYLE_TEXT = CSS + "\n" + SETTINGS_CSS.join("\n") + "\n" + PAGE_PHASE_CSS.join("\n");
|
|
3497
3554
|
|
|
3498
3555
|
function ensureStyle() {
|
|
3499
3556
|
if (document.getElementById(STYLE_ID) !== null) return;
|
|
@@ -3617,6 +3674,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3617
3674
|
/** 鼠标绕圈:在 spinWindowMs 内累计转过 spinTurns 圈就算转晕。 */
|
|
3618
3675
|
spinTurns: 2,
|
|
3619
3676
|
spinWindowMs: 1600,
|
|
3677
|
+
/** 会话提示音音量,0 为无声。 */
|
|
3678
|
+
soundVolume: 0.35,
|
|
3620
3679
|
};
|
|
3621
3680
|
|
|
3622
3681
|
/** 出厂值快照(「恢复默认」用)。 */
|
|
@@ -3648,6 +3707,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3648
3707
|
{ key: "bubbleHoldMs", label: "一句话停留 ms", min: 800, max: 15000, step: 200, group: "bubble" },
|
|
3649
3708
|
{ key: "spinTurns", label: "转几圈算晕", min: 1, max: 6, step: 0.5, group: "interact" },
|
|
3650
3709
|
{ key: "spinWindowMs", label: "要在多少 ms 内", min: 300, max: 6000, step: 100, group: "interact" },
|
|
3710
|
+
{ key: "soundVolume", label: "提示音音量", min: 0, max: 1, step: 0.05, group: "sound" },
|
|
3651
3711
|
];
|
|
3652
3712
|
/** 可调项的分组(没写 group 的都归「手感」)。hint 显示在卡片右上角。 */
|
|
3653
3713
|
const TUNING_GROUPS = [
|
|
@@ -3825,6 +3885,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3825
3885
|
* relations: { "<槽位>:<标签>": { pairs, requires } }
|
|
3826
3886
|
* interactions: { <patReactions|tailReactions|spinReactions>: [标签] }
|
|
3827
3887
|
* lines: { <键>: [台词] , phase: { <相位>: 台词 } }
|
|
3888
|
+
* sounds: { <相位>: [[频率 Hz, 起始秒数], ...] }(空数组 = 静音)
|
|
3828
3889
|
*/
|
|
3829
3890
|
/** 当前宠物的清单,给设置界面用(DSH 设置页拿不到组件里的 pet)。 */
|
|
3830
3891
|
const MANIFEST = { current: null };
|
|
@@ -3839,10 +3900,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3839
3900
|
* 姿势本身的性质 —— 在摸鱼表里改它,右键面板点同一个姿势、相位池里抽到它,
|
|
3840
3901
|
* 行为必须一致。所以它单独存一层,而不是挂在条目上。
|
|
3841
3902
|
*/
|
|
3842
|
-
//
|
|
3903
|
+
// 六个键都要在这里给出来(哪怕只是空壳):`applyOverride()` 是**原地往这个对象上写**的,
|
|
3843
3904
|
// 少一个键就等于那条路径第一次改设置时静默丢掉 —— 互动反应候选原来就是这么漏的
|
|
3844
3905
|
// (读的地方全写着 `?.`,所以"能读、写不进去",只有正面写它的界面会踩到)。
|
|
3845
|
-
const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {}, interactions: {}, lines: {} };
|
|
3906
|
+
const PHASE_OVERRIDES = { phases: {}, fidget: {}, relations: {}, interactions: {}, lines: {}, sounds: {} };
|
|
3846
3907
|
|
|
3847
3908
|
/** 槽位 id -> 中文标签(关系行显示「贴纸」而不是 `sticker`)。 */
|
|
3848
3909
|
const slotLabelOf = (slotId) =>
|
|
@@ -3968,10 +4029,264 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3968
4029
|
tailEnabled: true,
|
|
3969
4030
|
/** 鼠标绕圈转晕。 */
|
|
3970
4031
|
spinEnabled: true,
|
|
4032
|
+
/** 首次使用默认静音,避免装上插件后突然响铃。 */
|
|
4033
|
+
soundEnabled: false,
|
|
3971
4034
|
};
|
|
3972
4035
|
const FLAG_DEFAULTS = Object.freeze(Object.assign({}, FLAGS));
|
|
3973
4036
|
const OVERRIDE_KEY = "dsh-pet-live2d.settings.v2";
|
|
3974
4037
|
|
|
4038
|
+
// 浏览器只允许用户操作后播放声音;文件由宿主按宠物和相位保存在独立目录。
|
|
4039
|
+
const SOUND_URL = "/api/live2d-pet/sound";
|
|
4040
|
+
const UPLOADED_SOUNDS = { petId: null, sounds: {} };
|
|
4041
|
+
let playingUploaded = null;
|
|
4042
|
+
let playingUploadedPhase = null;
|
|
4043
|
+
const stopUploadedSound = () => {
|
|
4044
|
+
if (playingUploaded !== null) playingUploaded.pause();
|
|
4045
|
+
playingUploaded = null;
|
|
4046
|
+
playingUploadedPhase = null;
|
|
4047
|
+
};
|
|
4048
|
+
const uploadedSoundFor = (phase) => UPLOADED_SOUNDS.petId === MANIFEST.current?.id
|
|
4049
|
+
? UPLOADED_SOUNDS.sounds[phase] : undefined;
|
|
4050
|
+
const pullUploadedSounds = async () => {
|
|
4051
|
+
const id = MANIFEST.current?.id;
|
|
4052
|
+
if (typeof id !== "string") return false;
|
|
4053
|
+
try {
|
|
4054
|
+
const response = await fetch(SOUND_URL + "/" + encodeURIComponent(id), { cache: "no-store" });
|
|
4055
|
+
if (!response.ok) return false;
|
|
4056
|
+
const payload = await response.json();
|
|
4057
|
+
if (MANIFEST.current?.id !== id || payload?.ok !== true) return false;
|
|
4058
|
+
const sounds = {};
|
|
4059
|
+
for (const phase of PHASE_LINE_KEYS) {
|
|
4060
|
+
const entry = payload.sounds?.[phase];
|
|
4061
|
+
if (entry !== null && typeof entry === "object" && /^[a-f0-9]{64}$/.test(entry.token)
|
|
4062
|
+
&& ["audio/wav", "audio/mpeg", "audio/ogg"].includes(entry.mime)) sounds[phase] = entry;
|
|
4063
|
+
}
|
|
4064
|
+
if (UPLOADED_SOUNDS.petId !== id || JSON.stringify(UPLOADED_SOUNDS.sounds) !== JSON.stringify(sounds)) {
|
|
4065
|
+
UPLOADED_SOUNDS.petId = id;
|
|
4066
|
+
UPLOADED_SOUNDS.sounds = sounds;
|
|
4067
|
+
notifySettings();
|
|
4068
|
+
}
|
|
4069
|
+
return true;
|
|
4070
|
+
} catch { return false; }
|
|
4071
|
+
};
|
|
4072
|
+
const changeUploadedSound = async (phase, file) => {
|
|
4073
|
+
const id = MANIFEST.current?.id;
|
|
4074
|
+
if (typeof id !== "string" || !PHASE_LINE_KEYS.includes(phase)) throw new Error("宠物尚未加载");
|
|
4075
|
+
if (file && file.size > 1024 * 1024) throw new Error("音频文件不能超过 1 MB");
|
|
4076
|
+
if (file !== null) {
|
|
4077
|
+
const previewUrl = URL.createObjectURL(file);
|
|
4078
|
+
const preview = new window.Audio();
|
|
4079
|
+
let timer;
|
|
4080
|
+
try {
|
|
4081
|
+
await new Promise((resolve, reject) => {
|
|
4082
|
+
preview.preload = "metadata";
|
|
4083
|
+
preview.onloadedmetadata = () => resolve();
|
|
4084
|
+
preview.onerror = () => reject(new Error("音频文件损坏或格式不受支持"));
|
|
4085
|
+
timer = window.setTimeout(() => reject(new Error("读取音频超时,请换一个文件")), 5000);
|
|
4086
|
+
preview.src = previewUrl;
|
|
4087
|
+
preview.load();
|
|
4088
|
+
});
|
|
4089
|
+
} finally {
|
|
4090
|
+
window.clearTimeout(timer);
|
|
4091
|
+
preview.removeAttribute("src");
|
|
4092
|
+
preview.load();
|
|
4093
|
+
URL.revokeObjectURL(previewUrl);
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
const base64 = file === null ? null : await new Promise((resolve, reject) => {
|
|
4097
|
+
const reader = new FileReader();
|
|
4098
|
+
reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
|
|
4099
|
+
reader.onerror = () => reject(new Error("读取音频文件失败"));
|
|
4100
|
+
reader.readAsDataURL(file);
|
|
4101
|
+
});
|
|
4102
|
+
const response = await fetch(SOUND_URL + "/" + encodeURIComponent(id) + "/" + phase, {
|
|
4103
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
4104
|
+
body: JSON.stringify(file === null ? { action: "reset" } : { base64 }),
|
|
4105
|
+
});
|
|
4106
|
+
if (!response.ok) {
|
|
4107
|
+
const result = await response.json().catch(() => ({}));
|
|
4108
|
+
throw new Error(result.error === "invalid-audio" ? "仅支持 MP3、WAV、OGG 音频(最大 1 MB)"
|
|
4109
|
+
: result.error === "body-too-large" ? "音频文件不能超过 1 MB"
|
|
4110
|
+
: "保存音频失败(" + response.status + ")");
|
|
4111
|
+
}
|
|
4112
|
+
await pullUploadedSounds();
|
|
4113
|
+
};
|
|
4114
|
+
let soundContext = null;
|
|
4115
|
+
/**
|
|
4116
|
+
* 按需建/恢复 AudioContext。相位提示音与设置页的「试听」共用同一个实例。
|
|
4117
|
+
*
|
|
4118
|
+
* 返回 null 表示这台机器没有 Web Audio(或浏览器禁用了)——调用方各自决定怎么退。
|
|
4119
|
+
*/
|
|
4120
|
+
const ensureSoundContext = () => {
|
|
4121
|
+
if (soundContext === null) {
|
|
4122
|
+
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
4123
|
+
if (AudioContext === undefined) return null;
|
|
4124
|
+
soundContext = new AudioContext();
|
|
4125
|
+
}
|
|
4126
|
+
if (soundContext.state === "suspended") void soundContext.resume().catch(() => {});
|
|
4127
|
+
return soundContext;
|
|
4128
|
+
};
|
|
4129
|
+
const unlockSound = () => {
|
|
4130
|
+
if (!FLAGS.soundEnabled) return;
|
|
4131
|
+
try {
|
|
4132
|
+
ensureSoundContext();
|
|
4133
|
+
} catch {
|
|
4134
|
+
/* 无音频设备或浏览器禁用 Web Audio:只保留视觉状态 */
|
|
4135
|
+
}
|
|
4136
|
+
};
|
|
4137
|
+
// 未声明 sounds 的旧宠物沿用原来的四声;声明了 sounds 的宠物只响自己列出的相位。
|
|
4138
|
+
const BUILTIN_SOUNDS = {
|
|
4139
|
+
done: [[660, 0], [880, 0.13]],
|
|
4140
|
+
failed: [[370, 0], [277, 0.16]],
|
|
4141
|
+
waiting: [[520, 0], [660, 0.16]],
|
|
4142
|
+
asking: [[520, 0], [780, 0.16]],
|
|
4143
|
+
};
|
|
4144
|
+
const cleanSoundNotes = (raw) => Array.isArray(raw) && raw.length <= 8
|
|
4145
|
+
&& raw.every((note) => Array.isArray(note) && note.length === 2
|
|
4146
|
+
&& typeof note[0] === "number" && Number.isFinite(note[0]) && note[0] >= 80 && note[0] <= 4000
|
|
4147
|
+
&& typeof note[1] === "number" && Number.isFinite(note[1]) && note[1] >= 0 && note[1] <= 2)
|
|
4148
|
+
? raw.map((note) => note.slice()) : [];
|
|
4149
|
+
const petSoundNotesFor = (phase) => cleanSoundNotes(
|
|
4150
|
+
MANIFEST.current?.sounds === null || MANIFEST.current?.sounds === undefined
|
|
4151
|
+
? BUILTIN_SOUNDS[phase] : MANIFEST.current.sounds[phase]);
|
|
4152
|
+
const soundNotesFor = (phase) => Object.prototype.hasOwnProperty.call(PHASE_OVERRIDES.sounds, phase)
|
|
4153
|
+
? PHASE_OVERRIDES.sounds[phase] : petSoundNotesFor(phase);
|
|
4154
|
+
const setPhaseSoundMuted = (phase, muted) => {
|
|
4155
|
+
if (muted) {
|
|
4156
|
+
PHASE_OVERRIDES.sounds[phase] = [];
|
|
4157
|
+
if (playingUploadedPhase === phase) stopUploadedSound();
|
|
4158
|
+
}
|
|
4159
|
+
else delete PHASE_OVERRIDES.sounds[phase];
|
|
4160
|
+
saveOverrides();
|
|
4161
|
+
notifySettings();
|
|
4162
|
+
};
|
|
4163
|
+
/**
|
|
4164
|
+
* 播一段上传音频。真实相位与设置页的「试听」共用它 —— 两条路必须发出同一种声音,
|
|
4165
|
+
* 分开写迟早会漂移(试听听到的和真响的不一样,比没有试听更糟)。
|
|
4166
|
+
*/
|
|
4167
|
+
const playUploadedSound = (phase, entry, volume) => {
|
|
4168
|
+
try {
|
|
4169
|
+
const audio = new window.Audio(SOUND_URL + "/" + encodeURIComponent(MANIFEST.current.id)
|
|
4170
|
+
+ "/" + phase + "?token=" + entry.token);
|
|
4171
|
+
audio.volume = volume;
|
|
4172
|
+
playingUploaded = audio;
|
|
4173
|
+
playingUploadedPhase = phase;
|
|
4174
|
+
void audio.play().catch(() => { /* 浏览器阻止自动播放时不影响相位动画 */ });
|
|
4175
|
+
return true;
|
|
4176
|
+
} catch { return false; /* 音频设备不可用时不影响相位动画 */ }
|
|
4177
|
+
};
|
|
4178
|
+
/** 把一个相位的音符排进 AudioContext。同样是与「试听」共用的那一份。 */
|
|
4179
|
+
const scheduleSoundNotes = (context, notes, volume) => {
|
|
4180
|
+
try {
|
|
4181
|
+
const now = context.currentTime;
|
|
4182
|
+
for (const [frequency, offset] of notes) {
|
|
4183
|
+
const oscillator = context.createOscillator();
|
|
4184
|
+
const gain = context.createGain();
|
|
4185
|
+
oscillator.type = "sine";
|
|
4186
|
+
oscillator.frequency.value = frequency;
|
|
4187
|
+
gain.gain.setValueAtTime(0, now + offset);
|
|
4188
|
+
gain.gain.linearRampToValueAtTime(volume * 0.12, now + offset + 0.015);
|
|
4189
|
+
gain.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.15);
|
|
4190
|
+
oscillator.connect(gain);
|
|
4191
|
+
gain.connect(context.destination);
|
|
4192
|
+
oscillator.start(now + offset);
|
|
4193
|
+
oscillator.stop(now + offset + 0.16);
|
|
4194
|
+
}
|
|
4195
|
+
return true;
|
|
4196
|
+
} catch { return false; /* 音频异常不能中断相位动画 */ }
|
|
4197
|
+
};
|
|
4198
|
+
const playPhaseSound = (phase) => {
|
|
4199
|
+
stopUploadedSound();
|
|
4200
|
+
if (!FLAGS.soundEnabled || !(TUNING.soundVolume > 0)) return;
|
|
4201
|
+
if (Object.prototype.hasOwnProperty.call(PHASE_OVERRIDES.sounds, phase)
|
|
4202
|
+
&& PHASE_OVERRIDES.sounds[phase].length === 0) return;
|
|
4203
|
+
const uploaded = uploadedSoundFor(phase);
|
|
4204
|
+
if (uploaded !== undefined) { playUploadedSound(phase, uploaded, TUNING.soundVolume); return; }
|
|
4205
|
+
const notes = soundNotesFor(phase);
|
|
4206
|
+
if (notes.length === 0 || soundContext?.state !== "running") return;
|
|
4207
|
+
scheduleSoundNotes(soundContext, notes, TUNING.soundVolume);
|
|
4208
|
+
};
|
|
4209
|
+
|
|
4210
|
+
/**
|
|
4211
|
+
* 试听:设置页里"我现在就要听一下这个相位"。(`PREVIEW_VOLUME` 是音量被拉到 0 时的兜底。)
|
|
4212
|
+
*
|
|
4213
|
+
* 与真实播放**只差两点,且都是故意的**:
|
|
4214
|
+
* 1. 不看总开关、不看单相位静音 —— 默认总开关就是关的(`soundEnabled: false`),
|
|
4215
|
+
* 跟着它走的话点试听必然没反应,等于没做;
|
|
4216
|
+
* 2. 取音源时跳过"静音覆盖"(`sounds[phase] = []`),仍按"上传音频 > 宠物音符"拿
|
|
4217
|
+
* **有效来源**,这样静音着的相位也能先听再决定要不要开。
|
|
4218
|
+
* 返回是否真的发出了声音(设置页与驱动都靠它判断,不靠听)。
|
|
4219
|
+
*/
|
|
4220
|
+
const PREVIEW_VOLUME = 0.35;
|
|
4221
|
+
const previewNotesFor = (phase) => {
|
|
4222
|
+
const override = PHASE_OVERRIDES.sounds[phase];
|
|
4223
|
+
return Array.isArray(override) && override.length > 0 ? override : petSoundNotesFor(phase);
|
|
4224
|
+
};
|
|
4225
|
+
const canPreviewPhaseSound = (phase) => uploadedSoundFor(phase) !== undefined
|
|
4226
|
+
|| previewNotesFor(phase).length > 0;
|
|
4227
|
+
const previewPhaseSound = (phase) => {
|
|
4228
|
+
try {
|
|
4229
|
+
if (typeof MANIFEST.current?.id !== "string" || !PHASE_LINE_KEYS.includes(phase)) return false;
|
|
4230
|
+
stopUploadedSound();
|
|
4231
|
+
const volume = TUNING.soundVolume > 0 ? TUNING.soundVolume : PREVIEW_VOLUME;
|
|
4232
|
+
const uploaded = uploadedSoundFor(phase);
|
|
4233
|
+
if (uploaded !== undefined) return playUploadedSound(phase, uploaded, volume);
|
|
4234
|
+
const notes = previewNotesFor(phase);
|
|
4235
|
+
if (notes.length === 0) return false;
|
|
4236
|
+
const context = ensureSoundContext();
|
|
4237
|
+
if (context === null) return false;
|
|
4238
|
+
// 首次点击时 AudioContext 常常还是 suspended,而 resume() 是异步的:
|
|
4239
|
+
// 等它落地再排音符,否则会像真实相位那样被 state 检查挡掉、又变成"点了没反应"。
|
|
4240
|
+
if (context.state === "suspended") {
|
|
4241
|
+
void context.resume().then(() => { scheduleSoundNotes(context, notes, volume); }).catch(() => {});
|
|
4242
|
+
return true;
|
|
4243
|
+
}
|
|
4244
|
+
return scheduleSoundNotes(context, notes, volume);
|
|
4245
|
+
} catch { return false; }
|
|
4246
|
+
};
|
|
4247
|
+
|
|
4248
|
+
/**
|
|
4249
|
+
* 拖音量滑杆时的即时试听:音量只有数字时,用户只能靠"下一次相位切换"猜它有多大。
|
|
4250
|
+
*
|
|
4251
|
+
* 与上面那个「试听」按钮**只差音量处理**:这里按**真实音量**播(音量 0 就是静音),
|
|
4252
|
+
* 因为"0 听起来是什么样"的答案本来就是"没声";按钮那边用兜底音量,避免点了没反应。
|
|
4253
|
+
* 音源取这只宠物"最像提示音"的那段(优先 `done`,否则第一个有音的相位)。
|
|
4254
|
+
*/
|
|
4255
|
+
const volumePreviewNotes = () => {
|
|
4256
|
+
const done = previewNotesFor("done");
|
|
4257
|
+
if (done.length > 0) return done;
|
|
4258
|
+
for (const phase of PHASE_LINE_KEYS) {
|
|
4259
|
+
const notes = previewNotesFor(phase);
|
|
4260
|
+
if (notes.length > 0) return notes;
|
|
4261
|
+
}
|
|
4262
|
+
return [];
|
|
4263
|
+
};
|
|
4264
|
+
const previewVolumeSound = () => {
|
|
4265
|
+
try {
|
|
4266
|
+
const volume = TUNING.soundVolume;
|
|
4267
|
+
if (!(volume > 0)) return false; // 0 = 静音,这里就该听不到,不许兜底
|
|
4268
|
+
const notes = volumePreviewNotes();
|
|
4269
|
+
if (notes.length === 0) return false; // 这只宠物没有任何音源:滑杆只给数字反馈
|
|
4270
|
+
const context = ensureSoundContext();
|
|
4271
|
+
if (context === null) return false;
|
|
4272
|
+
if (context.state === "suspended") {
|
|
4273
|
+
void context.resume().then(() => { scheduleSoundNotes(context, notes, volume); }).catch(() => {});
|
|
4274
|
+
return true;
|
|
4275
|
+
}
|
|
4276
|
+
return scheduleSoundNotes(context, notes, volume);
|
|
4277
|
+
} catch { return false; }
|
|
4278
|
+
};
|
|
4279
|
+
/** 拖滑杆会连着来几十次 onChange:防抖到停手再响,否则是电音。 */
|
|
4280
|
+
const VOLUME_PREVIEW_DEBOUNCE_MS = 160;
|
|
4281
|
+
let volumePreviewTimer = 0;
|
|
4282
|
+
const scheduleVolumePreview = () => {
|
|
4283
|
+
if (volumePreviewTimer !== 0) window.clearTimeout(volumePreviewTimer);
|
|
4284
|
+
volumePreviewTimer = window.setTimeout(() => {
|
|
4285
|
+
volumePreviewTimer = 0;
|
|
4286
|
+
previewVolumeSound();
|
|
4287
|
+
}, VOLUME_PREVIEW_DEBOUNCE_MS);
|
|
4288
|
+
};
|
|
4289
|
+
|
|
3975
4290
|
const saveOverrides = () => {
|
|
3976
4291
|
// 走共享落点(宿主优先):相位池子覆盖与开关两个窗口共用同一份。
|
|
3977
4292
|
persistShared({ overrides: Object.assign({}, PHASE_OVERRIDES, { flags: FLAGS }) });
|
|
@@ -3981,6 +4296,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
3981
4296
|
const applyFlag = (key, value) => {
|
|
3982
4297
|
if (!Object.prototype.hasOwnProperty.call(FLAGS, key)) return;
|
|
3983
4298
|
FLAGS[key] = value === true;
|
|
4299
|
+
if (key === "soundEnabled") {
|
|
4300
|
+
if (FLAGS[key]) unlockSound();
|
|
4301
|
+
else {
|
|
4302
|
+
if (soundContext !== null) void soundContext.suspend().catch(() => {});
|
|
4303
|
+
stopUploadedSound();
|
|
4304
|
+
}
|
|
4305
|
+
}
|
|
3984
4306
|
if (key === "outfitArchive" && FLAGS[key] === false) {
|
|
3985
4307
|
try {
|
|
3986
4308
|
storage.removeItem(OUTFIT_KEY);
|
|
@@ -4095,11 +4417,21 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4095
4417
|
if (Object.keys(next).length > 0) PHASE_OVERRIDES.relations[key] = next;
|
|
4096
4418
|
}
|
|
4097
4419
|
}
|
|
4420
|
+
// 每次重建这一层:另一个窗口恢复默认后,不能把本窗口上次的静音覆盖留下。
|
|
4421
|
+
PHASE_OVERRIDES.sounds = {};
|
|
4422
|
+
if (saved.sounds !== null && typeof saved.sounds === "object" && !Array.isArray(saved.sounds)) {
|
|
4423
|
+
for (const phase of PHASE_LINE_KEYS) {
|
|
4424
|
+
if (Object.prototype.hasOwnProperty.call(saved.sounds, phase) && Array.isArray(saved.sounds[phase])) {
|
|
4425
|
+
PHASE_OVERRIDES.sounds[phase] = cleanSoundNotes(saved.sounds[phase]);
|
|
4426
|
+
}
|
|
4427
|
+
}
|
|
4428
|
+
}
|
|
4098
4429
|
const flags = saved.flags;
|
|
4099
4430
|
if (flags !== null && typeof flags === "object") {
|
|
4100
4431
|
for (const key of Object.keys(FLAGS)) {
|
|
4101
4432
|
if (typeof flags[key] === "boolean") FLAGS[key] = flags[key];
|
|
4102
4433
|
}
|
|
4434
|
+
if (!FLAGS.soundEnabled) stopUploadedSound();
|
|
4103
4435
|
}
|
|
4104
4436
|
// 互动反应候选:三组标签数组。
|
|
4105
4437
|
const interactions = saved.interactions;
|
|
@@ -4114,6 +4446,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4114
4446
|
}
|
|
4115
4447
|
// 台词覆盖:两层(每条一组 + 相位的单句)。数组要清掉空串,否则 `[""]` 会被
|
|
4116
4448
|
// 当成"用户改过",`linesNow()` 就永远拿不到宠物默认了。
|
|
4449
|
+
PHASE_OVERRIDES.lines = {};
|
|
4117
4450
|
const lines = saved.lines;
|
|
4118
4451
|
if (lines !== null && typeof lines === "object") {
|
|
4119
4452
|
const next = {};
|
|
@@ -4361,6 +4694,12 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4361
4694
|
const field = TUNING_FIELDS.find((entry) => entry.key === key);
|
|
4362
4695
|
TUNING[key] = field === undefined ? value : clampSetting(field, value);
|
|
4363
4696
|
}
|
|
4697
|
+
if (playingUploaded !== null) {
|
|
4698
|
+
if (!(TUNING.soundVolume > 0)) stopUploadedSound();
|
|
4699
|
+
else playingUploaded.volume = TUNING.soundVolume;
|
|
4700
|
+
}
|
|
4701
|
+
// 动的是音量就顺手试听一小段 —— 否则"这个音量到底多大声"只能等下一次相位切换才知道。
|
|
4702
|
+
if (Object.prototype.hasOwnProperty.call(patch, "soundVolume")) scheduleVolumePreview();
|
|
4364
4703
|
// 走共享落点:有宿主就写宿主(DSH 与桌面端两个窗口都能看见),本地留一份兜底。
|
|
4365
4704
|
persistShared({ tuning: Object.assign({}, TUNING) });
|
|
4366
4705
|
notifySettings();
|
|
@@ -4388,6 +4727,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
4388
4727
|
TUNING[field.key] = clampSetting(field, value);
|
|
4389
4728
|
restored = true;
|
|
4390
4729
|
}
|
|
4730
|
+
if (playingUploaded !== null) {
|
|
4731
|
+
if (!(TUNING.soundVolume > 0)) stopUploadedSound();
|
|
4732
|
+
else playingUploaded.volume = TUNING.soundVolume;
|
|
4733
|
+
}
|
|
4391
4734
|
if (restored) notifySettings();
|
|
4392
4735
|
};
|
|
4393
4736
|
|
|
@@ -5366,6 +5709,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
5366
5709
|
phaseExpressionRef.current = Object.assign({}, phaseBaseRef.current.expressions);
|
|
5367
5710
|
// 设置界面(含 DSH 设置页那个独立组件)需要清单里有哪些动作/表情/槽位。
|
|
5368
5711
|
MANIFEST.current = pet;
|
|
5712
|
+
UPLOADED_SOUNDS.petId = null;
|
|
5713
|
+
UPLOADED_SOUNDS.sounds = {};
|
|
5714
|
+
stopUploadedSound();
|
|
5715
|
+
void pullUploadedSounds();
|
|
5369
5716
|
// 头部部件(宿主从 cdi3 的作者命名里挑的,21 个)交给控制器:摸头判定按这些
|
|
5370
5717
|
// 部件的**真实三角面**测,而不是一个手调内边距的方框。
|
|
5371
5718
|
motion.current.setHeadParts(pet.headParts ?? []);
|
|
@@ -6720,6 +7067,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
6720
7067
|
});
|
|
6721
7068
|
// The motion subscription (declared above) flushes a deferred phase here.
|
|
6722
7069
|
flushPhaseRef.current = applyPhase;
|
|
7070
|
+
let receivedInitialPhase = false;
|
|
6723
7071
|
const onMessage = (event) => {
|
|
6724
7072
|
let payload;
|
|
6725
7073
|
try {
|
|
@@ -6729,9 +7077,13 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
6729
7077
|
}
|
|
6730
7078
|
const phase = payload?.phase;
|
|
6731
7079
|
if (typeof phase !== "string") return;
|
|
7080
|
+
const initial = !receivedInitialPhase;
|
|
7081
|
+
receivedInitialPhase = true;
|
|
6732
7082
|
setPhaseState(phase);
|
|
6733
7083
|
if (phase === phaseRef.current) return;
|
|
6734
7084
|
phaseRef.current = phase;
|
|
7085
|
+
// 只提示真正的新相位;首次 SSE 快照和相同相位重发都不响。
|
|
7086
|
+
if (!initial) playPhaseSound(phase);
|
|
6735
7087
|
// A phase animation may replace another phase animation, but must never
|
|
6736
7088
|
// cut off something the user just triggered (tap / fidget / panel).
|
|
6737
7089
|
const owner = motion.current.kind();
|
|
@@ -6748,6 +7100,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
6748
7100
|
source.addEventListener("message", onMessage);
|
|
6749
7101
|
return () => {
|
|
6750
7102
|
source.close();
|
|
7103
|
+
stopUploadedSound();
|
|
6751
7104
|
phaseRef.current = "idle";
|
|
6752
7105
|
phasePinsRef.current = {};
|
|
6753
7106
|
phaseSlotsRef.current = [];
|
|
@@ -7391,7 +7744,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
7391
7744
|
// 桌面端专属:设置正文(和 DSH 设置页那一节是同一个组件、同一份 store)。
|
|
7392
7745
|
// 外面这层 `data-settings` 把作用域带进来 —— 面板只有 270→340px 宽,
|
|
7393
7746
|
// 样式表里已经为窄容器收过一档列宽。
|
|
7394
|
-
? h("div", { "data-settings": "", "data-panel-settings": "" }, h(PetSettingsBody,
|
|
7747
|
+
? h("div", { "data-settings": "", "data-panel-settings": "" }, h(PetSettingsBody, { compact: true }))
|
|
7395
7748
|
: null,
|
|
7396
7749
|
),
|
|
7397
7750
|
h("div", { "data-hintrow": "" }, "在宠物身上点右键打开这里 · Esc 或点空白处关闭"),
|
|
@@ -7528,19 +7881,36 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
7528
7881
|
* 列表里只出现**被定制过**的相位(一行 = 一条定制);新增一个相位会先按 pet.json
|
|
7529
7882
|
* 的 `looksByPhase` 把默认池子显示出来,改哪张表就物化哪张表。
|
|
7530
7883
|
*/
|
|
7531
|
-
function PhaseControls() {
|
|
7884
|
+
function PhaseControls({ compact = false } = {}) {
|
|
7532
7885
|
useSettings();
|
|
7533
|
-
//
|
|
7534
|
-
// 折叠是给"配好之后收起来"用的,不是默认藏起来。
|
|
7886
|
+
// 设置页先概览全部相位,一次展开一个;窄面板保持原有逐行折叠行为。
|
|
7535
7887
|
const [folded, setFolded] = useState({});
|
|
7888
|
+
const [selected, setSelected] = useState(null);
|
|
7889
|
+
const [uploading, setUploading] = useState({});
|
|
7890
|
+
const [uploadErrors, setUploadErrors] = useState({});
|
|
7891
|
+
const updateUploaded = async (phase, file) => {
|
|
7892
|
+
setUploading((previous) => Object.assign({}, previous, { [phase]: true }));
|
|
7893
|
+
setUploadErrors((previous) => Object.assign({}, previous, { [phase]: "" }));
|
|
7894
|
+
try {
|
|
7895
|
+
await changeUploadedSound(phase, file);
|
|
7896
|
+
if (file !== null && PHASE_OVERRIDES.sounds[phase]?.length === 0) setPhaseSoundMuted(phase, false);
|
|
7897
|
+
} catch (error) {
|
|
7898
|
+
setUploadErrors((previous) => Object.assign({}, previous, {
|
|
7899
|
+
[phase]: error instanceof Error ? error.message : "保存音频失败",
|
|
7900
|
+
}));
|
|
7901
|
+
} finally {
|
|
7902
|
+
setUploading((previous) => Object.assign({}, previous, { [phase]: false }));
|
|
7903
|
+
}
|
|
7904
|
+
};
|
|
7536
7905
|
const pet = MANIFEST.current;
|
|
7537
7906
|
if (pet === null) return h("div", { "data-empty": "phases" }, "宠物还没加载好");
|
|
7538
7907
|
const slots = pet.expressionSlots ?? [];
|
|
7539
7908
|
const known = Array.from(new Set([
|
|
7909
|
+
...PHASE_LINE_KEYS,
|
|
7540
7910
|
...Object.keys(PHASE_MOTION),
|
|
7541
7911
|
...Object.keys(pet.looksByPhase ?? {}),
|
|
7542
7912
|
...Object.keys(PHASE_OVERRIDES.phases),
|
|
7543
|
-
]))
|
|
7913
|
+
]));
|
|
7544
7914
|
// **全部相位都列出来**,不再是"只列定制过的"。
|
|
7545
7915
|
//
|
|
7546
7916
|
// 用户问过:"会话相位里是空的,但点了添加有默认值,这正常吗?应该初始就带上默认值吧。"
|
|
@@ -7549,7 +7919,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
7549
7919
|
// **看不出宠物默认会演什么**,而且和「摸鱼」那张卡不一致(摸鱼永远列着它的池子)。
|
|
7550
7920
|
//
|
|
7551
7921
|
// 现在每行显示的是**有效池子**(有覆盖用覆盖,没有就用 pet.json 的默认),行头标出
|
|
7552
|
-
// 「默认 /
|
|
7922
|
+
// 「默认 / 已改过」,只有改过的那行才显示「重置动作」。覆盖依然**只在真的编辑时**
|
|
7553
7923
|
// 才落盘(`setPhasePool` 会把有效池子整份物化),所以"看一眼"不留痕迹、
|
|
7554
7924
|
// 以后默认值改进了也照样能吃到。
|
|
7555
7925
|
return h("div", { "data-settings": "", "data-setting": "phases" },
|
|
@@ -7561,32 +7931,127 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
7561
7931
|
const pools = phasePoolsFor(phase);
|
|
7562
7932
|
const used = Object.keys(pools);
|
|
7563
7933
|
const free = slots.filter((slot) => used.indexOf(slot.id) === -1);
|
|
7564
|
-
const open = folded[phase] !== true;
|
|
7934
|
+
const open = compact ? folded[phase] !== true : selected === phase;
|
|
7565
7935
|
const candidates = used.reduce((sum, slotId) => sum + (pools[slotId] ?? []).length, 0);
|
|
7566
|
-
|
|
7936
|
+
const soundFile = uploadedSoundFor(phase);
|
|
7937
|
+
return h("div", { key: phase, "data-phase": phase,
|
|
7938
|
+
...(open ? { "data-expanded": "" } : {}),
|
|
7939
|
+
...(custom ? { "data-phase-custom": "" } : {}) },
|
|
7567
7940
|
h("div", { "data-phase-head": "", ...(open ? {} : { "data-collapsed": "" }) },
|
|
7568
|
-
h("
|
|
7569
|
-
|
|
7570
|
-
|
|
7571
|
-
|
|
7572
|
-
|
|
7573
|
-
|
|
7574
|
-
|
|
7575
|
-
|
|
7576
|
-
|
|
7577
|
-
|
|
7941
|
+
h("div", { "data-phase-head-main": "" },
|
|
7942
|
+
h("button", {
|
|
7943
|
+
type: "button",
|
|
7944
|
+
"data-phase-toggle": phase,
|
|
7945
|
+
"aria-expanded": open,
|
|
7946
|
+
title: open ? "收起" : "展开",
|
|
7947
|
+
onClick: () => compact
|
|
7948
|
+
? setFolded((prev) => Object.assign({}, prev, { [phase]: !(prev[phase] === true) }))
|
|
7949
|
+
: setSelected((prev) => prev === phase ? null : phase),
|
|
7950
|
+
},
|
|
7951
|
+
h("span", { "data-caret": "" }, open ? "▾" : "▸"),
|
|
7952
|
+
h("span", { "data-phase-name": "" }, PHASE_LINE_FIELDS.find((field) => field.key === phase)?.label ?? phase),
|
|
7953
|
+
h("code", null, phase),
|
|
7954
|
+
),
|
|
7955
|
+
custom ? h("button", {
|
|
7956
|
+
type: "button",
|
|
7957
|
+
"data-phase-remove": phase,
|
|
7958
|
+
title: "恢复此相位的动作槽位默认值",
|
|
7959
|
+
"aria-label": "恢复" + phase + "的动作槽位默认值",
|
|
7960
|
+
onClick: () => removePhaseRow(phase),
|
|
7961
|
+
}, "重置动作") : null,
|
|
7962
|
+
),
|
|
7963
|
+
h("div", { "data-phase-meta": "" },
|
|
7964
|
+
h("span", { "data-phase-chip": "motion", ...(custom ? { "data-custom": "" } : {}) },
|
|
7965
|
+
"动作 " + used.length + " 槽 · " + candidates + " 候选" + (custom ? " · 已改" : "")),
|
|
7966
|
+
h("span", { "data-phase-chip": "line",
|
|
7967
|
+
...(PHASE_OVERRIDES.lines.phase?.[phase] !== undefined ? { "data-custom": "" } : {}) },
|
|
7968
|
+
"台词 " + (PHASE_OVERRIDES.lines.phase?.[phase] !== undefined ? "已改" : "默认")),
|
|
7969
|
+
h("span", { "data-phase-chip": "sound",
|
|
7970
|
+
...(PHASE_OVERRIDES.sounds[phase] !== undefined || soundFile !== undefined ? { "data-custom": "" } : {}) },
|
|
7971
|
+
"音频 " + (PHASE_OVERRIDES.sounds[phase] !== undefined ? "已静音"
|
|
7972
|
+
: soundFile !== undefined ? "已上传" : petSoundNotesFor(phase).length === 0 ? "未配置" : "宠物默认")),
|
|
7578
7973
|
),
|
|
7579
|
-
// × 只在**改过**的行上出现:它就是"恢复默认"(删掉整个覆盖)。
|
|
7580
|
-
custom ? h("button", {
|
|
7581
|
-
type: "button",
|
|
7582
|
-
"data-phase-remove": phase,
|
|
7583
|
-
title: "恢复默认(删掉这个相位的全部改动)",
|
|
7584
|
-
onClick: () => removePhaseRow(phase),
|
|
7585
|
-
}, "×") : null,
|
|
7586
7974
|
),
|
|
7587
7975
|
open ? [
|
|
7976
|
+
h("div", { key: "details", "data-phase-details": "" },
|
|
7977
|
+
h("div", { "data-phase-section-title": "" }, "气泡与提示音"),
|
|
7978
|
+
h("div", { "data-phase-line": phase },
|
|
7979
|
+
h("label", { "data-line-row": "phase:" + phase },
|
|
7980
|
+
h("span", { "data-row-label": "" }, "气泡台词"),
|
|
7981
|
+
h("input", {
|
|
7982
|
+
type: "text",
|
|
7983
|
+
"data-line-input": "phase:" + phase,
|
|
7984
|
+
value: linesNow().phase?.[phase] ?? "",
|
|
7985
|
+
placeholder: pet.lines?.phase?.[phase] ?? "",
|
|
7986
|
+
onChange: (event) => applyOverride({ lines: { phase: { [phase]: event.target.value } } }),
|
|
7987
|
+
})),
|
|
7988
|
+
Object.prototype.hasOwnProperty.call(PHASE_OVERRIDES.lines.phase ?? {}, phase)
|
|
7989
|
+
? h("button", {
|
|
7990
|
+
type: "button",
|
|
7991
|
+
"data-reset": "",
|
|
7992
|
+
"data-phase-line-reset": phase,
|
|
7993
|
+
onClick: () => {
|
|
7994
|
+
delete PHASE_OVERRIDES.lines.phase[phase];
|
|
7995
|
+
saveOverrides();
|
|
7996
|
+
notifySettings();
|
|
7997
|
+
},
|
|
7998
|
+
}, "恢复默认") : null,
|
|
7999
|
+
),
|
|
8000
|
+
h("label", { "data-phase-sound": phase },
|
|
8001
|
+
h("span", { "data-row-label": "" }, "提示音"),
|
|
8002
|
+
petSoundNotesFor(phase).length === 0 && soundFile === undefined
|
|
8003
|
+
? h("span", { "data-note-inline": "" }, "未配置;可上传音频")
|
|
8004
|
+
: h("span", { "data-phase-sound-control": "" },
|
|
8005
|
+
h("input", {
|
|
8006
|
+
type: "checkbox",
|
|
8007
|
+
"aria-label": (PHASE_LINE_FIELDS.find((field) => field.key === phase)?.label ?? phase) + "提示音",
|
|
8008
|
+
checked: PHASE_OVERRIDES.sounds[phase] === undefined
|
|
8009
|
+
? soundFile !== undefined || soundNotesFor(phase).length > 0
|
|
8010
|
+
: soundNotesFor(phase).length > 0,
|
|
8011
|
+
onChange: (event) => setPhaseSoundMuted(phase, !event.target.checked),
|
|
8012
|
+
}),
|
|
8013
|
+
h("span", { "data-note-inline": "" },
|
|
8014
|
+
soundFile === undefined
|
|
8015
|
+
? petSoundNotesFor(phase).map((note) => note[0] + "Hz").join(" → ")
|
|
8016
|
+
+ (PHASE_OVERRIDES.sounds[phase] === undefined ? " · 宠物默认" : " · 已静音")
|
|
8017
|
+
: "已上传 " + (soundFile.mime === "audio/mpeg" ? "MP3"
|
|
8018
|
+
: soundFile.mime.replace("audio/", "").toUpperCase())
|
|
8019
|
+
+ " · " + (soundFile.bytes < 1024 ? "<1" : Math.round(soundFile.bytes / 1024)) + " KB"
|
|
8020
|
+
+ (PHASE_OVERRIDES.sounds[phase] === undefined ? "" : " · 已静音")))),
|
|
8021
|
+
h("div", { "data-phase-upload": phase },
|
|
8022
|
+
// 「试听」放在上传这一行:它既不为某个来源独占,也不该塞进上面那个
|
|
8023
|
+
// `<label>`(label 里放 button,点试听会连带把静音勾选框点掉)。
|
|
8024
|
+
h("button", {
|
|
8025
|
+
type: "button",
|
|
8026
|
+
"data-phase-preview": phase,
|
|
8027
|
+
disabled: !canPreviewPhaseSound(phase),
|
|
8028
|
+
title: "试听这个相位的有效来源(不受总开关与单相位静音影响,音量用当前提示音音量)",
|
|
8029
|
+
onClick: () => { previewPhaseSound(phase); },
|
|
8030
|
+
}, "试听"),
|
|
8031
|
+
h("label", { "data-upload-picker": phase, "data-busy": uploading[phase] === true ? "" : undefined },
|
|
8032
|
+
h("input", {
|
|
8033
|
+
type: "file", accept: ".mp3,.wav,.ogg,audio/mpeg,audio/wav,audio/ogg",
|
|
8034
|
+
"data-upload-input": phase, disabled: uploading[phase] === true,
|
|
8035
|
+
onChange: (event) => {
|
|
8036
|
+
const file = event.target.files?.[0];
|
|
8037
|
+
event.target.value = "";
|
|
8038
|
+
if (file) void updateUploaded(phase, file);
|
|
8039
|
+
},
|
|
8040
|
+
}),
|
|
8041
|
+
uploading[phase] ? "上传中…" : soundFile === undefined ? "上传音频" : "替换音频"),
|
|
8042
|
+
h("span", { "data-upload-hint": "" }, "MP3 / WAV / OGG,最多 1 MB"),
|
|
8043
|
+
soundFile === undefined ? null : h("button", {
|
|
8044
|
+
type: "button", "data-upload-reset": phase, disabled: uploading[phase] === true,
|
|
8045
|
+
onClick: () => { void updateUploaded(phase, null); },
|
|
8046
|
+
}, "移除上传"),
|
|
8047
|
+
uploadErrors[phase] ? h("span", { role: "alert", "data-upload-error": phase }, uploadErrors[phase]) : null,
|
|
8048
|
+
),
|
|
8049
|
+
),
|
|
8050
|
+
h("div", { key: "motion-head", "data-phase-motion-head": "" },
|
|
8051
|
+
h("span", null, "动作槽位"),
|
|
8052
|
+
h("span", { "data-phase-motion-hint": "" }, "按候选权重抽取")),
|
|
7588
8053
|
used.length === 0
|
|
7589
|
-
? h("div", { "data-pool-empty": "", key: "empty" }, "
|
|
8054
|
+
? h("div", { "data-pool-empty": "", key: "empty" }, "没有动作槽位,可在下方添加")
|
|
7590
8055
|
: null,
|
|
7591
8056
|
...used.map((slotId) => {
|
|
7592
8057
|
// 槽位 id 来自池子的键:清单里没有它(换了宠物)就退化成一个空槽位,
|
|
@@ -8014,7 +8479,7 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8014
8479
|
}
|
|
8015
8480
|
|
|
8016
8481
|
/**
|
|
8017
|
-
*
|
|
8482
|
+
* 台词:非会话相位的气泡文本;相位台词在对应相位行。
|
|
8018
8483
|
*
|
|
8019
8484
|
* 一组 = 一行输入,**用 `|` 分隔多个变体**(随机挑一句);相位台词是单句。
|
|
8020
8485
|
* 留空就退回宠物默认(`pet.json` 的 `live2d.lines`),所以"看一眼"不会把默认改掉。
|
|
@@ -8042,14 +8507,6 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8042
8507
|
(text) => setLines({ [field.key]: splitLineInput(text) }),
|
|
8043
8508
|
joinLineInput(MANIFEST.current?.lines?.[field.key]),
|
|
8044
8509
|
)),
|
|
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
8510
|
);
|
|
8054
8511
|
}
|
|
8055
8512
|
|
|
@@ -8164,7 +8621,8 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8164
8621
|
);
|
|
8165
8622
|
}
|
|
8166
8623
|
|
|
8167
|
-
function PetSettingsBody() {
|
|
8624
|
+
function PetSettingsBody({ compact = false } = {}) {
|
|
8625
|
+
useSettings();
|
|
8168
8626
|
const card = (key, title, hint, body) => h("div", { key, "data-card": key },
|
|
8169
8627
|
h("div", { "data-card-head": "" },
|
|
8170
8628
|
h("span", { "data-card-title": "" }, title),
|
|
@@ -8177,7 +8635,20 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8177
8635
|
"tune-" + group.id, group.label, group.hint, h(TuningControls, { group: group.id }))),
|
|
8178
8636
|
// 显示层放最上面:它是"这只宠物在哪"的问题,比手感/池子更先要回答。
|
|
8179
8637
|
card("layer", "显示位置", "页面内 / 桌面上", h(LayerControls, null)),
|
|
8180
|
-
card("
|
|
8638
|
+
card("sound", "会话提示音", "总开关 · 音量(拖滑杆会试听)", [
|
|
8639
|
+
h("label", { key: "enabled", "data-flag-row": "soundEnabled" },
|
|
8640
|
+
h("input", {
|
|
8641
|
+
type: "checkbox",
|
|
8642
|
+
checked: FLAGS.soundEnabled === true,
|
|
8643
|
+
"data-flag": "soundEnabled",
|
|
8644
|
+
onChange: (event) => applyFlag("soundEnabled", event.target.checked),
|
|
8645
|
+
}),
|
|
8646
|
+
h("span", { "data-row-label": "" }, "开启会话提示音"),
|
|
8647
|
+
h("span", { "data-note-inline": "" }, FLAGS.soundEnabled ? "" : "默认静音"),
|
|
8648
|
+
),
|
|
8649
|
+
h(TuningControls, { key: "volume", group: "sound" }),
|
|
8650
|
+
]),
|
|
8651
|
+
card("phases", "会话相位", "点开相位调整台词 · 音频 · 动作", h(PhaseControls, { compact })),
|
|
8181
8652
|
// 「摸鱼节奏」(多久摸一次)和「摸鱼」(摸鱼做什么)是同一件事的两半,原来
|
|
8182
8653
|
// 被「会话相位」隔成两张卡,调摸鱼要上下跳。合成一张:节奏在上、池子在下。
|
|
8183
8654
|
card("pools", "摸鱼", "多久摸一次 · 摸鱼做什么", [
|
|
@@ -8185,10 +8656,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8185
8656
|
h(FidgetControls, { key: "pools" }),
|
|
8186
8657
|
]),
|
|
8187
8658
|
card("outfit", "装扮", null, h(OutfitControls, null)),
|
|
8188
|
-
//
|
|
8659
|
+
// 相位台词在相位卡;气泡卡只保留互动台词、开关和位置。
|
|
8189
8660
|
card("interact", "互动", "摸头 / 摸尾巴 / 转圈", h(InteractControls, null)),
|
|
8190
8661
|
card("bubble", "气泡", "显示什么 · 在哪 · 停留多久", [
|
|
8191
|
-
h("label", { "data-flag-row": "bubbleEnabled" },
|
|
8662
|
+
h("label", { key: "enabled", "data-flag-row": "bubbleEnabled" },
|
|
8192
8663
|
h("input", {
|
|
8193
8664
|
type: "checkbox",
|
|
8194
8665
|
checked: FLAGS.bubbleEnabled === true,
|
|
@@ -8230,6 +8701,21 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8230
8701
|
// 先按本地存档起来(宿主不在时这就是全部)。
|
|
8231
8702
|
restoreTuning();
|
|
8232
8703
|
restoreOverrides();
|
|
8704
|
+
// 刷新后浏览器会锁住声音;下次用户点击页面时再解锁,不自动播放。
|
|
8705
|
+
window.addEventListener("pointerdown", unlockSound);
|
|
8706
|
+
ctx.effect(() => () => {
|
|
8707
|
+
window.removeEventListener("pointerdown", unlockSound);
|
|
8708
|
+
// 卸载前把还没触发的音量试听定时器清掉:否则它 160ms 后醒来会**新建**一个
|
|
8709
|
+
// AudioContext 并放出一声 —— 宠物都已经走了。
|
|
8710
|
+
if (volumePreviewTimer !== 0) {
|
|
8711
|
+
window.clearTimeout(volumePreviewTimer);
|
|
8712
|
+
volumePreviewTimer = 0;
|
|
8713
|
+
}
|
|
8714
|
+
if (soundContext !== null) {
|
|
8715
|
+
void soundContext.close().catch(() => {});
|
|
8716
|
+
soundContext = null;
|
|
8717
|
+
}
|
|
8718
|
+
}, "live2d-pet: notification audio");
|
|
8233
8719
|
// 再问宿主要"共享的那一份":桌面端与 DSH 是两个 origin,localStorage 互不可见,
|
|
8234
8720
|
// 所以跨窗口一致只能靠宿主。拉回来会覆盖本地(宿主是权威),并广播给两个界面。
|
|
8235
8721
|
const applyRemote = (payload) => {
|
|
@@ -8250,7 +8736,10 @@ window.__ModuleLoader__.load({ id: "dsh-pet-live2d", factory: (require) => {
|
|
|
8250
8736
|
void pullShared(applyRemote);
|
|
8251
8737
|
// 3 秒轮询:另一个窗口改了,这个窗口跟着变。`storage` 事件不跨 origin,只能轮询。
|
|
8252
8738
|
if (sharedPoll === 0) {
|
|
8253
|
-
sharedPoll = window.setInterval(() => {
|
|
8739
|
+
sharedPoll = window.setInterval(() => {
|
|
8740
|
+
void pullShared(applyRemote);
|
|
8741
|
+
void pullUploadedSounds();
|
|
8742
|
+
}, SHARED_POLL_MS);
|
|
8254
8743
|
}
|
|
8255
8744
|
applySettings(ctx);
|
|
8256
8745
|
// Takeover: an earlier instance — a hot reload, or one left behind by a
|