@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.
@@ -0,0 +1,1219 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 桌面小控件 —— 渲染层
5
+ *
6
+ * 交互总览
7
+ * 拖拽 按住角色拖动窗口,摆动幅度随速度变化,松手有落地弹性
8
+ * 单击 跳一下 + 甜妹语气台词 + 飘爱心
9
+ * 双击 跳舞
10
+ * 鼠标悬停 眼睛/身体跟随光标轻微偏移,高亮
11
+ * 长时间不动 打瞌睡,鼠标一靠近就醒
12
+ * 右键 原生菜单(动作 / 穿透 / 置顶 / 缩放 / 退出)
13
+ * 透明区域 自动鼠标穿透,不挡桌面其它图标
14
+ *
15
+ * 外部消息:主进程 HTTP 接口 -> pet:say -> 气泡 + 说话动画
16
+ */
17
+
18
+ const LINES = [
19
+ '呜哇,你终于点我啦~',
20
+ '在的在的,我一直都在这里陪着你哦!',
21
+ '今天的代码也要好好写哦,我超相信你的~',
22
+ '诶嘿,被你抓到啦!',
23
+ '要不要休息一下呀?眼睛也是要爱护的嘛~',
24
+ '我我我……我会加油的!',
25
+ '你看起来有点累呢,喝口水好不好?',
26
+ '唔……刚刚是不是有个 bug 想跑掉?',
27
+ '摸头的话,我可是会害羞的哦~',
28
+ '今天也超级棒!记得夸夸自己呀!',
29
+ '偷偷告诉你,我最喜欢认真工作的你了~',
30
+ '需要抱抱吗?免费的哦,仅限今天!',
31
+ ];
32
+
33
+ const IDLE_MS = 75_000; // 无操作多久后睡觉
34
+ const HIT_ALPHA = 26; // 命中测试的 alpha 阈值
35
+ const CLICK_SLOP = 12; // 松手时总位移小于此值按单击处理(手抖容差)
36
+ const ROAM_GAP_MS = [6000, 15000]; // 两次自动活动之间的间隔区间
37
+ const WATER_H = 152; // 水面高度(CSS px),要与 style.css 的 .water height 一致
38
+ const SWIM_SUBMERGE = 0.42; // 游泳时水线切在身体自下往上多少比例处
39
+ // 每种动作「每帧停留多久」,一轮帧循环的时长 = 帧数 × 这个值(所以帧数改了不用改这里)
40
+ const PER_FRAME_MS = { walk: 150, run: 75, swim: 200 };
41
+ /**
42
+ * 自主活动的动作权重 —— 写多少 = 一轮发牌里出现几张。
43
+ *
44
+ * 觉醒用 'awaken:*' 通配,写多少 = 「每套觉醒各几张」。
45
+ * 这里 walk/run/swim 各 4 张、每套觉醒各 5 张,一沓 32 张:
46
+ * 单个觉醒 5/32 = 15.6%,单个平时动作 4/32 = 12.5%
47
+ * 也就是「觉醒比平时动作略容易抽到一点点」(1.25 倍)。
48
+ * 想让觉醒稀有回去,把 5 改成 1 就行(1/16 ≈ 6%);想让它更常出就继续加大。
49
+ */
50
+ const ROAM_WEIGHTS = { walk: 4, run: 4, swim: 4, 'awaken:*': 5 };
51
+ /**
52
+ * 觉醒演出之后歇多久再考虑下一次活动。
53
+ * 觉醒占屏 5.7 秒、还是放大窗口的演出,刚完就又接一次会很像卡住;
54
+ * 而且发牌只决定「这一张是什么」,没有这个停顿的话连着抽到两张觉醒
55
+ * 会真的背靠背放两次。
56
+ */
57
+ const AWAKEN_REST_MS = [24000, 52000];
58
+ /**
59
+ * 调参 / 自动化用:PET_ROAM_GAP_MS=<毫秒>(主进程从环境变量读,随 getConfig 发下来)。
60
+ * 设了就同时接管「两次活动的间隔」和「觉醒后的停顿」两个随机区间 —— 不开就是正常节奏。
61
+ */
62
+ let gapOverride = null;
63
+
64
+ function nextGap(range) {
65
+ if (gapOverride) return gapOverride;
66
+ return range[0] + Math.random() * (range[1] - range[0]);
67
+ }
68
+
69
+ const el = {};
70
+ const state = {
71
+ anim: 'idle',
72
+ dragging: false,
73
+ pressed: false,
74
+ movedFar: false,
75
+ sleeping: false,
76
+ hover: false,
77
+ dragDist: 0,
78
+ scale: 1,
79
+ interactive: null,
80
+ talkUntil: 0,
81
+ roaming: false,
82
+ roamMode: null,
83
+ awakening: false, // 觉醒演出中:巡游/小动作/睡觉全部让位
84
+ autoAwaken: false, // 这次觉醒是自主发牌抽到的(手动触发的不该跟着歇那么久)
85
+ face: 1, // 1 = 朝右,-1 = 朝左
86
+ sheetKey: null,
87
+ sheetW: 0,
88
+ sheetH: 0,
89
+ sheetPadY: 0,
90
+ sheetFrames: 1,
91
+ };
92
+
93
+ let cfg = { assets: {} };
94
+ let hitCtx = null;
95
+ let hitCanvas = null;
96
+ const sheetCache = new Map();
97
+
98
+ let idleTimer = null;
99
+ let blinkTimer = null;
100
+ let microTimer = null;
101
+ let bubbleTimer = null;
102
+ let typingTimer = null;
103
+ let roamTimer = null;
104
+ let gaitTimer = null;
105
+ let greetTimer = null;
106
+ let lastPointer = { x: 0, y: 0, sx: 0, sy: 0 };
107
+ let lastHitCheck = 0;
108
+ let lineBag = [];
109
+ let dragOrigin = null;
110
+ let moveTicks = 0;
111
+ let dbgOn = false;
112
+
113
+ function dbg(...parts) {
114
+ if (dbgOn) window.pet.debug(parts.join(' '));
115
+ }
116
+
117
+ // 渲染层的异常不会自己跳到终端,统一转给主进程的 pet-debug.log,
118
+ // 不然出错时只能看到一个「特效没出来」的现象。
119
+ window.addEventListener('error', (e) => {
120
+ window.pet?.debug?.(`ERROR ${e.message} @${e.filename}:${e.lineno}:${e.colno}`);
121
+ });
122
+ window.addEventListener('unhandledrejection', (e) => {
123
+ window.pet?.debug?.(`REJECT ${e.reason?.stack || e.reason}`);
124
+ });
125
+
126
+ /* -------------------------------------------------------------- 小工具 */
127
+
128
+ function toFileUrl(p) {
129
+ if (!p) return '';
130
+ return `file:///${String(p).replace(/\\/g, '/').replace(/^\//, '')}`;
131
+ }
132
+
133
+ function pickLine() {
134
+ if (lineBag.length === 0) lineBag = LINES.slice().sort(() => Math.random() - 0.5);
135
+ return lineBag.pop();
136
+ }
137
+
138
+ window.__petRandomLine = pickLine;
139
+
140
+ function setAnim(name, restoreAfter = 0) {
141
+ // 巡游中不允许被 "idle" 顶掉(说话/微动作的定时器可能刚好在这个当口回来)
142
+ if (name === 'idle' && state.roaming) return;
143
+ state.anim = name;
144
+ el.stage.dataset.anim = name;
145
+ if (restoreAfter > 0) {
146
+ const token = name + ':' + Date.now();
147
+ el.stage.dataset.animToken = token;
148
+ setTimeout(() => {
149
+ if (el.stage.dataset.animToken === token && state.anim === name) setAnim('idle');
150
+ }, restoreAfter);
151
+ }
152
+ }
153
+
154
+ /* ------------------------------------------------------------ 精灵图 */
155
+
156
+ function placeholderSheet() {
157
+ return {
158
+ url: placeholderCharacter(),
159
+ frames: 1,
160
+ figH: 360,
161
+ natW: 240,
162
+ natH: 360,
163
+ img: null,
164
+ };
165
+ }
166
+
167
+ let placeholderUrl = '';
168
+ const sheetStore = new Map(); // key -> 已加载的 sheet 对象
169
+
170
+ function placeholderSheet() {
171
+ if (!placeholderUrl) placeholderUrl = placeholderCharacter();
172
+ return {
173
+ url: placeholderUrl,
174
+ frames: 1,
175
+ refPx: 360,
176
+ frameW: 240,
177
+ frameH: 360,
178
+ axis: 'h',
179
+ padY: 0,
180
+ img: null,
181
+ };
182
+ }
183
+
184
+ /** 加载一张精灵图(同一路径只加载一次) */
185
+ function loadSheet(conf) {
186
+ if (!conf?.path) return Promise.resolve(null);
187
+ if (sheetCache.has(conf.path)) return sheetCache.get(conf.path);
188
+ const p = new Promise((resolve) => {
189
+ const im = new Image();
190
+ im.onload = () => resolve({ ...conf, url: toFileUrl(conf.path), img: im });
191
+ im.onerror = () => resolve(null);
192
+ im.src = toFileUrl(conf.path);
193
+ });
194
+ sheetCache.set(conf.path, p);
195
+ return p;
196
+ }
197
+
198
+ function confFor(key) {
199
+ if (key === 'idle') return cfg.assets?.idle || null;
200
+ if (key === 'blink') return cfg.assets?.blink || null;
201
+ return cfg.assets?.sheets?.[key] || null;
202
+ }
203
+
204
+ /**
205
+ * 开场先把所有图加载完。这样切动作就只是换 CSS 背景,是同步的,
206
+ * 不会出现「已经迈步了但还挂着站立图」那一两帧。
207
+ */
208
+ async function preloadSheets() {
209
+ const keys = ['idle', 'walk', 'run', 'swim', 'blink'];
210
+ await Promise.all(
211
+ keys.map(async (k) => {
212
+ const conf = confFor(k);
213
+ if (!conf) return;
214
+ const s = await loadSheet(conf);
215
+ if (s) sheetStore.set(k, s);
216
+ }),
217
+ );
218
+ if (!sheetStore.has('idle')) sheetStore.set('idle', placeholderSheet());
219
+
220
+ // 觉醒序列帧(蓄力 / 爆发 / 旧版单帧立绘)也走同一套 sheet 机制。
221
+ // 关键:它们和 idle 用同一个 refPx 归一化公式,所以换装那一瞬间不会忽大忽小。
222
+ // 缺哪个阶段就不放进 store,演出自己退回 idle 帧 —— 光效照跑。
223
+ for (const [id, phases] of Object.entries(cfg.assets?.awaken || {})) {
224
+ for (const [phase, conf] of Object.entries(phases)) {
225
+ const s = await loadSheet({ ...conf, axis: 'h', padY: conf.padY || 0 });
226
+ if (s) sheetStore.set(`awaken:${id}:${phase}`, s);
227
+ }
228
+ }
229
+
230
+ const missing = ['walk', 'run', 'swim'].filter((k) => !sheetStore.has(k));
231
+ if (missing.length) dbg(`缺少动作精灵图:${missing.join(',')} —— 这几个动作会退回 idle 帧`);
232
+ }
233
+
234
+ /**
235
+ * 切到某张精灵图:换背景图 + 按「角色标准屏幕身高」反推帧的显示尺寸。
236
+ * 所有动作图里角色都被归一化到同一身高,所以来回切不会忽大忽小、跳位置。
237
+ */
238
+ function useSheet(key) {
239
+ const sheet = sheetStore.get(key) || sheetStore.get('idle');
240
+ if (!sheet) return false;
241
+
242
+ // 觉醒演出会直接写 backgroundPositionX 来逐帧播放(不走 steps() 动画),
243
+ // 所以换表时必须清掉上一次留下的偏移,否则新表一上来就偏几帧。
244
+ el.sprite.style.backgroundPositionX = '0px';
245
+
246
+ // refPx 是「角色在这张图里有多大」:竖构图是身高,游泳那种横躺图是身长。
247
+ // 都换算到同一个屏幕尺寸,所以站立↔跑步↔游泳来回切不会忽大忽小。
248
+ const figDisp = (cfg.assets?.figureHeight ?? 378) * state.scale;
249
+ const k = figDisp / sheet.refPx;
250
+ const fw = Math.max(1, Math.round(sheet.frameW * k));
251
+ const fh = Math.max(1, Math.round(sheet.frameH * k));
252
+
253
+ const changed = state.sheetKey !== key || state.sheetW !== fw || state.sheetH !== fh;
254
+ state.sheetKey = key;
255
+ state.sheetW = fw;
256
+ state.sheetH = fh;
257
+ state.sheetPadY = (sheet.padY || 0) * k;
258
+ state.sheetFrames = sheet.frames;
259
+ el.sprite.style.width = `${fw}px`;
260
+ el.sprite.style.height = `${fh}px`;
261
+ el.sprite.style.backgroundImage = `url("${sheet.url}")`;
262
+ el.sprite.style.backgroundSize = `${fw * sheet.frames}px ${fh}px`;
263
+ el.character.style.height = `${fh}px`;
264
+ el.stage.style.setProperty('--nframes', String(sheet.frames));
265
+ el.stage.style.setProperty('--fw', `${fw}px`);
266
+ el.stage.style.setProperty('--strip', `${fw * sheet.frames}px`);
267
+ el.stage.style.setProperty('--fh', `${fh}px`);
268
+
269
+ // 命中测试始终用 idle 帧:姿势一直在变,用基准姿势当命中区才不会时灵时不灵
270
+ if (key === 'idle' && sheet.img && changed) buildHitMask(sheet.img);
271
+ if (changed) {
272
+ dbg(
273
+ `sheet=${key} ${sheet.frames}帧 帧${sheet.frameW}x${sheet.frameH} ref=${sheet.refPx}(${sheet.axis}) -> ${fw}x${fh}`,
274
+ );
275
+ }
276
+ return true;
277
+ }
278
+
279
+ /**
280
+ * 游泳时角色是横躺的,不能按「脚踩地面」定位,得让身体骑在水线上。
281
+ * 帧图上下各留了 padY 的内边距,所以真正的身体底边 = 窗口底 + padY - sink。
282
+ * 解一下:让水线落在身体自下往上的 SWIM_SUBMERGE 处。
283
+ */
284
+ function swimSink() {
285
+ const padY = state.sheetPadY || 0;
286
+ const bodyH = Math.max(1, state.sheetH - 2 * padY);
287
+ return Math.round(6 + padY - WATER_H * state.scale + SWIM_SUBMERGE * bodyH);
288
+ }
289
+
290
+ /** 一轮帧循环的时长:帧数 × 每帧停留时长 */
291
+ function gaitFor(mode) {
292
+ return Math.round((state.sheetFrames || 4) * (PER_FRAME_MS[mode] || 150));
293
+ }
294
+
295
+ function setMoving(on) {
296
+ el.sprite.classList.toggle('moving', !!on);
297
+ }
298
+
299
+ /* ------------------------------------------------------ 命中测试(alpha) */
300
+
301
+ function buildHitMask(img) {
302
+ try {
303
+ hitCanvas = document.createElement('canvas');
304
+ hitCanvas.width = img.naturalWidth;
305
+ hitCanvas.height = img.naturalHeight;
306
+ hitCtx = hitCanvas.getContext('2d', { willReadFrequently: true });
307
+ hitCtx.drawImage(img, 0, 0);
308
+ } catch {
309
+ hitCtx = null; // 拿不到像素就退化成矩形命中
310
+ }
311
+ }
312
+
313
+ function hitTest(x, y) {
314
+ const r = el.sprite.getBoundingClientRect();
315
+ if (x < r.left || x > r.right || y < r.top || y > r.bottom) return false;
316
+ if (!hitCtx) return true;
317
+
318
+ const nx = Math.floor(((x - r.left) / r.width) * hitCanvas.width);
319
+ const ny = Math.floor(((y - r.top) / r.height) * hitCanvas.height);
320
+ if (nx < 0 || ny < 0 || nx >= hitCanvas.width || ny >= hitCanvas.height) return false;
321
+
322
+ try {
323
+ return hitCtx.getImageData(nx, ny, 1, 1).data[3] > HIT_ALPHA;
324
+ } catch {
325
+ return true;
326
+ }
327
+ }
328
+
329
+ function setInteractive(next) {
330
+ if (state.interactive === next) return;
331
+ state.interactive = next;
332
+ window.pet.setInteractive(next);
333
+ }
334
+
335
+ /** 命中测试的详细快照,只在调试模式用 */
336
+ function hitDebug(x, y) {
337
+ const r = el.sprite.getBoundingClientRect();
338
+ const inside = x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
339
+ let alpha = -1;
340
+ if (inside && hitCtx) {
341
+ const nx = Math.floor(((x - r.left) / r.width) * hitCanvas.width);
342
+ const ny = Math.floor(((y - r.top) / r.height) * hitCanvas.height);
343
+ try {
344
+ alpha = hitCtx.getImageData(nx, ny, 1, 1).data[3];
345
+ } catch {
346
+ alpha = -2;
347
+ }
348
+ }
349
+ return (
350
+ `at=${Math.round(x)},${Math.round(y)} inside=${inside} alpha=${alpha} ` +
351
+ `rect=${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.width)}x${Math.round(r.height)} ` +
352
+ `canvas=${hitCanvas ? `${hitCanvas.width}x${hitCanvas.height}` : 'none'} ` +
353
+ `sheet=${state.sheetKey} ${state.sheetW}x${state.sheetH}`
354
+ );
355
+ }
356
+
357
+ /* ---------------------------------------------------------------- 气泡 */
358
+
359
+ function say(text, { mood } = {}) {
360
+ // 觉醒演出只有几秒,但很怕被打断:这会儿推消息进来会直接把动作顶成 talk
361
+ if (state.awakening) return;
362
+ const content = String(text ?? '').slice(0, 500);
363
+ if (!content) return;
364
+ wake();
365
+
366
+ clearTimeout(bubbleTimer);
367
+ clearInterval(typingTimer);
368
+
369
+ el.bubble.classList.add('show');
370
+ el.bubbleText.textContent = '';
371
+ el.text.scrollTop = 0;
372
+
373
+ if (mood === 'sleep' || mood === 'zzz') {
374
+ setAnim('sleep');
375
+ }
376
+
377
+ // 逐字显示
378
+ let i = 0;
379
+ typingTimer = setInterval(() => {
380
+ el.bubbleText.textContent = content.slice(0, ++i);
381
+ if (i >= content.length) clearInterval(typingTimer);
382
+ }, 34);
383
+
384
+ // 说话动画(张嘴效果:快速在睁眼/闭眼帧间切换)
385
+ const talkMs = Math.min(9000, 1400 + content.length * 95);
386
+ state.talkUntil = Date.now() + talkMs;
387
+ if (!state.sleeping) setAnim('talk');
388
+ setTimeout(() => {
389
+ if (Date.now() >= state.talkUntil && !state.sleeping && !state.roaming) setAnim('idle');
390
+ }, talkMs);
391
+
392
+ bubbleTimer = setTimeout(() => el.bubble.classList.remove('show'), talkMs + 1200);
393
+ }
394
+
395
+ /* ---------------------------------------------------------------- 粒子 */
396
+
397
+ const PARTICLE_KINDS = {
398
+ heart: { cls: 'heart', chars: ['💗', '💖', '✨', '💕'], count: 5, life: 1900 },
399
+ dust: { cls: 'dust', chars: ['·', '。', '•'], count: 2, life: 800 },
400
+ bubble: { cls: 'bubble-p', chars: ['○', '◦', '·'], count: 2, life: 1500 },
401
+ splash: { cls: 'splash', chars: ['💧', '✨', '。'], count: 6, life: 1000 },
402
+ ripple: { cls: 'ripple', chars: [''], count: 1, life: 1300 },
403
+ };
404
+
405
+ /**
406
+ * 粒子统一下发:爱心在身体中上部,尘土/水花在脚下,涟漪在水面高度。
407
+ * 坐标用 anchor 的视口矩形算,和窗口同坐标系。
408
+ */
409
+ function spawnParticles(kind, count) {
410
+ const spec = PARTICLE_KINDS[kind];
411
+ if (!spec) return;
412
+ const rect = el.anchor.getBoundingClientRect();
413
+ const n = count ?? spec.count;
414
+ for (let i = 0; i < n; i++) {
415
+ const p = document.createElement('div');
416
+ p.className = `p ${spec.cls}`;
417
+ const up =
418
+ kind === 'heart'
419
+ ? 0.15 + Math.random() * 0.4
420
+ : kind === 'ripple'
421
+ ? 0.86
422
+ : 0.72 + Math.random() * 0.2;
423
+ p.style.left = `${rect.left + rect.width * (0.18 + Math.random() * 0.64)}px`;
424
+ p.style.top = `${rect.top + rect.height * up}px`;
425
+ // 尘土/水花往身后飘(--drift 带朝向),气泡往反方向浮
426
+ const back = -state.face * (24 + Math.random() * 40);
427
+ const drift = kind === 'bubble' ? -back * 0.4 : kind === 'heart' ? (Math.random() - 0.5) * 60 : back;
428
+ p.style.setProperty('--drift', `${drift}px`);
429
+ p.style.setProperty('--rise', `${-(24 + Math.random() * 40)}px`);
430
+ p.style.animationDelay = `${i * 60}ms`;
431
+ p.textContent = spec.chars[Math.floor(Math.random() * spec.chars.length)];
432
+ el.hearts.appendChild(p);
433
+ setTimeout(() => p.remove(), spec.life + i * 60);
434
+ }
435
+ }
436
+
437
+ function spawnHearts(count = 5) {
438
+ spawnParticles('heart', count);
439
+ }
440
+
441
+ /* ------------------------------------------------------------ 闲置逻辑 */
442
+
443
+ function awake() {
444
+ return !state.sleeping;
445
+ }
446
+
447
+ function wake() {
448
+ const wasSleeping = state.sleeping;
449
+ state.sleeping = false;
450
+ el.stage.classList.remove('sleeping');
451
+ if (wasSleeping) {
452
+ setAnim('idle');
453
+ say('唔……我醒了我醒了!你回来啦~');
454
+ }
455
+ resetIdleTimer();
456
+ }
457
+
458
+ function resetIdleTimer() {
459
+ clearTimeout(idleTimer);
460
+ idleTimer = setTimeout(() => {
461
+ if (state.awakening) return resetIdleTimer();
462
+ // 睡着前先把巡游停下,不然会边睡边走
463
+ if (state.roaming) {
464
+ window.pet.haltRoam();
465
+ endLoco('sleep');
466
+ }
467
+ state.sleeping = true;
468
+ el.stage.classList.add('sleeping');
469
+ setAnim('sleep');
470
+ el.bubble.classList.remove('show');
471
+ el.bubbleText.textContent = 'Zzz…';
472
+ el.bubble.classList.add('show');
473
+ setTimeout(() => {
474
+ if (state.sleeping) el.bubble.classList.remove('show');
475
+ }, 4000);
476
+ }, IDLE_MS);
477
+ }
478
+
479
+ /* --------------------------------------------------------------- 眨眼 */
480
+
481
+ function scheduleBlink() {
482
+ clearTimeout(blinkTimer);
483
+ const wait = state.sleeping ? 5200 : 2600 + Math.random() * 3200;
484
+ blinkTimer = setTimeout(() => {
485
+ // 只有站着眨眼:跑动/游泳的帧图本来就有自己的表情,而且帧几何不一样
486
+ const blink = sheetStore.get('blink');
487
+ if (!state.dragging && state.sheetKey === 'idle') {
488
+ if (blink) {
489
+ el.sprite.style.backgroundImage = `url("${blink.url}")`;
490
+ setTimeout(() => {
491
+ const idle = sheetStore.get('idle');
492
+ if (state.sheetKey === 'idle' && idle) {
493
+ el.sprite.style.backgroundImage = `url("${idle.url}")`;
494
+ }
495
+ }, 130);
496
+ } else {
497
+ el.stage.classList.add('blinking');
498
+ setTimeout(() => el.stage.classList.remove('blinking'), 130);
499
+ }
500
+ }
501
+ scheduleBlink();
502
+ }, wait);
503
+ }
504
+
505
+ /* --------------------------------------------------------- 随机小动作 */
506
+
507
+ function scheduleMicro() {
508
+ clearTimeout(microTimer);
509
+ microTimer = setTimeout(
510
+ () => {
511
+ if (awake() && !state.dragging && !state.pressed && !state.roaming && !state.awakening && Math.random() < 0.55) {
512
+ if (Math.random() < 0.25) {
513
+ setAnim('dance', 2800);
514
+ } else if (Math.random() < 0.35) {
515
+ setAnim('shake', 500);
516
+ } else {
517
+ // 左右张望
518
+ el.sprite.style.setProperty('--look-x', `${(Math.random() - 0.5) * 14}px`);
519
+ setTimeout(() => el.sprite.style.setProperty('--look-x', '0px'), 900);
520
+ }
521
+ }
522
+ scheduleMicro();
523
+ },
524
+ 9000 + Math.random() * 9000,
525
+ );
526
+ }
527
+
528
+ /* ------------------------------------------------- 自动活动(走 / 跑 / 游) */
529
+
530
+ function busy() {
531
+ return state.dragging || state.roaming;
532
+ }
533
+
534
+ /**
535
+ * 能不能现在开始一段自主演出(走路/跑步/游泳,或者觉醒)。
536
+ * force = 用户明确要求(菜单 / HTTP 点名,可以在说话/跳舞时插队)。
537
+ * 名字还叫 canRoam,但它现在是「自主行为总闸」—— 觉醒走的是同一套条件。
538
+ */
539
+ function canRoam(force) {
540
+ if (!window.pet.roam) return false;
541
+ if (state.dragging || state.pressed || state.roaming) return false;
542
+ if (state.awakening) return false;
543
+ if (!awake()) return false;
544
+ if (!force && state.anim !== 'idle') return false;
545
+ return true;
546
+ }
547
+
548
+ // 动作袋:不是每次独立抛骰子,而是把一轮的动作洗完牌再一张张发。
549
+ // 纯随机会出现「连走三次都不游」,而用户又看不出来那是随机;发牌则是「每 N 次里必定各来一次」,
550
+ // 顺序仍然随机,不会显得机械。
551
+ let roamBag = [];
552
+
553
+ function buildRoamBag() {
554
+ const bag = [];
555
+ const awakenIds = window.AWAKENS?.order || [];
556
+ for (const [key, n] of Object.entries(ROAM_WEIGHTS)) {
557
+ if (key === 'awaken:*') {
558
+ // 每套觉醒各发 n 张,抽到哪张就是哪套
559
+ for (const id of awakenIds) {
560
+ for (let i = 0; i < n; i++) bag.push(`awaken:${id}`);
561
+ }
562
+ } else {
563
+ for (let i = 0; i < n; i++) bag.push(key);
564
+ }
565
+ }
566
+ // Fisher-Yates
567
+ for (let i = bag.length - 1; i > 0; i--) {
568
+ const j = Math.floor(Math.random() * (i + 1));
569
+ [bag[i], bag[j]] = [bag[j], bag[i]];
570
+ }
571
+ // 一沓 32 张,不逐张打日志(太长了),只报构成
572
+ const tally = {};
573
+ for (const c of bag) {
574
+ const k = c.startsWith('awaken:') ? 'awaken' : c;
575
+ tally[k] = (tally[k] || 0) + 1;
576
+ }
577
+ dbg(`roam-bag 新一轮发牌 ${bag.length} 张:${JSON.stringify(tally)}`);
578
+ return bag;
579
+ }
580
+
581
+ function pickRoamMode() {
582
+ if (roamBag.length === 0) roamBag = buildRoamBag();
583
+ return roamBag.shift(); // 从头部取,日志里的发牌顺序就是实际执行顺序
584
+ }
585
+
586
+ async function doRoam(mode, { force = false } = {}) {
587
+ if (!canRoam(force)) return false;
588
+ let plan = null;
589
+ try {
590
+ plan = await window.pet.roam(mode); // 主进程已经开动了,顺便把轨迹计划回传
591
+ } catch {
592
+ plan = null;
593
+ }
594
+ if (!plan) return false;
595
+ beginLoco(plan);
596
+ return true;
597
+ }
598
+
599
+ /**
600
+ * 发牌:出什么就执行什么。牌可能是 'walk'/'run'/'swim',也可能是 'awaken:seraph'。
601
+ * 觉醒这张牌自己会管下次什么时候再抽(见 releaseShow 里的 AWAKEN_REST_MS),
602
+ * 所以这里直接 return,不再 scheduleRoam —— 否则会和演出结束时的排程撞车。
603
+ */
604
+ function playCard(card) {
605
+ if (card.startsWith('awaken:')) {
606
+ const id = card.slice('awaken:'.length);
607
+ playAwaken(id, { auto: true })
608
+ .then((ok) => {
609
+ if (!ok) scheduleRoam(); // 没起来(正在说话/拖拽/已经在演出)就正常续轮
610
+ })
611
+ .catch(() => scheduleRoam());
612
+ return;
613
+ }
614
+ doRoam(card).catch(() => {});
615
+ scheduleRoam();
616
+ }
617
+
618
+ /** 自主行为的节拍器:过 6~15 秒看一眼,现在适不适合动一动 */
619
+ function scheduleRoam(delay) {
620
+ clearTimeout(roamTimer);
621
+ const wait = delay ?? nextGap(ROAM_GAP_MS);
622
+ roamTimer = setTimeout(() => {
623
+ if (cfg.autoRoam === false) return scheduleRoam(8000);
624
+ if (!canRoam()) return scheduleRoam(2600); // 在睡觉 / 在说话 / 拖拽中,缓一下再看
625
+ playCard(pickRoamMode());
626
+ }, wait);
627
+ }
628
+
629
+ /** 按主进程给的 plan 进入对应的运动状态 */
630
+ function beginLoco(plan) {
631
+ state.roaming = true;
632
+ state.roamMode = plan.mode;
633
+ state.face = plan.dir < 0 ? -1 : 1;
634
+ el.facer.style.setProperty('--face', String(state.face));
635
+ el.stage.classList.toggle('swimming', plan.mode === 'swim');
636
+ useSheet(plan.mode); // 先换图,gait 要用到新图的帧数
637
+ setMoving(true);
638
+ // 步频跟着帧数走,不是写死的常数
639
+ el.stage.style.setProperty('--gait', `${gaitFor(plan.mode)}ms`);
640
+
641
+ if (plan.mode === 'swim') {
642
+ el.anchor.style.setProperty('--sink', `${swimSink()}px`);
643
+ spawnParticles('splash', 7);
644
+ } else {
645
+ el.anchor.style.setProperty('--sink', '0px');
646
+ if (plan.mode === 'run') spawnParticles('dust', 3);
647
+ }
648
+ setAnim(plan.mode);
649
+ startGaitFx(plan.mode);
650
+ dbg(`loco-start mode=${plan.mode} dir=${plan.dir} dist=${plan.dist} dur=${plan.duration}`);
651
+ }
652
+
653
+ function endLoco(reason = 'arrived') {
654
+ if (!state.roaming) return;
655
+ const mode = state.roamMode;
656
+ state.roaming = false;
657
+ state.roamMode = null;
658
+ stopGaitFx();
659
+ setMoving(false);
660
+ useSheet('idle'); // 帧动画停了,回到站立帧
661
+ if (mode === 'swim') spawnParticles('splash', 5);
662
+ el.anchor.style.setProperty('--sink', '0px');
663
+ el.stage.classList.remove('swimming');
664
+ if (reason === 'drag') setAnim('idle');
665
+ else setAnim('land', 480);
666
+ dbg(`loco-end mode=${mode} reason=${reason}`);
667
+ }
668
+
669
+ /** 跑动时周期性踢起尘土 / 冒泡 / 泛涟漪 */
670
+ function startGaitFx(mode) {
671
+ stopGaitFx();
672
+ const gap = mode === 'run' ? 170 : mode === 'swim' ? 430 : 640;
673
+ gaitTimer = setInterval(() => {
674
+ if (!state.roaming) return stopGaitFx();
675
+ if (mode === 'run') {
676
+ spawnParticles('dust', 2);
677
+ } else if (mode === 'swim') {
678
+ spawnParticles('bubble', 2);
679
+ if (Math.random() < 0.6) spawnParticles('ripple', 1);
680
+ } else if (Math.random() < 0.5) {
681
+ spawnParticles('dust', 1);
682
+ }
683
+ }, gap);
684
+ }
685
+
686
+ function stopGaitFx() {
687
+ clearInterval(gaitTimer);
688
+ gaitTimer = null;
689
+ }
690
+
691
+ /** 截图自检 / 视觉回归:不走位,只把某个动作状态摆好 */
692
+ function applyDemo(anim) {
693
+ clearTimeout(roamTimer);
694
+ clearTimeout(greetTimer); // 不然开机那句台词会把 demo 动作顶成 talk
695
+ el.bubble.classList.remove('show');
696
+ state.roaming = true; // 锁住,否则 setAnim('idle') 会把 demo 动画顶掉
697
+ state.roamMode = anim;
698
+ state.face = 1;
699
+ el.facer.style.setProperty('--face', '1');
700
+ useSheet(anim);
701
+ setMoving(true);
702
+ el.stage.style.setProperty('--gait', `${gaitFor(anim)}ms`);
703
+ if (anim === 'swim') {
704
+ el.stage.classList.add('swimming');
705
+ el.anchor.style.setProperty('--sink', `${swimSink()}px`);
706
+ spawnParticles('splash', 6);
707
+ } else if (anim === 'run') {
708
+ spawnParticles('dust', 4);
709
+ }
710
+ setAnim(anim);
711
+ startGaitFx(anim);
712
+ dbg(`demo anim=${anim}`);
713
+ }
714
+
715
+ /* ---------------------------------------------------------- 觉醒技能 */
716
+
717
+ /**
718
+ * 一场觉醒演出的时间线(毫秒):
719
+ * 0 黑屏切入(canvas 暗幕)
720
+ * 180 窗口放大 + 换上战斗服立绘 + 「镜头推近」
721
+ * revealAt 暗幕退 + 白闪 + 画面抖动 + 主爆发(具体特效由各套觉醒的 build 排)
722
+ * closeAt 暗幕再进 + 窗口收回(正好盖住窗口变回去的那一帧)
723
+ * duration 暗幕退掉,回到站立
724
+ *
725
+ * 关键点:窗口尺寸是在「暗幕已经盖满」之后才改的,所以看不到窗口在变形。
726
+ * DNF 的觉醒也是这么处理的 —— 黑屏切入从来不只是省钱,它负责把不好看的那一帧藏起来。
727
+ */
728
+
729
+ let awakenToken = 0; // 每次演出一个令牌;旧演出的挂起回调拿它对不上号就自己失效
730
+
731
+ /** 角色当前的屏幕位置,事件在触发的那一刻现取(窗口刚被放大过) */
732
+ /**
733
+ * 觉醒演出的逐帧播放:直接写 backgroundPositionX,不走 steps() 帧动画。
734
+ * 为什么不用 CSS 帧动画:一套觉醒要「蓄力慢、爆发快」两种节奏,
735
+ * steps() 一条动画只能一个速度;而且帧要和特效时间轴对死,写死帧号最直接。
736
+ */
737
+ function awakenFrame(idx) {
738
+ setMoving(false);
739
+ el.sprite.style.backgroundPositionX = `${-idx * (state.sheetW || 0)}px`;
740
+ }
741
+
742
+ function awakenPoint() {
743
+ const r = el.sprite.getBoundingClientRect();
744
+ const cx = r.left + r.width / 2;
745
+ return {
746
+ w: window.innerWidth,
747
+ h: window.innerHeight,
748
+ cx,
749
+ cy: r.top + r.height / 2,
750
+ footX: cx,
751
+ footY: r.bottom,
752
+ };
753
+ }
754
+
755
+ function holdShow() {
756
+ state.awakening = true;
757
+ state.sleeping = false;
758
+ el.stage.classList.remove('sleeping');
759
+ el.bubble.classList.remove('show');
760
+ el.bubbleText.textContent = '';
761
+ clearTimeout(roamTimer);
762
+ clearTimeout(microTimer);
763
+ clearTimeout(idleTimer);
764
+ clearTimeout(blinkTimer);
765
+ clearTimeout(greetTimer);
766
+ stopGaitFx();
767
+ setInteractive(false); // 演出期间别挡着桌面,鼠标直接穿过去
768
+ }
769
+
770
+ function releaseShow() {
771
+ const wasAuto = state.autoAwaken;
772
+ state.awakening = false;
773
+ state.autoAwaken = false;
774
+ el.stage.classList.remove('awakening', 'flashing', 'shaking');
775
+ el.stage.style.removeProperty('--awaken-zoom');
776
+ useSheet('idle');
777
+ setAnim('idle');
778
+ resetIdleTimer();
779
+ scheduleBlink();
780
+ scheduleMicro();
781
+ // 自己抽到的觉醒歇久一点:它的牌权重本来就比平时动作高,不缓和一下
782
+ // 就会变成「一分钟一次大演出」,很挡事。用户手动点的就正常续轮即可。
783
+ scheduleRoam(nextGap(wasAuto ? AWAKEN_REST_MS : ROAM_GAP_MS));
784
+ }
785
+
786
+ /** 爆发那一刻:白闪 + 抖屏 */
787
+ function awakenReveal(token) {
788
+ if (token !== awakenToken) return;
789
+ el.stage.classList.remove('flashing', 'shaking');
790
+ void el.stage.offsetWidth; // 强制重排,否则同一个 class 加不回去动画不会重播
791
+ el.stage.classList.add('flashing', 'shaking');
792
+ setTimeout(() => el.stage.classList.remove('flashing'), 300);
793
+ setTimeout(() => el.stage.classList.remove('shaking'), 500);
794
+ }
795
+
796
+ async function playAwaken(id, { auto = false } = {}) {
797
+ const def = window.AWAKENS?.list?.[id];
798
+ if (!def || state.awakening) return false;
799
+ if (state.dragging || state.pressed) return false; // 手还按在角色上,等松手
800
+
801
+ const token = ++awakenToken;
802
+ state.autoAwaken = !!auto;
803
+ if (state.roaming) {
804
+ window.pet.haltRoam();
805
+ endLoco('awaken');
806
+ }
807
+ holdShow();
808
+
809
+ // 1) 先把屏幕压黑
810
+ FX.dim(def.dimAlpha, 170, def.palette.stroke);
811
+
812
+ // 2) 暗幕盖满之后再把窗口放大,同时按新窗口高度算「镜头推近」的倍数
813
+ await new Promise((r) => setTimeout(r, 180));
814
+ if (token !== awakenToken) return false;
815
+
816
+ let target = null;
817
+ try {
818
+ target = await window.pet.awakenOpen();
819
+ } catch {
820
+ target = null;
821
+ }
822
+ if (token !== awakenToken) return false;
823
+
824
+ const boxH = target?.height || window.innerHeight;
825
+ const curH = el.sprite.getBoundingClientRect().height || 1;
826
+ // 角色占画面 ~70% 高:上方要留出「台词 + 技能名」那条安全带。
827
+ // 不能再大了 —— 觉醒序列图里「举臂 + 光环」那种帧本身就比站姿高一大截。
828
+ const zoom = Math.max(1.05, Math.min(cfg.awaken?.stage?.zoom ?? 1.5, (boxH * 0.7) / curH));
829
+ el.stage.style.setProperty('--awaken-zoom', zoom.toFixed(3));
830
+ el.stage.classList.add('awakening');
831
+
832
+ // 战斗服序列帧(轻量档:只在演出里换)。分三个阶段找素材:
833
+ // charge(蓄力序列)> burst(爆发序列)> still(旧版单帧立绘)> idle 帧
834
+ // 缺哪一段就退到下一级,光效不会因此少一块。
835
+ const sheet = def.sheet || {};
836
+ const phaseKey = (phase) => `awaken:${id}:${phase}`;
837
+ const hasPhase = (phase) => sheetStore.has(phaseKey(phase));
838
+ // 素材降级链:缺什么就用下一档,光效不会因此少一块
839
+ const anyArt = hasPhase('charge') || hasPhase('burst') || hasPhase('still');
840
+ const fallbackKey = () =>
841
+ hasPhase('charge') ? phaseKey('charge') : hasPhase('burst') ? phaseKey('burst') : hasPhase('still') ? phaseKey('still') : 'idle';
842
+ let curSheet = null;
843
+ const showFrame = (key, idx) => {
844
+ if (curSheet !== key) {
845
+ useSheet(key);
846
+ curSheet = key;
847
+ }
848
+ awakenFrame(idx);
849
+ };
850
+
851
+ // 起手:黑幕还没打开就先摆好第一帧,避免「幕布一淡开是 idle 站姿」
852
+ if (anyArt) showFrame(fallbackKey(), 0);
853
+ else useSheet('idle');
854
+ setAnim('idle');
855
+
856
+ const built = def.build({ fx: FX, point: awakenPoint });
857
+ const events = built.events.slice();
858
+
859
+ // 蓄力:暗幕从全黑提到 0.6(soft),让玩家真的能看到蓄力动作在演,
860
+ // 而不是从头到尾一层黑幕摆完五秒 —— 那多帧素材就白做了。
861
+ if (sheet.charge) {
862
+ events.push([sheet.charge.at, () => FX.dim(0.6, 420, def.palette.stroke, true)]);
863
+ }
864
+
865
+ // 逐帧排点:帧号写死在时间轴上,所以「动作推到哪一帧」和「特效炸在哪一刻」是对死的
866
+ const addFrames = (scope, phase) => {
867
+ if (!scope) return;
868
+ const key = hasPhase(phase) ? phaseKey(phase) : fallbackKey();
869
+ const count = key === 'idle' ? 1 : sheetStore.get(key).frames;
870
+ for (let i = 0; i < count; i++) {
871
+ events.push([
872
+ scope.at + i * scope.step,
873
+ () => {
874
+ if (token !== awakenToken) return;
875
+ showFrame(key, i);
876
+ },
877
+ ]);
878
+ }
879
+ };
880
+ addFrames(sheet.charge, 'charge');
881
+ addFrames(sheet.burst, 'burst');
882
+
883
+ events.push([def.revealAt, () => awakenReveal(token)]);
884
+ events.push([
885
+ def.closeAt,
886
+ () => {
887
+ if (token !== awakenToken) return;
888
+ FX.dim(def.dimAlpha, 240, def.palette.stroke);
889
+ // 窗口变回去这一帧同样藏在暗幕后面,所以这里可以放心地提前收
890
+ setTimeout(() => {
891
+ if (token !== awakenToken) return;
892
+ el.stage.classList.remove('awakening', 'flashing', 'shaking');
893
+ el.stage.style.removeProperty('--awaken-zoom');
894
+ useSheet('idle');
895
+ window.pet.awakenClose();
896
+ }, 260);
897
+ },
898
+ ]);
899
+ events.sort((a, b) => a[0] - b[0]);
900
+
901
+ FX.play(events, built.duration, () => {
902
+ if (token !== awakenToken) return;
903
+ FX.dim(0, 320, def.palette.stroke);
904
+ setTimeout(() => {
905
+ if (token !== awakenToken) return;
906
+ releaseShow();
907
+ }, 340);
908
+ });
909
+
910
+ dbg(`awaken-start id=${id} zoom=${zoom.toFixed(2)} box=${boxH} art=${curSheet || 'idle'}`);
911
+ return true;
912
+ }
913
+
914
+ /** 演出中途叫停(用户拖动 / 菜单 / HTTP /awaken/stop) */
915
+ function stopAwaken() {
916
+ if (!state.awakening) return false;
917
+ awakenToken++; // 让所有挂起的回调失效
918
+ FX.reset();
919
+ window.pet.awakenClose();
920
+ releaseShow();
921
+ dbg('awaken-stop');
922
+ return true;
923
+ }
924
+
925
+ /* ------------------------------------------------------------ 交互事件 */
926
+
927
+ function onPointerDown(e) {
928
+ if (e.button === 2) return;
929
+ // 演出期间用户上手 = 要收回画面,别跟他争
930
+ if (state.awakening) {
931
+ stopAwaken();
932
+ return;
933
+ }
934
+ const hit = hitTest(e.clientX, e.clientY);
935
+ dbg(
936
+ `pointerdown hit=${hit} client=${Math.round(e.clientX)},${Math.round(e.clientY)} screen=${e.screenX},${e.screenY}`,
937
+ );
938
+ if (!hit) return;
939
+
940
+ wake();
941
+ // 用户上手了,正在进行的巡游立即让位(主进程也会从 drag-start 那边兜一道)
942
+ if (state.roaming) {
943
+ window.pet.haltRoam();
944
+ endLoco('drag');
945
+ }
946
+ state.pressed = true;
947
+ state.movedFar = false;
948
+ state.dragDist = 0;
949
+ // 用屏幕坐标做阈值:窗口跟着光标移动时 client 坐标会失真
950
+ dragOrigin = { x: e.screenX, y: e.screenY };
951
+ lastPointer.sx = e.screenX;
952
+ lastPointer.sy = e.screenY;
953
+ moveTicks = 0;
954
+ try {
955
+ el.stage.setPointerCapture(e.pointerId);
956
+ } catch {
957
+ /* 忽略 */
958
+ }
959
+ }
960
+
961
+ function onPointerMove(e) {
962
+ lastPointer.x = e.clientX;
963
+ lastPointer.y = e.clientY;
964
+
965
+ // 1) 命中测试 -> 决定是否鼠标穿透
966
+ const now = performance.now();
967
+ if (now - lastHitCheck > 30) {
968
+ lastHitCheck = now;
969
+ if (state.pressed || state.dragging || cfg.clickThrough) {
970
+ // 拖拽中保持可交互
971
+ } else {
972
+ const hit = hitTest(e.clientX, e.clientY);
973
+ setInteractive(hit);
974
+ if (hit !== state.hover) {
975
+ state.hover = hit;
976
+ el.stage.classList.toggle('hovering', hit);
977
+ dbg(`hit-change hit=${hit} ${hitDebug(e.clientX, e.clientY)}`);
978
+ }
979
+ }
980
+ }
981
+
982
+ // 2) 拖拽窗口 + 摆动
983
+ if (state.pressed && dragOrigin) {
984
+ const dx = e.screenX - dragOrigin.x;
985
+ const dy = e.screenY - dragOrigin.y;
986
+ if (!state.movedFar && Math.hypot(dx, dy) > 4) {
987
+ state.movedFar = true;
988
+ state.dragging = true;
989
+ el.stage.classList.remove('hovering');
990
+ dbg(`drag-start screen=${e.screenX},${e.screenY}`);
991
+ window.pet.dragStart();
992
+ setAnim('drag');
993
+ }
994
+ if (state.dragging) {
995
+ // 用屏幕坐标算速度 -> 身体反向摆动,像被拎起来晃
996
+ const vx = e.screenX - lastPointer.sx;
997
+ lastPointer.sx = e.screenX;
998
+ lastPointer.sy = e.screenY;
999
+ state.dragDist = Math.max(state.dragDist, Math.hypot(dx, dy));
1000
+ const tilt = Math.max(-16, Math.min(16, -vx * 2.2));
1001
+ el.stage.style.setProperty('--tilt', `${tilt}deg`);
1002
+ window.pet.dragMove();
1003
+ if (moveTicks++ % 12 === 0) dbg(`drag-move screen=${e.screenX},${e.screenY}`);
1004
+ }
1005
+ }
1006
+
1007
+ // 3) 悬停时身体/眼神跟随(巡游中不跟,不然会和步态动画打架)
1008
+ if (awake() && !busy()) {
1009
+ const r = el.anchor.getBoundingClientRect();
1010
+ const nx = (e.clientX - r.left) / r.width - 0.5;
1011
+ const ny = (e.clientY - r.top) / r.height - 0.62;
1012
+ // ×face:look 偏移是在 facer 里面做的,会被 scaleX 镜像,预纠正一下方向才对
1013
+ const lookX = Math.max(-9, Math.min(9, nx * 16)) * state.face;
1014
+ el.sprite.style.setProperty('--look-x', `${lookX}px`);
1015
+ el.sprite.style.setProperty('--look-y', `${Math.max(-6, Math.min(6, ny * 10))}px`);
1016
+ el.stage.style.setProperty('--lean', `${Math.max(-3, Math.min(3, nx * 5))}deg`);
1017
+ }
1018
+
1019
+ if (awake()) resetIdleTimer();
1020
+ }
1021
+
1022
+ function onPointerUp(e) {
1023
+ if (e.button === 2) return;
1024
+
1025
+ const wasDragging = state.dragging;
1026
+ const wasPressed = state.pressed;
1027
+ dbg(`pointerup pressed=${wasPressed} dragging=${wasDragging} screen=${e.screenX},${e.screenY}`);
1028
+
1029
+ state.pressed = false;
1030
+ state.dragging = false;
1031
+ dragOrigin = null;
1032
+
1033
+ if (wasDragging) {
1034
+ window.pet.dragEnd();
1035
+ el.stage.style.setProperty('--tilt', '0deg');
1036
+ // 手抖:按下后漂移不到 CLICK_SLOP 像素就松手,按单击处理,不然用户会觉得“点了没反应”
1037
+ if (state.dragDist < CLICK_SLOP) {
1038
+ dbg(`small-drag-as-click dist=${Math.round(state.dragDist)}`);
1039
+ reactToClick();
1040
+ } else {
1041
+ setAnim('land', 520);
1042
+ }
1043
+ wake();
1044
+ return;
1045
+ }
1046
+
1047
+ if (wasPressed) reactToClick();
1048
+ }
1049
+
1050
+ /** 单击反应:跳一下 + 甜妹台词 + 爱心 */
1051
+ function reactToClick() {
1052
+ const line = pickLine();
1053
+ dbg(`click-react "${line}"`);
1054
+ setAnim('hop', 640);
1055
+ setTimeout(() => {
1056
+ if (!awake()) return;
1057
+ spawnHearts(4 + Math.floor(Math.random() * 3));
1058
+ }, 300);
1059
+ say(line);
1060
+ resetIdleTimer();
1061
+ }
1062
+
1063
+ function onDblClick(e) {
1064
+ if (!hitTest(e.clientX, e.clientY)) return;
1065
+ clearTimeout(bubbleTimer);
1066
+ el.bubble.classList.remove('show');
1067
+ setAnim('dance', 2700);
1068
+ spawnHearts(7);
1069
+ say(['看我的必杀技~!', '嘿嘿,跳舞给你看!', '转圈圈~转圈圈~'][Math.floor(Math.random() * 3)]);
1070
+ }
1071
+
1072
+ function onContextMenu(e) {
1073
+ e.preventDefault();
1074
+ if (!hitTest(e.clientX, e.clientY)) return;
1075
+ wake();
1076
+ window.pet.contextMenu();
1077
+ }
1078
+
1079
+ function onWindowLeave() {
1080
+ if (!state.pressed && !state.dragging) setInteractive(false);
1081
+ }
1082
+
1083
+ /* ----------------------------------------------------------- 生命周期 */
1084
+
1085
+ async function init() {
1086
+ el.stage = document.getElementById('stage');
1087
+ el.anchor = document.getElementById('anchor');
1088
+ el.facer = document.getElementById('facer');
1089
+ el.character = document.getElementById('character');
1090
+ el.sprite = document.getElementById('sprite');
1091
+ el.water = document.getElementById('water');
1092
+ el.bubble = document.getElementById('bubble');
1093
+ el.bubbleText = document.getElementById('bubble-text');
1094
+ el.hearts = document.getElementById('hearts');
1095
+ el.text = el.bubble;
1096
+
1097
+ FX.init(document.getElementById('fx'));
1098
+
1099
+ cfg = await window.pet.getConfig();
1100
+ dbgOn = !!cfg.debug;
1101
+ gapOverride = cfg.roamGapMs || null;
1102
+ dbg(`init debug=${dbgOn} roamGap=${gapOverride ?? '默认区间'}`);
1103
+ state.scale = cfg.scale ?? 1;
1104
+ applyScale(state.scale);
1105
+
1106
+ // 先把所有精灵图加载完,后续切动作就是纯 CSS 替换
1107
+ await preloadSheets();
1108
+ useSheet('idle');
1109
+
1110
+ document.addEventListener('pointerdown', onPointerDown);
1111
+ document.addEventListener('pointermove', onPointerMove);
1112
+ document.addEventListener('pointerup', onPointerUp);
1113
+ document.addEventListener('pointercancel', onPointerUp);
1114
+ document.addEventListener('dblclick', onDblClick);
1115
+ document.addEventListener('contextmenu', onContextMenu);
1116
+ document.addEventListener('mouseleave', onWindowLeave);
1117
+ document.addEventListener('keydown', (e) => {
1118
+ if (e.key === 'Escape') window.pet.setScale(1);
1119
+ });
1120
+
1121
+ window.pet.onSay((payload) => say(payload.text, { mood: payload.mood }));
1122
+ window.pet.onAction(({ name }) => {
1123
+ if (name === 'awaken-stop') {
1124
+ stopAwaken();
1125
+ return;
1126
+ }
1127
+ // 演出期间别的动作一律丢掉,不然会把觉醒的动画顶掉
1128
+ if (state.awakening) return;
1129
+ if (name.startsWith('awaken:')) {
1130
+ playAwaken(name.slice('awaken:'.length)).catch(() => {});
1131
+ return;
1132
+ }
1133
+ if (name === 'greet') {
1134
+ setAnim('hop', 640);
1135
+ say('你好呀~我是你的桌面小助手!');
1136
+ spawnHearts(4);
1137
+ } else if (name === 'dance') {
1138
+ setAnim('dance', 2700);
1139
+ spawnHearts(6);
1140
+ say('Music—— start!');
1141
+ } else if (name === 'sleep') {
1142
+ if (state.roaming) {
1143
+ window.pet.haltRoam();
1144
+ endLoco('sleep');
1145
+ }
1146
+ state.sleeping = true;
1147
+ el.stage.classList.add('sleeping');
1148
+ setAnim('sleep');
1149
+ } else if (name === 'walk' || name === 'run' || name === 'swim') {
1150
+ // 菜单 / HTTP 明确点名:可以插队打断当前动画
1151
+ doRoam(name, { force: true }).catch(() => {});
1152
+ } else if (name === 'stop') {
1153
+ window.pet.haltRoam();
1154
+ endLoco('halt');
1155
+ }
1156
+ });
1157
+ window.pet.onLocoEnd(({ reason }) => {
1158
+ if (reason === 'restart') return; // 主进程紧接着会发新的一段,让它自己接管
1159
+ endLoco(reason);
1160
+ });
1161
+ window.pet.onAuto(({ enabled }) => {
1162
+ cfg.autoRoam = enabled;
1163
+ if (!enabled) {
1164
+ window.pet.haltRoam();
1165
+ endLoco('halt');
1166
+ }
1167
+ scheduleRoam(1500);
1168
+ });
1169
+ window.pet.onDemo(({ anim }) => applyDemo(anim));
1170
+ window.pet.onPollHit(({ x, y }) => {
1171
+ if (state.pressed || state.dragging || !awake()) return;
1172
+ const hit = hitTest(x, y);
1173
+ if (hit) {
1174
+ setInteractive(true);
1175
+ dbg(`poll-hit wake ${hitDebug(x, y)}`);
1176
+ }
1177
+ });
1178
+ window.pet.onScale((s) => {
1179
+ state.scale = s;
1180
+ applyScale(s);
1181
+ // 尺寸变了精灵图要重新算显示像素;演出中窗口由主进程控,别在这时候重算
1182
+ if (!state.awakening) useSheet(state.sheetKey || 'idle');
1183
+ });
1184
+
1185
+ // 首次使用给个小提示
1186
+ el.stage.classList.add('hinting');
1187
+ setTimeout(() => el.stage.classList.remove('hinting'), 3500);
1188
+
1189
+ scheduleBlink();
1190
+ scheduleMicro();
1191
+ scheduleRoam();
1192
+ resetIdleTimer();
1193
+ setAnim('idle');
1194
+
1195
+ greetTimer = setTimeout(() => say('我在这里哦~点我试试!'), 700);
1196
+ }
1197
+
1198
+ function applyScale(scale) {
1199
+ state.scale = scale;
1200
+ el.stage.style.setProperty('--pet-scale', scale);
1201
+ }
1202
+
1203
+ /** 素材缺失时的兜底角色(内联 SVG,不依赖外部文件) */
1204
+ function placeholderCharacter() {
1205
+ const svg = `
1206
+ <svg xmlns="http://www.w3.org/2000/svg" width="240" height="360" viewBox="0 0 240 360">
1207
+ <ellipse cx="120" cy="345" rx="58" ry="10" fill="rgba(80,50,80,0.25)"/>
1208
+ <path d="M62 190 Q40 130 78 92 Q120 52 162 92 Q200 130 178 190 Z" fill="#ffd0e4"/>
1209
+ <circle cx="120" cy="120" r="62" fill="#ffe3ef" stroke="#e79dc0" stroke-width="4"/>
1210
+ <path d="M58 108 Q70 52 120 48 Q170 52 182 108 Q150 84 120 84 Q90 84 58 108Z" fill="#ffc2dd" stroke="#e79dc0" stroke-width="3"/>
1211
+ <circle cx="98" cy="126" r="11" fill="#3a2b46"/><circle cx="142" cy="126" r="11" fill="#3a2b46"/>
1212
+ <circle cx="102" cy="121" r="4" fill="#fff"/><circle cx="146" cy="121" r="4" fill="#fff"/>
1213
+ <path d="M106 152 Q120 164 134 152" stroke="#c96b95" stroke-width="4" fill="none" stroke-linecap="round"/>
1214
+ <path d="M64 176 Q120 214 176 176 L188 300 Q120 322 52 300 Z" fill="#b8e6d5" stroke="#8fcbb6" stroke-width="4"/>
1215
+ </svg>`;
1216
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
1217
+ }
1218
+
1219
+ init();