Skip to content

dsh-hit-feedback

Verified

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

怎么找是哪个 ✅:

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