9.3 UI 状态与交互反馈规范

同一颗按钮在正常、悬停、按下、禁用、选中五态下长什么样,是规范里最容易被漏掉的一层。本节把《星屑旅店》的五态参数写进一份 JSON 配置,用 S 层 Python 算每态前景/背景的对比度 ×10、做状态完备性与反馈时长两条 lint,用 P 层 TS 内核跨语言逐位对照,并断言低于对比度下限的配色必须被判失败。

9.3.1 状态是规范里最容易漏的一层

图表口径的无障碍配色 关心的是数据系列在色觉障碍下还能不能区分,那是数据可视化的家;本节做的是游戏 UI 的五态参数表与对比度闸门——关心的是同一颗控件在五种交互状态下,前景和背景的对比度够不够、状态有没有漏画、反馈时长有没有超出体感区间。两者都会算对比度,但对象不同:那边是数据系列,这边是控件状态。

9.1 定了元素落在哪一格,9.2 定了资源怎么拉伸。但美术交上来的按钮往往只有一张「正常态」图。玩家按下没反馈、鼠标悬停没变化、禁用态还能点——这些不是实现 bug,是规范漏了一整层。把五态写死成参数,是这一节的全部目的。

五个状态的定义必须一次说清,否则每个人理解的「选中」都不一样:

状态触发玩家要读到什么
normal默认这里可以点
hover指针悬停 / 手柄聚焦当前焦点在这里
pressed按下未抬起收到了,正在处理
disabled条件不满足现在不能点,别试了
selected处于选中态(开关、页签)它是当前生效项

9.3.2 D 层:配置即规范

五态参数写进一份 JSON 配置。它是 D 层产物——不是代码,是被断言的数据;改了它,S 层与 P 层会立刻用新值复验:

{
  "version": 1,
  "game": "stardust-inn",
  "states": ["normal", "hover", "pressed", "disabled", "selected"],
  "durationRangeMs": [0, 300],
  "components": [
    {
      "id": "primary_button",
      "states": {
        "normal": { "fg": "bg", "bg": "accent", "durationMs": 0 },
        "hover": { "fg": "bg", "bg": "accent", "durationMs": 90 },
        "pressed": { "fg": "ink", "bg": "bg", "durationMs": 60 },
        "disabled": { "fg": "muted", "bg": "surface", "durationMs": 0 },
        "selected": { "fg": "ink", "bg": "surface", "durationMs": 120 }
      }
    },
    {
      "id": "icon_toggle",
      "states": {
        "normal": { "fg": "muted", "bg": "surface", "durationMs": 0 },
        "hover": { "fg": "ink", "bg": "surface", "durationMs": 90 },
        "pressed": { "fg": "ink", "bg": "bg", "durationMs": 60 },
        "disabled": { "fg": "muted", "bg": "surface", "durationMs": 0 },
        "selected": { "fg": "bg", "bg": "accent", "durationMs": 120 }
      }
    },
    {
      "id": "destructive_button",
      "states": {
        "normal": { "fg": "ink", "bg": "bg", "durationMs": 0 },
        "hover": { "fg": "ink", "bg": "bg", "durationMs": 90 },
        "pressed": { "fg": "ink", "bg": "bg", "durationMs": 60 },
        "disabled": { "fg": "muted", "bg": "surface", "durationMs": 0 },
        "selected": { "fg": "danger", "bg": "surface", "durationMs": 120 }
      }
    }
  ]
}

配色本身不在这里定义——fg / bg 只是 token 名,真值来自全站共享的只读真源 config/visual_spec.json:bg [31,41,51]、surface [45,58,71]、ink [236,239,241]、muted [148,163,184]、accent [245,158,11]、danger [239,68,68],以及对比度下限 contrastFloor10 = 45。改色只改真源一处,三个组件一起跟着变。

把配置跑一遍,得到的就是五态参数表(格内是 前景/背景 对比度×10):

组件normalhoverpresseddisabledselected
primary_buttonbg/accent 69bg/accent 69ink/bg 128muted/surface 45ink/surface 101
icon_togglemuted/surface 45ink/surface 101ink/bg 128muted/surface 45bg/accent 69
destructive_buttonink/bg 128ink/bg 128ink/bg 128muted/surface 45danger/surface 31 ✗

destructive_button 的选中态是故意留的一处真实缺陷:danger 落在 surface 上只有 31 < 45。规范如果从不出错,它就不是闸门。这一条必须在跑的时候被判失败,而不是被顺手改掉。

9.3.3 S 层:对比度与两条 lint

