@ganziliang/desktop-pet 0.1.0 → 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
@@ -24,6 +24,7 @@ desktop-pet
24
24
  ```
25
25
 
26
26
  包内自带 electron 依赖,命令行参数原样透传给主进程(`--snapshot`、`--anim=<walk|run|swim|awaken:seraph>`、`--no-auto`)。
27
+ 首次启动会先下载 Electron 运行时(约 100MB,只在第一次),终端会打印 `Downloading Electron binary...`。
27
28
  只发布运行需要的素材,`assets/source/`(生图原始图,约 39MB)与 `character_green*.png` 不进包。
28
29
 
29
30
 
@@ -59,21 +60,35 @@ desktop-pet
59
60
  和特效事件共用同一条时间轴,所以「动作推到哪一帧」和「特效炸在哪一刻」是对死的:
60
61
 
61
62
  ```
62
- 0ms 黑屏切入(canvas 暗幕,全黑 0.94)
63
- 180ms 窗口放大(藏在黑幕里,看不见窗口在变形)
64
- 560ms 暗幕提到 0.6 —— 角色以「蓄力」序列帧亮相
63
+ 0ms 切入(默认不铺暗幕,桌面直接透出去)
64
+ 180ms 窗口放大(只向上、向两侧长,脚下那条线和水平中心都不动,所以看不到变形)
65
+ 560ms 角色以「蓄力」序列帧亮相
65
66
  蓄力 4 帧 × 360ms:合手聚气 → 抬手 → 上举 → 蓄力顶点
66
67
  同时跑魔法阵、向心汇聚、脚下螺旋
67
68
  2000ms 爆发(revealAt):白闪 + 抖屏 + 冲击波,切到「爆发」序列帧
68
69
  爆发 4 帧 × 300ms:张臂 → 光爆 → 释放 → 收势
69
70
  2200ms 光柱冲天 + 主爆星屑
70
71
  2650ms 技能名落字
71
- 4400ms 暗幕再进 + 窗口收回(同样藏在黑幕里)
72
- 5700ms 暗幕退掉,回到站立
72
+ 4400ms 窗口收回(同样只向上向两侧收,角色不动)
73
+ 5700ms 回到站立
73
74
  ```
74
75
 
75
- 暗幕在 `charge.at` 就从全黑提到 0.6,所以**蓄力动作是真的看得见的** ——
76
- 不是一层黑幕盖满五秒再炸一下。
76
+ **演出期间窗口背景完全透明**:桌面上只看得到角色和光效,不会出现一块暗色矩形。
77
+ 这一点靠 `assets/outfits.json` 的 `curtain` 控制,默认 `false`:
78
+
79
+ ```json
80
+ { "curtain": false }
81
+ ```
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
+
89
+ 要不要铺暗幕,取决于壁纸:加色混合的光效(金光、雷光)在**亮色壁纸**上本来就很难拉开对比,
90
+ 垫一层暗底才亮得起来 —— 想回到老的「黑屏切入」就把 `curtain` 改成 `true`(代价是屏幕上会多一个暗窗)。
91
+ 关掉时光效照跑,只是没有暗底垫着。
77
92
 
78
93
  ### 素材是两步,不是一步
79
94
 
@@ -315,7 +330,7 @@ npx electron . --snapshot
315
330
  # --anim=<walk|run|swim> 先把角色摆到某个动作再截,用来查动作素材
316
331
  npx electron . --snapshot --anim=swim
317
332
  # 觉醒也能截:--anim=awaken:<id>;换 PET_SNAPSHOT_MS 就能逐帧看整场演出
318
- PET_SNAPSHOT_MS=1200 npx electron . --snapshot --anim=awaken:seraph # 蓄力:黑屏 + 魔法阵
333
+ PET_SNAPSHOT_MS=1200 npx electron . --snapshot --anim=awaken:seraph # 蓄力:魔法阵 + 聚气
319
334
  PET_SNAPSHOT_MS=3400 npx electron . --snapshot --anim=awaken:seraph # 亮相 + 技能名
320
335
  PET_SNAPSHOT_MS=6500 npx electron . --snapshot --anim=awaken:seraph # 收招:窗口应该已还原
321
336
  # PET_SNAPSHOT_MS 改截帧时刻。用两个不同值各截一张做差分,就能证明逐帧动画真的在推进:
@@ -323,6 +338,11 @@ PET_SNAPSHOT_MS=6500 npx electron . --snapshot --anim=awaken:seraph # 收招
323
338
  PET_SNAPSHOT_MS=1500 PET_SNAPSHOT_OUT=f1.png npx electron . --snapshot --anim=run
324
339
  PET_SNAPSHOT_MS=1580 PET_SNAPSHOT_OUT=f2.png npx electron . --snapshot --anim=run
325
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
+
326
346
  # 2) 带调试日志启动(写 pet-debug.log)
327
347
  powershell -ExecutionPolicy Bypass -File scripts/debug-run.ps1
328
348
 
@@ -18,11 +18,12 @@
18
18
  ```js
