@ganziliang/desktop-pet 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -80,6 +80,12 @@ desktop-pet
80
80
  { "curtain": false }
81
81
  ```
82
82
 
83
+ 开关只有**一个**生效点 —— `renderer/fx.js` 的 `FX.setDimEnabled()`:`dim()` 的调用点一半在
84
+ `app.js` 的演出时间线,一半散在 `awaken.js` 每套觉醒亮相时的柔暗底(`fx.dim(0.34, …, c.stroke, true)`),
85
+ 只在调用点各自判会漏 —— 漏掉的那一处就是屏幕上那块暗紫色矩形(`capturePage` 的四角探针也看不见它,
86
+ 因为柔暗底在画面正中浓、到四角已经淡到 0,但**窗口的四条边**上依然是有颜色的)。
87
+ 要回归就往 `scripts/probe-dim.py` 那条命令,看环带的中位 alpha。
88
+
83
89
  要不要铺暗幕,取决于壁纸:加色混合的光效(金光、雷光)在**亮色壁纸**上本来就很难拉开对比,
84
90
  垫一层暗底才亮得起来 —— 想回到老的「黑屏切入」就把 `curtain` 改成 `true`(代价是屏幕上会多一个暗窗)。
85
91
  关掉时光效照跑,只是没有暗底垫着。
@@ -332,6 +338,11 @@ PET_SNAPSHOT_MS=6500 npx electron . --snapshot --anim=awaken:seraph # 收招
332
338
  PET_SNAPSHOT_MS=1500 PET_SNAPSHOT_OUT=f1.png npx electron . --snapshot --anim=run
333
339
  PET_SNAPSHOT_MS=1580 PET_SNAPSHOT_OUT=f2.png npx electron . --snapshot --anim=run
334
340
 
341
+ # 觉醒「不该有暗色矩形」的回归测试:沿画布四边取 18px 环带看 alpha。
342
+ # 注意 --snapshot 的 capturePage 只证明「画布上没画暗色」,不能证明桌面上没有暗雾,
343
+ # 所以真正要看的是脚本里的中位 alpha(整窗覆盖 ≈ 几十,只有粒子 ≈ 0):
344
+ python scripts/probe-dim.py /tmp/dim-probe 2000,2400,3000,3600 seraph nocturne thunder astral
345
+
335
346
  # 2) 带调试日志启动(写 pet-debug.log)
336
347
  powershell -ExecutionPolicy Bypass -File scripts/debug-run.ps1
337
348
 
@@ -11,9 +11,29 @@
11
11
  "zoom": 1.5
12
12
  },
13
13
  "list": [
14
- { "id": "seraph", "name": "圣光 · 炽天使", "skill": "圣裁之光", "hint": "金白光柱 / 羽翼 / 圆形符文阵" },
15
- { "id": "nocturne", "name": "暗影 · 血月狂想", "skill": "夜狱降临", "hint": "血月升起 / 蝠影 / 黑雾" },
16
- { "id": "thunder", "name": "雷霆 · 天罚", "skill": "雷神裁决", "hint": "雷云 / 连锁闪电 / 电磁环" },
17
- { "id": "astral", "name": "幻梦 · 星海", "skill": "星海咏叹", "hint": "星尘漩涡 / 星河光带 / 水晶碎片" }
14
+ {
15
+ "id": "seraph",
16
+ "name": "圣光 · 炽天使",
17
+ "skill": "圣裁之光",
18
+ "hint": "金白光柱 / 羽翼 / 圆形符文阵"
19
+ },
20
+ {
21
+ "id": "nocturne",
22
+ "name": "暗影 · 血月狂想",
23
+ "skill": "夜狱降临",
24
+ "hint": "血月升起 / 蝠影 / 黑雾"
25
+ },
26
+ {
27
+ "id": "thunder",
28
+ "name": "雷霆 · 天罚",
29
+ "skill": "雷神裁决",
30
+ "hint": "雷云 / 连锁闪电 / 电磁环"
31
+ },
32
+ {
33
+ "id": "astral",
34
+ "name": "幻梦 · 星海",
35
+ "skill": "星海咏叹",
36
+ "hint": "星尘漩涡 / 星河光带 / 水晶碎片"
37
+ }
18
38
  ]
19
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganziliang/desktop-pet",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "桌面动漫角色小控件:透明置顶窗 + 像素级命中测试 + 鼠标交互 + 本地 HTTP 消息接口(Electron 桌宠)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/app.js CHANGED
@@ -732,11 +732,11 @@ let awakenToken = 0; // 每次演出一个令牌;旧演出的挂起回调拿
732
732
 
733
733
  /**
734
734
  * 暗幕开关(assets/outfits.json 的 curtain,默认 false = 不铺暗色底)。
735
- * 关掉时所有 dimalpha 都走 0:FX 那套渐变/时间轴一行不用改,
736
- * 只是每帧的 drawDim 直接 return,画布上除角色和光效之外没有别的东西。
735
+ * 真正生效的位置在 fx.js 的 FX.setDimEnabled():那里是唯一的开关,
736
+ * 因为 dim() 的调用点除了下面这条时间线,还有 awaken.js 里每套觉醒亮相时的柔暗底。
737
+ * 漏掉任何一处都会在屏幕上留一块暗色矩形,所以不要在调用点反复判。
737
738
  */
738
739
  const curtainOn = () => cfg.awaken?.curtain === true;
739
- const curtainAlpha = (a) => (curtainOn() ? a : 0);
740
740
 
741
741
  /** 角色当前的屏幕位置,事件在触发的那一刻现取(窗口刚被放大过) */
742
742
  /**
@@ -810,14 +810,16 @@ async function playAwaken(id, { auto = false } = {}) {
810
810
 
811
811
  const token = ++awakenToken;
812
812
  state.autoAwaken = !!auto;
813
+ // 整场演出的暗幕开关一次性定好(含 awaken.js 里各套觉醒自己排的柔暗底)
814
+ FX.setDimEnabled(curtainOn());
813
815
  if (state.roaming) {
814
816
  window.pet.haltRoam();
815
817
  endLoco('awaken');
816
818
  }
817
819
  holdShow();
818
820
 
819
- // 1) 先把屏幕压黑(curtain=false 时是 0,等于什么都不铺)
820
- FX.dim(curtainAlpha(def.dimAlpha), 170, def.palette.stroke);
821
+ // 1) 先把屏幕压黑(curtain=false FX 内部直接忽略)
822
+ FX.dim(def.dimAlpha, 170, def.palette.stroke);
821
823
 
822
824
  // 2) 暗幕盖满之后再把窗口放大,同时按新窗口高度算「镜头推近」的倍数
823
825
  await new Promise((r) => setTimeout(r, 180));
@@ -868,8 +870,7 @@ async function playAwaken(id, { auto = false } = {}) {
868
870
 
869
871
  // 蓄力:暗幕从全黑提到 0.6(soft),让玩家真的能看到蓄力动作在演,
870
872
  // 而不是从头到尾一层黑幕摆完五秒 —— 那多帧素材就白做了。
871
- // curtain=false 时这层不铺:桌面本来就透得出来,角色一直看得见。
872
- if (sheet.charge && curtainOn()) {
873
+ if (sheet.charge) {
873
874
  events.push([sheet.charge.at, () => FX.dim(0.6, 420, def.palette.stroke, true)]);
874
875
  }
875
876
 
@@ -896,9 +897,9 @@ async function playAwaken(id, { auto = false } = {}) {
896
897
  def.closeAt,
897
898
  () => {
898
899
  if (token !== awakenToken) return;
899
- FX.dim(curtainAlpha(def.dimAlpha), 240, def.palette.stroke);
900
- // 窗口变回去的那一帧本来就不用遮(窗口只向上向两侧收回,角色不动),
901
- // 这里留着只是让 curtain=true 的旧路径一样安全
900
+ FX.dim(def.dimAlpha, 240, def.palette.stroke);
901
+ // 窗口变回去的那一帧本来就不用遮(窗口只向上向两侧收回,角色不动);
902
+ // curtain=true 时这层仍然和以前一样盖住收招
902
903
  setTimeout(() => {
903
904
  if (token !== awakenToken) return;
904
905
  el.stage.classList.remove('awakening', 'flashing', 'shaking');
@@ -1111,6 +1112,7 @@ async function init() {
1111
1112
  cfg = await window.pet.getConfig();
1112
1113
  dbgOn = !!cfg.debug;
1113
1114
  gapOverride = cfg.roamGapMs || null;
1115
+ FX.setDimEnabled(cfg.awaken?.curtain === true); // 暗幕开关交给引擎,见 curtainOn 的注释
1114
1116
  dbg(`init debug=${dbgOn} roamGap=${gapOverride ?? '默认区间'}`);
1115
1117
  state.scale = cfg.scale ?? 1;
1116
1118
  applyScale(state.scale);
package/renderer/fx.js CHANGED
@@ -550,6 +550,14 @@ const FX = (() => {
550
550
  let dimColor = '#0a0410';
551
551
  let dimSoft = false;
552
552
 
553
+ // 暗幕总开关。setDimEnabled(false) 之后 dim() 全部变成空操作,
554
+ // 也就是「演出期间窗口背景完全透明,桌面上只看得到角色和光效」。
555
+ //
556
+ // 为什么开关放在引擎里,而不是让每个调用点自己判:dim 的调用点一半在 app.js 的时间线、
557
+ // 一半散在 awaken.js 每套觉醒的 build 里(亮相那一下的柔暗底),
558
+ // 漏掉任何一处就会在屏幕上留下一层「暗色矩形」—— 这正是之前的 bug。
559
+ let dimAllowed = true;
560
+
553
561
  /**
554
562
  * 暗幕 / 全屏闪白。alpha 0 = 全透明,1 = 全黑。
555
563
  * 0→1 用来做「黑屏切入」,1→0 用来做「角色亮相」,alpha 1 + 白色 = 闪白。
@@ -558,6 +566,13 @@ const FX = (() => {
558
566
  * 本来就很难看清楚(白底上加白还是白),垫一层柔和的暗底,金光/雷光才能真的亮起来。
559
567
  */
560
568
  function dim(alpha, ms, color, soft) {
569
+ if (!dimAllowed) {
570
+ // 关掉时顺手把残留清干净,避免开关切换的瞬间卡在半透明
571
+ dimNow = 0;
572
+ dimTo = 0;
573
+ dimSoft = false;
574
+ return;
575
+ }
561
576
  if (color) dimColor = color;
562
577
  if (soft !== undefined) dimSoft = !!soft;
563
578
  const target = Math.max(0, Math.min(1, alpha));
@@ -1023,6 +1038,19 @@ const FX = (() => {
1023
1038
  play,
1024
1039
  stop,
1025
1040
  reset,
1041
+ /** 暗幕总开关:false = 演出全程不铺任何暗色底(默认关,见 app.js 的 curtainOn) */
1042
+ setDimEnabled(on) {
1043
+ dimAllowed = !!on;
1044
+ if (!dimAllowed) {
1045
+ dimNow = 0;
1046
+ dimTo = 0;
1047
+ dimSoft = false;
1048
+ drawDim(); // dimNow 已归零 → 内部直接 return,等效于不再画
1049
+ }
1050
+ },
1051
+ get dimEnabled() {
1052
+ return dimAllowed;
1053
+ },
1026
1054
  get busy() {
1027
1055
  return (
1028
1056
  !!script || emitters.length > 0 || geo.length > 0 || dimNow !== dimTo || aliveParticles() > 0
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env python
2
+ # QA:用 capturePage 逐帧探「整窗覆盖物」——沿画布四边 18px 环带取 alpha
3
+ # 用法: probe-dim.py <输出目录> <a,b,c... 时刻ms> <anim...>
4
+ import os, subprocess, sys, glob
5
+ from PIL import Image
6
+
7
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
8
+ outdir, stamps, anims = sys.argv[1], [int(v) for v in sys.argv[2].split(',')], sys.argv[3:]
9
+ os.makedirs(outdir, exist_ok=True)
10
+
11
+ def ring_stats(path):
12
+ im = Image.open(path).convert('RGBA')
13
+ W, H = im.size
14
+ px = im.load()
15
+ band = 18
16
+ alphas = []
17
+ for x in range(0, W, 3):
18
+ for y in list(range(0, band)) + list(range(H - band, H)):
19
+ alphas.append(px[x, y][3])
20
+ for y in range(0, H, 3):
21
+ for x in list(range(0, band)) + list(range(W - band, W)):
22
+ alphas.append(px[x, y][3])
23
+ alphas.sort()
24
+ return W, H, max(alphas), alphas[len(alphas) // 2], sum(alphas) / len(alphas)
25
+
26
+ print(f"{'套装':<10}{'时刻ms':<8}{'画布':<12}{'环带max α':<11}{'环带中位 α':<11}{'环带均值 α'}")
27
+ bad = []
28
+ for anim in anims:
29
+ for ms in stamps:
30
+ dst = os.path.join(outdir, f'{anim}-{ms}.png')
31
+ env = dict(os.environ, PET_SNAPSHOT_OUT=dst, PET_SNAPSHOT_MS=str(ms))
32
+ subprocess.run(['node', 'bin/desktop-pet.js', '--snapshot', f'--anim=awaken:{anim}'],
33
+ cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
34
+ W, H, mx, med, avg = ring_stats(dst)
35
+ flag = ''
36
+ if mx > 12:
37
+ flag = ' ← 整窗有覆盖物!'
38
+ bad.append((anim, ms, mx))
39
+ print(f"{anim:<10}{ms:<8}{f'{W}x{H}':<12}{mx:<11}{med:<11}{avg:<8.1f}{flag}")
40
+ print()
41
+ print("结论:", "环带上还有非零 alpha 的帧:" + str(bad) if bad else "全部帧环带 alpha 均为 0 —— 没有任何整窗覆盖物")
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ # QA:在「已确认是亮色」的背景板上,对同一段觉醒演出连拍整屏。
3
+ # 用法: qa-awaken-capture.sh <输出目录> [curtain:false|true]
4
+ set -u
5
+ # 仓库根 = 本脚本所在目录的上一级,不写死绝对路径
6
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -W 2>/dev/null || cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
7
+ OUT="$1"; CURTAIN="${2:-false}"; ANIM="${3:-awaken:seraph}"
8
+ mkdir -p "$OUT"
9
+
10
+ kill_all() {
11
+ powershell -NoProfile -Command "Get-Process electron -ErrorAction SilentlyContinue | Stop-Process -Force" >/dev/null 2>&1
12
+ powershell -NoProfile -Command "Get-Process powershell -ErrorAction SilentlyContinue | Where-Object { \$_.MainWindowTitle -eq '' } | Stop-Process -Force" >/dev/null 2>&1
13
+ sleep 1
14
+ }
15
+
16
+ # 1) 背景板,等到「照片确实是亮的」再继续
17
+ kill_all
18
+ powershell -NoProfile -ExecutionPolicy Bypass -File "$ROOT/scripts/qa-backdrop.ps1" \
19
+ "$(cygpath -w $ROOT/outputs/qa-backdrop.png)" >/dev/null 2>&1 &
20
+ for i in $(seq 1 25); do
21
+ sleep 1
22
+ powershell -NoProfile -ExecutionPolicy Bypass -File "$ROOT/scripts/shoot-screen.ps1" "$(cygpath -w $OUT/warmup.png)" >/dev/null 2>&1
23
+ bright=$(python - "$OUT/warmup.png" <<'PY'
24
+ import sys
25
+ from PIL import Image
26
+ im = Image.open(sys.argv[1]).convert('L').resize((32,18))
27
+ px = sorted(im.getdata()); print(px[len(px)//2])
28
+ PY
29
+ )
30
+ echo "背景板预热 ${i}s 中位亮度=$bright"
31
+ [ "$bright" -gt 200 ] && break
32
+ done
33
+
34
+ # 2) 开桌宠(curtain 由调用方先改好 outfits.json)
35
+ cd "$ROOT"
36
+ PET_DEBUG=1 nohup node bin/desktop-pet.js --no-auto > "$OUT/pet.out" 2>&1 &
37
+ ok=0
38
+ for i in $(seq 1 25); do
39
+ sleep 1
40
+ if curl -s --max-time 2 http://127.0.0.1:8520/health > "$OUT/health.json"; then ok=1; break; fi
41
+ done
42
+ if [ "$ok" != 1 ]; then
43
+ echo "!! /health 起不来,pet.out:"; cat "$OUT/pet.out"; tail -3 "$OUT/pet-debug.log" 2>/dev/null; exit 1
44
+ fi
45
+ echo "pet 就绪(${i}s)"
46
+ grep -o '"bounds":{[^}]*}' "$OUT/health.json" || true
47
+
48
+ # 3) 连拍整屏,中途触发一次觉醒
49
+ powershell -NoProfile -ExecutionPolicy Bypass -File "$ROOT/scripts/qa-burst.ps1" \
50
+ "$(cygpath -w $OUT)" 6.5 2.0 "$ANIM"
51
+
52
+ # 门禁:base 必须是亮的,否则背景板没真的在最上层,这轮数据不能用
53
+ base_med=$(python - "$OUT/base.png" <<'PY2'
54
+ import sys
55
+ from PIL import Image
56
+ im = Image.open(sys.argv[1]).convert('L').resize((60,34))
57
+ px = sorted(im.getdata()); print(px[len(px)//2])
58
+ PY2
59
+ )
60
+ echo "base.png 全屏中位亮度=$base_med"
61
+ if [ "$base_med" -lt 180 ]; then
62
+ echo "!! 背景板被盖住了(base 偏暗),这轮作废"; exit 2
63
+ fi
64
+
65
+ cp "$ROOT/pet-debug.log" "$OUT/pet-debug.log" 2>/dev/null
66
+ grep -o 'awaken-open {[^}]*}' "$OUT/pet-debug.log" | tail -1 > "$OUT/awaken-rect.txt"
67
+ echo "curtain=$CURTAIN → $OUT ($(ls "$OUT"/t*.png | wc -l) 帧) 觉醒矩形: $(cat "$OUT/awaken-rect.txt")"
68
+ kill_all
@@ -0,0 +1,14 @@
1
+ Add-Type -AssemblyName System.Windows.Forms, System.Drawing
2
+ $img = [System.Drawing.Image]::FromFile($args[0])
3
+ $f = New-Object System.Windows.Forms.Form
4
+ $f.FormBorderStyle = 'None'
5
+ $f.StartPosition = 'Manual'
6
+ $f.Location = New-Object System.Drawing.Point(0, 0)
7
+ $f.Size = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Size
8
+ $f.BackColor = [System.Drawing.Color]::White
9
+ $f.BackgroundImage = $img
10
+ $f.BackgroundImageLayout = 'Stretch'
11
+ $f.ShowInTaskbar = $false
12
+ $f.TopMost = $true
13
+ $f.Add_Shown({ $f.Activate() })
14
+ [System.Windows.Forms.Application]::Run($f)
@@ -0,0 +1,36 @@
1
+ # QA: capture the whole screen on a fixed cadence and fire one awaken mid-way.
2
+ # ASCII only on purpose: PowerShell 5.1 reads .ps1 as ANSI, CJK comments can break parsing.
3
+ Add-Type -AssemblyName System.Windows.Forms, System.Drawing
4
+
5
+ $outDir = $args[0]
6
+ $seconds = [double]$args[1]
7
+ $delay = [double]$args[2] # wait before firing the awaken
8
+ $anim = $args[3]
9
+
10
+ $screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
11
+ $sw = [System.Diagnostics.Stopwatch]::StartNew()
12
+
13
+ function Shoot([string]$name) {
14
+ $bmp = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height)
15
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
16
+ $g.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size)
17
+ $bmp.Save((Join-Path $outDir $name), [System.Drawing.Imaging.ImageFormat]::Png)
18
+ $g.Dispose(); $bmp.Dispose()
19
+ }
20
+
21
+ Start-Sleep -Seconds $delay
22
+ Shoot 'base.png'
23
+ $t0 = $sw.Elapsed.TotalMilliseconds
24
+
25
+ try {
26
+ Invoke-RestMethod -Method Post -Uri 'http://127.0.0.1:8520/action' `
27
+ -ContentType 'application/json' -Body ("{`"name`":`"$anim`"}") | Out-Null
28
+ } catch { Write-Output "trigger failed: $_" }
29
+
30
+ $i = 0
31
+ while ($sw.Elapsed.TotalSeconds -lt ($delay + $seconds)) {
32
+ $ms = [int]($sw.Elapsed.TotalMilliseconds - $t0)
33
+ Shoot ('t{0:d5}.png' -f $ms)
34
+ $i++
35
+ }
36
+ Write-Output "shots=$i dur=$([int]($sw.Elapsed.TotalSeconds - $delay))s"
@@ -0,0 +1,7 @@
1
+ Add-Type -AssemblyName System.Windows.Forms, System.Drawing
2
+ $b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
3
+ $bmp = New-Object System.Drawing.Bitmap($b.Width, $b.Height)
4
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
5
+ $g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size)
6
+ $bmp.Save($args[0], [System.Drawing.Imaging.ImageFormat]::Png)
7
+ $g.Dispose(); $bmp.Dispose()