@ganziliang/desktop-pet 0.1.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/main.js ADDED
@@ -0,0 +1,1143 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 桌面动漫角色小控件 —— 主进程
5
+ *
6
+ * 能力:
7
+ * - 透明 / 无边框 / 置顶 / 不进任务栏的窗口
8
+ * - 精确命中测试:透明像素处自动鼠标穿透(renderer 算好再通知主进程)
9
+ * - 托盘 + 右键原生菜单(穿透、置顶、缩放、开机自启、退出)
10
+ * - 本地 HTTP 接口,供 pi 扩展 / 其它程序推送消息给角色
11
+ */
12
+
13
+ const {
14
+ app,
15
+ BrowserWindow,
16
+ ipcMain,
17
+ Menu,
18
+ Tray,
19
+ nativeImage,
20
+ screen,
21
+ globalShortcut,
22
+ } = require('electron');
23
+ const path = require('node:path');
24
+ const fs = require('node:fs');
25
+ const http = require('node:http');
26
+
27
+ const BASE_W = 420;
28
+ const BASE_H = 420;
29
+ const HTTP_PORT = 8520;
30
+ const ASSETS = path.join(__dirname, 'assets');
31
+ // 角色在屏幕上的标准特征尺寸(scale=1):站姿是身高,游泳这种横躺姿势是身长
32
+ const BASE_FIGURE_H = 378;
33
+ // 精灵图元信息清单(scripts/spritesheet.py 产出),帧数/帧尺寸/参考尺寸都在里面
34
+ const SPRITE_MANIFEST = path.join(ASSETS, 'sprites.json');
35
+ // 战斗服装 / 觉醒技能清单(觉醒立绘放在 assets/awaken/<id>.png)
36
+ const OUTFITS_FILE = path.join(ASSETS, 'outfits.json');
37
+ // 清单缺失时的兜底(只针对没跑过 spritesheet.py 的原始图)
38
+ const SHEET_FALLBACK = {
39
+ walk: { frames: 4, refPx: 620 },
40
+ run: { frames: 4, refPx: 620 },
41
+ swim: { frames: 6, refPx: 420 },
42
+ };
43
+
44
+ const DEBUG = !!process.env.PET_DEBUG;
45
+ const debugFile = path.join(__dirname, 'pet-debug.log');
46
+ if (DEBUG) fs.writeFileSync(debugFile, '');
47
+
48
+ function dbg(...parts) {
49
+ if (!DEBUG) return;
50
+ // 同步写,避免流缓冲导致看不到日志
51
+ try {
52
+ fs.appendFileSync(debugFile, `${new Date().toISOString().slice(11, 23)} ${parts.join(' ')}\n`);
53
+ } catch {
54
+ /* 忽略 */
55
+ }
56
+ }
57
+
58
+ let win = null;
59
+ let tray = null;
60
+ let httpServer = null;
61
+ let ignoreMouse = false;
62
+ let saveTimer = null;
63
+ let outfits = { stage: {}, list: [] };
64
+ let manifest = {};
65
+
66
+ const state = {
67
+ x: null,
68
+ y: null,
69
+ scale: 1,
70
+ clickThrough: false,
71
+ alwaysOnTop: true,
72
+ openAtLogin: false,
73
+ autoRoam: true, // 自己决定什么时候走动 / 跑步 / 游泳
74
+ gravity: false, // 拖完松手后是否落回屏幕底部;默认关,拖到哪就在哪
75
+ };
76
+
77
+ /* ------------------------------------------------------------------ 持久化 */
78
+
79
+ const stateFile = () => path.join(app.getPath('userData'), 'pet-state.json');
80
+
81
+ function loadState() {
82
+ try {
83
+ Object.assign(state, JSON.parse(fs.readFileSync(stateFile(), 'utf8')));
84
+ } catch {
85
+ /* 首次运行,用默认值 */
86
+ }
87
+ }
88
+
89
+ function saveState() {
90
+ try {
91
+ fs.mkdirSync(path.dirname(stateFile()), { recursive: true });
92
+ fs.writeFileSync(stateFile(), JSON.stringify(state, null, 2));
93
+ } catch {
94
+ /* 忽略写失败 */
95
+ }
96
+ }
97
+
98
+ /* -------------------------------------------------------------------- 窗口 */
99
+
100
+ function assetIfExists(file) {
101
+ const p = path.join(ASSETS, file);
102
+ return fs.existsSync(p) ? p : null;
103
+ }
104
+
105
+ /**
106
+ * 组装一张精灵图的元信息。figH 是「角色在这张图里有多高」,
107
+ * 渲染层用例它把角色换算到统一的屏幕身高,换素材时不会忽大忽小。
108
+ * 归一化过的图(spritesheet.py 产出,固定 SHEET_FIGURE_H)优先;
109
+ * 只有用户自己新丢进来的原图时,就退回用图片自身高度。
110
+ */
111
+ function loadSpriteManifest() {
112
+ // spritesheet.py 的 --manifest 默认是「输出目录/sprites.json」。
113
+ // 给觉醒素材跑管线时输出目录是 assets/awaken/,忘了带 --manifest 就会写到这里,
114
+ // 而本进程只读 assets/sprites.json —— 现象是「素材文件明明在,游戏里却没换衣服」,
115
+ // 而且是静默失败。这里主动报一下,别让人查半天。
116
+ if (DEBUG && fs.existsSync(path.join(ASSETS, 'awaken', 'sprites.json'))) {
117
+ dbg('WARN assets/awaken/sprites.json 存在 —— 跑 spritesheet.py 时漏了 --manifest assets/sprites.json');
118
+ }
119
+ try {
120
+ return JSON.parse(fs.readFileSync(SPRITE_MANIFEST, 'utf8'));
121
+ } catch {
122
+ return {};
123
+ }
124
+ }
125
+
126
+ /**
127
+ * 读服装 / 觉醒清单。读不到就用一份兜底,保证菜单永远有东西,
128
+ * 而不会因为少个 json 让整个右键菜单空掉。
129
+ */
130
+ function loadOutfits() {
131
+ const fallback = { stage: {}, list: [{ id: 'seraph', name: '圣光 · 炽天使', skill: '圣裁之光' }] };
132
+ try {
133
+ const raw = JSON.parse(fs.readFileSync(OUTFITS_FILE, 'utf8'));
134
+ return {
135
+ stage: raw.stage || {},
136
+ list: Array.isArray(raw.list) && raw.list.length ? raw.list : fallback.list,
137
+ };
138
+ } catch {
139
+ return fallback;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * 组装一张精灵图的元信息。
145
+ * refPx 是「角色在这张图里有多大」(竖构图=身高,横构图=身长),
146
+ * 渲染层用它把角色换算到统一的屏幕尺寸,所以换素材/加动作不会忽大忽小。
147
+ * 数据优先从 sprites.json 读;只有用户自己新丢进来的原图才走兜底值。
148
+ */
149
+ function sheetConf(key, file, fallback = {}) {
150
+ const p = assetIfExists(file);
151
+ if (!p) return null;
152
+ const size = nativeImage.createFromPath(p).getSize();
153
+ if (!size.width || !size.height) return null;
154
+
155
+ const m = manifest[key] || {};
156
+ const frames = Math.max(1, m.frames || fallback.frames || 1);
157
+ return {
158
+ path: p,
159
+ frames,
160
+ // fallback.refPx <= 0 表示「把整张图当成角色」(未归一化的原图)
161
+ refPx: m.refPx || fallback.refPx || size.height,
162
+ frameW: m.frameW || Math.round(size.width / frames),
163
+ frameH: m.frameH || size.height,
164
+ axis: m.axis || 'h',
165
+ padY: m.padY ?? 0,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * 觉醒素材的三个阶段。
171
+ * 蓄力和爆发各是一张横向序列图(走 spritesheet.py 管线,和 walk/run 一样);
172
+ * still 是旧的单帧立绘,保留兼容。
173
+ */
174
+ const AWAKEN_PHASES = ['charge', 'burst', 'still'];
175
+
176
+ function spriteAssets() {
177
+ // 觉醒素材:一个服装一套,按阶段找文件。
178
+ // 蓄力:assets/awaken/<id>-charge.png(4 帧,清单 key awaken-<id>-charge)
179
+ // 爆发:assets/awaken/<id>-burst.png (4 帧,清单 key awaken-<id>-burst)
180
+ // 旧版单帧立绘:assets/awaken/<id>.png(清单 key awaken-<id>)
181
+ // 缺哪一张就少哪个 key,渲染层自己退回 idle 帧 —— 缺素材不会崩,只是不换衣服。
182
+ const awakenArt = {};
183
+ for (const o of outfits.list) {
184
+ const phases = {};
185
+ for (const phase of AWAKEN_PHASES) {
186
+ const file =
187
+ phase === 'still' ? path.join('awaken', `${o.id}.png`) : path.join('awaken', `${o.id}-${phase}.png`);
188
+ const p = assetIfExists(file);
189
+ if (!p) continue;
190
+ const size = nativeImage.createFromPath(p).getSize();
191
+ if (!size.width || !size.height) continue;
192
+
193
+ // refPx / frames / padY 都从 sprites.json 读;渲染层用和 idle 完全相同的公式
194
+ // 换算显示尺寸,所以换装不会忽大忽小。
195
+ const m = manifest[`awaken-${o.id}-${phase}`] || manifest[`awaken-${o.id}`] || {};
196
+ const frames = Math.max(1, m.frames || 1);
197
+ phases[phase] = {
198
+ path: p,
199
+ frames,
200
+ refPx: m.refPx || size.height,
201
+ frameW: m.frameW || Math.round(size.width / frames),
202
+ frameH: m.frameH || size.height,
203
+ padY: m.padY ?? 0,
204
+ };
205
+ }
206
+ if (Object.keys(phases).length) awakenArt[o.id] = phases;
207
+ }
208
+
209
+ return {
210
+ idle: sheetConf('idle', 'pet-idle.png') || sheetConf('idle', 'pet-base.png', { refPx: 0 }),
211
+ blink: sheetConf('idle-blink', 'pet-idle-blink.png') || sheetConf('blink', 'pet-blink.png', { refPx: 0 }),
212
+ sheets: {
213
+ walk: sheetConf('walk', 'pet-walk.png', SHEET_FALLBACK.walk),
214
+ run: sheetConf('run', 'pet-run.png', SHEET_FALLBACK.run),
215
+ swim: sheetConf('swim', 'pet-swim.png', SHEET_FALLBACK.swim),
216
+ },
217
+ awaken: awakenArt,
218
+ figureHeight: BASE_FIGURE_H,
219
+ };
220
+ }
221
+
222
+ function createWindow() {
223
+ manifest = loadSpriteManifest();
224
+ outfits = loadOutfits();
225
+ const display = screen.getPrimaryDisplay().workAreaSize;
226
+ const w = Math.round(BASE_W * state.scale);
227
+ const h = Math.round(BASE_H * state.scale);
228
+
229
+ win = new BrowserWindow({
230
+ width: w,
231
+ height: h,
232
+ x: state.x ?? Math.round(display.width - w - 60),
233
+ y: state.y ?? Math.round(display.height - h - 20),
234
+ transparent: true,
235
+ backgroundColor: '#00000000',
236
+ frame: false,
237
+ resizable: false,
238
+ maximizable: false,
239
+ minimizable: false,
240
+ fullscreenable: false,
241
+ skipTaskbar: true,
242
+ hasShadow: false,
243
+ alwaysOnTop: state.alwaysOnTop,
244
+ title: '桌面小控件',
245
+ webPreferences: {
246
+ preload: path.join(__dirname, 'preload.js'),
247
+ contextIsolation: true,
248
+ nodeIntegration: false,
249
+ backgroundThrottling: false,
250
+ },
251
+ });
252
+
253
+ win.setAlwaysOnTop(state.alwaysOnTop, 'screen-saver');
254
+ win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
255
+ win.setIgnoreMouseEvents(false);
256
+ const hwnd =
257
+ process.platform === 'win32' ? win.getNativeWindowHandle().readBigUInt64LE(0).toString() : 'n/a';
258
+ const b = win.getBounds();
259
+ dbg('window-created', `hwnd=${hwnd}`, `bounds=${b.x},${b.y},${b.width}x${b.height}`);
260
+
261
+ win.on('resize', () => {
262
+ if (!win) return;
263
+ const b = win.getBounds();
264
+ const cb = win.getContentBounds();
265
+ dbg(
266
+ 'RESIZE',
267
+ `bounds=${b.x},${b.y},${b.width}x${b.height}`,
268
+ `content=${cb.width}x${cb.height}`,
269
+ `zoom=${win.webContents.getZoomFactor().toFixed(3)}`,
270
+ `scaleFactor=${screen.getDisplayMatching(b).scaleFactor}`,
271
+ `resizable=${win.isResizable()}`,
272
+ );
273
+ // 尺寸漂移了就钉回去(DIP 变化/混合 DPI 拖动都会触发)
274
+ pinSize();
275
+ });
276
+
277
+ win.on('moved', () => {
278
+ if (!win) return;
279
+ // 巡游中的位置不值得记:重启后回到默认位置就好,否则每秒要写好几次盘
280
+ if (roamPlan) return;
281
+ // 觉醒期间窗口是被临时放大的,记下来下次启动就会「大一号」开机
282
+ if (awakenRect) return;
283
+ const [x, y] = win.getPosition();
284
+ state.x = x;
285
+ state.y = y;
286
+ dbg('moved', `${x},${y}`);
287
+ clearTimeout(saveTimer);
288
+ saveTimer = setTimeout(saveState, 400);
289
+ });
290
+
291
+ win.on('move', () => dbg('move', win ? win.getPosition().join(',') : '-'));
292
+
293
+ win.on('closed', () => {
294
+ awakenRect = null;
295
+ awakenPrev = null;
296
+ win = null;
297
+ });
298
+ }
299
+
300
+ /**
301
+ * 把窗口拉回工作区内。
302
+ * 有必要是因为位置是持久化的:上次退出时如果角色被放在屏幕外(或者那块屏后来拔了),
303
+ * 下次启动它就是「半截在屏幕外」甚至完全看不见。
304
+ */
305
+ function clampIntoWorkArea() {
306
+ if (!win) return false;
307
+ if (awakenRect) return false; // 演出中不要动窗口
308
+ const b = win.getBounds();
309
+ const wa = screen.getDisplayMatching(b).workArea;
310
+ const { width, height } = targetSize();
311
+ const x = Math.min(Math.max(b.x, wa.x), Math.max(wa.x, wa.x + wa.width - width));
312
+ const y = Math.min(Math.max(b.y, wa.y), Math.max(wa.y, wa.y + wa.height - height));
313
+ if (x === b.x && y === b.y) return false;
314
+ dbg('clamp', `${b.x},${b.y} -> ${x},${y}`);
315
+ win.setBounds({ x, y, width, height });
316
+ return true;
317
+ }
318
+
319
+ function applyClickThrough(on) {
320
+ state.clickThrough = on;
321
+ saveState();
322
+ if (!win) return;
323
+ // forward: true -> 忽略鼠标事件时仍把 mousemove 转发给渲染进程,才能再切回来
324
+ win.setIgnoreMouseEvents(on, { forward: true });
325
+ if (!on) ignoreMouse = false;
326
+ }
327
+
328
+ /**
329
+ * 穿透状态下不能依赖 mouseover/mousemove 来“醒过来”:光标停着不动就永远醒不了,
330
+ * 按下的那一刻还在穿透 -> 点击直接穿到桌面(就是拖不动的根因)。
331
+ * 所以由主进程按 220ms 轮询系统光标位置,交给渲染层做像素命中测试。
332
+ */
333
+ function startHitPoll() {
334
+ setInterval(() => {
335
+ if (!win || !ignoreMouse || state.clickThrough) return;
336
+ const p = screen.getCursorScreenPoint();
337
+ const b = win.getBounds();
338
+ win.webContents.send('pet:poll-hit', { x: p.x - b.x, y: p.y - b.y });
339
+ }, 220);
340
+ }
341
+
342
+ /** 窗口的目标尺寸(DIP);窗口大小只由 state.scale 决定 */
343
+ function targetSize(scale = state.scale) {
344
+ return { width: Math.round(BASE_W * scale), height: Math.round(BASE_H * scale) };
345
+ }
346
+
347
+ /* ------------------------------------------------ 觉醒演出的临时放大窗口 */
348
+
349
+ // 觉醒期间窗口的临时目标 bounds;非空即表示「正在演出」
350
+ let awakenRect = null;
351
+ let awakenPrev = null;
352
+
353
+ /**
354
+ * 算出觉醒演出要用多大的窗口。
355
+ * 默认是工作区的 46% x 72%(可在 assets/outfits.json 的 stage 里改),
356
+ * 不做全屏:一是全屏窗会盖住整个桌面太霸道,二是用户明确说了要「小窗口 / 屏幕的百分之几」。
357
+ *
358
+ * 关键:只向上和向两侧长,保住「角色脚下那条线」和水平中心,
359
+ * 这样窗口变大时角色在屏幕上的位置几乎不动 —— 配合暗幕就完全看不出窗口在变。
360
+ */
361
+ function awakenTarget() {
362
+ if (!win) return null;
363
+ const s = outfits.stage || {};
364
+ const b = win.getBounds();
365
+ const wa = screen.getDisplayMatching(b).workArea;
366
+
367
+ const w = Math.round(
368
+ Math.max(s.minWidth ?? 560, Math.min(s.maxWidth ?? 1280, wa.width * (s.widthRatio ?? 0.46))),
369
+ );
370
+ const h = Math.round(
371
+ Math.max(s.minHeight ?? 520, Math.min(s.maxHeight ?? 900, wa.height * (s.heightRatio ?? 0.72))),
372
+ );
373
+
374
+ const cx = b.x + b.width / 2;
375
+ const bottom = b.y + b.height;
376
+ const x = Math.max(wa.x, Math.min(Math.round(cx - w / 2), wa.x + wa.width - w));
377
+ const y = Math.max(wa.y, Math.min(Math.round(bottom - h), wa.y + wa.height - h));
378
+ return { x, y, width: w, height: h };
379
+ }
380
+
381
+ /** 开 / 关觉醒演出窗口,返回生效的 bounds(渲染层用它算角色缩放) */
382
+ function awakenWindow(on) {
383
+ if (!win) return null;
384
+ if (on) {
385
+ if (awakenRect) return awakenRect;
386
+ haltRoam('awaken'); // 巡游引擎也在 setBounds,不先停掉两边会打架
387
+ awakenPrev = win.getBounds();
388
+ awakenRect = awakenTarget();
389
+ if (!awakenRect) return null;
390
+ dbg('awaken-open', JSON.stringify(awakenRect));
391
+ win.setBounds(awakenRect);
392
+ if (tray) tray.setContextMenu(buildTrayMenu());
393
+ return awakenRect;
394
+ }
395
+ if (!awakenRect) return null;
396
+ const fallback = { ...awakenRect, ...targetSize() };
397
+ const back = awakenPrev || fallback;
398
+ dbg('awaken-close', JSON.stringify(back));
399
+ awakenRect = null;
400
+ awakenPrev = null;
401
+ win.setBounds(back);
402
+ if (tray) tray.setContextMenu(buildTrayMenu());
403
+ return back;
404
+ }
405
+
406
+ /**
407
+ * Windows 混合 DPI 的坑:在缩放不是 100% 的屏幕上,每次 SetWindowPos(拖动时每个 mousemove 都会调)
408
+ * Chromium 的 DIP<->物理像素换算都会漂 ~1px,窗口尺寸于是一路棘轮式长大——按住拖一会就变成两三倍大。
409
+ * 本控件是固定尺寸窗,把尺寸钉回目标值即可。
410
+ * 注:非 100% 缩放下 Electron 对无边框窗口的尺寸取整本身就有 1~3px 偏差(#51572),
411
+ * 所以用 SIZE_SLACK 容差判断,否则会陷入「改了还是差几 px」的死循环。
412
+ * 参考:electron/electron#20423、#20683。
413
+ */
414
+ const SIZE_SLACK = 4;
415
+
416
+ function pinSize() {
417
+ if (!win) return false;
418
+ // 觉醒演出中窗口是「故意」被放大的,按平时尺寸纠回去会把演出直接掐断
419
+ const want = awakenRect
420
+ ? { width: awakenRect.width, height: awakenRect.height }
421
+ : targetSize();
422
+ // 用 content 尺寸做判据:混合 DPI 下 getBounds() 会把物理像素当成 DIP 报出来,
423
+ // 照它纠正反而会打架;content 才是 CSS 真正看到的视口。
424
+ const [cw, ch] = win.getContentSize();
425
+ if (Math.abs(cw - want.width) <= SIZE_SLACK && Math.abs(ch - want.height) <= SIZE_SLACK) {
426
+ return false;
427
+ }
428
+ dbg('pin-size', `content=${cw}x${ch} -> ~${want.width}x${want.height}`);
429
+ win.setContentSize(want.width, want.height);
430
+ return true;
431
+ }
432
+
433
+ function applyScale(scale) {
434
+ dbg('applyScale', `${state.scale} -> ${scale}`, new Error('applyScale caller').stack?.split('\n')[2]?.trim());
435
+ state.scale = scale;
436
+ saveState();
437
+ if (!win) return;
438
+ win.setSize(targetSize(scale).width, targetSize(scale).height, false);
439
+ win.webContents.send('pet:scale', scale);
440
+ }
441
+
442
+ /* ------------------------------------------------------ 自动活动(桌面巡游) */
443
+
444
+ // 每档的巡航速度(DIP/秒)与加速度(DIP/秒²)
445
+ const ROAM = {
446
+ walk: { speed: 118, accel: 900 },
447
+ run: { speed: 430, accel: 2600 },
448
+ swim: { speed: 120, accel: 700 },
449
+ };
450
+ const ROAM_MODES = Object.keys(ROAM);
451
+ const ROAM_TICK_MS = 16;
452
+
453
+ let roamTimer = null;
454
+ let roamPlan = null;
455
+
456
+ /** 当前屏幕工作区的底边 = 角色的“地面线”(窗口贴住它) */
457
+ function groundY() {
458
+ const d = screen.getDisplayMatching(win.getBounds());
459
+ return d.workArea.y + d.workArea.height - targetSize().height;
460
+ }
461
+
462
+ /**
463
+ * 梯形速度曲线:加速 -> 匀速 -> 减速,返回 0..1 的位移比例。
464
+ * 直接线性插值会“啪”地起步、撞墙一样急停,梯形看起来才像生物在动。
465
+ */
466
+ function trapezoid(t, duration, accTime) {
467
+ const a = Math.max(1, Math.min(accTime, duration * 0.45));
468
+ const v = 1 / (duration - a);
469
+ if (t <= 0) return 0;
470
+ if (t >= duration) return 1;
471
+ if (t < a) return (0.5 * v * t * t) / a;
472
+ if (t < duration - a) return 0.5 * v * a + v * (t - a);
473
+ const rest = duration - t;
474
+ return 1 - (0.5 * v * rest * rest) / a;
475
+ }
476
+
477
+ function roamGeometry(mode) {
478
+ const b = win.getBounds();
479
+ const wa = screen.getDisplayMatching(b).workArea;
480
+ const { width, height } = targetSize();
481
+ const minX = wa.x;
482
+ const maxX = Math.max(minX, wa.x + wa.width - width);
483
+ const floorY = wa.y + wa.height - height;
484
+ // 开了重力就贴屏幕底那条地面线;没开就在你放它的高度上走。
485
+ // (游泳的水面画在窗口内部,窗口在哪水就在哪,不需要为此强行回到屏幕底)
486
+ const y = state.gravity ? floorY : Math.min(floorY, Math.max(wa.y, b.y));
487
+ return { b, minX, maxX, floorY, y, width, height, span: maxX - minX };
488
+ }
489
+
490
+ function pickTargetX({ b, minX, maxX, span }, mode) {
491
+ const near = mode === 'run' ? 440 : 230;
492
+ let target;
493
+ if (span > 60 && Math.random() < 0.7) {
494
+ // 多数时候就地在附近晃两步,偶尔横跨半屏
495
+ target = b.x + (Math.random() < 0.5 ? -1 : 1) * near * (0.5 + Math.random() * 0.9);
496
+ if (target < minX || target > maxX) target = b.x - (target - b.x); // 撞边就掉头
497
+ } else {
498
+ target = minX + Math.random() * span;
499
+ }
500
+ return Math.round(Math.min(maxX, Math.max(minX, target)));
501
+ }
502
+
503
+ /** 中止当前巡游;返回是否真的中止了 */
504
+ function haltRoam(reason = 'halt') {
505
+ if (roamTimer) clearInterval(roamTimer);
506
+ roamTimer = null;
507
+ if (!roamPlan) return false;
508
+ const { mode } = roamPlan;
509
+ roamPlan = null;
510
+ win?.webContents.send('pet:loco-end', { mode, reason });
511
+ return true;
512
+ }
513
+
514
+ /**
515
+ * 让角色在屏幕上走 / 跑 / 游一段。
516
+ * 位置由主进程每帧算好直接推窗口(渲染层不会被 60fps 的 IPC 淹掉),
517
+ * 渲染层只负责播对应的骨骼动画,两边靠同一份 plan 对齐朝向和时长。
518
+ */
519
+ function startRoam(mode = 'walk') {
520
+ if (!win || !win.isVisible()) return null;
521
+ if (!ROAM_MODES.includes(mode)) mode = 'walk';
522
+ haltRoam('restart');
523
+
524
+ const geo = roamGeometry(mode);
525
+ const fromX = geo.b.x;
526
+ const toX = pickTargetX(geo, mode);
527
+ const dist = Math.abs(toX - fromX);
528
+ const { speed, accel } = ROAM[mode];
529
+ const accTime = Math.min(340, Math.max(110, (speed / accel) * 1000));
530
+ const duration = Math.max(500, (dist / speed) * 1000 + accTime * 0.8);
531
+
532
+ const plan = {
533
+ mode,
534
+ dir: toX < fromX ? -1 : 1,
535
+ fromX,
536
+ toX,
537
+ dist: Math.round(dist),
538
+ duration: Math.round(duration),
539
+ speed,
540
+ };
541
+ roamPlan = plan;
542
+
543
+ const t0 = performance.now();
544
+ const timer = setInterval(() => {
545
+ if (!win || roamPlan !== plan) {
546
+ clearInterval(timer);
547
+ return;
548
+ }
549
+ const t = performance.now() - t0;
550
+ // 纵向也平滑过去:关掉重力时可能停在半空,直接瞬移太突兀
551
+ const yFrac = Math.min(1, t / Math.min(320, duration * 0.4));
552
+ const y = Math.round(geo.b.y + (geo.y - geo.b.y) * (1 - (1 - yFrac) * (1 - yFrac)));
553
+ win.setBounds({
554
+ x: Math.round(fromX + (toX - fromX) * trapezoid(t, duration, accTime)),
555
+ y,
556
+ width: geo.width,
557
+ height: geo.height,
558
+ });
559
+ if (t >= duration) {
560
+ clearInterval(timer);
561
+ roamTimer = null;
562
+ roamPlan = null;
563
+ win.webContents.send('pet:loco-end', { mode, reason: 'arrived' });
564
+ }
565
+ }, ROAM_TICK_MS);
566
+ roamTimer = timer;
567
+
568
+ return plan;
569
+ }
570
+
571
+ /** 把窗口当前位置记下来(Windows 上只有 move 事件,'moved' 是 macOS 专属) */
572
+ function persistPosition() {
573
+ if (!win) return;
574
+ const [x, y] = win.getPosition();
575
+ state.x = x;
576
+ state.y = y;
577
+ saveState();
578
+ }
579
+
580
+ /** 拖完松手后落回地面(重力关掉就原地待着) */
581
+ function settleToGround() {
582
+ if (!win || !state.gravity || roamPlan || awakenRect) return false;
583
+ const b = win.getBounds();
584
+ const y1 = groundY();
585
+ if (Math.abs(y1 - b.y) <= 2) {
586
+ persistPosition(); // 已经在地面上了,把落脚点记下来
587
+ return false;
588
+ }
589
+
590
+ const { width, height } = targetSize();
591
+ const dur = Math.min(720, 200 + Math.sqrt(Math.abs(y1 - b.y)) * 20);
592
+ const t0 = performance.now();
593
+ const timer = setInterval(() => {
594
+ if (!win) return clearInterval(timer);
595
+ const p = Math.min(1, (performance.now() - t0) / dur);
596
+ const frac = 1 - (1 - p) * (1 - p); // 先快后慢,像落在柔软的地面上
597
+ win.setBounds({ x: b.x, y: Math.round(b.y + (y1 - b.y) * frac), width, height });
598
+ if (p >= 1) {
599
+ clearInterval(timer);
600
+ persistPosition();
601
+ }
602
+ }, ROAM_TICK_MS);
603
+ return true;
604
+ }
605
+
606
+ function setAutoRoam(on) {
607
+ state.autoRoam = !!on;
608
+ saveState();
609
+ if (tray) tray.setContextMenu(buildTrayMenu());
610
+ if (!state.autoRoam) haltRoam('auto-off');
611
+ win?.webContents.send('pet:auto', { enabled: state.autoRoam });
612
+ }
613
+
614
+ function setGravity(on) {
615
+ state.gravity = !!on;
616
+ saveState();
617
+ if (tray) tray.setContextMenu(buildTrayMenu());
618
+ if (state.gravity) settleToGround();
619
+ }
620
+
621
+ /** 托盘 / 右键菜单 / HTTP 都从这里派发动作给渲染层 */
622
+ function sendAction(name) {
623
+ if (!win) return false;
624
+ if (!win.isVisible()) win.show();
625
+ win.webContents.send('pet:action', { name });
626
+ return true;
627
+ }
628
+
629
+ /* -------------------------------------------------------------------- 托盘 */
630
+
631
+ function trayIcon() {
632
+ const src = assetIfExists('tray.png') || assetIfExists('pet-idle.png') || assetIfExists('pet-base.png');
633
+ const img = src ? nativeImage.createFromPath(src) : nativeImage.createEmpty();
634
+ if (img.isEmpty()) return img;
635
+ const size = img.getSize();
636
+ // 取角色头部区域(上方中间),缩成托盘大小更易辨认
637
+ const crop = img.crop({
638
+ x: Math.round(size.width * 0.3),
639
+ y: Math.round(size.height * 0.05),
640
+ width: Math.round(size.width * 0.4),
641
+ height: Math.round(size.height * 0.35),
642
+ });
643
+ return crop.resize({ width: 16, height: 16 });
644
+ }
645
+
646
+ /**
647
+ * 「✨ 觉醒技能」子菜单:服装和觉醒是一对一的(轻量档:战斗服只在觉醒演出里出现),
648
+ * 所以不需要单独再列一个「服装」菜单。
649
+ */
650
+ function awakenSubmenu() {
651
+ const items = outfits.list.map((o) => ({
652
+ label: `✨ ${o.skill} (${o.name})`,
653
+ click: () => sendAction(`awaken:${o.id}`),
654
+ }));
655
+ if (items.length > 1) {
656
+ items.push(
657
+ { type: 'separator' },
658
+ {
659
+ label: '🎲 随机来一个',
660
+ click: () => {
661
+ const pick = outfits.list[Math.floor(Math.random() * outfits.list.length)];
662
+ sendAction(`awaken:${pick.id}`);
663
+ },
664
+ },
665
+ );
666
+ }
667
+ items.push(
668
+ { type: 'separator' },
669
+ { label: '⏹ 结束演出', click: () => sendAction('awaken-stop') },
670
+ );
671
+ return { label: '✨ 觉醒技能', submenu: items };
672
+ }
673
+
674
+ function buildTrayMenu() {
675
+ return Menu.buildFromTemplate([ {
676
+ label: '显示 / 隐藏',
677
+ click: () => {
678
+ if (!win) return createWindow();
679
+ win.isVisible() ? win.hide() : (win.show(), win.focus());
680
+ },
681
+ },
682
+ {
683
+ label: '鼠标穿透模式(点击穿透到桌面)',
684
+ type: 'checkbox',
685
+ checked: state.clickThrough,
686
+ click: (item) => applyClickThrough(item.checked),
687
+ },
688
+ {
689
+ label: '窗口置顶',
690
+ type: 'checkbox',
691
+ checked: state.alwaysOnTop,
692
+ click: (item) => {
693
+ state.alwaysOnTop = item.checked;
694
+ saveState();
695
+ win?.setAlwaysOnTop(item.checked, 'screen-saver');
696
+ },
697
+ },
698
+ {
699
+ label: '角色大小',
700
+ submenu: [0.8, 1, 1.25, 1.5].map((s) => ({
701
+ label: `${Math.round(s * 100)}%`,
702
+ type: 'radio',
703
+ checked: Math.abs(state.scale - s) < 0.01,
704
+ click: () => applyScale(s),
705
+ })),
706
+ },
707
+ {
708
+ label: '回到屏幕右下角',
709
+ click: () => {
710
+ if (!win) return;
711
+ haltRoam('tray');
712
+ const wa = screen.getPrimaryDisplay().workAreaSize;
713
+ const [w, h] = win.getSize();
714
+ win.setPosition(wa.width - w - 60, wa.height - h - 20);
715
+ },
716
+ },
717
+ { type: 'separator' },
718
+ {
719
+ label: '让它动起来',
720
+ submenu: [
721
+ { label: '🚶 走一走', click: () => sendAction('walk') },
722
+ { label: '🏃 跑一跑', click: () => sendAction('run') },
723
+ { label: '🏊 游一游', click: () => sendAction('swim') },
724
+ { type: 'separator' },
725
+ { label: '⏹ 停下来', click: () => sendAction('stop') },
726
+ ],
727
+ },
728
+ {
729
+ label: '自动活动(自己走动 / 跑步 / 游泳)',
730
+ type: 'checkbox',
731
+ checked: state.autoRoam,
732
+ click: (item) => setAutoRoam(item.checked),
733
+ },
734
+ awakenSubmenu(),
735
+ {
736
+ label: '拖完松手后落回屏幕底部(重力)',
737
+ type: 'checkbox',
738
+ checked: state.gravity,
739
+ click: (item) => setGravity(item.checked),
740
+ },
741
+ { type: 'separator' },
742
+ {
743
+ label: '开机自启',
744
+ type: 'checkbox',
745
+ checked: state.openAtLogin,
746
+ click: (item) => {
747
+ state.openAtLogin = item.checked;
748
+ saveState();
749
+ app.setLoginItemSettings({ openAtLogin: item.checked, args: [] });
750
+ },
751
+ },
752
+ {
753
+ label: `消息接口:http://127.0.0.1:${HTTP_PORT}/say`,
754
+ click: () => {
755
+ require('electron').clipboard.writeText(
756
+ `curl -X POST http://127.0.0.1:${HTTP_PORT}/say -H "Content-Type: application/json" -d "{\\"text\\":\\"你好呀~\\"}"`,
757
+ );
758
+ },
759
+ },
760
+ { type: 'separator' },
761
+ { label: '退出', role: 'quit' },
762
+ ]);
763
+ }
764
+
765
+ function createTray() {
766
+ tray = new Tray(trayIcon());
767
+ tray.setToolTip('桌面小控件');
768
+ tray.setContextMenu(buildTrayMenu());
769
+ tray.on('click', () => tray.popUpContextMenu());
770
+ }
771
+
772
+ /* -------------------------------------------------------------- 右键菜单 */
773
+
774
+ function popupContextMenu() {
775
+ Menu.buildFromTemplate([
776
+ {
777
+ label: '😊 打个招呼',
778
+ click: () => sendAction('greet'),
779
+ },
780
+ {
781
+ label: '💃 跳个舞',
782
+ click: () => sendAction('dance'),
783
+ },
784
+ {
785
+ label: '🛌 去睡觉',
786
+ click: () => sendAction('sleep'),
787
+ },
788
+ {
789
+ label: '让它动起来',
790
+ submenu: [
791
+ { label: '🚶 走一走', click: () => sendAction('walk') },
792
+ { label: '🏃 跑一跑', click: () => sendAction('run') },
793
+ { label: '🏊 游一游', click: () => sendAction('swim') },
794
+ { type: 'separator' },
795
+ { label: '⏹ 停下来', click: () => sendAction('stop') },
796
+ ],
797
+ },
798
+ {
799
+ label: '🗨️ 让它说点什么',
800
+ click: async () => {
801
+ const line = await win?.webContents.executeJavaScript('window.__petRandomLine()');
802
+ win?.webContents.send('pet:say', { text: line });
803
+ },
804
+ },
805
+ { type: 'separator' },
806
+ {
807
+ label: '鼠标穿透',
808
+ type: 'checkbox',
809
+ checked: state.clickThrough,
810
+ click: (item) => applyClickThrough(item.checked),
811
+ },
812
+ {
813
+ label: '窗口置顶',
814
+ type: 'checkbox',
815
+ checked: state.alwaysOnTop,
816
+ click: (item) => {
817
+ state.alwaysOnTop = item.checked;
818
+ saveState();
819
+ win?.setAlwaysOnTop(item.checked, 'screen-saver');
820
+ },
821
+ },
822
+ {
823
+ label: '自动活动',
824
+ type: 'checkbox',
825
+ checked: state.autoRoam,
826
+ click: (item) => setAutoRoam(item.checked),
827
+ },
828
+ awakenSubmenu(),
829
+ {
830
+ label: '拖完松手后落回屏幕底部(重力)',
831
+ type: 'checkbox',
832
+ checked: state.gravity,
833
+ click: (item) => setGravity(item.checked),
834
+ },
835
+ {
836
+ label: '角色大小',
837
+ submenu: [0.8, 1, 1.25, 1.5].map((s) => ({
838
+ label: `${Math.round(s * 100)}%`,
839
+ type: 'radio',
840
+ checked: Math.abs(state.scale - s) < 0.01,
841
+ click: () => applyScale(s),
842
+ })),
843
+ },
844
+ { type: 'separator' },
845
+ { label: '隐藏(托盘里可再打开)', click: () => win?.hide() },
846
+ { label: '退出', role: 'quit' },
847
+ ]).popup({ window: win ?? undefined });
848
+ }
849
+
850
+ /* -------------------------------------------------------- 本地 HTTP 接口 */
851
+
852
+ function readJson(req) {
853
+ return new Promise((resolve) => {
854
+ let raw = '';
855
+ req.on('data', (c) => {
856
+ raw += c;
857
+ if (raw.length > 64 * 1024) req.destroy();
858
+ });
859
+ req.on('end', () => {
860
+ try {
861
+ resolve(raw ? JSON.parse(raw) : {});
862
+ } catch {
863
+ resolve({});
864
+ }
865
+ });
866
+ });
867
+ }
868
+
869
+ function json(res, code, body) {
870
+ res.writeHead(code, {
871
+ 'Content-Type': 'application/json; charset=utf-8',
872
+ 'Access-Control-Allow-Origin': '*',
873
+ 'Access-Control-Allow-Headers': 'Content-Type',
874
+ });
875
+ res.end(JSON.stringify(body));
876
+ }
877
+
878
+ function startHttpServer() {
879
+ httpServer = http.createServer(async (req, res) => {
880
+ const url = new URL(req.url, `http://127.0.0.1:${HTTP_PORT}`);
881
+
882
+ if (req.method === 'OPTIONS') return json(res, 204, {});
883
+
884
+ if (req.method === 'GET' && url.pathname === '/health') {
885
+ return json(res, 200, {
886
+ ok: true,
887
+ app: 'desktop-pet',
888
+ visible: !!win?.isVisible(),
889
+ // 诊断用:窗口尺寸 + 每块屏的 scaleFactor(排查 DPI 反馈环)
890
+ bounds: win ? win.getBounds() : null,
891
+ scale: state.scale,
892
+ // 自动化探针会先关掉自动活动,需要能读到原值才好恢复
893
+ autoRoam: state.autoRoam,
894
+ gravity: state.gravity,
895
+ displays: screen.getAllDisplays().map((d) => ({
896
+ scaleFactor: d.scaleFactor,
897
+ bounds: d.bounds,
898
+ workArea: d.workArea,
899
+ })),
900
+ // 给自动化测试用:暴露原生窗口句柄
901
+ hwnd:
902
+ win && process.platform === 'win32'
903
+ ? win.getNativeWindowHandle().readBigUInt64LE(0).toString()
904
+ : null,
905
+ });
906
+ }
907
+
908
+ if (req.method === 'POST' && url.pathname === '/say') {
909
+ const body = await readJson(req);
910
+ const text = String(body.text ?? '').slice(0, 500);
911
+ if (!text) return json(res, 400, { ok: false, error: 'text is required' });
912
+ if (!win) createWindow();
913
+ win.show();
914
+ win.webContents.send('pet:say', { text, mood: body.mood });
915
+ return json(res, 200, { ok: true });
916
+ }
917
+
918
+ if (req.method === 'POST' && url.pathname === '/action') {
919
+ const body = await readJson(req);
920
+ sendAction(String(body.name ?? 'greet'));
921
+ return json(res, 200, { ok: true });
922
+ }
923
+
924
+ // 让它走 / 跑 / 游一段(mode: walk | run | swim)
925
+ if (req.method === 'POST' && url.pathname === '/roam') {
926
+ const body = await readJson(req);
927
+ const mode = ROAM_MODES.includes(body.mode) ? body.mode : 'walk';
928
+ const ok = sendAction(mode);
929
+ return json(res, 200, { ok, mode });
930
+ }
931
+
932
+ if (req.method === 'POST' && url.pathname === '/halt') {
933
+ const stopped = haltRoam('api');
934
+ sendAction('stop');
935
+ return json(res, 200, { ok: true, stopped });
936
+ }
937
+
938
+ // 觉醒技能:{"id":"seraph|nocturne|thunder|astral|random"}
939
+ if (req.method === 'POST' && url.pathname === '/awaken') {
940
+ const body = await readJson(req);
941
+ let id = String(body.id ?? 'random');
942
+ if (id === 'random' || !outfits.list.some((o) => o.id === id)) {
943
+ id = outfits.list[Math.floor(Math.random() * outfits.list.length)].id;
944
+ }
945
+ sendAction(`awaken:${id}`);
946
+ return json(res, 200, { ok: true, id });
947
+ }
948
+
949
+ // 演出中途叫停
950
+ if (req.method === 'POST' && url.pathname === '/awaken/stop') {
951
+ sendAction('awaken-stop');
952
+ return json(res, 200, { ok: true });
953
+ }
954
+
955
+ // 当前可用的服装 / 觉醒清单
956
+ if (req.method === 'GET' && url.pathname === '/outfits') {
957
+ return json(res, 200, { ok: true, list: outfits.list, stage: outfits.stage });
958
+ }
959
+
960
+ // 开 / 关自动活动(自动化测试里先关掉,免得抢窗口)
961
+ if (req.method === 'POST' && url.pathname === '/auto') {
962
+ const body = await readJson(req);
963
+ setAutoRoam(body.enabled !== false);
964
+ return json(res, 200, { ok: true, autoRoam: state.autoRoam });
965
+ }
966
+
967
+ if (req.method === 'POST' && url.pathname === '/show') {
968
+ win ? win.show() : createWindow();
969
+ return json(res, 200, { ok: true });
970
+ }
971
+
972
+ if (req.method === 'POST' && url.pathname === '/hide') {
973
+ win?.hide();
974
+ return json(res, 200, { ok: true });
975
+ }
976
+
977
+ return json(res, 404, { ok: false, error: 'not found' });
978
+ });
979
+
980
+ httpServer.on('error', (err) => {
981
+ console.error(`[http] 端口 ${HTTP_PORT} 不可用:${err.message}`);
982
+ });
983
+
984
+ httpServer.listen(HTTP_PORT, '127.0.0.1');
985
+ }
986
+
987
+ /* -------------------------------------------------------------------- IPC */
988
+
989
+ function registerIpc() {
990
+ let dragOrigin = null;
991
+ let moveCount = 0;
992
+
993
+ ipcMain.on('pet:debug', (_e, msg) => dbg('renderer:', msg));
994
+
995
+ ipcMain.handle('pet:get-config', () => ({
996
+ scale: state.scale,
997
+ clickThrough: state.clickThrough,
998
+ autoRoam: state.autoRoam,
999
+ debug: DEBUG,
1000
+ // 调参 / 自动化用:PET_ROAM_GAP_MS=700 把自主活动的间隔(含觉醒后的停顿)
1001
+ // 压成固定值,几十秒就能把一整轮发牌看完。不开就是正常的随机区间。
1002
+ roamGapMs: Number(process.env.PET_ROAM_GAP_MS) || null,
1003
+ assets: spriteAssets(),
1004
+ // 觉醒清单交给渲染层:菜单里的名字、角色缩放比例、窗口目标规模都从这取
1005
+ awaken: { list: outfits.list, stage: outfits.stage },
1006
+ httpPort: HTTP_PORT,
1007
+ }));
1008
+
1009
+ // 觉醒演出:先用暗幕盖住 → 再调这里放大窗口 → 演出结束再调一次收回
1010
+ ipcMain.handle('pet:awaken-open', () => awakenWindow(true));
1011
+ ipcMain.on('pet:awaken-close', () => awakenWindow(false));
1012
+
1013
+ // 渲染层决定“想走一下”,主进程算出轨迹并驱动窗口
1014
+ ipcMain.handle('pet:roam', (_e, payload) => {
1015
+ const mode = typeof payload === 'string' ? payload : payload?.mode;
1016
+ return startRoam(mode);
1017
+ });
1018
+
1019
+ ipcMain.on('pet:halt-roam', () => haltRoam('renderer'));
1020
+
1021
+ ipcMain.on('pet:drag-start', () => {
1022
+ if (!win) return;
1023
+ haltRoam('drag'); // 用户上手了,巡游让位
1024
+ moveCount = 0;
1025
+ dragOrigin = { cursor: screen.getCursorScreenPoint(), win: win.getPosition() };
1026
+ dbg('drag-start', `cursor=${dragOrigin.cursor.x},${dragOrigin.cursor.y}`, `win=${dragOrigin.win.join(',')}`);
1027
+ win.webContents.send('pet:dragging', true);
1028
+ });
1029
+
1030
+ ipcMain.on('pet:drag-move', () => {
1031
+ if (!win || !dragOrigin) return;
1032
+ const now = screen.getCursorScreenPoint();
1033
+ const x = dragOrigin.win[0] + (now.x - dragOrigin.cursor.x);
1034
+ const y = dragOrigin.win[1] + (now.y - dragOrigin.cursor.y);
1035
+ // 用 setBounds 并显式带上目标尺寸:只 setPosition 的话窗口会一路变大(见 pinSize 注释)
1036
+ win.setBounds({ x, y, ...targetSize() });
1037
+ if (moveCount++ % 12 === 0) {
1038
+ dbg('drag-move', `cursor=${now.x},${now.y}`, `target=${x},${y}`, `actual=${win.getPosition().join(',')}`);
1039
+ }
1040
+ });
1041
+
1042
+ ipcMain.on('pet:drag-end', () => {
1043
+ dragOrigin = null;
1044
+ dbg('drag-end', `win=${win ? win.getPosition().join(',') : '-'}`);
1045
+ win?.webContents.send('pet:dragging', false);
1046
+ // 开了重力时落地会在结束时记位置;关重力就直接把当前落点记下来
1047
+ if (!settleToGround()) persistPosition();
1048
+ });
1049
+
1050
+ ipcMain.on('pet:context-menu', () => popupContextMenu());
1051
+ ipcMain.on('pet:set-scale', (_e, s) => applyScale(Number(s) || 1));
1052
+ ipcMain.on('pet:set-interactive', (_e, interactive) => {
1053
+ if (!win) return;
1054
+ // 注意:渲染层传的是「光标是否落在角色不透明像素上」
1055
+ // interactive=true -> 窗口要接收鼠标事件 -> setIgnoreMouseEvents(false)
1056
+ const nextIgnore = !interactive;
1057
+ if (ignoreMouse === nextIgnore) return;
1058
+ ignoreMouse = nextIgnore;
1059
+ if (state.clickThrough) return; // 手动穿透模式下不听渲染层的
1060
+ dbg('set-interactive', `interactive=${!!interactive} ignoreMouseEvents=${nextIgnore}`);
1061
+ win.setIgnoreMouseEvents(nextIgnore, { forward: true });
1062
+ });
1063
+ ipcMain.on('pet:quit', () => app.quit());
1064
+ }
1065
+
1066
+ /* ------------------------------------------------------------------- 启动 */
1067
+
1068
+ loadState();
1069
+ // 自动化测试用:--no-auto 关掉自动巡游,测试自己控制什么时候动
1070
+ if (process.argv.includes('--no-auto')) state.autoRoam = false;
1071
+
1072
+ // 截图自检用:--anim=<walk|run|swim|awaken:seraph> 摆到某个状态再截帧
1073
+ const DEMO_ANIM = (process.argv.find((a) => a.startsWith('--anim=')) || '').split('=')[1] || '';
1074
+
1075
+ if (!app.requestSingleInstanceLock()) {
1076
+ app.quit();
1077
+ } else {
1078
+ app.on('second-instance', () => {
1079
+ win ? win.show() : createWindow();
1080
+ });
1081
+
1082
+ app.whenReady().then(() => {
1083
+ if (process.platform === 'win32') app.setAppUserModelId('com.local.desktop-pet');
1084
+
1085
+ createWindow();
1086
+ clampIntoWorkArea(); // 上次的位置可能已经不在屏幕上了
1087
+ createTray();
1088
+ registerIpc();
1089
+ startHttpServer();
1090
+ startHitPoll();
1091
+
1092
+ // 显示器缩放/分辨率变化后同样把尺寸钉回目标值
1093
+ screen.on('display-metrics-changed', () => pinSize());
1094
+
1095
+ if (state.clickThrough) applyClickThrough(true);
1096
+ app.setLoginItemSettings({ openAtLogin: state.openAtLogin });
1097
+
1098
+ globalShortcut.register('Control+Shift+P', () => applyClickThrough(!state.clickThrough));
1099
+ globalShortcut.register('Control+Shift+D', () => {
1100
+ if (!win) return createWindow();
1101
+ win.isVisible() ? win.hide() : win.show();
1102
+ });
1103
+ globalShortcut.register('Control+Shift+A', () => {
1104
+ const pick = outfits.list[Math.floor(Math.random() * outfits.list.length)];
1105
+ sendAction(`awaken:${pick.id}`);
1106
+ });
1107
+
1108
+ // 开发自检:截图后退出,用于无人工确认时验证渲染结果
1109
+ // PET_SNAPSHOT_MS 可以改截帧时刻,配合不同值做两帧差分就能证明逐帧动画真的在走
1110
+ if (process.argv.includes('--snapshot') || DEMO_ANIM) {
1111
+ const wantShot = process.argv.includes('--snapshot');
1112
+ const shotMs = Number(process.env.PET_SNAPSHOT_MS) || 1500;
1113
+ win.webContents.once('did-finish-load', () => {
1114
+ if (DEMO_ANIM) {
1115
+ setTimeout(() => {
1116
+ // 觉醒演出走正常的 action 通道(和菜单点的一样),不是 demo 摆拍
1117
+ if (DEMO_ANIM.startsWith('awaken:')) sendAction(DEMO_ANIM);
1118
+ else win?.webContents.send('pet:demo', { anim: DEMO_ANIM });
1119
+ }, 400);
1120
+ }
1121
+ if (!wantShot) return;
1122
+ setTimeout(async () => {
1123
+ const image = await win.webContents.capturePage();
1124
+ const dst = process.env.PET_SNAPSHOT_OUT || path.join(ASSETS, '_snapshot.png');
1125
+ fs.writeFileSync(dst, image.toPNG());
1126
+ console.log(`[snapshot] 已保存 ${dst}`);
1127
+ app.quit();
1128
+ }, shotMs);
1129
+ });
1130
+ }
1131
+ });
1132
+
1133
+ app.on('window-all-closed', () => {
1134
+ // 托盘常驻,不随窗口关闭退出
1135
+ });
1136
+
1137
+ app.on('will-quit', () => {
1138
+ haltRoam('quit');
1139
+ persistPosition();
1140
+ globalShortcut.unregisterAll();
1141
+ httpServer?.close();
1142
+ });
1143
+ }