dsh-hit-feedback
Verifieddsh-hit-feedback · v2.1.0 · MIT · Web UI
Turns every agent action into a game-style hit: floating impact numbers, a combo counter with a wave dome, skill casts, and an end-of-turn bill.
Install
dsh plugin add dsh-hit-feedback Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Creators
Readme
Hit Feedback
一个 DSH 客户端插件:把 agent 的每一次动作变成游戏式的命中反馈。
包名
dsh-hit-feedback✅ · 无宿主依赖 ✅ · 无网络请求 ✅ · 无素材文件 ✅
安装
# 从本地目录(开发时这样装,改完源码不用重装)
plugin_manager install_bundle target: /绝对路径/dsh-hit-feedback
# 发布了之后
plugin_manager install_bundle target: dsh-hit-feedback
装完刷新页面 ✅。插件注册在两个槽位 ✅:conversation.input.dock(连击 HUD ✅)和 shell.overlay(飘字 / 技能 / 战报 ✅)。
声音需要一次真实交互 ✅ —— 浏览器不允许自动播放 ✅,刷新后先点一下界面 ✅。
已知冲突
如果你看到这样的报错 ✅:
Failed to load plugins
dsh-hit-feedback: import failed: client-modules:
... skipped after an earlier failure of this bundle;
... loaded without registering "dsh-hit-feedback" via __ModuleLoader__.load
这不是本插件的问题 ✅ —— 加载器把所有 client 插件打进同一个请求 ✅,只要其中一个在导入阶段抛错 ✅,整包都会被跳过 ✅,本插件根本没有机会执行 ✅。
怎么找是哪个 ✅:
- 把第三方插件全部禁用(官方
@deepseek-ai/*的留着)✅ - 刷新 ✅ —— 本插件能出现 ✅ → 确认是冲突 ✅
- 一个个启用 ✅,哪个一开就崩 ✅,就是它 ✅
注意:我试过凭报错里的插件列表猜是哪个 ✅ —— 猜错了 ✅。列表里出现 ≠ 它是元凶 ✅,只能靠二分测 ✅。
发布到 npm
这个仓库就是一个可以直接发的包 ✅。发布前的清单:
| 项 | 状态 |
|---|---|
name 是没被占用的 npm 名 ✅ |
dsh-hit-feedback ✅ |
version ✅ |
1.7.1 ✅ |
private 已删除 ✅ |
✅ |
description / keywords / license ✅ |
✅ |
files 里带上了 test/ ✅ |
✅ |
| 展示用的标题和描述 ✅ | 在 locale/*.json 的 meta 里 ✅ |
| 图标 ✅ | icon.svg ✅ |
npm login # 第一次要先建账号:https://www.npmjs.com/signup
npm publish # 无 scope 的包不需要 --access public
发布之后任何人一条命令就能装 ✅:
plugin_manager install_bundle target: dsh-hit-feedback
把 DSH 的每一次动作变成一次命中反馈:思考、正文、工具调用、结果、回合结束,各有各的表现。
- 普通攻击 — 思考、正文输出、用户输入、回合、todo、goal、交付
- 技能 — 工具调用、工具结果、错误、命令、工作流
命中是怎么来的
监听 session 的事件流,翻译成动作:
| 事件 | 动作 | 级别 |
|---|---|---|
assistant/live-chunk(reasoning-delta) |
思考 | 火花 ✅ |
assistant/live-chunk(text-delta) |
输出 | 火花 ✅ |
assistant/message |
思考 / 输出 | 普通 ✅ |
tool/call |
工具 | 技能(发动 ✅) |
tool/result |
结果 / 错误 | 技能(结算 ✅) |
user/message |
输入 | 普通 |
turn/end |
回合 | 普通(且 big) |
command/run |
命令 | 技能 |
todo/write · goal/change · team/task |
待办 / 目标 / 团队 | 普通 |
compaction/start · llm/retry |
压缩 / 重试 | 普通 |
approval/asked · hook/invoked |
审批 / 钩子 | 普通 |
deliverables/presented |
交付 | 普通 |
tool-workflow/run-start |
工作流 | 技能 |
流的期间:标记在第一下就出来 ✅ ✅,数字跟着它一起出来** ✅ ✅,然后一边数一边涨 ✅ ✅。**
每个数字是"这一下"的值 ✅ ✅ —— 不是总数 ✅ ✅。总数是伤害条旁边那个 ✅ ✅(它一直都在 ✅ ✅),所以飘字这边不用再重复一遍 ✅ ✅ —— 这里只管这一下有多重 ✅ ✅。
所以普攻期间伤害条一直在涨 ✅ ✅ —— 每一块流出来的内容都算 ✅ ✅(按字符折算 token ✅ ✅,和脉冲同一个约定 ✅ ✅),不等消息落地 ✅ ✅。
为什么不用宿主报的 outputTokens ✅ ✅:它把思考也算进去了 ✅ ✅(实测这个会话里,10 个字符的消息报了 312 token ✅ ✅)—— 用它给回复结算 ✅ ✅,等于把思考算两遍 ✅ ✅。
数字什么时候飞进条 ✅ ✅:这一类动作结束的时候 ✅ ✅ —— 思考结束就是输出开始的那一刻 ✅ ✅,输出结束就是消息落地的那一刻 ✅ ✅。
颜色:每种动作一个固定色,罩子是玻璃
试过让"背景"统一用档位色(思考、输出、输入全一个颜色),被否了:颜色随连击变化,就分不清这是思考还是输出了——而这正是这些标记存在的意义。
定下来的规则:
| 颜色 | |
|---|---|
| 每一种动作 | 固定色,与连击无关:思考紫 #a78bfa、输出蓝 #38bdf8、工具琥珀 #fbbf24、成功绿 #4ade80、失败红 #f87171…… |
| 罩子 | 中性玻璃——不加自己的色相,所以不会和飘字打架 |
| 技能存在期间 | 罩子短暂染上该技能的颜色(工具琥珀、成功绿、失败红) |
罩子是飘字飞过的一块表面,它不该有颜色主张。之前它用档位色,是屏幕上最大的一块彩色,和飘字天然对立——那才是"割裂"的来源。
(也试过反过来:让整条流跟着档位变色、让罩子跟最近一次命中变色——都不如"每类动作固定色"清楚。)
两级特效的语义
| 普通攻击 | 技能 | |
|---|---|---|
| 语义 | 打完就飘走 | 发动 / 执行 |
| 图标 | 20px | 26px + 更强光晕 |
| 环 | 1 个,向外扩散 | 2 个,向内收拢;第二个是虚线符文环,旋转合围 |
| 光 | 无 | 底部一道柔光上引 |
| 运动 | 上升 104px 后飘走 | 只升 30px,停一下再淡出 |
| 寿命 | 1.2s | 1.3s |
const SKILL_KINDS = new Set(['tool', 'result', 'error', 'command', 'workflow']);
一开始给技能配了一道斜光带(slash),被否了——斜切是"挥砍落下的"语言,而工具调用不是谁被打,是东西在被使用。所以改成收拢:环向内缩进图标、虚线符文环转着合围、底部柔光上引,图标升起后多停一会儿。
教训:特效的语义要对得上动作的语义。普通攻击是"字打完了",所以飘走;技能是"动作正在执行",所以要停下来。
技能是两拍:发动,然后结算
技能原来只有一拍 ✅ —— 工具调用和工具结果都是收拢 ✅ ✅,长得一模一样 ✅,所以"调了个工具"和"工具跑完了"读起来是同一件事 ✅ ✅。慢工具留下的那个转圈,也就没有东西可以兑现 ✅。
现在分成两拍 ✅:
| 动作 | 表现 | |
|---|---|---|
| 发动 ✅ | tool/call · command/run · tool-workflow/run-start ✅ |
环向内收拢 ✅、符文环旋转合围 ✅、底部柔光上引 ✅、升起后停一下 ✅ |
| 结算 ✅ | tool/result(成功)· tool/result(失败)✅ |
环向外炸开 ✅、符文环反向散开 ✅、光从中心推出去 ✅、标记被甩出去 ✅ |
const RELEASE_KINDS = new Set(['result', 'error']);
环必须反向 ✅ —— 收拢的环是"正在被吸进去" ✅,结算的环是"被弹出来" ✅。一样的环配一样的方向 ✅,两拍就还是一拍 ✅ ✅。
声音也是反的 ✅:发动是两级上行 ✅,结算是两级下行 ✅ —— 不看屏幕也知道是开始还是结束 ✅。
罩子也分 ✅:发动把罩子顶成尖刺 ✅(domePeak(..., true) ✅),结算把它推成一片鼓包 ✅(domePeak(..., false) ✅)。所以结算的时候罩子不染色 ✅ —— 技能存在期间才染色 ✅,工具已经跑完了 ✅。
伤害数字上屏,并汇入伤害条
飘字原来只带倍率 ✅ ✅,伤害条只显示累计值 ✅ ✅。中间那个数——这一下到底打了多少——屏幕上从来没有出现过 ✅ ✅。
现在它出现了 ✅ ✅,而且飞进伤害条的前缘 ✅:
⚔ bash ×1.35 +45 ──────▶ ████████▌░░░░░░░
为什么必须飞进去 ✅ —— 只是显示一个数字 ✅,它还是"一个数字" ✅ ✅;飞进条里 ✅,它才变成"这一下加在了这儿" ✅ ✅。
瞄的是"命中那一刻"的前缘 ✅,不是现在的前缘 ✅ —— 条一直在长 ✅,数字还在路上 ✅,等它到的时候 ✅,条已经把它吃进去了 ✅ ✅。所以晚到的数字会落在填充的里面 ✅ —— 那正是它该去的地方 ✅ ✅。
每一发都有数字 ✅ —— 火花也有 ✅ ✅。
以前脉冲不带数字 ✅ ✅,理由是"一秒九个数字就是九件要读的事" ✅ ✅ —— 但那个前提是脉冲不打伤害 ✅ ✅,条不会因为它们动 ✅ ✅。
现在脉冲打了伤害 ✅ ✅,条在动 ✅ ✅,旁边却没有数 ✅ ✅ —— 看着就是"白涨" ✅ ✅。
⇒ 数字不是用来读的,是用来爽的 ✅ ✅ —— 一串数字哗哗地往外蹦 ✅ ✅,那个节奏本身就是反馈 ✅ ✅(就像 DNF 的连击数字 ✅ ✅)。而且后面的数字比前面的大 ✅ ✅(它们带的字更多 ✅ ✅),大小跟着这一下的大小走 ✅ ✅ —— 整串数字读起来是一段节奏 ✅ ✅,不是一张清单 ✅ ✅。
数字是信息,不是 artwork ✅ ✅ —— 它穿主题的前景色 ✅ ✅,动作的色调退成外面那圈光晕 ✅ ✅。飘字那套艺术色是给深色面画的 ✅ ✅(输出的天蓝 #38bdf8 ✅ ✅),浅色主题上直接拿来当字色 ✅ ✅,就是一团糊 ✅ ✅。
数字生在标记的那条线上 ✅ ✅,不是连击数的数字上 ✅ ✅ —— 两个数字挤在一个点上 ✅ ✅,哪个都读不出来 ✅ ✅。
位置不靠测量 ✅ —— 它用浮层自己的锚点 ✅ ✅(和飘字同一个 ✅ ✅,测量没发生时就是画面正中 ✅ ✅),只算"往条那边偏多少" ✅ ✅。因为按测量摆的数字,在测量没发生之前会落在左上角 ✅ ✅ —— 落在角落的数字等于没有数字 ✅ ✅。
宿主根本不报"思考"的 token
这条事件里 usage 只有四个字段 ✅ ✅:inputTokens ✅、outputTokens ✅、totalTokens ✅、cacheReadTokens ✅ —— 没有思考的 token ✅ ✅。
所以思考的份量是从思考文本的长度估的 ✅ ✅(除以 2 ✅ ✅,和流式脉冲同一个约定 ✅ ✅)。没有它 ✅ ✅,一次想得很深的回复 ✅ ✅ 和一次根本没想的回复 ✅ ✅,价值一样 ✅ ✅。
思考在输出开始的那一刻结算
| 思考的伤害 ✅ | 输出开始的那一刻 ✅ ✅ —— 那时候思考已经全部流完 ✅ ✅,份量也就知道了 ✅ ✅ |
| 输出的伤害 ✅ | 消息落地时 ✅ ✅ —— 那里才有真实的 token 数 ✅ ✅ |
为什么不是都在消息落地时结算 ✅ ✅:两个数字会同时冒出来、位置完全相同 ✅ ✅ —— 一个画在另一个上面 ✅ ✅,于是思考看起来一文不值 ✅ ✅。
思考的份量从流过的字数估 ✅ ✅(CHARS_PER_TOKEN ✅ ✅,和脉冲同一个约定 ✅ ✅)—— 宿主根本不报思考的 token ✅ ✅。
普攻 = 一个标记 + 一个数字,一起出生
| 火花 ✅ | 流式期间 ✅ ✅ —— 没有环 ✅、没有标签 ✅、没有数字 ✅ ✅ |
| 思考的标记 + 数字 ✅ | 输出开始的那一刻 ✅ ✅(思考已经全部流完 ✅ ✅) |
| 输出的标记 + 数字 ✅ | 消息落地时 ✅ ✅(真实 token 数到了 ✅ ✅) |
伤害必须在标记出现的那一刻就已知 ✅ ✅ —— 否则数字只能是猜的 ✅ ✅,标记和数字就不是一件事 ✅ ✅。
一个 chunk 带的是字符,不是 token ✅ ✅ —— 真实的计数要等消息 ✅ ✅,而消息原来被去重吃掉了 ✅ ✅。
所以一次普通回复原来只值一两下伤害 ✅ ✅:12 × √(首段字符/2 ÷ 3000) ✅ ✅ ≈ 1 ✅ ✅。真实 token 数到了,却被扔掉 ✅ ✅,飞进条里的那个数字什么也没说 ✅ ✅。
现在:标记和数字一起出生 ✅ ✅,数字就是这一下真正打出的伤害 ✅ ✅。
条的位置由条自己公布 ✅ ✅(damageTrack ✅)—— 全场只有条知道自己的前缘在哪儿 ✅ ✅。命中带的那一下伤害 ✅ 和当时的目标 ✅ 一起记在命中上 ✅ ✅(damageTotal · damageTarget ✅),前缘的位置由这两者算出来 ✅ ✅。
大小和颜色深度按伤害
一次命中的大小、颜色深度、还有它的伤害数字有多大 ✅ ✅,全都由这一下打出了多少决定 ✅ ✅,不是固定档位 ✅ ✅。
为什么不是按 token 量 ✅ ✅:token 量是普通攻击的重量 ✅ ✅,但技能的 token 数是 0 ✅ ✅(工具调用没有 token ✅ ✅)—— 于是普攻的标记比技能的大 ✅ ✅,而技能的伤害比普攻的高 ✅ ✅。大小和数值在吵架 ✅ ✅。
⇒ 统一成"伤害"这一条 ✅ ✅:大小就是重要程度 ✅ ✅,数字的大小就是数值的强调 ✅ ✅。
const numberSize = (damage) => (13 + Math.sqrt(damage) * 2.3).toFixed(1);
const hitDepth = (damage) => (0.7 + Math.sqrt(damage) / 22).toFixed(2);
| 伤害 | 标记 | 数字 | |
|---|---|---|---|
| 火花 | 2 | 16px | 16px |
| 普攻 | 10 | 20px | 20px |
| 技能 | 45 | 28px | 28px |
| 击杀 | 120 | 38px | 38px |
而 token 量仍然决定伤害本身 ✅ ✅ —— 它是伤害的来源 ✅ ✅,只是不再直接决定长什么样 ✅ ✅。
一次普通攻击的伤害由它背后的 token 量决定:
const TOKEN_WEIGHT_REF = 3000;
const hitWeight = (tokens) => {
if (!Number.isFinite(tokens) || tokens <= 0) return 0;
return Math.min(1, Math.sqrt(tokens / TOKEN_WEIGHT_REF));
};
- 输出的命中用
usage.outputTokens,思考的命中用usage.reasoningTokens - 脉冲(流式输出时的命中)没有 token 数,用它代表的那段输出的字符数折算
- 取平方根而不是直接比例:100 token 应该是小命中、2000 是大命中,但线性刻度会让几百以下全一样、几千以上也全一样
- 权重同时驱动三件事:图标字号、颜色饱和度(
saturate)、光晕半径
用饱和度而不是亮度:这样色相不变——「这是哪一类动作」一眼还是能读出来,重的只是更深。
两种参照系
| 满格参照 | 用途 | |
|---|---|---|
| 完整回复 | TOKEN_WEIGHT_REF = 3000 token |
消息落地时那一下 |
| 流式脉冲 | PULSE_WEIGHT_REF = 80 token |
流式输出中的每一次 |
脉冲是小得多的一块工作,用自己的尺子量;拿回复的尺子量的话,每个脉冲都是百分之零点几,彼此就完全没有差别了。
被限流丢掉的 chunk 不算丢
state.pending += size; // 累积
if (!isFirst && now - state.lastHitAt < pulseGap(...)) return; // 这次不发
const carried = state.pending; state.pending = 0; // 下一次脉冲带着它
限流丢掉的那段输出不是白干的——它是下一次脉冲的份量。所以一次代表了很多输出的脉冲,应该比代表一个短句的脉冲更重。
字符折算 token 用 CHARS_PER_TOKEN = 2,只用来让两个脉冲互相之间有差别,从不用来报数(英文大约 4 字符 1 token,中文接近 1)。
每个飘字后面跟着它的伤害倍率
飘字原来跟的是连击数(×22 ✅)✅ —— 换成伤害倍率(×2.29 ✅):
◈ bash ×2.29
理由 ✅:连击数计数器上已经有了 ✅ ✅。飘字要回答的是**「这一下值多少」** ✅ —— 而这一下的价值 = 基础伤害 × 倍率 ✅ ✅,倍率是那个会变的部分 ✅。
只在这一下真的被加成时才显示 ✅(item.mult > 1 ✅)—— 倍率是 1 的时候 ✅ 跟一个 ×1.00 是噪音 ✅ ✅。
连续反馈与速率模型
高速输出不能只报一次。assistant/live-chunk 每来一段就报一个脉冲(pulse):更轻的火花,没有环、没有标签,760ms。
脉冲的间隔跟着输出速度走:
const gap = clamp(PULSE_REFERENCE_GAP * PULSE_REFERENCE_SPEED / speed, 110, 900);
速度用 EMA 平滑(0.7),所以速度抖动时脉冲间隔也跟着抖,但不会跳变。实测 10 / 20 / 67 次每 10 秒,对应 10 / 80 / 4000 字符每秒。
脉冲不重置连击计数器的弹跳(popAt 只在非脉冲命中时前进),否则一串输出会让数字一直抖。
长工具:转圈而不是静默
bash 这类工具执行期间完全不产生事件——没有命中可画,界面就干等着。
- 工具调用时登记
callId → { 名字, 开始时间 } - 同一个 100ms tick 扫描:超过
SLOW_TOOL_MS(1.2 秒)还没结束的,弹一个 spinner - 工具结果落地时移除 spinner,并在原位炸开
不需要额外定时器:扫描挂在连击倒计时那个 tick 上。所以 1.2 秒内就跑完的工具(read、edit)永远不会闪一下。
spinner 弹出的方式跟飘字同一层:外层弹出(0.35→1,260ms)、中层微幅晃动(3.6s 循环,±7px)、内层自转(900ms 一圈)。三条动画在三个元素上——一个元素一条动画。
持续转圈的必须是同一个技能。 瞬发的 bash 已经有了技能的语言(符文环、柔光、收拢),转圈的那个如果还停在旧样子,两者是两个不同的东西。所以转圈的也带上:
| 层 | 动画 |
|---|---|
.dsh-hit-busy-ring |
实心弧自转(900ms 一圈) |
.dsh-hit-busy-rune |
虚线符文环,反向转(2.4s 一圈)——和实心环反向,才读得出是两个环而不是一个糊的 |
.dsh-hit-busy-glow |
一圈径向光晕,呼吸着(1.8s) |
环、符文环、光晕在同一个 40×40 的盒子里(.dsh-hit-busy-core),三者必然同心——各算各的百分比位置迟早会错开。
光必须是径向的,不能是线性的。 线性渐变的实色边会读成「标签底下贴了个方块」——第一版就是这么翻车的:一条米色竖条压在 "bash" 上,完全不知道在表达什么。径向渐变没有实边,读起来就是光。
「同一个技能的瞬发版和持续版」这件事值得当成一条规则:技能有固定的表现语言,瞬发是它的一次,持续是它开着。
结束是「爆开」,不是「消失」
| 层 | 动画 | 时长 |
|---|---|---|
.dsh-hit-burst-flash |
中心闪光放大淡出 | 300ms |
.dsh-hit-burst-ring |
冲击环放大 2.5 倍 | 460ms |
.dsh-hit-burst-ring.is-late |
第二道环,延迟 80ms | 620ms |
.dsh-hit-burst-spark ×5 |
放射火花向外飞出(72° 均分) | 420ms |
.dsh-hit-burst-label |
工具名弹出淡出 | 620ms |
.dsh-hit-flash |
整帧闪一下(峰值 0.2,240ms) | 240ms |
爆点就是 spinner 所在的位置(同一个 slot 偏移)。之前结束特效是另一个飘字、从别处冒出来,两个位置没有视觉连接,所以读起来就是"转着转着没了"。
整帧闪的是前景色(--dsw-alias-label-primary)不是字面白:这个 profile 跑浅色主题,白闪盖在白底上等于没闪。
伤害条
一个 boss 血条样子的伤害条 ✅,独立一条 ✅,横贯输入框卡片宽度 ✅ —— boss 条要宽,塞进中间那格会变形 ✅。
它注册在 conversation.input.dock 的 order 40 ✅,HUD 是 30 ✅ ✅ —— dock 从上往下排 ✅ ✅(待办 0 ✅、目标 10 ✅、排队消息 20 ✅、HUD 30 ✅ —— HUD 排在最后 ✅),所以条在连击数的下一行 ✅ ✅,也就是紧贴输入框的那一条 ✅ ✅。
(这里原来写的是"上一行" ✅ —— 和 order 40 排在 HUD 30 之后 ✅ 是矛盾的 ✅ ✅。代码说了算 ✅ ✅。)
█████████▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌▌░░░░░░░░░░░░░░░░ 128 / 300 ×2.10
它是涨的,不是掉的
boss 血条是掉的 ✅ —— 但掉的那条会变成 KPI ✅ ✅(「这关还剩多少没打完」就是在给压力 ✅)。
所以条涨的是「已造成的伤害」✅,涨满 = 这一回合的目标达成 ✅。 视觉上照样是 boss 条 ✅(20 格分段 ✅、前缘高光 ✅、受击闪烁 ✅),语义反过来 ✅。
谁能造成伤害
| 来源 | 伤害 |
|---|---|
| 普攻(思考/输出) | 12 × 权重 ✅ —— 权重就是 token 量 ✅,长回答打得重 ✅ |
| 干活但没有 token(待办/目标/协作/交付/审批) | 8 ✅ —— 固定 ✅ |
| 技能(工具调用) | 45 ✅ |
| 慢工具完成(击杀) | 120 ✅ |
| 回合结束 | 20 ✅ —— 这个回合的收尾一击 ✅ |
| 你的输入 | 0 ✅ —— 你不是干活的那个 ✅ |
| 系统的事(压缩/重试/钩子) | 0 ✅ —— 那是周围的机械,不是产出 ✅ |
权重来自 token 量 ✅ ✅,所以没有 token 的命中权重就是 0 ✅ ✅,而条是"这个回合打出了多少" ✅ ✅ —— 于是待办、目标、交付这些 agent 真的干了的事 ✅ ✅,原来一点伤害都不打 ✅ ✅。
所以给它们一个固定的价钱 ✅ ✅(WORK_KINDS ✅ ✅,8 点 ✅ ✅)—— 一次轻击 ✅ ✅,条微微动一下 ✅ ✅,数字飞进去 ✅ ✅。
回合结束也是 ✅ —— 它是这一回合画得最大的一个标记 ✅ ✅,一个这么大的标记一点伤害都不打 ✅ ✅,那是屏幕在撒谎 ✅ ✅。给它 20 ✅ ✅,它落在战报弹出来的那一刻 ✅ ✅,下一条消息把条清零 ✅ ✅。
(这里原来写着"失败 = 0 —— 不惩罚" ✅ —— 但代码从来没这么写 ✅ ✅:失败的工具走的是技能分支 ✅ ✅,实际打 45 ✅ ✅。这一条还没定 ✅ ✅。)
最终伤害 = 基础伤害 × (1 + max(0, min(连击, 80) - 10) × 0.035)
| 连击 | 倍率 |
|---|---|
| 1–10 | ×1.00 ✅ |
| 11 | ×1.03 ✅ |
| 20 | ×1.35 ✅ |
| 30 | ×1.70 ✅ |
| 60 | ×2.75 ✅ |
| 80+ | ×3.45(封顶 ✅)✅ |
前 10 下不带加成 ✅ —— 十连击是正常工作量 ✅,按面值结算 ✅ ✅。加成从暖区之后开始算 ✅,所以"倍率"这个词出现的时候 ✅ 它真的已经在加成了 ✅ ✅。
某一下按它落地的那个连击结算 ✅ —— 发布 9 时打出的那一下 ✅ 正好是第 10 下 ✅,仍是 ×1.00 ✅ ✅。
连击断了倍率就断 ✅ → 「保持连击才打得快」✅。而慢工具期间连击被锯齿托住 ✅ → 长工具能保住你的倍率 ✅。
每回合重制
下一条消息发出来时清零 ✅ —— 所以它是每回合的分数 ✅,不是跨回合的进度 ✅。
不需要持久化 ✅ —— 插件保持无状态 ✅ ✅(不引入 localStorage ✅、不引入多窗口同步 ✅)。
目标是一条阶梯,不是一个刻度
10/300 → 200/300 → 300/300(碎掉)→ 301/1000 → …
- 第一阶 300 ✅,之后每阶 ×3.3 ✅(300 → 990 → 3267 …)
- 每跨一阶,条炸一次 ✅ —— 见下 ✅
- 伤害不清零 ✅ —— 所以数字读起来像「走了多远」✅,而不是「还剩多少」✅
- 只显示伤害值 ✅ —— 不印分母 ✅。条的长度就是进度 ✅,再写一遍
1467 / 990只是让数字更难读 ✅ - 下一条消息发出来时整轮重制 ✅ —— 伤害 ✅、阶段 ✅、碎的次数 ✅ 全部归零 ✅
为什么第一阶只有 300 ✅ —— 它应该早早就碎 ✅,用来教玩家「把条填满是有事的」✅ ✅。
连击越高,条越"壮"
| 连击 | 条 |
|---|---|
| < 10 | 8px ✅ |
| ≥ 10 | 10px ✅ |
| ≥ 30 | 12px ✅ + 高光更亮 ✅ |
| ≥ 60 | 14px ✅ |
行高固定 18px ✅ —— 条在行内变粗 ✅,不会把输入框推下去 ✅ ✅。
圆角 4px ✅ —— 药丸形 ✅(= 高度的一半 ✅)。
搏动幅度跟着高度走 ✅ —— scaleY 是相对的 ✅:条 8px 时弹 1.5 倍 = 12px ✅,正好在 18px 的行里 ✅;而条 50px 时同一个 1.5 倍就是 75px ✅,会把整行吞掉 ✅ ✅。所以幅度是按"想要弹起多少像素"倒推出来的 ✅。
条纹试了两版,都扔了
白条纹 ✅ rgba(255,255,255,.5) + 420/280ms ✅ —— 高饱和绿底上快速移动的白纹 = 频闪 ✅ ✅,看花眼 ✅。
暗条纹 ✅ rgba(0,0,0,.13→.21) + 900/700/520ms ✅ —— 不晃了 ✅,但还是抢视线 ✅ ✅。
教训 ✅:条里只要有东西在动 ✅,注意力就从填充上被拉走了 ✅ ✅。而"猛"的观感搏动已经给了 ✅ —— 不需要第二个动的东西 ✅ ✅。
现在:静态地"变强壮"
1. 变粗 ✅
6px → 8px → 10px → 12px
行高固定 18px ✅ —— 条在行内变粗 ✅,不会把输入框推下去 ✅ ✅。
2. 上沿一道硬高光 ✅
background: linear-gradient(to bottom, rgba(255,255,255,.3) 0 34%, transparent 34%);
硬切 34% ✅ —— 这是让一个平面色块读成实体的关键 ✅ ✅(不是渐变 ✅)。
3. 前端必须有一条硬边
这里试过三版 ✅:
| 版本 | 结果 |
|---|---|
| 3px 纯白硬帽 ✅ | 像被砍了一刀 ✅ |
radial-gradient 柔光 ✅ |
边界消失了 ✅ —— 填充淡进没填的地方 ✅,看不出条到哪儿 ✅,增长就不爽 ✅ |
| 圆角 + 什么都不加(现在)✅ | 一条干净的硬边 ✅ |
结论 ✅:条的边缘必须是硬的 ✅ —— 这条条的全部意义就是"到哪儿了" ✅ ✅。把它柔掉 ✅,等于把它抹掉 ✅ ✅。
唯一保留的软 ✅:border-radius: 3px ✅ —— 圆角不改边界位置 ✅,只改拐角的形状 ✅ ✅。
另外 ✅:宽度动画从 220ms 收到 160ms ✅ —— 一步跨到位 ✅,不是"滑过去" ✅ ✅。
断连时是"冷下去",不是"换一根"
断连那一帧 ✅,颜色 ✅、粗细 ✅、高光 ✅ 同时掉到位 ✅ —— 看起来像啪一下换了一根条 ✅ ✅。
现在三样各用各的时长淡下去 ✅:
| 什么 | 时长 |
|---|---|
颜色 ✅ background-color |
420ms ✅ |
高光 ✅ opacity |
420ms ✅ |
粗细 ✅ height |
380ms ✅ |
为什么高光不能直接 transition ✅:它是 linear-gradient ✅ —— 渐变不能过渡 ✅ ❌。所以改成:一个固定强度的渐变 ✅ + 用 opacity 控制强弱 ✅ —— opacity 可以过渡 ✅ ✅。
颜色为什么能过渡 ✅:background: var(--dsh-hit-tone) ✅ 最终落到 background-color 上 ✅ —— 所以 transition background-color 就有效 ✅ ✅(条的颜色确实会一档一档退 ✅)。
为什么宽度还是 160ms 不变 ✅ —— 命中时要"跳一格" ✅,"冷下去"是断连时的事 ✅ ✅。快和慢用在不同事件上 ✅。
一条极淡的光,慢慢扫过去
目的 ✅:让条在数值不变的时候也像在往前走 ✅ ✅。
| 之前的条纹(扔了 ✅) | 现在(留 ✅) | |
|---|---|---|
| 数量 | 很多条 ✅ | 一条 ✅ |
| 宽 | 细 ✅ | 38% 宽 ✅ |
| 速度 | 280–600ms ✅ | 2200ms ✅ |
| 对比 | 白 50% ✅ | 白 14–20% ✅ |
| 位移方式 | background-position ✅ |
transform ✅ |
密而快 = 频闪 ✅;一条极淡的光慢慢走 = 呼吸 ✅ ✅。这跟"平的世界"不冲突 ✅ —— 它是动 ✅,不是光影 ✅ ✅。
用 transform 不用 background-position ✅ —— 位移交给合成器 ✅,不重绘 ✅ ✅。
填充要 overflow: hidden ✅ —— 否则光带会扫到还没填的那一段去 ✅ ✅(那就变成"提前显示进度" ✅)。
搏动
每次命中条本身弹一下 ✅(scaleY 1.35 → 2.1,随档位变高 ✅)✅。
技术上的麻烦 ✅:动画要「每次命中重播」✅ —— 但重挂载元素会把碎掉的动画也一起重置 ✅ ❌。
解法 ✅:两个一模一样的关键帧 ✅,靠 damage % 2 交替 ✅:
animation: `dsh-hit-damage-beat-${damage % 2} 240ms ...`
beat-0 ↔ beat-1 名字一变,动画就重播 ✅ —— 但元素不用重挂载 ✅ ✅。
而且不能带 forwards ✅ —— 带了条会卡在弹起状态 ✅ ✅。有测试守着 ✅。
为什么放弃了"画火"
试了四版 ✅,都放弃了 ✅:
| 版本 | 结果 |
|---|---|
3 个大元素 space-between |
三坨分开的黄饼 ✅ |
16 个元素 flex: 1 1 0 平分 |
条 2000px 宽 → 每个 125px → 大三角 ✅ |
| 单个元素两层锯齿 | 出来是城垛 ✅ |
| 细长碎片竖排 | 还是浮在半空 ✅(定位参照物错 ✅) |
根本原因:静态装饰怎么画都像贴上去的 ✅ ✅。高连击的爽感来自动和快 ✅,不来自"画了什么形状" ✅。
现在的做法
速度线 ✅ —— repeating-linear-gradient(115deg, ...) ✅ 铺在填充上 ✅,background-position 从 0 跑到 200% ✅ 循环 ✅:
.dsh-hit-damage.is-charged .dsh-hit-damage-fill::after {
animation: dsh-hit-damage-streak 620ms linear infinite;
}
@keyframes dsh-hit-damage-streak {
from { background-position: 0 0; }
to { background-position: 200% 0; }
}
- 档位只改两个数 ✅:
animation-duration✅ + 条纹密度 ✅ - 不可能变成奇形怪状 ✅ —— 它永远是条里的斜纹 ✅
- 前端白刃 ✅ —— 条的最右端一道 2px 白线 ✅,像能量要溢出来 ✅
- 零布局影响 ✅ —— 不占高度 ✅、不加元素 ✅、不推动输入框 ✅
碎掉那一下,五件事同时发生
| 层 | 现象 |
|---|---|
| 鬼影 ✅ | 一条满格的亮条停一拍 ✅,然后直接消失 ✅ —— 它只负责让眼睛确认「刚才确实是满的」✅ |
| 闪光 ✅ | 一层模糊的光晕从条上炸开 ✅,纵向拉高 5 倍 ✅ |
| 冲击环 ✅ | 从前缘(条的最右端 ✅)扩出一圈环 ✅,放大 7 倍淡出 ✅ |
| 碎片 ✅ | 18 片 ✅,粗细/角度/速度/延迟全部由 hash01 决定 ✅ —— 往上翻一下再往下掉 ✅,980ms ✅,延迟最多 140ms ✅ |
| 整条 ✅ | 纵向拉高到 2.1 倍再回弹 ✅,左右各震两下 ✅ |
声音也是三段 ✅:一声脆裂(噪声 ✅)+ 一记低沉的坠(180→52Hz ✅)+ 四个高音泛音(2480 / 3120 / 3760 / 4380)✅ —— 单音听起来像通知 ✅,三段才像「碎」✅。
一个坑:碎片飞不出去
条原来 overflow: hidden ✅ —— 碎片只能在条里面沿条掉 ✅。所以拆成两层 ✅:
.dsh-hit-damage-track 不裁 ✅(碎片从这里飞出去)
└ .dsh-hit-damage-clip 裁 ✅(填充保持圆角)
有测试守着 ✅ —— 谁把 overflow: hidden 加回 track ✅,测试就红 ✅。
跨阶的过渡:从 0 涨起来,不是从满缩下去
试过「从满值缩到新位置」✅ —— 手感不明显 ✅ ✅。现在是第二种 ✅:
0ms 满 ✅(鬼影压在上面)
180ms 真填充归零 ✅(鬼影还盖着)
213ms 鬼影开始淡出 ✅,露出空条
180-700ms 条从 0 涨到当前伤害的位置 ✅
怎么做到的 ✅:填充以 snap.shatter 为 key ✅ —— 每碎一次就重新挂载一次 ✅ → 挂上 dsh-hit-damage-rebuild 动画(0% → var(--dsh-hit-damage) ✅)就自动重播 ✅ ✅。
关键:动画不能带 forwards ✅ —— 带了的话 ✅,动画结束后它会永久锁住宽度 ✅,后面的伤害就涨不动了 ✅ ✅(这是这个项目踩过好几次的坑 ✅)。不带 forwards ✅,动画一结束 ✅,宽度就交还给数据 ✅ ✅。
有测试守着这一条 ✅。
为什么伤害不清零 ✅ —— 清了零的话 ✅,每跨一阶条就从满跳回空 ✅,读起来像被扣分 ✅ ✅。不清零 ✅,条的长度就是「当前伤害 ÷ 当前目标」✅ —— 跨阶时它会从 100% 缩到 30% 左右 ✅,那一下正是碎掉的样子 ✅ ✅。
它和别的机制不冲突
| 现有 | 关系 |
|---|---|
| 连击数 ✅ | 倍率来源 ✅,不改 ✅ |
| 倒计时条 ✅ | 不碰 ✅ —— 那是「还剩多久」✅,这是「打了多少」✅ |
| 半圆罩的波 ✅ | 不碰 ✅ —— 那是「链的状态」✅ |
| 回合战报 ✅ | 加一行「伤害」✅ |
每个会话有自己的条
一个连击属于一次对话 ✅ —— 切到别的会话还带着上一个会话的连击 ✅,那是 bug ✅ ✅。
怎么做的
状态里加了一个 cells ✅ —— 每个会话一格 ✅ ✅。一格是完整的一份状态 ✅(连击 ✅、伤害 ✅、阶段 ✅、碎的次数 ✅…)✅。
const fieldOf = (state, sessionId) =>
sessionId && state.cells[sessionId] ? state.cells[sessionId] : state;
const publish = (patch, sessionId = activeSession) => { … };
三个要点 ✅:
1. 组件读自己的会话
const shared = useSyncExternalStore(subscribe, getSnapshot);
const snap = fieldOf(shared, sessionId);
之后所有 snap.combo ✅ snap.damage ✅ 都自动是这个会话的 ✅ ✅。
2. 事件自己带着会话
监听器本来就绑在 sessions.binding(sessionId) 上 ✅ —— 现在它还会在每次处理事件前记下 activeSession ✅ ✅:
const listen = (binding, emit, sessionId) => {
const enter = () => { activeSession = sessionId; };
…
};
模块级的那些函数 ✅(emitHit ✅、addBusy ✅、sweepSlowTools ✅)就靠这个默认值把命中写回产生它的那个会话 ✅ ✅。
3. 命中按自己的会话算
emitHit 里的连击 ✅、伤害 ✅、阶段 ✅ 全部从 fieldOf(snapshot, sessionId) 读 ✅ —— 不是从共享状态读 ✅ ✅。
这一条最要紧 ✅:改之前 ✅,B 会话的第一下会接着 A 会话的连击数往上加 ✅ ✅(测试抓到的 ✅:combo 3 ✅)。
还是共享的东西
| 共享 ✅ | 为什么 |
|---|---|
| 飘字 ✅ | 属于窗口 ✅,不属于对话 ✅(第 2 步再收 ✅) |
| 战报 ✅ | 同上 ✅ |
| 镜头震动 ✅ | 同上 ✅ |
锚点 anchorX ✅ |
每个会话的输入框位置都一样 ✅ |
一条设计原则:不做体积
试过给伤害条加上沿高光 + 底部压深 + 投影 ✅ —— 想让它像一根"立起来的管子" ✅。
回退了 ✅。理由 ✅:
整套特效的语言是淡色块 ✅ —— 飞出来的标记 ✅、飘字 ✅、碎掉的碎片 ✅、罩子的波 ✅ —— 全是平的 ✅ ✅。给条加上体积光影 ✅,它一下子变成厚油彩 ✅,和旁边所有的东西都不是一个世界 ✅ ✅。
所以 ✅:
| 做 | 不做 |
|---|---|
| 硬切的色块 ✅ | 渐变过渡的明暗 ✅ |
| 平的形状 ✅ | 投影 / 高光 / 内阴影 ✅ |
| 靠动(搏动 ✅、速度 ✅)表达强度 ✅ | 靠光影表达体积 ✅ |
强度用运动表达 ✅,不用光影表达 ✅ ✅ —— 这条和"重色块涂鸦"是同一件事的两面 ✅。
回合结束的战报
turn/end 时弹一张战报,和飘字同一套语言——浮空文字,无边框、无背景、无卡片:
↓ 输入 12k tok
↑ 输出 4.5k tok
◈ 思考 2.1k tok ← 有推理才有
◐ 缓存 98% ← 有缓存读取才有
◔ 耗时 42.3s
⚔ bash ×7 12.4s
⚔ read ×3 0.8s
★ 最高连击 ×312
✕ 失败 1 ← 有失败才有
可选行不存在就不显示,而不是显示 0。
| 行 | 来源 |
|---|---|
| 输入 / 输出 | assistant/message.usage 的 inputTokens / outputTokens,按回合内每次请求累加 |
| 思考 | 同上的 reasoningTokens |
| 缓存 | cacheReadTokens / (inputTokens + cacheReadTokens + cacheWriteTokens) |
| 耗时 | 用户消息 → turn/end 的墙钟时间 |
| 工具 | tool/call → tool/result 按工具名聚合,耗时降序 |
| 最高连击 | 回合内 store 里连击的峰值 |
| 失败 | tool/result.data.message.isError 计数 |
「最高连击」数的是峰值,不是结束时的值:连击中途断过(比如工具跑了 5 秒)也不能把已经到过的峰值冲掉。
这里踩过一个坑:统计只在 handleDurable 里接管了 emit,而流式脉冲、工具调用、工具结果这三个是在它前面定义的,仍然直接调 store——于是一个跑过工具的回合,报出来的峰值完全忽略了那些命中。现在 listen 里所有命中都走 report,只有它内部的 emit(hit) 还直接调。
缓存率向下取整:宿主存 inputTokens 时已经扣掉了缓存,所以 prompt 是三者之和;漏掉写入会把比例抬高,而 Math.round(99.6) 会打印成 100%——一个从不完美的缓存看起来像 bug。
数字是滚上去的
从 0 出发,1800ms 内五次缓出(先快后慢)停在真实值上——像计分板结算。
const countUpValue = (target, t) => {
if (t <= 0) return 0;
if (t >= 1) return target;
return target * (1 - (1 - t) ** 5);
};
- 滚动中只是变暗 + 微光,不抖动(抖动的数字读不清)
- 落定瞬间放大回弹一下(
dsh-hit-count-lock) - 滚动时宽度必须等于最终宽度:单位按最终值定死,位数补零(
00k→12k),否则跨过 1000 / 10000 时整行会左右跳
三个时间必须成阶梯:滚动 < 整行动画 < 卡片存活,否则任何一个最短的都会把后面的切断。
布局:HUD 独占输入框上方的一行
HUD 注册在 conversation.input.dock——输入框上方的整行区块。下面那一行是外壳共享的(外壳的「正在工作」指示和 token 统计都在里面),HUD 挤进去就会推它们。
这一行里还有外壳的三个条目:待办(order 0)、目标(10)、排队消息(20)。HUD 用 order 30,排在最后,紧贴输入框上方,读起来像一条状态栏。
HUD 是三列,但三块贴在一起围在连击数两边:
●●●●● ×44 ⚔ bash
└ 最近命中 ┘└ 连击数 ┘└ 当前动作 ┘
用 grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr)。注意是 minmax(0, 1fr) 不是 1fr——裸 1fr 等价于 minmax(auto, 1fr),两侧列的最小宽度来自各自内容(记录点窄、动作标签固定 8em 宽),两列不等宽,连击数就被推偏了。零最小宽度让两侧完全等分。
两侧内容向内对齐(左格 justify-self: end、右格 justify-self: start),三块因此紧挨着连击数而不是被拉到行两端。
连击数在预留的 5ch 盒子里居中,位数变多时向两边均匀长,盒子宽度不变。HUD 整体无边框、无背景、无填充——读起来是外壳统计行的一部分,不是外挂的控件。
飘字和连击数必须在同一条竖线上
飘字在 shell.overlay(整帧浮层),连击数在输入框卡片的中轴上(会话列里)——这俩不是同一条线。有侧边栏的时候卡片被推离窗口正中,于是:
- 飘字从视口中心升起
- 罩子立在卡片中轴上
于是「从左边飞来的标记」和「罩子左边立起来的峰」根本不在一处,罩子就读不出「刚才那些命中」。
所以连击数自己量自己,公布中轴的位置,浮层拿它当锚点:
const box = element.getBoundingClientRect();
publish({ anchorX: Math.round(box.left + box.width / 2) });
.dsh-hit-fx { left: var(--dsh-hit-anchor-x, 50%); }
.dsh-hit-busy { left: calc(var(--dsh-hit-anchor-x, 50%) + ...); }
.dsh-hit-burst { left: calc(var(--dsh-hit-anchor-x, 50%) + ...); }
.dsh-hit-summary { left: var(--dsh-hit-anchor-x, 50%); }
只在位置真的变了时才公布——HUD 的 tick 每秒跑十次。
飘字从连击数自己那条线出发
X 轴早就是测量值了 ✅(anchorX ✅)—— Y 轴一直没跟上 ✅。
.dsh-hit-fx {
left: var(--dsh-hit-anchor-x, 50%);
bottom: calc(var(--dsh-hit-anchor-y, 17%) + var(--dsh-hit-lift, 0px));
}
原来 Y 轴是一个写死的 bottom: 17% ✅ —— 视口的百分之十七 ✅。那是加伤害条之前 ✅ ✅ 连击数所在的地方 ✅ ✅。
加了伤害条那一行,连击数就移位了 ✅ ✅,而那个百分比不会跟着走 ✅ ✅ —— 于是飘字从一个连击数早就不在的地方出发 ✅ ✅。
⇒ 发出点必须是测量的 ✅ ✅(anchorY ✅,HUD 量了连击数就公布 ✅ ✅),和 X 轴一个道理 ✅ ✅。
抬升 40px ✅ ✅:连击数的数字在那儿 ✅ ✅,而且飘字自己的第一帧还要再低 14px ✅ ✅(fly 的 0% 是 translate(0, 14px) ✅ ✅)—— 两个加起来才刚好让飘字从数字上方出去 ✅ ✅。
罩子上的三种痕迹
| 事件 | 罩子上的表现 | 高度 |
|---|---|---|
| 普通攻击 | 圆润的鼓包(cos²),高度 = token 权重,档位色 |
≤ 18 |
| 技能 | 尖锐的刺(cos⁴),更窄(5 vs 9),颜色 = 该技能自己的颜色 |
20 |
| 持续型任务 | 整条边缘在呼吸(1.5s 一个周期,起伏 1.6),每个在跑的工具占一个宽而低的包(宽 14) | ≈ 6 |
- 形状说「是事件还是背景」:圆的鼓包是又说了句话,尖的刺是干了件事
- 颜色说「是哪类事件」:技能存在期间,罩子的填充染上它的颜色(工具琥珀、成功绿、失败红),退去后回到档位色
- 呼吸说「还在跑」:持续型任务整条边缘缓慢起伏,工具结束时那个低包消失、转成一记尖刺——「托住 → 放开」是连着的
普通攻击继续用档位色,它不染色——它是背景。
半圆罩住连击数
波不是画在罩子上的,罩子的边缘本身就是波。
轮廓是算出来的:沿圆弧取 96 个采样点,每个点被附近那次的命中顶出去多少,就由它的权重决定。没有命中的时候,轮廓就是正圆。
const domeOutline = (peaks, now) => {
// 每个采样点:基础圆上的位置 + 附近峰的凸起
offset += height * Math.cos((distance / half) * (Math.PI / 2)) ** 2;
points.push(domePoint(angle, offset));
};
- 凸起用升余弦(
cos²):离开圆的地方斜率为零,峰顶也平滑,所以轮廓在波和圆的交界处没有折角 - 峰的高度是时间函数(
peakHeight):快起慢落,900ms 归零 - 罩子用渐变填充(上密下疏),所以边缘和边缘上的波才是读到的东西
- 轮廓是一条路径——没有独立的「峰」元素,也就没有「贴在上面的形状」
试过两版都不对:一开始是整条弧的描边(穿过峰,像有边框的东西);然后描边在峰处断开、由峰的轮廓接过去;最后发现根本不该有独立的峰——轮廓本身被顶出去就够了。
因为轮廓是算出来的而不是 CSS 动画,HUD 需要每 100ms 重画一次(有峰的时候才重画)。
默认不存在,但只在条子排空之后才淡出
连击框没有静止状态:第一次命中才出现,链一断就消失。但它不在条子排空的过程中变淡:
.dsh-hit-hud { opacity: 0; transition: opacity 260ms ease; }
.dsh-hit-hud.is-alive { opacity: 1; }
- 链活着:整框保持不透明,条子在里面照常排空,罩子照常随条子变淡
- 条子归 0 的那一刻:
combo同时归零 →is-alive撤掉 → 整框 260ms 淡出
条子在归零之前就已经看不见了,那才是"渐隐"该开始的时候——如果整框跟着条子一起淡,用户就看不到条子排到底。
它保留占位(不是 display: none),所以输入框不会因此跳动。
改参数
| 常量 | 值 | 作用 |
|---|---|---|
COMBO_WINDOW_MS |
2800 | 连击窗口 |
SLOW_TOOL_MS |
1200 | 工具跑多久才出 spinner |
MAX_FX |
12 | 同时存在的飘字数(也是飘字簇宽度的上限) |
FEED_LEN |
5 | 最近命中的记录点数 |
SKILL_KINDS |
— | 哪些算技能 |
RELEASE_KINDS |
— | 哪些技能是结算(向外)而不是发动(向内) |
COUNT_UP_MS |
1800 | 战报数字滚多久 |
SUMMARY_TTL_MS |
7200 | 战报存在多久 |
PUMP_MIN / PUMP_MAX |
0.45 / 0.95 | 撑住时长的随机范围(占窗口的比例) |
HELD_FLOOR |
0.06 | 被撑住时条子的下限 |
FX_CSS 里 .dsh-hit-fx { bottom } |
17% | 飘字锚点(视口) |
HUD_CSS 里 .dsh-hit-hud-inner 的 grid-template-columns |
— | 三列的分配 |
HUD_CSS 里 .dsh-hit-combo-value 的 width |
5ch | 连击数预留的位数 |
HUD_CSS 里 .dsh-hit-current 的 width |
8em | 动作标签的固定宽度 |
FX_CSS 里 dsh-hit-fade 的关键帧 |
— | 渐隐的形状和长度 |
四个小东西
| 飘字带连击倍率 | 标记不只是"发生了什么",还有"发生的时候连击是多少"(输出 ×7 ✅) |
| 回合评级 | 战报第一行是 S / A / B / C / D ✅,按这一回合做了什么算:命中数 + 工具数×3 + 输出量 + 高连击加成 ✅ —— 不按耗时 ✅(那是在评价机器,不是在评价这一回合) |
| MAX 状态 | 连击破百后数字前面的 × 变成 ★ ✅ 并开始呼吸 ✅ —— 到这时候它已经不是计数了,是炫耀 |
| 战报翻滚 | 战报的行不是淡入,是翻起来的(rotateX ✅,支点在顶边 ✅),像一块板子立起来 |
声音
游戏是听觉优先的——这个插件之前是纯视觉的。现在每一声响都是振荡器或噪声,没有素材、不加载任何文件:
| 事件 | 声音 |
|---|---|
| 普通命中 | 一声 tick,音高随连击上升(×0 是 330Hz,×60 接近 930Hz)——不看数字也能听出连击有多长 |
| 流式脉冲 | 极轻的一声 tick(几乎听不见,只是节奏) |
| 技能 | 两声上行 |
| 击杀 | 噪声爆破 + 底下两声下坠 |
| 回合结束 | 一声「叮」 —— 三个泛音一起敲,基音最响也余最久,上面的先死(这是钟和 beep 的区别) |
| 断连 | 一声轻脆响 + 一道下滑(520→90Hz,音量压低,让「叮」是主角) |
| 升阶 | 四个音的上行琶音 |
长音不能落在元音频段。 断连那声本来是 320Hz 的三角波、持续 300ms ✅ —— 320Hz 正好在人声「啊」的频段里(300–800Hz ✅),所以听起来确实像一声「啊」✅,不是错觉 ✅。
现在改成下滑(exponentialRampToValueAtTime 从 520Hz 滑到 90Hz ✅)✅ —— 那是「掉下去」的声音 ✅,不可能被听成元音 ✅。其余长时间音(击杀的 130Hz / 72Hz ✅)都在元音频段以下 ✅,是低沉的轰响而不是人声 ✅。
浏览器的自动播放策略:音频上下文初始是挂起的,必须等用户真的做过什么才能出声。所以插件在第一次交互(pointerdown / keydown)时唤醒它,在那之前只是安静 ✅,不是坏掉 ✅。
会发声的插件必须能关掉声音 ✅:连击框右侧有一个 ♪ 按钮 ✅,状态存在 localStorage ✅。它只在连击框可见时可点(框子不可见时 pointer-events: none ✅,免得有个看不见的幽灵按钮在那儿)。
镜头与停顿
冲击力不只是视觉——镜头和时间也是。
- 技能让画面抖一下(
shake: 1) - 击杀让画面狠狠一抖(
shake: 3),并且世界停一下:已经在空中的飘字冻结 140ms,而那一击本身继续走
停顿是关键:冻结的是"周围",不是"这一下" ✅。游戏里的 hitstop 正是这样——被冻住的是已经存在的东西,新的一击照常发生 ✅。
震动是衰减的位移而不是抖动:第一步最大,后面越来越小 ✅,所以读起来是"被击中"而不是"在晃"。它的强度用 CSS 变量传(--dsh-hit-shake)✅,所以震幅可调而不用改动画 ✅。
震动用时间戳重新武装 ✅——只靠 class 的话第二次就不会重放了 ✅。
升阶是一个成就
连击跨过 ×10 / ×30 / ×60(温 / 热 / 传奇)以前只是颜色变了 ✅——对一个成就来说太安静了。现在跨档会:
- 罩子张开一下(放大再收回)
- 数字挨一记更重的拳(1.9 倍再弹回,比平时那下大得多)
- 浮层里出现一行 TIER UP 温
档位判定收在一个函数里(comboTier),HUD 的 is-warm / is-hot / is-legend 三个类名和它共用同一组阈值 ✅——测试会核对两边一致 ✅。
提前返回会吞掉后面的活
战报的清理原来长这样 ✅:
if (!state.fx.length) return; // ← 没有飘字就返回
...
if (summary !== null && now - summary.born > summary.ttl) {
publish({ summary: null }); // ← 永远执行不到
}
回合结束后飘字很快清空 ✅ → return 生效 ✅ → 战报的清理永远跑不到 ✅。
而战报的 DOM 看起来已经消失了 ✅(它的动画停在透明度 0 ✅)✅ —— 所以肉眼完全看不出来 ✅,只有储里还挂着它 ✅。定格的连击在等「战报没了」这个条件 ✅ → 永远等不到 ✅ → 计数永远不消失 ✅。
现在清理是一个独立的 retire() 函数 ✅,没有提前返回 ✅ —— 每次两件都清 ✅。
回合结束就定格
连击是回合内的事 ✅。回合结束、战报已经跳出来了 ✅,这时候再提示「断连」是自相矛盾的 ✅ —— 战报是结果 ✅,旁边的计数不该于是又死一次 ✅。
所以 turn/end 会把连击定格 ✅:
| 规则 | 实现 |
|---|---|
| 不定格就正常倒计时 | comboExpired 照旧 ✅ |
| 定格了就不再过期 | comboExpired 里加一条 state.frozen !== true ✅ |
| 定格时条子满格、不排空 | windowLeft 返回 1 ✅ |
| 计数留在屏幕上,和战报一起消失 | is-alive = combo > 0 || frozen ✅;战报清空时安静地归零(不触发断连)✅ |
| 定格期间用户发新问题 → 连击重制 | user/message 里 publish({ combo: 0, frozen: false, broken: 0 }) ✅ |
chainAlive 故意不认识 frozen ✅ —— 所以定格的连击不会延长 ✅,回合结束后的第一次命中会从 1 开始,不继承上一回合的计数 ✅。
断连时罩子不该「复活」
罩子的透明度就是进度条的剩余量 ✅ —— 所以条子排空时 ✅,罩子已经淡到零了 ✅。
一开始还给断连加了个「罩子塌陷」✅,关键帧里写了 opacity: 1 ✅ → 结果是把已经淡没的罩子瞬间拉回满色 ✅ 再下沉 ✅ —— 连起来就是「先慢慢消失 → 突然满色 → 再死一次」✅。
根本问题:那一刻罩子本来就已经没了 ✅,没什么可塌的 ✅。 现在断连只由数字被打飞 ✅ 和 COMBO BREAK 那行承接 ✅。
同一个道理也修了升阶:那个强调动画原来也把 opacity 钉死 ✅ —— 现在只动 transform ✅,透明度始终归倒计时管 ✅。
一个元素只能有一个地方管透明度 ✅ —— 动画和基础规则同时写,就会打架 ✅。
动画会吃掉用来居中的 transform
罩子靠 transform: translateX(-50%) 居中 ✅ —— 而动画的 transform 是替换整个属性 ✅,不是叠加 ✅。
所以一个写成 scale(1.28) 的关键帧 ✅:动画一跑 ✅,translateX(-50%) 就没了 ✅ → 罩子往右跳半个自身宽度 ✅(68px 的一半 = 34px ✅)✅ —— 看起来就是「断连/升阶时罩子右偏」✅。
修法:任何动到 transform 的关键帧,都要自己把居中写进去 ✅。
@keyframes dsh-hit-dome-collapse {
0% { transform: translateX(-50%) scaleY(1); opacity: 1; }
100% { transform: translateX(-50%) scaleY(0.18) translateY(10px); opacity: 0; }
}
测试里有一条通用守卫 ✅:扫描所有「用 translate 居中 + 又有动画」的元素 ✅,只要有任何一个关键帧的 transform 少了那个 translate ✅,就失败 ✅ —— 我把 bug 临时改回去验证过 ✅,它确实会红 ✅。
罩子的支点在底边,不在顶点
SVG 的 transform-box 默认是 view-box ✅,而这个 viewBox 的中心在罩子的顶点高度 ✅(viewBox="-34 -52 68 52" ✅)✅。
于是升阶的 scale(1.28) 和断连的 scaleY(0.18) 默认绕顶点缩放 ✅ → 罩子可见的重心往上飘 ✅ → 读起来就是「罩子错位了」✅,而它其实只是在变形。
.dsh-hit-dome { transform-origin: 50% 100%; } /* 底边 */
从底边缩 ✅:升阶时罩子从计数器往上鼓 ✅,断连时塌回计数器上 ✅,中心不动 ✅ ✅。
报信要待在它属于的地方
TIER UP 和 COMBO BREAK 原来放在浮层里,靠 --dsh-hit-anchor-x 定位;而罩子在 HUD 里,靠 .dsh-hit-combo 定位。两棵树、两套定位方式,算出来的 x 会差几个像素——表现就是「罩子突然在右边一点位置闪了一下」。
现在两条报信都放进计数器自己的盒子里,和罩子用同一套定位(left: 50%)✅——不是去对齐两个坐标系,而是让它们共用一个 ✅。
断连是一个事件
连击断掉以前是完全静默的:数字归零、盒子淡出。没有声音的失去,就不会被感觉到。
现在断链会:
- 数字被打飞(歪、坠、缩小、淡出)——不是归零
- 罩子塌陷(压扁、沉下去)
- 浮层里出现一行 COMBO BREAK ×27,写明断了多少
下一次命中会清掉这个状态,所以新的连击不会继承上一条的"墓碑"。
断连会把链条吸回去
断连原来只是数字被打飞 ✅ ✅ 加一行 COMBO BREAK ✅ ✅。但这一下什么也没被拿走 ✅ ✅ —— 它只是不见了 ✅。
现在断连把链条收回去 ✅:
| 表现 | |
|---|---|
| 最近几下 ✅ | 从记录里取出最近 5 下 ✅ ✅,变成小字符 ✅ ✅,汇进连击数 ✅ ✅,被吃掉 ✅ |
| 还在空中的飘字 ✅ | 原地冻住 ✅ ✅,然后被拉回连击数 ✅ ✅,缩小消失 ✅ |
为什么是"最近几下"而不是"空中的飘字" ✅ ✅ —— 断连通常发生在安静之后 ✅ ✅,而那时天上早就空了 ✅ ✅:最后一个飘字在窗口跑完之前一秒半就死了 ✅ ✅。所以能被拿回去的不是恰好还飘着的东西 ✅ ✅,是链条本身 ✅ ✅。
每一下的距离都是单独量的 ✅ —— 它们停在不同的高度 ✅ ✅,一个距离套在所有飘字上 ✅ ✅,会把一半送到它从来没去过的地方 ✅ ✅。飞行冻在原地 ✅ ✅,所以是从它当下所在的位置被走回去的 ✅ ✅。
为此飘字的内容多了一层盒子 ✅ ✅(.dsh-hit-fx-body ✅)—— 飞行占着一个元素的 transform ✅ ✅,一个元素只能有一个 ✅ ✅,所以要拉回来的东西得自己有元素可动 ✅ ✅。两个 transform 相乘 ✅ ✅:飞行把标记带走 ✅,盒子把它带回来 ✅。
静音的失去不会被感觉到 ✅ ✅ —— 这条道理在这里也成立 ✅ ✅。
没有暴击
试过「每 20 次命中算一次暴击」——删了。那个数字对命中的内容毫无意义,它只是在给算术打标记。
真正的暴击应该表达「这次输出不寻常」,需要能读出"不寻常"的信号(比如某条回复的 token 量远超该回合的中位数、或者一次工具调用扫了几百个文件)。在想到可信的信号之前,不做。
测试里有一条否定式断言守着:命中里不能出现 critical,源码里不能出现 crit。
两条踩过的坑
渐隐必须和位移分开,而且要分在两个元素上。 逗号分隔的 animation 简写在客户端的 CSS 管线里活不下来:位移跑了、渐隐没跑,于是每个飘字都全不透明地停在终态,排成一条长直线。拆成两个元素之后,位移和渐隐各跑各的。
颜色必须是主题 token,不能是字面量。 深色面上选的字面色到了浅色面上几乎看不见。HUD 里的文字全部走 --dsw-alias-*;飘字那套艺术色是瞬时的,可以例外。
设置
两个开关,在 DSH 自己的设置里 ✅ ✅ —— 左边那一列里单独一项 ✅ ✅(settings.section ✅ ✅,id 是 hit-feedback ✅ ✅,菜单名走插件自己的字典 ✅ ✅,所以跟着用户选的语言走 ✅ ✅)✅:
| 语言 ✅ | 中文 / English / 跟随系统 ✅ ✅ |
| 显示伤害条 ✅ | 关掉的话,数字照出 ✅ ✅,但原地浮起淡出 ✅ ✅ —— 不再飞向条原来的位置 ✅ ✅ |
这两个值存在浏览器本地 ✅ ✅(localStorage ✅ ✅,hit-feedback.settings ✅ ✅)—— 和静音开关同一个套路 ✅ ✅。
为什么不是宿主端的设置文档 ✅ ✅:宿主端的配置字段要schema 库 ✅ ✅,而没装这个库的 profile 会让整个 bundle 加载失败 ✅ ✅ —— 反馈会跟着一起没 ✅ ✅。一个设置项不该有让特效整个消失的能力 ✅ ✅。
条关掉之后落点要靠设置决定,不能靠测量 ✅ ✅ —— 条藏起来之后,它最后一次量到的位置还留在 store 里 ✅ ✅,飘字就会继续飞向一个已经不存在的地方 ✅ ✅。
const barShown = (state.settings || {}).showDamageBar !== false;
if (!barShown) return { dx: 0, dy: -28 }; // 原地浮起
⇒ 所以宿主端一行依赖都不加 ✅ ✅(index.js ✅ ✅ 一个空函数 ✅ ✅),插件永远是加载得起来的 ✅ ✅。
跨设备共享是有意不做的 ✅ ✅ —— 它值一个 schema 库的依赖 ✅ ✅,而那个依赖能让整个插件加载失败 ✅ ✅。一个外观插件不该有这种代价 ✅ ✅。同一个浏览器里,设置该有的都有 ✅ ✅。
界面文案在 client.js 的 EN/ZH 里 ✅ ✅ —— locale/*.json 是插件市场里那张卡的标题和描述 ✅ ✅(meta 里的)✅ ✅,不是界面文案 ✅ ✅。放错了地方,页面上就全是原始 key ✅ ✅。
改的是插件自己的语言,不是设置页的语言 ✅ ✅ —— 设置页是宿主的页面 ✅ ✅,它的字("语言"、""显示伤害条"、菜单名)跟宿主的语言走 ✅ ✅;只有反馈自己的字(连击、TIER UP、提示)跟用户选的那个走 ✅ ✅。
这两个翻译器是两个不同的东西 ✅ ✅:
translate // 反馈自己的字,跟着用户选的
hostTranslate // 宿主的页面,跟着宿主的语言
放反了会怎样 ✅ ✅:选了英文,连设置页都变英文 ✅ ✅ —— 那这个设置就不是在说它说的那件事 ✅ ✅。
「跟随系统」是宿主自己的语言 ✅ ✅ —— 插件注册了两份字典 ✅ ✅,由宿主的 locale 服务挑 ✅ ✅。选另外两个时,插件用自己的字典 ✅ ✅,哪怕宿主是另一种语言 ✅ ✅。
读取是可选的 ✅ ✅ —— 没有设置服务的宿主 ✅ ✅,插件照样工作 ✅ ✅,只是用默认值 ✅ ✅。一个用户够不着的设置,绝不能是反馈不工作的理由 ✅ ✅。
看起来是什么样
特效是组件渲染出来的 DOM 上的 CSS 动画 ✅ —— 所以看它的办法只有一个 ✅ ✅:把真的组件和真的样式表放在一起 ✅ ✅,让浏览器自己放 ✅ ✅。
node tools/build-preview.mjs # 写出 preview.html ✅
open preview.html # 三个场景各自放着 ✅
页面里的三个场景 ✅ 就是上面那三件事 ✅ ✅ —— 技能的两拍 ✅、伤害数字飞进条 ✅、断连把链条吸回去 ✅ ✅。
预览页有几个开关 ✅ ✅:
| 参数 | 作用 |
|---|---|
?only=1 |
只看第 N 个场景 ✅ |
?freeze=560 |
把场景冻在第 560 毫秒 ✅ —— 半秒就没的东西得能停住看 ✅ |
?zoom=2 |
放大舞台 ✅ |
?debug=1 |
把每个标记的位置和动画状态打进页面 ✅ |
预览页是生成的 ✅ —— preview.html 不进版本库 ✅ ✅,client.js 变了就重新生成 ✅ ✅。
怎么知道页面上跑的是哪一版
插件加载的时候会在控制台打一行 ✅ ✅:
[hit-feedback] build v2.1.0 rev=cf63665b08b3 host=0.2.1-rc.2+332895e0 loaded=2026-10-26T11:07:31.412Z
| 字段 | 是什么 |
|---|---|
v2.1.0 |
插件自己的版本 ✅ —— 和 package.json 一致 ✅ |
rev |
宿主的清单给每个插件算的哈希 ✅ ✅ —— 文件一改就变 ✅ ✅,所以它是"页面跑的是不是刚写的代码"的答案 ✅ ✅ |
host |
宿主的版本和 commit ✅ ✅ |
loaded |
加载的那一刻 ✅ ✅ |
这行没出现 = 插件根本没加载 ✅ ✅ —— 那和"效果没出来"是两回事 ✅ ✅,很容易搞混 ✅ ✅。
同一个信息也在 DOM 上 ✅ ✅:
// 页面跑的是哪一版
document.querySelector('.dsh-hit-fx-layer')?.dataset.build // v2.1.0+cf63665b08b3
// 插件刚才做了什么
document.querySelector('.dsh-hit-fx-layer')?.dataset.trace // damage:486 speak:12 tool:45 result:45
trace ✅ ✅ 是最近几下命中和它们的伤害 ✅ ✅ —— 数字没出来的时候 ✅ ✅,看一眼就知道断在哪一环 ✅ ✅。
测试
npm test
418 条断言。包含三类:
- 纯函数——连击规则、锯齿、缓动、格式化
- 离线路径——假时钟 + 假 session 事件源,驱动完整的监听器
- 渲染树——把组件真的渲染出来、展开函数组件、遍历输出,断言它到底吐了什么
最后那类能抓住值断言抓不到的问题:重复的 React key 曾让动作标签堆成一条长串,而断言只数子节点个数是发现不了的——必须比对内容。