npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dsh-hit-feedback

v2.1.0

Published

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.

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 插件打进同一个请求 ✅,只要其中一个在导入阶段抛错 ✅,整包都会被跳过 ✅,本插件根本没有机会执行 ✅。

怎么找是哪个 ✅:

  1. 把第三方插件全部禁用(官方 @deepseek-ai/* 的留着)✅
  2. 刷新 ✅ —— 本插件能出现 ✅ → 确认是冲突 ✅
  3. 一个个启用 ✅,哪个一开就崩 ✅,就是它 ✅

注意:我试过凭报错里的插件列表猜是哪个 ✅ —— 猜错了 ✅。列表里出现 ≠ 它是元凶 ✅,只能靠二分测 ✅。

发布到 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 曾让动作标签堆成一条长串,而断言只数子节点个数是发现不了的——必须比对内容。