9.2 九宫格与可拉伸资源

把一张 48×48 的小图切成 3×3,四角保尺寸、四边单轴拉伸、中心双轴拉伸,就得到任意尺寸都不变形的按钮与面板。本节用整数公式算切片与落位矩形、用 PIL 生成拉伸示意图、用无头浏览器断言四角像素与源角块逐位相同,并给出面积账与取舍;切分参数一改,三层立刻复验。

9.2.1 一张小图怎么撑到任意尺寸

九宫格在引擎里怎么实现(纹理过滤、批处理、shader 变形)属于引擎实现侧 ,本书不碰;本章做的是游戏 UI 的九宫格规范,也就是切分参数与像素判据。这和网页 CSS 的 border-image、图表可视化的栅格都不是一回事:CSS 关心流式重排,图表关心数据映射,而九宫格只关心一张小图被整数拉伸后,四角有没有被拉糊。

9.1 定了元素落在哪一格,但格子大小随设备变——按钮要拉长、面板要撑大,而美术只画一张小图。九宫格(nine-slice) 把图切成 3×3:四角保尺寸不缩放(圆角与描边不变形),四边单轴拉伸,中心双轴拉伸。

切分参数只有四边四个数 left / right / top / bottom,全部整数,且必须 left + right < srcW、top + bottom < srcH。中心宽 = srcW − left − right,中心高 = srcH − top − bottom。

《星屑旅店》的对话框、按钮、卡片共用一张九宫底图:源图 48×48、四边各 16,中心即 16×16;目标拉到 240×160,中心被拉到 208×128,四角始终 16×16。

区块数量源尺寸目标尺寸变换
四角416×1616×16不缩放
四边416×16208×16 或 16×128单轴拉伸
中心116×16208×128双轴拉伸

9.2.2 S 层:切片矩形、落位矩形与面积账

下面这段 S 层脚本算源图 9 个切片矩形与目标图 9 个落位矩形(全整数),再算四角/四边/中心三区面积账,并用 PIL 依同一份 config 生成一张拉伸示意图 PNG:

#!/usr/bin/env python3
# file: sim/sp09_nineslice.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp09_nineslice.py
"""9.2 九宫格 S 层整数账 + PIL 生成。四角保尺寸、四边单轴拉伸、中心双轴拉伸,中心宽 =
srcW-left-right。算源图切片矩形、目标落位矩形与三区面积账,并依同一 config 生成拉伸示意图
PNG。落 out/sp09_nineslice.json 供 P 层对照,PNG 由 prism_rungen.py 回读断言。"""
import json
import os
import sys

from PIL import Image, ImageDraw

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
NAMES = ["TL", "top", "TR", "left", "center", "right", "BL", "bottom", "BR"]
KINDS = ["corner", "edge", "corner", "edge", "center", "edge", "corner", "edge", "corner"]
COLOR_KEY = dict(zip(NAMES, ["cornerTL", "edgeTop", "cornerTR", "edgeLeft", "center", "edgeRight", "cornerBL", "edgeBottom", "cornerBR"]))


def grid(w, h, left, right, top, bottom):
    """3×3 的九个 (x, y, w, h),顺序 TL,top,TR,left,center,right,BL,bottom,BR。"""
    cw, ch = w - left - right, h - top - bottom
    if cw <= 0 or ch <= 0:
        raise ValueError(f"中心 {cw}×{ch} 非正")
    x2, y2 = left + cw, top + ch
    return [(0, 0, left, top), (left, 0, cw, top), (x2, 0, right, top),
            (0, top, left, ch), (left, top, cw, ch), (x2, top, right, ch),
            (0, y2, left, bottom), (left, y2, cw, bottom), (x2, y2, right, bottom)]


def rects(w, h, l, r, t, b, keys):
    return [dict(zip(("name", "kind") + keys, (NAMES[i], KINDS[i]) + g)) for i, g in enumerate(grid(w, h, l, r, t, b))]


