3.1 扁平色块与线宽规范

2D 手绘最容易失控的不是画得漂不漂亮,而是线宽、圆角与色块数各画各的。本节把这三样写成可校验的整数规范:线宽阶梯、圆角上限、每类资产的色块数上限,并用同一份规范真生成色卡与线宽样本 PNG;再用 TypeScript 校验器与 Python 落盘的 JSON 对拍,证明两侧的整数类违例集合与摘要逐位一致。

3.1 扁平色块与线宽规范

站内 游戏美术风格指南 用提纲把「风格要统一、色块要干净」讲成了方向。本节不重复那套口号,而是把它压成三个可校验的整数:线多宽、圆角多大、一块资产最多几个色块。上一章 2.3 像素 tile 与 tile 预算 算的是像素侧的整数账;本章换到 2D 手绘与矢量,对应主题包 handdrawn(strokePermille=480、detailPermille=520,见 15.1 规范实例化:6 个主题包 )。色彩 token 的写法在卷一 3.2 色彩 token 系统 ,图标网格与描边的通则在卷一 10.1 图标网格与描边规则 ;本节只做手绘侧的整数规范与账,不评价「画得好不好」——那需要真实画师与真实环境,命令行不可复现(X)。

线宽、圆角、色块数:三个必须整数的量

手绘资产失控的头号原因不是画得难看,而是同一个界面里三条线三种粗细、四个圆角四种弧度、一块图标用了七个色。这三个量都能写成整数,也都能被脚本拦下:

量规范形态为什么必须整数
线宽阶梯 [2, 4, 6, 8](px)线宽 × 倍率必须仍是整数像素,否则降采样落半像素
圆角半径阶梯 [0, 4, 8](px),是 unit 的倍数圆角超过块短边 1/2 会画不出、也不可拼
色块数按资产类别分级的整数上限色块越多越难复用、越难批量改色

三者的共同点是整数 + 阶梯:不是「尽量细一点」,而是「只能取这几个值」。阶梯一旦写死,跨资产的一致性就不再靠记忆。

为什么线宽必须是 unit 的倍数

本套 unit = 2。线宽在倍率 [1, 2, 3] 下逐个相乘都还是整数(2→[2,4,6]、4→[4,8,12]…),但真正的约束来自降采样:手绘资产常要按 0.5 倍放进低分辨率包,此时线宽 3 会变成 1.5 像素——屏幕上要么被抗锯齿糊成两层灰,要么在整数像素画布里被四舍五入成 2 或 1,同一条线在不同位置粗细不一致。所以规范不是「线宽是整数」,而是线宽是 unit 的倍数:3 被 E_HALF_PIXEL 拦下,6 放行。

