9.1 栅格、间距与安全区

栅格、间距与安全区是 UI 规范里最先被写死、也最容易被分辨率打脸的一层。本节把《星屑旅店》的基准分辨率、12 列栅格、8px 间距阶与刘海屏安全区写成一份可跑的整数规范,用 S 层 Python 与 P 层 TS 内核算出每个元素落在哪一格,并对四条边做越界断言。

9.1.1 UI 栅格是规范的第一层

游戏 UI 的栅格和网页 CSS 栅格、图表可视化的栅格看着像,其实是三件事。图表可视化里的感知编码 关心的是数据如何映射到视觉通道;网页的 CSS Grid 关心的是内容如何在流式布局里重排。本章做的是游戏 UI 的栅格、九宫格与状态规范——它的约束来自一个固定的基准分辨率、整数倍的 DPI 缩放,以及设备物理安全区(刘海、挖孔、圆角),而不是数据刻度,也不是无限重排。

这三节是一条线:9.1 定格子与安全区(元素落在哪一格),9.2 定可拉伸资源(一张图怎么撑到任意尺寸),9.3 定状态与反馈(同一控件在五态下长什么样)。本节产出是一条可断言的规矩:任何 UI 元素都必须能算出它的整数矩形,且矩形必须完全落在安全区内——算不出来的元素,就是没被规范覆盖的元素。

9.1.2 基准分辨率与 12 列栅格

《星屑旅店》是横屏治愈系经营游戏,UI 以桌面 16:9 为基准、向下兼容平板与手机。基准分辨率选 1920×1080,栅格用 12 列——12 能被 2、3、4、6 整除,横排按钮、三栏面板、四分格都能对齐。列与列之间留 24px gutter,内容区两侧留 48px margin。

关键在于列宽不是拍脑袋定的,而是从分辨率反推的整数:

内容宽   = width - 2*margin
列宽     = (内容宽 - (列数-1)*gutter) // 列数        ← 整数地板除
第 i 列 x = margin + i*(列宽 + gutter)
跨 n 列宽 = n*列宽 + (n-1)*gutter

地板除意味着总会有几像素的余数:1920 内容宽 1824,(1824 − 11×24) ÷ 12 = 130 余 0;换成 1170 的手机宽,(1074 − 264) ÷ 12 = 67.5,地板除得 67,右边多出 6px。这 6px 不许摊到某一列上,否则「第 6 列左边界」在不同页面就不一致——规范的做法是余数留给右 margin,所有列左边界按 67 算,元素落位才稳定。参数写进 D 层配置 config/sp09_grid.json:

{
  "version": 1,
  "game": "stardust-inn",
  "base": { "width": 1920, "height": 1080 },
  "columns": 12,
  "gutter": 24,
  "margin": 48,
  "spacingScale": [0, 4, 8, 12, 16, 24, 32, 48, 64],
  "devices": [
    { "id": "desktop_16_9", "width": 1920, "height": 1080, "safe": { "top": 0, "bottom": 0, "left": 0, "right": 0 } },
    { "id": "phone_notch", "width": 1170, "height": 2532, "safe": { "top": 132, "bottom": 102, "left": 0, "right": 0 } },
    { "id": "tablet_4_3", "width": 2048, "height": 1536, "safe": { "top": 48, "bottom": 48, "left": 64, "right": 64 } }
  ],
  "elements": [
    { "id": "dialog", "device": "desktop_16_9", "col": 3, "colSpan": 6, "y": 380, "height": 320 },
    { "id": "hud_left", "device": "tablet_4_3", "col": 0, "colSpan": 3, "y": 120, "height": 96 },
    { "id": "top_bar", "device": "phone_notch", "col": 0, "colSpan": 12, "y": 0, "height": 88 },
    { "id": "shop_button", "device": "phone_notch", "col": 8, "colSpan": 4, "y": 2320, "height": 72 }
  ],
  "rawSpacings": [6, 14, 20, 30, 52]
}

9.1.3 间距阶:8px 阶与吸附

间距不写连续值,只写一档一档的台阶:基准台阶 8px,补 4px 与 12px 做半档与一档半,向上一路到 64px——[0, 4, 8, 12, 16, 24, 32, 48, 64]。设计师给「大概 20px」时规范不接收 20,而是向上吸附到最近的台阶 24;向下的 16 会让相邻元素贴得过紧、出现「看起来挨着」的观感事故,向上只多留一点白,代价小得多。吸附是纯整数:snap_up(24) = 24(刚好在台阶上不再跳),snap_up(25) = 32,snap_up(52) = 64——这三条是 P 层测试里锁死的边界。

