10.1 安全区与刘海屏适配

同一套 HUD 要在刘海屏、挖孔屏、平板与折叠屏上都躲开遮挡。本节把安全区做成整数几何:屏幕减四边 inset 得安全矩形,元件按千分比锚点走整数地板除放进可用框,再用 AABB 相交对刘海做兜底,Python、TS 与浏览器看板三处逐位一致。

10.1 安全区与刘海屏适配

卷一 9.1 栅格、间距与安全区 从规范角度定了「UI 该留多少边距」;这一节换到管线角度:给定一批真实设备参数,把安全区算成一组整数坐标。屏幕上「哪里被挡住」最终由系统与渲染器决定,本节只做美术侧的可算几何,屏幕裁剪与合成实现外链 《移动端渲染优化》 ,不重推。适配问题的形状很固定:屏幕是一块矩形,遮挡是若干块矩形,元件是若干块矩形——只要三者都能用整数表达,命中与否就退化成矩形相交,不需要渲染器,也不需要真机。

10.1.1 安全矩形是近似,刘海是硬约束

config/ta10_safearea.json 里每台设备有三组整数:screen(屏幕尺寸)、insets(四边安全区 inset,顺序 左/上/右/下)、notches(遮挡矩形 [x,y,w,h] 列表):

设备screeninsetssafe 矩形刘海
phone_notch1170×25320/132/0/102[0,132,1170,2298]1 块(顶部居中 390×132)
phone_punch1080×24000/80/0/60[0,80,1080,2260]1 块(挖孔 80×80)
tablet2048×27320/48/0/40[0,48,2048,2644]无
fold_unfolded2208×17680/0/0/0[0,0,2208,1768]无

inset 是系统给的矩形近似:它把「刘海高度」折成一个上边距,但刘海本身未必恰好贴边,所以规范里两张表都要留——安全矩形负责布局,刘海矩形负责兜底断言。元件用千分比锚点定位(ax=0 贴左、1000 贴右、500 居中),放进安全矩形的可用框:x = safe.x + (safe.w - widget.w) * ax // 1000。千分比加地板除让它天然是整数:同一份锚点在 1170 宽与 2048 宽上各自算出像素坐标,没有浮点,也没有「谁先舍入」的分歧。

10.1.2 S 层:安全矩形、锚定与相交

下面这段是 S 层脚本,读配置算每台设备的安全矩形、每个元件的矩形与刘海命中数,并同时算一份「锚到全屏」的对照版:

#!/usr/bin/env python3
# file: sim/ta10_safearea.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta10_safearea.py
"""安全区与刘海屏适配的整数几何(S 层,卷二 10.1)。

屏幕减四边 inset 得安全矩形,元件按千分比锚点走整数地板除放进可用框,再对刘海做 AABB 相交兜底;
并算「锚到全屏」的对照版。坐标 / 尺寸 / 千分比 / 命中数全整数 → 与 TS 逐位一致。落盘 out/ta10_safearea.json。
"""
import hashlib
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFG = os.path.join(ROOT, "config/ta10_safearea.json")
OUT = os.path.join(ROOT, "out")


def safe_rect(screen, insets):
    l, t, r, b = insets
    return [l, t, screen[0] - l - r, screen[1] - t - b]


