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 与遮挡规则 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。