9.1 等距投影的整数变换

等距(2.5D)是一套跨主题的投影,不是主题包:tile 宽高定死之后,cozy-clean 与 voxel 的屏幕坐标逐位相同。本节用与卷一 [16.2 双案例回放:《星屑旅店》6 主题的规范装配] 同源的整数变换,算《星屑旅店》8×8 地板的正变换、逆变换回程、包围盒与菱形填充率,并证明投影与主题正交。

9.1 等距投影的整数变换

卷一 16.2 双案例回放:《星屑旅店》6 主题的规范装配 把六套主题包装配到同一份规范上,其中等距地板这一层用的投影常数是 tileW = 32 / tileH = 16,正变换就写在 core/src/sp00/spec.ts 的 isoToScreen 里。本节不另起一套公式:同一组常数、同一个函数,只把它展开成一笔可对账的整数账。

先把最容易混淆的一点钉死:等距是一套投影方式,不是一个主题。主题管的是「这块地板刷什么颜色、边线多粗、有没有噪点」,投影管的是「格子坐标 (tx, ty) 落到屏幕的哪个像素」。两件事正交——同一块 8×8 地板在 cozy-clean 与 voxel 下的屏幕坐标串逐位相同,只有调色板串不同。这正是本节 S 层要用 digest 证明的那句话。

投影本身只有两行乘法:

