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-frame | E_BLOCK_CAP | 6 个色块超过 UI 类上限 5 |
deco_vine | E_WIDTH_LADDER | 线宽 3 不在阶梯 [2,4,6,8] 内 |
deco_vine | E_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 手绘资产的切分与复用 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。