@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 +28 -8
- package/assets/awaken/README.md +3 -2
- package/assets/outfits.json +25 -4
- package/main.js +10 -2
- package/package.json +1 -1
- package/renderer/app.js +21 -7
- package/renderer/fx.js +28 -0
- package/scripts/probe-dim.py +41 -0
- package/scripts/qa-awaken-capture.sh +68 -0
- package/scripts/qa-backdrop.ps1 +14 -0
- package/scripts/qa-burst.ps1 +36 -0
- package/scripts/shoot-screen.ps1 +7 -0
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
|
|
63
|
-
180ms
|
|
64
|
-
560ms
|
|
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
|
-
|
|
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
|
|
package/assets/awaken/README.md
CHANGED
|
@@ -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
|
-
|
|
25
|
+
暗幕默认**关**(`assets/outfits.json` 的 `curtain: false`):演出期间窗口背景全透明,墙上不会出现暗色矩形。
|
|
26
|
+
改成 `true` 就回到「黑屏切入」—— 起步全黑、`charge.at` 提到 0.6 的柔暗底,蓄力动作依然看得见。
|
|
26
27
|
|
|
27
28
|
## 怎么加一张
|
|
28
29
|
|
package/assets/outfits.json
CHANGED
|
@@ -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
|
-
{
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 = {
|
|
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
package/renderer/app.js
CHANGED
|
@@ -716,18 +716,28 @@ function applyDemo(anim) {
|
|
|
716
716
|
|
|
717
717
|
/**
|
|
718
718
|
* 一场觉醒演出的时间线(毫秒):
|
|
719
|
-
* 0
|
|
719
|
+
* 0 切入(curtain=true 时是黑屏,默认直接把桌面透出去)
|
|
720
720
|
* 180 窗口放大 + 换上战斗服立绘 + 「镜头推近」
|
|
721
|
-
* revealAt
|
|
722
|
-
* closeAt 暗幕再进 +
|
|
721
|
+
* revealAt 亮相 + 白闪 + 画面抖动 + 主爆发(具体特效由各套觉醒的 build 排)
|
|
722
|
+
* closeAt 暗幕再进 + 窗口收回
|
|
723
723
|
* duration 暗幕退掉,回到站立
|
|
724
724
|
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
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()
|