19
19
  sheet: { charge: { at: 560, step: 360 }, burst: { at: 2000, step: 300 } },
20
20
  revealAt: 2000, // = burst.at,爆发那一刻(白闪 + 抖屏)
21
- closeAt: 4400, // 暗幕再进 + 窗口收回
21
+ closeAt: 4400, // 窗口收回(默认铺暗幕;见下)
22
22
  ```
23
23
 
24
24
  蓄力 4 帧 × 360ms(560→1640),爆发 4 帧 × 300ms(2000→2900),之后停在最后一帧收招式。
25
- 暗幕在 `charge.at` 从全黑提到 0.6,所以**蓄力动作是真的看得见的**,不是一层黑幕盖完五秒。
25
+ 暗幕默认**关**(`assets/outfits.json` `curtain: false`):演出期间窗口背景全透明,墙上不会出现暗色矩形。
26
+ 改成 `true` 就回到「黑屏切入」—— 起步全黑、`charge.at` 提到 0.6 的柔暗底,蓄力动作依然看得见。
26
27
 
27
28
  ## 怎么加一张
28
29
 
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "comment": "战斗服装 / 觉醒技能清单。渲染层按 id 找 assets/awaken/<id>.png 作为觉醒立绘;素材缺失时用 idle 帧 + 纯特效兜底,不会崩。",
3
+ "curtain": false,
3
4
  "stage": {
4
5
  "widthRatio": 0.46,
5
6
  "heightRatio": 0.72,
@@ -10,9 +11,29 @@
10
11
  "zoom": 1.5
11
12
  },
12
13
  "list": [
13
- { "id": "seraph", "name": "圣光 · 炽天使", "skill": "圣裁之光", "hint": "金白光柱 / 羽翼 / 圆形符文阵" },
14
- { "id": "nocturne", "name": "暗影 · 血月狂想", "skill": "夜狱降临", "hint": "血月升起 / 蝠影 / 黑雾" },
15
- { "id": "thunder", "name": "雷霆 · 天罚", "skill": "雷神裁决", "hint": "雷云 / 连锁闪电 / 电磁环" },
16
- { "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
+ }
17
38
  ]
18
39
  }
package/main.js CHANGED
@@ -128,12 +128,20 @@ function loadSpriteManifest() {
128
128
  * 而不会因为少个 json 让整个右键菜单空掉。
129
129
  */
130
130
  function loadOutfits() {
131
- const fallback = { stage: {}, list: [{ id: 'seraph', name: '圣光 · 炽天使', skill: '圣裁之光' }] };
131
+ const fallback = {
132
+ stage: {},
133
+ list: [{ id: 'seraph', name: '圣光 · 炽天使', skill: '圣裁之光' }],
134
+ curtain: false,
135
+ };
132
136
  try {
133
137
  const raw = JSON.parse(fs.readFileSync(OUTFITS_FILE, 'utf8'));
134
138
  return {
135
139
  stage: raw.stage || {},
136
140
  list: Array.isArray(raw.list) && raw.list.length ? raw.list : fallback.list,
141
+ // 觉醒暗幕开关。默认关:演出期间窗口背景完全透明,桌面上只有角色和光效,
142
+ // 不会出现一块暗色矩形。显式写 true 才回到旧的「黑屏切入」
143
+ // (亮色壁纸下加色光效更清楚,代价是屏幕上多一个暗窗)。
144
+ curtain: raw.curtain === true,
137
145
  };
138
146
  } catch {
139
147
  return fallback;
@@ -1002,7 +1010,7 @@ function registerIpc() {
1002
1010
  roamGapMs: Number(process.env.PET_ROAM_GAP_MS) || null,
1003
1011
  assets: spriteAssets(),
1004
1012
  // 觉醒清单交给渲染层:菜单里的名字、角色缩放比例、窗口目标规模都从这取
1005
- awaken: { list: outfits.list, stage: outfits.stage },
1013
+ awaken: { list: outfits.list, stage: outfits.stage, curtain: outfits.curtain },
1006
1014
  httpPort: HTTP_PORT,
1007
1015
  }));
1008
1016
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganziliang/desktop-pet",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "桌面动漫角色小控件:透明置顶窗 + 像素级命中测试 + 鼠标交互 + 本地 HTTP 消息接口(Electron 桌宠)",
5
5
  "main": "main.js",
6
6
  "bin": {
package/renderer/app.js CHANGED
@@ -716,18 +716,28 @@ function applyDemo(anim) {
716
716
 
717
717
  /**
718
718
  * 一场觉醒演出的时间线(毫秒):
719
- * 0 黑屏切入(canvas 暗幕)
719
+ * 0 切入(curtain=true 时是黑屏,默认直接把桌面透出去)
720
720
  * 180 窗口放大 + 换上战斗服立绘 + 「镜头推近」
721
- * revealAt 暗幕退 + 白闪 + 画面抖动 + 主爆发(具体特效由各套觉醒的 build 排)
722
- * closeAt 暗幕再进 + 窗口收回(正好盖住窗口变回去的那一帧)
721
+ * revealAt 亮相 + 白闪 + 画面抖动 + 主爆发(具体特效由各套觉醒的 build 排)
722
+ * closeAt 暗幕再进 + 窗口收回
723
723
  * duration 暗幕退掉,回到站立
724
724
  *
725
- * 关键点:窗口尺寸是在「暗幕已经盖满」之后才改的,所以看不到窗口在变形。
726
- * DNF 的觉醒也是这么处理的 —— 黑屏切入从来不只是省钱,它负责把不好看的那一帧藏起来。
725
+ * 窗口尺寸是在「画面已经被盖住」之后才改的。默认 curtain=false,也就是不铺暗幕:
726
+ * 窗口只向上、向两侧长(见 main.js awakenTarget),角色脚下那条线和水平中心都不动,
727
+ * 所以窗口变形这一帧本来就看不出来 —— 没必要为了藏它而多一块暗色矩形。
728
+ * 亮色壁纸下想让金光/雷光更亮,可以在 assets/outfits.json 里把 curtain 改成 true。
727
729
  */
728
730
 
729
731
  let awakenToken = 0; // 每次演出一个令牌;旧演出的挂起回调拿它对不上号就自己失效
730
732
 
733
+ /**
734
+ * 暗幕开关(assets/outfits.json 的 curtain,默认 false = 不铺暗色底)。
735
+ * 真正生效的位置在 fx.js 的 FX.setDimEnabled():那里是唯一的开关,
736
+ * 因为 dim() 的调用点除了下面这条时间线,还有 awaken.js 里每套觉醒亮相时的柔暗底。
737
+ * 漏掉任何一处都会在屏幕上留一块暗色矩形,所以不要在调用点反复判。
738
+ */
739
+ const curtainOn = () => cfg.awaken?.curtain === true;
740
+
731
741
  /** 角色当前的屏幕位置,事件在触发的那一刻现取(窗口刚被放大过) */
732
742
  /**
733
743
  * 觉醒演出的逐帧播放:直接写 backgroundPositionX,不走 steps() 帧动画。
@@ -800,13 +810,15 @@ async function playAwaken(id, { auto = false } = {}) {
800
810
 
801
811
  const token = ++awakenToken;
802
812
  state.autoAwaken = !!auto;
813
+ // 整场演出的暗幕开关一次性定好(含 awaken.js 里各套觉醒自己排的柔暗底)
814
+ FX.setDimEnabled(curtainOn());
803
815
  if (state.roaming) {
804
816
  window.pet.haltRoam();
805
817
  endLoco('awaken');
806
818
  }
807
819
  holdShow();
808
820
 
809
- // 1) 先把屏幕压黑
821
+ // 1) 先把屏幕压黑(curtain=false 时 FX 内部直接忽略)
810
822
  FX.dim(def.dimAlpha, 170, def.palette.stroke);
811
823
 
812
824
  // 2) 暗幕盖满之后再把窗口放大,同时按新窗口高度算「镜头推近」的倍数
@@ -886,7 +898,8 @@ async function playAwaken(id, { auto = false } = {}) {
886
898
  () => {
887
899
  if (token !== awakenToken) return;
888
900
  FX.dim(def.dimAlpha, 240, def.palette.stroke);
889
- // 窗口变回去这一帧同样藏在暗幕后面,所以这里可以放心地提前收
901
+ // 窗口变回去的那一帧本来就不用遮(窗口只向上向两侧收回,角色不动);
902
+ // curtain=true 时这层仍然和以前一样盖住收招
890
903
  setTimeout(() => {
891
904
  if (token !== awakenToken) return;
892
905
  el.stage.classList.remove('awakening', 'flashing', 'shaking');
@@ -1099,6 +1112,7 @@ async function init() {
1099
1112
  cfg = await window.pet.getConfig();
1100
1113
  dbgOn = !!cfg.debug;
1101
1114
  gapOverride = cfg.roamGapMs || null;
1115
+ FX.setDimEnabled(cfg.awaken?.curtain === true); // 暗幕开关交给引擎,见 curtainOn 的注释
1102
1116
  dbg(`init debug=${dbgOn} roamGap=${gapOverride ?? '默认区间'}`);
1103
1117
  state.scale = cfg.scale ?? 1;
1104
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()