12.2 圆角与柔和过渡规范

清新治愈的形状语言同样可以整数化:圆角取阶梯且不超短边一半、柔边宽取阶梯、过渡带数必须整除柔边宽(每带等宽整数像素)、模糊半径取阶梯、柔和风格禁粗描边。本节用 Python 真跑六块 UI 资产的规范审计并生成圆角样本 PNG,再用 TypeScript 校验器与 Python 落盘的 JSON 对拍。

12.2 圆角与柔和过渡规范

站内 游戏美术风格指南 把「圆润、柔和」讲成了感觉。本节不重复那套口号,而是把它压成五个可校验的整数:圆角半径、柔边宽、过渡带数、模糊半径、描边宽。上一节 12.1 高明度低饱和调色板 定的是「底色」的整数边界,本节定的是「形状」的整数边界,两者同属 cozy-clean 主题包。圆角与柔边的粒度沿用 2D 手绘侧的 unit 阶梯写法,见 3.1 扁平色块与线宽规范 ;九宫格可拉伸的边界另见卷一 9.2 九宫格可拉伸 。本节只做参数与账,真实抗锯齿与模糊的观感需真实环境,命令行不可复现(X)。

五类整数约束

「柔和」不是「模糊一点」,而是「只能取这几个值、且互相整除」。unit = 2 是所有阶梯的粒度:

量规范形态为什么必须整数
圆角半径阶梯 [0, 4, 8, 12](px)圆角不超块短边 1/2,否则画不出圆角矩形
柔边宽阶梯 [2, 4, 6](px)柔边宽是 unit 的倍数,降采样后才不落半像素
过渡带数[2, 3, 4](整数)柔边宽必须被带数整除,每带等宽整数像素
模糊半径阶梯 [4, 8, 12](px)模糊是 unit 的倍数,半径不同则柔度不同
描边宽≤ 2(px)柔和风格禁粗描边;粗黑边是「硬」的信号

最容易被忽略的是过渡带整除:柔边宽 6 px 分 3 带,每带 2 px,是整数;柔边宽 5 px 分 2 带,每带 2.5 px,屏幕上就会有的带 3 像素、有的带 2 像素,同一条柔边深浅不匀。规范因此写成 softEdge % bands == 0,而不是「带数尽量小」。

S 层:圆角与柔边规范账

下面这段是 S 层脚本。它读本章的 config/st12_rounded.json(五类阶梯与待检资产块表)与 config/sp16_themes.json(取 cozy-clean 实例),对每块资产跑六类整数检查,并依同一份 spec 用 PIL 真生成圆角样本 PNG:

{
  "unit": 2,
  "radii": [0, 4, 8, 12],
  "softEdges": [2, 4, 6],
  "blurRadii": [4, 8, 12],
  "strokeMax": 2
}
#!/usr/bin/env python3
# file: sim/st12_rounded.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st12_rounded.py
"""清新/治愈:圆角与柔和过渡的整数规范账(第 12 章 12.2,S 层)。

读 config/st12_rounded.json(本章整数规范)+ config/sp16_themes.json(取 cozy-clean 实例)。
五类整数检查:**圆角阶梯**(圆角在阶梯内且不超块短边 1/2)、**柔边阶梯**(柔边宽取阶梯值)、
**过渡带整除**(柔边宽必须被过渡带数整除,每带等宽整数像素)、**模糊阶梯**(模糊半径取阶梯值)、
**描边上限**(柔和风格 strokeWidth 不超 strokeMax,禁粗描边)。并依同一份 spec 用 PIL 真生成
圆角样本 PNG。落 out/st12_rounded.json 供 P 层对照。全部整数类,逐位一致。
"""
import csv
import hashlib
import json
import os
import sys

from PIL import Image, ImageDraw

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GEN = os.path.join(ROOT, "out", "gen")