S 层脚本读配置与真源,对每个组件的每一态算前景/背景的 WCAG 对比度 ×10(整数),再做两条 lint:完备性(五态缺一即 fail)与反馈时长(必须落在 durationRangeMs 内且为整数):

#!/usr/bin/env python3
# file: sim/sp09_states.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp09_states.py
"""9.3 UI 状态与交互反馈规范的 S 层整数账。

读 config/sp09_states.json 与共享真源 config/visual_spec.json(只读),对每个组件的每个
状态算前景/背景 token 的 WCAG 对比度 ×10(整数),与下限 contrastFloor10 比;再做两条 lint:
  - 完备性:五个状态 normal/hover/pressed/disabled/selected 缺一即 fail
  - 反馈时长:durationMs 必须落在 durationRangeMs 内,且为整数
落 out/sp09_states.json,供 P 层跨语言逐位对照。对比度是整数类,逐位一致。
"""
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)

SRGB_THRESHOLD, SRGB_DIVISOR = 0.04045, 12.92
SRGB_OFFSET, SRGB_SCALE, SRGB_GAMMA = 0.055, 1.055, 2.4
LUMA_R, LUMA_G, LUMA_B = 0.2126, 0.7152, 0.0722


def srgb_to_linear(c: int) -> float:
    s = c / 255
    return s / SRGB_DIVISOR if s <= SRGB_THRESHOLD else ((s + SRGB_OFFSET) / SRGB_SCALE) ** SRGB_GAMMA


def luminance(rgb) -> float:
    return LUMA_R * srgb_to_linear(rgb[0]) + LUMA_G * srgb_to_linear(rgb[1]) + LUMA_B * srgb_to_linear(rgb[2])


def contrast10(a, b) -> int:
    la, lb = luminance(a), luminance(b)
    hi, lo = max(la, lb), min(la, lb)
    return round(((hi + 0.05) / (lo + 0.05)) * 10)


def lint_component(comp: dict, states, tokens, floor10, dmin, dmax):
    present = comp["states"]
    missing = [s for s in states if s not in present]
    rows = []
    for s in states:
        if s not in present:
            continue
        st = present[s]
        c = contrast10(tokens[st["fg"]], tokens[st["bg"]])
        dur_ok = isinstance(st["durationMs"], int) and dmin <= st["durationMs"] <= dmax
        rows.append({"state": s, "fg": st["fg"], "bg": st["bg"], "contrast10": c,
                     "pass": c >= floor10, "durationMs": st["durationMs"], "durationOk": dur_ok})
    return {"id": comp["id"], "complete": not missing, "missing": missing, "states": rows}


