9.3 等距资产的制作约束

等距资产不能随便画尺寸:底菱要落在半行网格上、宽度要偶数才能整数居中、锚点必须是底菱中心、单件还得装进图集格。本节把 C1–C6 这 6 条约束写成整数判据,对《星屑旅店》的 6 件等距资产逐件过账,让灯具半行不对齐、雨棚超格超宽这三条真实违例当场现形。

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 色彩与线条规范 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.2 等距 tile 与遮挡规则
  3. 9.1 等距投影的整数变换