9.1.4 安全区:刘海与挖孔

安全区(safe area)是物理屏幕的可用区域:刘海机顶部躲开 132px、底部躲开 Home Indicator 102px;平板横屏左右各躲 64px。安全区不是「减去栅格 margin」,而是与栅格内容区求交——元素矩形必须同时落在两者内。

坑在:栅格全局 margin 是 48px,平板安全区左内缩却是 64px。48 < 64,于是贴 col 0 的元素(x = 48)全掉进安全区外。规范必须报出来,不能默默裁掉:

设备内容宽列宽左安全边界col 0 的 x判定
desktop_16_91824130048通过
phone_notch107467048通过
tablet_4_319521406448左越界

修法两条:把该设备 margin 提到 64,或让元素起点挪到能进安全区的列。规范只负责报,怎么修由设计定。

9.1.5 S 层:整数落位与越界断言

下面这段是 S 层脚本,读 config/sp09_grid.json,对每台设备算列宽与列边界,对每个元素求矩形并做四条边的越界断言,最后把原始间距吸附到台阶,落 out/sp09_grid.json:

#!/usr/bin/env python3
# file: sim/sp09_grid.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp09_grid.py
"""9.1 栅格、间距与安全区的 S 层整数账。读 config/sp09_grid.json,对每台设备算列宽
(=(内容宽-(列数-1)*gutter)//列数)与列边界,对每个 UI 元素求「落在哪一格」并做四条边
越界断言(元素矩形必须完全落在安全区内),最后把原始间距向上吸附到 8px 阶。
落 out/sp09_grid.json,供 P 层跨语言逐位对照。
"""
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)


def column_width(width: int, margin: int, columns: int, gutter: int) -> int:
    content = width - 2 * margin
    return (content - (columns - 1) * gutter) // columns


def column_x(index: int, margin: int, colw: int, gutter: int) -> int:
    return margin + index * (colw + gutter)


def span_width(span: int, colw: int, gutter: int) -> int:
    return span * colw + (span - 1) * gutter


def snap_up(value: int, scale) -> int:
    for s in scale:
        if s >= value:
            return s
    return scale[-1]


def safe_rect(dev: dict) -> dict:
    s = dev["safe"]
    return {"left": s["left"], "top": s["top"],
            "right": dev["width"] - s["right"], "bottom": dev["height"] - s["bottom"]}


def place_element(el: dict, dev: dict, margin: int, columns: int, gutter: int) -> dict:
    colw = column_width(dev["width"], margin, columns, gutter)
    x = column_x(el["col"], margin, colw, gutter)
    w = span_width(el["colSpan"], colw, gutter)
    right = x + w
    bottom = el["y"] + el["height"]
    sr = safe_rect(dev)
    v = []
    if x < sr["left"]:
        v.append("left")
    if right > sr["right"]:
        v.append("right")
    if el["y"] < sr["top"]:
        v.append("top")
    if bottom > sr["bottom"]:
        v.append("bottom")
    return {"id": el["id"], "device": el["device"], "x": x, "y": el["y"], "w": w, "h": el["height"],
            "right": right, "bottom": bottom, "safe": sr, "violations": v}