def place(box, size, anchor):
    """在可用框 box=(x,y,w,h) 内按千分比 anchor 放 size;负可用框夹到 0。整数地板除。"""
    aw, ah = max(0, box[2] - size[0]), max(0, box[3] - size[1])
    return [box[0] + aw * anchor[0] // 1000, box[1] + ah * anchor[1] // 1000, size[0], size[1]]


def hits(rect, notches):
    """元件矩形碰到的刘海矩形个数(AABB:两轴都重叠才算)。"""
    x, y, w, h = rect
    return sum(1 for nx, ny, nw, nh in notches
               if x < nx + nw and nx < x + w and y < ny + nh and ny < y + h)


def main():
    cfg = json.load(open(CFG, encoding="utf-8"))
    os.makedirs(OUT, exist_ok=True)
    widgets = cfg["widgets"]
    lines, devices, safe_total, naive_total = [], [], 0, 0
    for d in cfg["devices"]:
        safe = safe_rect(d["screen"], d["insets"])
        full = [0, 0, d["screen"][0], d["screen"][1]]
        wl, sh, nhs = [], 0, 0
        for w in widgets:
            r = place(safe, w["size"], w["anchor"])
            nr = place(full, w["size"], w["anchor"])
            h, nh = hits(r, d["notches"]), hits(nr, d["notches"])
            sh += h
            nhs += nh
            lines.append(f"{d['id']}|{w['id']}|{r[0]},{r[1]},{r[2]},{r[3]}")
            wl.append({"id": w["id"], "rect": r, "hits": h, "naiveRect": nr, "naiveHits": nh})
        safe_total += sh
        naive_total += nhs
        devices.append({"id": d["id"], "screen": d["screen"], "safe": safe, "notches": d["notches"],
                        "widgets": wl, "safeHits": sh, "naiveHits": nhs})
    digest = hashlib.sha256("\n".join(sorted(lines)).encode("utf-8")).hexdigest()[:16]
    rep = {"project": cfg["project"], "deviceCount": len(devices), "widgetCount": len(widgets),
           "placements": len(lines), "safeHits": safe_total, "naiveHits": naive_total,
           "devices": devices, "digest": digest}
    with open(os.path.join(OUT, "ta10_safearea.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2, sort_keys=True)
    for d in devices:
        print(f"  {d['id']:<14} safe={d['safe']} safeHits={d['safeHits']} naiveHits={d['naiveHits']}")
    print(f"placements={len(lines)} safeHits={safe_total} naiveHits={naive_total}")
    print(f"digest={digest}")
    return 0


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

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

  phone_notch    safe=[0, 132, 1170, 2298] safeHits=0 naiveHits=1
  phone_punch    safe=[0, 80, 1080, 2260] safeHits=0 naiveHits=1
  tablet         safe=[0, 48, 2048, 2644] safeHits=0 naiveHits=0
  fold_unfolded  safe=[0, 0, 2208, 1768] safeHits=0 naiveHits=0
placements=20 safeHits=0 naiveHits=2
digest=ef36f5cc0fe191d2

4 台设备 × 5 个元件 = 20 个放置点,锚到安全矩形时刘海命中为 0;同样这 5 个元件若锚到全屏,有 2 处会压到刘海。差的这 2 处就是安全区替你挡掉的东西。

10.1.3 P 层:同一套几何的 TS 内核

下面这段是 P 层内核,与 Python 逐条对应——同一个安全矩形公式、同一个千分比地板除、同一套 AABB 相交:

// file: core/src/ta10/safearea.ts
// tier: P
// verify: node --test 'core/test/ta10_*.test.ts'
// 卷二 10.1 安全区整数几何的 TS 侧实现。与 sim/ta10_safearea.py 同一套规则:安全矩形由屏幕减
// inset 得到,元件按千分比锚点走整数地板除放进可用框,再做 AABB 相交数刘海命中。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { createHash } from "node:crypto";

export type Rect = [number, number, number, number];
export type Device = { id: string; screen: number[]; insets: number[]; notches: number[][] };
export type Widget = { id: string; size: number[]; anchor: number[] };

export function safeRect(screen: number[], insets: number[]): Rect {
  const [l, t, r, b] = insets;
  return [l, t, screen[0] - l - r, screen[1] - t - b];
}

export function place(box: Rect, size: number[], anchor: number[]): Rect {
  const availW = Math.max(0, box[2] - size[0]);
  const availH = Math.max(0, box[3] - size[1]);
  const x = box[0] + Math.floor((availW * anchor[0]) / 1000);
  const y = box[1] + Math.floor((availH * anchor[1]) / 1000);
  return [x, y, size[0], size[1]];
}

export function hits(rect: Rect, notches: number[][]): number {
  const [x, y, w, h] = rect;
  let n = 0;
  for (const [nx, ny, nw, nh] of notches) {
    if (x < nx + nw && nx < x + w && y < ny + nh && ny < y + h) n += 1;
  }
  return n;
}

export function placementLines(cfg: any): string[] {
  const lines: string[] = [];
  for (const d of cfg.devices as Device[]) {
    const safe = safeRect(d.screen, d.insets);
    for (const w of cfg.widgets as Widget[]) {
      const r = place(safe, w.size, w.anchor);
      lines.push(`${d.id}|${w.id}|${r[0]},${r[1]},${r[2]},${r[3]}`);
    }
  }
  return lines.sort();
}

export function digest16(lines: string[]): string {
  return createHash("sha256").update(lines.join("\n"), "utf8").digest("hex").slice(0, 16);
}

P 层测试读的是 Python 落盘的 out/ta10_safearea.json(不是同一份源码),做跨语言对照。实跑输出(node --test core/test/ta10_safearea.test.ts):

✔ 安全区几何:TS 复算与 Python 产物逐位一致
✔ 每台设备的安全矩形与命中数与 Python 一致
✔ 锚定样本:phone_notch 上 toast_top 贴安全区顶边,全屏锚定会压到刘海
ℹ tests 3
ℹ pass 3
ℹ fail 0

两侧的元件矩形 digest 都是 ef36f5cc0fe191d2——整数类,逐位一致。toast_top 在 phone_notch 上被锚到 [285,132,600,80]:y 恰好等于上 inset,贴在刘海下沿,命中 0;同一元件锚到全屏是 [285,0,600,80],与刘海矩形 [390,0,390,132] 相交,命中 1。

10.1.4 W 层:浏览器里画出来

下面这段是 W 层构建脚本,用 node:module 的 stripTypeScriptTypes 把 spec.ts 与 safearea.ts 擦除类型后内联进单文件 HTML,为每台设备画一张全分辨率画布:

// file: web/build_ta10_safearea.mjs
// tier: W
// verify: node web/build_ta10_safearea.mjs
// 卷二 10.1 W 层:把 core/src/sp00/spec.ts + ta10/safearea.ts 擦除类型后内联进单文件 HTML。
// 每台设备一张全分辨率画布:安全矩形填 surface、刘海填 danger、元件填 accent。
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 spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta10_safearea.json"), "utf8"));
const strip = (p) => stripTypeScriptTypes(readFileSync(resolve(ROOT, p), "utf8"), { mode: "strip" })
  .replace(/^import[^;]*;\s*$/gm, "").replace(/^export\s*\{[^}]*\};?\s*$/gm, "").replace(/^export\s+/gm, "");
const js = [strip("core/src/sp00/spec.ts"), strip("core/src/ta10/safearea.ts")].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:20px;background:#1f2933;color:#eceff1;font:14px/1.6 system-ui}
#grid{display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start}.dev{background:#252f3b;border:1px solid #2d3a47;border-radius:6px;padding:8px}canvas{display:block;max-width:210px;border:1px solid #3d4a57;image-rendering:pixelated}.cap{margin-top:6px;font:11px/1.5 ui-monospace,monospace;color:#94a3b8;max-width:210px}</style></head><body>
<h1>安全区与刘海屏适配看板</h1>
<div style="color:#94a3b8;font-size:12px">安全矩形填 surface · 刘海填 danger · 元件按千分比锚点填 accent</div>
<div id="grid"></div>
<script type="module">
${js}
const SPEC = ${JSON.stringify(spec)};
const CFG = ${JSON.stringify(cfg)};
const rgb = (t) => "rgb(" + t.join(",") + ")";
const placements = [], devices = {};
for (const d of CFG.devices) {
  const safe = safeRect(d.screen, d.insets);
  const cv = document.createElement("canvas");
  cv.width = d.screen[0]; cv.height = d.screen[1]; cv.id = "cv_" + d.id;
  const ctx = cv.getContext("2d");
  ctx.fillStyle = rgb(SPEC.tokens.bg); ctx.fillRect(0, 0, d.screen[0], d.screen[1]);
  ctx.fillStyle = rgb(SPEC.tokens.surface); ctx.fillRect(safe[0], safe[1], safe[2], safe[3]);
  const ws = []; let safeHits = 0;
  for (const w of CFG.widgets) {
    const r = place(safe, w.size, w.anchor); const h = hits(r, d.notches);
    safeHits += h; placements.push(d.id + "|" + w.id + "|" + r.join(","));
    ws.push({ id: w.id, rect: r, hits: h });
    ctx.fillStyle = rgb(SPEC.tokens.accent); ctx.fillRect(r[0], r[1], r[2], r[3]);
  }
  for (const n of d.notches) { ctx.fillStyle = rgb(SPEC.tokens.danger); ctx.fillRect(n[0], n[1], n[2], n[3]); }
  const wrap = document.createElement("div"); wrap.className = "dev";
  const cap = document.createElement("div"); cap.className = "cap";
  cap.textContent = d.id + " " + d.screen.join("x") + " · safe " + safe.join(",") + " · hits " + safeHits;
  wrap.append(cv, cap); document.getElementById("grid").append(wrap);
  devices[d.id] = { safe, notches: d.notches, widgets: ws, safeHits };
}
globalThis.__prism = { placements, devices, tokens: SPEC.tokens };
document.title = "prism ta10 safearea ready";
</script></body></html>`;
const out = resolve(ROOT, "web/dist/ta10_safearea.html");
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, html, "utf8");
console.log(`built web/dist/ta10_safearea.html (${Buffer.byteLength(html)} bytes)`);

下面这段是 W 层断言脚本,用无头 Chromium 打开看板,采样 globalThis.__prism 与 Python 落盘的 JSON 对照,再按像素验证三层颜色:

// file: web/verify_ta10_safearea.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ta10_safearea.mjs
// 卷二 10.1 W 层断言:无头 Chromium 打开安全区看板,采样 globalThis.__prism 与 Python 落盘的
// out/ta10_safearea.json 对照;再按像素验证安全区 / 刘海 / 元件三层颜色。
import { createRequire } from "node:module";
import { createHash } from "node:crypto";
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 spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta10_safearea.json"), "utf8"));
const pageUrl = pathToFileURL(resolve(ROOT, "web/dist/ta10_safearea.html")).href;
mkdirSync(resolve(ROOT, "out/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: 1100, height: 900 } });
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(() => ({ placements: globalThis.__prism.placements, devices: globalThis.__prism.devices }));
check("无 pageerror", errors.length === 0, errors.join("|"));
const digest = createHash("sha256").update(s.placements.slice().sort().join("\n"), "utf8").digest("hex").slice(0, 16);
check("元件矩形 digest 与 Python 逐位一致", digest === py.digest, `${digest}`);
const flat = Object.values(s.devices).flatMap((d) => d.widgets.map((w) => [w.id, w.rect, w.hits]));
const flatPy = py.devices.flatMap((d) => d.widgets.map((w) => [w.id, w.rect, w.hits]));
check("设备数与全部元件矩形 / 命中数与 Python 逐位一致",
  Object.keys(s.devices).length === py.deviceCount && JSON.stringify(flat) === JSON.stringify(flatPy), `${py.deviceCount}`);
check("安全区命中总数 = 0", py.safeHits === 0 && Object.values(s.devices).reduce((n, d) => n + d.safeHits, 0) === 0);
const px = await page.evaluate(({ T }) => { const ctx = document.getElementById("cv_phone_notch").getContext("2d");
  const at = (x, y) => { const d = ctx.getImageData(x, y, 1, 1).data; return [d[0], d[1], d[2]]; };
  return { widget: at(585, 172), notch: at(585, 66), safe: at(585, 600), outside: at(100, 60), T }; }, { T: spec.tokens });
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
check("元件中心 = accent 色、刘海中心 = danger 色", same(px.widget, px.T.accent) && same(px.notch, px.T.danger), `${JSON.stringify(px.widget)} ${JSON.stringify(px.notch)}`);
check("安全区空白 = surface 色、安全区之外 = bg 色", same(px.safe, px.T.surface) && same(px.outside, px.T.bg), `${JSON.stringify(px.safe)} ${JSON.stringify(px.outside)}`);
const drawn = await page.evaluate(() => { const cv = document.getElementById("cv_phone_notch");
  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`);
const shot = resolve(ROOT, "out/shots/ta10_safearea.png");
await page.screenshot({ path: shot, fullPage: true });
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);