def main() -> int:
    cfg = json.load(open(os.path.join(ROOT, "config/sp09_states.json"), encoding="utf-8"))
    spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
    tokens, floor10 = spec["tokens"], spec["contrastFloor10"]
    dmin, dmax = cfg["durationRangeMs"]

    comps = [lint_component(c, cfg["states"], tokens, floor10, dmin, dmax) for c in cfg["components"]]
    failing = [{"component": c["id"], "state": r["state"], "contrast10": r["contrast10"]}
               for c in comps for r in c["states"] if not r["pass"]]
    incomplete = [{"component": c["id"], "missing": c["missing"]} for c in comps if not c["complete"]]
    bad_duration = [{"component": c["id"], "state": r["state"], "durationMs": r["durationMs"]}
                    for c in comps for r in c["states"] if not r["durationOk"]]

    report = {"game": cfg["game"], "states": cfg["states"], "contrastFloor10": floor10,
              "durationRangeMs": cfg["durationRangeMs"], "components": comps,
              "failing": failing, "incomplete": incomplete, "badDuration": bad_duration,
              "violationCount": len(failing) + len(incomplete) + len(bad_duration)}

    with open(os.path.join(ROOT, "out/sp09_states.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
        f.write("\n")

    for c in comps:
        print(f"{c['id']}: complete={c['complete']} missing={c['missing']}")
        for r in c["states"]:
            flag = "ok" if r["pass"] else "FAIL"
            print(f"  {r['state']:9s} {r['fg']:8s}/{r['bg']:8s} cr10={r['contrast10']:3d} "
                  f"dur={r['durationMs']:3d} {flag}")
    print(f"floor10={floor10} failing={len(failing)} incomplete={len(incomplete)} badDuration={len(bad_duration)}")
    print("wrote out/sp09_states.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑(末尾一行就是全部结论:1 条失败、0 条缺态、0 条时长越界):

primary_button: complete=True missing=[]
  normal    bg      /accent   cr10= 69 dur=  0 ok
  hover     bg      /accent   cr10= 69 dur= 90 ok
  pressed   ink     /bg       cr10=128 dur= 60 ok
  disabled  muted   /surface  cr10= 45 dur=  0 ok
  selected  ink     /surface  cr10=101 dur=120 ok
icon_toggle: complete=True missing=[]
  normal    muted   /surface  cr10= 45 dur=  0 ok
  hover     ink     /surface  cr10=101 dur= 90 ok
  pressed   ink     /bg       cr10=128 dur= 60 ok
  disabled  muted   /surface  cr10= 45 dur=  0 ok
  selected  bg      /accent   cr10= 69 dur=120 ok
destructive_button: complete=True missing=[]
  normal    ink     /bg       cr10=128 dur=  0 ok
  hover     ink     /bg       cr10=128 dur= 90 ok
  pressed   ink     /bg       cr10=128 dur= 60 ok
  disabled  muted   /surface  cr10= 45 dur=  0 ok
  selected  danger  /surface  cr10= 31 dur=120 FAIL
floor10=45 failing=1 incomplete=0 badDuration=0
wrote out/sp09_states.json

disabled 一态在三处都取 muted/surface = 45,刚好贴住下限——这是有意为之:下限是「可以接受的最差」,不是「最好」。低于它(如 31)就是不可读,等于没画。时长这一列则是另一条判据:normal / disabled 是静止态,反馈时长 0;pressed 用 60ms 做即时按压反馈,hover 用 90ms 做过渡,selected 用 120ms 让选中状态有存在感——三者都在 0~300ms 的体感区间内。

9.3.4 P 层:同一套规则的 TS 内核

// file: core/src/sp09/uistate.ts
// tier: P
// verify: node --test 'core/test/sp09_*.test.ts'
// 9.3 UI 状态与交互反馈规范的 P 层内核。与 sim/sp09_states.py 同一套规则:每个组件必须
// 齐全五态,每态算前景/背景 token 的对比度 ×10(整数,复用 sp00/spec.ts 的 contrast10),
// 低于 contrastFloor10 即 fail;反馈时长必须落在 durationRangeMs 内。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { contrast10 } from "../sp00/spec.ts";

export function lintComponent(comp: any, states: string[], tokens: any, floor10: number, dmin: number, dmax: number): any {
  const present = comp.states;
  const missing = states.filter((s) => !(s in present));
  const rows: any[] = [];
  for (const s of states) {
    if (!(s in present)) continue;
    const st = present[s];
    const c = contrast10(tokens[st.fg], tokens[st.bg]);
    const durationOk = Number.isInteger(st.durationMs) && st.durationMs >= dmin && st.durationMs <= dmax;
    rows.push({ state: s, fg: st.fg, bg: st.bg, contrast10: c, pass: c >= floor10, durationMs: st.durationMs, durationOk });
  }
  return { id: comp.id, complete: missing.length === 0, missing, states: rows };
}

export function lintStates(config: any, tokens: any, floor10: number): any {
  const dmin = config.durationRangeMs[0];
  const dmax = config.durationRangeMs[1];
  const components = config.components.map((c: any) => lintComponent(c, config.states, tokens, floor10, dmin, dmax));
  const failing = components.flatMap((c: any) =>
    c.states.filter((r: any) => !r.pass).map((r: any) => ({ component: c.id, state: r.state, contrast10: r.contrast10 })));
  const incomplete = components.filter((c: any) => !c.complete).map((c: any) => ({ component: c.id, missing: c.missing }));
  const badDuration = components.flatMap((c: any) =>
    c.states.filter((r: any) => !r.durationOk).map((r: any) => ({ component: c.id, state: r.state, durationMs: r.durationMs })));
  return { components, failing, incomplete, badDuration,
    violationCount: failing.length + incomplete.length + badDuration.length };
}

这里有个关键设计:TS 侧不重算对比度公式,而是 import { contrast10 } from "../sp00/spec.ts"——真源只有一份。Python 侧独立实现同一公式,两条路径的结果按整数逐位对照,任何一侧改错都会被测试抓住。

core/test/sp09_states.test.ts 读 Python 落盘的 out/sp09_states.json,逐组件逐态对照 [state, fg, bg, contrast10, pass, durationMs, durationOk] 七元组,并用三个负例分别验证「缺态」「低对比度」「时长越界」都会被抓住:

// file: core/test/sp09_states.test.ts
// tier: P
// verify: node --test 'core/test/sp09_*.test.ts'
// 跨语言对照:读 sim/sp09_states.py 落盘的 out/sp09_states.json,断言 TS 侧每组件每态的
// 对比度 ×10、完备性、时长判定逐位一致。负例:缺态必须被完备性 lint 抓到;低于下限的
// 配色必须判 fail(而不是静默通过)。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { lintStates, lintComponent } from "../src/sp09/uistate.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp09_states.json"), "utf8"));
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp09_states.json"), "utf8"));

test("每组件每态:对比度 ×10 与判定逐位一致", () => {
  const mine = lintStates(cfg, spec.tokens, spec.contrastFloor10);
  assert.equal(mine.components.length, py.components.length);
  for (let i = 0; i < mine.components.length; i += 1) {
    const a = mine.components[i];
    const b = py.components[i];
    assert.equal(a.id, b.id);
    assert.equal(a.complete, b.complete);
    assert.deepEqual(a.missing, b.missing);
    assert.deepEqual(a.states.map((r: any) => [r.state, r.fg, r.bg, r.contrast10, r.pass, r.durationMs, r.durationOk]),
      b.states.map((r: any) => [r.state, r.fg, r.bg, r.contrast10, r.pass, r.durationMs, r.durationOk]), a.id);
  }
});

test("违例集合:failing / incomplete / badDuration 与 Python 一致", () => {
  const mine = lintStates(cfg, spec.tokens, spec.contrastFloor10);
  assert.deepEqual(mine.failing, py.failing);
  assert.deepEqual(mine.incomplete, py.incomplete);
  assert.deepEqual(mine.badDuration, py.badDuration);
  assert.equal(mine.violationCount, py.violationCount);
  assert.equal(py.contrastFloor10, spec.contrastFloor10);
});

test("负例:缺一个状态必须被完备性 lint 抓到", () => {
  const bad = { id: "x", states: { normal: { fg: "ink", bg: "bg", durationMs: 0 },
    hover: { fg: "ink", bg: "bg", durationMs: 0 }, disabled: { fg: "muted", bg: "surface", durationMs: 0 },
    selected: { fg: "ink", bg: "bg", durationMs: 0 } } };
  const r = lintComponent(bad, cfg.states, spec.tokens, spec.contrastFloor10, 0, 300);
  assert.equal(r.complete, false);
  assert.deepEqual(r.missing, ["pressed"]);
});

test("负例:低于对比度下限的配色必须判 fail", () => {
  const low = { id: "y", states: { normal: { fg: "danger", bg: "surface", durationMs: 0 } } };
  const r = lintComponent(low, ["normal"], spec.tokens, spec.contrastFloor10, 0, 300);
  assert.equal(r.states[0].contrast10, 31);
  assert.equal(r.states[0].pass, false);
  assert.ok(r.states[0].contrast10 < spec.contrastFloor10);
});

test("负例:时长越界必须被判 badDuration", () => {
  const slow = { id: "z", states: { normal: { fg: "ink", bg: "bg", durationMs: 999 } } };
  const r = lintComponent(slow, ["normal"], spec.tokens, spec.contrastFloor10, 0, 300);
  assert.equal(r.states[0].durationOk, false);
});

实跑 node --test 'core/test/sp09_*.test.ts',本节 5 条(全套 12 条全绿):

✔ 每组件每态:对比度 ×10 与判定逐位一致 (1.653042ms)
✔ 违例集合:failing / incomplete / badDuration 与 Python 一致 (0.154166ms)
✔ 负例:缺一个状态必须被完备性 lint 抓到 (0.082917ms)
✔ 负例:低于对比度下限的配色必须判 fail (0.094375ms)
✔ 负例:时长越界必须被判 badDuration (0.069125ms)

9.3.5 边界与取舍

  • destructive_button 的选中态是真缺陷,不是样例。规范跑出 failing=1 才是正常状态;把它改成 45 以上(例如选中态改用 ink 落在 bg)后重跑,failing 应变 0——这正是「规范驱动改色」的闭环。
  • 真机上的过渡曲线、缓动函数与触感反馈(cubic-bezier、手柄震动、音效对齐)需要真实运行时与硬件,命令行不可复现,标 X;本节只验证时长数值落在体感区间内,不验证曲线形状。
  • 真实 UI 框架的状态机(Unity UGUI 的 Selectable、UE 的 Slate 状态、各引擎的伪类)不在此列,属于引擎实现侧;本节交付的是与引擎无关的参数表 + 两条 lint。
  • 状态定义一旦增删(例如加入 focused),必须同步改 states 数组与三处组件配置,S/P 两层会立刻报出缺态——完备性 lint 的作用就是让「漏画一个状态」变成构建失败,而不是上线后的玩家反馈。

阅读导航:上一节:9.2 九宫格与可拉伸资源 · 下一节:10.1 图标网格与描边规则 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则