def main() -> int:
    cfg = json.load(open(os.path.join(ROOT, "config/sp09_grid.json"), encoding="utf-8"))
    columns, gutter, margin = cfg["columns"], cfg["gutter"], cfg["margin"]
    dev_by_id = {d["id"]: d for d in cfg["devices"]}

    per_device = {}
    for dev in cfg["devices"]:
        colw = column_width(dev["width"], margin, columns, gutter)
        per_device[dev["id"]] = {
            "width": dev["width"], "height": dev["height"],
            "contentWidth": dev["width"] - 2 * margin, "columnWidth": colw,
            "columnX": [column_x(i, margin, colw, gutter) for i in range(columns)],
            "safe": safe_rect(dev),
        }

    elements = [place_element(el, dev_by_id[el["device"]], margin, columns, gutter)
                for el in cfg["elements"]]
    snapped = [{"raw": v, "snapUp": snap_up(v, cfg["spacingScale"])} for v in cfg["rawSpacings"]]
    violations = [{"id": e["id"], "side": s} for e in elements for s in e["violations"]]

    report = {"game": cfg["game"], "columns": columns, "gutter": gutter, "margin": margin,
              "spacingScale": cfg["spacingScale"], "perDevice": per_device,
              "elements": elements, "snapped": snapped,
              "violationCount": len(violations), "violations": violations}

    with open(os.path.join(ROOT, "out/sp09_grid.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
        f.write("\n")

    for d, v in per_device.items():
        print(f"{d}: colWidth={v['columnWidth']} content={v['contentWidth']} safe={v['safe']}")
    for e in elements:
        print(f"  {e['id']:12s} x={e['x']:4d} w={e['w']:4d} right={e['right']:4d} "
              f"violations={e['violations']}")
    print(f"snapped={[(s['raw'], s['snapUp']) for s in snapped]}")
    print(f"violationCount={len(violations)}")
    print("wrote out/sp09_grid.json")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/sp09_grid.py:

desktop_16_9: colWidth=130 content=1824 safe={'left': 0, 'top': 0, 'right': 1920, 'bottom': 1080}
phone_notch: colWidth=67 content=1074 safe={'left': 0, 'top': 132, 'right': 1170, 'bottom': 2430}
tablet_4_3: colWidth=140 content=1952 safe={'left': 64, 'top': 48, 'right': 1984, 'bottom': 1488}
  dialog       x= 510 w= 900 right=1410 violations=[]
  hud_left     x=  48 w= 468 right= 516 violations=['left']
  top_bar      x=  48 w=1068 right=1116 violations=['top']
  shop_button  x= 776 w= 340 right=1116 violations=[]
snapped=[(6, 8), (14, 16), (20, 24), (30, 32), (52, 64)]
violationCount=2
wrote out/sp09_grid.json

两个元素越界、越的是两条不同的边:hud_left 撞左边(48 < 64),top_bar 撞上边(y=0 < 132)。

9.1.6 P 层:同一套整数公式的 TS 内核

下面这段是 P 层 TS 内核 core/src/sp09/grid.ts。它和 Python 用同一套地板除公式,因此列宽、列边界、元素矩形全部逐位一致:

// file: core/src/sp09/grid.ts
// tier: P
// verify: node --test 'core/test/sp09_*.test.ts'
// 9.1 栅格、间距与安全区的 P 层内核。与 sim/sp09_grid.py 同一套整数公式(列宽 = (width -
// 2*margin - (columns-1)*gutter) // columns;跨 n 列宽 = n*列宽 + (n-1)*gutter),全部地板除故逐位一致。

export function columnWidth(width: number, margin: number, columns: number, gutter: number): number {
  const content = width - 2 * margin;
  return Math.floor((content - (columns - 1) * gutter) / columns);
}

export function columnX(index: number, margin: number, colw: number, gutter: number): number {
  return margin + index * (colw + gutter);
}

export function spanWidth(span: number, colw: number, gutter: number): number {
  return span * colw + (span - 1) * gutter;
}

export function snapUp(value: number, scale: number[]): number {
  for (const s of scale) if (s >= value) return s;
  return scale[scale.length - 1];
}

export function safeRect(dev: any): { left: number; top: number; right: number; bottom: number } {
  const s = dev.safe;
  return { left: s.left, top: s.top, right: dev.width - s.right, bottom: dev.height - s.bottom };
}

export function placeElement(el: any, dev: any, margin: number, columns: number, gutter: number): any {
  const colw = columnWidth(dev.width, margin, columns, gutter);
  const x = columnX(el.col, margin, colw, gutter);
  const w = spanWidth(el.colSpan, colw, gutter);
  const right = x + w;
  const bottom = el.y + el.height;
  const sr = safeRect(dev);
  const violations: string[] = [];
  if (x < sr.left) violations.push("left");
  if (right > sr.right) violations.push("right");
  if (el.y < sr.top) violations.push("top");
  if (bottom > sr.bottom) violations.push("bottom");
  return { id: el.id, device: el.device, x, y: el.y, w, h: el.height, right, bottom, safe: sr, violations };
}

测试 core/test/sp09_grid.test.ts 读 Python 落盘的 out/sp09_grid.json 做对照,并用内存设备把四条边各构造一次越界,确认每条都能被报出:

// file: core/test/sp09_grid.test.ts
// tier: P
// verify: node --test 'core/test/sp09_*.test.ts'
// 跨语言对照:读 sim/sp09_grid.py 的 out/sp09_grid.json,断言列宽/列边界/元素落位/越界集合逐位一致;负例覆盖四条边。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { columnWidth, columnX, spanWidth, snapUp, placeElement } from "../src/sp09/grid.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp09_grid.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp09_grid.json"), "utf8"));
const { columns, gutter, margin } = cfg;
const devById: Record<string, any> = Object.fromEntries(cfg.devices.map((d: any) => [d.id, d]));

test("列宽与列边界:整数类逐位一致", () => {
  for (const [id, v] of Object.entries(py.perDevice) as [string, any][]) {
    const dev = devById[id];
    assert.equal(columnWidth(dev.width, margin, columns, gutter), v.columnWidth, `${id} 列宽`);
    const xs = Array.from({ length: columns }, (_, i) => columnX(i, margin, v.columnWidth, gutter));
    assert.deepEqual(xs, v.columnX, `${id} 列边界`);
  }
});

test("元素落位与越界集合:逐位一致", () => {
  assert.equal(py.elements.length, cfg.elements.length);
  for (let i = 0; i < cfg.elements.length; i += 1) {
    const mine = placeElement(cfg.elements[i], devById[cfg.elements[i].device], margin, columns, gutter);
    const theirs = py.elements[i];
    assert.equal(mine.x, theirs.x, `${theirs.id} x`);
    assert.equal(mine.w, theirs.w, `${theirs.id} w`);
    assert.equal(mine.right, theirs.right, `${theirs.id} right`);
    assert.deepEqual(mine.violations, theirs.violations, `${theirs.id} 越界`);
  }
  assert.equal(py.violationCount, py.violations.length);
  // 正例:完全落在安全区内的元素无违例
  assert.deepEqual(placeElement(cfg.elements[0], devById["desktop_16_9"], margin, columns, gutter).violations, []);
});

test("间距吸附到 8px 阶:向上取整,逐位一致", () => {
  for (const s of py.snapped) assert.equal(snapUp(s.raw, cfg.spacingScale), s.snapUp, `raw ${s.raw}`);
  // 边界:刚好等于台阶值时不再上跳
  assert.equal(snapUp(24, cfg.spacingScale), 24);
  assert.equal(snapUp(25, cfg.spacingScale), 32);
});

test("负例:四条边各自都必须能被报出(每次只留一条违例)", () => {
  const dev = (safe: any) => ({ id: "d", width: 1000, height: 800, safe });
  const el = (o: any) => Object.assign({ id: "e", device: "d", col: 0, colSpan: 1, y: 100, height: 50 }, o);
  const zero = { left: 0, top: 0, right: 0, bottom: 0 };
  // 左越界:col 0 的 x=margin=48 < safe.left=60
  assert.deepEqual(placeElement(el({}), dev({ ...zero, left: 60 }), margin, columns, gutter).violations, ["left"]);
  // 上越界:y=0 小于 safe.top=60
  assert.deepEqual(placeElement(el({ y: 0 }), dev({ ...zero, top: 60 }), margin, columns, gutter).violations, ["top"]);
  // 右越界:col 11 跨 3 列会撑到内容宽之外
  assert.deepEqual(placeElement(el({ col: 11, colSpan: 3 }), dev({ ...zero, right: 40 }), margin, columns, gutter).violations, ["right"]);
  // 下越界:底边 830 超过 safe.bottom=760
  assert.deepEqual(placeElement(el({ y: 790, height: 40 }), dev({ ...zero, bottom: 40 }), margin, columns, gutter).violations, ["bottom"]);
});

实跑 node --test 'core/test/sp09_*.test.ts'(此处只列本节 4 条;三节合计 14 条全绿):

✔ 列宽与列边界:整数类逐位一致
✔ 元素落位与越界集合:逐位一致
✔ 间距吸附到 8px 阶:向上取整,逐位一致
✔ 负例:四条边各自都必须能被报出(每次只留一条违例)
ℹ tests 14
ℹ pass 14
ℹ fail 0

9.1.7 边界与取舍

  • 本节所有量都是整数(列宽、列边界、元素矩形、吸附台阶),跨语言逐位一致,不涉及色彩派生量;真设备适配(真机刘海形状、动态安全区、DPI 缩放后的像素取整)需真实设备与引擎运行时,命令行不可复现,标 X。安全区的物理像素来自厂商文档、随机型变,规范应把它做成可替换参数(devices[].safe)而非写死;9.3 会把同一套设备参数用于状态反馈的可点区域下限。

阅读导航:上一节:8.3 整包资源预算表 · 下一节:9.2 九宫格与可拉伸资源 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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