dsh-folia 4.2.0 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +317 -14
- package/lib/routes/pets.js +65 -4
- package/package.json +4 -2
package/lib/client.js
CHANGED
|
@@ -17270,7 +17270,7 @@ window.__ModuleLoader__.load({
|
|
|
17270
17270
|
));
|
|
17271
17271
|
|
|
17272
17272
|
children.push(React.createElement("div", { key: "hint", className: "we-picker__hint", style: { margin: "6px 0 10px" } },
|
|
17273
|
-
weT("
|
|
17273
|
+
weT("桌宠由本插件自己绘制(雪碧图逐帧),不依赖其它宠物插件。它会跟着模型状态换动作:干活时换成工作行、干完变完成、出错变失败;安装后出现在右下角,可拖动,点一下会挥手并出声。")));
|
|
17274
17274
|
|
|
17275
17275
|
children.push(React.createElement("div", { key: "opts", style: { ...PET_STYLE.row, margin: "2px 0 8px" } },
|
|
17276
17276
|
React.createElement("label", { style: { display: "flex", gap: "6px", alignItems: "center", fontSize: "12px" } },
|
|
@@ -17282,6 +17282,32 @@ window.__ModuleLoader__.load({
|
|
|
17282
17282
|
}),
|
|
17283
17283
|
weT("显示桌宠"),
|
|
17284
17284
|
),
|
|
17285
|
+
React.createElement("label", { style: { display: "flex", gap: "6px", alignItems: "center", fontSize: "12px" } },
|
|
17286
|
+
React.createElement("input", {
|
|
17287
|
+
type: "checkbox",
|
|
17288
|
+
checked: Boolean(cfg.sound),
|
|
17289
|
+
disabled: Boolean(busy),
|
|
17290
|
+
onChange: (e) => act("config", { sound: e.target.checked }, ""),
|
|
17291
|
+
}),
|
|
17292
|
+
weT("声音"),
|
|
17293
|
+
),
|
|
17294
|
+
React.createElement("label", { style: { display: "flex", gap: "4px", alignItems: "center", fontSize: "12px" } },
|
|
17295
|
+
weT("音量"),
|
|
17296
|
+
React.createElement("select", {
|
|
17297
|
+
className: "we-picker__playlist-select",
|
|
17298
|
+
value: String(cfg.volume == null ? 0.6 : cfg.volume),
|
|
17299
|
+
disabled: Boolean(busy) || !cfg.sound,
|
|
17300
|
+
onChange: (e) => act("config", { volume: Number(e.target.value) }, ""),
|
|
17301
|
+
},
|
|
17302
|
+
[[0.2, "20%"], [0.4, "40%"], [0.6, "60%"], [0.8, "80%"], [1, "100%"]].map(([v, lb]) =>
|
|
17303
|
+
React.createElement("option", { key: String(v), value: String(v) }, lb)),
|
|
17304
|
+
),
|
|
17305
|
+
),
|
|
17306
|
+
React.createElement("button", {
|
|
17307
|
+
type: "button", className: "we-picker__btn", disabled: Boolean(busy),
|
|
17308
|
+
title: weT("把桌宠挪回右下角"),
|
|
17309
|
+
onClick: () => { try { localStorage.removeItem("wePetPos"); } catch { /* ignore */ } setNote({ ok: true, text: weT("位置已复位(下一次出现回到右下角)") }); },
|
|
17310
|
+
}, weT("复位位置")),
|
|
17285
17311
|
React.createElement("label", { style: { display: "flex", gap: "4px", alignItems: "center", fontSize: "12px" } },
|
|
17286
17312
|
weT("大小"),
|
|
17287
17313
|
React.createElement("select", {
|
|
@@ -17319,10 +17345,20 @@ window.__ModuleLoader__.load({
|
|
|
17319
17345
|
actions.push(btn(isActive ? weT("使用中") : weT("使用"), () => act("config", { enabled: true, petId: p.dirId }, p.name), isActive || Boolean(busy)));
|
|
17320
17346
|
actions.push(btn(weT("卸载"), () => act("remove", { id: p.dirId }, p.name), Boolean(busy)));
|
|
17321
17347
|
return React.createElement("div", { key, style: PET_STYLE.card },
|
|
17322
|
-
|
|
17323
|
-
|
|
17324
|
-
|
|
17325
|
-
|
|
17348
|
+
// 预览只显示**第一帧**:雪碧图是 8×9 网格,直接贴图会看到一整排小人(用户实测反馈"看着很乱")。
|
|
17349
|
+
// 用 background-size = 列数/行数 × 100% + position 0 0 裁出左上角那一格。
|
|
17350
|
+
React.createElement("div", {
|
|
17351
|
+
style: {
|
|
17352
|
+
width: "100%",
|
|
17353
|
+
aspectRatio: (p.cell ? p.cell.width + " / " + p.cell.height : "192 / 208"),
|
|
17354
|
+
maxHeight: "96px",
|
|
17355
|
+
backgroundImage: "url(" + apiUrl(p.sheet) + ")",
|
|
17356
|
+
backgroundSize: (Number(p.columns) || 8) * 100 + "% " + (Array.isArray(p.frames) ? p.frames.length : 9) * 100 + "%",
|
|
17357
|
+
backgroundPosition: "0 0",
|
|
17358
|
+
backgroundRepeat: "no-repeat",
|
|
17359
|
+
backgroundColor: "rgba(0,0,0,0.25)",
|
|
17360
|
+
borderRadius: "6px",
|
|
17361
|
+
},
|
|
17326
17362
|
}),
|
|
17327
17363
|
React.createElement("div", { style: PET_STYLE.title }, p.name),
|
|
17328
17364
|
React.createElement("div", { style: PET_STYLE.meta },
|
|
@@ -17395,25 +17431,165 @@ window.__ModuleLoader__.load({
|
|
|
17395
17431
|
const PET_LAYER_ID = "we-pet-layer";
|
|
17396
17432
|
const PET_ROW = { idle: 0, runRight: 1, runLeft: 2, wave: 3, jump: 4, fail: 5, wait: 6, work: 7, done: 8 };
|
|
17397
17433
|
const PET_DEFAULT_FRAME_MS = 120;
|
|
17434
|
+
/**
|
|
17435
|
+
* 活动检测:DSH 的客户端 `ctx` 只暴露 effect/get/slots/surface,没有会话事件,
|
|
17436
|
+
* 所以"模型在不在干活"只能从**页面本身**读。两条独立信号,任一命中即认为在工作:
|
|
17437
|
+
* ① DOM 特征:选择器(默认覆盖常见的 streaming / 生成中 标记)
|
|
17438
|
+
* ② 文本特征:屏幕上出现"正在生成 / 思考中 / Stop generating"这类字眼
|
|
17439
|
+
* 两条都读不到时**安静地停在 idle** —— 宁可不动,也不要让宠物乱跳。
|
|
17440
|
+
*/
|
|
17441
|
+
const PET_ACTIVITY_SELECTORS = [
|
|
17442
|
+
'[data-dsh-streaming]', '[data-streaming="true"]', '.ds-streaming',
|
|
17443
|
+
'[class*="streaming"]', '[class*="isGenerating"]', '[aria-busy="true"]',
|
|
17444
|
+
];
|
|
17445
|
+
const PET_ACTIVITY_TEXTS = ['正在生成', '思考中', '停止生成', 'Stop generating', 'Thinking'];
|
|
17446
|
+
/** 检测节流(毫秒):一次 body 扫描比一次布局还便宜,但没必要每帧跑。 */
|
|
17447
|
+
const PET_ACTIVITY_POLL_MS = 500;
|
|
17398
17448
|
/** 光标静止后多久切回 idle(毫秒)。 */
|
|
17399
17449
|
const PET_WAVE_IDLE_MS = 9000;
|
|
17450
|
+
/**
|
|
17451
|
+
* 日常动作调度:没在干活时,每隔 5~11 秒随机演一个。
|
|
17452
|
+
*
|
|
17453
|
+
* 为什么需要它:只播 idle 的宠物看起来是"一张会呼吸的图",不像活物。这里把 manifest 的
|
|
17454
|
+
* 9 条轨道都用上 —— 走一段(左右跑)、挥手、跳一下、发会儿呆 —— 用**随机间隔**而不是固定节拍,
|
|
17455
|
+
* 避免机械感。干活(work/done/fail)优先级最高,调度器在那些状态下不抢行。
|
|
17456
|
+
*/
|
|
17457
|
+
const PET_IDLE_ACTIONS = [
|
|
17458
|
+
{ row: "wave", weight: 3, ms: 0 },
|
|
17459
|
+
{ row: "jump", weight: 2, ms: 0 },
|
|
17460
|
+
{ row: "wait", weight: 2, ms: 1800 },
|
|
17461
|
+
{ row: "walkRight", weight: 3, ms: 1600 },
|
|
17462
|
+
{ row: "walkLeft", weight: 3, ms: 1600 },
|
|
17463
|
+
];
|
|
17464
|
+
/**
|
|
17465
|
+
* 统一时钟。
|
|
17466
|
+
*
|
|
17467
|
+
* ⚠️ `requestAnimationFrame(fn)` 交给 fn 的是**页面相对时间**(与 performance.now 同源),
|
|
17468
|
+
* 不是 petNow()。两者混用会出现「holdUntil = petNow() + 1400,而 now 只有几千」——
|
|
17469
|
+
* 比较永远不成立,于是宠物切到 done/fail/wave 之后**再也回不到 idle**(实测复现)。
|
|
17470
|
+
* 所有时间比较一律走这一个函数。
|
|
17471
|
+
*/
|
|
17472
|
+
function petNow() {
|
|
17473
|
+
try {
|
|
17474
|
+
if (typeof performance !== "undefined" && typeof performance.now === "function") return performance.now();
|
|
17475
|
+
} catch { /* 退回 Date.now */ }
|
|
17476
|
+
return petNow();
|
|
17477
|
+
}
|
|
17478
|
+
/** 两次随机动作之间的间隔范围(毫秒)。 */
|
|
17479
|
+
const PET_IDLE_GAP_MS = [5000, 11000];
|
|
17480
|
+
|
|
17481
|
+
/**
|
|
17482
|
+
* 声音:包内 `sounds/<事件>.ogg|mp3|wav` 优先(manifest.sounds 显式声明),
|
|
17483
|
+
* 没有就用 Web Audio 合成 —— 一只宠物没声音比"声音是版权素材"糟得多。
|
|
17484
|
+
* 全部失败都静默:声音是可选项,不该有能力打断渲染。
|
|
17485
|
+
*/
|
|
17486
|
+
function petVoice(ctxWindow, sounds, cfg) {
|
|
17487
|
+
const enabled = Boolean(cfg && cfg.sound);
|
|
17488
|
+
const volume = Math.max(0, Math.min(1, Number(cfg && cfg.volume) || 0));
|
|
17489
|
+
let audioCtx = null;
|
|
17490
|
+
const cache = new Map();
|
|
17491
|
+
|
|
17492
|
+
function ensureCtx() {
|
|
17493
|
+
if (audioCtx || typeof ctxWindow.AudioContext !== 'function') return audioCtx;
|
|
17494
|
+
try { audioCtx = new ctxWindow.AudioContext(); } catch { audioCtx = null; }
|
|
17495
|
+
return audioCtx;
|
|
17496
|
+
}
|
|
17497
|
+
|
|
17498
|
+
/** 合成提示音:不同事件不同音高与波形,短、克制。 */
|
|
17499
|
+
function synth(kind) {
|
|
17500
|
+
const ac = ensureCtx();
|
|
17501
|
+
if (!ac) return;
|
|
17502
|
+
const spec = kind === "done" ? { f: 880, d: 0.09, w: "sine" }
|
|
17503
|
+
: kind === "fail" ? { f: 220, d: 0.16, w: "sawtooth" }
|
|
17504
|
+
: { f: 620, d: 0.08, w: "triangle" };
|
|
17505
|
+
try {
|
|
17506
|
+
const osc = ac.createOscillator();
|
|
17507
|
+
const gain = ac.createGain();
|
|
17508
|
+
osc.type = spec.w;
|
|
17509
|
+
osc.frequency.value = spec.f;
|
|
17510
|
+
gain.gain.value = 0.0001;
|
|
17511
|
+
osc.connect(gain);
|
|
17512
|
+
gain.connect(ac.destination);
|
|
17513
|
+
const now = ac.currentTime;
|
|
17514
|
+
gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, volume * 0.25), now + 0.01);
|
|
17515
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, now + spec.d);
|
|
17516
|
+
osc.start(now);
|
|
17517
|
+
osc.stop(now + spec.d + 0.02);
|
|
17518
|
+
} catch { /* 合成失败就算了 */ }
|
|
17519
|
+
}
|
|
17520
|
+
|
|
17521
|
+
function file(kind) {
|
|
17522
|
+
const url = sounds && sounds[kind];
|
|
17523
|
+
if (!url) return null;
|
|
17524
|
+
let el = cache.get(kind);
|
|
17525
|
+
if (!el) {
|
|
17526
|
+
try { el = new ctxWindow.Audio(apiUrl(url)); } catch { return null; }
|
|
17527
|
+
el.preload = "auto";
|
|
17528
|
+
cache.set(kind, el);
|
|
17529
|
+
}
|
|
17530
|
+
return el;
|
|
17531
|
+
}
|
|
17532
|
+
|
|
17533
|
+
return {
|
|
17534
|
+
get enabled() { return enabled; },
|
|
17535
|
+
play(kind) {
|
|
17536
|
+
if (!enabled || volume <= 0) return;
|
|
17537
|
+
const el = file(kind);
|
|
17538
|
+
if (el) {
|
|
17539
|
+
try { el.currentTime = 0; el.volume = volume; const p = el.play(); if (p && p.catch) p.catch(() => synth(kind)); return; } catch { /* 落回合成 */ }
|
|
17540
|
+
}
|
|
17541
|
+
synth(kind);
|
|
17542
|
+
},
|
|
17543
|
+
destroy() {
|
|
17544
|
+
cache.clear();
|
|
17545
|
+
try { if (audioCtx && typeof audioCtx.close === "function") audioCtx.close(); } catch { /* ignore */ }
|
|
17546
|
+
audioCtx = null;
|
|
17547
|
+
},
|
|
17548
|
+
};
|
|
17549
|
+
}
|
|
17550
|
+
|
|
17551
|
+
/** 页面里现在像不像"模型在干活"。任何异常都当 false。 */
|
|
17552
|
+
function petActivityProbe() {
|
|
17553
|
+
try {
|
|
17554
|
+
if (typeof document === 'undefined' || !document.body) return false;
|
|
17555
|
+
for (const sel of PET_ACTIVITY_SELECTORS) {
|
|
17556
|
+
if (document.querySelector(sel)) return true;
|
|
17557
|
+
}
|
|
17558
|
+
const text = document.body.innerText || "";
|
|
17559
|
+
for (const marker of PET_ACTIVITY_TEXTS) {
|
|
17560
|
+
if (text.indexOf(marker) !== -1) return true;
|
|
17561
|
+
}
|
|
17562
|
+
} catch { /* ignore */ }
|
|
17563
|
+
return false;
|
|
17564
|
+
}
|
|
17400
17565
|
|
|
17401
17566
|
/**
|
|
17402
17567
|
* 桌宠实例:一只宠物一条生命周期(换宠物 = 销毁重来)。
|
|
17403
17568
|
* 不导出(构建期内联进同一作用域),只由 mountPetLayer 使用。
|
|
17404
17569
|
*/
|
|
17405
|
-
function createPetLayer(layerEl, canvas, pet, scale) {
|
|
17570
|
+
function createPetLayer(layerEl, canvas, pet, scale, cfg) {
|
|
17406
17571
|
const ctx = canvas.getContext("2d");
|
|
17407
17572
|
const img = new Image();
|
|
17408
17573
|
let frame = 0;
|
|
17409
17574
|
let row = PET_ROW.idle;
|
|
17410
17575
|
let acc = 0;
|
|
17411
|
-
let last = 0;
|
|
17576
|
+
let last = 0; // rAF 时间戳(页面相对时间),不是 Date.now()
|
|
17412
17577
|
let raf = 0;
|
|
17413
17578
|
let dead = false;
|
|
17414
17579
|
let idleSince = 0;
|
|
17415
17580
|
let dragging = null;
|
|
17416
17581
|
let ready = false;
|
|
17582
|
+
const voice = petVoice(typeof window !== "undefined" ? window : {}, pet.sounds, cfg || {});
|
|
17583
|
+
let activity = false;
|
|
17584
|
+
let activityTimer = null;
|
|
17585
|
+
/** 一次性动作(done/fail)播完自动回 idle 的时间戳。 */
|
|
17586
|
+
let holdUntil = 0;
|
|
17587
|
+
/** 下一次随机动作的时间点;0 = 立即重排。 */
|
|
17588
|
+
let nextActionAt = 0;
|
|
17589
|
+
/** 走动中的速度(px/帧,正=向右)。0 = 不走。 */
|
|
17590
|
+
let walkSpeed = 0;
|
|
17591
|
+
/** 走动剩余时长(毫秒)。 */
|
|
17592
|
+
let walkLeft = 0;
|
|
17417
17593
|
|
|
17418
17594
|
const cw = Math.max(1, Number(pet.cell && pet.cell.width) || 192);
|
|
17419
17595
|
const ch = Math.max(1, Number(pet.cell && pet.cell.height) || 208);
|
|
@@ -17459,8 +17635,55 @@ window.__ModuleLoader__.load({
|
|
|
17459
17635
|
function tick(now) {
|
|
17460
17636
|
if (dead) return;
|
|
17461
17637
|
if (!last) last = now;
|
|
17462
|
-
|
|
17638
|
+
const dt = Math.max(0, Math.min(100, now - last)); // 切后台回来可能是几百毫秒,钳住
|
|
17639
|
+
acc += dt;
|
|
17463
17640
|
last = now;
|
|
17641
|
+
// ── 走动:每帧挪一点,走到头或时间到就停下回 idle ──
|
|
17642
|
+
if (walkSpeed !== 0 && walkLeft > 0) {
|
|
17643
|
+
const p = pos();
|
|
17644
|
+
const maxX = Math.max(0, (window.innerWidth || 1920) - layerEl.offsetWidth);
|
|
17645
|
+
const nx = p.left + walkSpeed;
|
|
17646
|
+
if (nx <= 0 || nx >= maxX) {
|
|
17647
|
+
walkSpeed = -walkSpeed;
|
|
17648
|
+
setRow(walkSpeed > 0 ? PET_ROW.runRight : PET_ROW.runLeft);
|
|
17649
|
+
moveTo(Math.max(0, Math.min(maxX, nx)), p.top);
|
|
17650
|
+
} else {
|
|
17651
|
+
moveTo(nx, p.top);
|
|
17652
|
+
}
|
|
17653
|
+
walkLeft -= dt;
|
|
17654
|
+
if (walkLeft <= 0) {
|
|
17655
|
+
walkSpeed = 0;
|
|
17656
|
+
setRow(PET_ROW.idle);
|
|
17657
|
+
nextActionAt = petNow() + PET_IDLE_GAP_MS[0] + Math.random() * (PET_IDLE_GAP_MS[1] - PET_IDLE_GAP_MS[0]);
|
|
17658
|
+
}
|
|
17659
|
+
}
|
|
17660
|
+
// ── 日常动作调度:只在"真的闲着"时才演 ──
|
|
17661
|
+
if (!activity && walkSpeed === 0 && row === PET_ROW.idle && !holdUntil && now >= nextActionAt) {
|
|
17662
|
+
playIdleAction();
|
|
17663
|
+
const gap = PET_IDLE_GAP_MS[0] + Math.random() * (PET_IDLE_GAP_MS[1] - PET_IDLE_GAP_MS[0]);
|
|
17664
|
+
nextActionAt = petNow() + gap;
|
|
17665
|
+
}
|
|
17666
|
+
|
|
17667
|
+
// 活动状态变化时先切行(work/done/fail 优先于 idle/wave)。
|
|
17668
|
+
if (activityTimer === null && typeof setInterval === "function") {
|
|
17669
|
+
activityTimer = setInterval(() => {
|
|
17670
|
+
const nowActive = petActivityProbe();
|
|
17671
|
+
if (nowActive === activity && now < holdUntil) return;
|
|
17672
|
+
activity = nowActive;
|
|
17673
|
+
if (activity) {
|
|
17674
|
+
holdUntil = 0;
|
|
17675
|
+
walkSpeed = 0;
|
|
17676
|
+
walkLeft = 0;
|
|
17677
|
+
if (row !== PET_ROW.work) setRow(PET_ROW.work);
|
|
17678
|
+
} else if (row === PET_ROW.work) {
|
|
17679
|
+
// 干完活 → 完成行一小会儿,再回 idle("被看见"的反馈)
|
|
17680
|
+
setRow(PET_ROW.done);
|
|
17681
|
+
holdUntil = petNow() + 1600;
|
|
17682
|
+
voice.play("done");
|
|
17683
|
+
}
|
|
17684
|
+
}, PET_ACTIVITY_POLL_MS);
|
|
17685
|
+
}
|
|
17686
|
+
if (holdUntil && now >= holdUntil) { holdUntil = 0; setRow(PET_ROW.idle); }
|
|
17464
17687
|
const ms = frameMs(row, frame);
|
|
17465
17688
|
if (acc >= ms) {
|
|
17466
17689
|
acc = 0;
|
|
@@ -17469,12 +17692,60 @@ window.__ModuleLoader__.load({
|
|
|
17469
17692
|
frame = 0;
|
|
17470
17693
|
// 一次性动作(挥手 / 跳)播完回 idle
|
|
17471
17694
|
if (row === PET_ROW.wave || row === PET_ROW.jump) row = PET_ROW.idle;
|
|
17695
|
+
// done / fail 由 holdUntil 计时收尾,不在这里回 idle(否则刚出现的反馈会被吞掉)。
|
|
17472
17696
|
}
|
|
17473
17697
|
}
|
|
17474
17698
|
draw();
|
|
17475
17699
|
raf = (typeof requestAnimationFrame === "function")
|
|
17476
17700
|
? requestAnimationFrame(tick)
|
|
17477
|
-
: setTimeout(() => tick(
|
|
17701
|
+
: setTimeout(() => tick(petNow()), 60);
|
|
17702
|
+
}
|
|
17703
|
+
|
|
17704
|
+
/** 当前左上角(拖动/走动都读写这一对值)。 */
|
|
17705
|
+
function pos() {
|
|
17706
|
+
const left = parseFloat(layerEl.style.left);
|
|
17707
|
+
const top = parseFloat(layerEl.style.top);
|
|
17708
|
+
return {
|
|
17709
|
+
left: Number.isFinite(left) ? left : (window.innerWidth - layerEl.offsetWidth - 24),
|
|
17710
|
+
top: Number.isFinite(top) ? top : (window.innerHeight - layerEl.offsetHeight - 16),
|
|
17711
|
+
};
|
|
17712
|
+
}
|
|
17713
|
+
function moveTo(left, top) {
|
|
17714
|
+
const maxX = Math.max(0, (window.innerWidth || 1920) - layerEl.offsetWidth);
|
|
17715
|
+
const maxY = Math.max(0, (window.innerHeight || 1080) - layerEl.offsetHeight);
|
|
17716
|
+
const x = Math.max(0, Math.min(maxX, left));
|
|
17717
|
+
const y = Math.max(0, Math.min(maxY, top));
|
|
17718
|
+
layerEl.style.left = x + "px";
|
|
17719
|
+
layerEl.style.top = y + "px";
|
|
17720
|
+
layerEl.style.right = "auto";
|
|
17721
|
+
layerEl.style.bottom = "auto";
|
|
17722
|
+
}
|
|
17723
|
+
|
|
17724
|
+
/** 从 idle 里挑一个动作演。走动会真的位移,演完自己停。 */
|
|
17725
|
+
function playIdleAction() {
|
|
17726
|
+
const total = PET_IDLE_ACTIONS.reduce((s, a) => s + a.weight, 0);
|
|
17727
|
+
let pick = Math.random() * total;
|
|
17728
|
+
let action = PET_IDLE_ACTIONS[0];
|
|
17729
|
+
for (const a of PET_IDLE_ACTIONS) { if ((pick -= a.weight) <= 0) { action = a; break; } }
|
|
17730
|
+
if (action.row === "walkRight" || action.row === "walkLeft") {
|
|
17731
|
+
const dir = action.row === "walkRight" ? 1 : -1;
|
|
17732
|
+
const p0 = pos();
|
|
17733
|
+
// 走到边上就掉头:撞墙不停在原地踏步,直接换方向。
|
|
17734
|
+
const maxX = Math.max(0, (window.innerWidth || 1920) - layerEl.offsetWidth);
|
|
17735
|
+
if ((dir > 0 && p0.left >= maxX - 2) || (dir < 0 && p0.left <= 2)) {
|
|
17736
|
+
setRow(dir > 0 ? PET_ROW.runLeft : PET_ROW.runRight);
|
|
17737
|
+
walkSpeed = -dir * (0.9 * scale);
|
|
17738
|
+
} else {
|
|
17739
|
+
setRow(dir > 0 ? PET_ROW.runRight : PET_ROW.runLeft);
|
|
17740
|
+
walkSpeed = dir * (0.9 * scale);
|
|
17741
|
+
}
|
|
17742
|
+
walkLeft = action.ms;
|
|
17743
|
+
return;
|
|
17744
|
+
}
|
|
17745
|
+
walkSpeed = 0;
|
|
17746
|
+
walkLeft = 0;
|
|
17747
|
+
setRow(PET_ROW[action.row] === undefined ? PET_ROW.idle : PET_ROW[action.row]);
|
|
17748
|
+
holdUntil = action.ms ? petNow() + action.ms : petNow() + 1400;
|
|
17478
17749
|
}
|
|
17479
17750
|
|
|
17480
17751
|
function setRow(next) {
|
|
@@ -17510,7 +17781,11 @@ window.__ModuleLoader__.load({
|
|
|
17510
17781
|
dragging = null;
|
|
17511
17782
|
if (!moved) {
|
|
17512
17783
|
// 单击 = 挥手(给用户一个"它有反应"的反馈),之后自动回 idle
|
|
17784
|
+
walkSpeed = 0;
|
|
17785
|
+
walkLeft = 0;
|
|
17513
17786
|
setRow(PET_ROW.wave);
|
|
17787
|
+
holdUntil = petNow() + 1400;
|
|
17788
|
+
voice.play("wave");
|
|
17514
17789
|
} else {
|
|
17515
17790
|
try { localStorage.setItem("wePetPos", JSON.stringify({ left: layerEl.style.left, top: layerEl.style.top })); } catch { /* ignore */ }
|
|
17516
17791
|
}
|
|
@@ -17525,11 +17800,14 @@ window.__ModuleLoader__.load({
|
|
|
17525
17800
|
window.addEventListener("pointermove", onPointerMove);
|
|
17526
17801
|
window.addEventListener("pointerup", onPointerUp);
|
|
17527
17802
|
}
|
|
17528
|
-
idleSince =
|
|
17529
|
-
raf = (typeof requestAnimationFrame === "function") ? requestAnimationFrame(tick) : setTimeout(() => tick(
|
|
17803
|
+
idleSince = petNow();
|
|
17804
|
+
raf = (typeof requestAnimationFrame === "function") ? requestAnimationFrame(tick) : setTimeout(() => tick(petNow()), 60);
|
|
17530
17805
|
|
|
17531
17806
|
function destroy() {
|
|
17532
17807
|
dead = true;
|
|
17808
|
+
if (typeof clearInterval === "function" && activityTimer !== null) clearInterval(activityTimer);
|
|
17809
|
+
activityTimer = null;
|
|
17810
|
+
try { voice.destroy(); } catch { /* ignore */ }
|
|
17533
17811
|
if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(raf); else clearTimeout(raf);
|
|
17534
17812
|
canvas.removeEventListener("pointerdown", onPointerDown);
|
|
17535
17813
|
if (typeof window !== "undefined") {
|
|
@@ -17538,7 +17816,15 @@ window.__ModuleLoader__.load({
|
|
|
17538
17816
|
}
|
|
17539
17817
|
}
|
|
17540
17818
|
|
|
17541
|
-
return {
|
|
17819
|
+
return {
|
|
17820
|
+
destroy, setRow, resize,
|
|
17821
|
+
/** 外部事件注入(面板操作失败等):一次性播放失败行并出声。 */
|
|
17822
|
+
fail() { setRow(PET_ROW.fail); holdUntil = petNow() + 1600; voice.play("fail"); },
|
|
17823
|
+
cheer() { setRow(PET_ROW.done); holdUntil = petNow() + 1600; voice.play("done"); },
|
|
17824
|
+
get row() { return row; },
|
|
17825
|
+
/** 让下一次随机动作尽快发生(首次出现时用)。 */
|
|
17826
|
+
scheduleSoon() { nextActionAt = petNow() + 1200 + Math.random() * 1800; },
|
|
17827
|
+
};
|
|
17542
17828
|
}
|
|
17543
17829
|
|
|
17544
17830
|
/**
|
|
@@ -17547,16 +17833,24 @@ window.__ModuleLoader__.load({
|
|
|
17547
17833
|
* 它自己拉 `/folia/pets/list`(配置 + 已装宠物),所以**不依赖面板是否打开过** ——
|
|
17548
17834
|
* 冷启动就能出宠物。
|
|
17549
17835
|
*/
|
|
17836
|
+
/** 让刚出现的宠物在 1.2~3 秒后演第一个动作(实例内部按需读 nextActionAt)。 */
|
|
17837
|
+
function nextActionSoon(instance) {
|
|
17838
|
+
try { if (instance && typeof instance.scheduleSoon === "function") instance.scheduleSoon(); } catch { /* ignore */ }
|
|
17839
|
+
}
|
|
17840
|
+
|
|
17550
17841
|
function mountPetLayer() {
|
|
17551
17842
|
if (typeof document === "undefined" || !document.body) return null;
|
|
17552
17843
|
let disposed = false;
|
|
17553
17844
|
let instance = null;
|
|
17554
17845
|
let layerEl = null;
|
|
17846
|
+
/** 当前实例对应的「宠物@大小」;变了就要销毁重建。 */
|
|
17847
|
+
let currentKey = "";
|
|
17555
17848
|
|
|
17556
17849
|
function teardownDom() {
|
|
17557
17850
|
if (instance) { try { instance.destroy(); } catch { /* ignore */ } instance = null; }
|
|
17558
17851
|
if (layerEl && layerEl.parentNode) layerEl.parentNode.removeChild(layerEl);
|
|
17559
17852
|
layerEl = null;
|
|
17853
|
+
currentKey = "";
|
|
17560
17854
|
}
|
|
17561
17855
|
|
|
17562
17856
|
async function sync() {
|
|
@@ -17572,7 +17866,12 @@ window.__ModuleLoader__.load({
|
|
|
17572
17866
|
if (!cfg || !cfg.enabled || !pet) { teardownDom(); return; }
|
|
17573
17867
|
|
|
17574
17868
|
const scale = Number(cfg.scale) > 0 ? Number(cfg.scale) : 1;
|
|
17575
|
-
|
|
17869
|
+
const key = String(pet.dirId || pet.id) + "@" + scale;
|
|
17870
|
+
// 换宠物 / 改大小 ⇒ **必须重建**:旧实例的贴图与几何都是上一只的。
|
|
17871
|
+
// (实测踩过:只在首次创建,于是点「使用」换了宠物而屏幕没变 —— 就是这里。)
|
|
17872
|
+
if (layerEl && currentKey === key) { layerEl.style.display = ""; return; }
|
|
17873
|
+
teardownDom();
|
|
17874
|
+
currentKey = key;
|
|
17576
17875
|
|
|
17577
17876
|
layerEl = document.createElement("div");
|
|
17578
17877
|
layerEl.id = PET_LAYER_ID;
|
|
@@ -17593,7 +17892,9 @@ window.__ModuleLoader__.load({
|
|
|
17593
17892
|
const canvas = document.createElement("canvas");
|
|
17594
17893
|
layerEl.appendChild(canvas);
|
|
17595
17894
|
document.body.appendChild(layerEl);
|
|
17596
|
-
instance = createPetLayer(layerEl, canvas, pet, scale);
|
|
17895
|
+
instance = createPetLayer(layerEl, canvas, pet, scale, cfg);
|
|
17896
|
+
// 首次出现时排一个随机动作,别一上来就傻站着。
|
|
17897
|
+
nextActionSoon(instance);
|
|
17597
17898
|
}
|
|
17598
17899
|
|
|
17599
17900
|
sync();
|
|
@@ -17611,6 +17912,8 @@ window.__ModuleLoader__.load({
|
|
|
17611
17912
|
};
|
|
17612
17913
|
}
|
|
17613
17914
|
|
|
17915
|
+
// 仅供单测驱动(构建期内联时会剥掉 export,不影响产物)。
|
|
17916
|
+
|
|
17614
17917
|
// ── 内联模块:src/quick-panel.js —— 快捷播放面板(官方侧栏 tab 与低版本抽屉共用同一份内容):当前壁纸 + 轮播 + 列表快切 + 声音 + 设置入口。列表虚拟滚动(spacer + 可视窗口)(构建期注入;勿手改本段)──
|
|
17615
17918
|
/**
|
|
17616
17919
|
* quick-panel.js — 快捷播放面板(侧边栏的**唯一**内容):当前壁纸 + 轮播 + 三档页签
|
package/lib/routes/pets.js
CHANGED
|
@@ -26,6 +26,7 @@ import { homedir } from 'node:os';
|
|
|
26
26
|
import { basename, dirname, extname, join } from 'node:path';
|
|
27
27
|
|
|
28
28
|
/** 注入契约(ADR-0013 D12)。 */
|
|
29
|
+
export { unzip };
|
|
29
30
|
export const DEPS = ['disposers', 'base', 'log'];
|
|
30
31
|
|
|
31
32
|
const DEFAULT_COLUMNS = 8;
|
|
@@ -92,6 +93,23 @@ function readPet(dir, id) {
|
|
|
92
93
|
bytes: (() => { try { return statSync(sheetPath).size; } catch { return null; } })(),
|
|
93
94
|
cell, columns, frames, tracks,
|
|
94
95
|
sheet: '/folia/pets/sheet?v=' + encodeURIComponent(id),
|
|
96
|
+
// 音效:manifest 里显式给了 `sounds`(事件 → 包内文件)才生效;
|
|
97
|
+
// 没有就由前端用 Web Audio 合成,避免"没声音"变成默认行为。
|
|
98
|
+
sounds: (() => {
|
|
99
|
+
const src = (manifest.sounds && typeof manifest.sounds === 'object') ? manifest.sounds : null;
|
|
100
|
+
if (!src) return null;
|
|
101
|
+
const out = {};
|
|
102
|
+
for (const [ev, file] of Object.entries(src)) {
|
|
103
|
+
if (typeof file !== 'string') continue;
|
|
104
|
+
const rel = file.replace(/\\/g, '/');
|
|
105
|
+
if (!/^sounds\/[^/]+\.(ogg|mp3|wav|m4a|webm)$/i.test(rel)) continue;
|
|
106
|
+
const abs = join(dir, rel);
|
|
107
|
+
if (!existsSync(abs)) continue;
|
|
108
|
+
// 原样带 sounds/ 前缀:资源路由按 sounds/<文件> 白名单校验,剥掉前缀就对不上了。
|
|
109
|
+
out[ev] = '/folia/pets/asset?v=' + encodeURIComponent(id) + '&f=' + encodeURIComponent(rel);
|
|
110
|
+
}
|
|
111
|
+
return Object.keys(out).length ? out : null;
|
|
112
|
+
})(),
|
|
95
113
|
};
|
|
96
114
|
}
|
|
97
115
|
|
|
@@ -120,9 +138,11 @@ function readConfig() {
|
|
|
120
138
|
enabled: raw.enabled === true,
|
|
121
139
|
petId: typeof raw.petId === 'string' ? raw.petId : '',
|
|
122
140
|
scale: Number.isFinite(Number(raw.scale)) ? Math.min(3, Math.max(0.5, Number(raw.scale))) : 1,
|
|
141
|
+
sound: raw.sound === true,
|
|
142
|
+
volume: Number.isFinite(Number(raw.volume)) ? Math.min(1, Math.max(0, Number(raw.volume))) : 0.6,
|
|
123
143
|
};
|
|
124
144
|
} catch {
|
|
125
|
-
return { enabled: false, petId: '', scale: 1 };
|
|
145
|
+
return { enabled: false, petId: '', scale: 1, sound: false, volume: 0.6 };
|
|
126
146
|
}
|
|
127
147
|
}
|
|
128
148
|
function writeConfig(next) {
|
|
@@ -131,6 +151,8 @@ function writeConfig(next) {
|
|
|
131
151
|
enabled: next.enabled === undefined ? cur.enabled : Boolean(next.enabled),
|
|
132
152
|
petId: next.petId === undefined ? cur.petId : String(next.petId || ''),
|
|
133
153
|
scale: next.scale === undefined ? cur.scale : Math.min(3, Math.max(0.5, Number(next.scale) || 1)),
|
|
154
|
+
sound: next.sound === undefined ? cur.sound : Boolean(next.sound),
|
|
155
|
+
volume: next.volume === undefined ? cur.volume : Math.min(1, Math.max(0, Number(next.volume) || 0)),
|
|
134
156
|
};
|
|
135
157
|
try {
|
|
136
158
|
ensureDir(dirname(configPath()));
|
|
@@ -181,8 +203,12 @@ function unzip(buf, opts = {}) {
|
|
|
181
203
|
const localOff = buf.readUInt32LE(p + 42);
|
|
182
204
|
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
|
183
205
|
p += 46 + nameLen + extraLen + commentLen;
|
|
184
|
-
//
|
|
185
|
-
|
|
206
|
+
// 路径安全:只收「根文件」与「sounds/<文件>」两种形态 —— 任何 `..`、绝对路径、
|
|
207
|
+
// 其它子目录一律丢弃。宠物包只需要雪碧图/清单/音效三类东西,收窄比放行安全。
|
|
208
|
+
const safeName = String(name).replace(/\\/g, '/');
|
|
209
|
+
const okShape = safeName.includes('..') || safeName.startsWith('/') ? false
|
|
210
|
+
: (safeName.indexOf('/') === -1 || /^sounds\/[^/]+$/.test(safeName));
|
|
211
|
+
if (!safeName || !okShape) continue;
|
|
186
212
|
if (compSize > maxEntry) continue;
|
|
187
213
|
const lNameLen = buf.readUInt16LE(localOff + 26);
|
|
188
214
|
const lExtraLen = buf.readUInt16LE(localOff + 28);
|
|
@@ -300,6 +326,35 @@ export function registerPetsRoutes(webServer, c) {
|
|
|
300
326
|
},
|
|
301
327
|
}));
|
|
302
328
|
|
|
329
|
+
// ── GET /pets/asset?v=<dirId>&f=<相对路径>(包内音频等额外资源) ───────────
|
|
330
|
+
disposers.push(webServer.register({
|
|
331
|
+
kind: 'exact',
|
|
332
|
+
path: `${BASE}/pets/asset`,
|
|
333
|
+
handler: async (req, res) => {
|
|
334
|
+
let q = null;
|
|
335
|
+
try { q = new URL(req.url || '/', 'http://x').searchParams; } catch { q = null; }
|
|
336
|
+
const id = q ? String(q.get('v') || '') : '';
|
|
337
|
+
const rel = q ? String(q.get('f') || '').replace(/\\/g, '/') : '';
|
|
338
|
+
if (!id || /[\\/]/.test(id) || id.includes('..') || !rel) { res.statusCode = 400; res.end(); return; }
|
|
339
|
+
// 只放行 sounds/ 下的已知音频扩展名:这条路由不充当通用文件读口。
|
|
340
|
+
if (!/^sounds\/[^/]+\.(ogg|mp3|wav|m4a|webm)$/i.test(rel)) { res.statusCode = 400; res.end(); return; }
|
|
341
|
+
const file = join(dataRoot(), id, rel);
|
|
342
|
+
let size = 0;
|
|
343
|
+
try { size = statSync(file).size; } catch { size = 0; }
|
|
344
|
+
if (!size || size > 16 * 1024 * 1024) { res.statusCode = 404; res.end(); return; }
|
|
345
|
+
const ext = extname(file).toLowerCase();
|
|
346
|
+
const type = ext === '.mp3' ? 'audio/mpeg' : ext === '.wav' ? 'audio/wav'
|
|
347
|
+
: ext === '.m4a' ? 'audio/mp4' : ext === '.webm' ? 'audio/webm' : 'audio/ogg';
|
|
348
|
+
if (typeof res.setHeader === 'function') {
|
|
349
|
+
res.setHeader('content-type', type);
|
|
350
|
+
res.setHeader('content-length', String(size));
|
|
351
|
+
res.setHeader('cache-control', 'public, max-age=31536000, immutable');
|
|
352
|
+
}
|
|
353
|
+
res.statusCode = 200;
|
|
354
|
+
createReadStream(file).pipe(res);
|
|
355
|
+
},
|
|
356
|
+
}));
|
|
357
|
+
|
|
303
358
|
// ── POST /pets/config(开关 / 选中 / 缩放) ───────────────────────────────
|
|
304
359
|
disposers.push(webServer.register({
|
|
305
360
|
kind: 'exact',
|
|
@@ -358,7 +413,13 @@ export function registerPetsRoutes(webServer, c) {
|
|
|
358
413
|
const dir = join(dataRoot(), id);
|
|
359
414
|
try {
|
|
360
415
|
ensureDir(dir);
|
|
361
|
-
for (const [name, data] of entries)
|
|
416
|
+
for (const [name, data] of entries) {
|
|
417
|
+
const rel = String(name).replace(/\\/g, '/');
|
|
418
|
+
const safe = rel.indexOf('/') === -1 ? basename(rel) : rel; // sounds/x.ogg 保留一层
|
|
419
|
+
const target = join(dir, safe);
|
|
420
|
+
ensureDir(dirname(target));
|
|
421
|
+
writeFileSync(target, data);
|
|
422
|
+
}
|
|
362
423
|
} catch (e) {
|
|
363
424
|
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
364
425
|
sendJson(res, 500, { ok: false, error: 'write-failed', detail: String((e && e.message) || e) });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-folia",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.1",
|
|
4
4
|
"description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -191,7 +191,9 @@
|
|
|
191
191
|
"verify:all": "node test/run-guards.mjs all",
|
|
192
192
|
"prepack": "node scripts/prepare.mjs",
|
|
193
193
|
"test:boot": "node test/boot-http.mjs",
|
|
194
|
-
"
|
|
194
|
+
"test:pets": "node test/pets-http.mjs",
|
|
195
|
+
"test:pets-layer": "node test/pets-layer.mjs",
|
|
196
|
+
"gate": "node scripts/build-client.mjs \u0026\u0026 node test/run-guards.mjs smoke \u0026\u0026 node test/boot-http.mjs \u0026\u0026 node test/pets-http.mjs \u0026\u0026 node test/pets-layer.mjs"
|
|
195
197
|
},
|
|
196
198
|
"peerDependencies": {
|
|
197
199
|
"@deepseek-ai/cordis": "^4.0.4",
|