dsh-wisp 1.37.0 → 1.44.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 +426 -8
- package/cordis.patch.yml +19 -19
- package/lib/client.js +722 -27
- package/lib/client.template.js +722 -27
- package/lib/index.js +312 -28
- package/package.json +2 -1
- package/tools/engine-probe.mjs +421 -0
- package/tools/smoke.mjs +272 -0
- package/verify-wisp.mjs +4751 -3719
package/lib/client.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.44.0'
|
|
55
55
|
|
|
56
56
|
/* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
|
|
57
57
|
忘了更新就会红,不会静默过期。 */
|
|
@@ -81,6 +81,17 @@ const WHATS_NEW = {
|
|
|
81
81
|
'1.35.0': '她开始替你值班:你走开时看着,回来报账;还能说今日小结。',
|
|
82
82
|
'1.36.0': '她会说英文了(跟随界面语言/系统语言,也能强制);音效与"汇报"姿势已就位。',
|
|
83
83
|
'1.37.0': '菜单里能直接选台词语言(跟随界面 / 中文 / English),选一次就记住。',
|
|
84
|
+
'1.38.0': '她有身体了:按下去会压一下弹回来、拖着会侧身、落地会顿一下,指针靠近会朝你倾过来。',
|
|
85
|
+
'1.39.0': '她会看你的余额了(菜单里点一下),未登录或查不到时如实说。',
|
|
86
|
+
'1.40.0': '她的透明处真的点不到了:按轮廓命中,站在按钮上也不挡你点它。',
|
|
87
|
+
'1.41.0': '检查更新更耐得住:一个源连不上不再把另一个拖死,失败时 doctor() 会说清是哪个源、为什么。',
|
|
88
|
+
'1.42.0': '查更新失败时她会直接说清是哪一种:没通道 / 连不上 / 宿主没应答。',
|
|
89
|
+
'1.43.0': '一批"真的用鼠标点过一遍"才发现的问题:拨完开关面板不再消失、大小能调回出厂值、触摸与指针离开不再留姿势、静止档真的会停。',
|
|
90
|
+
'1.43.1': '检查更新多了一条兜底通道(问插件管理器),并且失败时会直接说出"为什么拿不到"。',
|
|
91
|
+
'1.43.2': '台词只说人话;查不成的原因排进「关于她」弹窗,不再往句子里塞英文碎片。',
|
|
92
|
+
'1.44.0': '查更新改成客户端自己抓(bundle 插件没有 host RPC 座位)—— 这才是它一直缺的那条路。',
|
|
93
|
+
'1.43.4': '把查更新失败时的诊断临时写进窗口标题,供定位用。',
|
|
94
|
+
'1.43.3': '把"没接上宿主"和"宿主没有网络通道"分成两句 —— 断在哪一环,一句话就看得出来。',
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
/* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
|
|
@@ -90,6 +101,11 @@ const WHATS_NEW = {
|
|
|
90
101
|
所以这里只做三件真能做的事:报版本、说她更新了、把包名复制给你去 DSH 里装。 */
|
|
91
102
|
const SEEN_VERSION_KEY = 'dsh-wisp:seen-version:v1'
|
|
92
103
|
const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
104
|
+
/* 但"按包名安装"只在 npm 上已经有这一版时成立。npm 暂存(或还没发)而 GitHub
|
|
105
|
+
已经更新时,照那句话装回来的是**旧版** —— 她得改口,并复制仓库地址而不是包名。
|
|
106
|
+
她本来就知道这个版本号是从哪个源读到的(updateCheck.from / sources)。 */
|
|
107
|
+
const UPDATE_REPO = 'https://github.com/969246694/dsh-wisp'
|
|
108
|
+
const UPDATE_HINT_GITHUB = 'npm 上还是旧版,用这个地址装 dsh-wisp:' + UPDATE_REPO
|
|
93
109
|
|
|
94
110
|
/* ---- sprites: 256x384 WebP, transparent, inlined by build.mjs ---- */
|
|
95
111
|
const SPRITES = {
|
|
@@ -219,11 +235,63 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
219
235
|
const WAKE_THROTTLE_MS = 1000
|
|
220
236
|
const WORRIED_MS = 3500 // 出错表情持续多久
|
|
221
237
|
|
|
238
|
+
/* ---------------------------------------------------------------------
|
|
239
|
+
动作(motion)。
|
|
240
|
+
|
|
241
|
+
在这之前,"她的动作"只有两件事:换一张精灵图,以及一条常驻的呼吸
|
|
242
|
+
循环动画。按下、拖动、松手这些**手势**在画面上没有任何回应 —— 她像
|
|
243
|
+
一张贴在屏幕上的图,而不是一个被碰到会有反应的东西。
|
|
244
|
+
|
|
245
|
+
这里补的是"身体感",而且刻意只用 CSS 关键帧 + 一个样式变量:插件
|
|
246
|
+
的稳态开销承诺是**零动画帧**(见 README 性能表),加一条 rAF 循环
|
|
247
|
+
就等于把那条承诺撤掉。所有动作都是"事件写一次属性,浏览器自己算"。
|
|
248
|
+
|
|
249
|
+
三个层各管一件事。CSS 的 transform 在同一个元素上是**覆盖**而不是
|
|
250
|
+
叠加,所以"分层"不是设计洁癖,是唯一做法:
|
|
251
|
+
|
|
252
|
+
.wisp-body 情绪动画(呼吸 / 跳 / 推 / 担心)—— 既有,不动
|
|
253
|
+
.wisp-motion 一次性动作(按下压一下 / 落地顿一下 / 换表情弹一下)
|
|
254
|
+
由 data-accent 驱动,放外层 → 落地时的压扁是**屏幕坐标**
|
|
255
|
+
里的竖直方向(重力方向),不会跟着侧倾一起歪
|
|
256
|
+
.wisp-lean 持续姿势(拖动速度 / 指针靠近 / 溜达方向)
|
|
257
|
+
由 --wisp-tilt 驱动,旋转原点在脚底 → 像钟摆,不像转盘
|
|
258
|
+
|
|
259
|
+
data-accent 的取值里,pop-a / pop-b 是**同一件事的两个名字**:改
|
|
260
|
+
animation-name 是让同一条动画立刻重播的唯一可靠办法(同一个值再写
|
|
261
|
+
一次不会重播),而连戳两下必须看到两次弹。
|
|
262
|
+
--------------------------------------------------------------------- */
|
|
263
|
+
const MOTION_AMP = { full: 1, subtle: 0.5, off: 0 }
|
|
264
|
+
const TILT_DRAG_MAX = 8 // 拖动时最大侧倾(度)
|
|
265
|
+
const TILT_DRAG_PER_PX = 0.55 // 每一次指针事件横向走 1px 折算多少度(≈速度)
|
|
266
|
+
const TILT_NEAR_MAX = 3.5 // 指针靠近时最大侧倾(度)
|
|
267
|
+
const TILT_NEAR_PX = 170 // 多远算"她注意到你了"
|
|
268
|
+
const TILT_GLIDE_MAX = 2.5 // 溜达时朝行进方向前倾(度)
|
|
269
|
+
const TILT_GLIDE_PER_PX = 0.02 // 溜达距离折算成前倾角的斜率
|
|
270
|
+
/* 变化小于这个数就不写样式 —— pointermove 一秒能来上百次,而 0.05° 在
|
|
271
|
+
屏幕上是看不见的。写一次 = 一次样式重算,阈值就是它的闸门。 */
|
|
272
|
+
const TILT_STEP = 0.05
|
|
273
|
+
|
|
274
|
+
/* 动作时长必须是**代码**里的一份:CSS 只负责怎么动,什么时候收手由这里
|
|
275
|
+
决定(和 glide 的 1700ms 一个路子)。两边对不上就会出现"动画早放完了,
|
|
276
|
+
属性还挂着"或者反过来的空档。
|
|
277
|
+
没有 release:松手之后紧接着就是**点击反应**(换表情 → 弹一下),两者
|
|
278
|
+
抢同一个通道,而反应赢 —— "手指抬起来了"不值得盖过"她换了个表情"。
|
|
279
|
+
通道只留最后发生的那件事,不留一个永远演不到的关键帧。 */
|
|
280
|
+
const ACCENT_MS = { press: 200, land: 560, pop: 380 }
|
|
281
|
+
|
|
282
|
+
/* 换表情时"弹一下"的表情集合。idle / alert / sleep 不在里面:它们要么是
|
|
283
|
+
长时间稳态,要么由轮询反复重算,弹一下就变成抖。 */
|
|
284
|
+
const POPPY_MOODS = ['happy', 'poked', 'proud', 'attn', 'care', 'eat', 'worried', 'report']
|
|
285
|
+
|
|
222
286
|
const DEFAULTS = {
|
|
223
287
|
bottom: GROUND,
|
|
224
288
|
right: 26,
|
|
225
289
|
sleepAfterMs: 90000,
|
|
226
290
|
reactions: true,
|
|
291
|
+
/* 交互动效档位:full 灵动 / subtle 克制(半幅)/ off 静止。
|
|
292
|
+
默认全开 —— 但必须有一个**不用改系统设置**就能让她安静下来的开关,
|
|
293
|
+
prefers-reduced-motion 是给"所有动画"的,不是给"我不想让她动"的。 */
|
|
294
|
+
motion: 'full',
|
|
227
295
|
size: 4, // 560x840 CSS px, drawn from the 2048x3072 tier
|
|
228
296
|
persist: true, // remember where she was dragged to
|
|
229
297
|
celebrate: true, // cheer when a run finishes
|
|
@@ -240,6 +308,14 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
240
308
|
backAfterMs: 120000, // 离开超过这么久,回来时她会说一句(2 分钟;0 = 关)
|
|
241
309
|
milestones: true, // 今日轮次到达里程碑时得意一下
|
|
242
310
|
memory: true, // 她记着你:一起多少天 / 常放哪个角 / 常穿哪套
|
|
311
|
+
/* lang 与 soundVolume 曾经**只在 CONFIG_SPEC 里声明、DEFAULTS 里没有**:
|
|
312
|
+
前者靠 isZhNow() 把非字符串兜成 'auto' 侥幸没事,后者直接是 undefined →
|
|
313
|
+
`Number(undefined)` = NaN → `clip.volume = NaN` 在浏览器里抛,被
|
|
314
|
+
playSound 的 try/catch 吞掉 —— 于是"打开音效"整条路静默失效。
|
|
315
|
+
配置键的两个"端"(声明 / 默认值)现在由预检钉住。 */
|
|
316
|
+
lang: 'auto', // 'auto' 跟随界面语言;'zh' / 'en' 强制
|
|
317
|
+
sound: false, // 音效默认关(无故出声是最招人烦的交互之一),且要有素材
|
|
318
|
+
soundVolume: 0.7, // 音效音量(音效本身默认关,且要有 assets/audio 才存在)
|
|
243
319
|
happyMs: 1100,
|
|
244
320
|
wander: true, // stroll around on her own while idle
|
|
245
321
|
wanderMs: 45000, // how often she considers moving
|
|
@@ -398,6 +474,16 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
398
474
|
updateCurrent: ['已经是最新的了。', '我就是最新的。', '没有更新的。'],
|
|
399
475
|
updateAvailable: ['有新版本了:', '有新版:', '更新来了:'],
|
|
400
476
|
updateFailed: ['更新没查成。', '这次没查到更新。', '更新检查没成功。'],
|
|
477
|
+
/* 失败不是一个笼统的状态:「这个壳里没有联网的通道」「两个源都没连上」
|
|
478
|
+
「宿主那边没应答」是三件事,含糊地说成"没查到"就没人能追下去 ——
|
|
479
|
+
而用户看到的恰恰只有那一句。 */
|
|
480
|
+
updateNoChannel: ['这个壳里我没有联网的通道,查不了更新。', '这里没有能出去的网络通道。'],
|
|
481
|
+
/* 两种"没通道"必须分开说 —— 它们是链路上**不同的一环**断了:
|
|
482
|
+
· 没接上宿主半包(客户端这一侧连 `host.call` 都没有)
|
|
483
|
+
· 宿主跑了,但它拿不到 `web` 服务
|
|
484
|
+
合并成一句话的代价是:谁也说不清断在哪一环(这个坑真踩过)。 */
|
|
485
|
+
updateNoSeat: ['我没接上宿主半包,查不了更新。', '宿主那边没接上,查不了更新。'],
|
|
486
|
+
updateNoHost: ['宿主那边没应答,更新没查成。', '问不到宿主,更新没查成。'],
|
|
401
487
|
milestone: [
|
|
402
488
|
'今天第 {n} 轮了。', '{n} 轮了,还不错。', '已经 {n} 轮了,你挺能撑。',
|
|
403
489
|
'第 {n} 轮 —— 我数着呢。', '{n} 轮,进度可观。', '今天 {n} 轮,记上了。',
|
|
@@ -409,6 +495,17 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
409
495
|
'去窗边看看。', '我在这儿等你回来。', '坐太久了,动一下。', '你不动,我就一直看着你。',
|
|
410
496
|
],
|
|
411
497
|
hint: ['右键我一下试试。', '右键我有菜单。', '拖我,或者右键我。'],
|
|
498
|
+
|
|
499
|
+
/* 余额。数字由客户端拼好({n}),台词只管口气 —— 她报的是账户,
|
|
500
|
+
不是她自己的钱,所以既不邀功也不夸张。 */
|
|
501
|
+
balanceChecking: ['看一下……', '等等,我查查。', '让我看看。'],
|
|
502
|
+
balance: ['余额 {n}。', '还有 {n}。', '{n},暂时够用。', '账上是 {n}。'],
|
|
503
|
+
balanceBonus: ['其中赠送 {n}。', '还有 {n} 是赠送的。'],
|
|
504
|
+
balanceEmpty: ['没查到余额记录。', '账上没有额度记录。'],
|
|
505
|
+
balanceSignedOut: ['没登录,看不到余额。', '这个得先登录才行。', '还没登录,余额在平台那边。'],
|
|
506
|
+
balanceUnavailable: ['刚才没读到,一会儿再看。', '这一下没读到,稍后再试。'],
|
|
507
|
+
balanceFailed: ['余额没查成。', '这个没读出来,稍后再试。'],
|
|
508
|
+
balanceUnsupported: ['这个壳里我查不了余额。', '这里没有查余额的通道。'],
|
|
412
509
|
}
|
|
413
510
|
|
|
414
511
|
/* 英文台词。**只翻译她说的话**(菜单按钮文案仍由 DSH 界面语言决定,不在这一层)。
|
|
@@ -443,6 +540,18 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
443
540
|
backSoon: ['Back already?', 'There you are.'],
|
|
444
541
|
backLong: ['You were gone a while.', 'Long break. Welcome back.'],
|
|
445
542
|
streak: ['{n} days in a row.', 'Day {n} together.'],
|
|
543
|
+
updateFailed: ["The update check didn't work.", "Couldn't reach the update sources."],
|
|
544
|
+
updateNoChannel: ["This shell gives me no network channel, so I can't check for updates.", 'No way out to the network from here.'],
|
|
545
|
+
updateNoSeat: ["I'm not connected to my host half, so I can't check for updates.", 'No host half attached.'],
|
|
546
|
+
updateNoHost: ["The host side didn't answer — the update check failed.", 'No answer from the host.'],
|
|
547
|
+
balanceChecking: ['One moment…', 'Let me check.'],
|
|
548
|
+
balance: ['Balance: {n}.', "You've got {n}.", '{n} on the account.'],
|
|
549
|
+
balanceBonus: [' {n} of that is credit.', ' ({n} is credit.)'],
|
|
550
|
+
balanceEmpty: ['No balance record came back.', 'Nothing on the account.'],
|
|
551
|
+
balanceSignedOut: ["You're signed out — I can't see the balance.", 'Sign in first and I can read it.'],
|
|
552
|
+
balanceUnavailable: ["Couldn't read it just now — try again in a bit.", 'That read did not go through.'],
|
|
553
|
+
balanceFailed: ["The balance lookup didn't work.", "Couldn't read that — try again later."],
|
|
554
|
+
balanceUnsupported: ["I can't reach the balance from this shell.", 'No balance channel here.'],
|
|
446
555
|
}
|
|
447
556
|
|
|
448
557
|
/* 语言:中文环境(或读不到语言)用中文,其余走英文覆盖层。
|
|
@@ -586,7 +695,10 @@ const SOUNDS = (typeof __WISP_SOUNDS__ === 'object' && __WISP_SOUNDS__ !== null)
|
|
|
586
695
|
if (Ctor === null) return
|
|
587
696
|
try {
|
|
588
697
|
const clip = new Ctor(src)
|
|
589
|
-
|
|
698
|
+
/* 音量必须落到 [0,1] 内的有限数:给媒体元素设 NaN/Infinity 会**抛异常**,
|
|
699
|
+
而这一整段是被 catch 包住的 —— 一个坏值会让音效静默消失,看起来像"开关没反应"。 */
|
|
700
|
+
const volume = Number(cfg.soundVolume)
|
|
701
|
+
clip.volume = isFinite(volume) ? clamp(volume, 0, 1) : 0.7
|
|
590
702
|
const started = clip.play()
|
|
591
703
|
if (started && typeof started.catch === 'function') started.catch(() => { /* 自动播放被拦下就作罢 */ })
|
|
592
704
|
} catch (e) { /* 音效永远不该打断别的事 */ }
|
|
@@ -617,6 +729,7 @@ const SOUNDS = (typeof __WISP_SOUNDS__ === 'object' && __WISP_SOUNDS__ !== null)
|
|
|
617
729
|
wanderMs: { kind: 'num', lo: 5000, hi: 3600000 },
|
|
618
730
|
wanderRange: { kind: 'num', lo: 0, hi: 2000 },
|
|
619
731
|
reactions: { kind: 'bool' },
|
|
732
|
+
motion: { kind: 'one', of: Object.keys(MOTION_AMP) },
|
|
620
733
|
persist: { kind: 'bool' },
|
|
621
734
|
celebrate: { kind: 'bool' },
|
|
622
735
|
wander: { kind: 'bool' },
|
|
@@ -629,6 +742,9 @@ const SOUNDS = (typeof __WISP_SOUNDS__ === 'object' && __WISP_SOUNDS__ !== null)
|
|
|
629
742
|
const spec = CONFIG_SPEC[key]
|
|
630
743
|
if (spec.kind === 'num') return num(value, spec.lo, spec.hi, fallback)
|
|
631
744
|
if (spec.kind === 'str') return typeof value === 'string' ? value : fallback
|
|
745
|
+
/* 枚举:认不出来的值一律退回 fallback。写错一个档位名不该让她变成
|
|
746
|
+
"动也不动、也没有报错"的那种坏状态。 */
|
|
747
|
+
if (spec.kind === 'one') return spec.of.indexOf(value) >= 0 ? value : fallback
|
|
632
748
|
return bool(value, fallback)
|
|
633
749
|
}
|
|
634
750
|
|
|
@@ -805,8 +921,13 @@ const SOUNDS = (typeof __WISP_SOUNDS__ === 'object' && __WISP_SOUNDS__ !== null)
|
|
|
805
921
|
.wisp-root{position:absolute;width:${BOX_W}px;height:${BOX_H}px;pointer-events:none;transform-origin:top center;--wisp-aura:rgba(110,200,255,.42)}
|
|
806
922
|
.wisp-root[data-dragging="true"]{will-change:transform}
|
|
807
923
|
.wisp-root[data-gliding="true"]{transition:transform 1.6s cubic-bezier(.4,0,.2,1)}
|
|
808
|
-
.wisp-body{position:absolute;inset:0;pointer-events:
|
|
809
|
-
|
|
924
|
+
.wisp-body{position:absolute;inset:0;pointer-events:none;touch-action:none;user-select:none;-webkit-user-select:none;animation:wisp-bob 5.6s ease-in-out infinite}
|
|
925
|
+
/* 命中层:一个**不画任何东西**的空盒子,只负责被鼠标点到。它带一层由 alpha 蒙版
|
|
926
|
+
生成的 clip-path —— 于是"透明处点不到"是**浏览器**做的,而不是 JS 事后补救:
|
|
927
|
+
事件根本不会派到她身上,下面的应用照常收到 hover / 点击 / 右键 / 滚轮。
|
|
928
|
+
(只写 pointer-events:none 是不够的:那只是不拦她自己的事,盒子仍然盖在应用上面。) */
|
|
929
|
+
.wisp-hit{position:absolute;inset:0;pointer-events:auto;cursor:grab}
|
|
930
|
+
.wisp-hit:active{cursor:grabbing}
|
|
810
931
|
.wisp-img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;object-position:bottom center;filter:drop-shadow(0 6px 14px rgba(0,0,0,.30));transition:opacity .28s ease,filter .5s ease}
|
|
811
932
|
.wisp-shine{position:absolute;left:50%;bottom:-8px;width:68%;height:16px;margin-left:-34%;border-radius:50%;background:radial-gradient(ellipse at center,var(--wisp-aura) 0%,transparent 70%);filter:blur(4px);pointer-events:none;transition:background .5s ease}
|
|
812
933
|
.wisp-zzz{position:absolute;right:6px;top:8px;font-size:calc(13px * var(--wisp-scale));font-weight:600;color:var(--wisp-ink);text-shadow:0 0 8px var(--wisp-aura);animation:wisp-zzz 2.6s ease-out infinite;pointer-events:none}
|
|
@@ -829,6 +950,31 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
829
950
|
.wisp-root[data-mood="attn"] .wisp-body{animation-name:wisp-nudge;animation-duration:1.5s;animation-iteration-count:4}
|
|
830
951
|
@keyframes wisp-nudge{0%,100%{transform:translate3d(0,0,0)}25%{transform:translate3d(-7px,0,0)}75%{transform:translate3d(7px,0,0)}}
|
|
831
952
|
@keyframes wisp-hop{0%,100%{transform:translate3d(0,0,0)}45%{transform:translate3d(0,-16px,0)}}
|
|
953
|
+
/* ---- 动作层(见文件上方 motion 常量处的说明)----
|
|
954
|
+
两层都是**空盒子**:绝对定位铺满、不吃指针。姿势与一次性动作分别只碰自己
|
|
955
|
+
那一层的 transform,所以它们永远不会互相覆盖。
|
|
956
|
+
transform-origin 放在脚底:压扁是"踩在地上压下去",侧倾是"钟摆",不是"转盘"。 */
|
|
957
|
+
.wisp-motion,.wisp-lean{position:absolute;inset:0;pointer-events:none;transform-origin:50% 100%}
|
|
958
|
+
.wisp-lean{transform:rotate(var(--wisp-tilt,0deg));transition:transform .5s cubic-bezier(.22,1.2,.36,1)}
|
|
959
|
+
/* 拖动中姿势必须**跟手**:一有过渡,拖起来就像拽着一根橡皮筋。 */
|
|
960
|
+
.wisp-root[data-dragging="true"] .wisp-lean{transition:none}
|
|
961
|
+
/* 幅度档位:一个变量管住所有一次性动作的幅度(关键帧里用 calc 乘它),
|
|
962
|
+
指针侧倾的幅度在 JS 里读同一张表 —— 两边不会各调各的。 */
|
|
963
|
+
.wisp-root{--wisp-amp:1}
|
|
964
|
+
.wisp-root[data-motion="subtle"]{--wisp-amp:.5}
|
|
965
|
+
.wisp-root[data-motion="off"]{--wisp-amp:0}
|
|
966
|
+
/* 静止档:连呼吸和 z 都停 —— 她说"我不想让她动"时,是最彻底的静止。 */
|
|
967
|
+
.wisp-root[data-motion="off"] .wisp-body,.wisp-root[data-motion="off"] .wisp-zzz{animation:none}
|
|
968
|
+
/* 但"不动"不等于"冻住":动画一关,z 会停在满不透明的初始态,看起来像一个卡住的字 */
|
|
969
|
+
.wisp-root[data-motion="off"] .wisp-zzz{opacity:.5}
|
|
970
|
+
.wisp-root[data-accent="press"] .wisp-motion{animation:wisp-press .2s ease-out}
|
|
971
|
+
.wisp-root[data-accent="land"] .wisp-motion{animation:wisp-land .56s cubic-bezier(.2,1.15,.35,1)}
|
|
972
|
+
.wisp-root[data-accent="pop-a"] .wisp-motion{animation:wisp-pop-a .38s ease-out}
|
|
973
|
+
.wisp-root[data-accent="pop-b"] .wisp-motion{animation:wisp-pop-b .38s ease-out}
|
|
974
|
+
@keyframes wisp-press{0%{transform:scale(1,1)}60%{transform:scale(calc(1 + .045*var(--wisp-amp,1)),calc(1 - .06*var(--wisp-amp,1)))}100%{transform:scale(1,1)}}
|
|
975
|
+
@keyframes wisp-land{0%{transform:scale(calc(1 + .06*var(--wisp-amp,1)),calc(1 - .085*var(--wisp-amp,1)))}42%{transform:scale(calc(1 - .022*var(--wisp-amp,1)),calc(1 + .028*var(--wisp-amp,1)))}74%{transform:scale(calc(1 + .01*var(--wisp-amp,1)),calc(1 - .012*var(--wisp-amp,1)))}100%{transform:scale(1,1)}}
|
|
976
|
+
@keyframes wisp-pop-a{0%{transform:scale(1)}46%{transform:scale(calc(1 + .035*var(--wisp-amp,1)))}100%{transform:scale(1)}}
|
|
977
|
+
@keyframes wisp-pop-b{0%{transform:scale(1)}46%{transform:scale(calc(1 + .03*var(--wisp-amp,1)),calc(1 - .025*var(--wisp-amp,1)))}100%{transform:scale(1,1)}}
|
|
832
978
|
/* 菜单与级联子面板共用一套外观变量 —— 面板是菜单的"上一层",只改阴影与浮起量。
|
|
833
979
|
尺寸走**紧凑刻度**:这是陪伴插件的浮层,不该占掉屏幕的观感重心。 */
|
|
834
980
|
.wisp-menu{--wisp-surface:var(--dsw-alias-bg-overlay,var(--wisp-say-bg));--wisp-line:var(--dsw-alias-border-l1,var(--wisp-say-line));--wisp-hover:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.2));--wisp-accent:var(--dsw-alias-brand-primary,#4a7cf7);position:absolute;min-width:164px;max-width:196px;padding:4px;border-radius:9px;font-size:11px;line-height:1.2;color:var(--dsw-alias-label-primary,var(--wisp-say-fg));background:var(--wisp-surface);border:1px solid var(--wisp-line);box-shadow:0 10px 28px rgba(0,0,0,.3);-webkit-backdrop-filter:blur(16px) saturate(160%);backdrop-filter:blur(16px) saturate(160%);pointer-events:auto;animation:wisp-menu .13s ease}
|
|
@@ -906,7 +1052,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
906
1052
|
.wisp-credits{font-size:11px;opacity:.8;margin:6px 0 0}
|
|
907
1053
|
.wisp-dialog-action{display:block;width:100%;margin:4px 0 2px;padding:7px 10px;border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));border-radius:8px;background:none;color:inherit;font:inherit;cursor:pointer}
|
|
908
1054
|
.wisp-dialog-action:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.2))}
|
|
909
|
-
@media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}.wisp-menu,.wisp-submenu,.wisp-switch,.wisp-switch i,.wisp-slider-knob,.wisp-menu-item,.wisp-corner{animation:none!important;transition:none!important}}`
|
|
1055
|
+
@media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}.wisp-motion{animation:none!important}.wisp-zzz{animation:none!important;opacity:.5}.wisp-lean{transform:none!important;transition:none!important}.wisp-menu,.wisp-submenu,.wisp-switch,.wisp-switch i,.wisp-slider-knob,.wisp-menu-item,.wisp-corner{animation:none!important;transition:none!important}}`
|
|
910
1056
|
|
|
911
1057
|
const installStyles = (styles) => {
|
|
912
1058
|
if (styles && typeof styles.insert === 'function') {
|
|
@@ -971,8 +1117,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
971
1117
|
return null
|
|
972
1118
|
}
|
|
973
1119
|
|
|
974
|
-
const buildMask = (key, url) => {
|
|
975
|
-
if (masks[key])
|
|
1120
|
+
const buildMask = (key, url, onReady) => {
|
|
1121
|
+
if (masks[key]) {
|
|
1122
|
+
if (typeof onReady === 'function') onReady(key)
|
|
1123
|
+
return
|
|
1124
|
+
}
|
|
976
1125
|
try {
|
|
977
1126
|
const canvas = document.createElement('canvas')
|
|
978
1127
|
if (!canvas || typeof canvas.getContext !== 'function') return
|
|
@@ -1003,12 +1152,62 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1003
1152
|
// the aura, so without this a click that looks like it is on her
|
|
1004
1153
|
// edge falls into the pass-through margin.
|
|
1005
1154
|
masks[key] = dilate(raw)
|
|
1155
|
+
if (typeof onReady === 'function') onReady(key)
|
|
1006
1156
|
} catch (e) { /* tainted or unsupported: the full box stays hittable */ }
|
|
1007
1157
|
}
|
|
1008
1158
|
probe.src = url
|
|
1009
1159
|
} catch (e) { /* no canvas: the full box stays hittable */ }
|
|
1010
1160
|
}
|
|
1011
1161
|
|
|
1162
|
+
/* ---------------------------------------------------------------------
|
|
1163
|
+
蒙版 → clip-path。
|
|
1164
|
+
|
|
1165
|
+
蒙版是 96×144 的格,这里把每行连续的不透明格合并成矩形(再和上一行同列同宽的
|
|
1166
|
+
矩形纵向合并),输出一条 SVG path。段数通常在几十段(实测某套皮肤 40 段),
|
|
1167
|
+
一次设置、只在换皮肤或改大小时重算 —— 不是每帧的事。
|
|
1168
|
+
|
|
1169
|
+
为什么非要交给浏览器:`pointer-events:none` 只能让她自己不拦事件,她那个
|
|
1170
|
+
560×840 的盒子仍然盖在应用上面 —— 事件**先派给她**,JS 再 return 已经晚了。
|
|
1171
|
+
有了 clip-path,命中判定在浏览器里就按她的轮廓做,透明处是真的穿透。
|
|
1172
|
+
--------------------------------------------------------------------- */
|
|
1173
|
+
const clipPathOf = (mask, width, height) => {
|
|
1174
|
+
if (!mask || !(width > 0) || !(height > 0)) return ''
|
|
1175
|
+
const rects = []
|
|
1176
|
+
let prev = new Map() // 上一行里"可能继续往下长"的矩形
|
|
1177
|
+
for (let y = 0; y < MASK_H; y++) {
|
|
1178
|
+
const next = new Map()
|
|
1179
|
+
let x = 0
|
|
1180
|
+
while (x < MASK_W) {
|
|
1181
|
+
if (mask[y * MASK_W + x] <= MASK_MIN_ALPHA) { x++; continue }
|
|
1182
|
+
const start = x
|
|
1183
|
+
while (x < MASK_W && mask[y * MASK_W + x] > MASK_MIN_ALPHA) x++
|
|
1184
|
+
const key = start + ':' + (x - start)
|
|
1185
|
+
const carried = prev.get(key)
|
|
1186
|
+
if (carried && carried.y + carried.h === y) {
|
|
1187
|
+
carried.h++
|
|
1188
|
+
next.set(key, carried)
|
|
1189
|
+
} else {
|
|
1190
|
+
const rect = { x: start, y, w: x - start, h: 1 }
|
|
1191
|
+
rects.push(rect)
|
|
1192
|
+
next.set(key, rect)
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
prev = next
|
|
1196
|
+
}
|
|
1197
|
+
if (rects.length === 0) return ''
|
|
1198
|
+
const sx = width / MASK_W
|
|
1199
|
+
const sy = height / MASK_H
|
|
1200
|
+
let d = ''
|
|
1201
|
+
for (const rect of rects) {
|
|
1202
|
+
const rx = (rect.x * sx).toFixed(1)
|
|
1203
|
+
const ry = (rect.y * sy).toFixed(1)
|
|
1204
|
+
const rw = (rect.w * sx).toFixed(1)
|
|
1205
|
+
const rh = (rect.h * sy).toFixed(1)
|
|
1206
|
+
d += 'M' + rx + ' ' + ry + 'h' + rw + 'v' + rh + 'h-' + rw + 'z'
|
|
1207
|
+
}
|
|
1208
|
+
return 'path("' + d + '")'
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1012
1211
|
/* ---------------------------------------------------------------------
|
|
1013
1212
|
WHAT THE APP IS DOING — read from the DOM, deliberately.
|
|
1014
1213
|
|
|
@@ -1316,6 +1515,22 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1316
1515
|
sayEl.style.top = Math.round(Math.max(coords.y, SAY_CLEARANCE)) + 'px'
|
|
1317
1516
|
}
|
|
1318
1517
|
|
|
1518
|
+
/* ---- 姿势角 --------------------------------------------------------
|
|
1519
|
+
tiltScreen 是**屏幕坐标**里的角度:正数 = 顶朝右。
|
|
1520
|
+
但她在左半边时整个 root 是镜像的(scaleX(-1)),镜像会把里面的旋转
|
|
1521
|
+
一起翻过去 —— 所以写进样式前先乘一次 facing。少了这一步,"她朝指针
|
|
1522
|
+
倾"在她跨过屏幕中线之后会变成朝反方向倾,而且只在半张屏幕上错。
|
|
1523
|
+
它必须写在 paint() 里,因为 facing 只在 paint() 里算。 */
|
|
1524
|
+
let tiltScreen = 0
|
|
1525
|
+
let tiltWritten = null
|
|
1526
|
+
let facingNow = 1
|
|
1527
|
+
const applyTilt = () => {
|
|
1528
|
+
const text = (Math.round(tiltScreen * facingNow * 10) / 10) + 'deg'
|
|
1529
|
+
if (text === tiltWritten) return // 同一个值不重复写:写一次就是一次样式重算
|
|
1530
|
+
tiltWritten = text
|
|
1531
|
+
root.style.setProperty('--wisp-tilt', text)
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1319
1534
|
const paint = () => {
|
|
1320
1535
|
const facing = coords.x + boxW() / 2 < vw() / 2 ? -1 : 1
|
|
1321
1536
|
// The translate places the box's TOP-LEFT corner, so it is `coords.x`,
|
|
@@ -1323,7 +1538,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1323
1538
|
// feeding it a centre offset put the whole box half a box-width to the
|
|
1324
1539
|
// right, off the edge of the viewport. The mirror still pivots in place
|
|
1325
1540
|
// because `transform-origin` is the top CENTRE.
|
|
1541
|
+
facingNow = facing
|
|
1326
1542
|
root.style.transform = 'translate3d(' + coords.x + 'px,' + coords.y + 'px,0) scaleX(' + facing + ')'
|
|
1543
|
+
applyTilt()
|
|
1327
1544
|
placeSay()
|
|
1328
1545
|
}
|
|
1329
1546
|
paint()
|
|
@@ -1335,7 +1552,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1335
1552
|
bodyEl.style.left = '0'
|
|
1336
1553
|
bodyEl.style.right = '0'
|
|
1337
1554
|
bodyEl.style.bottom = '0'
|
|
1338
|
-
bodyEl.style.pointerEvents = '
|
|
1555
|
+
bodyEl.style.pointerEvents = 'none'
|
|
1339
1556
|
bodyEl.setAttribute('role', 'button')
|
|
1340
1557
|
/* tabindex 是 0 而不是 -1:菜单已经支持键盘导航(↑↓/Home/End/Esc),但如果她本身
|
|
1341
1558
|
不可聚焦,键盘用户就**没有办法打开它** —— 声明了 role="menu" 却没有入口,与
|
|
@@ -1345,6 +1562,38 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1345
1562
|
bodyEl.setAttribute('aria-label', 'DeepSeek娘')
|
|
1346
1563
|
bodyEl.setAttribute('aria-haspopup', 'menu')
|
|
1347
1564
|
|
|
1565
|
+
/* 命中层(见 clipPathOf 上面的说明):空盒子 + 由蒙版生成的 clip-path。
|
|
1566
|
+
关键样式同样行内再写一遍 —— 样式表缺失时它必须仍然是"铺满、吃指针"的那个。 */
|
|
1567
|
+
const hitEl = document.createElement('div')
|
|
1568
|
+
hitEl.className = 'wisp-hit'
|
|
1569
|
+
hitEl.setAttribute('aria-hidden', 'true')
|
|
1570
|
+
hitEl.style.position = 'absolute'
|
|
1571
|
+
hitEl.style.top = '0'
|
|
1572
|
+
hitEl.style.left = '0'
|
|
1573
|
+
hitEl.style.right = '0'
|
|
1574
|
+
hitEl.style.bottom = '0'
|
|
1575
|
+
hitEl.style.pointerEvents = 'auto'
|
|
1576
|
+
hitEl.style.cursor = 'grab'
|
|
1577
|
+
|
|
1578
|
+
/* 动作层(见文件上方 motion 常量处的说明):两个空盒子,把"姿势"与
|
|
1579
|
+
"一次性动作"从情绪动画里分出来。关键样式同样**行内再写一遍** ——
|
|
1580
|
+
理由和 layer/root 一样:样式表缺失时它们是 position:static 的普通
|
|
1581
|
+
div,会把精灵图挤成两个叠着的方块。 */
|
|
1582
|
+
const motionEl = document.createElement('div')
|
|
1583
|
+
motionEl.className = 'wisp-motion'
|
|
1584
|
+
const leanEl = document.createElement('div')
|
|
1585
|
+
leanEl.className = 'wisp-lean'
|
|
1586
|
+
for (const box of [motionEl, leanEl]) {
|
|
1587
|
+
box.style.position = 'absolute'
|
|
1588
|
+
box.style.top = '0'
|
|
1589
|
+
box.style.left = '0'
|
|
1590
|
+
box.style.right = '0'
|
|
1591
|
+
box.style.bottom = '0'
|
|
1592
|
+
box.style.pointerEvents = 'none'
|
|
1593
|
+
/* 旋转/压扁的原点在脚底:否则侧倾像转盘,压扁像从头顶往下缩。 */
|
|
1594
|
+
box.style.transformOrigin = '50% 100%'
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1348
1597
|
const shine = document.createElement('div')
|
|
1349
1598
|
shine.className = 'wisp-shine'
|
|
1350
1599
|
|
|
@@ -1375,9 +1624,23 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1375
1624
|
return skin.idle
|
|
1376
1625
|
}
|
|
1377
1626
|
|
|
1627
|
+
/* 把当前精灵图的蒙版变成命中层的形状。蒙版是异步来的(图片解码完才有),
|
|
1628
|
+
所以它由 buildMask 的回调触发;换皮肤、改大小的时候各再调一次。 */
|
|
1629
|
+
let hitClip = null
|
|
1630
|
+
const applyHitClip = (key) => {
|
|
1631
|
+
if (key !== currentSprite) return // 过期的回调:那张图已经不是现在这张
|
|
1632
|
+
const mask = masks[activeSkin + '/' + key]
|
|
1633
|
+
const path = mask ? clipPathOf(mask, boxW(), boxH()) : ''
|
|
1634
|
+
if (path === hitClip) return // 同一个形状不重复写
|
|
1635
|
+
hitClip = path
|
|
1636
|
+
hitEl.style.clipPath = path
|
|
1637
|
+
hitEl.style.webkitClipPath = path
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1378
1640
|
const setSprite = (key) => {
|
|
1641
|
+
currentSprite = key
|
|
1379
1642
|
const url = resolveSprite(spriteOf(key))
|
|
1380
|
-
buildMask(activeSkin + '/' + key, url)
|
|
1643
|
+
buildMask(activeSkin + '/' + key, url, () => applyHitClip(key))
|
|
1381
1644
|
currentSprite = key
|
|
1382
1645
|
/* Cross-fade rather than swapping `src` outright: a hard swap makes every
|
|
1383
1646
|
mood change read as a glitch. The outgoing sprite keeps its own layer
|
|
@@ -1401,7 +1664,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1401
1664
|
outgoing.draggable = false
|
|
1402
1665
|
outgoing.setAttribute('aria-hidden', 'true')
|
|
1403
1666
|
outgoing.src = previous
|
|
1404
|
-
|
|
1667
|
+
leanEl.insertBefore(outgoing, img)
|
|
1405
1668
|
img.src = url
|
|
1406
1669
|
img.style.opacity = '0'
|
|
1407
1670
|
clock.after(16, () => {
|
|
@@ -1412,8 +1675,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1412
1675
|
clock.after(340, () => { if (outgoing.parentNode) outgoing.parentNode.removeChild(outgoing) })
|
|
1413
1676
|
}
|
|
1414
1677
|
|
|
1415
|
-
|
|
1416
|
-
|
|
1678
|
+
/* 结构:body(情绪动画)> motion(一次性动作)> lean(姿势)> shine/img,
|
|
1679
|
+
外加一个兄弟节点 hit(命中层)。 */
|
|
1680
|
+
/* 光晕(脚底那圈光)挂在 motion 层、**不在 lean 层**:它是地面上的东西,
|
|
1681
|
+
不该跟着她的侧身一起歪 —— 挂在 lean 里的时候,她一倾斜光圈也跟着转,
|
|
1682
|
+
看起来像地板歪了。但按下/落地那一下的压扁它该跟着(那是"她压在地上"),
|
|
1683
|
+
所以留在 motion 层而不是提到 body 层。 */
|
|
1684
|
+
motionEl.appendChild(shine)
|
|
1685
|
+
leanEl.appendChild(img)
|
|
1686
|
+
motionEl.appendChild(leanEl)
|
|
1687
|
+
bodyEl.appendChild(motionEl)
|
|
1688
|
+
bodyEl.appendChild(hitEl)
|
|
1417
1689
|
root.appendChild(bodyEl)
|
|
1418
1690
|
layer.appendChild(root)
|
|
1419
1691
|
setSprite('idle')
|
|
@@ -1470,10 +1742,86 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1470
1742
|
return true
|
|
1471
1743
|
}
|
|
1472
1744
|
|
|
1745
|
+
/* ---- 动作层的三个入口 -------------------------------------------------
|
|
1746
|
+
amp() :幅度档位(full 1 / subtle .5 / off 0),CSS 那边读的是同一个
|
|
1747
|
+
区间 —— 关键帧里用 calc 乘 --wisp-amp,指针侧倾在这里乘。
|
|
1748
|
+
reduced():prefers-reduced-motion。CSS 已经把动画关掉了,但**姿势是 JS
|
|
1749
|
+
写进去的**,样式表关不掉它,所以这里必须再问一次系统。
|
|
1750
|
+
matchMedia 不存在的壳里返回 false:拿不到就不假装系统说了什么。
|
|
1751
|
+
accent() :放一次"一次性动作"。它只写一个属性,什么时候收手由这里的
|
|
1752
|
+
时钟决定(CSS 只管怎么动)—— 所以不会出现"动画早放完了、
|
|
1753
|
+
属性还挂着"那种半截状态。 */
|
|
1754
|
+
let reducedMql = null
|
|
1755
|
+
const reduced = () => {
|
|
1756
|
+
try {
|
|
1757
|
+
if (reducedMql === null && typeof window.matchMedia === 'function') {
|
|
1758
|
+
reducedMql = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
1759
|
+
}
|
|
1760
|
+
return !!(reducedMql && reducedMql.matches)
|
|
1761
|
+
} catch (e) { return false }
|
|
1762
|
+
}
|
|
1763
|
+
const amp = () => {
|
|
1764
|
+
const value = MOTION_AMP[cfg.motion]
|
|
1765
|
+
return typeof value === 'number' ? value : 1
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
let accentHandle = null
|
|
1769
|
+
let accentFlip = false
|
|
1770
|
+
const accent = (name) => {
|
|
1771
|
+
if (destroyed) return
|
|
1772
|
+
/* 静止档与"系统要求别动"都不放动作。CSS 那边也关着(媒体查询 +
|
|
1773
|
+
--wisp-amp:0),但**属性本身也不该被写上** —— 否则 doctor() 会报一个
|
|
1774
|
+
根本没发生的动作,读代码的人也会以为这条路是活的。 */
|
|
1775
|
+
if (amp() === 0 || reduced()) return
|
|
1776
|
+
/* pop-a / pop-b 是同一件事的两个名字。**同一个值再写一次不会重播动画**,
|
|
1777
|
+
而连戳两下必须看到两次弹 —— 换名字是这里最可靠的强制重播办法
|
|
1778
|
+
(靠读一次 offsetWidth 强制重排也能做到,但那要在替身里也装一个
|
|
1779
|
+
布局引擎,而替身没有布局)。 */
|
|
1780
|
+
const value = name === 'pop'
|
|
1781
|
+
? ((accentFlip = !accentFlip) ? 'pop-b' : 'pop-a')
|
|
1782
|
+
: name
|
|
1783
|
+
root.dataset.accent = value
|
|
1784
|
+
if (accentHandle) clock.cancel(accentHandle)
|
|
1785
|
+
accentHandle = clock.after(ACCENT_MS[name] || 400, () => {
|
|
1786
|
+
accentHandle = null
|
|
1787
|
+
if (!destroyed) delete root.dataset.accent
|
|
1788
|
+
})
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/* 姿势:° 是**屏幕坐标**里的角度(正 = 顶朝右),乘镜像由 applyTilt 做。
|
|
1792
|
+
这里是所有姿势的唯一闸门 —— 静止档与"系统要求别动"都在这一个地方归零,
|
|
1793
|
+
调用点不必各自记得判断。 */
|
|
1794
|
+
const setTilt = (deg) => {
|
|
1795
|
+
const next = (amp() === 0 || reduced()) ? 0 : clamp(deg, -TILT_DRAG_MAX, TILT_DRAG_MAX)
|
|
1796
|
+
if (Math.abs(next - tiltScreen) < TILT_STEP) return
|
|
1797
|
+
tiltScreen = next
|
|
1798
|
+
applyTilt()
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/* 档位改动要**当场**生效,不用刷新:属性一改,CSS 那边的全部幅度规则
|
|
1802
|
+
(关键帧里的 --wisp-amp、静止档关掉呼吸与 z)立刻跟上。
|
|
1803
|
+
另外把姿势归零 —— 从"灵动"切到"静止"时她还歪着,就成了半截状态。 */
|
|
1804
|
+
const applyMotion = () => {
|
|
1805
|
+
root.dataset.motion = cfg.motion
|
|
1806
|
+
if (amp() === 0 || reduced()) setTilt(0)
|
|
1807
|
+
}
|
|
1808
|
+
applyMotion()
|
|
1809
|
+
|
|
1473
1810
|
const setMood = (next) => {
|
|
1474
|
-
if (destroyed
|
|
1811
|
+
if (destroyed) return
|
|
1812
|
+
if (mood === next) return
|
|
1475
1813
|
mood = next
|
|
1476
1814
|
root.dataset.mood = next
|
|
1815
|
+
/* 换表情 = 弹一下(只给有表情的那几个状态配,理由见 POPPY_MOODS)。
|
|
1816
|
+
它必须在"真的换了表情"**之后**:`attn` 也在 POPPY_MOODS 里,而
|
|
1817
|
+
poll 只要看到待处理面板就每一轮重算 setMood('attn') —— 放在去重之前,
|
|
1818
|
+
她会在你盯着审批卡片等的时候每 1.2 秒抽一下(真机上 6 秒 5 次)。
|
|
1819
|
+
"连戳两下第二下也要弹"是**手势**的要求,由 flashHappy/flashPoked
|
|
1820
|
+
自己补(见那两处),不该让稳态重算也走这条路。 */
|
|
1821
|
+
if (POPPY_MOODS.indexOf(next) >= 0) accent('pop')
|
|
1822
|
+
/* 睡着时不该还朝着你的指针倾 —— 那和一个"睡着了还跟着你转头"的人一样怪。
|
|
1823
|
+
在**进入** sleep 时归零,而不是在指针处理器里每帧都判一次。 */
|
|
1824
|
+
if (next === 'sleep') setTilt(0)
|
|
1477
1825
|
const want = SPRITE_OF[next] || 'idle'
|
|
1478
1826
|
if (want !== currentSprite) setSprite(want)
|
|
1479
1827
|
const zs = root.querySelectorAll('.wisp-zzz')
|
|
@@ -1501,6 +1849,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1501
1849
|
const el = document.createElement('div')
|
|
1502
1850
|
el.className = 'wisp-say'
|
|
1503
1851
|
el.style.position = 'absolute'
|
|
1852
|
+
/* 气泡的淡出时长跟着**它的寿命**走,而不是跟着样式表里那个数:
|
|
1853
|
+
两边各写一份就会漂(样式表里写 4.2s、代码里活 4.4s —— 气泡会提前
|
|
1854
|
+
200ms 淡完再干等)。和 ACCENT_MS 一个道理:CSS 管怎么动,代码管多久。 */
|
|
1855
|
+
el.style.animationDuration = (SAY_MS / 1000) + 's'
|
|
1504
1856
|
el.textContent = text
|
|
1505
1857
|
// Into the LAYER, not the root: a child of the root would be mirrored
|
|
1506
1858
|
// with her and scaled by `size`.
|
|
@@ -1734,6 +2086,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1734
2086
|
}
|
|
1735
2087
|
|
|
1736
2088
|
const flashHappy = (text) => {
|
|
2089
|
+
/* 手势驱动的反应:**已经是这个表情时也要弹**。setMood 的弹一下挂在
|
|
2090
|
+
"真的换了表情"后面(否则轮询重算的 attn 会变成 1.2 秒一次的抽搐),
|
|
2091
|
+
所以"连戳两下"这条要求由这里补上 —— 它只由用户的手势触发。 */
|
|
2092
|
+
if (mood === 'happy') accent('pop')
|
|
1737
2093
|
setMood('happy')
|
|
1738
2094
|
say(text)
|
|
1739
2095
|
if (happyHandle) clock.cancel(happyHandle)
|
|
@@ -1745,6 +2101,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1745
2101
|
const POKED_MS = 1800
|
|
1746
2102
|
let pokedHandle = null
|
|
1747
2103
|
const flashPoked = (text) => {
|
|
2104
|
+
if (mood === 'poked') accent('pop') // 同上:连戳到底也要每次都弹
|
|
1748
2105
|
setMood('poked')
|
|
1749
2106
|
say(text)
|
|
1750
2107
|
if (pokedHandle) clock.cancel(pokedHandle)
|
|
@@ -2017,23 +2374,32 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2017
2374
|
`data-gliding`,由 CSS 过渡接管 transform;一旦按下就立刻摘掉,
|
|
2018
2375
|
否则拖动会被过渡拖后腿。 */
|
|
2019
2376
|
let glideHandle = null
|
|
2020
|
-
const glideTo = () => {
|
|
2377
|
+
const glideTo = (dx) => {
|
|
2021
2378
|
root.dataset.gliding = 'true'
|
|
2022
2379
|
paint()
|
|
2380
|
+
/* 朝行进方向微微前倾。整段 1.6 秒的滑行里,这一点倾斜就是"她往那边去"
|
|
2381
|
+
与"她被平移到那边"的区别 —— 归零那一半由 .wisp-lean 的过渡做。 */
|
|
2382
|
+
setTilt(clamp((Number(dx) || 0) * TILT_GLIDE_PER_PX, -TILT_GLIDE_MAX, TILT_GLIDE_MAX) * amp())
|
|
2023
2383
|
if (glideHandle) clock.cancel(glideHandle)
|
|
2024
2384
|
glideHandle = clock.after(1700, () => {
|
|
2025
2385
|
glideHandle = null
|
|
2026
2386
|
delete root.dataset.gliding
|
|
2387
|
+
setTilt(0)
|
|
2027
2388
|
})
|
|
2028
2389
|
}
|
|
2029
2390
|
const stopGlide = () => {
|
|
2030
2391
|
if (glideHandle) { clock.cancel(glideHandle); glideHandle = null }
|
|
2031
2392
|
delete root.dataset.gliding
|
|
2393
|
+
setTilt(0)
|
|
2032
2394
|
}
|
|
2033
2395
|
|
|
2034
2396
|
let wanderHandle = null
|
|
2035
2397
|
const syncWander = () => {
|
|
2036
2398
|
if (wanderHandle) { clock.cancel(wanderHandle); wanderHandle = null }
|
|
2399
|
+
/* 溜达也是"动作":`motion: off` 和 prefers-reduced-motion 都该让她待在原地。
|
|
2400
|
+
之前只关了 CSS 动画,她照样会自己滑走 —— 那是最容易被忽略的一种"没停下来",
|
|
2401
|
+
因为看起来只是一次正常的挪动。(CSS 关不掉 JS 写进去的坐标。) */
|
|
2402
|
+
if (cfg.motion === 'off' || reduced()) return
|
|
2037
2403
|
if (!cfg.wander || cfg.wanderRange <= 0 || paused) return
|
|
2038
2404
|
wanderHandle = clock.every(cfg.wanderMs, () => {
|
|
2039
2405
|
if (destroyed || hidden || mood !== 'idle' || start !== null) return
|
|
@@ -2042,9 +2408,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2042
2408
|
// a real stroll rather than a rounding error in a random direction.
|
|
2043
2409
|
const angle = Math.random() * Math.PI * 2
|
|
2044
2410
|
const dist = cfg.wanderRange * (0.45 + Math.random() * 0.55)
|
|
2411
|
+
const fromX = coords.x
|
|
2045
2412
|
coords.x = clamp(coords.x + Math.cos(angle) * dist, 0, maxX())
|
|
2046
2413
|
coords.y = clamp(coords.y + Math.sin(angle) * dist * 0.6, 0, maxY())
|
|
2047
|
-
glideTo()
|
|
2414
|
+
glideTo(coords.x - fromX)
|
|
2048
2415
|
// 故意不 rememberPosition():记忆的是"用户把她放在哪",她自己的溜达
|
|
2049
2416
|
// 不该覆盖那个意图,否则刷新后她会出现在一个用户从没选过的位置。
|
|
2050
2417
|
})
|
|
@@ -2100,13 +2467,15 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2100
2467
|
if (destroyed) return
|
|
2101
2468
|
hidden = next === true
|
|
2102
2469
|
root.dataset.hidden = hidden ? 'true' : 'false'
|
|
2103
|
-
if (hidden) { closeMenu(); hideTab(); clearSay(); showTab() }
|
|
2470
|
+
if (hidden) { closeMenu(); hideTab(); clearSay(); showTab(); setTilt(0) }
|
|
2104
2471
|
else { hideTab(); paint() }
|
|
2105
2472
|
if (cfg.persist) writeStored(HIDDEN_KEY, { hidden })
|
|
2106
2473
|
}
|
|
2107
2474
|
|
|
2108
2475
|
/* ---- drag & click ---- */
|
|
2109
2476
|
let start = null, origin = null, moved = false, lastClick = 0
|
|
2477
|
+
/* 上一次指针的横坐标:拖动中的侧倾是"这一段的位移",不是"从按下算起的总位移" */
|
|
2478
|
+
let lastDragX = 0
|
|
2110
2479
|
/* 连戳计数:POKE_WINDOW_MS 内累计到 POKE_ANNOYED_AT 次就抗议一次 */
|
|
2111
2480
|
const POKE_WINDOW_MS = 3000
|
|
2112
2481
|
const POKE_ANNOYED_AT = 3
|
|
@@ -2139,14 +2508,34 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2139
2508
|
const dx = e.clientX - start.x, dy = e.clientY - start.y
|
|
2140
2509
|
if (!moved && Math.sqrt(dx * dx + dy * dy) > 4) moved = true
|
|
2141
2510
|
if (!moved) return
|
|
2511
|
+
const beforeX = coords.x, beforeY = coords.y
|
|
2142
2512
|
coords.x = clamp(origin.x + dx, 0, maxX())
|
|
2143
2513
|
coords.y = clamp(origin.y + dy, 0, maxY())
|
|
2514
|
+
/* 被边界夹住时她其实**没有动** —— 这时候再按指针位移去倾,就是"原地歪",
|
|
2515
|
+
看起来像坏了。位置没变就把姿势归零(指针继续拖也不影响,因为下一帧
|
|
2516
|
+
只要真的动了就会重新算)。 */
|
|
2517
|
+
if (coords.x === beforeX && coords.y === beforeY) {
|
|
2518
|
+
lastDragX = e.clientX
|
|
2519
|
+
setTilt(0)
|
|
2520
|
+
return
|
|
2521
|
+
}
|
|
2522
|
+
/* 拖动时她朝**运动的反方向**倾:被拎着走的东西,顶是往后倒的。
|
|
2523
|
+
只看"这一次事件走了多远"就等价于速度,不用存历史、不用积分 ——
|
|
2524
|
+
而且它天然跟着指针事件的频率走,慢拖就是小角度。 */
|
|
2525
|
+
setTilt(-(e.clientX - lastDragX) * TILT_DRAG_PER_PX * amp())
|
|
2526
|
+
lastDragX = e.clientX
|
|
2144
2527
|
paint()
|
|
2145
2528
|
}
|
|
2146
2529
|
const onUp = () => {
|
|
2147
2530
|
window.removeEventListener('pointermove', onMove)
|
|
2148
2531
|
window.removeEventListener('pointerup', onUp)
|
|
2149
2532
|
delete root.dataset.dragging
|
|
2533
|
+
/* 松手分两种:真拖过 = 落地(顿一下);只按了一下 = 什么都不放 ——
|
|
2534
|
+
紧接着的点击反应会自己弹一下(见 ACCENT_MS 的说明:通道只留
|
|
2535
|
+
最后发生的那件事)。姿势则一律由 CSS 过渡归零(拖动中它被
|
|
2536
|
+
data-dragging 关掉了,现在恢复了)。 */
|
|
2537
|
+
if (moved) accent('land')
|
|
2538
|
+
setTilt(0)
|
|
2150
2539
|
start = null
|
|
2151
2540
|
if (moved) {
|
|
2152
2541
|
rememberPosition()
|
|
@@ -2186,15 +2575,61 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2186
2575
|
if (!hitsBody(e)) return
|
|
2187
2576
|
stopGlide() // a stroll must not fight the drag
|
|
2188
2577
|
e.preventDefault(); e.stopPropagation()
|
|
2578
|
+
/* 点她 = 把键盘焦点给她。以前这是浏览器的默认行为(她自己就是那个被点的
|
|
2579
|
+
tabindex=0 元素);命中层接管点击之后默认焦点会落到 <body>,于是
|
|
2580
|
+
"点一下她、再按 Enter 开菜单"就断了 —— 显式补回来。 */
|
|
2581
|
+
focusHer()
|
|
2189
2582
|
start = { x: e.clientX, y: e.clientY }
|
|
2190
2583
|
origin = { x: coords.x, y: coords.y }
|
|
2191
2584
|
moved = false
|
|
2585
|
+
lastDragX = e.clientX
|
|
2192
2586
|
root.dataset.dragging = 'true'
|
|
2587
|
+
/* 按下去就压一下 —— 触摸反馈要在**按下**那一刻发生,等到松手才动
|
|
2588
|
+
就已经晚了(那正是"拖动"与"点击"共同的起点)。 */
|
|
2589
|
+
accent('press')
|
|
2193
2590
|
window.addEventListener('pointermove', onMove)
|
|
2194
2591
|
window.addEventListener('pointerup', onUp)
|
|
2195
2592
|
}
|
|
2196
2593
|
bodyEl.addEventListener('pointerdown', onDown)
|
|
2197
2594
|
|
|
2595
|
+
/* ---- 姿势:她注意到你的指针 -------------------------------------------
|
|
2596
|
+
指针进到 170px 的圈里,她朝指针方向侧身;贴得越近倾得越多,出圈归零。
|
|
2597
|
+
|
|
2598
|
+
这是全插件唯一一处"持续读指针"的逻辑,所以它必须便宜:
|
|
2599
|
+
· 纯算术,不查 DOM、不读布局(读一次 getBoundingClientRect 就是一次强制重排)
|
|
2600
|
+
· 出圈就走 setTilt(0),而 setTilt 自己挡掉"变化不到 0.05°"的写入
|
|
2601
|
+
· 拖动中直接返回 —— 那时候姿势由速度接管
|
|
2602
|
+
window 上本来就挂着 pointermove(wake:她得能被碰醒),所以这里不是
|
|
2603
|
+
新开一条全局热路径,只是同一条路上的第二个判断。 */
|
|
2604
|
+
const leanToPointer = (e) => {
|
|
2605
|
+
if (destroyed || paused || hidden || start !== null) return
|
|
2606
|
+
if (mood === 'sleep') return
|
|
2607
|
+
if (cfg.motion === 'off' || reduced()) return
|
|
2608
|
+
/* 触摸屏上"指针"就是手指:手指划过时让她整只歪过去是错的 ——
|
|
2609
|
+
那既不是"靠近"也不是"路过",而且手指一抬起姿势就冻在那儿。
|
|
2610
|
+
所以触摸一律归零(真正该有的反馈是"按下压一下",那是另一条通道)。 */
|
|
2611
|
+
if (e.pointerType === 'touch') { setTilt(0); return }
|
|
2612
|
+
if (typeof e.clientX !== 'number' || typeof e.clientY !== 'number') return
|
|
2613
|
+
const dx = e.clientX - (coords.x + boxW() / 2)
|
|
2614
|
+
const dy = e.clientY - (coords.y + boxH() / 2)
|
|
2615
|
+
if (Math.abs(dx) > TILT_NEAR_PX || Math.abs(dy) > TILT_NEAR_PX) { setTilt(0); return }
|
|
2616
|
+
const near = 1 - Math.abs(dx) / TILT_NEAR_PX
|
|
2617
|
+
const level = 1 - Math.abs(dy) / TILT_NEAR_PX // 从她正上方路过不该让她倾
|
|
2618
|
+
setTilt((dx < 0 ? -1 : 1) * TILT_NEAR_MAX * near * level * amp())
|
|
2619
|
+
}
|
|
2620
|
+
window.addEventListener('pointermove', leanToPointer, { passive: true })
|
|
2621
|
+
/* 指针移出窗口、或者你切走应用之后,不会再有点事件来把它归零 ——
|
|
2622
|
+
没有这一下,她会一直朝着最后那个位置歪着。
|
|
2623
|
+
`blur` 只覆盖"切走应用",**指针移出窗口不会触发它** —— 补一个文档级
|
|
2624
|
+
mouseleave(指针离开文档时才发),两条一起才真的覆盖到。 */
|
|
2625
|
+
const resetTilt = () => setTilt(0)
|
|
2626
|
+
window.addEventListener('blur', resetTilt)
|
|
2627
|
+
try {
|
|
2628
|
+
if (typeof document !== 'undefined' && document.addEventListener) {
|
|
2629
|
+
document.addEventListener('mouseleave', resetTilt)
|
|
2630
|
+
}
|
|
2631
|
+
} catch (e) { /* 没有 document:她最多保留最后一次侧身,不影响其它功能 */ }
|
|
2632
|
+
|
|
2198
2633
|
/* 键盘入口:Enter / Space / 菜单键 / Shift+F10 打开菜单。
|
|
2199
2634
|
Space 默认会滚动页面,Enter 在表单里会提交 —— 都要拦。 */
|
|
2200
2635
|
/* 方向键微调位置。
|
|
@@ -2233,7 +2668,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2233
2668
|
只认**带修饰键**的滚轮:她占地不小,直接吞掉普通滚轮等于在她身上把页面滚动禁掉。
|
|
2234
2669
|
带修饰键是「缩放」的通行手势,此时 preventDefault 才是帮忙而不是捣乱。
|
|
2235
2670
|
步长 0.25,上下限由 CONFIG_SPEC 统一夹紧(0.4–8),不在这里再写一遍。 */
|
|
2236
|
-
|
|
2671
|
+
/* 步长必须是"默认值可达"的:size 的区间是 0.4–8,默认 2.5。
|
|
2672
|
+
原来键盘/滚轮步长 0.25,可达值只有 0.4 + 0.25k —— **2.5 不在格点上**
|
|
2673
|
+
(最近的是 2.4 / 2.65),于是用户只要动过一次滑块,就再也调不回出厂大小。
|
|
2674
|
+
0.1 让 2.5 正好落在格点上(0.4 + 21×0.1)。预检里有一条守着这个不变式。 */
|
|
2675
|
+
const WHEEL_STEP = 0.1
|
|
2237
2676
|
const onWheel = (ev) => {
|
|
2238
2677
|
if (!ev.ctrlKey && !ev.metaKey) return // 普通滚轮原样放行
|
|
2239
2678
|
if (typeof ev.preventDefault === 'function') ev.preventDefault()
|
|
@@ -2544,12 +2983,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2544
2983
|
子面板里的开关点完必须留在原地 —— 拨个开关面板就消失是最烦人的交互之一。 */
|
|
2545
2984
|
const refreshSurfaces = () => {
|
|
2546
2985
|
const keep = flyoutName
|
|
2986
|
+
/* 重画之前先记住"这个面板是点开的还是悬停开的":openMenu 里的 closeFlyout()
|
|
2987
|
+
会把标记清掉,不记下来,拨一次开关后面板就从"钉住"降级成"悬停开启" ——
|
|
2988
|
+
而重画出来的 DOM 里鼠标并没有真的悬停过,宽限期一到面板就自己关了。
|
|
2989
|
+
用户看到的现象就是:拨个开关,面板消失(而这正是这段注释上面说不能有的)。 */
|
|
2990
|
+
const keepPinned = flyoutPinned
|
|
2547
2991
|
const keepIndex = flyoutRow && flyoutRow.dataset ? Number(flyoutRow.dataset.rowIndex) : -1
|
|
2548
2992
|
openMenu(menuAtX, menuAtY, false) // 重画菜单(会先关掉面板)
|
|
2549
2993
|
if (!keep || !menuEl) return
|
|
2550
2994
|
const row = menuEl.querySelectorAll('.wisp-menu-item')
|
|
2551
2995
|
.find((el) => el.dataset && el.dataset.group === keep)
|
|
2552
|
-
if (row) openFlyout(keep, row, Number.isFinite(keepIndex) && keepIndex >= 0 ? keepIndex : Number(row.dataset.rowIndex))
|
|
2996
|
+
if (row) openFlyout(keep, row, Number.isFinite(keepIndex) && keepIndex >= 0 ? keepIndex : Number(row.dataset.rowIndex), keepPinned)
|
|
2553
2997
|
}
|
|
2554
2998
|
|
|
2555
2999
|
const openFlyout = (name, rowEl, index, pinned) => {
|
|
@@ -2829,7 +3273,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2829
3273
|
])
|
|
2830
3274
|
}
|
|
2831
3275
|
|
|
2832
|
-
const aboutDialog = () => {
|
|
3276
|
+
const aboutDialog = (diagRes) => {
|
|
2833
3277
|
const note = WHATS_NEW[VERSION]
|
|
2834
3278
|
/* 菜单里那条「版本与更新」已经收掉了(版本号现在直接显示在菜单头部),
|
|
2835
3279
|
所以检查更新必须有新的入口 —— 放在这里,上下文正合适。 */
|
|
@@ -2849,9 +3293,12 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2849
3293
|
para('她还能陪你专注计时:菜单里开一段 25 或 45 分钟,期间她保持"干活中",戳她一下是问还剩多久,到点她替你收工。'),
|
|
2850
3294
|
para('这一版:' + (note || VERSION)),
|
|
2851
3295
|
para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
|
|
3296
|
+
/* 上一次检查更新失败时,把宿主看到的东西摆在最显眼的位置 —— 台词只有一句,
|
|
3297
|
+
而"为什么拿不到"必须能被看到、被复制、被追问。 */
|
|
3298
|
+
diagRes ? para('上次查更新为什么没成:\n' + describeDiag(diagRes)) : null,
|
|
2852
3299
|
checkBtn,
|
|
2853
3300
|
dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
|
|
2854
|
-
])
|
|
3301
|
+
].filter(Boolean))
|
|
2855
3302
|
}
|
|
2856
3303
|
|
|
2857
3304
|
/* 菜单的结构 ------------------------------------------------------------
|
|
@@ -2901,6 +3348,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2901
3348
|
{ label: '说句话', glyph: '✦', run: () => { wake(true); say(pick(LINES.click)) } },
|
|
2902
3349
|
{ label: '睡一会儿', glyph: '☾', run: () => setMood('sleep') },
|
|
2903
3350
|
{ label: '今天做了什么', glyph: '✎', run: () => saySummary() },
|
|
3351
|
+
/* 余额:按需查,查完她自己说 —— 不做轮询(稳态零开销那条承诺不破)。 */
|
|
3352
|
+
{ label: '看看余额', glyph: '¥', run: () => { checkBalance() } },
|
|
2904
3353
|
/* 语言:三选一。切换**立刻生效**(LINES 是视图,不是死值)。 */
|
|
2905
3354
|
{
|
|
2906
3355
|
kind: 'choice', key: 'lang', label: '台词语言', glyph: '文',
|
|
@@ -2910,6 +3359,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2910
3359
|
{ value: 'en', label: 'English' },
|
|
2911
3360
|
],
|
|
2912
3361
|
},
|
|
3362
|
+
/* 动作幅度:三选一。同样立刻生效 —— 它只改一个属性,CSS 全挂在上面。
|
|
3363
|
+
给这一档的理由:prefers-reduced-motion 是**系统级**的"所有动画都别放",
|
|
3364
|
+
而"我不想让这只桌宠动来动去"是另一件事,不该逼人去改系统设置。 */
|
|
3365
|
+
{
|
|
3366
|
+
kind: 'choice', key: 'motion', label: '动作幅度', glyph: '≈',
|
|
3367
|
+
options: [
|
|
3368
|
+
{ value: 'full', label: '灵动' },
|
|
3369
|
+
{ value: 'subtle', label: '克制' },
|
|
3370
|
+
{ value: 'off', label: '静止' },
|
|
3371
|
+
],
|
|
3372
|
+
},
|
|
2913
3373
|
/* 没有音效文件就不摆这个开关 —— 点了没反应的开关比没有开关更糟 */
|
|
2914
3374
|
...(HAS_SOUNDS ? [{ kind: 'switch', key: 'sound', label: '音效', glyph: '♪' }] : []),
|
|
2915
3375
|
/* 开关是**真正的开关行**:带 aria-checked,点完菜单不关(就在原地更新) */
|
|
@@ -3131,7 +3591,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3131
3591
|
if (focused && focused.dataset && focused.dataset.slider === '1'
|
|
3132
3592
|
&& (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight')) {
|
|
3133
3593
|
if (typeof ev.preventDefault === 'function') ev.preventDefault()
|
|
3134
|
-
const step = ev.shiftKey ? 1 : 0.
|
|
3594
|
+
const step = ev.shiftKey ? 1 : 0.1
|
|
3135
3595
|
applyConfig({ size: cfg.size + (ev.key === 'ArrowRight' ? step : -step) })
|
|
3136
3596
|
/* 只更新滑块自身,不重画整张菜单 —— 焦点和展开状态都不动 */
|
|
3137
3597
|
try {
|
|
@@ -3322,6 +3782,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3322
3782
|
root.style.width = Math.round(boxW()) + 'px'
|
|
3323
3783
|
root.style.height = Math.round(boxH()) + 'px'
|
|
3324
3784
|
layer.style.setProperty('--wisp-scale', String(cfg.size))
|
|
3785
|
+
/* 尺寸变了,命中层的形状也要跟着缩放(clip-path 里的坐标是 px,不是百分比)。 */
|
|
3786
|
+
applyHitClip(currentSprite)
|
|
3787
|
+
/* 动作档位也要当场生效(菜单里的胶囊就靠这一行):它只改一个属性,
|
|
3788
|
+
CSS 那边所有幅度规则都挂在它上面。 */
|
|
3789
|
+
if (partial && typeof partial === 'object' && partial.motion !== undefined) applyMotion()
|
|
3325
3790
|
coords.x = clamp(coords.x, 0, maxX())
|
|
3326
3791
|
coords.y = clamp(coords.y, 0, maxY())
|
|
3327
3792
|
paint()
|
|
@@ -3343,6 +3808,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3343
3808
|
|
|
3344
3809
|
host 是 shell 注入的名字,不是全局:拿不到就如实说"查不了",不许抛。 */
|
|
3345
3810
|
let updateCheck = { state: 'idle', at: 0 }
|
|
3811
|
+
/* 上一次真正复制给用户的提示语(doctor 里能看到)—— "她让你装的是哪个" 必须可查,
|
|
3812
|
+
否则"按包名安装"和"用 GitHub 地址装"这两条路到底走了哪条就没人说得清。 */
|
|
3813
|
+
let lastUpdateHint = UPDATE_HINT
|
|
3346
3814
|
|
|
3347
3815
|
const compareVersions = (a, b) => {
|
|
3348
3816
|
const parse = (v) => String(v).split('-')[0].split('.').map((n) => {
|
|
@@ -3368,18 +3836,75 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3368
3836
|
return null
|
|
3369
3837
|
}
|
|
3370
3838
|
|
|
3839
|
+
/* 客户端**自己的**通道 —— 这才是这个插件真正的联网方式。
|
|
3840
|
+
|
|
3841
|
+
事实(从平台的 dsh-client-modules 加载器读出来的):bundle 插件的客户端半包
|
|
3842
|
+
是用 <script> 加载的**普通页面脚本**,平台不注入陷阱、不替换全局 —— 所以
|
|
3843
|
+
`window.fetch` 对它可用。而 `host.call` 那个 RPC 座位只发给**动态插件**
|
|
3844
|
+
(runner 里 `hasHostHalf: definition.hostCode !== undefined`),我们这个形态
|
|
3845
|
+
从来没有过它。
|
|
3846
|
+
|
|
3847
|
+
以前代码里写着"浏览器半包的 fetch 是陷阱",那是把**预检替身**的契约当成了
|
|
3848
|
+
平台契约 —— 整个更新检查因此在错误的路上走了很久(真机上永远"没接上宿主")。 */
|
|
3849
|
+
const NPM_LATEST_URL = 'https://registry.npmjs.org/dsh-wisp/latest'
|
|
3850
|
+
const GITHUB_RAW_URL = 'https://raw.githubusercontent.com/969246694/dsh-wisp/main/package.json'
|
|
3851
|
+
const clientVersionAt = (url) => {
|
|
3852
|
+
try {
|
|
3853
|
+
const fetcher = window.fetch
|
|
3854
|
+
if (typeof fetcher !== 'function') return Promise.resolve(null)
|
|
3855
|
+
return fetcher.call(window, url, { cache: 'no-store' })
|
|
3856
|
+
.then((res) => (res && res.ok === true ? res.json() : null))
|
|
3857
|
+
.then((data) => (data && typeof data.version === 'string' ? data.version : null))
|
|
3858
|
+
.catch(() => null)
|
|
3859
|
+
} catch (e) { return Promise.resolve(null) }
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
/* 客户端侧的完整检查:两个源各取一次,取更高的那个(与宿主侧同一套规则)。 */
|
|
3863
|
+
const clientSideCheck = () => {
|
|
3864
|
+
updateCheck = { state: 'checking', at: nowMs() }
|
|
3865
|
+
return Promise.all([clientVersionAt(NPM_LATEST_URL), clientVersionAt(GITHUB_RAW_URL)])
|
|
3866
|
+
.then(([npmVersion, githubVersion]) => {
|
|
3867
|
+
const sources = {
|
|
3868
|
+
npm: npmVersion ? { ok: true, version: npmVersion } : { ok: false, reason: 'fetch-failed' },
|
|
3869
|
+
github: githubVersion ? { ok: true, version: githubVersion } : { ok: false, reason: 'fetch-failed' },
|
|
3870
|
+
}
|
|
3871
|
+
const cands = [['npm', npmVersion], ['github', githubVersion]].filter((p) => typeof p[1] === 'string')
|
|
3872
|
+
if (cands.length === 0) {
|
|
3873
|
+
updateCheck = { state: 'failed', reason: 'fetch-failed', sources, diag: null, at: nowMs() }
|
|
3874
|
+
publishDiagToTitle(updateCheck)
|
|
3875
|
+
return updateCheck
|
|
3876
|
+
}
|
|
3877
|
+
let best = cands[0]
|
|
3878
|
+
for (const c of cands) if (compareVersions(best[1], c[1]) < 0) best = c
|
|
3879
|
+
updateCheck = compareVersions(VERSION, best[1]) < 0
|
|
3880
|
+
? { state: 'available', latest: best[1], from: best[0], sources, at: nowMs() }
|
|
3881
|
+
: { state: 'current', latest: best[1], from: best[0], sources, at: nowMs() }
|
|
3882
|
+
return updateCheck
|
|
3883
|
+
})
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3371
3886
|
const checkForUpdate = () => {
|
|
3372
3887
|
const seat = hostSeat()
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
}
|
|
3888
|
+
/* 没有 host 座位不是失败 —— 那是**这个形态的插件的常态**(bundle 插件没有 host RPC)。
|
|
3889
|
+
客户端自己抓:这才是它本来该走的路。 */
|
|
3890
|
+
if (seat === null) return clientSideCheck()
|
|
3377
3891
|
updateCheck = { state: 'checking', at: nowMs() }
|
|
3378
3892
|
return Promise.resolve()
|
|
3379
3893
|
.then(() => seat.call('checkUpdate', { current: VERSION }))
|
|
3380
3894
|
.then((res) => {
|
|
3381
3895
|
if (!res || res.ok !== true || typeof res.latest !== 'string') {
|
|
3382
|
-
|
|
3896
|
+
/* 失败时也要把**每个源各自的原因**留下来。用户看到的是"没查成",
|
|
3897
|
+
而 doctor() 必须能说清是"这个壳里没有网络服务"还是"两个源都没连上" ——
|
|
3898
|
+
以前这里把 sources 丢了,失败就变成一句无法追问的话。 */
|
|
3899
|
+
updateCheck = {
|
|
3900
|
+
state: 'failed',
|
|
3901
|
+
reason: (res && res.reason) || 'no-result',
|
|
3902
|
+
sources: (res && res.sources) || null,
|
|
3903
|
+
/* 宿主看到的诊断也要留下来 —— 失败台词会把它念出来,doctor 里也能查。 */
|
|
3904
|
+
diag: (res && res.diag) || null,
|
|
3905
|
+
at: nowMs(),
|
|
3906
|
+
}
|
|
3907
|
+
publishDiagToTitle(updateCheck)
|
|
3383
3908
|
} else if (compareVersions(VERSION, res.latest) < 0) {
|
|
3384
3909
|
/* from / sources 一起留着:两个源不一致时(npm 暂存、GitHub 已更新),
|
|
3385
3910
|
"这个数字是哪儿来的"比数字本身更需要能查。 */
|
|
@@ -3400,12 +3925,154 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3400
3925
|
})
|
|
3401
3926
|
}
|
|
3402
3927
|
|
|
3928
|
+
/* ---- 看余额 -------------------------------------------------------------
|
|
3929
|
+
和"检查更新"走同一条路,但服务不同:宿主半包 inject 'deepseekAccount',
|
|
3930
|
+
用平台自己的账户服务取余额。**插件从头到尾不碰 API key** —— 凭据在平台
|
|
3931
|
+
手里,浏览器半包连读都读不到(它没有网络,fetch 是陷阱)。
|
|
3932
|
+
|
|
3933
|
+
未登录、读不到、这个壳里没有那个服务,是三句不同的话:
|
|
3934
|
+
说是"查不到"但其实是"没登录",就是在骗人。 */
|
|
3935
|
+
let balanceCheck = { state: 'idle', at: 0 }
|
|
3936
|
+
|
|
3937
|
+
/* 金额由客户端拼,台词只管口气。货币符号认不出来就原样写代码。 */
|
|
3938
|
+
const balanceAmount = (wallets) => {
|
|
3939
|
+
const bits = []
|
|
3940
|
+
for (const w of (Array.isArray(wallets) ? wallets : [])) {
|
|
3941
|
+
if (!w || typeof w.balance !== 'string') continue
|
|
3942
|
+
const sign = w.currency === 'CNY' ? '¥' : (w.currency === 'USD' ? '$' : '')
|
|
3943
|
+
bits.push(sign === '' ? (w.balance + (w.currency ? ' ' + w.currency : '')) : sign + w.balance)
|
|
3944
|
+
}
|
|
3945
|
+
return bits.join(' + ')
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
const balanceLine = (res) => {
|
|
3949
|
+
if (res.state === 'ready') {
|
|
3950
|
+
const main = balanceAmount(res.wallets)
|
|
3951
|
+
if (main === '') return pick(LINES.balanceEmpty)
|
|
3952
|
+
const text = pick(LINES.balance).split('{n}').join(main)
|
|
3953
|
+
const bonus = balanceAmount(res.bonusWallets)
|
|
3954
|
+
return bonus === '' ? text : text + pick(LINES.balanceBonus).split('{n}').join(bonus)
|
|
3955
|
+
}
|
|
3956
|
+
if (res.state === 'signed-out') return pick(LINES.balanceSignedOut)
|
|
3957
|
+
if (res.state === 'unavailable') return pick(LINES.balanceUnavailable)
|
|
3958
|
+
if (res.state === 'unsupported') return pick(LINES.balanceUnsupported)
|
|
3959
|
+
return pick(LINES.balanceFailed)
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
/* 按需查:菜单里那一行和 `__wisp.checkBalance()` 都走这里。
|
|
3963
|
+
它**自己说话**(先"看一下……"再报结果)—— 菜单点完就关了,留一个
|
|
3964
|
+
只返回状态、不出声的函数,用户会以为没反应。 */
|
|
3965
|
+
const checkBalance = () => {
|
|
3966
|
+
const seat = hostSeat()
|
|
3967
|
+
if (seat === null) {
|
|
3968
|
+
balanceCheck = { state: 'unsupported', at: nowMs() }
|
|
3969
|
+
wake(true)
|
|
3970
|
+
say(balanceLine(balanceCheck))
|
|
3971
|
+
return Promise.resolve(balanceCheck)
|
|
3972
|
+
}
|
|
3973
|
+
balanceCheck = { state: 'checking', at: nowMs() }
|
|
3974
|
+
wake(true)
|
|
3975
|
+
say(pick(LINES.balanceChecking))
|
|
3976
|
+
return Promise.resolve()
|
|
3977
|
+
.then(() => seat.call('checkBalance', { locale: LANG_TAG }))
|
|
3978
|
+
.then((res) => {
|
|
3979
|
+
if (!res || res.ok !== true) {
|
|
3980
|
+
balanceCheck = { state: 'failed', reason: (res && res.reason) || 'no-result', at: nowMs() }
|
|
3981
|
+
} else if (res.status === 'ready') {
|
|
3982
|
+
balanceCheck = {
|
|
3983
|
+
state: 'ready',
|
|
3984
|
+
wallets: Array.isArray(res.wallets) ? res.wallets : [],
|
|
3985
|
+
bonusWallets: Array.isArray(res.bonusWallets) ? res.bonusWallets : [],
|
|
3986
|
+
at: nowMs(),
|
|
3987
|
+
}
|
|
3988
|
+
} else if (res.status === 'signed-out' || res.status === 'unavailable') {
|
|
3989
|
+
balanceCheck = { state: res.status, at: nowMs() }
|
|
3990
|
+
} else {
|
|
3991
|
+
/* 平台答了,答案是 failed —— 是结果,不是异常。 */
|
|
3992
|
+
balanceCheck = { state: 'failed', reason: 'service-failed', at: nowMs() }
|
|
3993
|
+
}
|
|
3994
|
+
say(balanceLine(balanceCheck))
|
|
3995
|
+
return balanceCheck
|
|
3996
|
+
})
|
|
3997
|
+
.catch((err) => {
|
|
3998
|
+
balanceCheck = {
|
|
3999
|
+
state: 'failed',
|
|
4000
|
+
reason: 'call-failed',
|
|
4001
|
+
detail: String(err && err.message ? err.message : err),
|
|
4002
|
+
at: nowMs(),
|
|
4003
|
+
}
|
|
4004
|
+
say(balanceLine(balanceCheck))
|
|
4005
|
+
return balanceCheck
|
|
4006
|
+
})
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
/* 版本号来自 GitHub,而 npm 上那份**不是同一个号**(通常是 npm 暂存或还没发)——
|
|
4010
|
+
这时候"按包名安装"会把人送回旧版。 */
|
|
4011
|
+
const githubOnlyNewer = (res) => {
|
|
4012
|
+
if (!res || res.state !== 'available' || res.from !== 'github') return false
|
|
4013
|
+
const npm = res.sources && res.sources.npm
|
|
4014
|
+
return !!(npm && npm.ok === true && npm.version !== res.latest)
|
|
4015
|
+
}
|
|
4016
|
+
|
|
3403
4017
|
const updateLine = (res) => {
|
|
3404
4018
|
if (res.state === 'available') return pick(LINES.updateAvailable) + res.latest
|
|
3405
4019
|
if (res.state === 'current') return pick(LINES.updateCurrent)
|
|
4020
|
+
/* 失败要说清是哪一种:没有通道 / 连不上 / 宿主没应答。
|
|
4021
|
+
`no-web-service` 是"这个壳根本没有联网入口",不是"网络不好"——
|
|
4022
|
+
以前两者共用一句"没查到更新",于是诊断只能靠猜。 */
|
|
4023
|
+
/* 失败要说清是哪一种 —— 它们是链路上**不同的一环**断了:
|
|
4024
|
+
`unsupported`:客户端这侧连 `host.call` 都没有(第一环);
|
|
4025
|
+
`no-web-service`:宿主跑了,但它拿不到 `web` 服务(第三环);
|
|
4026
|
+
`fetch-failed`:服务拿到了,两个源都连不上(第四环)。 */
|
|
4027
|
+
if (res.state === 'unsupported') return pick(LINES.updateNoSeat)
|
|
4028
|
+
if (res.reason === 'no-web-service') return pick(LINES.updateNoChannel)
|
|
4029
|
+
if (res.reason === 'call-failed') return pick(LINES.updateNoHost)
|
|
3406
4030
|
return pick(LINES.updateFailed)
|
|
3407
4031
|
}
|
|
3408
4032
|
|
|
4033
|
+
/* 技术的部分**不进台词**(台词是给人读的一句话,塞进去就是一串英文碎片),
|
|
4034
|
+
但必须看得见 —— 失败时把宿主实际看到的排进「关于她」弹窗:
|
|
4035
|
+
取服务的方式、拿到的东西是什么形状、兜底通道为什么也不行。
|
|
4036
|
+
这是"她说查不了"和"我知道为什么"之间的那一步。 */
|
|
4037
|
+
/* 诊断的**临时出口**:把"宿主看见了什么"写进窗口标题。
|
|
4038
|
+
Agent 侧读一下窗口标题就能拿到(不用截图、不用控制台、不用用户复制)——
|
|
4039
|
+
这台机器上的断点已经卡了两轮,值得开一个临时通道把它钉死。
|
|
4040
|
+
120 秒后自动还原;定位到问题后这一条会撤掉。 */
|
|
4041
|
+
let diagTitleTimer = null
|
|
4042
|
+
const publishDiagToTitle = (res) => {
|
|
4043
|
+
try {
|
|
4044
|
+
if (typeof document === 'undefined' || !document || typeof document.title !== 'string') return
|
|
4045
|
+
const diag = (res && res.diag) || {}
|
|
4046
|
+
const short = [
|
|
4047
|
+
'wisp-diag',
|
|
4048
|
+
String((res && res.state) || '?'),
|
|
4049
|
+
String((res && res.reason) || '-'),
|
|
4050
|
+
'via=' + String(diag.via || '-'),
|
|
4051
|
+
'how=' + String(diag.how || '-'),
|
|
4052
|
+
'why=' + String(diag.why || '-'),
|
|
4053
|
+
'mgr=' + String(diag.manager || '-'),
|
|
4054
|
+
].join(' ')
|
|
4055
|
+
const original = document.title
|
|
4056
|
+
document.title = short
|
|
4057
|
+
if (diagTitleTimer !== null) clock.cancel(diagTitleTimer)
|
|
4058
|
+
diagTitleTimer = clock.timeout(() => {
|
|
4059
|
+
try { document.title = original } catch (e) { /* ignore */ }
|
|
4060
|
+
diagTitleTimer = null
|
|
4061
|
+
}, 120000)
|
|
4062
|
+
} catch (e) { /* 标题写不了不该影响任何别的功能 */ }
|
|
4063
|
+
}
|
|
4064
|
+
|
|
4065
|
+
const describeDiag = (res) => {
|
|
4066
|
+
const diag = res && res.diag
|
|
4067
|
+
if (!diag || typeof diag !== 'object') return '这次连诊断信息都没有拿到。'
|
|
4068
|
+
const lines = ['通道:' + String(diag.via || '未知')]
|
|
4069
|
+
if (diag.how) lines.push('取服务的方式:' + String(diag.how))
|
|
4070
|
+
if (diag.why) lines.push('看到的东西:' + String(diag.why))
|
|
4071
|
+
if (diag.registry) lines.push('插件管理器的 registry:' + String(diag.registry))
|
|
4072
|
+
if (diag.manager) lines.push('兜底通道:' + String(diag.manager))
|
|
4073
|
+
return lines.join('\n')
|
|
4074
|
+
}
|
|
4075
|
+
|
|
3409
4076
|
/* 把包名复制到剪贴板,便于在 DSH 的「按插件包名安装」里粘贴。
|
|
3410
4077
|
没有剪贴板权限(或不是安全上下文)时**静默降级**成只说话 —— 不该因此报错。 */
|
|
3411
4078
|
const copyText = (text) => {
|
|
@@ -3425,6 +4092,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3425
4092
|
用户点这一项想知道的就是这两件事。 */
|
|
3426
4093
|
const showUpdate = () => {
|
|
3427
4094
|
const note = WHATS_NEW[VERSION]
|
|
4095
|
+
let hint = UPDATE_HINT
|
|
3428
4096
|
const copied = copyText('dsh-wisp')
|
|
3429
4097
|
/* 一步到位:先把"我是谁、这一版做了什么"说出来,同时去查有没有新版;
|
|
3430
4098
|
查回来再用一句话覆盖掉 —— 一次只冒一个气泡,所以是"说完结果就结束"。 */
|
|
@@ -3435,9 +4103,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3435
4103
|
started.then((res) => {
|
|
3436
4104
|
if (destroyed) return
|
|
3437
4105
|
if (res.state === 'checking') return
|
|
4106
|
+
/* 新版只在 GitHub 上 → 改口 + 把仓库地址放进剪贴板(包名会装回旧版)。 */
|
|
4107
|
+
if (githubOnlyNewer(res)) {
|
|
4108
|
+
hint = UPDATE_HINT_GITHUB
|
|
4109
|
+
lastUpdateHint = hint
|
|
4110
|
+
copyText(UPDATE_REPO)
|
|
4111
|
+
}
|
|
3438
4112
|
flashHappy(base + updateLine(res))
|
|
4113
|
+
/* 查不成的时候:把"为什么"摆到弹窗里(台词只留一句人话)。 */
|
|
4114
|
+
if (res.state === 'failed') aboutDialog(res)
|
|
3439
4115
|
})
|
|
3440
4116
|
}
|
|
4117
|
+
lastUpdateHint = hint
|
|
3441
4118
|
return { version: VERSION, note: note || null, copied, checking: true }
|
|
3442
4119
|
}
|
|
3443
4120
|
|
|
@@ -3468,6 +4145,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3468
4145
|
startFocus,
|
|
3469
4146
|
stopFocus,
|
|
3470
4147
|
saySummary,
|
|
4148
|
+
checkBalance,
|
|
3471
4149
|
get today() { return { ...watchToday() } },
|
|
3472
4150
|
get focus() { return focusState() },
|
|
3473
4151
|
openAbout: () => aboutDialog(),
|
|
@@ -3562,6 +4240,21 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3562
4240
|
error: { selector: ERROR_SELECTOR, matches: probe(ERROR_SELECTOR) },
|
|
3563
4241
|
},
|
|
3564
4242
|
dark: !!(body && body.hasAttribute('data-ds-dark-theme')),
|
|
4243
|
+
/* 余额:最后一次查的结果 + 有没有 host 座位。**凭据从不经过这里** ——
|
|
4244
|
+
插件拿不到 key,也就不可能被谁从 doctor() 里读走。 */
|
|
4245
|
+
balance: Object.assign({ hostSeat: hostSeat() !== null }, balanceCheck),
|
|
4246
|
+
/* 动作层的实时状态。姿势角是**屏幕坐标**里的角度(未乘镜像),
|
|
4247
|
+
accent 是当下挂着的一次性动作 —— "她为什么歪着/弹了一下"
|
|
4248
|
+
在 Console 里一眼能看出来,不用去读样式。 */
|
|
4249
|
+
motion: {
|
|
4250
|
+
level: cfg.motion,
|
|
4251
|
+
amp: amp(),
|
|
4252
|
+
reduced: reduced(),
|
|
4253
|
+
tilt: Math.round(tiltScreen * 100) / 100,
|
|
4254
|
+
tiltLocal: tiltWritten,
|
|
4255
|
+
accent: root.dataset.accent || null,
|
|
4256
|
+
pressed: root.dataset.dragging === 'true',
|
|
4257
|
+
},
|
|
3565
4258
|
tokens,
|
|
3566
4259
|
/* 窗口背景:Mica 能不能露出来,取决于页面有没有在背景处画不透明像素。
|
|
3567
4260
|
blockers 是"每个采样点上最上面那个铺满视口的画底色的元素",
|
|
@@ -3581,7 +4274,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3581
4274
|
version: VERSION,
|
|
3582
4275
|
whatsNew: WHATS_NEW[VERSION] ?? null,
|
|
3583
4276
|
seenBefore: readStored(SEEN_VERSION_KEY),
|
|
3584
|
-
hint:
|
|
4277
|
+
hint: lastUpdateHint,
|
|
3585
4278
|
canSelfUpdate: false,
|
|
3586
4279
|
/* 上一次检查的结果(idle / checking / current / available / failed / unsupported) */
|
|
3587
4280
|
lastCheck: { ...updateCheck },
|
|
@@ -3664,6 +4357,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3664
4357
|
}
|
|
3665
4358
|
window.removeEventListener('pointermove', onMove)
|
|
3666
4359
|
window.removeEventListener('pointerup', onUp)
|
|
4360
|
+
window.removeEventListener('pointermove', leanToPointer)
|
|
4361
|
+
window.removeEventListener('blur', resetTilt)
|
|
3667
4362
|
bodyEl.removeEventListener('pointerdown', onDown)
|
|
3668
4363
|
bodyEl.removeEventListener('keydown', onBodyKey)
|
|
3669
4364
|
bodyEl.removeEventListener('contextmenu', onContext)
|