12.1 管特效、12.2 管动画,本节把两者收束到一个玩家真正能感觉到的问题:反馈有没有轻重之分。拾取一枚金币、被客人投诉、旅店升级——这三件事在玩家的体感里应该明显不同,但很多项目做出来是「一样抖一下」。本节把反馈分成轻/中/重三级,给每级一组整数参数,并用整数判据断言相邻两级必须可区分。分工:屏幕震动与闪白最终走的是后处理管线 ,那是渲染实现,本节不重推后处理,只做美术侧的反馈分级参数表与可区分性判据。
12.3.1 反馈为什么要分级
反馈是玩家和游戏之间最短的那根线:按下一个键,屏幕要在几十毫秒内告诉他「收到了」。但反馈的强度必须匹配事件的分量:
- 如果拾取金币和升级一样震屏,升级就不再「重要」——玩家的情绪曲线被拉平了。
- 如果三级反馈的参数只差一点点,玩家根本分不出来,分级就是自欺欺人。
所以规范要同时说清两件事:每级给什么参数,以及级与级之间差多少才算「真的不一样」。前者是设计,后者是可校验的门禁。
12.3.2 三级参数表(D 层配置)
下面这段是 D 层配置:轻/中/重三级各给四个整数参数(时长毫秒、位移像素、缩放千分比、震屏像素)加一个颜色 token 引用,再给出相邻两级必须拉开的最小差 gapMin。末段的 badExample 是故意不达标的表:重的时长只比中的多 30ms、位移只多 1px、缩放只多 10‰,还引用了一个不存在的 neon:
{
"project": "stardust-inn",
"schemaVersion": 1,
"specRef": "config/visual_spec.json",
"note": "反馈层级的视觉表达:轻/中/重三级,每级给出整数毫秒时长、位移像素、缩放千分比(1000 = 1.0 倍)、震屏像素,以及颜色 token 引用(必须存在于 visual_spec.json 的 tokens)。gapMin 是相邻两级必须拉开的最小差;三级必须可区分,任一项差小于下限即 fail。badExample 是故意不达标的表。",
"tiers": [
{ "id": "light", "cn": "轻", "durationMs": 120, "displacementPx": 2, "scalePermille": 1010, "shakePx": 0, "token": "muted" },
{ "id": "medium", "cn": "中", "durationMs": 220, "displacementPx": 6, "scalePermille": 1040, "shakePx": 2, "token": "warn" },
{ "id": "heavy", "cn": "重", "durationMs": 380, "displacementPx": 14, "scalePermille": 1100, "shakePx": 6, "token": "danger" }
],
"gapMin": { "durationMs": 60, "displacementPx": 3, "scalePermille": 20, "shakePx": 2 },
"badExample": [
{ "id": "light", "durationMs": 120, "displacementPx": 2, "scalePermille": 1010, "shakePx": 0, "token": "muted" },
{ "id": "medium", "durationMs": 220, "displacementPx": 6, "scalePermille": 1040, "shakePx": 2, "token": "warn" },
{ "id": "heavy", "durationMs": 250, "displacementPx": 7, "scalePermille": 1050, "shakePx": 2, "token": "neon" }
]
}
正表的设计意图:轻的是「一抖」(2px、几乎不缩放、不震屏、muted 灰);中的是「明显一下」(6px、放大 4%、震 2px、warn 橙);重的是「震一下屏」(14px、放大 10%、震 6px、danger 红)。颜色 token 全部引用 visual_spec.json,不硬编码 hex。
12.3.3 S 层:可区分性断言
下面这段是 S 层脚本,对正表与 badExample 各算一遍:逐项比较相邻两级的参数差,并按 gapMin 判违例,同时检查 token 引用是否存在:
#!/usr/bin/env python3
# file: sim/sp12_feedback.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp12_feedback.py
"""12.3 反馈层级视觉表达的 S 层账。
读 config/visual_spec.json(token 真源,只读)与 config/sp12_feedback.json。
把反馈分成轻/中/重三级,每级四个整数参数(时长 ms / 位移 px / 缩放千分比 / 震屏 px)
加一个颜色 token 引用。逐项账:
1. 相邻两级每个参数的差必须 ≥ gapMin 对应下限,否则记 gap 违例;
2. 每级的 token 必须在 visual_spec.json 的 tokens 里,否则记 token 违例。
对「正表」与「badExample」各算一遍:正表必须零违例,badExample 必须被逐项报出。
产出 out/sp12_feedback.json,供 P 层跨语言对照。全部整数类,逐位一致。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/visual_spec.json")
CFG = os.path.join(ROOT, "config/sp12_feedback.json")
OUT = os.path.join(ROOT, "out")
PARAMS = ["durationMs", "displacementPx", "scalePermille", "shakePx"]
def load(p):
with open(p, encoding="utf-8") as f:
return json.load(f)
def check_table(tiers, gap_min, tokens):
"""逐项算相邻两级的参数差与 token 引用违例。"""
gaps = []
for a, b in zip(tiers, tiers[1:]):
for p in PARAMS:
d = b[p] - a[p]
gaps.append({"pair": f"{a['id']}->{b['id']}", "param": p, "diff": d,
"min": gap_min[p], "ok": d >= gap_min[p]})
token_v = [{"tier": t["id"], "token": t["token"]}
for t in tiers if t["token"] not in tokens]
return {
"tiers": [t["id"] for t in tiers],
"gaps": gaps,
"gapViolations": [f"{g['pair']}:{g['param']}" for g in gaps if not g["ok"]],
"tokenViolations": token_v,
"pass": not any(not g["ok"] for g in gaps) and not token_v,
}
def main() -> int:
spec, cfg = load(SPEC), load(CFG)
tokens, gap_min = spec["tokens"], cfg["gapMin"]
good = check_table(cfg["tiers"], gap_min, tokens)
bad = check_table(cfg["badExample"], gap_min, tokens)
summary = {
"project": spec["project"], "specVersion": spec["specVersion"],
"gapMin": gap_min, "params": PARAMS,
"good": good, "bad": bad,
"minDiff": min(g["diff"] for g in good["gaps"]),
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "sp12_feedback.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
for label, tbl in (("good", good), ("bad", bad)):
print(f"[{label}] pass={tbl['pass']} gaps={len(tbl['gaps'])} "
f"gapViolations={tbl['gapViolations']} tokenViolations={tbl['tokenViolations']}")
print(f"gapMin={gap_min}")
print("wrote out/sp12_feedback.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp12_feedback.py:
[good] pass=True gaps=8 gapViolations=[] tokenViolations=[]
[bad] pass=False gaps=8 gapViolations=['medium->heavy:durationMs', 'medium->heavy:displacementPx', 'medium->heavy:scalePermille', 'medium->heavy:shakePx'] tokenViolations=[{'tier': 'heavy', 'token': 'neon'}]
gapMin={'durationMs': 60, 'displacementPx': 3, 'scalePermille': 20, 'shakePx': 2}
wrote out/sp12_feedback.json
正表 pass=True、8 项差全过(gaps=8 是两级 × 四参数)。badExample 在 medium->heavy 上四项全炸:时长差 30<60、位移差 1<3、缩放差 10<20、震屏差 0<2,再加一条 token 违例(neon 不在真源里)。这就是「三级必须可区分」被门禁真的拦下来的样子。
12.3.4 P 层:同一套判据的 TS 内核
下面这段是 P 层 TS 内核 core/src/sp12/feedback.ts,与 Python 用同一套整数判据(diff >= min):
// file: core/src/sp12/feedback.ts
// tier: P
// verify: node --test 'core/test/sp12_*.test.ts'
// 12.3 反馈层级视觉表达的 TS 侧校验器。与 sim/sp12_feedback.py 同一套整数规则:
// 相邻两级四个整数参数(时长/位移/缩放千分比/震屏)的差必须 ≥ gapMin 对应下限;
// 每级的 token 引用必须存在于 visual_spec.json 的 tokens。整数类逐位一致。
export const FEEDBACK_PARAMS = ["durationMs", "displacementPx", "scalePermille", "shakePx"];
export function checkFeedbackTiers(tiers: any[], gapMin: any, tokens: any): any {
const gaps: any[] = [];
for (let i = 0; i + 1 < tiers.length; i++) {
const a = tiers[i];
const b = tiers[i + 1];
for (const p of FEEDBACK_PARAMS) {
const diff = b[p] - a[p];
gaps.push({ pair: a.id + "->" + b.id, param: p, diff, min: gapMin[p], ok: diff >= gapMin[p] });
}
}
const tokenViolations = tiers
.filter((t) => !(t.token in tokens))
.map((t) => ({ tier: t.id, token: t.token }));
const gapViolations = gaps.filter((g) => !g.ok).map((g) => g.pair + ":" + g.param);
return { gaps, gapViolations, tokenViolations, pass: gapViolations.length === 0 && tokenViolations.length === 0 };
}
配套的 P 层测试 core/test/sp12_feedback.test.ts 读 Python 落盘的 out/sp12_feedback.json 做跨语言对照,并用内存样本构造「参数差不足」「token 不存在」两个负例。实跑 node --test 'core/test/sp12_*.test.ts'(此处只列本节相关的 4 条;三节合计 14 条全绿):
✔ 正表:三级可区分,与 Python 产物逐位一致
✔ badExample:参数差不足与 token 不存在都必须被报出
✔ 负例:相邻两级参数差小于下限必须 fail
✔ 负例:引用不存在的 token 必须 fail
ℹ tests 14
ℹ pass 14
ℹ fail 0
四个参数、gapMin、违例清单全是整数或字符串,TS 与 Python 逐位一致;token 引用存在性是布尔判据,同样逐位一致。
12.3.5 阈值怎么定:可区分 vs 过冲
gapMin 是拍的,所以要有依据。本套的取值逻辑:
| 参数 | 下限 | 依据 |
|---|---|---|
| 时长 | 60ms | 低于约 50ms 的差异人眼难分,取 60 留余量 |
| 位移 | 3px | 设计分辨率下 3px 是可感知的最小位移档 |
| 缩放 | 20‰ | 2% 的缩放差刚好跨过「看得出在放大」的门槛 |
| 震屏 | 2px | 0 与 2px 是「不震」与「震」的分界 |
两条纪律:
- 下限只管「够不够分」,不管「够不够炫」。参数可以远大于下限(正表里重的位移 14px 就远超 3px 下限),门禁只保证「不低于」。
- 不要盲目拉大。反馈过冲(比如每级都震 10px)会让玩家疲劳,且与 12.1 的屏占比预算冲突——分级是编排,不是堆料。
12.3.6 参数怎么落到实现
参数表只是「说清」,落地还要一张映射表:每个参数对应引擎里的哪个通道,归哪一章管。
| 参数 | 引擎侧对应 | 归属 |
|---|---|---|
durationMs | 反馈动画时长(帧数 = 时长 × fps // 1000) | 动画(12.2) |
displacementPx | 元素相对位移 | UI / 动效 |
scalePermille | 缩放目标值(1000 = 原尺寸) | UI / 动效 |
shakePx | 相机震屏振幅 | 后处理(外链) |
token | 闪白 / 高亮的颜色 | 色彩 token(第 3、4 章) |
注意 durationMs 和 12.2 是同一套帧账:反馈动画也要落到整数帧,所以这里的时长必须能被 fps 换算到可接受的残差。shakePx 归后处理管线(外链),本节只声明振幅、不实现;token 归色彩 token 系统,本节只做引用完整性校验。三个参数、三章各管一段,靠 token 与帧数两个整数口径缝在一起。
12.3.7 边界与取舍
- 真实反馈的手感(震动曲线、音画同步、玩家体感)需要真实环境(引擎 / 真机 / 真人评测),命令行不可复现,标 X;本节只做参数表与可区分性的整数判据,不预测真实手感。
- 三级是**《星屑旅店》这一款治愈系休闲游戏的取值**;格斗或动作游戏可能需要五级,且每级参数更激进。换项目换分级数,但「可区分性可校验」的原则不变。
- 颜色 token 引用完整性依赖
visual_spec.json(唯一真源,本节只读);改 token 名会让反馈表报错,这正是想要的效果。 - 三级之外的微反馈(按钮 hover、图标轻弹)量级太小,单独一张表更清楚,不混进这套分级。
gapMin与 12.1 的屏占比上限是两套独立约束:反馈参数差达标,不代表特效预算不超——两张门禁都要过。- 全部量为整数类,TS 与 Python 逐位一致;本节不涉及色彩派生量,故没有浮点容差问题。
阅读导航:上一节:12.2 动画帧率与状态规范 · 下一节:13.1 美术需求书(Brief)的结构化写法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。