dsh-wisp 1.27.0 → 1.29.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/README.md +89 -0
- package/lib/client.js +454 -51
- package/lib/client.template.js +454 -51
- package/package.json +1 -1
- package/tools/smoke.mjs +79 -5
- package/verify-wisp.mjs +365 -14
package/lib/client.template.js
CHANGED
|
@@ -51,7 +51,7 @@ window.__ModuleLoader__.load({
|
|
|
51
51
|
var exports = module.exports
|
|
52
52
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
53
53
|
|
|
54
|
-
const VERSION = '1.
|
|
54
|
+
const VERSION = '1.29.0'
|
|
55
55
|
|
|
56
56
|
/* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
|
|
57
57
|
忘了更新就会红,不会静默过期。 */
|
|
@@ -68,6 +68,8 @@ const WHATS_NEW = {
|
|
|
68
68
|
'1.25.1': '内容与 1.25.0 相同,重新发布以修好安装源。',
|
|
69
69
|
'1.26.0': '检查更新同时读 npm 和 GitHub,取更高的那个版本。',
|
|
70
70
|
'1.27.0': '她饿了会捧出一碗白米饭,还有第八个表情。',
|
|
71
|
+
'1.28.0': '菜单分成折叠分组,另外多了「关于她」和「动作一览」两个弹窗。',
|
|
72
|
+
'1.29.0': '夜深了她会劝你去睡;你走开再回来她记得;连续来了几天她也记着。',
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
/* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
|
|
@@ -163,6 +165,11 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
163
165
|
careAfterMs: 5400000, // 连续活动这么久之后提醒你起来动动(90 分钟;0 = 关)
|
|
164
166
|
hungry: true, // 干饭:她饿了会说
|
|
165
167
|
hungerMs: 2700000, // 连续活动这么久就该吃一碗了(45 分钟;0 = 关)
|
|
168
|
+
night: true, // 深夜劝睡
|
|
169
|
+
bedtimeHour: 23, // 几点算深夜(0-23)
|
|
170
|
+
wakeHour: 5, // 几点算天亮(0-23);bedtime > wake 时窗口跨零点
|
|
171
|
+
nightMs: 1800000, // 深夜每隔这么久说一句(30 分钟;0 = 关)
|
|
172
|
+
backAfterMs: 120000, // 离开超过这么久,回来时她会说一句(2 分钟;0 = 关)
|
|
166
173
|
milestones: true, // 今日轮次到达里程碑时得意一下
|
|
167
174
|
memory: true, // 她记着你:一起多少天 / 常放哪个角 / 常穿哪套
|
|
168
175
|
happyMs: 1100,
|
|
@@ -266,6 +273,31 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
266
273
|
'算力不够了,得补点碳水。', '电量告急,切待机。', '你要不要也来一口?',
|
|
267
274
|
'这碗不算加餐,算基础设施。', '吃饱了才有力气摸鱼。', '饭碗见底了,该续命了。', '我数着米粒呢。',
|
|
268
275
|
],
|
|
276
|
+
/* 深夜劝睡:23 点后一种语气,凌晨 1 点后另一种 —— 后者是社区设定里"嘴硬心软"那一档。
|
|
277
|
+
分档按 bedtimeHour + 2 推导,所以改了作息这套分档也跟着走。 */
|
|
278
|
+
nightLate: [
|
|
279
|
+
'十一点了。', '该收的东西收一收吧。', '我不催你,时钟在催。', '这个点还在跑,明天会还债的。',
|
|
280
|
+
'夜里的屏幕最亮,眼睛最累。', '再干一会儿就收工?', '白天的你会感谢现在的你。', '我给你留着灯。',
|
|
281
|
+
'要不要把剩下的留到明天?', '深夜的算力也是算力,但人也得睡。', '我先打个盹,你随意。', '这时候的安静挺值钱的。',
|
|
282
|
+
],
|
|
283
|
+
nightDeep: [
|
|
284
|
+
'一点了。', '我不管你,但……真的该睡了。', '明天再跑也一样,代码不会跑。', '再撑下去,明天会很难受。',
|
|
285
|
+
'才不是担心你,只是我不想半夜还看着屏幕。', '你要是倒下,我可没有备份。', '这个点做的决定,白天多半会后悔。',
|
|
286
|
+
'我数到十你就去睡。……一。', '熬夜是拿明天的算力换今晚的。', '天快亮了,你还在这儿。',
|
|
287
|
+
'我要是能关灯就好了。', '睡吧。我留在这儿。',
|
|
288
|
+
],
|
|
289
|
+
/* 离开又回来:按走了多久分两档(阈值见 backAfterMs / BACK_LONG_MS)。 */
|
|
290
|
+
backSoon: [
|
|
291
|
+
'回来了。', '刚才去哪了。', '我在这儿等着呢。', '一眼没看见你。', '这么快就回来,水都没喝吧。', '位置我没动。',
|
|
292
|
+
],
|
|
293
|
+
backLong: [
|
|
294
|
+
'你走了好久。', '差点以为今天见不到了。', '回来了就好。', '我一直在这儿。', '这么久,是去吃饭了吗?',
|
|
295
|
+
'屏幕都凉了。', '我还以为你把我关了。', '下次说一声,我就不数时间了。',
|
|
296
|
+
],
|
|
297
|
+
/* 连续天数({n} 是天数) */
|
|
298
|
+
streak: [
|
|
299
|
+
'连续第 {n} 天了。', '第 {n} 天了,你还在。', '我们已经连着见了 {n} 天。', '第 {n} 天。我记着呢。', '{n} 天没断,挺厉害的。',
|
|
300
|
+
],
|
|
269
301
|
updateCurrent: ['已经是最新的了。', '我就是最新的。', '没有更新的。'],
|
|
270
302
|
updateAvailable: ['有新版本了:', '有新版:', '更新来了:'],
|
|
271
303
|
updateFailed: ['更新没查成。', '这次没查到更新。', '更新检查没成功。'],
|
|
@@ -282,6 +314,37 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
282
314
|
hint: ['右键我一下试试。', '右键我有菜单。', '拖我,或者右键我。'],
|
|
283
315
|
}
|
|
284
316
|
|
|
317
|
+
/* 深夜窗口。bedtime > wake 时它跨零点(默认 23→5);不跨也能用(比如 1→5)。 */
|
|
318
|
+
const isNightHour = (hour, from, to) => {
|
|
319
|
+
if (from === to) return false
|
|
320
|
+
return from > to ? (hour >= from || hour < to) : (hour >= from && hour < to)
|
|
321
|
+
}
|
|
322
|
+
/* "深度深夜"= 已经过了入睡时间两小时以上。按**经过的小时数**算,而不是按绕圈区间:
|
|
323
|
+
后者在窄窗口(比如 13 点睡、14 点起)会把窗外的一大段时间也算进来。 */
|
|
324
|
+
const DEEP_AFTER_HOURS = 2
|
|
325
|
+
const hoursSince = (hour, from) => (((hour - from) % 24) + 24) % 24
|
|
326
|
+
const deepHourOf = (bedtime) => (Math.round(bedtime) + DEEP_AFTER_HOURS) % 24
|
|
327
|
+
|
|
328
|
+
/* 连续天数:从今天往回数,memory.days 里一天不落有多长。
|
|
329
|
+
用毫秒回推而不是解析日期字符串 —— dayKey() 产出的 2026-9-30 不是 ISO 形式,
|
|
330
|
+
各引擎对它的解析并不一致。 */
|
|
331
|
+
const dayKeyOf = (ms) => {
|
|
332
|
+
try {
|
|
333
|
+
const d = new Date(ms)
|
|
334
|
+
return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
|
|
335
|
+
} catch (e) { return 'unknown' }
|
|
336
|
+
}
|
|
337
|
+
const streakOf = (days) => {
|
|
338
|
+
if (!Array.isArray(days) || days.length === 0) return 0
|
|
339
|
+
const set = new Set(days)
|
|
340
|
+
let n = 0
|
|
341
|
+
let t = Date.now()
|
|
342
|
+
while (n < 400 && set.has(dayKeyOf(t))) { n++; t -= 86400000 }
|
|
343
|
+
return n
|
|
344
|
+
}
|
|
345
|
+
/* 连续天数的里程碑;到了才说一次。 */
|
|
346
|
+
const STREAK_MILESTONES = [7, 30, 100, 365]
|
|
347
|
+
|
|
285
348
|
/* 时段划分:凌晨与白天说话的口气不一样。深夜跨了两天,所以按"< 5 点"算夜里。 */
|
|
286
349
|
const greetPoolName = (hour) => {
|
|
287
350
|
if (hour < 5 || hour >= 22) return 'night'
|
|
@@ -336,6 +399,11 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
336
399
|
careAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
337
400
|
hungry: { kind: 'bool' },
|
|
338
401
|
hungerMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
402
|
+
night: { kind: 'bool' },
|
|
403
|
+
bedtimeHour: { kind: 'num', lo: 0, hi: 23 },
|
|
404
|
+
wakeHour: { kind: 'num', lo: 0, hi: 23 },
|
|
405
|
+
nightMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
406
|
+
backAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
339
407
|
milestones: { kind: 'bool' },
|
|
340
408
|
memory: { kind: 'bool' },
|
|
341
409
|
happyMs: { kind: 'num', lo: 200, hi: 10000 },
|
|
@@ -564,6 +632,22 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
564
632
|
.wisp-tab img{width:100%;height:100%;object-fit:contain;object-position:bottom center;pointer-events:none}
|
|
565
633
|
@keyframes wisp-menu{0%{opacity:0;transform:scale(.94)}100%{opacity:1;transform:scale(1)}}
|
|
566
634
|
@keyframes wisp-say{0%{opacity:0;transform:translate(-50%,-100%) translateY(6px) scale(.94)}12%{opacity:1;transform:translate(-50%,-100%) translateY(0) scale(1)}82%{opacity:1;transform:translate(-50%,-100%) translateY(0) scale(1)}100%{opacity:0;transform:translate(-50%,-100%) translateY(-6px) scale(.98)}}
|
|
635
|
+
.wisp-menu-item[data-group]{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
636
|
+
.wisp-menu-caret{opacity:.55;font-size:10px}
|
|
637
|
+
.wisp-menu-sub{padding-left:10px}
|
|
638
|
+
.wisp-menu-sub .wisp-menu-item{padding-left:12px;opacity:.94}
|
|
639
|
+
.wisp-dialog-backdrop{position:absolute;inset:0;background:rgba(6,12,26,.34);pointer-events:auto;animation:wisp-menu .16s ease}
|
|
640
|
+
.wisp-dialog{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(340px,calc(100vw - 28px));max-height:min(440px,calc(100vh - 36px));overflow:auto;padding:14px 14px 10px;border-radius:14px;font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-primary,var(--wisp-say-fg));background:var(--dsw-alias-bg-overlay,var(--wisp-say-bg));border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));box-shadow:0 18px 50px rgba(0,0,0,.34);-webkit-backdrop-filter:blur(16px) saturate(160%);backdrop-filter:blur(16px) saturate(160%);pointer-events:auto;animation:wisp-menu .16s ease}
|
|
641
|
+
.wisp-dialog-title{font-size:13.5px;font-weight:600;margin:0 22px 8px 0}
|
|
642
|
+
.wisp-dialog p,.wisp-dialog .wisp-dialog-p{margin:0 0 8px}
|
|
643
|
+
.wisp-dialog-x{position:absolute;right:8px;top:7px;width:22px;height:22px;border:0;border-radius:6px;background:none;color:inherit;font:inherit;font-size:15px;line-height:1;cursor:pointer;opacity:.7}
|
|
644
|
+
.wisp-dialog-x:hover{opacity:1;background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.22))}
|
|
645
|
+
.wisp-dialog-foot{display:flex;justify-content:space-between;gap:10px;margin-top:8px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));font-size:11px;opacity:.72}
|
|
646
|
+
.wisp-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin:2px 0 6px}
|
|
647
|
+
.wisp-cell{display:flex;flex-direction:column;align-items:center;gap:2px;padding:4px 2px 5px;border:1px solid transparent;border-radius:9px;background:none;color:inherit;font:inherit;font-size:10.5px;cursor:pointer}
|
|
648
|
+
.wisp-cell:hover{border-color:var(--dsw-alias-border-l1,var(--wisp-say-line));background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.18))}
|
|
649
|
+
.wisp-cell img{width:100%;height:52px;object-fit:contain;object-position:bottom center;pointer-events:none}
|
|
650
|
+
.wisp-credits{font-size:11px;opacity:.8;margin:6px 0 0}
|
|
567
651
|
@media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}}`
|
|
568
652
|
|
|
569
653
|
const installStyles = (styles) => {
|
|
@@ -618,6 +702,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
618
702
|
return out
|
|
619
703
|
}
|
|
620
704
|
|
|
705
|
+
/* 经 window 取 Image,而不是裸的全局名:这个半包在壳里由 new Function 求值,
|
|
706
|
+
裸名解析到的是**外层的全局作用域**,那里未必有 Image —— 实测在预检环境里就是
|
|
707
|
+
`TypeError: Image is not a constructor`(点动作预览换到新精灵图键时才踩出来)。
|
|
708
|
+
这个文件读时钟、读性能也是同样的规矩:一切经 window。
|
|
709
|
+
两处用它:命中蒙版、以及换皮肤后的预热。 */
|
|
710
|
+
const imageCtor = () => {
|
|
711
|
+
if (typeof window !== 'undefined' && window && typeof window.Image === 'function') return window.Image
|
|
712
|
+
if (typeof Image === 'function') return Image
|
|
713
|
+
return null
|
|
714
|
+
}
|
|
715
|
+
|
|
621
716
|
const buildMask = (key, url) => {
|
|
622
717
|
if (masks[key]) return
|
|
623
718
|
try {
|
|
@@ -627,7 +722,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
627
722
|
if (!ctx || typeof ctx.drawImage !== 'function' || typeof ctx.getImageData !== 'function') return
|
|
628
723
|
canvas.width = MASK_W
|
|
629
724
|
canvas.height = MASK_H
|
|
630
|
-
const
|
|
725
|
+
const ImageCtor = imageCtor()
|
|
726
|
+
if (ImageCtor === null) return
|
|
727
|
+
const probe = new ImageCtor()
|
|
631
728
|
probe.onload = () => {
|
|
632
729
|
try {
|
|
633
730
|
ctx.drawImage(probe, 0, 0, MASK_W, MASK_H)
|
|
@@ -1310,6 +1407,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1310
1407
|
let lastCareAt = 0
|
|
1311
1408
|
/* 上一顿饭的时间。起手是 0,所以计时以 activeSince 为准 —— 见下面 mealBase。 */
|
|
1312
1409
|
let lastMealAt = 0
|
|
1410
|
+
/* 上一次深夜劝睡;离开又回来的时间点。 */
|
|
1411
|
+
let lastNightAt = 0
|
|
1412
|
+
let awayAt = 0
|
|
1313
1413
|
let happyHandle = null
|
|
1314
1414
|
let lastErrorCount = 0
|
|
1315
1415
|
|
|
@@ -1349,6 +1449,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1349
1449
|
pokedHandle = clock.after(POKED_MS, () => { if (mood === 'poked') setMood('idle') })
|
|
1350
1450
|
}
|
|
1351
1451
|
|
|
1452
|
+
/* 打盹:深夜劝睡用它(一个哈欠比一条通知更像她)。睡着是瞬时状态,
|
|
1453
|
+
自己到期就回稳态。 */
|
|
1454
|
+
const NAP_MS = 5200
|
|
1455
|
+
let napHandle = null
|
|
1456
|
+
const flashSleepy = (text) => {
|
|
1457
|
+
if (mood === 'sleep') wake(true)
|
|
1458
|
+
setMood('sleep')
|
|
1459
|
+
say(text)
|
|
1460
|
+
if (napHandle) clock.cancel(napHandle)
|
|
1461
|
+
napHandle = clock.after(NAP_MS, () => { if (mood === 'sleep') setMood('idle') })
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1352
1464
|
/* 干饭用的也是瞬时状态(借 eat 的姿态)。比"提醒你活动"长一点 ——
|
|
1353
1465
|
吃饭本来就慢。 */
|
|
1354
1466
|
const EAT_MS = 5200
|
|
@@ -1450,6 +1562,21 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1450
1562
|
flashEat(pick(LINES.eat))
|
|
1451
1563
|
}
|
|
1452
1564
|
|
|
1565
|
+
/* ---- 深夜劝睡 ---------------------------------------------------------
|
|
1566
|
+
作息**是可配的**(bedtimeHour / wakeHour),所以它既是一项真功能,
|
|
1567
|
+
也让测试不必伪造时钟 —— 把窗口套在当前那个小时上就能测。
|
|
1568
|
+
深度深夜从 bedtime + 2 起算,语气换一档:从"该收了"变成"嘴硬心软"。 */
|
|
1569
|
+
if (cfg.night && cfg.nightMs > 0) {
|
|
1570
|
+
const hour = hourNow()
|
|
1571
|
+
const nightBase = Math.max(lastNightAt, activeSince)
|
|
1572
|
+
if (isNightHour(hour, cfg.bedtimeHour, cfg.wakeHour)
|
|
1573
|
+
&& nowTick - nightBase >= cfg.nightMs) {
|
|
1574
|
+
lastNightAt = nowTick
|
|
1575
|
+
const deep = hoursSince(hour, cfg.bedtimeHour) >= DEEP_AFTER_HOURS
|
|
1576
|
+
flashSleepy(pick(deep ? LINES.nightDeep : LINES.nightLate))
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1453
1580
|
// 有东西在等她处理时,优先级高于一切:这是唯一"你不理它就卡住"的状态。
|
|
1454
1581
|
const pending = cfg.reactions ? readPending() : null
|
|
1455
1582
|
if (pending === null) {
|
|
@@ -1468,6 +1595,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1468
1595
|
// 否则刚摆出的表情在同一个 poll 周期里就被 setMood('idle') 抹掉了。
|
|
1469
1596
|
if (mood === 'sleep' || mood === 'happy' || mood === 'poked' || mood === 'proud' || mood === 'care'
|
|
1470
1597
|
|| mood === 'eat' || mood === 'worried') return
|
|
1598
|
+
/* 动作预览期间也要挡住稳态赋值,否则刚摆的表情在同一轮里被算回去 */
|
|
1599
|
+
if (nowMs() < previewUntil) return
|
|
1471
1600
|
setMood((busy || typing) ? 'alert' : 'idle')
|
|
1472
1601
|
}
|
|
1473
1602
|
|
|
@@ -1723,6 +1852,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1723
1852
|
const MENU_PAD = 10
|
|
1724
1853
|
let menuEl = null
|
|
1725
1854
|
let menuOff = null
|
|
1855
|
+
/* 折叠分组要"在原地重开",所以记住这次的落点 */
|
|
1856
|
+
let menuAtX = 0
|
|
1857
|
+
let menuAtY = 0
|
|
1726
1858
|
|
|
1727
1859
|
const closeMenu = () => {
|
|
1728
1860
|
if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl)
|
|
@@ -1763,45 +1895,204 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1763
1895
|
{ key: 'reactions', label: '跟随状态' },
|
|
1764
1896
|
{ key: 'celebrate', label: '跑完撒花' },
|
|
1765
1897
|
{ key: 'hungry', label: '饿了会说话' },
|
|
1898
|
+
{ key: 'night', label: '深夜劝睡' },
|
|
1766
1899
|
]
|
|
1767
1900
|
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1901
|
+
/* ---- 弹窗 ----------------------------------------------------------------
|
|
1902
|
+
菜单是"做事"的地方,不是"看东西"的地方:19 个同级条目,谁也找不到谁。
|
|
1903
|
+
所以顶层只留动作,"她是谁"和"她有哪些动作"放进弹窗 —— 关掉即从 DOM 移除,
|
|
1904
|
+
不常驻、不参与轮询。 */
|
|
1905
|
+
let dialogEl = null
|
|
1906
|
+
let dialogKind = null
|
|
1907
|
+
let dialogKeyOff = null
|
|
1908
|
+
let activeGroup = ''
|
|
1909
|
+
/* 预览锁:动作预览要摆出某个表情给你看,而稳态轮询每一轮都会把它算回去。
|
|
1910
|
+
用一个到期时间挡住轮询,比给每个情绪再加一个瞬时标记干净。 */
|
|
1911
|
+
let previewUntil = 0
|
|
1912
|
+
let previewHandle = null
|
|
1913
|
+
|
|
1914
|
+
const closeDialog = () => {
|
|
1915
|
+
if (dialogEl && dialogEl.parentNode) dialogEl.parentNode.removeChild(dialogEl)
|
|
1916
|
+
dialogEl = null
|
|
1917
|
+
dialogKind = null
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/* 弹窗不抢焦点,所以 Esc 只能靠一个常驻监听 —— 而且必须是**一个**:
|
|
1921
|
+
每次开弹窗都挂一个新的会越积越多,引用还拿不回来,销毁时摘不掉。 */
|
|
1922
|
+
const onDialogKey = (ev) => {
|
|
1923
|
+
if (ev.key !== 'Escape' || dialogEl === null) return
|
|
1924
|
+
ev.preventDefault()
|
|
1925
|
+
closeDialog()
|
|
1926
|
+
focusHer()
|
|
1927
|
+
}
|
|
1928
|
+
/* 文档级监听:真 document 一定有,但这里仍然判断一下 ——
|
|
1929
|
+
"环境少一个方法"不该让整个插件起不来(预检的替身就少了它)。 */
|
|
1930
|
+
if (typeof document.addEventListener === 'function') {
|
|
1931
|
+
document.addEventListener('keydown', onDialogKey, true)
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
const para = (text) => {
|
|
1935
|
+
const p = document.createElement('p')
|
|
1936
|
+
p.className = 'wisp-dialog-p'
|
|
1937
|
+
p.textContent = text
|
|
1938
|
+
return p
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
const dialogFoot = (left, right) => {
|
|
1942
|
+
const row = document.createElement('div')
|
|
1943
|
+
row.className = 'wisp-dialog-foot'
|
|
1944
|
+
const a = document.createElement('span')
|
|
1945
|
+
/* 两个 span 各带类名:真 DOM 从父节点就能读到汇总文本,
|
|
1946
|
+
简化 DOM 不能 —— 带类名意味着两边都能定位到这段文字。 */
|
|
1947
|
+
a.className = 'wisp-dialog-foot-l'
|
|
1948
|
+
a.textContent = left
|
|
1949
|
+
const b = document.createElement('span')
|
|
1950
|
+
b.className = 'wisp-dialog-foot-r'
|
|
1951
|
+
b.textContent = right
|
|
1952
|
+
row.appendChild(a)
|
|
1953
|
+
row.appendChild(b)
|
|
1954
|
+
return row
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const openDialog = (kind, title, rows) => {
|
|
1958
|
+
closeMenu()
|
|
1959
|
+
closeDialog()
|
|
1960
|
+
const back = document.createElement('div')
|
|
1961
|
+
back.className = 'wisp-dialog-backdrop'
|
|
1962
|
+
back.addEventListener('pointerdown', () => { closeDialog(); focusHer() })
|
|
1963
|
+
const card = document.createElement('div')
|
|
1964
|
+
card.className = 'wisp-dialog'
|
|
1965
|
+
card.setAttribute('role', 'dialog')
|
|
1966
|
+
card.setAttribute('aria-modal', 'true')
|
|
1967
|
+
card.setAttribute('aria-label', title)
|
|
1968
|
+
card.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
1969
|
+
const head = document.createElement('div')
|
|
1970
|
+
head.className = 'wisp-dialog-title'
|
|
1971
|
+
head.textContent = title
|
|
1972
|
+
card.appendChild(head)
|
|
1973
|
+
const x = document.createElement('button')
|
|
1974
|
+
x.className = 'wisp-dialog-x'
|
|
1975
|
+
x.setAttribute('type', 'button')
|
|
1976
|
+
x.setAttribute('aria-label', '关闭')
|
|
1977
|
+
x.textContent = '×'
|
|
1978
|
+
x.addEventListener('click', (ev) => { ev.preventDefault(); closeDialog(); focusHer() })
|
|
1979
|
+
card.appendChild(x)
|
|
1980
|
+
for (let i = 0; i < rows.length; i++) card.appendChild(rows[i])
|
|
1981
|
+
back.appendChild(card)
|
|
1982
|
+
layer.appendChild(back)
|
|
1983
|
+
dialogEl = back
|
|
1984
|
+
dialogKind = kind
|
|
1985
|
+
return card
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/* 八个动作。key 是精灵图名,mood 是运行时该摆的状态 —— 两者不一样:
|
|
1989
|
+
"干活中"的图叫 work,但状态是 alert;"待机"两者都是 idle。 */
|
|
1990
|
+
const ACTION_ROWS = [
|
|
1991
|
+
{ key: 'idle', mood: 'idle', label: '待机', say: '平时就是这样。' },
|
|
1992
|
+
{ key: 'happy', mood: 'happy', label: '开心', say: '嗯,开心。' },
|
|
1993
|
+
{ key: 'sleepy', mood: 'sleep', label: '打盹', say: '困了……' },
|
|
1994
|
+
{ key: 'work', mood: 'alert', label: '干活中', say: '在跑呢,别催。' },
|
|
1995
|
+
{ key: 'attn', mood: 'attn', label: '有动静', say: '我注意到了。' },
|
|
1996
|
+
{ key: 'poked', mood: 'poked', label: '被戳', say: '干嘛。' },
|
|
1997
|
+
{ key: 'proud', mood: 'proud', label: '得意', say: '跑完了,不错吧。' },
|
|
1998
|
+
{ key: 'eat', mood: 'eat', label: '干饭', say: '先吃一碗。' },
|
|
1999
|
+
]
|
|
2000
|
+
|
|
2001
|
+
const previewMood = (key) => {
|
|
2002
|
+
const row = ACTION_ROWS.find((r) => r.key === key)
|
|
2003
|
+
if (!row) return false
|
|
2004
|
+
closeDialog()
|
|
2005
|
+
/* 被点掉的那个格子随弹窗一起消失了,焦点必须有人接 —— 否则掉进 <body>。 */
|
|
2006
|
+
focusHer()
|
|
2007
|
+
previewUntil = nowMs() + 6000
|
|
2008
|
+
setMood(row.mood)
|
|
2009
|
+
/* 预览必须**自己有到期时间**:预览的姿势可能在"稳态轮询不许覆盖"的名单里
|
|
2010
|
+
(干饭就在),光靠 previewUntil 挡住轮询的话,锁一过期她就被永久卡在那个表情上。
|
|
2011
|
+
到点主动交还稳态,之后该是什么由轮询说了算。 */
|
|
2012
|
+
if (previewHandle) clock.cancel(previewHandle)
|
|
2013
|
+
previewHandle = clock.after(6200, () => { setMood('idle') })
|
|
2014
|
+
say(row.say)
|
|
2015
|
+
return true
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
const actionsDialog = () => {
|
|
2019
|
+
const grid = document.createElement('div')
|
|
2020
|
+
grid.className = 'wisp-grid'
|
|
2021
|
+
for (const row of ACTION_ROWS) {
|
|
2022
|
+
const cell = document.createElement('button')
|
|
2023
|
+
cell.className = 'wisp-cell'
|
|
2024
|
+
cell.setAttribute('type', 'button')
|
|
2025
|
+
cell.setAttribute('aria-label', '预览' + row.label)
|
|
2026
|
+
cell.dataset.mood = row.key
|
|
2027
|
+
const im = document.createElement('img')
|
|
2028
|
+
im.setAttribute('alt', '')
|
|
2029
|
+
im.src = resolveSprite(spriteOf(row.key))
|
|
2030
|
+
const cap = document.createElement('span')
|
|
2031
|
+
cap.textContent = row.label
|
|
2032
|
+
cell.appendChild(im)
|
|
2033
|
+
cell.appendChild(cap)
|
|
2034
|
+
cell.addEventListener('click', (ev) => { ev.preventDefault(); previewMood(row.key) })
|
|
2035
|
+
grid.appendChild(cell)
|
|
1801
2036
|
}
|
|
1802
|
-
return
|
|
2037
|
+
return openDialog('actions', '动作一览', [
|
|
2038
|
+
para('点一个动作,她就当场做给你看。(图都是当前皮肤 ' + skinLabel(activeSkin) + ')'),
|
|
2039
|
+
grid,
|
|
2040
|
+
dialogFoot('共 ' + ACTION_ROWS.length + ' 个动作', '按 Esc 关闭'),
|
|
2041
|
+
])
|
|
1803
2042
|
}
|
|
1804
2043
|
|
|
2044
|
+
const aboutDialog = () => {
|
|
2045
|
+
const note = WHATS_NEW[VERSION]
|
|
2046
|
+
return openDialog('about', '关于她', [
|
|
2047
|
+
para('DeepSeek娘 —— DSH 界面上的浮动桌宠。她会跟着你的状态换表情:你打字她在忙,你闲着她在晃,你跑完一轮她替你高兴。'),
|
|
2048
|
+
para('她的样子照社区规范来:深蓝色渐变长直发、头顶一个大圆环呆毛、两片鲸鳍耳、女仆装。'),
|
|
2049
|
+
para('白米饭是算力的硬通货 —— 所以她每隔一阵会捧碗出来补点碳水。'),
|
|
2050
|
+
para('这一版:' + (note || VERSION)),
|
|
2051
|
+
para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
|
|
2052
|
+
dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
|
|
2053
|
+
])
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
/* 顶层只放"做什么",外观/行为/位置收进可折叠的分组 —— 19 个同级条目等于没有结构。 */
|
|
2057
|
+
const menuItems = () => [
|
|
2058
|
+
{ label: '关于她…', run: () => aboutDialog() },
|
|
2059
|
+
{ label: '动作一览…', run: () => actionsDialog() },
|
|
2060
|
+
{
|
|
2061
|
+
group: '外观',
|
|
2062
|
+
children: [
|
|
2063
|
+
...SKIN_IDS.map((id) => ({
|
|
2064
|
+
label: (id === activeSkin ? '✓ ' : ' ') + skinLabel(id),
|
|
2065
|
+
skin: id,
|
|
2066
|
+
run: () => {
|
|
2067
|
+
if (id === activeSkin) return
|
|
2068
|
+
applySkin(id)
|
|
2069
|
+
say('换成' + skinLabel(id) + '了。')
|
|
2070
|
+
},
|
|
2071
|
+
})),
|
|
2072
|
+
{ label: '放大一点', run: () => applyConfig({ size: cfg.size + 0.5 }) },
|
|
2073
|
+
{ label: '缩小一点', run: () => applyConfig({ size: cfg.size - 0.5 }) },
|
|
2074
|
+
],
|
|
2075
|
+
},
|
|
2076
|
+
{
|
|
2077
|
+
group: '行为',
|
|
2078
|
+
children: [
|
|
2079
|
+
{ label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
|
|
2080
|
+
{ label: '睡一会儿', run: () => setMood('sleep') },
|
|
2081
|
+
...TOGGLES.map((t) => ({
|
|
2082
|
+
label: (cfg[t.key] === true ? '✓ ' : ' ') + t.label,
|
|
2083
|
+
run: () => applyConfig({ [t.key]: cfg[t.key] !== true }),
|
|
2084
|
+
})),
|
|
2085
|
+
],
|
|
2086
|
+
},
|
|
2087
|
+
{
|
|
2088
|
+
group: '位置',
|
|
2089
|
+
children: CORNERS.map((c) => ({ label: c.label, run: () => goCorner(c.label) })),
|
|
2090
|
+
},
|
|
2091
|
+
{ label: '版本与更新', run: () => showUpdate() },
|
|
2092
|
+
/* 一个开关,不是一个单向动作:从迷你按钮右键打开菜单时,它必须能把她叫回来。 */
|
|
2093
|
+
{ label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden) },
|
|
2094
|
+
]
|
|
2095
|
+
|
|
1805
2096
|
/* ---- 菜单的键盘驱动 ----
|
|
1806
2097
|
菜单已经声明了 role="menu",没有键盘支持的话,对屏幕阅读器来说比不声明更糟 ——
|
|
1807
2098
|
它宣布了一个你无法操作的菜单。采用 roving tabindex:只有当前项 tabindex=0,
|
|
@@ -1832,7 +2123,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1832
2123
|
}
|
|
1833
2124
|
}
|
|
1834
2125
|
|
|
1835
|
-
|
|
2126
|
+
/* keepGroup:点分组是"在原地重开",得把刚设好的分组带过去。
|
|
2127
|
+
其他入口都是新手势,一律回到收起状态 —— 所以这个重置放在这里,
|
|
2128
|
+
而不是 closeMenu 里(放那里会让展开动作把自己清掉)。 */
|
|
2129
|
+
const openMenu = (x, y, keepGroup) => {
|
|
2130
|
+
menuAtX = x
|
|
2131
|
+
menuAtY = y
|
|
2132
|
+
if (keepGroup !== true) activeGroup = ''
|
|
1836
2133
|
closeMenu()
|
|
1837
2134
|
stopGlide()
|
|
1838
2135
|
const items = menuItems()
|
|
@@ -1841,16 +2138,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1841
2138
|
menu.className = 'wisp-menu'
|
|
1842
2139
|
menu.setAttribute('role', 'menu')
|
|
1843
2140
|
menu.style.position = 'absolute'
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
if (spec.header !== undefined) {
|
|
1847
|
-
// 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
|
|
1848
|
-
const head = document.createElement('div')
|
|
1849
|
-
head.className = 'wisp-menu-head'
|
|
1850
|
-
head.textContent = spec.header
|
|
1851
|
-
menu.appendChild(head)
|
|
1852
|
-
continue
|
|
1853
|
-
}
|
|
2141
|
+
let heads = 0
|
|
2142
|
+
const addItem = (spec, sub) => {
|
|
1854
2143
|
const btn = document.createElement('button')
|
|
1855
2144
|
btn.className = 'wisp-menu-item'
|
|
1856
2145
|
btn.setAttribute('type', 'button')
|
|
@@ -1866,14 +2155,71 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1866
2155
|
})
|
|
1867
2156
|
menu.appendChild(btn)
|
|
1868
2157
|
menuButtons.push(btn)
|
|
2158
|
+
return btn
|
|
2159
|
+
}
|
|
2160
|
+
for (let i = 0; i < items.length; i++) {
|
|
2161
|
+
const spec = items[i]
|
|
2162
|
+
if (spec.header !== undefined) {
|
|
2163
|
+
// 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
|
|
2164
|
+
const head = document.createElement('div')
|
|
2165
|
+
head.className = 'wisp-menu-head'
|
|
2166
|
+
head.textContent = spec.header
|
|
2167
|
+
menu.appendChild(head)
|
|
2168
|
+
heads++
|
|
2169
|
+
continue
|
|
2170
|
+
}
|
|
2171
|
+
if (spec.group !== undefined) {
|
|
2172
|
+
/* 折叠分组:一行标题,点一下在原地展开/收起(重开同一个坐标,不是新开一个菜单)。 */
|
|
2173
|
+
const open = activeGroup === spec.group
|
|
2174
|
+
const btn = document.createElement('button')
|
|
2175
|
+
btn.className = 'wisp-menu-item'
|
|
2176
|
+
btn.setAttribute('type', 'button')
|
|
2177
|
+
btn.setAttribute('role', 'menuitem')
|
|
2178
|
+
btn.setAttribute('tabindex', '-1')
|
|
2179
|
+
btn.setAttribute('aria-expanded', open ? 'true' : 'false')
|
|
2180
|
+
btn.dataset.group = spec.group
|
|
2181
|
+
btn.dataset.open = open ? 'true' : 'false'
|
|
2182
|
+
/* 文字直接写在按钮上,不拆成子 span:真 DOM 会汇总子节点的 textContent,
|
|
2183
|
+
但简化 DOM(预检的替身)不会 —— 依赖那种汇总会让分组名在替身里变成空串。 */
|
|
2184
|
+
btn.textContent = (open ? '▾ ' : '▸ ') + spec.group
|
|
2185
|
+
btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
2186
|
+
btn.addEventListener('click', (ev) => {
|
|
2187
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
2188
|
+
activeGroup = open ? '' : spec.group
|
|
2189
|
+
openMenu(menuAtX, menuAtY, true)
|
|
2190
|
+
})
|
|
2191
|
+
menu.appendChild(btn)
|
|
2192
|
+
menuButtons.push(btn)
|
|
2193
|
+
if (!open) continue
|
|
2194
|
+
const wrap = document.createElement('div')
|
|
2195
|
+
wrap.className = 'wisp-menu-sub'
|
|
2196
|
+
for (const child of spec.children) {
|
|
2197
|
+
const cb = document.createElement('button')
|
|
2198
|
+
cb.className = 'wisp-menu-item'
|
|
2199
|
+
cb.setAttribute('type', 'button')
|
|
2200
|
+
cb.setAttribute('role', 'menuitem')
|
|
2201
|
+
cb.setAttribute('tabindex', '-1')
|
|
2202
|
+
cb.textContent = child.label
|
|
2203
|
+
cb.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
2204
|
+
cb.addEventListener('click', (ev) => {
|
|
2205
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
2206
|
+
closeMenu()
|
|
2207
|
+
try { child.run() } catch (err) { /* 同上 */ }
|
|
2208
|
+
})
|
|
2209
|
+
wrap.appendChild(cb)
|
|
2210
|
+
menuButtons.push(cb)
|
|
2211
|
+
}
|
|
2212
|
+
menu.appendChild(wrap)
|
|
2213
|
+
continue
|
|
2214
|
+
}
|
|
2215
|
+
addItem(spec, false)
|
|
1869
2216
|
}
|
|
1870
2217
|
// 先挂上去再定位:定位要用最终尺寸,而尺寸只有渲染后才知道
|
|
1871
2218
|
layer.appendChild(menu)
|
|
1872
2219
|
menuEl = menu
|
|
1873
2220
|
const w = MENU_W
|
|
1874
|
-
//
|
|
1875
|
-
|
|
1876
|
-
for (const it of items) hs += it.header !== undefined ? 22 : MENU_ITEM_H
|
|
2221
|
+
// 高度只用于夹取落点,所以按**实际渲染出来的行数**估算即可(假 DOM 里没有布局可测)
|
|
2222
|
+
const hs = MENU_PAD + heads * 22 + menuButtons.length * MENU_ITEM_H
|
|
1877
2223
|
menu.style.left = Math.round(clamp(x, 8, Math.max(8, vw() - w - 8))) + 'px'
|
|
1878
2224
|
menu.style.top = Math.round(clamp(y, 8, Math.max(8, vh() - hs - 8))) + 'px'
|
|
1879
2225
|
|
|
@@ -1924,6 +2270,26 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1924
2270
|
}
|
|
1925
2271
|
window.addEventListener('resize', onResize)
|
|
1926
2272
|
|
|
2273
|
+
/* ---- 你离开又回来 --------------------------------------------------------
|
|
2274
|
+
visibilitychange 是唯一能知道"你去忙别的了"的信号。离开时长按两个阈值分档:
|
|
2275
|
+
短走一句、长走一句;backAfterMs: 0 就整个关掉。
|
|
2276
|
+
注意监听要能摘掉(destroy 里 remove)—— 文档级监听泄漏过一次,记住这个教训。 */
|
|
2277
|
+
const BACK_LONG_MS = 900000
|
|
2278
|
+
const onVisibility = () => {
|
|
2279
|
+
let state = 'visible'
|
|
2280
|
+
try { state = document.visibilityState === 'hidden' ? 'hidden' : 'visible' } catch (e) { state = 'visible' }
|
|
2281
|
+
if (state === 'hidden') { awayAt = nowMs(); return }
|
|
2282
|
+
if (awayAt === 0) return
|
|
2283
|
+
const away = nowMs() - awayAt
|
|
2284
|
+
awayAt = 0
|
|
2285
|
+
if (!(cfg.backAfterMs > 0) || away < cfg.backAfterMs) return
|
|
2286
|
+
wake(true)
|
|
2287
|
+
say(pick(away >= BACK_LONG_MS ? LINES.backLong : LINES.backSoon))
|
|
2288
|
+
}
|
|
2289
|
+
if (typeof document.addEventListener === 'function') {
|
|
2290
|
+
document.addEventListener('visibilitychange', onVisibility)
|
|
2291
|
+
}
|
|
2292
|
+
|
|
1927
2293
|
attach()
|
|
1928
2294
|
/* 打招呼按时段取:凌晨三点和上午十点不该说同一句话。时段在**挂载时**取一次 ——
|
|
1929
2295
|
一个挂了一整天的页面不该在过零点后突然换口气,那反而像 bug。 */
|
|
@@ -1931,6 +2297,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1931
2297
|
const greetName = greetPoolName(greetHour)
|
|
1932
2298
|
clock.after(800, () => say(pick(greetPool(greetName))))
|
|
1933
2299
|
|
|
2300
|
+
/* 连续天数:第 2 天起才提(第一天说"连续第 1 天"很怪);到里程碑单独说一句。 */
|
|
2301
|
+
const streak = streakOf(memory.days)
|
|
2302
|
+
if (streak >= 2 && cfg.memory) {
|
|
2303
|
+
const hitMilestone = STREAK_MILESTONES.indexOf(streak) >= 0
|
|
2304
|
+
clock.after(2600, () => {
|
|
2305
|
+
if (destroyed) return
|
|
2306
|
+
const text = pick(hitMilestone ? LINES.milestone : LINES.streak).split('{n}').join(String(streak))
|
|
2307
|
+
flashHappy(text)
|
|
2308
|
+
})
|
|
2309
|
+
}
|
|
2310
|
+
|
|
1934
2311
|
/* ---- 她更新了吗 --------------------------------------------------------
|
|
1935
2312
|
插件不能联网,所以"检查更新"它做不了。但**它知道自己现在是哪一版** —— 于是能做到
|
|
1936
2313
|
一件真事:上次见到的是旧版本,这次不是了,那就是更新过了,说一句。
|
|
@@ -1969,9 +2346,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1969
2346
|
/* warm the other sprites after the first frame so mood changes never blink */
|
|
1970
2347
|
clock.after(1500, () => {
|
|
1971
2348
|
if (destroyed) return
|
|
2349
|
+
/* 预热其余的精灵图。没有 Image 构造函数的地方(简化 DOM)就跳过,
|
|
2350
|
+
预热本来就是锦上添花,不该把整只宠物带崩 —— 这里原来硬用裸的 new Image()。 */
|
|
2351
|
+
const ImageCtor = imageCtor()
|
|
2352
|
+
if (ImageCtor === null) return
|
|
1972
2353
|
for (const key in SPRITES[activeSkin]) {
|
|
1973
2354
|
if (key === currentSprite) continue
|
|
1974
|
-
const pre = new
|
|
2355
|
+
const pre = new ImageCtor()
|
|
1975
2356
|
pre.src = resolveSprite(spriteOf(key))
|
|
1976
2357
|
}
|
|
1977
2358
|
})
|
|
@@ -2130,6 +2511,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2130
2511
|
setSkin: applySkin,
|
|
2131
2512
|
showUpdate,
|
|
2132
2513
|
checkForUpdate,
|
|
2514
|
+
/* 弹窗与折叠分组:菜单结构变了之后,测试要能问"现在开着哪个弹窗 / 哪个分组"。 */
|
|
2515
|
+
openAbout: () => aboutDialog(),
|
|
2516
|
+
openActions: () => actionsDialog(),
|
|
2517
|
+
closeDialog,
|
|
2518
|
+
previewMood,
|
|
2519
|
+
get dialog() { return dialogKind },
|
|
2520
|
+
get menuGroup() { return activeGroup },
|
|
2133
2521
|
get updateCheck() { return { ...updateCheck } },
|
|
2134
2522
|
get hidden() { return hidden },
|
|
2135
2523
|
hide: () => setHidden(true),
|
|
@@ -2242,6 +2630,16 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2242
2630
|
},
|
|
2243
2631
|
/* 干饭:不用猜"她为什么突然捧碗" */
|
|
2244
2632
|
hunger: { enabled: cfg.hungry === true, everyMs: cfg.hungerMs, lastMealAt: Math.round(lastMealAt) },
|
|
2633
|
+
/* 深夜:窗口是可配的,所以把算出来的窗口一起报出来 */
|
|
2634
|
+
night: {
|
|
2635
|
+
enabled: cfg.night === true,
|
|
2636
|
+
window: [cfg.bedtimeHour, cfg.wakeHour],
|
|
2637
|
+
deepFrom: deepHourOf(cfg.bedtimeHour),
|
|
2638
|
+
hour: hourNow(),
|
|
2639
|
+
lastAt: Math.round(lastNightAt),
|
|
2640
|
+
},
|
|
2641
|
+
streak: streakOf(memory.days),
|
|
2642
|
+
awaySince: Math.round(awayAt),
|
|
2245
2643
|
/* 今日轮次:不用猜"她为什么突然得意",数字就在这里 */
|
|
2246
2644
|
runsToday: { day: runsToday.day, count: runsToday.count, said: runsToday.said.slice() },
|
|
2247
2645
|
/* 她记着你:一起多少天、习惯放在哪、常穿哪套、已经说过哪些 */
|
|
@@ -2282,12 +2680,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2282
2680
|
clock.dispose()
|
|
2283
2681
|
for (const ev of WAKE_EVENTS) window.removeEventListener(ev, wake)
|
|
2284
2682
|
window.removeEventListener('resize', onResize)
|
|
2683
|
+
if (typeof document.removeEventListener === 'function') {
|
|
2684
|
+
document.removeEventListener('visibilitychange', onVisibility)
|
|
2685
|
+
}
|
|
2285
2686
|
window.removeEventListener('pointermove', onMove)
|
|
2286
2687
|
window.removeEventListener('pointerup', onUp)
|
|
2287
2688
|
bodyEl.removeEventListener('pointerdown', onDown)
|
|
2288
2689
|
bodyEl.removeEventListener('keydown', onBodyKey)
|
|
2289
2690
|
bodyEl.removeEventListener('contextmenu', onContext)
|
|
2290
2691
|
closeMenu()
|
|
2692
|
+
closeDialog()
|
|
2693
|
+
if (dialogKeyOff) { dialogKeyOff(); dialogKeyOff = null }
|
|
2291
2694
|
hideTab()
|
|
2292
2695
|
if (typeof offTheme === 'function') {
|
|
2293
2696
|
try { offTheme() } catch (e) { /* ignore */ }
|