实跑 node web/build_ta10_safearea.mjs 后接 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ta10_safearea.mjs:

built web/dist/ta10_safearea.html (9159 bytes)
pass 无 pageerror
pass 元件矩形 digest 与 Python 逐位一致 ef36f5cc0fe191d2
pass 设备数与全部元件矩形 / 命中数与 Python 逐位一致 4
pass 安全区命中总数 = 0
pass 元件中心 = accent 色、刘海中心 = danger 色 [245,158,11] [239,68,68]
pass 安全区空白 = surface 色、安全区之外 = bg 色 [45,58,71] [31,41,51]
pass canvas 已绘制 2962440 px
pass 截图非空 39658 bytes
ℹ pass 8
ℹ fail 0

8 项全绿。像素采样比对的是 visual_spec.json 的 sRGB 8-bit token(accent [245,158,11]、danger [239,68,68]、surface [45,58,71]、bg [31,41,51])——整数三元组,采样值与之逐位一致。canvas 已绘制 只断言「≥ 1 px」:无头 Chromium 冷启动的首个 page 读 canvas 会恒为空白,故脚本先开一个预热页走一遍再关掉。

10.1.5 三条边界

  • 安全矩形是近似,刘海矩形才是硬约束。系统给的 inset 是把遮挡折成边距的经验值;挖孔、圆角、手势条未必被矩形 inset 完全覆盖,故两张表都要留,并用相交检查兜底。
  • 本节只算几何,不算渲染。真机上「安全区到底是多少」由系统 API 给出,真截图回归要在真实引擎里跑——需真实环境(设备 / 引擎),命令行不可复现。
  • 锚点用千分比而非百分比浮点,是为了让两侧的地板除逐位对齐;写成 0.5 会引入「谁先舍入」的分歧,而 500 // 1000 没有歧义。

阅读导航:上一节:9.3 动画事件与逻辑一致性 · 下一节:10.2 多分辨率资源分级 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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