def audit_block(b, cfg):
    errs = []
    if b["radius"] not in cfg["radii"]:
        errs.append("E_RADIUS_LADDER")
    if b["radius"] > min(b["w"], b["h"]) // 2:
        errs.append("E_RADIUS_CAP")
    if b["softEdge"] not in cfg["softEdges"]:
        errs.append("E_SOFT_EDGE_LADDER")
    if b["softEdge"] % b["bands"] != 0:
        errs.append("E_BAND_STEP")
    if b["blur"] not in cfg["blurRadii"]:
        errs.append("E_BLUR_LADDER")
    if b["stroke"] > cfg["strokeMax"]:
        errs.append("E_STROKE_HARD")
    return errs


def gen_rounded_sample(tokens, radii, path):
    """圆角样本:同一画布并列四种允许圆角,直观对照弧度阶梯(像素级,非笔触)。"""
    cell, gap = 40, 8
    w = len(radii) * cell + (len(radii) + 1) * gap
    im = Image.new("RGB", (w, cell + 2 * gap), tuple(tokens["bg"]))
    d = ImageDraw.Draw(im)
    for i, r in enumerate(radii):
        x = gap + i * (cell + gap)
        d.rounded_rectangle([x, gap, x + cell - 1, gap + cell - 1], radius=r,
                            fill=tuple(tokens["surface"]), outline=tuple(tokens["accent"]), width=1)
    im.save(path)