sx = (tx - ty) * (tileW // 2) = (tx - ty) * 16
sy = (tx + ty) * (tileH // 2) = (tx + ty) * 8

tx - ty 是屏幕水平列号,tx + ty 是屏幕深度键——这两个量在 9.2 的遮挡规则里还会再出现一次。逆变换也全是整数:令 a = tx - ty、b = tx + ty,则 a + b = 2·tx 必为偶数,所以 tx = (a + b) // 2、ty = (b - a) // 2 用地板除精确无误差,不需要浮点也不需要四舍五入。

S 层:投影 / 逆变换 / 包围盒 / 填充率(Python 整数账)

下面这段是本节的全部算术。它读 config/st09_iso.json(投影常数与 tile 宽高同源于 core/src/sp00/spec.ts),把 8×8 地板的 64 个地块逐个投影、逐个逆变换回程比对,再取所有菱形四顶点的 min/max 得到包围盒,最后算菱形总面积占包围盒的千分比。落 out/st09_iso_transform.json 供 P 层与 W 层对照。

#!/usr/bin/env python3
# file: sim/st09_iso_transform.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st09_iso_transform.py
"""卷三 9.1 等距投影的整数变换(S 层)。

读 config/st09_iso.json。投影常数 tileW=32 / tileH=16 与 core/src/sp00/spec.ts 的
isoToScreen 同源——本脚本不另起一套公式:

    sx = (tx - ty) * (tileW // 2) = (tx - ty) * 16
    sy = (tx + ty) * (tileH // 2) = (tx + ty) * 8

整节要证三件事:① 正变换是纯整数乘法;② 逆变换用整数地板除也精确(因为
a = tx - ty、b = tx + ty 都是整数且 a + b = 2·tx 必为偶数);③ 投影与主题**正交**——
同一块 8×8 地板在 cozy-clean 与 voxel 下的屏幕坐标串 digest 逐位相同,只有调色板串不同。
落 out/st09_iso_transform.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 iso_to_screen(tx, ty, half_w, half_h):
    """与 core/src/sp00/spec.ts 的 isoToScreen 同源:整数地板除。"""
    return ((tx - ty) * half_w, (tx + ty) * half_h)


def screen_to_iso(sx, sy, half_w, half_h):
    """整数逆变换:a=tx-ty、b=tx+ty,a+b=2·tx 必为偶数,故地板除精确。"""
    a, b = sx // half_w, sy // half_h
    return ((a + b) // 2, (b - a) // 2)


def board_tiles(rows):
    return [(tx, ty) for ty, row in enumerate(rows) for tx, ch in enumerate(row)]


def theme_palette_canon(rows, legend, modules, tokens):
    """按 tile 顺序把每个地块解析成主题 token 的 sRGB 8-bit 整数三元组串。"""
    by_id = {m["id"]: m for m in modules}
    parts = []
    for tx, ty in board_tiles(rows):
        mod = by_id[legend[rows[ty][tx]]]
        rgb = tokens[mod["token"]]
        parts.append(f"{tx},{ty}:{rgb[0]},{rgb[1]},{rgb[2]}")
    return ";".join(parts)


def main():
    cfg = json.load(open(CFG, encoding="utf-8"))
    iso, board = cfg["iso"], cfg["board"]
    tile_w, tile_h = iso["tileW"], iso["tileH"]
    half_w, half_h = tile_w // 2, tile_h // 2
    tiles = board_tiles(board["rows"])

    proj = {t: iso_to_screen(t[0], t[1], half_w, half_h) for t in tiles}
    mism = [t for t in tiles if screen_to_iso(*proj[t], half_w, half_h) != t]

    xs, ys = [], []
    for sx, sy in proj.values():
        xs += [sx - half_w, sx + half_w]
        ys += [sy - half_h, sy + half_h]
    box = [min(xs), min(ys), max(xs), max(ys)]
    box_area = (box[2] - box[0]) * (box[3] - box[1])
    per_tile = tile_w * tile_h // 2
    fill_permille = len(tiles) * per_tile * 1000 // box_area

    coord_canon = (";".join(f"{tx},{ty}:{sx},{sy}" for (tx, ty), (sx, sy) in proj.items())
                   + f"|box:{','.join(map(str, box))}|rt:{len(mism)}|fill:{fill_permille}")
    coord_digest = hashlib.sha256(coord_canon.encode("utf-8")).hexdigest()[:16]

    themes = {}
    for t in cfg["themes"]:
        canon = theme_palette_canon(board["rows"], board["legend"], board["modules"], t["tokens"])
        themes[t["id"]] = {"paletteDigest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16],
                           "paletteCanon": canon}

    report = {"project": cfg["project"], "tileW": tile_w, "tileH": tile_h,
              "halfW": half_w, "halfH": half_h, "board": [board["w"], board["h"]],
              "tileCount": len(tiles), "box": box, "boxArea": box_area,
              "perTileArea": per_tile, "totalTileArea": len(tiles) * per_tile,
              "fillPermille": fill_permille, "roundTripMismatch": len(mism),
              "samples": {f"{tx},{ty}": list(proj[(tx, ty)]) for tx, ty in [(0, 0), (3, 2), (7, 7)]},
              "coordCanon": coord_canon, "coordDigest": coord_digest,
              "themeIds": [t["id"] for t in cfg["themes"]],
              "themes": themes,
              "coordDigestPerTheme": {t["id"]: coord_digest for t in cfg["themes"]},
              "paletteDigestEqual": len({v["paletteDigest"] for v in themes.values()}) == 1}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "st09_iso_transform.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    write_svg(proj, box, cfg["themes"][0], cfg["themes"][1], board,
              os.path.join(OUT, "st09_iso_transform.svg"))

    print(f"tile={tile_w}x{tile_h} half={half_w}x{half_h} board={board['w']}x{board['h']} "
          f"tiles={len(tiles)}")
    print(f"box={box} boxArea={box_area} perTile={per_tile} total={len(tiles) * per_tile} "
          f"fill={fill_permille}permille")
    print(f"roundTripMismatch={len(mism)} samples={report['samples']}")
    print(f"coordDigest={coord_digest} (both themes)")
    for tid, v in themes.items():
        print(f"  {tid:<12} paletteDigest={v['paletteDigest']}")
    print(f"paletteDigestEqual={report['paletteDigestEqual']} (投影同源、主题可区分)")
    print("wrote out/st09_iso_transform.json out/st09_iso_transform.svg")
    return 0


def write_svg(proj, box, theme_a, theme_b, board, path):
    """左右两半:同一块地板的投影,两种主题调色板各画一遍。"""
    by_id = {m["id"]: m for m in board["modules"]}
    pad, half_w, half_h = 12, 16, 8
    bw = box[2] - box[0] + pad * 2
    bh = box[3] - box[1] + pad * 2
    W, H = bw * 2 + pad, bh + pad * 2
    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"})
    for si, theme in enumerate((theme_a, theme_b)):
        ox = pad + si * (bw + pad) - box[0] + pad
        oy = pad - box[1] + pad
        for (tx, ty), (sx, sy) in proj.items():
            mod = by_id[board["legend"][board["rows"][ty][tx]]]
            rgb = theme["tokens"][mod["token"]]
            pts = f"{ox + sx},{oy + sy - half_h} {ox + sx + half_w},{oy + sy} " \
                  f"{ox + sx},{oy + sy + half_h} {ox + sx - half_w},{oy + sy}"
            ET.SubElement(svg, "polygon", {"points": pts, "fill": f"rgb({rgb[0]},{rgb[1]},{rgb[2]})",
                                           "stroke": "#1f2933", "stroke-width": "1"})
        ET.SubElement(svg, "text", {"x": str(pad + si * (bw + pad)), "y": str(H - 4),
                                    "fill": "#94a3b8", "font-family": "monospace",
                                    "font-size": "10"}).text = theme["id"]
    tree = ET.ElementTree(svg)
    ET.indent(tree, space="  ")
    tree.write(path, encoding="utf-8", xml_declaration=True)


if __name__ == "__main__":
    sys.exit(main())
tile=32x16 half=16x8 board=8x8 tiles=64
box=[-128, -8, 128, 120] boxArea=32768 perTile=256 total=16384 fill=500permille
roundTripMismatch=0 samples={'0,0': [0, 0], '3,2': [16, 40], '7,7': [0, 112]}
coordDigest=f5a5286626176785 (both themes)
  cozy-clean   paletteDigest=63707d83c48a0376
  voxel        paletteDigest=a4720b82432afb52
paletteDigestEqual=False (投影同源、主题可区分)
wrote out/st09_iso_transform.json out/st09_iso_transform.svg

三个数值得解释一下。roundTripMismatch=0 说明 64 个地块投影再逆变换全部回到原格点,逆变换在格点上是无损的(非格点会丢,见 P 层负例)。box=[-128, -8, 128, 120] 宽 256、高 128,面积 32768;每块菱形面积 32 × 16 // 2 = 256,64 块合计 16384,恰好是包围盒的一半,所以 fill=500permille——等距板天然只占外接矩形一半面积,剩下的是菱形之间的锯齿空隙,不是浪费,是几何必然。采样 (3,2) → [16, 40] 与卷一 11.3 的锚点完全一致,这就是「同源」的可核对证据。

最后两行是本节的中心论点:coordDigest 两主题同一个 f5a5286626176785,paletteDigest 两主题不同(63707d83c48a0376 vs a4720b82432afb52),paletteDigestEqual=False。投影与主题正交,至此不是修辞而是指纹。

P 层:TypeScript 独立复算 + 同源锚点

P 层不重写投影,而是导入 core/src/sp00/spec.ts 的 isoToScreen(只读真源),只补上整数逆变换、包围盒、填充率与主题调色板串,再与 Python 落盘的 JSON 逐位比对。iso.ts 刻意不引 node:crypto,因为 W 层要把它整块内联进浏览器,digest 由调用方对 canonical 串取 sha256。

// file: core/src/st09/iso.ts
// tier: P
// verify: node --test 'core/test/st09_*.test.ts'
// 9.1 等距投影的整数变换内核。投影**复用** core/src/sp00/spec.ts 的 isoToScreen(只读,
// 同一份真源,tileW=32 / tileH=16),逆变换用整数地板除(a=tx-ty、b=tx+ty,a+b=2·tx 必为
// 偶数,故精确)。与 sim/st09_iso_transform.py 同口径:全整数,逐位一致。
// 本模块不引 node:crypto(要内联进浏览器),digest 由调用方对 canonical 串取 sha256。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

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

export type Pt = [number, number];

/** 整数逆变换:a = tx - ty、b = tx + ty,a + b 必为偶数,地板除精确。 */
export function screenToIso(sx: number, sy: number, halfW: number, halfH: number): Pt {
  const a = Math.floor(sx / halfW);
  const b = Math.floor(sy / halfH);
  return [Math.floor((a + b) / 2), Math.floor((b - a) / 2)];
}

/** 按行优先(ty 外层、tx 内层)铺出棋盘上所有地块坐标。 */
export function boardTiles(rows: string[]): Pt[] {
  const out: Pt[] = [];
  for (let ty = 0; ty < rows.length; ty += 1) {
    for (let tx = 0; tx < rows[ty].length; tx += 1) out.push([tx, ty]);
  }
  return out;
}

/** 按 tile 顺序把地块解析成主题 token 的 sRGB 8-bit 整数串。 */
export function themePaletteCanon(cfg: any, theme: any): string {
  const byId: Record<string, any> = {};
  for (const m of cfg.board.modules) byId[m.id] = m;
  const parts: string[] = [];
  for (const [tx, ty] of boardTiles(cfg.board.rows)) {
    const rgb = theme.tokens[byId[cfg.board.legend[cfg.board.rows[ty][tx]]].token];
    parts.push(`${tx},${ty}:${rgb[0]},${rgb[1]},${rgb[2]}`);
  }
  return parts.join(";");
}

export type TransformReport = {
  tileCount: number; box: number[]; boxArea: number; perTileArea: number;
  totalTileArea: number; fillPermille: number; roundTripMismatch: number;
  samples: Record<string, number[]>; coordCanon: string;
  themeIds: string[]; paletteCanons: Record<string, string>;
};

/** 9.1 的整数账:投影、逆变换回程、包围盒、菱形填充率、坐标 canonical 串。 */
export function transformReport(cfg: any): TransformReport {
  const tileW = cfg.iso.tileW;
  const tileH = cfg.iso.tileH;
  const halfW = Math.floor(tileW / 2);
  const halfH = Math.floor(tileH / 2);
  const tiles = boardTiles(cfg.board.rows);
  const proj: number[][] = tiles.map(([tx, ty]) => isoToScreen(tx, ty, tileW, tileH));
  let mism = 0;
  const xs: number[] = [];
  const ys: number[] = [];
  tiles.forEach(([tx, ty], i) => {
    const [sx, sy] = proj[i];
    const back = screenToIso(sx, sy, halfW, halfH);
    if (back[0] !== tx || back[1] !== ty) mism += 1;
    xs.push(sx - halfW, sx + halfW);
    ys.push(sy - halfH, sy + halfH);
  });
  const box = [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
  const boxArea = (box[2] - box[0]) * (box[3] - box[1]);
  const perTileArea = Math.floor((tileW * tileH) / 2);
  const fillPermille = Math.floor((tiles.length * perTileArea * 1000) / boxArea);
  const coordCanon =
    tiles.map(([tx, ty], i) => `${tx},${ty}:${proj[i][0]},${proj[i][1]}`).join(";") +
    `|box:${box.join(",")}|rt:${mism}|fill:${fillPermille}`;
  const samples: Record<string, number[]> = {};
  for (const [tx, ty] of [[0, 0], [3, 2], [7, 7]] as Pt[]) {
    const i = tiles.findIndex((t) => t[0] === tx && t[1] === ty);
    samples[`${tx},${ty}`] = proj[i];
  }
  const paletteCanons: Record<string, string> = {};
  for (const t of cfg.themes) paletteCanons[t.id] = themePaletteCanon(cfg, t);
  return {
    tileCount: tiles.length, box, boxArea, perTileArea, totalTileArea: tiles.length * perTileArea,
    fillPermille, roundTripMismatch: mism, samples, coordCanon,
    themeIds: cfg.themes.map((t: any) => t.id), paletteCanons,
  };
}
✔ 跨语言:投影、逆变换回程、包围盒、填充率逐位一致(整数类) (1.249709ms)
✔ 跨语言:坐标 canonical 串 + sha256 指纹逐位一致 (0.424958ms)
✔ 投影与主题正交:坐标串两主题同 digest,调色板串两主题不同 (0.204542ms)
✔ 同源锚点:isoToScreen(3,2,32,16) === [16,40],与卷一 11.3 采样一致 (0.070667ms)
✔ 负例:逆变换只在格点上精确,非格点必丢失 (0.08875ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 125.5675

第 4 条是本节的「同源锁」:isoToScreen(3, 2, 32, 16) 必须严格等于 [16, 40],与卷一 11.3 的采样值一字不差。它保证的不是「算得对」,而是三卷用的是同一个函数——将来谁改了 tileW,这条断言立刻红。第 5 条是诚实的边界:逆变换用 Math.floor 除法,只有输入本来就是格点才精确;喂 (16, 41) 这种非格点,回程必然偏一格,这不是 bug 而是整数除法的定义。所以本书的纪律是:格点坐标只由投影产生,永远不要手写屏幕坐标再反推。

边界与交接

本节只管「坐标」。投影之后还有三件事本节不算:一是遮挡与绘制顺序(同一条屏幕列上谁挡谁),9.2 用 tx - ty 与 tx + ty 接着算;二是资产尺寸约束(底菱要对齐半行网格、锚点必须在底菱中心),9.3 把它写成 6 条整数判据;三是透视与缩放变体(2:1 菱形之外的 1.5:1、真等距 30°),本书不展开,只提醒一句:换比例就等于换 halfW / halfH,整节算法原样复用,但所有已发布的锚点数值都会变,属于破坏性改动。

还有一条容易被忽略的交接:等距板的颜色不归本节管。同一套坐标喂给 cozy-clean 与 voxel,得到的是同一张几何图、两张不同的调色图——这正是卷一 16.2 那套「规范装配」能同时装下六套主题的原因。

阅读导航:上一节:8.3 体素的引擎接入 · 下一节:9.2 等距 tile 与遮挡规则 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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