def area_account(dests, total):
    acc = {"corner": 0, "edge": 0, "center": 0}
    for d in dests:
        acc[d["kind"]] += d["dw"] * d["dh"]
    acc["total"] = total
    acc["permille"] = {k: acc[k] * 1000 // total for k in ("corner", "edge", "center")}
    return acc


def main() -> int:
    cfg = json.load(open(os.path.join(ROOT, "config/sp09_nineslice.json"), encoding="utf-8"))
    s, t = cfg["source"], cfg["target"]
    sl = rects(s["width"], s["height"], s["left"], s["right"], s["top"], s["bottom"], ("sx", "sy", "sw", "sh"))
    ds = rects(t["width"], t["height"], s["left"], s["right"], s["top"], s["bottom"], ("dx", "dy", "dw", "dh"))
    acc = area_account(ds, t["width"] * t["height"])
    report = {"game": cfg["game"], "source": s, "target": t, "sliceRects": sl, "destRects": ds,
              "area": acc, "distinctColors": len(NAMES)}
    with open(os.path.join(ROOT, "out/sp09_nineslice.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
        f.write("\n")
    im = Image.new("RGB", (t["width"], t["height"]))
    d = ImageDraw.Draw(im)
    for r in ds:
        d.rectangle([r["dx"], r["dy"], r["dx"] + r["dw"] - 1, r["dy"] + r["dh"] - 1], fill=tuple(cfg["regions"][COLOR_KEY[r["name"]]]))
    im.save(os.path.join(ROOT, "out/gen/sp09_nineslice.png"))
    print(f"source {s['width']}×{s['height']} borders L{s['left']}/R{s['right']}/T{s['top']}/B{s['bottom']}")
    for row in range(3):
        print(f"dest row{row}: " + " ".join(f"{r['name']}({r['dx']},{r['dy']},{r['dw']},{r['dh']})" for r in ds[row * 3:row * 3 + 3]))
    print(f"area corner={acc['corner']} edge={acc['edge']} center={acc['center']} total={acc['total']}")
    print(f"permille corner={acc['permille']['corner']} edge={acc['permille']['edge']} center={acc['permille']['center']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑(切片与落位全是整数,故可跨语言逐位对照):

source 48×48 borders L16/R16/T16/B16
dest row0: TL(0,0,16,16) top(16,0,208,16) TR(224,0,16,16)
dest row1: left(0,16,16,128) center(16,16,208,128) right(224,16,16,128)
dest row2: BL(0,144,16,16) bottom(16,144,208,16) BR(224,144,16,16)
area corner=1024 edge=10752 center=26624 total=38400
permille corner=26 edge=280 center=693

三区面积账:四角 1024、四边 10752、中心 26624,合计 38400 = 240×160;千分比 26 / 280 / 693。中心占了近七成——九宫格的拉伸成本几乎全在中心。所以取舍是:可重复的纹理(渐变、纯色、条纹)放中心,不可复制的细节(圆角、描边、图标)放四角;四角只要 16×16,因为它从不缩放。

同一份 config 还生成拉伸示意图 out/gen/sp09_nineslice.png(240×160、RGB、9 色),并在 config/sp09_nineslice.json 的 generated 段声明 file / width / height / mode;prism_rungen.py 回读它并断言尺寸、模式与颜色数——规范既是生成器,也是闸门。

9.2.3 P 层:同一套切分公式的 TS 内核

// file: core/src/sp09/nineslice.ts
// tier: P
// verify: node --test 'core/test/sp09_*.test.ts'
// 9.2 九宫格切分与落位的 P 层内核。与 sim/sp09_nineslice.py 同一套整数公式:中心宽 =
// srcW - left - right;四角保尺寸,四边单轴拉伸,中心双轴拉伸。全部整数,故跨语言逐位一致。
export const SLICE_NAMES = ["TL", "top", "TR", "left", "center", "right", "BL", "bottom", "BR"];
export const SLICE_KINDS = ["corner", "edge", "corner", "edge", "center", "edge", "corner", "edge", "corner"];
function grid(w: number, h: number, left: number, right: number, top: number, bottom: number): number[][] {
  const cw = w - left - right;
  const ch = h - top - bottom;
  if (cw <= 0 || ch <= 0) throw new Error(`中心 ${cw}×${ch} 非正`);
  const x2 = left + cw;
  const y2 = top + ch;
  return [
    [0, 0, left, top], [left, 0, cw, top], [x2, 0, right, top],
    [0, top, left, ch], [left, top, cw, ch], [x2, top, right, ch],
    [0, y2, left, bottom], [left, y2, cw, bottom], [x2, y2, right, bottom],
  ];
}
function rects(w: number, h: number, l: number, r: number, t: number, b: number, keys: string[]): any[] {
  return grid(w, h, l, r, t, b).map((g, i) => {
    const o: any = { name: SLICE_NAMES[i], kind: SLICE_KINDS[i] };
    keys.forEach((k, j) => { o[k] = g[j]; });
    return o;
  });
}
export function sliceRects(w: number, h: number, l: number, r: number, t: number, b: number): any[] {
  return rects(w, h, l, r, t, b, ["sx", "sy", "sw", "sh"]);
}
export function destRects(w: number, h: number, l: number, r: number, t: number, b: number): any[] {
  return rects(w, h, l, r, t, b, ["dx", "dy", "dw", "dh"]);
}
export function areaAccount(dests: any[], total: number): any {
  const acc: any = { corner: 0, edge: 0, center: 0 };
  for (const d of dests) acc[d.kind] += d.dw * d.dh;
  acc.total = total;
  acc.permille = {
    corner: Math.floor((acc.corner * 1000) / total),
    edge: Math.floor((acc.edge * 1000) / total),
    center: Math.floor((acc.center * 1000) / total),
  };
  return acc;
}

core/test/sp09_nineslice.test.ts(36 行)读 Python 落盘的 out/sp09_nineslice.json,断言切片矩形、落位矩形、面积账逐位一致,并用三个负例确认「目标或源小于四边之和」会抛错而不是静默出负数。实跑 node --test 'core/test/sp09_*.test.ts',本节 3 条(全套 12 条全绿):

✔ 源切片矩形与目标落位矩形:逐位一致 (1.143458ms)
✔ 面积账与千分比:整数类逐位一致 (0.157042ms)
✔ 负例:目标或源小于四边之和必须报错(中心必须为正) (0.27275ms)

9.2.4 W 层:浏览器里真拉伸

S/P 只算了矩形,没验证像素。W 层把同一份 nineslice.ts 擦类型后内联进单文件 HTML,在无头 Chromium 里先按切片矩形画源图、再按落位矩形九宫格拉伸(关掉 imageSmoothingEnabled),最后回读像素:

// file: web/build_sp09_nineslice.mjs
// tier: W
// verify: node web/build_sp09_nineslice.mjs
// 第 9.2 节 W 层:把 core/src/sp09/nineslice.ts 擦除类型后内联进单文件 HTML,在浏览器里先按
// 切片矩形画源图,再按落位矩形九宫格拉伸(关掉 imageSmoothing),最后回读像素暴露到
// globalThis.__prism。复用 build_sp.mjs 的擦除写法。
import { stripTypeScriptTypes } from "node:module";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp09_nineslice.json"), "utf8"));
const src = readFileSync(resolve(ROOT, "core/src/sp09/nineslice.ts"), "utf8");
const js = stripTypeScriptTypes(src, { mode: "strip", sourceUrl: "sp09/nineslice.ts" })
  .replace(/^import[^;]*;/gm, "").replace(/^export\s+/gm, "");
const html = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><title>prism 九宫格 · ${cfg.game}</title>
<style>body{margin:0;padding:20px;background:#1f2933;color:#eceff1;font:14px/1.6 system-ui}
canvas{display:block;image-rendering:pixelated;background:#1f2933;border:1px solid #2d3a47}
</style></head><body>
<h1>九宫格拉伸看板</h1>
<canvas id="src" width="${cfg.source.width}" height="${cfg.source.height}" style="width:96px;height:96px"></canvas>
<canvas id="nine" width="${cfg.target.width}" height="${cfg.target.height}"></canvas>
<script type="module">
${js}
const CFG = ${JSON.stringify(cfg)};
const KEY = { TL: "cornerTL", top: "edgeTop", TR: "cornerTR", left: "edgeLeft", center: "center",
  right: "edgeRight", BL: "cornerBL", bottom: "edgeBottom", BR: "cornerBR" };
const S = CFG.source, T = CFG.target;
const sr = sliceRects(S.width, S.height, S.left, S.right, S.top, S.bottom);
const dr = destRects(T.width, T.height, S.left, S.right, S.top, S.bottom);
const sc = document.getElementById("src").getContext("2d");
for (const r of sr) { sc.fillStyle = \`rgb(\${CFG.regions[KEY[r.name]]})\`; sc.fillRect(r.sx, r.sy, r.sw, r.sh); }
const ctx = document.getElementById("nine").getContext("2d");
ctx.imageSmoothingEnabled = false;
for (let i = 0; i < 9; i += 1) ctx.drawImage(document.getElementById("src"),
  sr[i].sx, sr[i].sy, sr[i].sw, sr[i].sh, dr[i].dx, dr[i].dy, dr[i].dw, dr[i].dh);
const px = (x, y) => { const d = ctx.getImageData(x, y, 1, 1).data; return [d[0], d[1], d[2]]; };
const cx = Math.floor(S.left / 2), cy = Math.floor(S.top / 2);
globalThis.__prism = { srcRects: sr, dstRects: dr, regions: CFG.regions,
  samples: { TL: px(cx, cy), TR: px(T.width - cx - 1, cy), BL: px(cx, T.height - cy - 1),
    BR: px(T.width - cx - 1, T.height - cy - 1), center: px(Math.floor(T.width / 2), Math.floor(T.height / 2)),
    top: px(Math.floor(T.width / 2), cy), left: px(cx, Math.floor(T.height / 2)) },
  area: areaAccount(dr, T.width * T.height) };
document.title = "prism sp09 nine ready";
</script></body></html>`;
const out = resolve(ROOT, "web/dist/sp09_nineslice.html");
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, html, "utf8");
console.log(`built web/dist/sp09_nineslice.html (${Buffer.byteLength(html)} bytes)`);

verify 脚本用 Playwright 打开看板,断言四角像素与源角块颜色逐位相同、四角与中心不同、边像素不变、canvas 已绘制:

// file: web/verify_sp09_nineslice.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_sp09_nineslice.mjs
// 第 9.2 节 W 层断言:无头 Chromium 打开九宫格看板,采样 globalThis.__prism 与
// out/sp09_nineslice.json 对照(矩形与面积账整数逐位),再断言四角像素 == 源角块颜色、四角与
// 中心不同、canvas 已绘制。用 createRequire 加载全局 playwright(NODE_PATH 对 ESM 无效)。
import { createRequire } from "node:module";
import { readFileSync, mkdirSync, statSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp09_nineslice.json"), "utf8"));
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp09_nineslice.json"), "utf8"));
const pageUrl = pathToFileURL(resolve(ROOT, "web/dist/sp09_nineslice.html")).href;
const shots = resolve(ROOT, "out/shots");
mkdirSync(shots, { recursive: true });
let pass = 0, fail = 0;
const check = (n, ok, extra = "") => { ok ? (pass += 1) : (fail += 1); console.log(`${ok ? "pass" : "fail"} ${n}${extra ? " " + extra : ""}`); };
const browser = await chromium.launch({ headless: true });
const warm = await browser.newPage();          // 冷启动预热:首个 page 读 canvas 恒空白
await warm.goto(pageUrl);
await warm.close();
const page = await browser.newPage({ viewport: { width: 900, height: 700 } });
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 { srcRects: p.srcRects, dstRects: p.dstRects, samples: p.samples, area: p.area }; });
check("无 pageerror", errors.length === 0, errors.join("|"));
check("切片矩形与 Python 逐位一致", JSON.stringify(s.srcRects) === JSON.stringify(py.sliceRects));
check("落位矩形与 Python 逐位一致", JSON.stringify(s.dstRects) === JSON.stringify(py.destRects));
check("面积账与 Python 逐位一致", s.area.corner === py.area.corner && s.area.edge === py.area.edge &&
  s.area.center === py.area.center && JSON.stringify(s.area.permille) === JSON.stringify(py.area.permille));
const R = cfg.regions;
check("四角像素 == 源角块颜色(保尺寸不畸变)",
  JSON.stringify(s.samples.TL) === JSON.stringify(R.cornerTL) && JSON.stringify(s.samples.TR) === JSON.stringify(R.cornerTR) &&
  JSON.stringify(s.samples.BL) === JSON.stringify(R.cornerBL) && JSON.stringify(s.samples.BR) === JSON.stringify(R.cornerBR),
  JSON.stringify(s.samples));
check("四角像素与中心像素不同",
  [s.samples.TL, s.samples.TR, s.samples.BL, s.samples.BR].every((c) => JSON.stringify(c) !== JSON.stringify(s.samples.center)));
check("边像素 == 源边块颜色(单轴拉伸,颜色不变)",
  JSON.stringify(s.samples.top) === JSON.stringify(R.edgeTop) && JSON.stringify(s.samples.left) === JSON.stringify(R.edgeLeft));
const drawn = await page.evaluate(() => { const cv = document.getElementById("nine");
  const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data; let n = 0;
  for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n += 1; return n; });
check("canvas 已绘制", drawn >= 1, `${drawn} px`);
check("绘制像素数 == 目标面积", drawn === cfg.target.width * cfg.target.height, `${drawn}`);
const shot = resolve(shots, "sp09_nineslice.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);

实跑(10 项全 pass):

pass 无 pageerror
pass 切片矩形与 Python 逐位一致
pass 落位矩形与 Python 逐位一致
pass 面积账与 Python 逐位一致
pass 四角像素 == 源角块颜色(保尺寸不畸变) {"TL":[245,158,11],"TR":[34,197,94],"BL":[239,68,68],"BR":[234,88,12],"center":[120,120,120],"top":[236,239,241],"left":[31,41,51]}
pass 四角像素与中心像素不同
pass 边像素 == 源边块颜色(单轴拉伸,颜色不变)
pass canvas 已绘制 38400 px
pass 绘制像素数 == 目标面积 38400
pass 截图非空 9027 bytes
ℹ pass 10
ℹ fail 0

四角 [245,158,11] / [34,197,94] / [239,68,68] / [234,88,12] 与源角块颜色逐位相同,中心 [120,120,120] 与四角都不同——四角没被拉糊,这就是「不畸变」的机器判据。把 imageSmoothingEnabled 打开,四角会在边缘被插值出中间色,这条断言立刻变红。

9.2.5 边界与取舍

  • 九宫格只能处理矩形拉伸。透视、波浪边、不规则切角要靠网格变形或 shader,属于渲染实现,外链 Mesh Shader 与次世代几何 。
  • 真导入引擎(真机上的九宫格组件、各向异性过滤、mip 下的边缘渗色)需要真实引擎运行时,命令行不可复现,标 X;本节只验证 imageSmoothingEnabled = false 下的整数像素。
  • 四角尺寸必须整数且不小于描边宽,否则圆角会被裁;这条约束由 left + right < srcW 的负例测试守住。切分参数一旦改动,out/sp09_nineslice.json 与 PNG 都要重生成,P/W 两层会立刻用新值复验。

阅读导航:上一节:9.1 栅格、间距与安全区 · 下一节:9.3 UI 状态与交互反馈规范 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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