unit 同时是圆角的粒度:圆角必须是 unit 的倍数,且不超过块短边的 1/2(radius > min(w,h)//2 判 E_RADIUS_CAP)——一个 24×24 的图标圆角最多 12,超过就画不成圆角矩形。

S 层:整数规范账

下面这段是 S 层脚本。它读本章的 config/st03_flat.json(线宽阶梯、圆角阶梯、blockCaps、待检的资产块表,以及 handdrawn 的 8 个 token)、config/sp16_themes.json 与 config/visual_spec.json,对每个资产块跑五类整数检查,并依同一份 spec 用 PIL 真生成两张 PNG(色卡 + 线宽样本):

#!/usr/bin/env python3
# file: sim/st03_flat.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st03_flat.py
"""2D 手绘的扁平色块与线宽账(第 3 章 3.1,S 层)。

读 config/st03_flat.json(本章整数规范)+ config/sp16_themes.json(取 handdrawn 实例)+
config/visual_spec.json(容差与对比度下限)。三类整数检查:**线宽阶梯**(每个允许线宽在倍率
1/2/3 下仍是整数像素;因有 0.5 倍降采样,线宽须是 unit 的倍数,否则落到半像素)、**圆角与色块
上限**(圆角在阶梯内、是 unit 的倍数、不超短边 1/2;色块数不超 blockCaps)、**一处生成**(依
同一份 spec 用 PIL 生成色卡与线宽样本 PNG)。落 out/st03_flat.json 供 P 层对照。
真实手绘笔触与画质需真实环境,命令行不可复现(X)。
"""
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")
SRGB = (0.04045, 12.92, 0.055, 1.055, 2.4)
LUMA = (0.2126, 0.7152, 0.0722)


def srgb_to_linear(c: int) -> float:
    s = c / 255
    return s / SRGB[1] if s <= SRGB[0] else ((s + SRGB[2]) / SRGB[3]) ** SRGB[4]


def contrast10(a, b) -> int:
    f = lambda c: sum(LUMA[i] * srgb_to_linear(c[i]) for i in range(3))
    hi, lo = sorted((f(a), f(b)), reverse=True)
    return round(((hi + 0.05) / (lo + 0.05)) * 10)


def gen_palette_card(tokens, path):
    cols, cell = list(tokens.values()), 24
    im = Image.new("RGB", (cell * len(cols), 48))
    d = ImageDraw.Draw(im)
    for i, c in enumerate(cols):
        d.rectangle([i * cell, 0, (i + 1) * cell - 1, 47], fill=tuple(c))
    im.save(path)


def gen_linewidth_sample(tokens, widths, path):
    """线宽样本:同一画布并列四种允许线宽,直观对照粗细阶梯(像素级,非笔触)。"""
    im = Image.new("RGB", (192, 64), tuple(tokens["bg"]))
    d, y = ImageDraw.Draw(im), 6
    for w in widths:
        d.rectangle([16, y, 176, y + w - 1], fill=tuple(tokens["ink"]))
        y += w + 6
    im.save(path)


def audit_block(b, cfg):
    errs = []
    if b["lineWidth"] not in cfg["lineWidths"]:
        errs.append("E_WIDTH_LADDER")
    if b["lineWidth"] % cfg["unit"] != 0:
        errs.append("E_HALF_PIXEL")
    if b["radius"] not in cfg["radii"] or b["radius"] % cfg["unit"] != 0:
        errs.append("E_RADIUS_LADDER")
    if b["radius"] > min(b["w"], b["h"]) // 2:
        errs.append("E_RADIUS_CAP")
    if b["colorBlocks"] > cfg["blockCaps"][b["kind"]]:
        errs.append("E_BLOCK_CAP")
    return errs


def main() -> int:
    cfg = json.load(open(os.path.join(ROOT, "config/st03_flat.json"), encoding="utf-8"))
    spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
    themes = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
    hand = next(t for t in themes["themes"] if t["id"] == cfg["themeId"])
    tokens = hand["tokens"]
    min_c10 = min(contrast10(tokens[a], tokens[b]) for a, b in spec["contrastPairs"])
    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"], "lineWidth": b["lineWidth"],
                     "radius": b["radius"], "colorBlocks": b["colorBlocks"],
                     "cap": cfg["blockCaps"][b["kind"]], "ok": not errs})
    total_blocks = sum(b["colorBlocks"] for b in cfg["blocks"])
    total_cap = sum(cfg["blockCaps"][b["kind"]] for b in cfg["blocks"])
    counts = {}
    for v in violations:
        counts[v["code"]] = counts.get(v["code"], 0) + 1
    canon = json.dumps({"tokenCount": len(tokens), "minContrast10": min_c10,
                        "blockCount": len(cfg["blocks"]), "violationCount": len(violations),
                        "totalColorBlocks": total_blocks,
                        "capUsePermille": total_blocks * 1000 // total_cap},
                       separators=(",", ":"), ensure_ascii=False)
    summary = {"project": cfg["project"], "themeId": cfg["themeId"], "unit": cfg["unit"],
               "tokenMatch": tokens == cfg["tokens"], "tokenCount": len(tokens), "minContrast10": min_c10,
               "contrastFloor10": spec["contrastFloor10"], "contrastPass": min_c10 >= spec["contrastFloor10"],
               "lineWidths": cfg["lineWidths"], "scales": cfg["scales"],
               "widthLadder": [[w * s for s in cfg["scales"]] for w in cfg["lineWidths"]],
               "halfScaleIntegerWidths": [w for w in cfg["lineWidths"] if w % cfg["unit"] == 0],
               "strokePermille": hand["strokePermille"], "detailPermille": hand["detailPermille"],
               "blockCount": len(cfg["blocks"]), "totalColorBlocks": total_blocks, "totalCap": total_cap,
               "capUsePermille": total_blocks * 1000 // total_cap, "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/st03_flat.json"), "w", encoding="utf-8"),
              ensure_ascii=False, indent=2, sort_keys=True)
    with open(os.path.join(ROOT, "out/st03_flat.csv"), "w", encoding="utf-8", newline="") as f:
        wr = csv.writer(f)
        wr.writerow(["id", "kind", "lineWidth", "radius", "colorBlocks", "cap", "ok"])
        for r in rows:
            wr.writerow([r["id"], r["kind"], r["lineWidth"], r["radius"], r["colorBlocks"],
                         r["cap"], str(r["ok"]).lower()])
    gen_palette_card(tokens, os.path.join(GEN, "st03_palette_card.png"))
    gen_linewidth_sample(tokens, cfg["lineWidths"], os.path.join(GEN, "st03_linewidth_sample.png"))
    print(f"tokenMatch={summary['tokenMatch']} tokenCount={len(tokens)} minContrast10={min_c10} "
          f"contrastPass={summary['contrastPass']}")
    print(f"widthLadder={summary['widthLadder']} halfScaleIntegerWidths={summary['halfScaleIntegerWidths']}")
    print(f"strokePermille={hand['strokePermille']} detailPermille={hand['detailPermille']}")
    print(f"blocks={len(cfg['blocks'])} totalColorBlocks={total_blocks} totalCap={total_cap} "
          f"capUsePermille={summary['capUsePermille']}")
    print(f"violations={len(violations)} counts={counts}")
    for v in violations:
        print(f"  {v['id']:22s} {v['code']}")
    print(f"digest={summary['digest']}")
    print("wrote out/st03_flat.json out/st03_flat.csv out/gen/st03_*.png")
    return 0


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

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

tokenMatch=True tokenCount=8 minContrast10=46 contrastPass=True
widthLadder=[[2, 4, 6], [4, 8, 12], [6, 12, 18], [8, 16, 24]] halfScaleIntegerWidths=[2, 4, 6, 8]
strokePermille=480 detailPermille=520
blocks=7 totalColorBlocks=36 totalCap=41 capUsePermille=878
violations=3 counts={'E_BLOCK_CAP': 1, 'E_WIDTH_LADDER': 1, 'E_HALF_PIXEL': 1}
  ui_panel-frame         E_BLOCK_CAP
  deco_vine              E_WIDTH_LADDER
  deco_vine              E_HALF_PIXEL
digest=7a309c5c652409ce
wrote out/st03_flat.json out/st03_flat.csv out/gen/st03_*.png

读出来:handdrawn 的 8 个 token 与主题包逐位相同(tokenMatch=True),5 对对比度最小 minContrast10=46 ≥ 45(下限来自 visual_spec.json);线宽阶梯 [[2,4,6],[4,8,12],[6,12,18],[8,16,24]] 全整数,halfScaleIntegerWidths 是全部四个;7 块资产共 36 个色块、上限合计 41,capUsePermille=878。3 条违例是故意留的,它们证明这道闸门真的会拦东西:

资产违例含义
ui_panel-frameE_BLOCK_CAP6 个色块超过 UI 类上限 5
deco_vineE_WIDTH_LADDER线宽 3 不在阶梯 [2,4,6,8] 内
deco_vineE_HALF_PIXEL线宽 3 不是 unit=2 的倍数,0.5 倍落到 1.5 像素

E_HALF_PIXEL 是本节最容易被忽略的一条:它不报「线宽不是整数」,而是报「线宽不是 unit 的倍数」——整数在降采样后不一定还是整数。

P 层:同一套判据的 TS 侧

同一套判据,TypeScript 侧独立实现一遍 core/src/st03/flat.ts。它只读同一份配置,对比度复用 sp00/spec.ts 的同源常数,不 import Python 的任何东西:

// file: core/src/st03/flat.ts
// tier: P
// verify: node --test 'core/test/st03_*.test.ts'
// 扁平色块与线宽规范的 TS 侧校验器(第 3 章 3.1)。与 sim/st03_flat.py 共用 config/st03_flat.json、
// config/visual_spec.json、config/sp16_themes.json;判据全是字符串与整数(线宽、圆角、色块数、
// 对比度 ×10、千分比),故两侧违例集合与摘要逐位一致。对比度复用 sp00/spec.ts 的同源常数。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

import { contrast10 } from "../sp00/spec.ts";

export type RGB = number[];
export type Tokens = Record<string, RGB>;
export type Block = { id: string; kind: string; w: number; h: number;
  lineWidth: number; radius: number; colorBlocks: number };
export type FlatCfg = { unit: number; lineWidths: number[]; scales: number[]; radii: number[];
  blockCaps: Record<string, number>; blocks: Block[]; tokens: Tokens };
export type Theme = { id: string; tokens: Tokens; strokePermille: number; detailPermille: number };
export type Spec = { contrastPairs: string[][]; contrastFloor10: number };
export type Violation = { id: string; code: string };
export type FlatReport = { tokenMatch: boolean; tokenCount: number; minContrast10: number;
  contrastFloor10: number; contrastPass: boolean; lineWidths: number[]; widthLadder: number[][];
  halfScaleIntegerWidths: number[]; strokePermille: number; detailPermille: number; blockCount: number;
  totalColorBlocks: number; totalCap: number; capUsePermille: number; violationCount: number;
  counts: Record<string, number>; violations: Violation[]; canonical: string; digest: string };

/** 千分比:整数地板除,禁用浮点。 */
export function permille(part: number, total: number): number {
  return Math.floor((part * 1000) / total);
}

export function tokensEqual(a: Tokens, b: Tokens): boolean {
  const ka = Object.keys(a);
  if (ka.length !== Object.keys(b).length) return false;
  return ka.every((k) => k in b && a[k].length === b[k].length && a[k].every((v, i) => v === b[k][i]));
}

export function auditBlock(b: Block, cfg: FlatCfg): string[] {
  const errs: string[] = [];
  if (!cfg.lineWidths.includes(b.lineWidth)) errs.push("E_WIDTH_LADDER");
  if (b.lineWidth % cfg.unit !== 0) errs.push("E_HALF_PIXEL");
  if (!cfg.radii.includes(b.radius) || b.radius % cfg.unit !== 0) errs.push("E_RADIUS_LADDER");
  if (b.radius > Math.floor(Math.min(b.w, b.h) / 2)) errs.push("E_RADIUS_CAP");
  if (b.colorBlocks > cfg.blockCaps[b.kind]) errs.push("E_BLOCK_CAP");
  return errs;
}

export function auditFlat(cfg: FlatCfg, spec: Spec, themes: { themes: Theme[] }): FlatReport {
  const hand = themes.themes.find((t) => t.id === "handdrawn");
  if (!hand) throw new Error("themes 缺 handdrawn 实例");
  const tokens = hand.tokens;
  const minContrast10 = Math.min(...spec.contrastPairs.map(([a, b]) => contrast10(tokens[a], tokens[b])));
  const violations: Violation[] = [];
  for (const b of cfg.blocks) for (const c of auditBlock(b, cfg)) violations.push({ id: b.id, code: c });
  const totalColorBlocks = cfg.blocks.reduce((s, b) => s + b.colorBlocks, 0);
  const totalCap = cfg.blocks.reduce((s, b) => s + cfg.blockCaps[b.kind], 0);
  const counts: Record<string, number> = {};
  for (const v of violations) counts[v.code] = (counts[v.code] ?? 0) + 1;
  const report: FlatReport = {
    tokenMatch: tokensEqual(tokens, cfg.tokens), tokenCount: Object.keys(tokens).length, minContrast10,
    contrastFloor10: spec.contrastFloor10, contrastPass: minContrast10 >= spec.contrastFloor10,
    lineWidths: cfg.lineWidths, widthLadder: cfg.lineWidths.map((w) => cfg.scales.map((s) => w * s)),
    halfScaleIntegerWidths: cfg.lineWidths.filter((w) => w % cfg.unit === 0),
    strokePermille: hand.strokePermille, detailPermille: hand.detailPermille, blockCount: cfg.blocks.length,
    totalColorBlocks, totalCap, capUsePermille: permille(totalColorBlocks, totalCap),
    violationCount: violations.length, counts, violations, canonical: "", digest: "",
  };
  report.canonical = canonical(report);
  return report;
}

/** canonical 摘要:键序固定,与 Python 侧同序、无空白,供 sha256 对拍。 */
export function canonical(r: FlatReport): string {
  return JSON.stringify({ tokenCount: r.tokenCount, minContrast10: r.minContrast10,
    blockCount: r.blockCount, violationCount: r.violationCount,
    totalColorBlocks: r.totalColorBlocks, capUsePermille: r.capUsePermille });
}

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

flat digest = 7a309c5c652409ce
✔ 正例:token 与 handdrawn 主题一致、对比度达下限、线宽在倍率下为整数 (0.849709ms)
✔ 跨语言:违例集合、计数与摘要逐位一致(整数类) (0.729166ms)
✔ 负例:半像素、超色块上限、圆角越界、圆角不在阶梯,四类各自可被单独触发 (0.122416ms)
✔ 地板除锁死:千分比不得用浮点,token 相等判据逐通道比较 (0.087167ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0

两条跨语言断言把 TS 复算的违例集合(id:code)、计数与 canonical 摘要逐位对上 Python 落盘的 out/st03_flat.json,sha256 前 16 位都是 7a309c5c652409ce——线宽、圆角、色块数、千分比全是整数类,逐位一致。第三条是负例:lineWidth=3、色块超上限、圆角越界、圆角不在阶梯,四类各自可被单独触发;任何一类从「拦下」变成「放行」,这套规范就已经不可信。

自检表

检查项合格标准
线宽可降采样每个允许线宽都是 unit 的倍数,0.5 倍后仍是整数像素
圆角受约束圆角在阶梯内、是 unit 的倍数、不超块短边 1/2
色块有上限每类资产色块数不超 blockCaps,超则判 E_BLOCK_CAP
账可复算违例集合与摘要跨语言逐位一致
边界清晰真实笔触与画质标 X,不写成「实测」

线宽与色块是同一份规范的两次消费:S 层用 Python 算账、P 层用 TS 判。下一节把「色块」再往下拆一层——一件手绘资产由哪些切分件拼成,切分件怎么在资产之间复用,以及「拆了却没人用」的切分过度怎么用整数账抓出来。

阅读导航:上一节:2.3 像素 tile 与 tile 预算 · 下一节:3.2 手绘资产的切分与复用 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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