def main() -> int:
    cfg = json.load(open(os.path.join(ROOT, "config/st12_rounded.json"), encoding="utf-8"))
    themes = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
    th = next(t for t in themes["themes"] if t["id"] == cfg["themeId"])
    tokens = th["tokens"]
    violations, rows = [], []
    for b in cfg["blocks"]:
        errs = audit_block(b, cfg)
        violations += [{"id": b["id"], "code": c} for c in errs]
        rows.append({"id": b["id"], "kind": b["kind"], "radius": b["radius"], "softEdge": b["softEdge"],
                     "bands": b["bands"], "blur": b["blur"], "stroke": b["stroke"], "ok": not errs})
    counts = {}
    for v in violations:
        counts[v["code"]] = counts.get(v["code"], 0) + 1
    band_steps = sorted({b["softEdge"] // b["bands"] for b in cfg["blocks"]
                         if b["softEdge"] % b["bands"] == 0})
    canon = json.dumps({"blockCount": len(cfg["blocks"]), "violationCount": len(violations),
                        "radiusLadder": cfg["radii"], "softEdges": cfg["softEdges"],
                        "strokePermille": th["strokePermille"]},
                       separators=(",", ":"), ensure_ascii=False)
    summary = {"project": cfg["project"], "themeId": cfg["themeId"], "unit": cfg["unit"],
               "radii": cfg["radii"], "softEdges": cfg["softEdges"], "blurRadii": cfg["blurRadii"],
               "strokeMax": cfg["strokeMax"], "strokePermille": th["strokePermille"],
               "detailPermille": th["detailPermille"], "blockCount": len(cfg["blocks"]),
               "bandSteps": band_steps, "violationCount": len(violations), "counts": counts,
               "violations": violations, "rows": rows, "canonical": canon,
               "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}
    os.makedirs(GEN, exist_ok=True)
    json.dump(summary, open(os.path.join(ROOT, "out/st12_rounded.json"), "w", encoding="utf-8"),
              ensure_ascii=False, indent=2, sort_keys=True)
    with open(os.path.join(ROOT, "out/st12_rounded.csv"), "w", encoding="utf-8", newline="") as f:
        wr = csv.writer(f)
        wr.writerow(["id", "kind", "radius", "softEdge", "bands", "blur", "stroke", "ok"])
        for r in rows:
            wr.writerow([r["id"], r["kind"], r["radius"], r["softEdge"], r["bands"], r["blur"],
                         r["stroke"], str(r["ok"]).lower()])
    gen_rounded_sample(tokens, cfg["radii"], os.path.join(GEN, "st12_rounded_sample.png"))
    print(f"radii={cfg['radii']} softEdges={cfg['softEdges']} blurRadii={cfg['blurRadii']} strokeMax={cfg['strokeMax']}")
    print(f"strokePermille={th['strokePermille']} detailPermille={th['detailPermille']} bandSteps={band_steps}")
    print(f"blocks={len(cfg['blocks'])} violations={len(violations)} counts={counts}")
    for v in violations:
        print(f"  {v['id']:16s} {v['code']}")
    print(f"digest={summary['digest']}")
    print("wrote out/st12_rounded.json out/st12_rounded.csv out/gen/st12_rounded_sample.png")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/st12_rounded.py:

radii=[0, 4, 8, 12] softEdges=[2, 4, 6] blurRadii=[4, 8, 12] strokeMax=2
strokePermille=150 detailPermille=260 bandSteps=[1, 2]
blocks=6 violations=7 counts={'E_RADIUS_LADDER': 2, 'E_RADIUS_CAP': 1, 'E_SOFT_EDGE_LADDER': 1, 'E_BAND_STEP': 1, 'E_BLUR_LADDER': 1, 'E_STROKE_HARD': 1}
  icon_badge       E_RADIUS_LADDER
  icon_badge       E_RADIUS_CAP
  card_quest       E_RADIUS_LADDER
  card_quest       E_SOFT_EDGE_LADDER
  card_quest       E_BAND_STEP
  panel_dialog     E_BLUR_LADDER
  panel_dialog     E_STROKE_HARD
digest=7af0749ce0829e18
wrote out/st12_rounded.json out/st12_rounded.csv out/gen/st12_rounded_sample.png

读出来:cozy-clean 的风格参数是 strokePermille=150、detailPermille=260(低描边、低细节,与「干净」一致);六块资产里三块合规、三块各踩雷。7 条违例是故意留的,它们证明这道闸门真的会拦东西:

资产违例含义
icon_badgeE_RADIUS_LADDER + E_RADIUS_CAP圆角 16 不在阶梯 [0,4,8,12] 内,且超过 24 短边的一半 12
card_questE_RADIUS_LADDER + E_SOFT_EDGE_LADDER + E_BAND_STEP圆角 10 不在阶梯;柔边 5 不在阶梯;5 不能被 2 整除
panel_dialogE_BLUR_LADDER + E_STROKE_HARD模糊 6 不在阶梯 [4,8,12];描边 4 超过上限 2

E_BAND_STEP 是本节最容易被忽略的一条:它不报「柔边不是整数」,而是报「柔边宽不能被带数整除」——整数在分成若干带之后不一定还是整数。合规块里 softEdge=4, bands=2 与 softEdge=6, bands=3 都给出整数步长,bandSteps=[1, 2] 正是这些合法步长的集合。

P 层:同一套判据的 TS 侧

同一套判据,TypeScript 侧独立实现一遍 core/src/st12/rounded.ts。它只读同一份配置,不 import Python 的任何东西:

// file: core/src/st12/rounded.ts
// tier: P
// verify: node --test 'core/test/st12_*.test.ts'
// 圆角与柔和过渡规范的 TS 侧校验器(第 12 章 12.2)。与 sim/st12_rounded.py 共用
// config/st12_rounded.json 与 config/sp16_themes.json;判据全是整数(圆角、柔边宽、过渡带数、
// 模糊半径、描边宽、千分比),故两侧违例集合与摘要逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type Theme = { id: string; tokens: Record<string, number[]>; strokePermille: number; detailPermille: number };
export type Block = { id: string; kind: string; w: number; h: number; radius: number;
  softEdge: number; bands: number; blur: number; stroke: number };
export type RoundedCfg = { unit: number; radii: number[]; softEdges: number[]; blurRadii: number[];
  strokeMax: number; blocks: Block[] };
export type Violation = { id: string; code: string };
export type RoundedReport = { unit: number; radii: number[]; softEdges: number[]; blurRadii: number[];
  strokeMax: number; strokePermille: number; detailPermille: number; blockCount: number;
  bandSteps: number[]; violationCount: number; counts: Record<string, number>;
  violations: Violation[]; canonical: string; digest: string };

export function auditBlock(b: Block, cfg: RoundedCfg): string[] {
  const errs: string[] = [];
  if (!cfg.radii.includes(b.radius)) errs.push("E_RADIUS_LADDER");
  if (b.radius > Math.floor(Math.min(b.w, b.h) / 2)) errs.push("E_RADIUS_CAP");
  if (!cfg.softEdges.includes(b.softEdge)) errs.push("E_SOFT_EDGE_LADDER");
  if (b.softEdge % b.bands !== 0) errs.push("E_BAND_STEP");
  if (!cfg.blurRadii.includes(b.blur)) errs.push("E_BLUR_LADDER");
  if (b.stroke > cfg.strokeMax) errs.push("E_STROKE_HARD");
  return errs;
}

export function auditRounded(cfg: RoundedCfg, themes: { themes: Theme[] }): RoundedReport {
  const th = themes.themes.find((t) => t.id === "cozy-clean");
  if (!th) throw new Error("themes 缺 cozy-clean 实例");
  const violations: Violation[] = [];
  for (const b of cfg.blocks) for (const c of auditBlock(b, cfg)) violations.push({ id: b.id, code: c });
  const counts: Record<string, number> = {};
  for (const v of violations) counts[v.code] = (counts[v.code] ?? 0) + 1;
  const bandSteps = [...new Set(cfg.blocks.filter((b) => b.softEdge % b.bands === 0)
    .map((b) => Math.floor(b.softEdge / b.bands)))].sort((a, b) => a - b);
  const report: RoundedReport = {
    unit: cfg.unit, radii: cfg.radii, softEdges: cfg.softEdges, blurRadii: cfg.blurRadii,
    strokeMax: cfg.strokeMax, strokePermille: th.strokePermille, detailPermille: th.detailPermille,
    blockCount: cfg.blocks.length, bandSteps, violationCount: violations.length, counts, violations,
    canonical: "", digest: "",
  };
  report.canonical = canonical(report);
  return report;
}

/** canonical 摘要:键序固定,与 Python 侧同序、无空白,供 sha256 对拍。 */
export function canonical(r: RoundedReport): string {
  return JSON.stringify({ blockCount: r.blockCount, violationCount: r.violationCount,
    radiusLadder: r.radii, softEdges: r.softEdges, strokePermille: r.strokePermille });
}

node --test 'core/test/st12_*.test.ts'(本节 4 条,三节合计 12 条)真实输出:

rounded digest = 7af0749ce0829e18
✔ 正例:圆角/柔边/模糊取阶梯值、过渡带整除、描边不超上限、风格参数正确 (0.828375ms)
✔ 跨语言:违例集合、计数与摘要逐位一致(整数类) (0.795875ms)
✔ 负例:圆角越界/不在阶梯、柔边不在阶梯、过渡带不整除、模糊不在阶梯、粗描边各自可触发 (0.134833ms)
✔ 地板除锁死:过渡带步长必须整数,短边取半用整数地板除 (0.078542ms)
ℹ tests 12
ℹ pass 12
ℹ fail 0

两条跨语言断言把 TS 复算的违例集合、计数与 canonical 摘要逐位对上 Python 落盘的 out/st12_rounded.json,sha256 前 16 位都是 7af0749ce0829e18——圆角、柔边、带数、模糊、描边全是整数类,逐位一致。第三条是负例:五类违例各自可被单独触发;第四条验证 Math.floor 与 Python // 在「短边取半」上逐位一致(25 短边的块圆角 12 放行、13 被拦)。

自检表

检查项合格标准
圆角受约束圆角在阶梯内、不超块短边 1/2
柔边取阶梯柔边宽是 unit 的倍数,取阶梯值
过渡带整除softEdge % bands == 0,每带等宽整数像素
描边不硬柔和风格 strokeWidth ≤ 2,超则判 E_STROKE_HARD
账可复算违例集合与摘要跨语言逐位一致
边界清晰真实抗锯齿与模糊观感标 X,不写成「实测」

颜色与形状都定好之后,真正的敌人是时间:同一份规范铺到几十个场景,总会有几个场景的 token 悄悄偏了一两个通道。下一节把「一致性维护」也变成一笔可断言的整数账。

阅读导航:上一节:12.1 高明度低饱和调色板 · 下一节:12.3 一致性维护 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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