dsh-folia 4.3.0 → 4.4.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/lib/client.js CHANGED
@@ -17212,12 +17212,21 @@ window.__ModuleLoader__.load({
17212
17212
  const [state, setState] = React.useState(null);
17213
17213
  const [busy, setBusy] = React.useState("");
17214
17214
  const [note, setNote] = React.useState(null);
17215
+ const [importable, setImportable] = React.useState([]);
17215
17216
 
17216
17217
  const load = React.useCallback(async (force) => {
17217
17218
  try {
17218
17219
  const resp = await apiJson("/pets/list" + (force ? "?refresh=1" : ""), { parse: "always" });
17219
17220
  const doc = (resp && resp.data) || null;
17220
- if (resp && resp.ok && doc && doc.ok) setState(doc);
17221
+ if (resp && resp.ok && doc && doc.ok) {
17222
+ setState(doc);
17223
+ // 顺带问一下本机其它宠物插件里有哪些可导入的(失败不影响主列表)。
17224
+ try {
17225
+ const im = await apiJson("/pets/importable", { parse: "always" });
17226
+ const imDoc = (im && im.data) || null;
17227
+ if (imDoc && imDoc.ok) setImportable(imDoc.pets || []);
17228
+ } catch { /* 无外部目录是正常情况 */ }
17229
+ }
17221
17230
  else setNote({ ok: false, text: weT("读取失败:") + ((resp && resp.error) || "unknown") });
17222
17231
  } catch (e) {
17223
17232
  setNote({ ok: false, text: weT("请求失败:") + ((e && e.message) || weT("网络错误")) });
@@ -17235,6 +17244,7 @@ window.__ModuleLoader__.load({
17235
17244
  if (resp && resp.ok && r && r.ok) {
17236
17245
  if (kind === "install") setNote({ ok: true, text: weT("已安装并启用:") + (label || "") });
17237
17246
  if (kind === "remove") setNote({ ok: true, text: weT("已卸载:") + (label || "") });
17247
+ if (kind === "import") setNote({ ok: true, text: weT("已导入并启用:") + (label || "") });
17238
17248
  if (kind === "config") setNote({ ok: true, text: weT("已保存") });
17239
17249
  await load(true);
17240
17250
  } else {
@@ -17345,10 +17355,20 @@ window.__ModuleLoader__.load({
17345
17355
  actions.push(btn(isActive ? weT("使用中") : weT("使用"), () => act("config", { enabled: true, petId: p.dirId }, p.name), isActive || Boolean(busy)));
17346
17356
  actions.push(btn(weT("卸载"), () => act("remove", { id: p.dirId }, p.name), Boolean(busy)));
17347
17357
  return React.createElement("div", { key, style: PET_STYLE.card },
17348
- React.createElement("img", {
17349
- src: p.sheet, alt: "", loading: "lazy", decoding: "async",
17350
- style: { width: "100%", maxHeight: "96px", objectFit: "contain", background: "rgba(0,0,0,0.25)", borderRadius: "6px" },
17351
- onError: (e) => { e.target.style.display = "none"; },
17358
+ // 预览只显示**第一帧**:雪碧图是 8×9 网格,直接贴图会看到一整排小人(用户实测反馈"看着很乱")。
17359
+ // 用 background-size = 列数/行数 × 100% + position 0 0 裁出左上角那一格。
17360
+ React.createElement("div", {
17361
+ style: {
17362
+ width: "100%",
17363
+ aspectRatio: (p.cell ? p.cell.width + " / " + p.cell.height : "192 / 208"),
17364
+ maxHeight: "96px",
17365
+ backgroundImage: "url(" + apiUrl(p.sheet) + ")",
17366
+ backgroundSize: (Number(p.columns) || 8) * 100 + "% " + (Array.isArray(p.frames) ? p.frames.length : 9) * 100 + "%",
17367
+ backgroundPosition: "0 0",
17368
+ backgroundRepeat: "no-repeat",
17369
+ backgroundColor: "rgba(0,0,0,0.25)",
17370
+ borderRadius: "6px",
17371
+ },
17352
17372
  }),
17353
17373
  React.createElement("div", { style: PET_STYLE.title }, p.name),
17354
17374
  React.createElement("div", { style: PET_STYLE.meta },
@@ -17359,6 +17379,26 @@ window.__ModuleLoader__.load({
17359
17379
  : React.createElement("div", { className: "we-picker__hint" }, weT("还没有安装任何桌宠 —— 从下面的站点清单里挑一只。")),
17360
17380
  ));
17361
17381
 
17382
+ // 本机其它宠物插件里可导入的(dsh-pet 等下载的宠物就落在 $DSH_HOME/pets)
17383
+ if (importable.length) {
17384
+ children.push(React.createElement("div", { key: "impHead", className: "we-picker__section-head", style: { marginTop: "14px" } },
17385
+ React.createElement("span", { className: "we-picker__section-label" },
17386
+ weT("本机可导入") + " · " + importable.length),
17387
+ ));
17388
+ children.push(React.createElement("div", { key: "imp", style: PET_STYLE.grid },
17389
+ importable.map((it) => {
17390
+ const isBusy = busy === "import:" + it.id;
17391
+ return React.createElement("div", { key: "imp:" + it.id, style: PET_STYLE.card },
17392
+ React.createElement("div", { style: PET_STYLE.title }, it.name || it.id),
17393
+ React.createElement("div", { style: PET_STYLE.meta },
17394
+ weT("来自其它宠物插件 · ") + petFmtBytes(it.bytes)),
17395
+ React.createElement("div", { style: PET_STYLE.row },
17396
+ btn(isBusy ? weT("导入中…") : weT("导入"), () => act("import", { id: it.id }, it.name || it.id), Boolean(busy))),
17397
+ );
17398
+ }),
17399
+ ));
17400
+ }
17401
+
17362
17402
  // 站点:未装的可安装
17363
17403
  const available = site.filter((s) => !installedIds.has(s.id));
17364
17404
  children.push(React.createElement("div", { key: "siteHead", className: "we-picker__section-head", style: { marginTop: "14px" } },
@@ -17420,6 +17460,25 @@ window.__ModuleLoader__.load({
17420
17460
 
17421
17461
  const PET_LAYER_ID = "we-pet-layer";
17422
17462
  const PET_ROW = { idle: 0, runRight: 1, runLeft: 2, wave: 3, jump: 4, fail: 5, wait: 6, work: 7, done: 8 };
17463
+ /**
17464
+ * 行号 → 轨道名的**候选表**。
17465
+ *
17466
+ * ⚠️ dsh-pet 的 manifest 用的是 kebab 命名(`running-right` / `waving` / `failed` …),
17467
+ * 我最初按驼峰(`runRight` / `wave`)去查,结果**一次都没命中** —— 每帧都退回默认 120ms,
17468
+ * 于是所有动作的节奏都是同一个硬速度,看起来就像"动作不对/没几个动作"。
17469
+ * 这里按「标准名 → 驼峰别名 → 其它常见写法」依次找,命中即用。
17470
+ */
17471
+ const PET_TRACK_ALIASES = [
17472
+ ['idle'],
17473
+ ['running-right', 'runRight', 'right'],
17474
+ ['running-left', 'runLeft', 'left'],
17475
+ ['waving', 'wave'],
17476
+ ['jumping', 'jump'],
17477
+ ['failed', 'fail'],
17478
+ ['waiting', 'wait'],
17479
+ ['running', 'working', 'work'],
17480
+ ['review', 'done', 'completed'],
17481
+ ];
17423
17482
  const PET_DEFAULT_FRAME_MS = 120;
17424
17483
  /**
17425
17484
  * 活动检测:DSH 的客户端 `ctx` 只暴露 effect/get/slots/surface,没有会话事件,
@@ -17437,6 +17496,36 @@ window.__ModuleLoader__.load({
17437
17496
  const PET_ACTIVITY_POLL_MS = 500;
17438
17497
  /** 光标静止后多久切回 idle(毫秒)。 */
17439
17498
  const PET_WAVE_IDLE_MS = 9000;
17499
+ /**
17500
+ * 日常动作调度:没在干活时,每隔 5~11 秒随机演一个。
17501
+ *
17502
+ * 为什么需要它:只播 idle 的宠物看起来是"一张会呼吸的图",不像活物。这里把 manifest 的
17503
+ * 9 条轨道都用上 —— 走一段(左右跑)、挥手、跳一下、发会儿呆 —— 用**随机间隔**而不是固定节拍,
17504
+ * 避免机械感。干活(work/done/fail)优先级最高,调度器在那些状态下不抢行。
17505
+ */
17506
+ const PET_IDLE_ACTIONS = [
17507
+ { row: "wave", weight: 3, ms: 0 },
17508
+ { row: "jump", weight: 2, ms: 0 },
17509
+ { row: "wait", weight: 2, ms: 1800 },
17510
+ { row: "walkRight", weight: 3, ms: 1600 },
17511
+ { row: "walkLeft", weight: 3, ms: 1600 },
17512
+ ];
17513
+ /**
17514
+ * 统一时钟。
17515
+ *
17516
+ * ⚠️ `requestAnimationFrame(fn)` 交给 fn 的是**页面相对时间**(与 performance.now 同源),
17517
+ * 不是 petNow()。两者混用会出现「holdUntil = petNow() + 1400,而 now 只有几千」——
17518
+ * 比较永远不成立,于是宠物切到 done/fail/wave 之后**再也回不到 idle**(实测复现)。
17519
+ * 所有时间比较一律走这一个函数。
17520
+ */
17521
+ function petNow() {
17522
+ try {
17523
+ if (typeof performance !== "undefined" && typeof performance.now === "function") return performance.now();
17524
+ } catch { /* 退回 Date.now */ }
17525
+ return petNow();
17526
+ }
17527
+ /** 两次随机动作之间的间隔范围(毫秒)。 */
17528
+ const PET_IDLE_GAP_MS = [3000, 7000];
17440
17529
 
17441
17530
  /**
17442
17531
  * 声音:包内 `sounds/<事件>.ogg|mp3|wav` 优先(manifest.sounds 显式声明),
@@ -17533,7 +17622,7 @@ window.__ModuleLoader__.load({
17533
17622
  let frame = 0;
17534
17623
  let row = PET_ROW.idle;
17535
17624
  let acc = 0;
17536
- let last = 0;
17625
+ let last = 0; // rAF 时间戳(页面相对时间),不是 Date.now()
17537
17626
  let raf = 0;
17538
17627
  let dead = false;
17539
17628
  let idleSince = 0;
@@ -17544,7 +17633,16 @@ window.__ModuleLoader__.load({
17544
17633
  let activityTimer = null;
17545
17634
  /** 一次性动作(done/fail)播完自动回 idle 的时间戳。 */
17546
17635
  let holdUntil = 0;
17547
-
17636
+ /** 下一次随机动作的时间点;0 = 立即重排。 */
17637
+ let nextActionAt = 0;
17638
+ /** 走动中的速度(px/帧,正=向右)。0 = 不走。 */
17639
+ let walkSpeed = 0;
17640
+ /** 走动剩余时长(毫秒)。 */
17641
+ let walkLeft = 0;
17642
+ /** 单击轮换到第几号轨道。 */
17643
+ let clickIdx = 0;
17644
+
17645
+ let scaleCur = Number(scale) > 0 ? Number(scale) : 1;
17548
17646
  const cw = Math.max(1, Number(pet.cell && pet.cell.width) || 192);
17549
17647
  const ch = Math.max(1, Number(pet.cell && pet.cell.height) || 208);
17550
17648
  const cols = Math.max(1, Number(pet.columns) || 8);
@@ -17554,15 +17652,24 @@ window.__ModuleLoader__.load({
17554
17652
  const n = Number(frames[r]);
17555
17653
  return Number.isFinite(n) && n > 0 ? Math.min(cols, n) : cols;
17556
17654
  }
17655
+ /** 该行的轨道配置(按别名表找;找不到返回 null)。 */
17656
+ function trackOf(r) {
17657
+ const names = PET_TRACK_ALIASES[r] || [];
17658
+ for (const n of names) {
17659
+ const tr = pet.tracks && pet.tracks[n];
17660
+ if (tr && Array.isArray(tr.durations) && tr.durations.length) return tr;
17661
+ }
17662
+ return null;
17663
+ }
17557
17664
  function frameMs(r, f) {
17558
- const t = pet.tracks && pet.tracks[Object.keys(PET_ROW)[r] === undefined ? "" : Object.keys(PET_ROW)[r]];
17559
- const d = t && Array.isArray(t.durations) ? t.durations[f] : null;
17665
+ const tr = trackOf(r);
17666
+ const d = tr ? tr.durations[f] : null;
17560
17667
  return Number.isFinite(Number(d)) && Number(d) > 0 ? Number(d) : PET_DEFAULT_FRAME_MS;
17561
17668
  }
17562
17669
 
17563
17670
  function resize() {
17564
- const w = Math.round(cw * scale);
17565
- const h = Math.round(ch * scale);
17671
+ const w = Math.round(cw * scaleCur);
17672
+ const h = Math.round(ch * scaleCur);
17566
17673
  const dpr = Math.min(3, Math.max(1, (typeof window !== "undefined" && window.devicePixelRatio) || 1));
17567
17674
  canvas.width = Math.round(w * dpr);
17568
17675
  canvas.height = Math.round(h * dpr);
@@ -17581,16 +17688,43 @@ window.__ModuleLoader__.load({
17581
17688
  const sx = frame * cw;
17582
17689
  const sy = row * ch;
17583
17690
  try {
17584
- ctx.clearRect(0, 0, cw * scale, ch * scale);
17585
- ctx.drawImage(img, sx, sy, cw, ch, 0, 0, cw * scale, ch * scale);
17691
+ ctx.clearRect(0, 0, cw * scaleCur, ch * scaleCur);
17692
+ ctx.drawImage(img, sx, sy, cw, ch, 0, 0, cw * scaleCur, ch * scaleCur);
17586
17693
  } catch { /* 贴图还没解码完:下一帧再画 */ }
17587
17694
  }
17588
17695
 
17589
17696
  function tick(now) {
17590
17697
  if (dead) return;
17591
17698
  if (!last) last = now;
17592
- acc += now - last;
17699
+ const dt = Math.max(0, Math.min(100, now - last)); // 切后台回来可能是几百毫秒,钳住
17700
+ acc += dt;
17593
17701
  last = now;
17702
+ // ── 走动:每帧挪一点,走到头或时间到就停下回 idle ──
17703
+ if (walkSpeed !== 0 && walkLeft > 0) {
17704
+ const p = pos();
17705
+ const maxX = Math.max(0, (window.innerWidth || 1920) - layerEl.offsetWidth);
17706
+ const nx = p.left + walkSpeed;
17707
+ if (nx <= 0 || nx >= maxX) {
17708
+ walkSpeed = -walkSpeed;
17709
+ setRow(walkSpeed > 0 ? PET_ROW.runRight : PET_ROW.runLeft);
17710
+ moveTo(Math.max(0, Math.min(maxX, nx)), p.top);
17711
+ } else {
17712
+ moveTo(nx, p.top);
17713
+ }
17714
+ walkLeft -= dt;
17715
+ if (walkLeft <= 0) {
17716
+ walkSpeed = 0;
17717
+ setRow(PET_ROW.idle);
17718
+ nextActionAt = petNow() + PET_IDLE_GAP_MS[0] + Math.random() * (PET_IDLE_GAP_MS[1] - PET_IDLE_GAP_MS[0]);
17719
+ }
17720
+ }
17721
+ // ── 日常动作调度:只在"真的闲着"时才演 ──
17722
+ if (!activity && walkSpeed === 0 && row === PET_ROW.idle && !holdUntil && now >= nextActionAt) {
17723
+ playIdleAction();
17724
+ const gap = PET_IDLE_GAP_MS[0] + Math.random() * (PET_IDLE_GAP_MS[1] - PET_IDLE_GAP_MS[0]);
17725
+ nextActionAt = petNow() + gap;
17726
+ }
17727
+
17594
17728
  // 活动状态变化时先切行(work/done/fail 优先于 idle/wave)。
17595
17729
  if (activityTimer === null && typeof setInterval === "function") {
17596
17730
  activityTimer = setInterval(() => {
@@ -17599,11 +17733,13 @@ window.__ModuleLoader__.load({
17599
17733
  activity = nowActive;
17600
17734
  if (activity) {
17601
17735
  holdUntil = 0;
17736
+ walkSpeed = 0;
17737
+ walkLeft = 0;
17602
17738
  if (row !== PET_ROW.work) setRow(PET_ROW.work);
17603
17739
  } else if (row === PET_ROW.work) {
17604
17740
  // 干完活 → 完成行一小会儿,再回 idle("被看见"的反馈)
17605
17741
  setRow(PET_ROW.done);
17606
- holdUntil = Date.now() + 1600;
17742
+ holdUntil = petNow() + 1600;
17607
17743
  voice.play("done");
17608
17744
  }
17609
17745
  }, PET_ACTIVITY_POLL_MS);
@@ -17623,7 +17759,54 @@ window.__ModuleLoader__.load({
17623
17759
  draw();
17624
17760
  raf = (typeof requestAnimationFrame === "function")
17625
17761
  ? requestAnimationFrame(tick)
17626
- : setTimeout(() => tick(Date.now()), 60);
17762
+ : setTimeout(() => tick(petNow()), 60);
17763
+ }
17764
+
17765
+ /** 当前左上角(拖动/走动都读写这一对值)。 */
17766
+ function pos() {
17767
+ const left = parseFloat(layerEl.style.left);
17768
+ const top = parseFloat(layerEl.style.top);
17769
+ return {
17770
+ left: Number.isFinite(left) ? left : (window.innerWidth - layerEl.offsetWidth - 24),
17771
+ top: Number.isFinite(top) ? top : (window.innerHeight - layerEl.offsetHeight - 16),
17772
+ };
17773
+ }
17774
+ function moveTo(left, top) {
17775
+ const maxX = Math.max(0, (window.innerWidth || 1920) - layerEl.offsetWidth);
17776
+ const maxY = Math.max(0, (window.innerHeight || 1080) - layerEl.offsetHeight);
17777
+ const x = Math.max(0, Math.min(maxX, left));
17778
+ const y = Math.max(0, Math.min(maxY, top));
17779
+ layerEl.style.left = x + "px";
17780
+ layerEl.style.top = y + "px";
17781
+ layerEl.style.right = "auto";
17782
+ layerEl.style.bottom = "auto";
17783
+ }
17784
+
17785
+ /** 从 idle 里挑一个动作演。走动会真的位移,演完自己停。 */
17786
+ function playIdleAction() {
17787
+ const total = PET_IDLE_ACTIONS.reduce((s, a) => s + a.weight, 0);
17788
+ let pick = Math.random() * total;
17789
+ let action = PET_IDLE_ACTIONS[0];
17790
+ for (const a of PET_IDLE_ACTIONS) { if ((pick -= a.weight) <= 0) { action = a; break; } }
17791
+ if (action.row === "walkRight" || action.row === "walkLeft") {
17792
+ const dir = action.row === "walkRight" ? 1 : -1;
17793
+ const p0 = pos();
17794
+ // 走到边上就掉头:撞墙不停在原地踏步,直接换方向。
17795
+ const maxX = Math.max(0, (window.innerWidth || 1920) - layerEl.offsetWidth);
17796
+ if ((dir > 0 && p0.left >= maxX - 2) || (dir < 0 && p0.left <= 2)) {
17797
+ setRow(dir > 0 ? PET_ROW.runLeft : PET_ROW.runRight);
17798
+ walkSpeed = -dir * (0.9 * scaleCur);
17799
+ } else {
17800
+ setRow(dir > 0 ? PET_ROW.runRight : PET_ROW.runLeft);
17801
+ walkSpeed = dir * (0.9 * scaleCur);
17802
+ }
17803
+ walkLeft = action.ms;
17804
+ return;
17805
+ }
17806
+ walkSpeed = 0;
17807
+ walkLeft = 0;
17808
+ setRow(PET_ROW[action.row] === undefined ? PET_ROW.idle : PET_ROW[action.row]);
17809
+ holdUntil = action.ms ? petNow() + action.ms : petNow() + 1400;
17627
17810
  }
17628
17811
 
17629
17812
  function setRow(next) {
@@ -17659,13 +17842,103 @@ window.__ModuleLoader__.load({
17659
17842
  dragging = null;
17660
17843
  if (!moved) {
17661
17844
  // 单击 = 挥手(给用户一个"它有反应"的反馈),之后自动回 idle
17662
- setRow(PET_ROW.wave);
17663
- voice.play("wave");
17845
+ walkSpeed = 0;
17846
+ walkLeft = 0;
17847
+ // 单击 = 演下一个动作(按 9 条轨道轮换),让用户能逐个看到它会什么。
17848
+ clickIdx = (clickIdx + 1) % PET_TRACK_ALIASES.length;
17849
+ const nextRow = clickIdx === 0 ? PET_ROW.idle : clickIdx;
17850
+ setRow(nextRow);
17851
+ holdUntil = petNow() + 1400;
17852
+ voice.play(nextRow === PET_ROW.fail ? "fail" : nextRow === PET_ROW.done ? "done" : "wave");
17664
17853
  } else {
17665
17854
  try { localStorage.setItem("wePetPos", JSON.stringify({ left: layerEl.style.left, top: layerEl.style.top })); } catch { /* ignore */ }
17666
17855
  }
17667
17856
  }
17668
17857
 
17858
+ /* ── 右键菜单:换宠物之外的全部互动都在这里 ────────────────────────────
17859
+ * 为什么自己做:宿主的页签要打开面板才能操作,而"摸它"这件事应该发生在宠物身上。
17860
+ * 菜单条目 = 9 条动作各一条(点一下立刻试演)+ 声音/大小/复位/隐藏。
17861
+ */
17862
+ let menu = null;
17863
+ function closeMenu() {
17864
+ if (menu && menu.parentNode) menu.parentNode.removeChild(menu);
17865
+ menu = null;
17866
+ }
17867
+ function buildMenuItem(label, onClick) {
17868
+ const b = document.createElement("button");
17869
+ b.type = "button";
17870
+ b.textContent = label;
17871
+ b.style.cssText = "display:block;width:100%;padding:6px 10px;background:transparent;border:0;color:inherit;text-align:left;font-size:12px;cursor:pointer;white-space:nowrap";
17872
+ b.addEventListener("mouseenter", () => { b.style.background = "rgba(255,255,255,0.10)"; });
17873
+ b.addEventListener("mouseleave", () => { b.style.background = "transparent"; });
17874
+ b.addEventListener("click", (ev) => { ev.stopPropagation(); onClick(); });
17875
+ return b;
17876
+ }
17877
+ function openMenu(clientX, clientY) {
17878
+ closeMenu();
17879
+ menu = document.createElement("div");
17880
+ menu.style.cssText = [
17881
+ "position:fixed", "z-index:2147482001", "min-width:150px", "padding:6px 0",
17882
+ "border-radius:8px", "font-size:12px", "color:#eee",
17883
+ "background:rgba(28,32,40,0.96)", "border:1px solid rgba(255,255,255,0.16)",
17884
+ "box-shadow:0 10px 30px rgba(0,0,0,0.45)", "backdrop-filter:blur(8px)",
17885
+ ].join(";");
17886
+ const titles = ["闲置", "向右走", "向左走", "挥手", "跳一下", "失败", "发呆", "干活", "完成"];
17887
+ const head = document.createElement("div");
17888
+ head.textContent = "试演动作";
17889
+ head.style.cssText = "padding:4px 10px 6px;opacity:0.55;font-size:11px";
17890
+ menu.appendChild(head);
17891
+ titles.forEach((name, r) => {
17892
+ menu.appendChild(buildMenuItem(name, () => {
17893
+ walkSpeed = 0; walkLeft = 0;
17894
+ setRow(r);
17895
+ holdUntil = petNow() + 1600;
17896
+ voice.play(r === PET_ROW.fail ? "fail" : r === PET_ROW.done ? "done" : "wave");
17897
+ closeMenu();
17898
+ }));
17899
+ });
17900
+ const hr = document.createElement("div");
17901
+ hr.style.cssText = "height:1px;margin:6px 0;background:rgba(255,255,255,0.14)";
17902
+ menu.appendChild(hr);
17903
+ menu.appendChild(buildMenuItem("再大一点", () => { applyScale(scale + 0.2); closeMenu(); }));
17904
+ menu.appendChild(buildMenuItem("再小一点", () => { applyScale(Math.max(0.5, scale - 0.2)); closeMenu(); }));
17905
+ menu.appendChild(buildMenuItem("回右下角", () => {
17906
+ try { localStorage.removeItem("wePetPos"); } catch { /* ignore */ }
17907
+ layerEl.style.left = ""; layerEl.style.top = "";
17908
+ layerEl.style.right = "24px"; layerEl.style.bottom = "16px";
17909
+ closeMenu();
17910
+ }));
17911
+ menu.appendChild(buildMenuItem("不显示它", () => {
17912
+ // 只隐藏本地这一层;真正的开关在面板里(下一次轮询会被尊重)。
17913
+ if (layerEl) layerEl.style.display = "none";
17914
+ closeMenu();
17915
+ }));
17916
+ document.body.appendChild(menu);
17917
+ const w = menu.offsetWidth || 160;
17918
+ const h = menu.offsetHeight || 320;
17919
+ const maxX = (window.innerWidth || 1920) - w - 8;
17920
+ const maxY = (window.innerHeight || 1080) - h - 8;
17921
+ menu.style.left = Math.max(8, Math.min(maxX, clientX)) + "px";
17922
+ menu.style.top = Math.max(8, Math.min(maxY, clientY)) + "px";
17923
+ }
17924
+ function onContextMenu(e) {
17925
+ e.preventDefault();
17926
+ openMenu(e.clientX, e.clientY);
17927
+ }
17928
+ function onDocDown(e) {
17929
+ if (menu && !menu.contains(e.target)) closeMenu();
17930
+ }
17931
+ /** 菜单里的缩放:改本地样式并写回配置(下一次轮询沿用)。 */
17932
+ function applyScale(next) {
17933
+ const v = Math.max(0.5, Math.min(3, Number(next) || 1));
17934
+ try { apiPostJson("/pets/config", { scale: v }, { parse: "always" }); } catch { /* 本地先变 */ }
17935
+ scaleCur = v;
17936
+ resize();
17937
+ draw();
17938
+ }
17939
+
17940
+ canvas.addEventListener("contextmenu", onContextMenu);
17941
+ if (typeof document !== "undefined" && document.addEventListener) document.addEventListener("pointerdown", onDocDown);
17669
17942
  img.onload = () => { ready = true; resize(); draw(); };
17670
17943
  img.onerror = () => { destroy(); };
17671
17944
  img.src = apiUrl(pet.sheet || "/pets/sheet?v=" + encodeURIComponent(pet.dirId || pet.id));
@@ -17675,8 +17948,8 @@ window.__ModuleLoader__.load({
17675
17948
  window.addEventListener("pointermove", onPointerMove);
17676
17949
  window.addEventListener("pointerup", onPointerUp);
17677
17950
  }
17678
- idleSince = Date.now();
17679
- raf = (typeof requestAnimationFrame === "function") ? requestAnimationFrame(tick) : setTimeout(() => tick(Date.now()), 60);
17951
+ idleSince = petNow();
17952
+ raf = (typeof requestAnimationFrame === "function") ? requestAnimationFrame(tick) : setTimeout(() => tick(petNow()), 60);
17680
17953
 
17681
17954
  function destroy() {
17682
17955
  dead = true;
@@ -17685,6 +17958,9 @@ window.__ModuleLoader__.load({
17685
17958
  try { voice.destroy(); } catch { /* ignore */ }
17686
17959
  if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(raf); else clearTimeout(raf);
17687
17960
  canvas.removeEventListener("pointerdown", onPointerDown);
17961
+ canvas.removeEventListener("contextmenu", onContextMenu);
17962
+ if (typeof document !== "undefined" && document.removeEventListener) document.removeEventListener("pointerdown", onDocDown);
17963
+ closeMenu();
17688
17964
  if (typeof window !== "undefined") {
17689
17965
  window.removeEventListener("pointermove", onPointerMove);
17690
17966
  window.removeEventListener("pointerup", onPointerUp);
@@ -17694,9 +17970,11 @@ window.__ModuleLoader__.load({
17694
17970
  return {
17695
17971
  destroy, setRow, resize,
17696
17972
  /** 外部事件注入(面板操作失败等):一次性播放失败行并出声。 */
17697
- fail() { setRow(PET_ROW.fail); holdUntil = Date.now() + 1600; voice.play("fail"); },
17698
- cheer() { setRow(PET_ROW.done); holdUntil = Date.now() + 1600; voice.play("done"); },
17973
+ fail() { setRow(PET_ROW.fail); holdUntil = petNow() + 1600; voice.play("fail"); },
17974
+ cheer() { setRow(PET_ROW.done); holdUntil = petNow() + 1600; voice.play("done"); },
17699
17975
  get row() { return row; },
17976
+ /** 让下一次随机动作尽快发生(首次出现时用)。 */
17977
+ scheduleSoon() { nextActionAt = petNow() + 1200 + Math.random() * 1800; },
17700
17978
  };
17701
17979
  }
17702
17980
 
@@ -17706,16 +17984,24 @@ window.__ModuleLoader__.load({
17706
17984
  * 它自己拉 `/folia/pets/list`(配置 + 已装宠物),所以**不依赖面板是否打开过** ——
17707
17985
  * 冷启动就能出宠物。
17708
17986
  */
17987
+ /** 让刚出现的宠物在 1.2~3 秒后演第一个动作(实例内部按需读 nextActionAt)。 */
17988
+ function nextActionSoon(instance) {
17989
+ try { if (instance && typeof instance.scheduleSoon === "function") instance.scheduleSoon(); } catch { /* ignore */ }
17990
+ }
17991
+
17709
17992
  function mountPetLayer() {
17710
17993
  if (typeof document === "undefined" || !document.body) return null;
17711
17994
  let disposed = false;
17712
17995
  let instance = null;
17713
17996
  let layerEl = null;
17997
+ /** 当前实例对应的「宠物@大小」;变了就要销毁重建。 */
17998
+ let currentKey = "";
17714
17999
 
17715
18000
  function teardownDom() {
17716
18001
  if (instance) { try { instance.destroy(); } catch { /* ignore */ } instance = null; }
17717
18002
  if (layerEl && layerEl.parentNode) layerEl.parentNode.removeChild(layerEl);
17718
18003
  layerEl = null;
18004
+ currentKey = "";
17719
18005
  }
17720
18006
 
17721
18007
  async function sync() {
@@ -17731,7 +18017,12 @@ window.__ModuleLoader__.load({
17731
18017
  if (!cfg || !cfg.enabled || !pet) { teardownDom(); return; }
17732
18018
 
17733
18019
  const scale = Number(cfg.scale) > 0 ? Number(cfg.scale) : 1;
17734
- if (layerEl) { layerEl.style.display = ""; if (instance) instance.resize(); return; }
18020
+ const key = String(pet.dirId || pet.id) + "@" + scale;
18021
+ // 换宠物 / 改大小 ⇒ **必须重建**:旧实例的贴图与几何都是上一只的。
18022
+ // (实测踩过:只在首次创建,于是点「使用」换了宠物而屏幕没变 —— 就是这里。)
18023
+ if (layerEl && currentKey === key) { layerEl.style.display = ""; return; }
18024
+ teardownDom();
18025
+ currentKey = key;
17735
18026
 
17736
18027
  layerEl = document.createElement("div");
17737
18028
  layerEl.id = PET_LAYER_ID;
@@ -17753,6 +18044,8 @@ window.__ModuleLoader__.load({
17753
18044
  layerEl.appendChild(canvas);
17754
18045
  document.body.appendChild(layerEl);
17755
18046
  instance = createPetLayer(layerEl, canvas, pet, scale, cfg);
18047
+ // 首次出现时排一个随机动作,别一上来就傻站着。
18048
+ nextActionSoon(instance);
17756
18049
  }
17757
18050
 
17758
18051
  sync();
@@ -17770,6 +18063,8 @@ window.__ModuleLoader__.load({
17770
18063
  };
17771
18064
  }
17772
18065
 
18066
+ // 仅供单测驱动(构建期内联时会剥掉 export,不影响产物)。
18067
+
17773
18068
  // ── 内联模块:src/quick-panel.js —— 快捷播放面板(官方侧栏 tab 与低版本抽屉共用同一份内容):当前壁纸 + 轮播 + 列表快切 + 声音 + 设置入口。列表虚拟滚动(spacer + 可视窗口)(构建期注入;勿手改本段)──
17774
18069
  /**
17775
18070
  * quick-panel.js — 快捷播放面板(侧边栏的**唯一**内容):当前壁纸 + 轮播 + 三档页签
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { createHash } from 'node:crypto';
23
23
  import { inflateRawSync } from 'node:zlib';
24
- import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
24
+ import { cpSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
25
25
  import { homedir } from 'node:os';
26
26
  import { basename, dirname, extname, join } from 'node:path';
27
27
 
@@ -50,6 +50,35 @@ function siteBase() {
50
50
  /** 站点上的桌宠分类 slug(站点 `/api/public/categories` 里就有这一条)。 */
51
51
  const PET_CATEGORY = 'desktop-pet';
52
52
 
53
+ /**
54
+ * 别的宠物插件把用户下载的宠物放在 `$DSH_HOME/pets/<id>/`(dsh-pet 的公开契约)。
55
+ *
56
+ * 为什么提供"导入"而不是"共用目录":共用会让两边的生命周期纠缠在一起(对方删一只、
57
+ * 我们这边跟着消失)。**复制一份进我们自己的目录**,两边互不影响,代价只是多占几 MB。
58
+ */
59
+ function externalPetsDir() {
60
+ const home = process.env.DSH_HOME || join(process.env.APPDATA || homedir(), 'dsh-desktop', 'harness');
61
+ return join(home, 'pets');
62
+ }
63
+
64
+ /** 本机其它宠物插件目录里、我们还没装的那些(只读扫描,不写对方目录)。 */
65
+ function listImportable() {
66
+ const src = externalPetsDir();
67
+ const out = [];
68
+ let names = [];
69
+ try { names = readdirSync(src); } catch { return out; }
70
+ const mine = new Set(listInstalled().map((p) => p.dirId));
71
+ for (const name of names) {
72
+ if (mine.has(name)) continue;
73
+ const dir = join(src, name);
74
+ try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
75
+ const pet = readPet(dir, name);
76
+ if (!pet) continue;
77
+ out.push({ id: name, name: pet.name, bytes: pet.bytes, columns: pet.columns, cell: pet.cell });
78
+ }
79
+ return out;
80
+ }
81
+
53
82
  function ensureDir(p) {
54
83
  if (!existsSync(p)) mkdirSync(p, { recursive: true });
55
84
  return p;
@@ -355,6 +384,43 @@ export function registerPetsRoutes(webServer, c) {
355
384
  },
356
385
  }));
357
386
 
387
+ // ── GET /pets/importable(本机其它宠物插件里可导入的) ────────────────────
388
+ disposers.push(webServer.register({
389
+ kind: 'exact',
390
+ path: `${BASE}/pets/importable`,
391
+ handler: async (req, res) => {
392
+ let pets = [];
393
+ try { pets = listImportable(); } catch (e) { sendJson(res, 200, { ok: true, pets: [], error: String((e && e.message) || e) }); return; }
394
+ sendJson(res, 200, { ok: true, pets, dir: externalPetsDir() });
395
+ },
396
+ }));
397
+
398
+ // ── POST /pets/import(把本机另一只宠物复制进来) ─────────────────────────
399
+ disposers.push(webServer.register({
400
+ kind: 'exact',
401
+ path: `${BASE}/pets/import`,
402
+ handler: async (req, res) => {
403
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method' }); return; }
404
+ const text = await readBodyText(req);
405
+ let id = '';
406
+ try { id = String((JSON.parse(text || '{}') || {}).id || '').trim(); } catch { id = ''; }
407
+ if (!id || /[\\/]/.test(id) || id.includes('..') || id.length > 120) { sendJson(res, 400, { ok: false, error: 'bad-id' }); return; }
408
+ const src = join(externalPetsDir(), id);
409
+ if (!existsSync(src)) { sendJson(res, 404, { ok: false, error: 'not-found' }); return; }
410
+ if (!readPet(src, id)) { sendJson(res, 422, { ok: false, error: 'invalid-pet' }); return; }
411
+ const dst = join(dataRoot(), id);
412
+ try {
413
+ ensureDir(dirname(dst));
414
+ cpSync(src, dst, { recursive: true, force: true });
415
+ } catch (e) { sendJson(res, 500, { ok: false, error: 'copy-failed', detail: String((e && e.message) || e) }); return; }
416
+ const pet = readPet(dst, id);
417
+ if (!pet) { sendJson(res, 422, { ok: false, error: 'install-invalid' }); return; }
418
+ const cfg = writeConfig({ enabled: true, petId: id });
419
+ if (typeof log === 'function') log('[pets] imported ' + id + ' from ' + externalPetsDir());
420
+ sendJson(res, 200, { ok: true, id, pet, config: cfg });
421
+ },
422
+ }));
423
+
358
424
  // ── POST /pets/config(开关 / 选中 / 缩放) ───────────────────────────────
359
425
  disposers.push(webServer.register({
360
426
  kind: 'exact',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folia",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "description": "Folia 壁纸引擎:把本机 Wallpaper Engine 壁纸搬进 DSH 界面(场景实时渲染 / 视频 / 网页),内置液态玻璃与 Folia 吉祥物,并可从自建站点拉取壁纸。单包形态(组件已合并)。",
5
5
  "type": "module",
6
6
  "engines": {
@@ -192,7 +192,8 @@
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
- "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"
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"
196
197
  },
197
198
  "peerDependencies": {
198
199
  "@deepseek-ai/cordis": "^4.0.4",