9.2 等距 tile 与遮挡规则
9.1 把坐标算对了,但一张等距图不是「把每个地块按坐标画一遍」就完事——只要板上有两格以上高度,就一定有遮挡:前排的柜台会盖住后排地板的一角。本节把这件事写成两条纯整数规则,全部落在 9.1 已经出现过的两个量上。
规则一,绘制顺序(painter’s order):按屏幕深度键 tx + ty 升序画,同键再按 tx 升序。等价的说法是「从最远的地块画到最近的地块」——每个地块落笔时,所有可能盖住它的地块都还没画,所以先画的一定被后画的正确覆盖。这里不需要任何 z-buffer,顺序本身就是深度。
规则二,遮挡深度:tx - ty 相同的地块落在同一条屏幕竖直列上(就是 9.1 里的屏幕列号)。对地块 (tx, ty),沿对角线往后数 k = 1, 2, …,统计 (tx + k, ty + k) 里 blockHeight ≥ 1 的个数——这串坐标的 tx - ty 与 (tx, ty) 相同,所以它们确实在同一列上、且都在更远处。这个数就是遮挡深度:深度 0 表示前方没人挡;深度 3 表示前面叠了三层有高度的东西。超过 maxOcclusionCap 判越界——这不是渲染错误,是玩法错误:角色站在那儿会被挡得看不见。
S 层:绘制顺序与遮挡深度(Python 整数账)
脚本读 config/st09_iso.json 的 8×8 板:# 是高 2 的墙、C 是高 1 的柜台、. 是平地。它算出每个地块的遮挡深度与直方图,统计「顺序合法性」(任何深度键更小、即更靠后的地块,是否都在更靠前的地块之前落笔),并把地块/高度/深度三元组、绘制顺序、越界集合串成 canonical 串取 sha256。
#!/usr/bin/env python3
# file: sim/st09_occlusion.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st09_occlusion.py
"""卷三 9.2 等距 tile 与遮挡规则(S 层)。
读 config/st09_iso.json 的 board,跑两条纯整数规则:① 绘制顺序(painter's order)按屏幕深度键
`tx + ty` 升序、同键按 `tx` 升序,即「后 → 前」;② 遮挡深度 = 同屏幕列(`tx - ty` 相同)上位于
本格前方、`blockHeight ≥ 1` 的地块数,超过 maxOcclusionCap 判越界。
全部整数、无浮点。落 out/st09_occlusion.json,供 W 层看板与 node --test 对照。
"""
import hashlib
import json
import os
import sys
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 heights(rows, legend, modules):
"""tile -> blockHeight(整数)。"""
by_id = {m["id"]: m for m in modules}
return {(tx, ty): by_id[legend[ch]]["blockHeight"]
for ty, row in enumerate(rows) for tx, ch in enumerate(row)}
def depth_of(tx, ty, hgt, w, h):
"""同屏幕列上、位于本格前方且有高度(blockHeight ≥ 1)的地块数。"""
k, d = 1, 0
while tx + k < w and ty + k < h:
if hgt[(tx + k, ty + k)] >= 1:
d += 1
k += 1
return d
def main():
cfg = json.load(open(CFG, encoding="utf-8"))
board, cap = cfg["board"], cfg["iso"]["maxOcclusionCap"]
w, h = board["w"], board["h"]
hgt = heights(board["rows"], board["legend"], board["modules"])
tiles = sorted(hgt, key=lambda t: (t[0] + t[1], t[0])) # painter's order
index = {t: i for i, t in enumerate(tiles)}
depth = {t: depth_of(t[0], t[1], hgt, w, h) for t in hgt}
viol = [t for t in tiles if depth[t] > cap]
# 顺序合法性:任何和更小(更靠后)的地块都必须先落笔。
bad_order = sum(1 for a in tiles for b in tiles
if a[0] + a[1] < b[0] + b[1] and index[a] > index[b])
columns = {}
for tx, ty in tiles:
columns.setdefault(tx - ty, []).append((tx, ty))
hist = {d: sum(1 for t in tiles if depth[t] == d) for d in sorted({depth[t] for t in tiles})}
canon = ";".join(f"{tx},{ty}:{hgt[(tx, ty)]}:{depth[(tx, ty)]}" for tx, ty in tiles)
canon += "|order:" + ",".join(f"{tx},{ty}" for tx, ty in tiles)
canon += "|viol:" + ",".join(f"{tx},{ty}" for tx, ty in viol)
canon += f"|cols:{len(columns)}"
digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]
rep = {"project": cfg["project"], "board": [w, h], "maxOcclusionCap": cap,
"tileCount": len(tiles), "columnCount": len(columns),
"maxColumnLen": max(len(v) for v in columns.values()),
"depthHistogram": {str(k): v for k, v in hist.items()},
"maxDepth": max(depth.values()),
"visibleCount": sum(1 for t in tiles if depth[t] <= cap),
"violations": [f"{tx},{ty}:{depth[(tx, ty)]}" for tx, ty in viol],
"orderViolations": bad_order,
"firstInOrder": [f"{tx},{ty}" for tx, ty in tiles[:4]],
"lastInOrder": [f"{tx},{ty}" for tx, ty in tiles[-4:]],
"orderCanon": canon, "digest": digest,
"themeIds": [t["id"] for t in cfg["themes"]],
"digestPerTheme": {t["id"]: digest for t in cfg["themes"]}}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "st09_occlusion.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"board={w}x{h} tiles={len(tiles)} columns={len(columns)} maxColumnLen={rep['maxColumnLen']}")
print(f"depthHistogram={rep['depthHistogram']} maxDepth={rep['maxDepth']} "
f"visible={rep['visibleCount']} cap={cap}")
print(f"violations={rep['violations']} orderViolations={bad_order}")
print(f"firstInOrder={rep['firstInOrder']} lastInOrder={rep['lastInOrder']}")
print(f"digest={digest} (both themes; 几何与主题正交)")
print("wrote out/st09_occlusion.json")
return 0
if __name__ == "__main__":
sys.exit(main())
board=8x8 tiles=64 columns=15 maxColumnLen=8
depthHistogram={'0': 59, '1': 2, '2': 1, '3': 1, '4': 1} maxDepth=4 visible=63 cap=3
violations=['0,0:4'] orderViolations=0
firstInOrder=['0,0', '0,1', '1,0', '0,2'] lastInOrder=['7,5', '6,7', '7,6', '7,7']
digest=5394a5ceaa8414e0 (both themes; 几何与主题正交)
wrote out/st09_occlusion.json
读法:columns=15 是因为 tx - ty 的取值范围是 -7 … +7(8×8 板的两个对角各只有一格),maxColumnLen=8 说明最长的一条列上叠了 8 个地块。直方图里 59 格深度 0(多数平地前方没东西)、4 格有深度、maxDepth=4——那 4 格是 #/C 自身,它们前面还压着别的高格。violations=['0,0:4'] 只有一格越界:(0, 0) 前面压了 4 层(超过 cap 3),它既是整块板的最后一块(firstInOrder 第一个就是它),也是唯一「站不住人」的位置。orderViolations=0 证明这套排序对全部 64 格成立,不是抽样。
W 层:单文件等距看板 + Playwright 断言
W 层把 core/src/sp00/spec.ts 与三个 core/src/st09/ 内核擦除类型后内联进一个 HTML:并排画两块 canvas(cozy-clean 与 voxel),用同一份坐标、同一份绘制顺序,只有调色板不同;越界格用主题强调色描 2px 边,下方用 DOM 表列出所有深度 > 0 的地块。构建脚本先跑:
// file: web/build_st09_board.mjs
// tier: W
// verify: node web/build_st09_board.mjs
// 第 9 章 W 层:把 core/src/sp00/spec.ts(isoToScreen 同源真源)与 core/src/st09/ 三个内核
// 擦除类型后内联进单文件 HTML。看板并排画 cozy-clean 与 voxel 两块等距 tile 板:坐标完全
// 相同(投影与主题正交),越界遮挡格描强调色边。
import { stripTypeScriptTypes } from "node:module";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/st09_iso.json"), "utf8"));
const SRC = ["core/src/sp00/spec.ts", "core/src/st09/iso.ts", "core/src/st09/occlusion.ts",
"core/src/st09/constraint.ts"];
const js = SRC.map((rel) => stripTypeScriptTypes(readFileSync(resolve(ROOT, rel), "utf8"),
{ mode: "strip", sourceUrl: rel })
.replace(/^import[^;]*;/gm, "").replace(/^export\s*\{[^}]*\};?\s*$/gm, "")
.replace(/^export\s+/gm, "")).join("\n");
const html = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><title>prism 等距看板 · ${cfg.project}</title>
<style>body{margin:0;padding:24px;background:#1f2933;color:#eceff1;font:14px/1.6 system-ui,sans-serif}
h1{font-size:18px;margin:0 0 12px}h2{font-size:13px;color:#94a3b8;margin:16px 0 8px}
canvas{display:block;background:#1f2933;border:1px solid #2d3a47;border-radius:6px}
.row{display:flex;gap:24px;flex-wrap:wrap}
table{border-collapse:collapse;font:12px/1.5 ui-monospace,monospace}
td{padding:3px 10px;border-bottom:1px solid #2d3a47}.ok{color:#22c55e}.bad{color:#ef4444}</style></head><body>
<h1>prism 等距 tile 看板 · tile ${cfg.iso.tileW}×${cfg.iso.tileH}</h1>
<div class="row">${cfg.themes.map((_, i) => `<canvas id="cv${i}" width="320" height="176"></canvas>`).join("")}</div>
<h2>遮挡深度(cap ${cfg.iso.maxOcclusionCap})</h2><table id="oc"><tbody></tbody></table>
<script type="module">
${js}
const CFG = ${JSON.stringify(cfg)};
const HW = Math.floor(CFG.iso.tileW / 2), HH = Math.floor(CFG.iso.tileH / 2), CAP = CFG.iso.maxOcclusionCap;
const MODS = Object.fromEntries(CFG.board.modules.map((m) => [m.id, m]));
const HGT = heights(CFG);
const TILES = boardTiles(CFG.board.rows).sort((a, b) => (a[0] + a[1]) - (b[0] + b[1]) || a[0] - b[0]);
const DEPTH = (tx, ty) => depthOf(tx, ty, HGT, CFG.board.w, CFG.board.h);
const rgbOf = (theme, tx, ty) => theme.tokens[MODS[CFG.board.legend[CFG.board.rows[ty][tx]]].token];
function render(cv, theme) {
const ctx = cv.getContext("2d");
ctx.fillStyle = "#1f2933"; ctx.fillRect(0, 0, cv.width, cv.height);
const ox = cv.width / 2, oy = 24;
for (const [tx, ty] of TILES) {
const [sx, sy] = isoToScreen(tx, ty, CFG.iso.tileW, CFG.iso.tileH);
ctx.beginPath();
ctx.moveTo(ox + sx, oy + sy - HH); ctx.lineTo(ox + sx + HW, oy + sy);
ctx.lineTo(ox + sx, oy + sy + HH); ctx.lineTo(ox + sx - HW, oy + sy); ctx.closePath();
ctx.fillStyle = "rgb(" + rgbOf(theme, tx, ty).join(",") + ")"; ctx.fill();
ctx.strokeStyle = DEPTH(tx, ty) > CAP ? "rgb(" + theme.tokens.accent.join(",") + ")" : "#1f2933";
ctx.lineWidth = DEPTH(tx, ty) > CAP ? 2 : 1; ctx.stroke();
}
ctx.fillStyle = "#94a3b8"; ctx.font = "11px monospace"; ctx.fillText(theme.id, 8, cv.height - 6);
}
const rep = transformReport(CFG), oc = occlusionReport(CFG), con = constraintReport(CFG);
CFG.themes.forEach((t, i) => render(document.getElementById("cv" + i), t));
const row = ([tx, ty]) => { const d = DEPTH(tx, ty);
return \`<tr><td>\${tx},\${ty}</td><td>\${d}</td><td class="\${d <= CAP ? "ok" : "bad"}">\${d <= CAP ? "pass" : "fail"}</td></tr>\`; };
document.querySelector("#oc tbody").innerHTML = TILES.filter((t) => DEPTH(t[0], t[1]) > 0).map(row).join("");
globalThis.__prism = {
themeIds: CFG.themes.map((t) => t.id), tileCount: rep.tileCount, box: rep.box,
fillPermille: rep.fillPermille, coordCanon: rep.coordCanon,
palettes: Object.fromEntries(CFG.themes.map((t) => [t.id, t.tokens.surface.join(",")])),
occlusion: { violations: oc.violations, maxDepth: oc.maxDepth, visibleCount: oc.visibleCount,
orderCanon: oc.orderCanon, columns: oc.columnCount },
assets: { count: con.assetCount, violations: con.violations, canonical: con.canonical },
depth: DEPTH,
};
document.title = "prism st09 ready";
</script></body></html>`;
const out = resolve(ROOT, "web/dist/st09_board.html");
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, html, "utf8");
console.log(`built web/dist/st09_board.html (${Buffer.byteLength(html)} bytes)`);
built web/dist/st09_board.html (19327 bytes)
构建脚本本身只是「拼字符串」,真正的断言在 verify 里:无头 Chromium 打开这个 HTML,采样页面暴露的 globalThis.__prism,与 Python 落盘的三份 JSON 逐项对照,再验证两块 canvas 真的画了像素、两主题调色板真的不同、截图非空。
// file: web/verify_st09_board.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_st09_board.mjs
// 第 9 章 W 层断言:无头 Chromium 打开单文件等距看板,采样 globalThis.__prism,与 Python
// 落盘的 out/st09_*.json 逐项对照(整数类逐位),再验证两块 canvas 真的画了、两主题调色板
// 真的不同、截图非空。用 createRequire 加载全局 playwright(NODE_PATH 对 ESM import 无效)。
import { createRequire } from "node:module";
import { readFileSync, mkdirSync, statSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "..");
const iso = JSON.parse(readFileSync(resolve(ROOT, "out/st09_iso_transform.json"), "utf8"));
const oc = JSON.parse(readFileSync(resolve(ROOT, "out/st09_occlusion.json"), "utf8"));
const con = JSON.parse(readFileSync(resolve(ROOT, "out/st09_asset_constraint.json"), "utf8"));
const pageUrl = pathToFileURL(resolve(ROOT, "web/dist/st09_board.html")).href;
const shots = resolve(ROOT, "out/shots");
mkdirSync(shots, { recursive: true });
const sha16 = (s) => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
let pass = 0;
let fail = 0;
const check = (name, ok, extra = "") => {
if (ok) pass += 1; else fail += 1;
console.log(`${ok ? "pass" : "fail"} ${name}${extra ? " " + extra : ""}`);
};
const browser = await chromium.launch({ headless: true });
// 冷启动预热:无头 Chromium 首个 page 读 canvas 像素恒为空白
const warm = await browser.newPage();
await warm.goto(pageUrl);
await warm.close();
const page = await browser.newPage({ viewport: { width: 1000, height: 800 } });
const errors = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(pageUrl);
await page.waitForFunction(() => globalThis.__prism !== undefined, null, { timeout: 15000 });
const s = await page.evaluate(() => {
const p = globalThis.__prism;
return { themeIds: p.themeIds, tileCount: p.tileCount, box: p.box, fillPermille: p.fillPermille,
coordCanon: p.coordCanon, palettes: p.palettes, occlusion: p.occlusion,
assets: p.assets, depth00: p.depth(0, 0) };
});
check("无 pageerror", errors.length === 0, errors.join("|"));
check("tileCount / 包围盒 / 填充率与 Python 一致",
s.tileCount === iso.tileCount && JSON.stringify(s.box) === JSON.stringify(iso.box) &&
s.fillPermille === iso.fillPermille, `${s.tileCount} ${JSON.stringify(s.box)} ${s.fillPermille}`);
check("坐标 canonical 串 + digest 与 Python 一致",
s.coordCanon === iso.coordCanon && sha16(s.coordCanon) === iso.coordDigest);
check("两主题调色板真的不同(投影同源、主题可区分)",
new Set(Object.values(s.palettes)).size === 2, JSON.stringify(s.palettes));
check("遮挡深度 (0,0) 与越界集合与 Python 一致",
s.depth00 === 4 && JSON.stringify(s.occlusion.violations) === JSON.stringify(oc.violations),
`${s.depth00} vs ${JSON.stringify(oc.violations)}`);
check("painter's order canonical + digest 与 Python 一致",
s.occlusion.orderCanon === oc.orderCanon && sha16(s.occlusion.orderCanon) === oc.digest);
check("可见数 / 最大深度 / 列数与 Python 一致",
s.occlusion.visibleCount === oc.visibleCount && s.occlusion.maxDepth === oc.maxDepth &&
s.occlusion.columns === oc.columnCount);
check("制作约束 canonical + 越界集合与 Python 一致",
s.assets.canonical === con.canonical && JSON.stringify(s.assets.violations) === JSON.stringify(con.violations));
const drawn = [];
for (const i of [0, 1]) {
drawn.push(await page.evaluate((idx) => {
const cv = document.getElementById("cv" + idx);
const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
let n = 0;
for (let k = 3; k < d.length; k += 4) if (d[k] > 0) n += 1;
return n;
}, i));
}
check("两块 canvas 都已绘制(≥ 1px)", drawn.every((n) => n >= 1), drawn.join(" / ") + " px");
const rows = await page.$$eval("#oc tbody tr", (trs) => trs.length);
check("遮挡表行数 == 深度 > 0 的地块数", rows === oc.tileCount - oc.depthHistogram["0"], `${rows}`);
const shot = resolve(shots, "st09_board.png");
await page.screenshot({ path: shot });
await browser.close();
check("截图非空", statSync(shot).size > 0, `${statSync(shot).size} bytes`);
console.log(`ℹ pass ${pass}`);
console.log(`ℹ fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);
pass 无 pageerror
pass tileCount / 包围盒 / 填充率与 Python 一致 64 [-128,-8,128,120] 500
pass 坐标 canonical 串 + digest 与 Python 一致
pass 两主题调色板真的不同(投影同源、主题可区分) {"cozy-clean":"228,220,206","voxel":"58,50,74"}
pass 遮挡深度 (0,0) 与越界集合与 Python 一致 4 vs ["0,0:4"]
pass painter's order canonical + digest 与 Python 一致
pass 可见数 / 最大深度 / 列数与 Python 一致
pass 制作约束 canonical + 越界集合与 Python 一致
pass 两块 canvas 都已绘制(≥ 1px) 56320 / 56320 px
pass 遮挡表行数 == 深度 > 0 的地块数 5
pass 截图非空 26183 bytes
ℹ pass 11
ℹ fail 0
两个细节值得单独说。第一,冷启动预热:无头 Chromium 打开的第一个 page 读 canvas 像素恒为空白,所以脚本先开一个「预热页」跑一次再关掉,然后才开真正要断言的那个 page——不预热的话 56320 px 会变成 0,而且这种失败在本地和 CI 上表现不一致,极难排查。第二,canvas 断言的口径是「已绘制 ≥ 1 像素」,不是「恰好等于某个像素数」——不同 Chromium 版本的抗锯齿会让边缘像素数有微小差异,把断言写成「非空」才是稳的;同理,正文里任何「像素数 == 0」的写法都是坑,本节不写。另外 pass 制作约束 canonical + 越界集合与 Python 一致 这条是给下一节留的钩子:看板已经顺带跑了 9.3 的资产约束;最后一条 遮挡表行数 == 深度 > 0 的地块数 5 是结构断言,它不查数值,查的是「Python 说有 5 格有深度,页面上就得有 5 行」,防止视图层悄悄少渲染。
边界与交接
本节的两条规则只覆盖静态等距板:地块固定在整数格点上、只有 blockHeight 一档高度。三件事本节不算:一是角色与道具的移动遮挡——它们不在格点上,需要按 sy 排序的连续深度,属于运行时逻辑而不是美术规范;二是多层高度(同一格叠两层家具),blockHeight 只有一档,真要分层得改成高度数组并把深度改成「前方格子中高于本格顶面的个数」;三是半透明与描边——半透明不能靠 painter’s order 覆盖,必须真排序;描边会让相邻地块的边线互相压住,通常的做法是描边单独成层最后画。
还有一条实践提醒:maxOcclusionCap 是玩法参数,不是美术参数。cap 定得越小,可站区域越窄;定得越大,角色越容易被挡。本节取 3 是《星屑旅店》的取值,换玩法就得重算——而重算的成本很低,因为整条规则只有两个整数键。
阅读导航:上一节:9.1 等距投影的整数变换 · 下一节:9.3 等距资产的制作约束 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。