dsh-wisp 1.44.0 → 1.48.1
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 +599 -32
- package/assets/canon/attn.webp +0 -0
- package/assets/canon/eat.webp +0 -0
- package/assets/canon/happy.webp +0 -0
- package/assets/canon/idle.webp +0 -0
- package/assets/canon/poked.webp +0 -0
- package/assets/canon/proud.webp +0 -0
- package/assets/canon/sleepy.webp +0 -0
- package/assets/canon/work.webp +0 -0
- package/assets/classic/attn.webp +0 -0
- package/assets/classic/eat.webp +0 -0
- package/assets/classic/happy.webp +0 -0
- package/assets/classic/idle.webp +0 -0
- package/assets/classic/poked.webp +0 -0
- package/assets/classic/proud.webp +0 -0
- package/assets/classic/work.webp +0 -0
- package/assets/deepsea/attn.webp +0 -0
- package/assets/deepsea/eat.webp +0 -0
- package/assets/deepsea/happy.webp +0 -0
- package/assets/deepsea/idle.webp +0 -0
- package/assets/deepsea/poked.webp +0 -0
- package/assets/deepsea/proud.webp +0 -0
- package/assets/deepsea/sleepy.webp +0 -0
- package/assets/deepsea/work.webp +0 -0
- package/assets/motion/idle.webp +0 -0
- package/assets/motion/sleepy.webm +0 -0
- package/assets/motion/sleepy.webp +0 -0
- package/assets/motion/swim_attn.webp +0 -0
- package/assets/motion/swim_eat.webp +0 -0
- package/assets/motion/swim_happy.webp +0 -0
- package/assets/motion/swim_idle.webp +0 -0
- package/assets/motion/swim_poked.webp +0 -0
- package/assets/motion/swim_proud.webp +0 -0
- package/assets/motion/swim_sleepy.webp +0 -0
- package/assets/motion/swim_work.webp +0 -0
- package/assets/night/attn.webp +0 -0
- package/assets/night/eat.webp +0 -0
- package/assets/night/happy.webp +0 -0
- package/assets/night/idle.webp +0 -0
- package/assets/night/poked.webp +0 -0
- package/assets/night/proud.webp +0 -0
- package/assets/night/sleepy.webp +0 -0
- package/assets/night/work.webp +0 -0
- package/assets/pajama/attn.webp +0 -0
- package/assets/pajama/eat.webp +0 -0
- package/assets/pajama/happy.webp +0 -0
- package/assets/pajama/idle.webp +0 -0
- package/assets/pajama/poked.webp +0 -0
- package/assets/pajama/proud.webp +0 -0
- package/assets/pajama/sleepy.webp +0 -0
- package/assets/pajama/work.webp +0 -0
- package/assets/swim/attn.webp +0 -0
- package/assets/swim/eat.webp +0 -0
- package/assets/swim/happy.webp +0 -0
- package/assets/swim/idle.webp +0 -0
- package/assets/swim/poked.webp +0 -0
- package/assets/swim/proud.webp +0 -0
- package/assets/swim/sleepy.webp +0 -0
- package/assets/swim/work.webp +0 -0
- package/audit/generation-audit.jsonl +57 -0
- package/build.mjs +130 -8
- package/lib/client.js +826 -92
- package/lib/client.template.js +765 -53
- package/lib/index.js +194 -1
- package/package.json +1 -1
- package/tools/CHARACTER-PROMPT.md +65 -9
- package/tools/audit-log.mjs +11 -2
- package/tools/engine-probe.mjs +500 -2
- package/verify-wisp.mjs +1263 -32
package/lib/client.template.js
CHANGED
|
@@ -51,11 +51,26 @@ 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.48.1'
|
|
55
55
|
|
|
56
56
|
/* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
|
|
57
57
|
忘了更新就会红,不会静默过期。 */
|
|
58
58
|
const WHATS_NEW = {
|
|
59
|
+
'1.48.1': '内容与 1.48.0 相同 —— 重新发一次,把 npm 的「可信发布者」连接验证通过。',
|
|
60
|
+
'1.48.0': '查到新版可以直接装了:关于她 → 检查更新 → 「安装 vX」,由平台的插件管理器去装(装完该刷新还是该重启,她自己会说)。',
|
|
61
|
+
'1.47.1': '泳装八个状态真的有动图了:idle / attn / happy / sleep / alert / proud / eat / poked 各配一条 720p、24fps、97 帧的循环动图 WebP(八条合计 23.2 MB,仍走宿主半包的 /wisp-motion/ 路由),包围盒逐条量过、差 0.2%~1.6%,八条都不需要尺寸校正;另外动作一览不再点一下就关:连着点几个动作,她一个一个演给你看(Esc / × / 点背景关窗)。',
|
|
62
|
+
'1.47.0': '帧动画素材改成外置投递:宿主半包用 /wisp-motion/ 把 assets/motion/ 里的动图发给页面,客户端半包从 15.3 MB 回到 7.8 MB;泳装八态的接线(按皮肤+状态点素材)一并就位(素材本身这轮没生成出来,缺了就静默降级);另外多了「帧动画」开关 —— 一关画面就永远交给静态立绘,只收走动图,呼吸、气泡、一次性动作照旧。',
|
|
63
|
+
'1.46.5': '站着时她也在动:idle 接上第二条 4.04 秒循环(720x1280 / 24fps / 97 帧一帧没抽),两段素材合计内联压到 7.83 MB —— colorkey 过渡收到 0.05、WebP 质量退到 40,省下的余量换成了**去幕布色**:idle 的品红残留 1.09% → 0.006%,头发边上那圈紫边没了。',
|
|
64
|
+
'1.46.4': '睡着时她不再小一圈:动作层补上 scale(1.186) 与落地对齐(她在那张素材里只占 83.5% 盒高,立绘占 98.8%)。锯齿也修了:colorkey 过渡 0.02 → 0.06、WebP 质量 40 → 60,半透明边缘 0.80% → 1.40%,配套的 RGB 去绿边把绿边从 0.078% 压到 0.001%。',
|
|
65
|
+
'1.46.3': '睡眠循环换成 720p 重生成(480 在屏幕上放大发虚):720x1280、24fps、97 帧、绿边 0.01%;内联体积预算 2.4MB → 4.2MB。',
|
|
66
|
+
'1.46.2': '抠干净绿边:colorkey 0.10/0.10 → 0.22/0.02(边缘绿像素 0.43% → 0.01%,体积还小了一点)。',
|
|
67
|
+
'1.46.1': '帧动画换成动图 WebP(480 宽、24fps、97 帧一帧不抽):这个壳不还原 VP9 的 alpha,同一个文件在 <video> 里是黑/白剪影,在 <img> 里是硬的;「静止」「减少动态效果」「躲起来」改成藏动图、把画面交回立绘。',
|
|
68
|
+
'1.46.0': '查余额真的能用了:走平台自己的 Remote(账户服务),不再依赖只发给动态插件的 host 座位 —— 在这台机器上它一直回"查不了"。',
|
|
69
|
+
'1.45.3': '删掉两条身体层动画:attn 的左右轻推与 happy 的上下跳 —— 只剩 worried 那一下,换表情仍然弹。',
|
|
70
|
+
'1.45.3': '修掉睡眠帧动画变黑:带 alpha 的视频层不能再吃 CSS filter(透明区会被当成黑色)。',
|
|
71
|
+
'1.45.2': '修掉睡觉时的重影:帧动画一播,静态立绘就退场;「静止」档或视频加载失败时它再回来当兜底。',
|
|
72
|
+
'1.45.1': '删掉站立时的上下浮动(wisp-bob)—— 身体层不再有动画,动起来交给帧动画。',
|
|
73
|
+
'1.45.0': '她动起来了:睡觉时那段被子起伏是第一帧动画(带 alpha 的视频循环);「静止」档与系统的「减少动态效果」会把它停住并回到首帧,只留下静态立绘。',
|
|
59
74
|
'1.16.0': '按社区规范换上鲸鳍耳。',
|
|
60
75
|
'1.17.0': '鳍耳缩小、加上呆毛。',
|
|
61
76
|
'1.18.0': '新增原版女仆的完整提示词。',
|
|
@@ -110,9 +125,58 @@ const UPDATE_HINT_GITHUB = 'npm 上还是旧版,用这个地址装 dsh-wisp:
|
|
|
110
125
|
/* ---- sprites: 256x384 WebP, transparent, inlined by build.mjs ---- */
|
|
111
126
|
const SPRITES = __SPRITES_LITERAL__
|
|
112
127
|
|
|
128
|
+
/* ---------------------------------------------------------------------
|
|
129
|
+
帧动画(motion):**动图 WebP**。每一条对应一个"皮肤+状态"。
|
|
130
|
+
|
|
131
|
+
在这之前,"她的动作"全是**位移/缩放/旋转**——立绘本身一动不动。这一层
|
|
132
|
+
是真正的帧动画(v1.45.0 起):站着时那点起伏、睡着时被子的起伏,现在
|
|
133
|
+
八套泳装状态各有一条。
|
|
134
|
+
|
|
135
|
+
**投递方式在 v1.47.0 换了**(这条注释的其余部分解释的都是为什么):
|
|
136
|
+
表里存的不再是 base64,而是**素材文件名**;字节留在
|
|
137
|
+
`<包>/assets/motion/<文件名>.webp`,由宿主半包(lib/index.js)挂在平台
|
|
138
|
+
HTTP 载体上的 `/wisp-motion/` 路由发出来,这一层只管把 URL 指给 <img>。
|
|
139
|
+
原因只有一个但足够:720p 的动图是 MB 级素材,两条内联 7.83 MB、八条
|
|
140
|
+
泳装再加约 30 MB 会把浏览器半包推到 ~45 MB —— 那是把视频素材放错了地方,
|
|
141
|
+
不是能"再压一压"的预算问题。想要外置,就必须有一条从"包里的文件"到
|
|
142
|
+
"<img src>" 的路:`@deepseek-ai/dsh-client-resources` 不是它(那是
|
|
143
|
+
`dsh-resource://` 的**取值**模型,要有 provider、要挂在 slot 上),
|
|
144
|
+
平台的 `ctx.webServer.register` 才是(见 lib/index.js 的那一段长注释)。
|
|
145
|
+
|
|
146
|
+
代价说清楚:**这一层不再自给自足**。路由不在(Desktop 载体没有 HTTP
|
|
147
|
+
服务器)、文件 404、解码失败 —— 都走既有的静默降级:<img> 的 error
|
|
148
|
+
一到,动图藏起来、画面交回静态立绘,她照样在屏幕上,一个都不少。
|
|
149
|
+
|
|
150
|
+
为什么仍然是"动图 WebP"而不是 alpha 视频(v1.46.1):VP9 的 alpha 平面
|
|
151
|
+
在这个壳里不还原 —— 同一段素材在暗色主题下渲染成黑色剪影、亮色主题下
|
|
152
|
+
白色剪影(用户确认过)。图片路径的 alpha 是硬的:<img> 播动图时浏览器
|
|
153
|
+
直接按 alpha 合成,没有第二条解码通道可以走偏。
|
|
154
|
+
|
|
155
|
+
代价是**动图停不下来**:<img> 上没有 pause(),也没有 currentTime 可以
|
|
156
|
+
写回 0。所以"冻结"不再是"停住",而是**把动图藏掉、把画面交回静态立绘**
|
|
157
|
+
(见 mount 里的 syncMotion)。这也是 v1.45.2 那条规则的另一面:画面里
|
|
158
|
+
同时只该有一个她。
|
|
159
|
+
|
|
160
|
+
表是空的就代表这个包里没有动作素材(build 允许 motion/ 不存在):
|
|
161
|
+
客户端据此**连 <img> 都不建**,而不是摆一个永远不播的空盒子。
|
|
162
|
+
--------------------------------------------------------------------- */
|
|
163
|
+
const MOTION = __MOTION_LITERAL__
|
|
164
|
+
|
|
165
|
+
/* `/wisp-motion/<文件名>` 是宿主半包注册的路由(lib/index.js 的 MOTION_PATH,
|
|
166
|
+
两处必须同名)。相对**页面自己的 base** 解析:部署前缀、端口、以及
|
|
167
|
+
Desktop 的 `dsh-app://app/` 都由 baseURI 带进来 —— 客户端半包因此永远
|
|
168
|
+
不需要知道宿主的绝对路径,也就没有"路径写死了、换台机器就找不到"这回事。 */
|
|
169
|
+
const MOTION_ROUTE = 'wisp-motion/'
|
|
170
|
+
const motionSrc = (file) => {
|
|
171
|
+
/* 表里万一出现 data URI(预检注入、或构建被人改回内联),走立绘那条老路:
|
|
172
|
+
这个壳把 data: 当坏图处理,必须先换成 object URL(见下面 resolveSprite)。 */
|
|
173
|
+
if (file.slice(0, 5) === 'data:') return resolveSprite(file)
|
|
174
|
+
return new URL(MOTION_ROUTE + file, document.baseURI).href
|
|
175
|
+
}
|
|
176
|
+
|
|
113
177
|
/* 皮肤显示名。没在这里登记的皮肤直接用它的目录名 —— 所以增删皮肤只需要
|
|
114
178
|
放素材 + 重新构建,不必改这一段。 */
|
|
115
|
-
const SKIN_LABELS = { deepsea: '深海女仆', canon: '原版女仆', classic: '素绘女仆', night: '宵蓝礼服', pajama: '宵眠睡衣', default: '默认' }
|
|
179
|
+
const SKIN_LABELS = { deepsea: '深海女仆', canon: '原版女仆', classic: '素绘女仆', night: '宵蓝礼服', pajama: '宵眠睡衣', swim: '碧海泳装', default: '默认' }
|
|
116
180
|
/* 皮肤 id 表放在模块级:mount 里恢复"上次选的皮肤"时就要用它,那时 mount
|
|
117
181
|
内部的 const 还没求值(写成 mount 内的 const 会踩 TDZ)。 */
|
|
118
182
|
const SKIN_IDS = Object.keys(SPRITES)
|
|
@@ -236,6 +300,10 @@ const UPDATE_HINT_GITHUB = 'npm 上还是旧版,用这个地址装 dsh-wisp:
|
|
|
236
300
|
默认全开 —— 但必须有一个**不用改系统设置**就能让她安静下来的开关,
|
|
237
301
|
prefers-reduced-motion 是给"所有动画"的,不是给"我不想让她动"的。 */
|
|
238
302
|
motion: 'full',
|
|
303
|
+
/* 帧动画开关(v1.47.0):关掉 = 画面永远交给静态立绘。
|
|
304
|
+
它和 motion:'off' **不是一回事** —— 那个连呼吸、z、一次性动作一起停;
|
|
305
|
+
这个只收走动图,别的照旧。 */
|
|
306
|
+
frame: true,
|
|
239
307
|
size: 4, // 560x840 CSS px, drawn from the 2048x3072 tier
|
|
240
308
|
persist: true, // remember where she was dragged to
|
|
241
309
|
celebrate: true, // cheer when a run finishes
|
|
@@ -415,6 +483,16 @@ const UPDATE_HINT_GITHUB = 'npm 上还是旧版,用这个地址装 dsh-wisp:
|
|
|
415
483
|
streak: [
|
|
416
484
|
'连续第 {n} 天了。', '第 {n} 天了,你还在。', '我们已经连着见了 {n} 天。', '第 {n} 天。我记着呢。', '{n} 天没断,挺厉害的。',
|
|
417
485
|
],
|
|
486
|
+
/* 装上最新版(v1.48.0):平台的插件管理器是客户端可用的 Remote 命名空间,
|
|
487
|
+
所以"查到新版"之后可以**直接装** —— 见 installUpdate。 */
|
|
488
|
+
installChecking: ['正在装 {v}……', '好,去装 {v}。'],
|
|
489
|
+
installDone: ['装好了:v{v}。刷新一下页面就是新版。', 'v{v} 装好了 —— 刷新页面生效。'],
|
|
490
|
+
installRestart: ['v{v} 装好了,重启一下 DSH 才换上新的。'],
|
|
491
|
+
installPending: ['装的动作已经发出去了,还没回来 —— 过一会儿看看插件列表。'],
|
|
492
|
+
installCancelled: ['安装被取消了。'],
|
|
493
|
+
installNotPublished: ['npm 上还没有 v{v} 这一版(GitHub 先更新了)—— 等一会儿再装。'],
|
|
494
|
+
installFailed: ['没装成。', '安装没成功。'],
|
|
495
|
+
installUnsupported: ['这个壳里没有插件管理器,我装不了。'],
|
|
418
496
|
updateCurrent: ['已经是最新的了。', '我就是最新的。', '没有更新的。'],
|
|
419
497
|
updateAvailable: ['有新版本了:', '有新版:', '更新来了:'],
|
|
420
498
|
updateFailed: ['更新没查成。', '这次没查到更新。', '更新检查没成功。'],
|
|
@@ -484,6 +562,14 @@ const UPDATE_HINT_GITHUB = 'npm 上还是旧版,用这个地址装 dsh-wisp:
|
|
|
484
562
|
backSoon: ['Back already?', 'There you are.'],
|
|
485
563
|
backLong: ['You were gone a while.', 'Long break. Welcome back.'],
|
|
486
564
|
streak: ['{n} days in a row.', 'Day {n} together.'],
|
|
565
|
+
installChecking: ['Installing {v}…', 'Right — installing {v}.'],
|
|
566
|
+
installDone: ['v{v} installed — refresh the page to pick it up.', 'Installed v{v}. Refresh to use it.'],
|
|
567
|
+
installRestart: ['v{v} is installed; restart DSH to switch over.'],
|
|
568
|
+
installPending: ['The install is still running — check the plugin list in a bit.'],
|
|
569
|
+
installCancelled: ['The install was cancelled.'],
|
|
570
|
+
installNotPublished: ['npm does not have v{v} yet (GitHub updated first) — try again in a bit.'],
|
|
571
|
+
installFailed: ["The install didn't work.", 'That install failed.'],
|
|
572
|
+
installUnsupported: ['There is no plugin manager in this shell, so I cannot install it.'],
|
|
487
573
|
updateFailed: ["The update check didn't work.", "Couldn't reach the update sources."],
|
|
488
574
|
updateNoChannel: ["This shell gives me no network channel, so I can't check for updates.", 'No way out to the network from here.'],
|
|
489
575
|
updateNoSeat: ["I'm not connected to my host half, so I can't check for updates.", 'No host half attached.'],
|
|
@@ -673,6 +759,7 @@ const UPDATE_HINT_GITHUB = 'npm 上还是旧版,用这个地址装 dsh-wisp:
|
|
|
673
759
|
wanderRange: { kind: 'num', lo: 0, hi: 2000 },
|
|
674
760
|
reactions: { kind: 'bool' },
|
|
675
761
|
motion: { kind: 'one', of: Object.keys(MOTION_AMP) },
|
|
762
|
+
frame: { kind: 'bool' },
|
|
676
763
|
persist: { kind: 'bool' },
|
|
677
764
|
celebrate: { kind: 'bool' },
|
|
678
765
|
wander: { kind: 'bool' },
|
|
@@ -861,17 +948,80 @@ const UPDATE_HINT_GITHUB = 'npm 上还是旧版,用这个地址装 dsh-wisp:
|
|
|
861
948
|
size rather than transform-scaled.
|
|
862
949
|
--------------------------------------------------------------------- */
|
|
863
950
|
const CSS = `.wisp-layer{position:fixed;inset:0;z-index:2147482000;pointer-events:none;overflow:hidden;font-family:inherit;--wisp-scale:1;--wisp-say-fg:#0d1a22;--wisp-say-bg:rgba(255,255,255,.93);--wisp-say-line:rgba(15,30,40,.10);--wisp-ink:#3b5a70}
|
|
864
|
-
.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)}
|
|
951
|
+
.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);--wisp-accent:var(--dsw-alias-brand-primary,#4a7cf7);--wisp-glow:drop-shadow(0 0 0 rgba(0,0,0,0));--wisp-sprite:drop-shadow(0 6px 14px rgba(0,0,0,.30))}
|
|
865
952
|
.wisp-root[data-dragging="true"]{will-change:transform}
|
|
866
953
|
.wisp-root[data-gliding="true"]{transition:transform 1.6s cubic-bezier(.4,0,.2,1)}
|
|
867
|
-
.wisp-body{position:absolute;inset:0;pointer-events:none;touch-action:none;user-select:none;-webkit-user-select:none
|
|
954
|
+
.wisp-body{position:absolute;inset:0;pointer-events:none;touch-action:none;user-select:none;-webkit-user-select:none}
|
|
868
955
|
/* 命中层:一个**不画任何东西**的空盒子,只负责被鼠标点到。它带一层由 alpha 蒙版
|
|
869
956
|
生成的 clip-path —— 于是"透明处点不到"是**浏览器**做的,而不是 JS 事后补救:
|
|
870
957
|
事件根本不会派到她身上,下面的应用照常收到 hover / 点击 / 右键 / 滚轮。
|
|
871
958
|
(只写 pointer-events:none 是不够的:那只是不拦她自己的事,盒子仍然盖在应用上面。) */
|
|
872
959
|
.wisp-hit{position:absolute;inset:0;pointer-events:auto;cursor:grab}
|
|
873
960
|
.wisp-hit:active{cursor:grabbing}
|
|
874
|
-
|
|
961
|
+
/* 焦点环不能用浏览器默认的那一个。她的盒子是 560×840,里面**大部分是透明的**:
|
|
962
|
+
默认焦点环画的正好是这个盒子,于是屏幕上出现一个套着素材的矩形线框 ——
|
|
963
|
+
看起来像素材的边界,不像"她被选中了"(用户报的就是这个)。焦点照旧给她
|
|
964
|
+
(tabindex 见 JS),改成由**贴着剪影**的辉光表达:drop-shadow 跟的是 alpha 剪影,
|
|
965
|
+
形状就是她本人,任何尺寸下都不会出现矩形。见下面的 --wisp-glow。 */
|
|
966
|
+
.wisp-body:focus,.wisp-body:focus-visible{outline:none}
|
|
967
|
+
.wisp-img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;object-position:bottom center;filter:var(--wisp-glow) var(--wisp-sprite);transition:opacity .28s ease,filter .5s ease}
|
|
968
|
+
/* 帧动画层(v1.45.0,v1.46.1 起是 <img> 动图):叠在立绘**上面**、和它同一个盒子。
|
|
969
|
+
定位与缩放**逐条复用 .wisp-img 那一套**(inset:0 / 100% / contain / bottom center)——
|
|
970
|
+
另起一套就会出现"动起来的时候她跳了一下"这种只在真机上看得见的错位。
|
|
971
|
+
pointer-events:none 是硬要求:她能不能被点到,仍然只由 .wisp-hit 的剪影决定。
|
|
972
|
+
display 由 JS 写死(冻结/没有素材/加载失败时是 none),所以这里不写 display。
|
|
973
|
+
类名留着叫 wisp-video:它是"动起来的那一层",不是"一个 <video>"。 */
|
|
974
|
+
/* 动作层**不许有 CSS filter**(v1.45.3 立下的规矩,v1.46.1 照旧):它必须是一张
|
|
975
|
+
原样的画面 —— 加了滤镜就多一层栅格化,alpha 剪影会被重新合成一次(v1.45.3 的
|
|
976
|
+
黑色剪影就是这么来的)。阴影与调色只给静态立绘。
|
|
977
|
+
|
|
978
|
+
尺寸校正(v1.46.4):两层共用**同一个盒子**,但两张素材里她占画面的比例不同,
|
|
979
|
+
于是"一进入睡眠、画面交给她"的那一刻她整个人**缩了一圈**。实测包围盒(阈值
|
|
980
|
+
A>127,见 README 的量法):
|
|
981
|
+
|
|
982
|
+
立绘 1024x1536:她占 0.9883 高、底边距 0.0117(18px)
|
|
983
|
+
—— 它的宽高比 2:3 与盒子 140x210 **相同**,contain 正好铺满盒子的高;
|
|
984
|
+
动图 720x1280:她占 0.8352 高、底边距 0.0883(113px)
|
|
985
|
+
—— 宽高比 0.5625 窄于盒子,contain 按**高**贴合,画出来的还是盒子高。
|
|
986
|
+
|
|
987
|
+
两个数都是"占盒子高的比例",所以校正只有一个乘法:她要长到 0.9883 高,
|
|
988
|
+
就得乘 0.9883/0.8352 = 1.1834(取 **1.186** —— 97 帧之间包围盒自己还在 ±0.2%
|
|
989
|
+
抖,1.186 落在它中间,也正是 preflight 断言盯着的那个值)。
|
|
990
|
+
|
|
991
|
+
底边还得对齐。transform-origin 放在**脚底**(50% 100%),缩放不动底边;
|
|
992
|
+
缩放把底边距一起放大成 0.0883*1.186 = 0.1047,要收回立绘那 0.0117,
|
|
993
|
+
就得让底边下移 0.0930 个盒子高。而 scale(s) translateY(t) 是**右乘先生效**:
|
|
994
|
+
translate 先作用在元素自身坐标里,再被 s 缩放一次 —— 真正下移的是 s*t,
|
|
995
|
+
故 t = 0.0930/1.186 = **7.84%**(translateY 的百分比基准就是元素自身高度)。
|
|
996
|
+
|
|
997
|
+
验算:缩放后 0.8352*1.186 = 0.9905 高(立绘 0.9883,差 0.2% ≈ 1px),
|
|
998
|
+
底边距 0.1047 - 0.0930 = 0.0117(立绘 0.0117)—— 高度与落地一起对上。
|
|
999
|
+
两级都在**盒子坐标**里算,与 size 档位无关:放大是纯 transform,不重排版。
|
|
1000
|
+
|
|
1001
|
+
第二段素材(v1.46.5,idle)**不需要**校正:她在那张里占 0.972 个盒高,立绘按同一
|
|
1002
|
+
量法占 0.982~0.988 —— 差 1.0~1.6%,在 210px 的盒子里是 2~3px,比 sleepy 那
|
|
1003
|
+
15.7% 小一个量级。所以这一条就不补:每加一条变换就多一处能和"两层同格"对不上的
|
|
1004
|
+
地方,而它换来的是肉眼看不出的 2px。表在 JS 那边(MOTION_FIT),这里按 data-clip
|
|
1005
|
+
把"不校正"写成一条**显式**规则 —— 否则 base 那条 1.186 会落到 idle 头上,那才是
|
|
1006
|
+
真的错。两条素材都是"行内 + 样式表"两份落点(行内见 buildMotion())。 */
|
|
1007
|
+
.wisp-video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;object-position:bottom center;pointer-events:none;transform:scale(1.186) translateY(7.84%);transform-origin:50% 100%}
|
|
1008
|
+
.wisp-video[data-clip="idle"]{transform:none}
|
|
1009
|
+
/* 泳装那八条(v1.47.1):逐条量过包围盒,她在那八张素材里占 **0.9515~0.9858** 个盒高
|
|
1010
|
+
(各自对应的泳装立绘 0.9674~0.9967),差 **0.2%~1.6%** —— 全在"小于 2% 就不补"那条线
|
|
1011
|
+
以内(最大的一条是 swim_proud:1.6%,在 210px 的盒子里 3px 量级)。所以这八条要的
|
|
1012
|
+
同样是**显式的不校正**:base 那条 1.186 是给通用 sleepy 的,落到泳装这八条头上就是
|
|
1013
|
+
平白放大 18.6%。行内那份(buildMotion 按 MOTION_FIT 写)也写 none,两处各查各的 ——
|
|
1014
|
+
和上面 idle 那条是同一个道理,只是这一条一次盖住八条素材。 */
|
|
1015
|
+
.wisp-video[data-clip^="swim_"]{transform:none}
|
|
1016
|
+
/* 帧动画**接管画面**时,静态立绘退场(v1.45.2)。两层叠在同一格里,同时可见就是
|
|
1017
|
+
**重影** —— 动图的透明处并不遮挡下面那张静帧,于是那段起伏会被看成"两张画
|
|
1018
|
+
错位叠在一起"。退场必须发生在动图真的上了画面的时候,而不是"有素材"的时候:
|
|
1019
|
+
冻结与加载失败都还要靠这张立绘撑场面。
|
|
1020
|
+
用 visibility 而不是 display:none:盒子照旧占位,布局一点不动,"退场/回场"不会
|
|
1021
|
+
让她跳一下(视频那层正是靠同一套盒模型对齐的,动布局就是动对齐)。
|
|
1022
|
+
开关只有一处 —— syncMotion() 里的 setFrameOwner();这里只负责把根上那个属性
|
|
1023
|
+
翻译成画面。行内还有一份镜像(见 setFrameOwner),给样式表缺失的壳兜底。 */
|
|
1024
|
+
.wisp-root[data-frame="on"] .wisp-img{visibility:hidden}
|
|
875
1025
|
.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}
|
|
876
1026
|
.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}
|
|
877
1027
|
.wisp-say{position:absolute;max-width:210px;width:max-content;padding:7px 11px;border-radius:12px;font-size:12px;line-height:1.45;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 6px 24px rgba(0,0,0,.22);-webkit-backdrop-filter:blur(14px) saturate(160%);backdrop-filter:blur(14px) saturate(160%);animation:wisp-say 4.2s ease forwards;pointer-events:none;white-space:pre-wrap}
|
|
@@ -879,20 +1029,27 @@ const UPDATE_HINT_GITHUB = 'npm 上还是旧版,用这个地址装 dsh-wisp:
|
|
|
879
1029
|
.wisp-root[data-mood="happy"]{--wisp-aura:rgba(255,132,206,.50)}
|
|
880
1030
|
.wisp-root[data-mood="sleep"]{--wisp-aura:rgba(140,152,215,.30)}
|
|
881
1031
|
body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20,24,32,.88);--wisp-say-line:rgba(255,255,255,.14);--wisp-ink:#cfe6ff}
|
|
882
|
-
.wisp-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1032
|
+
/* 身体层只剩 worried 那一下(v1.45.3)。happy 的上下跳(wisp-hop)与 attn 的左右轻推
|
|
1033
|
+
(wisp-nudge)都删了:两条都靠 translate 挪整个 .wisp-body,而这一层现在同时承载帧动画
|
|
1034
|
+
与交叉淡入 —— 位移叠上去不像"她在动",像素材在抖。换表情本来就有 .wisp-motion 那一下弹
|
|
1035
|
+
(POPPY_MOODS 那一组),"她回应了"的信号不缺。
|
|
1036
|
+
一并删掉 sleep 9s / alert 4.6s 两条 animation-duration:它们是给 1.45.1 删掉的常驻浮动
|
|
1037
|
+
调速度用的,这里已经没有动画名可调,留着只会让人以为身体层还在动。 */
|
|
1038
|
+
.wisp-root[data-mood="sleep"]{--wisp-sprite:drop-shadow(0 6px 14px rgba(0,0,0,.25)) saturate(.82) brightness(.96)}
|
|
1039
|
+
/* 睡眠这档给静态立绘调色(saturate/brightness)。动作层**不跟着调**(v1.45.3 起):
|
|
1040
|
+
动作层不许挂 filter,所以睡着的那一刻两张图之间会有一点色差 —— 这是已知的、
|
|
1041
|
+
接受了的取舍:宁可色温跳一下,也不要 alpha 剪影被滤镜重新栅格化一次。 */
|
|
887
1042
|
@keyframes wisp-zzz{0%{opacity:0;transform:translate(0,0) scale(.6)}30%{opacity:.9}100%{opacity:0;transform:translate(14px,-30px) scale(1.15)}}
|
|
888
1043
|
.wisp-root[data-mood="attn"]{--wisp-aura:rgba(255,138,120,.55)}
|
|
889
1044
|
.wisp-root[data-mood="worried"]{--wisp-aura:rgba(150,170,190,.5)}
|
|
890
|
-
.wisp-root[data-mood="worried"]
|
|
1045
|
+
.wisp-root[data-mood="worried"]{--wisp-sprite:drop-shadow(0 6px 14px rgba(0,0,0,.25)) saturate(.86)}
|
|
1046
|
+
/* 键盘焦点:只有**键盘**拿到的焦点才亮这一圈(data-focus="key" 由 JS 写,见 focusHer)。
|
|
1047
|
+
两件事分开:默认的矩形环永远不画;"她有没有焦点"仍然看得见 —— 键盘用户 Tab 到她
|
|
1048
|
+
身上不能没有任何变化。滤镜链拆成 --wisp-glow + --wisp-sprite 就是为了让这一条
|
|
1049
|
+
叠加在任意表情的滤镜上(sleep / worried 各改各的那一半,谁也不覆盖谁)。 */
|
|
1050
|
+
.wisp-root[data-focus="key"]{--wisp-glow:drop-shadow(0 0 4px var(--wisp-accent)) drop-shadow(0 0 11px var(--wisp-accent))}
|
|
891
1051
|
.wisp-root[data-mood="worried"] .wisp-body{animation-name:wisp-worry;animation-duration:2.4s;animation-iteration-count:3}
|
|
892
1052
|
@keyframes wisp-worry{0%,100%{transform:translate3d(0,0,0)}50%{transform:translate3d(0,-5px,0)}}
|
|
893
|
-
.wisp-root[data-mood="attn"] .wisp-body{animation-name:wisp-nudge;animation-duration:1.5s;animation-iteration-count:4}
|
|
894
|
-
@keyframes wisp-nudge{0%,100%{transform:translate3d(0,0,0)}25%{transform:translate3d(-7px,0,0)}75%{transform:translate3d(7px,0,0)}}
|
|
895
|
-
@keyframes wisp-hop{0%,100%{transform:translate3d(0,0,0)}45%{transform:translate3d(0,-16px,0)}}
|
|
896
1053
|
/* ---- 动作层(见文件上方 motion 常量处的说明)----
|
|
897
1054
|
两层都是**空盒子**:绝对定位铺满、不吃指针。姿势与一次性动作分别只碰自己
|
|
898
1055
|
那一层的 transform,所以它们永远不会互相覆盖。
|
|
@@ -995,7 +1152,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
995
1152
|
.wisp-credits{font-size:11px;opacity:.8;margin:6px 0 0}
|
|
996
1153
|
.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}
|
|
997
1154
|
.wisp-dialog-action:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.2))}
|
|
998
|
-
@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}}`
|
|
1155
|
+
@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-video{display: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}}`
|
|
999
1156
|
|
|
1000
1157
|
const installStyles = (styles) => {
|
|
1001
1158
|
if (styles && typeof styles.insert === 'function') {
|
|
@@ -1504,6 +1661,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1504
1661
|
bodyEl.setAttribute('tabindex', '0')
|
|
1505
1662
|
bodyEl.setAttribute('aria-label', 'DeepSeek娘')
|
|
1506
1663
|
bodyEl.setAttribute('aria-haspopup', 'menu')
|
|
1664
|
+
/* 默认焦点环必须关掉,行内再写一遍:它画在**盒子**上,而盒子里大部分是透明的,
|
|
1665
|
+
看上去就是套着素材的矩形边界线。样式表缺失的壳里同样不该出现 ——
|
|
1666
|
+
与精灵图、两层盒子、命中层"关键样式行内再写一遍"是同一条理由。
|
|
1667
|
+
键盘焦点改用贴着剪影的辉光,见 CSS 里的 --wisp-glow。 */
|
|
1668
|
+
bodyEl.style.outline = 'none'
|
|
1507
1669
|
|
|
1508
1670
|
/* 命中层(见 clipPathOf 上面的说明):空盒子 + 由蒙版生成的 clip-path。
|
|
1509
1671
|
关键样式同样行内再写一遍 —— 样式表缺失时它必须仍然是"铺满、吃指针"的那个。 */
|
|
@@ -1668,6 +1830,49 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1668
1830
|
素材还没生成时由 SPRITE_FALLBACK 退回 idle —— 不会出现空白或坏图。 */
|
|
1669
1831
|
const SPRITE_OF = { idle: 'idle', alert: 'work', happy: 'happy', sleep: 'sleepy', attn: 'attn', care: 'attn', poked: 'poked', proud: 'proud', worried: 'sleepy', eat: 'eat', report: 'report' }
|
|
1670
1832
|
const SPRITE_FALLBACK = { attn: 'work', report: 'idle' }
|
|
1833
|
+
/* 哪个**皮肤+状态**有帧动画素材(v1.47.0 起按皮肤分开)。
|
|
1834
|
+
键是 `<皮肤>:<情绪>`,值是 MOTION 里的素材名。
|
|
1835
|
+
|
|
1836
|
+
为什么键也要带皮肤:泳装那八条是照着**泳装立绘**生成的,别的皮肤穿上
|
|
1837
|
+
就是另一个人的衣服 —— 把泳装那条放给宵蓝礼服,画面上会当场换装。
|
|
1838
|
+
所以查表是"先看这个皮肤有没有自己的这一段,没有再退回通用那一对"。
|
|
1839
|
+
|
|
1840
|
+
值是**素材名**(= 立绘名),不是情绪名:`sleep` 那一档播的是
|
|
1841
|
+
`swim_sleepy`(睡着那张立绘的动画)、`alert` 那一档播的是
|
|
1842
|
+
`swim_work`(敲键盘那张)—— 素材按"哪张立绘"点,`MOTION_FIT` 也按
|
|
1843
|
+
素材名查,两边不会各说各话。
|
|
1844
|
+
|
|
1845
|
+
通用那一对(`idle` / `sleepy`)是所有皮肤共用的;`worried` 虽然也用
|
|
1846
|
+
sleepy 那张立绘,但它有自己的抖动,"睡着了的呼吸"不该跟着它走 ——
|
|
1847
|
+
所以它查不到素材,连 <img> 都不建。 */
|
|
1848
|
+
const MOTION_OF = {
|
|
1849
|
+
'swim:idle': 'swim_idle',
|
|
1850
|
+
'swim:attn': 'swim_attn',
|
|
1851
|
+
'swim:happy': 'swim_happy',
|
|
1852
|
+
'swim:sleep': 'swim_sleepy',
|
|
1853
|
+
'swim:alert': 'swim_work',
|
|
1854
|
+
'swim:proud': 'swim_proud',
|
|
1855
|
+
'swim:eat': 'swim_eat',
|
|
1856
|
+
'swim:poked': 'swim_poked',
|
|
1857
|
+
}
|
|
1858
|
+
const MOTION_BASE = { idle: 'idle', sleep: 'sleepy' }
|
|
1859
|
+
/* 查表只有这一处:syncMotion() 与 doctor() 都走它,所以"报告里说有素材、
|
|
1860
|
+
画面上没有"这种分歧没有地方能长出来。 */
|
|
1861
|
+
const motionKeyFor = (moodNow) => MOTION_OF[activeSkin + ':' + moodNow] ?? MOTION_BASE[moodNow]
|
|
1862
|
+
/* 每一段素材自己的**尺寸校正**(推导见样式表 .wisp-video 上那一段)。
|
|
1863
|
+
表里没有 = 这一段不需要校正,动作层按原样铺满盒子。
|
|
1864
|
+
为什么 idle 不在表里:她在那张 720x1280 里占 0.972 个盒高(立绘 0.982~0.988),
|
|
1865
|
+
差 1.0~1.6% —— **小于 2% 就不补**:这点差在 210px 的盒子里是 2~3px,而每加一条
|
|
1866
|
+
变换就多一处能和"两层同格"这条规矩对不上的地方(sleepy 那 15.7% 是必须补的,
|
|
1867
|
+
这一条不是)。底边距 1.7% 对 1.2%,差 1px 量级,同样不补。
|
|
1868
|
+
泳装那八条按实测包围盒逐条定:差异 ≥2% 才写进来(见 1.47.0 小节)。
|
|
1869
|
+
**1.47.1 量完八条的结果:一条都不用写。**她在那八张 720x1280 素材里占
|
|
1870
|
+
0.9515~0.9858 个盒高,各自对应的泳装立绘占 0.9674~0.9967 —— 差 0.2%~1.6%,
|
|
1871
|
+
八条全在 2% 那条线以内(最大的是 swim_proud 的 1.6%,合 3px)。底边距同理
|
|
1872
|
+
(1.1~4.5px)。所以这张表**一个字都没变**,变的是样式表那边多了一条
|
|
1873
|
+
.wisp-video[data-clip^="swim_"]{transform:none}(见那一行上的量法与理由)——
|
|
1874
|
+
不补也要**显式地**不补,否则 base 那条 1.186 会落到这八条头上。 */
|
|
1875
|
+
const MOTION_FIT = { sleepy: 'scale(1.186) translateY(7.84%)' }
|
|
1671
1876
|
let mood = 'idle'
|
|
1672
1877
|
|
|
1673
1878
|
/* 换皮肤 = 换一整套精灵图。位置、情绪、气泡都不动,只有图与命中蒙版重建。 */
|
|
@@ -1682,6 +1887,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1682
1887
|
activeSkin = id
|
|
1683
1888
|
writeStored(SKIN_KEY, { id })
|
|
1684
1889
|
if (changed) setSprite(SPRITE_OF[mood])
|
|
1890
|
+
/* 换皮肤也要重算动作层(v1.47.0):素材是**按皮肤**点的,泳装那八条
|
|
1891
|
+
只属于泳装。少了这一句,换到泳装时画面上还是通用那段 idle 的循环
|
|
1892
|
+
——"图换了、动的那层没换"正是这一版最容易漏的一处。
|
|
1893
|
+
它在 setSprite 之后:动图接管画面时要读到的静态立绘已经是新的了。 */
|
|
1894
|
+
if (changed) syncMotion()
|
|
1685
1895
|
return true
|
|
1686
1896
|
}
|
|
1687
1897
|
|
|
@@ -1731,6 +1941,181 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1731
1941
|
})
|
|
1732
1942
|
}
|
|
1733
1943
|
|
|
1944
|
+
/* ---- 帧动画的闸门(v1.45.0;v1.46.1 起是 <img> 动图)--------------------
|
|
1945
|
+
syncMotion() 是**唯一**决定"这一刻画面归谁"的地方:
|
|
1946
|
+
|
|
1947
|
+
这个表情没有动作素材 -> 连 <img> 都不建(有也藏起来),不白解码 1.9 MB
|
|
1948
|
+
有素材但该冻结 -> 把动图藏掉,画面交回静态立绘
|
|
1949
|
+
有素材且可以动 -> 显示动图,静态立绘退场
|
|
1950
|
+
|
|
1951
|
+
它还顺带决定**画面归谁**(v1.45.2):动图真的上了画面,静态立绘就退场
|
|
1952
|
+
(setFrameOwner(true));其余每一种情况 —— 没有素材、冻结、加载失败 ——
|
|
1953
|
+
立绘都是画面里的那个人(setFrameOwner(false))。这不是两件事,是同一件事的
|
|
1954
|
+
两面:帧动画没在显示的时候,屏幕上必须有一个她。
|
|
1955
|
+
|
|
1956
|
+
什么算"该冻结":静止档(cfg.motion === 'off')、系统的
|
|
1957
|
+
prefers-reduced-motion,以及她**被躲起来**(root 上是 display:none)——
|
|
1958
|
+
屏幕上看不见的东西不该继续占着解码器。
|
|
1959
|
+
|
|
1960
|
+
动图**停不下来**:<img> 上没有 pause(),也没有 currentTime 可以写回 0。
|
|
1961
|
+
所以"冻结"只有一条路 —— 藏起来、把画面交回立绘。这也是为什么这一层
|
|
1962
|
+
必须和静态立绘同格:冻住的时候,"她"就是那张静帧。
|
|
1963
|
+
|
|
1964
|
+
三个触发点:换表情(setMood)、档位改动(applyMotion)、系统设置在会话
|
|
1965
|
+
中途变了(matchMedia 的 change)。以后再加一个"她该不该动"的开关,就必须
|
|
1966
|
+
接到这里来:这一版最容易犯的错就是"静止档里她还在动",而它只在真机上看得见。
|
|
1967
|
+
|
|
1968
|
+
元素换成了 <img>,play()/autoplay 那一串就没有了:动图加载完自己动。
|
|
1969
|
+
唯一还要包住的是 resolveSprite()(壳里的替身可能没有 Blob 通道),
|
|
1970
|
+
它失败时我们只是不显示这一层 —— 静默降级,画面回到立绘。
|
|
1971
|
+
|
|
1972
|
+
失败**按素材记**(v1.46.5):包里有两条素材了,"她这段坏了"和"她那段坏了"
|
|
1973
|
+
是两件事。坏过的那一段不再重指源(重试一个坏源只会每次换表情都烧一次解码,
|
|
1974
|
+
换来的画面和静态立绘一模一样);没坏的那一段照常播。 */
|
|
1975
|
+
let motionImg = null
|
|
1976
|
+
let motionKey = null
|
|
1977
|
+
let motionFailed = false
|
|
1978
|
+
const motionFailedKeys = Object.create(null)
|
|
1979
|
+
let motionMqlOff = null
|
|
1980
|
+
|
|
1981
|
+
/* 静态立绘什么时候退场,只有这一个开关(v1.45.2)。
|
|
1982
|
+
"帧动画接管画面"= 动图这一层真的显示出来了 —— 那一刻两层同时可见就是重影,
|
|
1983
|
+
而重影不是兜底,是错。属性走 CSS(.wisp-root[data-frame="on"] .wisp-img),
|
|
1984
|
+
因为属性盖得住**交叉淡入**留下的那张旧图,而行内只写得中当前这两张;
|
|
1985
|
+
行内再镜像一份,是为了样式表缺失的壳里画面仍然是对的(这条理由和精灵图、
|
|
1986
|
+
两层盒子、动图的"关键样式行内再写一遍"完全一样)。
|
|
1987
|
+
读的人只需要记住:谁在画面里,由 syncMotion() 说了算。 */
|
|
1988
|
+
const setFrameOwner = (on) => {
|
|
1989
|
+
if (on) root.dataset.frame = 'on'
|
|
1990
|
+
else delete root.dataset.frame
|
|
1991
|
+
const imgs = leanEl.querySelectorAll('.wisp-img')
|
|
1992
|
+
for (let i = 0; i < imgs.length; i++) imgs[i].style.visibility = on ? 'hidden' : ''
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
const hideMotion = () => {
|
|
1996
|
+
if (motionImg !== null) motionImg.style.display = 'none'
|
|
1997
|
+
/* 动图退场,静态立绘立刻回到画面:冻结时看到的、加载失败时兜底的都是这张。
|
|
1998
|
+
动图没有"暂停"可喊 —— 藏起来就是这一层唯一也是正确的冻结方式。 */
|
|
1999
|
+
setFrameOwner(false)
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const startMotion = () => {
|
|
2003
|
+
if (motionImg === null || motionFailed) return
|
|
2004
|
+
motionImg.style.display = ''
|
|
2005
|
+
/* 动图接管画面:静态立绘退场。这一行必须在任何提前返回**之前** ——
|
|
2006
|
+
每次换表情都会重建交叉淡入的那张旧图,而它同样得被藏掉,
|
|
2007
|
+
所以只要 syncMotion() 判了"在动",这个开关就要重写一遍。 */
|
|
2008
|
+
setFrameOwner(true)
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/* 一段素材的**几何**(行内那一份):变换按素材查表,元素上那个 data-clip 同时
|
|
2012
|
+
给样式表用(.wisp-video[data-clip="idle"]{transform:none})。
|
|
2013
|
+
两处都得调它:新建元素时、以及**换源**时 —— 换源不重写这两样,睡下之后
|
|
2014
|
+
身上还挂着站着那段的"不校正",而醒着的时候会反过来被放大 18.6%。
|
|
2015
|
+
(这正是 v1.46.5 换源那一条断言抓到的:图片换了、几何没换。) */
|
|
2016
|
+
const applyClipGeometry = (img, key) => {
|
|
2017
|
+
img.dataset.clip = key
|
|
2018
|
+
img.style.transform = MOTION_FIT[key] ?? 'none'
|
|
2019
|
+
img.style.transformOrigin = '50% 100%'
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const buildMotion = (key) => {
|
|
2023
|
+
const img = document.createElement('img')
|
|
2024
|
+
img.className = 'wisp-video'
|
|
2025
|
+
/* 关键样式**行内再写一遍**(和精灵图、两层盒子同一个理由):样式表缺失时
|
|
2026
|
+
它必须仍然是"和立绘同一格、不吃指针"的那个。 */
|
|
2027
|
+
img.style.position = 'absolute'
|
|
2028
|
+
img.style.top = '0'
|
|
2029
|
+
img.style.left = '0'
|
|
2030
|
+
img.style.width = '100%'
|
|
2031
|
+
img.style.height = '100%'
|
|
2032
|
+
img.style.objectFit = 'contain'
|
|
2033
|
+
img.style.objectPosition = 'bottom center'
|
|
2034
|
+
img.style.pointerEvents = 'none'
|
|
2035
|
+
/* 尺寸校正(推导见样式表 .wisp-video 上那一段):她在那张 720x1280 的动图里
|
|
2036
|
+
只占 0.8352 个盒高,而立绘占 0.9883 —— 不补这一下,睡着时她比立绘小 15.7%。
|
|
2037
|
+
按**素材**查表(MOTION_FIT),不是按状态写死:哪天换素材,改的是那张表。
|
|
2038
|
+
idle 不在表里 —— 它只差 1%,补它是在给"两层同格"制造一个能对不上的地方。
|
|
2039
|
+
没有校正时写 'none' 而不是留空:空的看不出是"不需要"还是"忘了"。 */
|
|
2040
|
+
applyClipGeometry(img, key)
|
|
2041
|
+
img.style.display = 'none'
|
|
2042
|
+
/* 动图是**装饰**:alt 留空、aria-hidden,读屏器不该为一个循环动图分心。 */
|
|
2043
|
+
img.setAttribute('alt', '')
|
|
2044
|
+
img.setAttribute('aria-hidden', 'true')
|
|
2045
|
+
img.draggable = false
|
|
2046
|
+
/* 加载失败**静默降级**:藏掉动图、继续用静态立绘。不抛、不冒泡、不写控制台
|
|
2047
|
+
—— 这条路的"失败"和"没有素材"是同一幅画面,没有可追问的东西。
|
|
2048
|
+
失败记在**这一段素材**名下(不是"这个元素坏了"):另一段素材没坏,
|
|
2049
|
+
她换个状态就该照常动。所以这里读的是元素**当下**在播的那一段(motionKey),
|
|
2050
|
+
不是建元素时那一段 —— 元素是复用的,绑定时的 key 早就过期了。 */
|
|
2051
|
+
const onFail = () => { motionFailedKeys[motionKey ?? key] = true; motionFailed = true; hideMotion() }
|
|
2052
|
+
if (typeof img.addEventListener === 'function') img.addEventListener('error', onFail)
|
|
2053
|
+
else img.onerror = onFail
|
|
2054
|
+
/* 先认领这个元素,再指源:错误是异步来的,但"它属于哪一段素材"不能等到
|
|
2055
|
+
指完源才知道(下面那个 catch 走的是同一条账)。 */
|
|
2056
|
+
motionImg = img
|
|
2057
|
+
motionKey = key
|
|
2058
|
+
/* 素材字节走**和立绘同一条路**:motionSrc() 把素材名变成 URL ——
|
|
2059
|
+
v1.47.0 起是宿主半包发出来的 `/wisp-motion/<文件>`(页面自己的 base
|
|
2060
|
+
起算),万一表里是 data URI 才回落到 Blob → object URL。
|
|
2061
|
+
这个壳把 data: 源当坏图处理(见文件上方的平台事实 3),动图没有理由
|
|
2062
|
+
不一样;而 resolveSprite 在 Blob 拿不到时会回落到原始 data URI。 */
|
|
2063
|
+
try { img.src = motionSrc(MOTION[key]) } catch (e) { motionFailedKeys[key] = true; motionFailed = true }
|
|
2064
|
+
leanEl.appendChild(img)
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
/* "这一刻该不该停住"只有这一份判断:syncMotion 拿它做决定,doctor() 拿它做汇报。
|
|
2068
|
+
两处各写一遍的话,"报告说在播、画面上停着"这种分歧迟早会出现。
|
|
2069
|
+
四个来源:**帧动画开关**(cfg.frame,v1.47.0,只收走动图)/ 幅度归零 / 系统的
|
|
2070
|
+
减少动态效果 / 她被躲起来(屏幕上看不见的东西不该占着解码)。 */
|
|
2071
|
+
const motionFrozen = () => cfg.frame === false || amp() === 0 || reduced() || root.dataset.hidden === 'true'
|
|
2072
|
+
|
|
2073
|
+
const syncMotion = () => {
|
|
2074
|
+
if (destroyed) return
|
|
2075
|
+
const key = motionKeyFor(mood)
|
|
2076
|
+
if (typeof key !== 'string' || typeof MOTION[key] !== 'string') {
|
|
2077
|
+
/* 这个皮肤在这个状态没有动作素材:不建 <img>。 */
|
|
2078
|
+
if (motionImg !== null) hideMotion()
|
|
2079
|
+
return
|
|
2080
|
+
}
|
|
2081
|
+
/* 这一段素材**已经坏过**:不再重指一次源。判断按素材走(见上面 motionFailedKeys)。
|
|
2082
|
+
motionFailed 是**派生**的 —— 它必须等于"当前这一段坏没坏",而不是"最近一次
|
|
2083
|
+
失败留下的印记":后者会把一段坏素材变成她**永远**不动(连换回好的那段也不动),
|
|
2084
|
+
而这正是 v1.46.5 的预检抓到的那条。 */
|
|
2085
|
+
motionFailed = motionFailedKeys[key] === true
|
|
2086
|
+
if (motionFailed) {
|
|
2087
|
+
if (motionImg !== null) hideMotion()
|
|
2088
|
+
return
|
|
2089
|
+
}
|
|
2090
|
+
if (motionImg === null) buildMotion(key)
|
|
2091
|
+
else if (motionKey !== key) {
|
|
2092
|
+
/* 多段素材时,同一个元素换 src 就够了 —— 不重建元素,
|
|
2093
|
+
免得把"元素存在"这件事也变成一次竞态。
|
|
2094
|
+
**几何要跟着换**:transform 与 data-clip 都是**按素材**取的,
|
|
2095
|
+
换源不重写它们,画面上的大小就会停在上一段素材那一档。 */
|
|
2096
|
+
motionKey = key
|
|
2097
|
+
motionFailed = false
|
|
2098
|
+
applyClipGeometry(motionImg, key)
|
|
2099
|
+
try { motionImg.src = motionSrc(MOTION[key]) } catch (e) { motionFailedKeys[key] = true; motionFailed = true }
|
|
2100
|
+
}
|
|
2101
|
+
if (motionFailed || motionFrozen()) {
|
|
2102
|
+
hideMotion()
|
|
2103
|
+
return
|
|
2104
|
+
}
|
|
2105
|
+
startMotion()
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/* 系统设置**在会话中途**改了也要当场生效:CSS 的媒体查询是实时的,而"这一层
|
|
2109
|
+
现在显不显示"是 JS 写的,媒体查询够不着它。不订阅这个变化,"你现在去系统里
|
|
2110
|
+
关掉动画"就得等到下一次换表情才生效 —— 而那一刻她正动着。 */
|
|
2111
|
+
try {
|
|
2112
|
+
reduced() // 先把 MQL 取出来(reduced() 里那次是懒加载)
|
|
2113
|
+
if (reducedMql && typeof reducedMql.addEventListener === 'function') {
|
|
2114
|
+
reducedMql.addEventListener('change', syncMotion)
|
|
2115
|
+
motionMqlOff = () => { try { reducedMql.removeEventListener('change', syncMotion) } catch (e) { /* ignore */ } }
|
|
2116
|
+
}
|
|
2117
|
+
} catch (e) { /* 拿不到就算了:冻结规则在"读一次"这条路上仍然成立 */ }
|
|
2118
|
+
|
|
1734
2119
|
/* 姿势:° 是**屏幕坐标**里的角度(正 = 顶朝右),乘镜像由 applyTilt 做。
|
|
1735
2120
|
这里是所有姿势的唯一闸门 —— 静止档与"系统要求别动"都在这一个地方归零,
|
|
1736
2121
|
调用点不必各自记得判断。 */
|
|
@@ -1743,10 +2128,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1743
2128
|
|
|
1744
2129
|
/* 档位改动要**当场**生效,不用刷新:属性一改,CSS 那边的全部幅度规则
|
|
1745
2130
|
(关键帧里的 --wisp-amp、静止档关掉呼吸与 z)立刻跟上。
|
|
1746
|
-
另外把姿势归零 —— 从"灵动"切到"静止"时她还歪着,就成了半截状态。
|
|
2131
|
+
另外把姿势归零 —— 从"灵动"切到"静止"时她还歪着,就成了半截状态。
|
|
2132
|
+
帧动画也在这里收:`motion: off` 是"她不动"的两个闸门之一,而动图是 JS
|
|
2133
|
+
在显示 —— CSS 的 --wisp-amp:0 对它一个字也说不上(见 syncMotion)。 */
|
|
1747
2134
|
const applyMotion = () => {
|
|
1748
2135
|
root.dataset.motion = cfg.motion
|
|
1749
2136
|
if (amp() === 0 || reduced()) setTilt(0)
|
|
2137
|
+
syncMotion()
|
|
1750
2138
|
}
|
|
1751
2139
|
applyMotion()
|
|
1752
2140
|
|
|
@@ -1775,6 +2163,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1775
2163
|
z2.style.right = '24px'; z2.style.top = '-2px'; z2.style.animationDelay = '1.1s'
|
|
1776
2164
|
root.appendChild(z1); root.appendChild(z2)
|
|
1777
2165
|
}
|
|
2166
|
+
/* 换表情是帧动画的第三个触发点:睡下时开始播、醒来时收掉(见 syncMotion)。
|
|
2167
|
+
放在**最后**:它读的是上面那几行刚写完的 mood 与 dataset。 */
|
|
2168
|
+
syncMotion()
|
|
1778
2169
|
}
|
|
1779
2170
|
|
|
1780
2171
|
/* 气泡也挂在 layer 上,所以躲起来时必须单独收掉 —— 只把 root 设成
|
|
@@ -2412,6 +2803,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2412
2803
|
root.dataset.hidden = hidden ? 'true' : 'false'
|
|
2413
2804
|
if (hidden) { closeMenu(); hideTab(); clearSay(); showTab(); setTilt(0) }
|
|
2414
2805
|
else { hideTab(); paint() }
|
|
2806
|
+
/* 躲起来 = 屏幕上看不见:帧动画跟着收(syncMotion 读的就是这个 dataset,
|
|
2807
|
+
所以它必须写在前面)。不接这一下的话,一层看不见的动图会继续解码。 */
|
|
2808
|
+
syncMotion()
|
|
2415
2809
|
if (cfg.persist) writeStored(HIDDEN_KEY, { hidden })
|
|
2416
2810
|
}
|
|
2417
2811
|
|
|
@@ -3066,6 +3460,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3066
3460
|
行为开关原本在设置页里,但那页与菜单重叠过大被撤掉了 —— 这三个开关是面板
|
|
3067
3461
|
真正的净增量,放进菜单比再建一页便宜得多(复用现有的项结构、键盘支持与关闭逻辑)。 */
|
|
3068
3462
|
const TOGGLES = [
|
|
3463
|
+
/* 帧动画单独一个开关(v1.47.0):它是**画面里唯一会自己动的东西**,
|
|
3464
|
+
所以"我不想让她动图循环"不该被逼着去动整个动作幅度档。 */
|
|
3465
|
+
{ key: 'frame', label: '帧动画' },
|
|
3069
3466
|
{ key: 'wander', label: '自己溜达' },
|
|
3070
3467
|
{ key: 'reactions', label: '跟随状态' },
|
|
3071
3468
|
{ key: 'celebrate', label: '跑完撒花' },
|
|
@@ -3176,9 +3573,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3176
3573
|
const previewMood = (key) => {
|
|
3177
3574
|
const row = ACTION_ROWS.find((r) => r.key === key)
|
|
3178
3575
|
if (!row) return false
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3576
|
+
/* **不关窗**(v1.47.1):动作一览是"一个一个试"的地方 —— 点一下关一次的话,
|
|
3577
|
+
想连看三个动作就得重开三次,每次还要重新找位置。焦点留在被点的格子上
|
|
3578
|
+
(弹窗还在,那个格子就还在,所以不用像以前那样把焦点交还给她)。
|
|
3579
|
+
要关窗仍有三条路:Esc / 右上角 × / 点背景。 */
|
|
3182
3580
|
previewUntil = nowMs() + 6000
|
|
3183
3581
|
setMood(row.mood)
|
|
3184
3582
|
/* 预览必须**自己有到期时间**:预览的姿势可能在"稳态轮询不许覆盖"的名单里
|
|
@@ -3210,7 +3608,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3210
3608
|
grid.appendChild(cell)
|
|
3211
3609
|
}
|
|
3212
3610
|
return openDialog('actions', '动作一览', [
|
|
3213
|
-
para('
|
|
3611
|
+
para('点一个动作,她就当场做给你看 —— 可以连着点,窗不会关。(图都是当前皮肤 ' + skinLabel(activeSkin) + ')'),
|
|
3214
3612
|
grid,
|
|
3215
3613
|
dialogFoot('共 ' + ACTION_ROWS.length + ' 个动作', '按 Esc 关闭'),
|
|
3216
3614
|
])
|
|
@@ -3229,6 +3627,22 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3229
3627
|
closeDialog()
|
|
3230
3628
|
showUpdate()
|
|
3231
3629
|
})
|
|
3630
|
+
/* 安装按钮:只在**查到新版**时出现,装的就是那一版(数字与装下去的东西一致)。 */
|
|
3631
|
+
const latest = typeof updateCheck.latest === 'string' ? updateCheck.latest : null
|
|
3632
|
+
const installBtn = document.createElement('button')
|
|
3633
|
+
installBtn.className = 'wisp-dialog-action'
|
|
3634
|
+
installBtn.setAttribute('type', 'button')
|
|
3635
|
+
installBtn.textContent = '安装 v' + String(latest)
|
|
3636
|
+
installBtn.addEventListener('click', (ev) => {
|
|
3637
|
+
ev.preventDefault()
|
|
3638
|
+
closeDialog()
|
|
3639
|
+
installUpdate(latest)
|
|
3640
|
+
})
|
|
3641
|
+
const installedNote = installState.state === 'installed' && installState.target === latest
|
|
3642
|
+
? para('v' + latest + (installState.application === 'restart-required'
|
|
3643
|
+
? ' 已经装上了 —— 重启 DSH 后生效。'
|
|
3644
|
+
: ' 已经装上了 —— 刷新页面就是新版。'))
|
|
3645
|
+
: null
|
|
3232
3646
|
return openDialog('about', '关于她', [
|
|
3233
3647
|
para('DeepSeek娘 —— DSH 界面上的浮动桌宠。她会跟着你的状态换表情:你打字她在忙,你闲着她在晃,你跑完一轮她替你高兴。'),
|
|
3234
3648
|
para('她的样子照社区规范来:深蓝色渐变长直发、头顶一个大圆环呆毛、两片鲸鳍耳、女仆装。'),
|
|
@@ -3239,6 +3653,12 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3239
3653
|
/* 上一次检查更新失败时,把宿主看到的东西摆在最显眼的位置 —— 台词只有一句,
|
|
3240
3654
|
而"为什么拿不到"必须能被看到、被复制、被追问。 */
|
|
3241
3655
|
diagRes ? para('上次查更新为什么没成:\n' + describeDiag(diagRes)) : null,
|
|
3656
|
+
/* 查到新版就**在这里装**(v1.48.0):装这一步由平台的插件管理器去做,
|
|
3657
|
+
插件自己不碰网络、也不需要宿主座位(见 installUpdate)。 */
|
|
3658
|
+
installedNote,
|
|
3659
|
+
(updateCheck.state === 'available' && latest !== null && installState.state !== 'installed')
|
|
3660
|
+
? installBtn
|
|
3661
|
+
: null,
|
|
3242
3662
|
checkBtn,
|
|
3243
3663
|
dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
|
|
3244
3664
|
].filter(Boolean))
|
|
@@ -3364,6 +3784,31 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3364
3784
|
}
|
|
3365
3785
|
}
|
|
3366
3786
|
|
|
3787
|
+
/* 焦点环长什么样(CSS 里 .wisp-body:focus 那段说了"为什么不能是矩形"),
|
|
3788
|
+
这里只回答一个问题:这一次焦点是不是**键盘**给的。
|
|
3789
|
+
|
|
3790
|
+
鼠标点她也会把焦点交给她(onDown 里的 focusHer —— 为的是"点一下她再按 Enter
|
|
3791
|
+
开菜单"这条不能断),那一下不该出现任何"被选中"的框:她的表情就是回执。
|
|
3792
|
+
键盘给的焦点才亮起那圈贴着剪影的辉光 —— 没有它,键盘用户 Tab 到她身上时
|
|
3793
|
+
屏幕上什么都不会变,而"声明了 role 却看不出焦点在哪"是这一版要避免的另一半。
|
|
3794
|
+
|
|
3795
|
+
记录必须走**捕获**阶段:focusHer() 是在 pointerdown 的冒泡处理器里调的,
|
|
3796
|
+
晚一档读到的就还是上一次的输入方式(先点她再按 Esc 会被当成鼠标)。 */
|
|
3797
|
+
let focusByKeyboard = false
|
|
3798
|
+
const syncFocusRing = () => {
|
|
3799
|
+
if (document.activeElement === bodyEl && focusByKeyboard) root.dataset.focus = 'key'
|
|
3800
|
+
else delete root.dataset.focus
|
|
3801
|
+
}
|
|
3802
|
+
/* 输入方式一变就重算一次:她已经拿着键盘焦点时再点她一下,focus() 是空操作
|
|
3803
|
+
(真实 DOM 里不会重复派发 focus 事件),只靠 focus 事件的话,那圈辉光会留在
|
|
3804
|
+
一个刚被鼠标点过的她身上 —— 而那一下的焦点其实是鼠标给的。 */
|
|
3805
|
+
const noteKeyInput = () => { focusByKeyboard = true; syncFocusRing() }
|
|
3806
|
+
const notePointerInput = () => { focusByKeyboard = false; syncFocusRing() }
|
|
3807
|
+
window.addEventListener('keydown', noteKeyInput, true)
|
|
3808
|
+
window.addEventListener('pointerdown', notePointerInput, true)
|
|
3809
|
+
bodyEl.addEventListener('focus', syncFocusRing)
|
|
3810
|
+
bodyEl.addEventListener('blur', syncFocusRing)
|
|
3811
|
+
|
|
3367
3812
|
/* keepGroup:点分组是"在原地重开",得把刚设好的分组带过去。
|
|
3368
3813
|
其他入口都是新手势,一律回到收起状态 —— 所以这个重置放在这里,
|
|
3369
3814
|
而不是 closeMenu 里(放那里会让展开动作把自己清掉)。 */
|
|
@@ -3729,7 +4174,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3729
4174
|
applyHitClip(currentSprite)
|
|
3730
4175
|
/* 动作档位也要当场生效(菜单里的胶囊就靠这一行):它只改一个属性,
|
|
3731
4176
|
CSS 那边所有幅度规则都挂在它上面。 */
|
|
3732
|
-
|
|
4177
|
+
/* 帧动画开关也走 applyMotion:它只改属性与画面归属,当场生效不用刷新。 */
|
|
4178
|
+
if (partial && typeof partial === 'object'
|
|
4179
|
+
&& (partial.motion !== undefined || partial.frame !== undefined)) applyMotion()
|
|
3733
4180
|
coords.x = clamp(coords.x, 0, maxX())
|
|
3734
4181
|
coords.y = clamp(coords.y, 0, maxY())
|
|
3735
4182
|
paint()
|
|
@@ -3869,13 +4316,49 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3869
4316
|
}
|
|
3870
4317
|
|
|
3871
4318
|
/* ---- 看余额 -------------------------------------------------------------
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
4319
|
+
两条通道读的是**同一个**平台账户服务(凭据始终在宿主手里,插件从头到尾
|
|
4320
|
+
碰不到 token):
|
|
4321
|
+
|
|
4322
|
+
① 客户端 Remote —— **默认这条路**。平台自己的「账户」设置页就是这么读的:
|
|
4323
|
+
ctx.remote.account.getBalance(client) → { ok, value }。
|
|
4324
|
+
1.44.0 的那条教训在这里同样成立:bundle 形态的客户端半包**没有** host RPC
|
|
4325
|
+
座位,所以只写下面那条路的旧实现,在真机上永远回"这个壳里查不了余额"。
|
|
4326
|
+
② host 座位 —— 只有动态插件形态才有:host.call('checkBalance'),宿主半包
|
|
4327
|
+
inject 'deepseekAccount'。两条路的**载荷形状完全一样**,所以只在取数那一层
|
|
4328
|
+
分叉,归一化与台词完全共用。
|
|
4329
|
+
|
|
4330
|
+
未登录、读不到、这个壳里没有账户服务,是三句不同的话:
|
|
3877
4331
|
说是"查不到"但其实是"没登录",就是在骗人。 */
|
|
3878
4332
|
let balanceCheck = { state: 'idle', at: 0 }
|
|
4333
|
+
let balanceVia = null // 上一次真的走了哪条通道:'remote' | 'host'
|
|
4334
|
+
|
|
4335
|
+
/* 每次调用**当场**找服务 —— 客户端服务是后注册的,闭包里存一份就会定格成 null
|
|
4336
|
+
(host 座位那条路上踩过同一个坑)。客户端服务的规矩就是运行时取:
|
|
4337
|
+
ctx.get('theme') / ctx.get('styles') 都是这么拿的,这里照同一条规矩。 */
|
|
4338
|
+
const remoteAccount = () => {
|
|
4339
|
+
const usable = (value) => (value && typeof value.getBalance === 'function' ? value : null)
|
|
4340
|
+
try {
|
|
4341
|
+
if (ctx && typeof ctx.get === 'function') {
|
|
4342
|
+
const direct = usable(ctx.get('remote.account'))
|
|
4343
|
+
if (direct !== null) return direct
|
|
4344
|
+
const parent = ctx.get('remote')
|
|
4345
|
+
const throughParent = usable(parent && parent.account)
|
|
4346
|
+
if (throughParent !== null) return throughParent
|
|
4347
|
+
}
|
|
4348
|
+
} catch (e) { /* 这个壳里没有 remote:换下一条 */ }
|
|
4349
|
+
try {
|
|
4350
|
+
const viaProperty = usable(ctx && ctx.remote && ctx.remote.account)
|
|
4351
|
+
if (viaProperty !== null) return viaProperty
|
|
4352
|
+
} catch (e) { /* 没声明 inject 时读属性会抛 —— 那同样只是"没有" */ }
|
|
4353
|
+
return null
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
/* 账户服务要的是**这次调用**的身份:版本、界面语言、UTC 偏移秒数(向东为正)。 */
|
|
4357
|
+
const accountMetadata = () => ({
|
|
4358
|
+
version: VERSION,
|
|
4359
|
+
locale: LANG_TAG,
|
|
4360
|
+
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
|
|
4361
|
+
})
|
|
3879
4362
|
|
|
3880
4363
|
/* 金额由客户端拼,台词只管口气。货币符号认不出来就原样写代码。 */
|
|
3881
4364
|
const balanceAmount = (wallets) => {
|
|
@@ -3902,12 +4385,85 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3902
4385
|
return pick(LINES.balanceFailed)
|
|
3903
4386
|
}
|
|
3904
4387
|
|
|
4388
|
+
/* 宿主半包那条路的载荷 → 状态。 */
|
|
4389
|
+
const balanceStateFrom = (res) => {
|
|
4390
|
+
if (!res || res.ok !== true) {
|
|
4391
|
+
return { state: 'failed', reason: (res && res.reason) || 'no-result', at: nowMs() }
|
|
4392
|
+
}
|
|
4393
|
+
if (res.status === 'ready') {
|
|
4394
|
+
return {
|
|
4395
|
+
state: 'ready',
|
|
4396
|
+
wallets: Array.isArray(res.wallets) ? res.wallets : [],
|
|
4397
|
+
bonusWallets: Array.isArray(res.bonusWallets) ? res.bonusWallets : [],
|
|
4398
|
+
at: nowMs(),
|
|
4399
|
+
}
|
|
4400
|
+
}
|
|
4401
|
+
if (res.status === 'signed-out' || res.status === 'unavailable') {
|
|
4402
|
+
return { state: res.status, at: nowMs() }
|
|
4403
|
+
}
|
|
4404
|
+
/* 平台答了,答案是 failed —— 是结果,不是异常。 */
|
|
4405
|
+
return { state: 'failed', reason: 'service-failed', at: nowMs() }
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
/* Remote 那条路外面裹着一层信封:{ ok:true, value } | { ok:false, … }。
|
|
4409
|
+
value 与宿主半包同形状,所以先拆信封,剩下的交给上面那张表。
|
|
4410
|
+
value 为 null 时**再问一次 getState()** 分清"没登录"与"刚才没读到":
|
|
4411
|
+
叫一个已经登录的人去登录,比说"刚才没读到"糟得多。 */
|
|
4412
|
+
const balanceStateFromRemote = (envelope, account) => {
|
|
4413
|
+
if (!envelope || envelope.ok !== true) {
|
|
4414
|
+
return { state: 'failed', reason: (envelope && envelope.reason) || 'no-result', at: nowMs() }
|
|
4415
|
+
}
|
|
4416
|
+
const value = envelope.value
|
|
4417
|
+
if (value !== null && value !== undefined) {
|
|
4418
|
+
if (value.status !== 'ready') return { state: 'failed', reason: 'service-failed', at: nowMs() }
|
|
4419
|
+
return {
|
|
4420
|
+
state: 'ready',
|
|
4421
|
+
wallets: Array.isArray(value.value) ? value.value : [],
|
|
4422
|
+
bonusWallets: Array.isArray(value.bonusWallets) ? value.bonusWallets : [],
|
|
4423
|
+
at: nowMs(),
|
|
4424
|
+
}
|
|
4425
|
+
}
|
|
4426
|
+
return Promise.resolve()
|
|
4427
|
+
.then(() => (account && typeof account.getState === 'function' ? account.getState() : null))
|
|
4428
|
+
.catch(() => null)
|
|
4429
|
+
.then((state) => {
|
|
4430
|
+
const view = state && state.ok === true ? state.value : null
|
|
4431
|
+
const signedIn = view && typeof view.status === 'string' ? view.status === 'credential-stored' : null
|
|
4432
|
+
return { state: signedIn === false ? 'signed-out' : 'unavailable', at: nowMs() }
|
|
4433
|
+
})
|
|
4434
|
+
}
|
|
4435
|
+
|
|
4436
|
+
/* Remote 也不接受 AbortSignal,所以同样只能竞速 —— 一个挂住的调用就是气泡
|
|
4437
|
+
永远停在"看一下……"。8 秒,与宿主半包那条路同一个数。 */
|
|
4438
|
+
const BALANCE_TIMEOUT_MS = 8000
|
|
4439
|
+
const withDeadline = (promise, ms) => new Promise((resolve, reject) => {
|
|
4440
|
+
let settled = false
|
|
4441
|
+
const limit = typeof ms === 'number' && ms > 0 ? ms : BALANCE_TIMEOUT_MS
|
|
4442
|
+
const handle = clock.after(limit, () => {
|
|
4443
|
+
if (settled) return
|
|
4444
|
+
settled = true
|
|
4445
|
+
reject(new Error('timeout'))
|
|
4446
|
+
})
|
|
4447
|
+
const finish = (fn, value) => {
|
|
4448
|
+
if (settled) return
|
|
4449
|
+
settled = true
|
|
4450
|
+
if (handle) clock.cancel(handle)
|
|
4451
|
+
fn(value)
|
|
4452
|
+
}
|
|
4453
|
+
Promise.resolve(promise).then(
|
|
4454
|
+
(value) => finish(resolve, value),
|
|
4455
|
+
(error) => finish(reject, error),
|
|
4456
|
+
)
|
|
4457
|
+
})
|
|
4458
|
+
|
|
3905
4459
|
/* 按需查:菜单里那一行和 `__wisp.checkBalance()` 都走这里。
|
|
3906
4460
|
它**自己说话**(先"看一下……"再报结果)—— 菜单点完就关了,留一个
|
|
3907
4461
|
只返回状态、不出声的函数,用户会以为没反应。 */
|
|
3908
4462
|
const checkBalance = () => {
|
|
3909
|
-
const
|
|
3910
|
-
|
|
4463
|
+
const account = remoteAccount()
|
|
4464
|
+
const seat = account === null ? hostSeat() : null
|
|
4465
|
+
if (account === null && seat === null) {
|
|
4466
|
+
balanceVia = null
|
|
3911
4467
|
balanceCheck = { state: 'unsupported', at: nowMs() }
|
|
3912
4468
|
wake(true)
|
|
3913
4469
|
say(balanceLine(balanceCheck))
|
|
@@ -3916,25 +4472,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
3916
4472
|
balanceCheck = { state: 'checking', at: nowMs() }
|
|
3917
4473
|
wake(true)
|
|
3918
4474
|
say(pick(LINES.balanceChecking))
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
.
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
} else if (res.status === 'signed-out' || res.status === 'unavailable') {
|
|
3932
|
-
balanceCheck = { state: res.status, at: nowMs() }
|
|
3933
|
-
} else {
|
|
3934
|
-
/* 平台答了,答案是 failed —— 是结果,不是异常。 */
|
|
3935
|
-
balanceCheck = { state: 'failed', reason: 'service-failed', at: nowMs() }
|
|
3936
|
-
}
|
|
3937
|
-
say(balanceLine(balanceCheck))
|
|
4475
|
+
balanceVia = account !== null ? 'remote' : 'host'
|
|
4476
|
+
const request = account !== null
|
|
4477
|
+
? withDeadline(Promise.resolve()
|
|
4478
|
+
.then(() => account.getBalance(accountMetadata()))
|
|
4479
|
+
.then((envelope) => balanceStateFromRemote(envelope, account)))
|
|
4480
|
+
: Promise.resolve()
|
|
4481
|
+
.then(() => seat.call('checkBalance', { locale: LANG_TAG }))
|
|
4482
|
+
.then((res) => balanceStateFrom(res))
|
|
4483
|
+
return request
|
|
4484
|
+
.then((next) => {
|
|
4485
|
+
balanceCheck = next
|
|
4486
|
+
say(balanceLine(next))
|
|
3938
4487
|
return balanceCheck
|
|
3939
4488
|
})
|
|
3940
4489
|
.catch((err) => {
|
|
@@ -4053,14 +4602,128 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
4053
4602
|
copyText(UPDATE_REPO)
|
|
4054
4603
|
}
|
|
4055
4604
|
flashHappy(base + updateLine(res))
|
|
4056
|
-
/*
|
|
4605
|
+
/* 查到新版就把弹窗摆出来 —— 里面那个「安装 vX」按钮就是这一步的下一步(v1.48.0)。
|
|
4606
|
+
查不成的时候同样开:把"为什么"摆在最显眼的位置(台词只留一句人话)。 */
|
|
4057
4607
|
if (res.state === 'failed') aboutDialog(res)
|
|
4608
|
+
else if (res.state === 'available') aboutDialog()
|
|
4058
4609
|
})
|
|
4059
4610
|
}
|
|
4060
4611
|
lastUpdateHint = hint
|
|
4061
4612
|
return { version: VERSION, note: note || null, copied, checking: true }
|
|
4062
4613
|
}
|
|
4063
4614
|
|
|
4615
|
+
/* ---- 装上最新版(v1.48.0)-------------------------------------------------
|
|
4616
|
+
平台把**插件管理器本身**也做成了 Remote 命名空间:`remote.pluginManager`
|
|
4617
|
+
(installBundle / waitForInstall / cancelInstall / listPlugins …)—— 平台自己的
|
|
4618
|
+
「插件」设置页用的就是它。所以「检查更新只能告诉你、装不了」是旧结论:客户端半包
|
|
4619
|
+
虽然没有 host 座位(1.44.0 那条教训),但这条路**不需要座位** ——
|
|
4620
|
+
它是平台给客户端开的正式通道,装这一步由平台带着 pnpm 与 profile 的写权限去做。
|
|
4621
|
+
|
|
4622
|
+
装的是**刚才查到的那一版**(spec = dsh-wisp@<version>),不是 latest ——
|
|
4623
|
+
用户看到的数字和装下去的东西必须是同一个。装完要不要重启由平台说了算
|
|
4624
|
+
(ChangeResult.application),我们照实转述,不自作聪明。 */
|
|
4625
|
+
const UPDATE_PACKAGE = 'dsh-wisp'
|
|
4626
|
+
/* 下载 + 落盘 + 链接可能很慢,所以给一个**宽松的**截止时间。到点了不谎报失败 ——
|
|
4627
|
+
只说「动作已经发出去了、还没回来」(安装本身不会被我们取消)。 */
|
|
4628
|
+
const INSTALL_TIMEOUT_MS = 180000
|
|
4629
|
+
let installState = { state: 'idle', at: 0 }
|
|
4630
|
+
|
|
4631
|
+
const pluginManagerRemote = () => {
|
|
4632
|
+
const usable = (value) => (value && typeof value.installBundle === 'function' ? value : null)
|
|
4633
|
+
try {
|
|
4634
|
+
if (ctx && typeof ctx.get === 'function') {
|
|
4635
|
+
const direct = usable(ctx.get('remote.pluginManager'))
|
|
4636
|
+
if (direct !== null) return direct
|
|
4637
|
+
const parent = ctx.get('remote')
|
|
4638
|
+
const nested = usable(parent && parent.pluginManager)
|
|
4639
|
+
if (nested !== null) return nested
|
|
4640
|
+
}
|
|
4641
|
+
} catch (e) { /* 这个壳里没有 remote:换下一条 */ }
|
|
4642
|
+
try {
|
|
4643
|
+
const viaProperty = usable(ctx && ctx.remote && ctx.remote.pluginManager)
|
|
4644
|
+
if (viaProperty !== null) return viaProperty
|
|
4645
|
+
} catch (e) { /* 没声明 inject 时读属性会抛 —— 那同样只是「没有」 */ }
|
|
4646
|
+
return null
|
|
4647
|
+
}
|
|
4648
|
+
|
|
4649
|
+
/* 台词里写**版本号**,不写 spec:平台回来的 target 是 `dsh-wisp@1.48.0`,
|
|
4650
|
+
直接塞进句子里就成了「装好了:vdsh-wisp@1.48.0」—— 数字对,话不对。 */
|
|
4651
|
+
const installVersion = (res) => {
|
|
4652
|
+
if (typeof res.version === 'string' && res.version !== '') return res.version
|
|
4653
|
+
const target = String(res.target ?? '')
|
|
4654
|
+
const at = target.lastIndexOf('@')
|
|
4655
|
+
return at > 0 ? target.slice(at + 1) : target
|
|
4656
|
+
}
|
|
4657
|
+
|
|
4658
|
+
const installLine = (res) => {
|
|
4659
|
+
if (res.state === 'installed') {
|
|
4660
|
+
return pick(res.application === 'restart-required' ? LINES.installRestart : LINES.installDone)
|
|
4661
|
+
.split('{v}').join(installVersion(res))
|
|
4662
|
+
}
|
|
4663
|
+
if (res.state === 'pending') return pick(LINES.installPending)
|
|
4664
|
+
if (res.state === 'cancelled') return pick(LINES.installCancelled)
|
|
4665
|
+
if (res.state === 'unsupported') return pick(LINES.installUnsupported)
|
|
4666
|
+
if (res.reason === 'no-matching-version') {
|
|
4667
|
+
return pick(LINES.installNotPublished).split('{v}').join(installVersion(res))
|
|
4668
|
+
}
|
|
4669
|
+
return pick(LINES.installFailed)
|
|
4670
|
+
}
|
|
4671
|
+
|
|
4672
|
+
const installUpdate = (version) => {
|
|
4673
|
+
const target = typeof version === 'string' && version !== ''
|
|
4674
|
+
? version
|
|
4675
|
+
: (typeof updateCheck.latest === 'string' ? updateCheck.latest : null)
|
|
4676
|
+
const manager = pluginManagerRemote()
|
|
4677
|
+
if (manager === null) {
|
|
4678
|
+
installState = { state: 'unsupported', at: nowMs() }
|
|
4679
|
+
wake(true)
|
|
4680
|
+
say(installLine(installState))
|
|
4681
|
+
return Promise.resolve(installState)
|
|
4682
|
+
}
|
|
4683
|
+
if (target === null) {
|
|
4684
|
+
installState = { state: 'failed', reason: 'no-version', at: nowMs() }
|
|
4685
|
+
wake(true)
|
|
4686
|
+
say(installLine(installState))
|
|
4687
|
+
return Promise.resolve(installState)
|
|
4688
|
+
}
|
|
4689
|
+
installState = { state: 'installing', target, at: nowMs() }
|
|
4690
|
+
wake(true)
|
|
4691
|
+
setMood('report')
|
|
4692
|
+
say(pick(LINES.installChecking).split('{v}').join(target))
|
|
4693
|
+
return withDeadline(
|
|
4694
|
+
Promise.resolve().then(() => manager.installBundle(UPDATE_PACKAGE + '@' + target)),
|
|
4695
|
+
INSTALL_TIMEOUT_MS,
|
|
4696
|
+
)
|
|
4697
|
+
.then((envelope) => {
|
|
4698
|
+
if (!envelope || envelope.ok !== true) {
|
|
4699
|
+
const code = envelope && envelope.error && typeof envelope.error.code === 'string' ? envelope.error.code : null
|
|
4700
|
+
installState = { state: 'failed', reason: code || 'refused', target, at: nowMs() }
|
|
4701
|
+
} else {
|
|
4702
|
+
const result = envelope.value && typeof envelope.value === 'object' ? envelope.value : {}
|
|
4703
|
+
const application = typeof result.application === 'string' ? result.application : 'unknown'
|
|
4704
|
+
const code = result.error && typeof result.error.code === 'string' ? result.error.code : null
|
|
4705
|
+
installState = {
|
|
4706
|
+
state: application === 'failed' ? 'failed' : (application === 'cancelled' ? 'cancelled' : 'installed'),
|
|
4707
|
+
application,
|
|
4708
|
+
reason: code,
|
|
4709
|
+
version: target,
|
|
4710
|
+
target: typeof result.target === 'string' ? result.target : target,
|
|
4711
|
+
at: nowMs(),
|
|
4712
|
+
}
|
|
4713
|
+
}
|
|
4714
|
+
say(installLine(installState))
|
|
4715
|
+
return installState
|
|
4716
|
+
})
|
|
4717
|
+
.catch((err) => {
|
|
4718
|
+
const detail = String(err && err.message ? err.message : err)
|
|
4719
|
+
installState = detail === 'timeout'
|
|
4720
|
+
? { state: 'pending', target, at: nowMs() }
|
|
4721
|
+
: { state: 'failed', reason: 'call-failed', detail, target, at: nowMs() }
|
|
4722
|
+
say(installLine(installState))
|
|
4723
|
+
return installState
|
|
4724
|
+
})
|
|
4725
|
+
}
|
|
4726
|
+
|
|
4064
4727
|
const api = {
|
|
4065
4728
|
version: VERSION,
|
|
4066
4729
|
clock: clock.mode,
|
|
@@ -4083,6 +4746,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
4083
4746
|
setSkin: applySkin,
|
|
4084
4747
|
showUpdate,
|
|
4085
4748
|
checkForUpdate,
|
|
4749
|
+
installUpdate,
|
|
4086
4750
|
/* 弹窗与折叠分组:菜单结构变了之后,测试要能问"现在开着哪个弹窗 / 哪个分组"。 */
|
|
4087
4751
|
/* 专注计时器:脚本与测试都能驱动 */
|
|
4088
4752
|
startFocus,
|
|
@@ -4183,9 +4847,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
4183
4847
|
error: { selector: ERROR_SELECTOR, matches: probe(ERROR_SELECTOR) },
|
|
4184
4848
|
},
|
|
4185
4849
|
dark: !!(body && body.hasAttribute('data-ds-dark-theme')),
|
|
4186
|
-
/* 余额:最后一次查的结果 +
|
|
4187
|
-
插件拿不到
|
|
4188
|
-
|
|
4850
|
+
/* 余额:最后一次查的结果 + 两条通道各自在不在 + 这一次真的走了哪条。
|
|
4851
|
+
**凭据从不经过这里** —— 插件拿不到 token(更别说 API key),
|
|
4852
|
+
也就不可能被谁从 doctor() 里读走。 */
|
|
4853
|
+
balance: Object.assign(
|
|
4854
|
+
{ hostSeat: hostSeat() !== null, remoteSeat: remoteAccount() !== null, via: balanceVia },
|
|
4855
|
+
balanceCheck,
|
|
4856
|
+
),
|
|
4189
4857
|
/* 动作层的实时状态。姿势角是**屏幕坐标**里的角度(未乘镜像),
|
|
4190
4858
|
accent 是当下挂着的一次性动作 —— "她为什么歪着/弹了一下"
|
|
4191
4859
|
在 Console 里一眼能看出来,不用去读样式。 */
|
|
@@ -4197,6 +4865,29 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
4197
4865
|
tiltLocal: tiltWritten,
|
|
4198
4866
|
accent: root.dataset.accent || null,
|
|
4199
4867
|
pressed: root.dataset.dragging === 'true',
|
|
4868
|
+
/* 帧动画(v1.45.0;v1.46.1 起是 <img> 动图):哪个状态有素材、元素建了没、
|
|
4869
|
+
现在在不在画面上、冻没冻住。它的失败是**静默降级**(画面回到静态立绘),
|
|
4870
|
+
所以"她怎么不动了"必须能在这里一眼看出来,而不是靠读代码。 */
|
|
4871
|
+
frame: {
|
|
4872
|
+
/* 这个状态**能不能**播、播的是哪一段:查得到才算数。只报 MOTION_OF 的话,
|
|
4873
|
+
"这一段素材不在包里"会显示成一个根本不存在的素材名(而画面早就是立绘了)。 */
|
|
4874
|
+
enabled: cfg.frame !== false,
|
|
4875
|
+
asset: typeof MOTION[motionKeyFor(mood)] === 'string' ? motionKeyFor(mood) : null,
|
|
4876
|
+
/* 这一刻的素材是**从哪来的**(v1.47.0):外置时它是一个
|
|
4877
|
+
`/wisp-motion/<文件>` URL,内联时是 data URI。"她怎么不动"
|
|
4878
|
+
的第一个分岔就在这里 —— 是没接上路由,还是这一段真的坏了。 */
|
|
4879
|
+
src: motionImg === null || typeof motionImg.src !== 'string' ? null : motionImg.src,
|
|
4880
|
+
built: motionImg !== null,
|
|
4881
|
+
/* 动图没有"在播"这个可读状态(<img> 上没有 paused),所以这里报的是
|
|
4882
|
+
**它是不是正在画面上** —— 也就是"这一刻画面归谁"的事实。 */
|
|
4883
|
+
showing: motionImg !== null && motionImg.style.display !== 'none',
|
|
4884
|
+
frozen: motionFrozen(),
|
|
4885
|
+
failed: motionFailed,
|
|
4886
|
+
/* 画面归谁(v1.45.2):true = 帧动画在接管,静态立绘已退场;
|
|
4887
|
+
false = 立绘就是画面里那个人(没有素材 / 冻结 / 加载失败)。
|
|
4888
|
+
"两层同时可见"就是重影,所以这件事必须能在这里一眼看出来。 */
|
|
4889
|
+
spriteHidden: root.dataset.frame === 'on',
|
|
4890
|
+
},
|
|
4200
4891
|
},
|
|
4201
4892
|
tokens,
|
|
4202
4893
|
/* 窗口背景:Mica 能不能露出来,取决于页面有没有在背景处画不透明像素。
|
|
@@ -4218,10 +4909,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
4218
4909
|
whatsNew: WHATS_NEW[VERSION] ?? null,
|
|
4219
4910
|
seenBefore: readStored(SEEN_VERSION_KEY),
|
|
4220
4911
|
hint: lastUpdateHint,
|
|
4221
|
-
|
|
4912
|
+
/* v1.48.0 起是 true —— 但要说清"她怎么做到的":她自己依然没有网络、没有 Node
|
|
4913
|
+
(宿主半包在 vm 沙箱里,harness 只有 defineTool/registerTool/handle),
|
|
4914
|
+
**装**这一步是交给平台的插件管理器(remote.pluginManager)去做的。 */
|
|
4915
|
+
canSelfUpdate: true,
|
|
4222
4916
|
/* 上一次检查的结果(idle / checking / current / available / failed / unsupported) */
|
|
4223
4917
|
lastCheck: { ...updateCheck },
|
|
4224
|
-
|
|
4918
|
+
/* 安装这条路:平台那个 Remote 命名空间在不在 + 最后一次装的结果 */
|
|
4919
|
+
install: Object.assign(
|
|
4920
|
+
{ via: pluginManagerRemote() !== null ? 'remote.pluginManager' : null, package: UPDATE_PACKAGE },
|
|
4921
|
+
installState,
|
|
4922
|
+
),
|
|
4923
|
+
why: '她自己没有网络也没有 Node(宿主半包在 vm 沙箱里),但**装**不用她做:平台的插件管理器是客户端可用的 Remote 命名空间(remote.pluginManager),她把版本号交给它,由它带着 pnpm 与 profile 写权限去装',
|
|
4225
4924
|
},
|
|
4226
4925
|
/* 干饭:不用猜"她为什么突然捧碗" */
|
|
4227
4926
|
hunger: { enabled: cfg.hungry === true, everyMs: cfg.hungerMs, lastMealAt: Math.round(lastMealAt) },
|
|
@@ -4305,10 +5004,23 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
4305
5004
|
bodyEl.removeEventListener('pointerdown', onDown)
|
|
4306
5005
|
bodyEl.removeEventListener('keydown', onBodyKey)
|
|
4307
5006
|
bodyEl.removeEventListener('contextmenu', onContext)
|
|
5007
|
+
bodyEl.removeEventListener('focus', syncFocusRing)
|
|
5008
|
+
bodyEl.removeEventListener('blur', syncFocusRing)
|
|
5009
|
+
window.removeEventListener('keydown', noteKeyInput, true)
|
|
5010
|
+
window.removeEventListener('pointerdown', notePointerInput, true)
|
|
4308
5011
|
closeMenu()
|
|
4309
5012
|
closeDialog()
|
|
4310
5013
|
if (dialogKeyOff) { dialogKeyOff(); dialogKeyOff = null }
|
|
4311
5014
|
hideTab()
|
|
5015
|
+
/* 帧动画自己收:动图在 detach 之后没有"停"这个动作可喊(<img> 上没有
|
|
5016
|
+
pause()),能做的就是把它从画面上拿掉 —— 隐藏 + 紧接着摘掉整个 layer,
|
|
5017
|
+
不再有一层看不见的循环在解码。系统设置的订阅也要摘掉(这条泄漏过一次,
|
|
5018
|
+
见 onVisibility 那段的教训)。 */
|
|
5019
|
+
hideMotion()
|
|
5020
|
+
if (typeof motionMqlOff === 'function') {
|
|
5021
|
+
try { motionMqlOff() } catch (e) { /* ignore */ }
|
|
5022
|
+
motionMqlOff = null
|
|
5023
|
+
}
|
|
4312
5024
|
if (typeof offTheme === 'function') {
|
|
4313
5025
|
try { offTheme() } catch (e) { /* ignore */ }
|
|
4314
5026
|
offTheme = null
|