9.3 等距资产的制作约束
9.1 定了投影,9.2 定了遮挡,剩下最后一笔账:等距资产本身该画多大。这件事在别的风格里很自由——像素画一个角色 24×32 还是 32×40 都行;在等距里却不行,因为资产的底菱必须和 9.1 的 tile 网格严丝合缝,差 1 像素就会在板上「浮起来」或者「陷下去」,而且每块地板都要错位。
约束一共 6 条,全是整数判据,逐条对应一个会真实发生的错误:
| 判据 | 规则 | 违反的后果 |
|---|---|---|
| C1 半行对齐 | heightPx % (tileH // 2) == 0 | 高度不是 8 的倍数,资产在垂直方向上无法叠进半行网格 |
| C2 宽度偶数 | widthPx % 2 == 0 | 底菱中心在 tileW // 2 = 16,宽度奇数就无法整数居中,永远差半像素 |
| C3 锚点 | (widthPx // 2, heightPx - tileH // 2) | 锚点不在底菱中心,放置时资产相对格点整体偏移 |
| C4 半行数 | halfRows = heightPx // (tileH // 2) | 记不出资产占几个半行,9.2 的遮挡键无从对齐 |
| C5 图集格内 | widthPx ≤ atlasCell 且 heightPx ≤ atlasCell | 单件超出图集格,打图集时被裁 |
| C6 宽度上限 | widthPx ≤ maxWidthTiles × tileW | 资产横跨超过声明的地块数,摆放时压到邻居格 |
C4 的 halfRows 与 9.2 的 blockHeight 是同一把尺子:heightStepPx = tileH // 2 = 8,所以高 2 的墙(blockHeight: 2)正好是 2 个半行、16 像素。两个数字对不上,就说明美术交付的图和玩法配置里的高度不是一套口径。
S 层:C1–C6 整数判据(Python 整数账)
脚本读 config/st09_iso.json 的 assets 与 budget,对 6 件资产逐件跑 C1–C6,输出记录(半行数、锚点、占地千分比)与违例列表,再把记录与违例串成 canonical 串取 sha256。
#!/usr/bin/env python3
# file: sim/st09_asset_constraint.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st09_asset_constraint.py
"""卷三 9.3 等距资产的制作约束(S 层)。
读 config/st09_iso.json 的 assets 与 budget,对每件等距资产跑一组**整数**约束:
C1 半行对齐 heightPx % (tileH // 2) == 0 高度必须是 8 的整数倍,才能叠在半行网格上;
C2 宽度偶数 widthPx % 2 == 0 底菱中心在 tileW // 2 = 16 处,宽度须偶数才能整数居中;
C3 锚点 anchor = (widthPx // 2, heightPx - tileH // 2) 底菱中心在图像内的整数坐标;
C4 半行数 halfRows = heightPx // (tileH // 2) 资产跨几个半行 = 9.2 的遮挡键跨度;
C5 图集格内 widthPx ≤ cell 且 heightPx ≤ cell 单格装得下;
C6 宽度上限 widthPx ≤ maxWidthTiles × tileW 不横跨超过声明的地块数。
约束全是几何,与主题正交:两套主题跑出的几何 digest 逐位相同,只有调色板串不同。
落 out/st09_asset_constraint.json + .svg,供 P 层 TS 侧对照。
"""
import hashlib
import json
import os
import sys
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
CFG = os.path.join(ROOT, "config/st09_iso.json")
OUT = os.path.join(ROOT, "out")
def check_asset(a, iso, budget):
"""跑 C1–C6,返回 (记录, 违例列表)。全部整数。"""
step = iso["heightStepPx"]
cell = budget["atlasCell"]
max_w = budget["maxWidthTiles"] * iso["tileW"]
w, h = a["widthPx"], a["heightPx"]
half_rows = h // step
rec = {"id": a["id"], "widthPx": w, "heightPx": h, "token": a["token"],
"halfRows": half_rows, "anchorX": w // 2, "anchorY": h - iso["tileH"] // 2,
"footprintPermille": w * h * 1000 // (cell * cell)}
viol = []
if h % step != 0:
viol.append(f"ALIGN:{a['id']}:{h}%{step}={h % step}")
if w % 2 != 0:
viol.append(f"EVEN:{a['id']}:{w}")
if w > cell or h > cell:
viol.append(f"CELL:{a['id']}:{w}x{h}>{cell}")
if w > max_w:
viol.append(f"WIDTH:{a['id']}:{w}>{max_w}")
if half_rows > budget["maxHalfRows"]:
viol.append(f"HALFROWS:{a['id']}:{half_rows}>{budget['maxHalfRows']}")
return rec, viol
def main():
cfg = json.load(open(CFG, encoding="utf-8"))
iso, budget = cfg["iso"], cfg["budget"]
recs, violations = [], []
for a in cfg["assets"]:
rec, viol = check_asset(a, iso, budget)
recs.append(rec)
violations += viol
cell = budget["atlasCell"]
total_px = sum(r["widthPx"] * r["heightPx"] for r in recs)
canon = ";".join(f"{r['id']}:{r['widthPx']},{r['heightPx']},{r['halfRows']},"
f"{r['anchorX']},{r['anchorY']}" for r in recs)
canon += "|viol:" + ",".join(sorted(violations))
digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]
palettes = {}
for t in cfg["themes"]:
parts = []
for a in cfg["assets"]:
rgb = t["tokens"][a["token"]]
parts.append(f"{a['id']}:{rgb[0]},{rgb[1]},{rgb[2]}")
palettes[t["id"]] = hashlib.sha256(";".join(parts).encode("utf-8")).hexdigest()[:16]
report = {"project": cfg["project"], "tileW": iso["tileW"], "tileH": iso["tileH"],
"heightStepPx": iso["heightStepPx"], "atlasCell": cell,
"maxWidthTiles": budget["maxWidthTiles"], "maxHalfRows": budget["maxHalfRows"],
"assetCount": len(recs), "assets": recs, "violations": sorted(violations),
"passCount": sum(1 for r in recs
if not any(v.split(":")[1] == r["id"] for v in violations)),
"totalFootprintPermille": total_px * 1000 // (cell * cell),
"canonical": canon, "digest": digest,
"themeIds": [t["id"] for t in cfg["themes"]], "paletteDigests": palettes,
"geometryThemeIndependent": len({digest}) == 1}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "st09_asset_constraint.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
write_svg(recs, violations, budget, cfg["themes"][0],
os.path.join(OUT, "st09_asset_constraint.svg"))
print(f"assets={len(recs)} pass={report['passCount']} violations={len(violations)}")
for r in recs:
print(f" {r['id']:<16} {r['widthPx']}x{r['heightPx']} halfRows={r['halfRows']} "
f"anchor=({r['anchorX']},{r['anchorY']}) fp={r['footprintPermille']}‰")
print(f"violations={report['violations']}")
print(f"totalFootprintPermille={report['totalFootprintPermille']} cell={cell}")
print(f"digest={digest} paletteDigests={palettes}")
print("wrote out/st09_asset_constraint.json out/st09_asset_constraint.svg")
return 0
def write_svg(recs, violations, budget, theme, path):
"""每件资产一个矩形(宽=像素宽,高=像素高),违例件描强调色边。"""
bad = {v.split(":")[1] for v in violations}
accent = theme["tokens"]["accent"]
pad, row = 14, 56
W, H = 520, pad + len(recs) * row + 18
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}", "role": "img"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#1f2933"})
cell = budget["atlasCell"]
for i, r in enumerate(recs):
y = pad + i * row
w = r["widthPx"] * 3
h = r["heightPx"] * 1
col = f"rgb({accent[0]},{accent[1]},{accent[2]})" if r["id"] in bad else "#5ac8b4"
ET.SubElement(svg, "rect", {"x": "150", "y": str(y), "width": str(w), "height": str(h),
"fill": "none", "stroke": col, "stroke-width": "2"})
ET.SubElement(svg, "text", {"x": "8", "y": str(y + h // 2 + 4), "fill": "#cbd5e1",
"font-family": "monospace", "font-size": "11"}).text = \
f"{r['id']} {r['widthPx']}x{r['heightPx']}"
ET.SubElement(svg, "text", {"x": str(150 + w + 8), "y": str(y + h // 2 + 4),
"fill": col, "font-family": "monospace",
"font-size": "10"}).text = \
f"halfRows={r['halfRows']} fp={r['footprintPermille']}‰" + (" VIOLATION" if r["id"] in bad else "")
ET.SubElement(svg, "text", {"x": str(pad), "y": str(H - 4), "fill": "#94a3b8",
"font-family": "monospace", "font-size": "10"}).text = \
f"atlas cell={cell} maxWidthTiles={budget['maxWidthTiles']} maxHalfRows={budget['maxHalfRows']}"
tree = ET.ElementTree(svg)
ET.indent(tree, space=" ")
tree.write(path, encoding="utf-8", xml_declaration=True)
if __name__ == "__main__":
sys.exit(main())
assets=6 pass=4 violations=3
iso_floor_tile 32x16 halfRows=2 anchor=(16,8) fp=125‰
iso_rug 32x16 halfRows=2 anchor=(16,8) fp=125‰
iso_counter 32x32 halfRows=4 anchor=(16,24) fp=250‰
iso_shelf 32x40 halfRows=5 anchor=(16,32) fp=312‰
iso_lamp 24x30 halfRows=3 anchor=(12,22) fp=175‰
iso_awning 96x16 halfRows=2 anchor=(48,8) fp=375‰
violations=['ALIGN:iso_lamp:30%8=6', 'CELL:iso_awning:96x16>64', 'WIDTH:iso_awning:96>64']
totalFootprintPermille=1363 cell=64
digest=b3141e27f14fc296 paletteDigests={'cozy-clean': '809b4493b9e13d31', 'voxel': '738a5c48398bf045'}
wrote out/st09_asset_constraint.json out/st09_asset_constraint.svg
逐行读一遍,几个数是有信息量的。iso_floor_tile 与 iso_rug 都是 32×16、halfRows=2、锚点 (16, 8)——底菱中心正好在图像正中,因为地板砖本身就是一整块菱形。iso_counter 高 32、halfRows=4、锚点 (16, 24):锚点从图像底部往上挪了 tileH // 2 = 8 像素,这是 C3 的定义——锚点不是「图像中心」,而是底菱中心,所以高瘦的资产锚点会明显偏下。iso_shelf 高 40、halfRows=5,是本节最高的合规件。
两条违例是故意埋的:iso_lamp 高 30,30 % 8 = 6,C1 直接判 ALIGN 违例——立灯是美术手绘时最容易被「目测对齐」坑到的资产,高度差 6 像素在单件预览里看不出来,铺到板上就是整排灯高矮不齐。iso_awning 宽 96,同时踩了 C5(96 > 64,超图集格)和 C6(96 > 2 × 32,超两格宽),两条违例都指向同一件事:跨三格的雨棚不能用一件资产表达,正确做法是拆成 3 件 32×16 的段,或者把 maxWidthTiles 提到 3 并同步扩图集格。totalFootprintPermille=1363 是 6 件资产的总像素面积占图集格的千分比(约 1.36 格),用来估图集够不够大。
最后一行是本章第三次出现的同一条纪律:digest=b3141e27f14fc296 是几何指纹,与主题无关;两个主题的 paletteDigests(809b4493b9e13d31 / 738a5c48398bf045)不同。约束是几何的,颜色是主题的,两者正交。
P 层:TypeScript 独立复算
P 层用同一组判据独立复算一遍,与 Python 落盘的 JSON 逐位比对。注意 passCount 的算法:按违例串里 : 分隔的第二段(资产 id)反查,有违例的资产不算通过——这样「一件资产多条违例」不会被重复计数。
// file: core/src/st09/constraint.ts
// tier: P
// verify: node --test 'core/test/st09_*.test.ts'
// 9.3 等距资产制作约束内核。C1 半行对齐(heightPx % (tileH//2) == 0)、C2 宽度偶数、
// C3 锚点 (widthPx//2, heightPx - tileH//2)、C4 半行数 heightPx // (tileH//2)、
// C5 图集格内(≤ cell)、C6 宽度上限(≤ maxWidthTiles × tileW)。全整数、无浮点;
// 与 sim/st09_asset_constraint.py 同口径,逐位一致。只允许可擦除 TS 语法。
export type Asset = { id: string; cn: string; widthPx: number; heightPx: number; token: string };
export type AssetRec = {
id: string; widthPx: number; heightPx: number; token: string;
halfRows: number; anchorX: number; anchorY: number; footprintPermille: number;
};
export function checkAsset(a: Asset, cfg: any): { rec: AssetRec; viol: string[] } {
const step = cfg.iso.heightStepPx;
const cell = cfg.budget.atlasCell;
const maxW = cfg.budget.maxWidthTiles * cfg.iso.tileW;
const w = a.widthPx;
const h = a.heightPx;
const halfRows = Math.floor(h / step);
const rec: AssetRec = {
id: a.id, widthPx: w, heightPx: h, token: a.token, halfRows,
anchorX: Math.floor(w / 2), anchorY: h - Math.floor(cfg.iso.tileH / 2),
footprintPermille: Math.floor((w * h * 1000) / (cell * cell)),
};
const viol: string[] = [];
if (h % step !== 0) viol.push(`ALIGN:${a.id}:${h}%${step}=${h % step}`);
if (w % 2 !== 0) viol.push(`EVEN:${a.id}:${w}`);
if (w > cell || h > cell) viol.push(`CELL:${a.id}:${w}x${h}>${cell}`);
if (w > maxW) viol.push(`WIDTH:${a.id}:${w}>${maxW}`);
if (halfRows > cfg.budget.maxHalfRows) viol.push(`HALFROWS:${a.id}:${halfRows}>${cfg.budget.maxHalfRows}`);
return { rec, viol };
}
export function assetPaletteCanon(cfg: any, theme: any): string {
return cfg.assets
.map((a: Asset) => {
const rgb = theme.tokens[a.token];
return `${a.id}:${rgb[0]},${rgb[1]},${rgb[2]}`;
})
.join(";");
}
export type ConstraintReport = {
assetCount: number; passCount: number; violations: string[]; totalFootprintPermille: number;
canonical: string; records: AssetRec[]; paletteCanons: Record<string, string>;
};
export function constraintReport(cfg: any): ConstraintReport {
const records: AssetRec[] = [];
const violations: string[] = [];
for (const a of cfg.assets as Asset[]) {
const { rec, viol } = checkAsset(a, cfg);
records.push(rec);
violations.push(...viol);
}
const sorted = [...violations].sort();
const canonical =
records.map((r) => `${r.id}:${r.widthPx},${r.heightPx},${r.halfRows},${r.anchorX},${r.anchorY}`).join(";") +
"|viol:" + sorted.join(",");
const totalPx = records.reduce((s, r) => s + r.widthPx * r.heightPx, 0);
const paletteCanons: Record<string, string> = {};
for (const t of cfg.themes) paletteCanons[t.id] = assetPaletteCanon(cfg, t);
return {
assetCount: records.length,
passCount: records.filter((r) => !sorted.some((v) => v.split(":")[1] === r.id)).length,
violations: sorted,
totalFootprintPermille: Math.floor((totalPx * 1000) / (cfg.budget.atlasCell * cfg.budget.atlasCell)),
canonical, records, paletteCanons,
};
}
✔ 跨语言:逐件半行数 / 锚点 / 占地千分比 + 违例集合逐位一致 (0.98425ms)
✔ 跨语言:约束 canonical 串 + sha256 指纹逐位一致 (0.369167ms)
✔ 几何与主题正交:两主题几何 canonical 相同,调色板串不同 (0.101375ms)
✔ 负例:高度 30 不是 8 的倍数,必须判 C1 违例 (0.095333ms)
✔ 负例:96 宽同时越图集格与宽度上限,合规件则零违例 (0.096875ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 123.233625
最后两条负例是本节的价值所在。第 4 条喂一件 heightPx: 30 的资产,断言恰好一条 ALIGN 违例——assert.equal(viol.length, 1) 这句比「有 ALIGN」更严格:它顺带证明 30 这个高度不会误触 C2/C5/C6,判据之间没有串扰。第 5 条喂 96 宽,断言同时出现 CELL 与 WIDTH 两条;再用一件 32×40 的合规件反查,断言 viol 为空且 halfRows = 5、锚点 (16, 32)——正例与负例成对,才说明判据不是「什么都报违例」。
边界与交接
三件事本节不管。一是像素内的美术细节——底菱的边缘要不要留 1px 出血、抗锯齿怎么处理,那是 3.x 与 7.x 的内容,本节只保证尺寸是整数。二是变体与状态——同一件柜台有开门/关门两帧,尺寸必须完全一致,否则 C1–C6 要逐帧各过一遍;本书的做法是变体共享同一份 widthPx/heightPx,只换图。三是图集打包本身——atlasCell = 64 是硬上限,真要把 6 件塞进一张图集、还要考虑 padding 与 mip,属于资产管线(卷一 8.x)的账,本节只输出「够不够」这个判断。
最后回到本章的主线:9.1 算坐标、9.2 算遮挡、9.3 算尺寸,三笔账全是整数、全部与主题正交——同一块板换成 voxel,三个 digest 一个都不变,只有调色板串变。这就是等距风格能跨主题复用的全部秘密:投影、遮挡、尺寸是几何,颜色才是主题。
阅读导航:上一节:9.2 等距 tile 与遮挡规则 · 下一节:10.1 色彩与线条规范 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。