本章最后一节,把前两节散落的检查收口成一条发布流水线。先把分工说清楚:站内的 GitHub Actions 与 CI/CD 专题 讲的是 CI/CD 的通用工程实践——工作流语法、矩阵构建、缓存、secrets、runner;本节讲的是小游戏项目特有的发布约束——分包体积门禁、配置表校验、可玩原型回归、多平台产物的构建矩阵。这两层不冲突:通用机制决定「流水线怎么跑」,本节决定「跑哪些检查、哪些失败必须拦、哪些只告警」。
有一条边界必须先声明,否则读者会误以为本节在教你「怎么配 CI 平台」:真实 CI 平台上的流水线执行属不可复现(X)。同一份 YAML 在不同 runner、不同缓存状态、不同并发下行为不同,本机跑不出「平台的那条流水线」。所以本节给的不是平台配置,而是同一套检查逻辑的本地可运行版本——把流水线里每一条检查项抽成一个纯函数,在本地逐条真跑,让「发布前该绿的东西」在推到平台之前就绿。平台的流水线把这些检查再跑一遍,两边的判定口径一致。
D 层:发布流水线定义
下面这段是 D 层(静态层) 的流水线定义 config/e16_pipeline.json:四个阶段,每阶段列一组检查项与一个失败动作(block 表示失败即拦、warn 表示只告警)。它只是数据——真正执行这些检查的是下面那支 S 层脚本。注意 budgetKB 与 requiredFiles 也写在这里,让「体积预算是多少、哪些文件必须有」成为可被脚本读取的声明,而不是散落在脚本里的常量。
{
"version": 1,
"project": "merge-island",
"seed": 20261012,
"budgetKB": { "config": 64 },
"requiredFiles": [
"config/e16_schema.json",
"config/e16_datatable.json",
"config/e16_pipeline.json",
"core/src/e16/boundary.ts",
"core/src/e16/validate.ts",
"core/test/e16_boundary.test.ts",
"core/test/e16_validate.test.ts",
"sim/e16_precheck.py"
],
"stages": [
{ "id": "config-valid", "title": "配置文件合法性", "checks": ["json-parses", "no-floats", "table-matches-schema"], "onFailure": "block" },
{ "id": "files-present", "title": "必需文件齐备", "checks": ["required-files"], "onFailure": "block" },
{ "id": "size-budget", "title": "配置体积预算", "checks": ["size-budget"], "onFailure": "warn" },
{ "id": "code-hygiene", "title": "代码规范", "checks": ["naming-prefix", "no-random-in-core"], "onFailure": "block" }
]
}
四个阶段的分工:配置文件合法性(JSON 可解析、无浮点、数据表与 schema 表名一致)是硬门槛,配错了游戏根本跑不起来;必需文件齐备防止「漏提交一个文件、平台构建才发现」;配置体积预算是软告警,超一点不拦、超很多才该回头看;代码规范(本章文件一律 e16_ 前缀、core/ 里无 Math.random())是硬门槛,因为它守住的是「不与卷一 chNN 撞车」和「随机必须可播种」这两条本套书的底线。
S 层:本地发布前检查
下面这段是 S 层脚本 sim/e16_precheck.py:它读上面那份流水线定义,把每个检查项在本地真跑一遍,按阶段的 onFailure 判定是否阻塞,产出 JSON、CSV、报告文本与一张 SVG。它的价值在于可复现——同一份工作区、同一份脚本,每次得到同一份报告;这正是平台流水线做不到的(X)。
#!/usr/bin/env python3
# file: sim/e16_precheck.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e16_precheck.py --config config/ --out out/
"""S 层:发布前检查的**本地可运行版本**。
真实 CI 平台上的流水线执行属不可复现(X)——同样的 YAML 在不同 runner、
不同缓存、不同并发下行为不同,本机跑不出「平台的流水线」。本脚本把
config/e16_pipeline.json 里声明的阶段与检查项**在本地逐条真跑一遍**,
产出 JSON + CSV + 报告文本 + SVG,让「发布前该绿的东西」在推之前就绿。
检查项(与流水线声明一一对应):
json-parses config/e16_*.json 全部可解析
no-floats 配置里无浮点字面量(跨语言逐位一致的前提)
table-matches-schema 数据表 table 字段与 schema 一致
required-files 流水线声明的必需文件齐备
size-budget 配置总体积不超过 budgetKB.config
naming-prefix 本章文件一律 e16_ 前缀(不与卷一 chNN 撞车)
no-random-in-core core/src/e16 下无 Math.random()(随机必须可播种)
全整数、无浮点;图表用纯 Python SVG。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import xml.etree.ElementTree as ET
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # pebble/
FLOAT_RE = re.compile(r"-?\d+\.\d+")
def strip_strings(line: str) -> str:
line = re.sub(r'"(?:[^"\\]|\\.)*"', '""', line)
line = re.sub(r"'(?:[^'\\]|\\.)*'", "''", line)
return line
def config_files(cfg_dir: str) -> list[str]:
return sorted(f for f in os.listdir(cfg_dir) if f.startswith("e16_") and f.endswith(".json"))
def check_json_parses(cfg_dir: str) -> tuple[str, str]:
bad = []
for f in config_files(cfg_dir):
try:
json.load(open(os.path.join(cfg_dir, f), encoding="utf-8"))
except json.JSONDecodeError as e:
bad.append(f"{f}:{e.lineno}")
return ("pass", f"{len(config_files(cfg_dir))} 个配置均可解析") if not bad else ("fail", "解析失败 " + ",".join(bad))
def check_no_floats(cfg_dir: str) -> tuple[str, str]:
hits = []
for f in config_files(cfg_dir):
for i, line in enumerate(open(os.path.join(cfg_dir, f), encoding="utf-8"), 1):
m = FLOAT_RE.search(strip_strings(line))
if m:
hits.append(f"{f}:{i}:{m.group(0)}")
return ("pass", "无浮点字面量") if not hits else ("fail", "浮点 " + ",".join(hits))
def check_table_matches_schema(cfg_dir: str) -> tuple[str, str]:
schema = json.load(open(os.path.join(cfg_dir, "e16_schema.json"), encoding="utf-8"))
data = json.load(open(os.path.join(cfg_dir, "e16_datatable.json"), encoding="utf-8"))
if schema["table"] == data["table"]:
return "pass", f"表名一致:{data['table']}({len(data['rows'])} 行)"
return "fail", f"表名不符 {data['table']} != {schema['table']}"
def check_required_files(pipeline: dict) -> tuple[str, str]:
missing = [p for p in pipeline["requiredFiles"] if not os.path.isfile(os.path.join(ROOT, p))]
return ("pass", f"{len(pipeline['requiredFiles'])} 个必需文件齐备") if not missing else ("fail", "缺 " + ",".join(missing))
def check_size_budget(cfg_dir: str, pipeline: dict) -> tuple[str, str]:
total = sum(os.path.getsize(os.path.join(cfg_dir, f)) for f in config_files(cfg_dir))
budget = pipeline["budgetKB"]["config"] * 1024
status = "pass" if total <= budget else "fail"
return status, f"配置体积 {total} B / 预算 {budget} B"
def check_naming_prefix() -> tuple[str, str]:
bad = []
for d, pat in ((os.path.join(ROOT, "sim"), r"^e16_.+\.py$"),
(os.path.join(ROOT, "core", "test"), r"^e16_.+\.test\.ts$")):
if not os.path.isdir(d):
continue
for f in sorted(os.listdir(d)):
if "e16" in f and not re.match(pat, f):
bad.append(f)
return ("pass", "e16 文件命名规范") if not bad else ("fail", "命名不规范 " + ",".join(bad))
def strip_ts_comments(text: str) -> str:
"""剥掉 // 行注释与 /* */ 块注释,避免把注释里的示例文本当成真实调用。"""
text = re.sub(r"/\*[\s\S]*?\*/", "", text)
text = re.sub(r"//[^\n]*", "", text)
return text
def check_no_random_in_core() -> tuple[str, str]:
d = os.path.join(ROOT, "core", "src", "e16")
hits = []
if os.path.isdir(d):
for f in sorted(os.listdir(d)):
code = strip_ts_comments(open(os.path.join(d, f), encoding="utf-8").read())
if "Math.random(" in code:
hits.append(f)
return ("pass", "core 无 Math.random()") if not hits else ("fail", "出现 Math.random():" + ",".join(hits))
CHECKS = {
"json-parses": lambda cfg, pl: check_json_parses(cfg),
"no-floats": lambda cfg, pl: check_no_floats(cfg),
"table-matches-schema": lambda cfg, pl: check_table_matches_schema(cfg),
"required-files": lambda cfg, pl: check_required_files(pl),
"size-budget": lambda cfg, pl: check_size_budget(cfg, pl),
"naming-prefix": lambda cfg, pl: check_naming_prefix(),
"no-random-in-core": lambda cfg, pl: check_no_random_in_core(),
}
def write_svg(path: str, used: int, budget: int, blocked: int) -> int:
W, H = 480, 150
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H)})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#0f172a"}).text = "配置体积 vs 预算"
bar_max = W - 32
w = bar_max * min(used, budget) // max(budget, 1)
ET.SubElement(svg, "rect", {"x": "16", "y": "44", "width": str(bar_max), "height": "24", "fill": "#e2e8f0", "rx": "4"})
ET.SubElement(svg, "rect", {"x": "16", "y": "44", "width": str(w), "height": "24", "fill": "#22c55e", "rx": "4"})
ET.SubElement(svg, "text", {"x": "16", "y": "92", "font-size": "13", "fill": "#334155"}).text = f"used {used} B / budget {budget} B"
color = "#16a34a" if blocked == 0 else "#dc2626"
ET.SubElement(svg, "text", {"x": "16", "y": "120", "font-size": "13", "fill": color}).text = f"阻塞项失败 {blocked} 个"
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg_dir = args.config if os.path.isabs(args.config) else os.path.join(ROOT, args.config)
out_dir = args.out if os.path.isabs(args.out) else os.path.join(ROOT, args.out)
os.makedirs(out_dir, exist_ok=True)
pipeline = json.load(open(os.path.join(cfg_dir, "e16_pipeline.json"), encoding="utf-8"))
rows = []
blocked = 0
for stage in pipeline["stages"]:
for cid in stage["checks"]:
status, detail = CHECKS[cid](cfg_dir, pipeline)
rows.append({"stage": stage["id"], "check": cid, "status": status, "detail": detail})
if status == "fail" and stage["onFailure"] == "block":
blocked += 1
used = sum(os.path.getsize(os.path.join(cfg_dir, f)) for f in config_files(cfg_dir))
budget = pipeline["budgetKB"]["config"] * 1024
report = {
"version": 1, "source": "sim/e16_precheck.py", "tier": "S",
"project": pipeline["project"], "checks": len(rows),
"passed": sum(1 for r in rows if r["status"] == "pass"),
"failed": sum(1 for r in rows if r["status"] == "fail"),
"blocked": blocked, "configBytes": used, "budgetBytes": budget,
"results": rows,
}
json.dump(report, open(os.path.join(out_dir, "e16_precheck.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=2)
with open(os.path.join(out_dir, "e16_precheck.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["stage", "check", "status", "detail"])
for r in rows:
w.writerow([r["stage"], r["check"], r["status"], r["detail"]])
svg_bytes = write_svg(os.path.join(out_dir, "e16_precheck.svg"), used, budget, blocked)
lines = [f"发布前检查(本地版,真实 CI 平台执行属 X): {len(rows)} 项"]
for r in rows:
lines.append(f" [{'OK ' if r['status'] == 'pass' else 'FAIL'}] {r['stage']}/{r['check']} {r['detail']}")
lines.append(f"== 通过 {report['passed']} / 失败 {report['failed']} / 阻塞 {blocked};配置体积 {used} B / 预算 {budget} B")
text = "\n".join(lines)
open(os.path.join(out_dir, "e16_precheck.txt"), "w", encoding="utf-8").write(text + "\n")
print(text)
print(f"== SVG {svg_bytes} B -> out/e16_precheck.svg")
return 1 if blocked else 0
if __name__ == "__main__":
raise SystemExit(main())
脚本的退出码设计是这条流水线的关键:只要有阻塞项失败,return 1。平台流水线接到的正是一个非零退出码——本地跑绿,平台才不会红。软告警(体积预算超限)只记进报告,不改变退出码。
本机实跑 /opt/homebrew/bin/python3 sim/e16_precheck.py --config config/ --out out/ 的输出如下(同机相对量级,绝对数值不可移植):
发布前检查(本地版,真实 CI 平台执行属 X): 7 项
[OK ] config-valid/json-parses 3 个配置均可解析
[OK ] config-valid/no-floats 无浮点字面量
[OK ] config-valid/table-matches-schema 表名一致:merge_items(8 行)
[OK ] files-present/required-files 8 个必需文件齐备
[OK ] size-budget/size-budget 配置体积 2536 B / 预算 65536 B
[OK ] code-hygiene/naming-prefix e16 文件命名规范
[OK ] code-hygiene/no-random-in-core core 无 Math.random()
== 通过 7 / 失败 0 / 阻塞 0;配置体积 2536 B / 预算 65536 B
== SVG 536 B -> out/e16_precheck.svg
七项检查全绿,退出码 0:三个配置均可解析、无浮点、表名一致(merge_items 8 行)、八个必需文件齐备、配置总体积 2536 B 远低于 64 KB 预算、e16_ 命名规范、core/ 无 Math.random()。这份报告可以整段贴进 PR 描述或发布记录——它同时是「这次提交通过了哪些检查」的证据。
顺带一个实现细节值得记下来:no-random-in-core 最初是直接 grep "Math.random(",结果把源码注释里那句「无 Math.random()」也当成了真实调用,报了个假阳性。修法是先剥掉 // 行注释与 /* */ 块注释再扫——这是所有「基于文本的检查」都要踩的坑:注释不是代码。同样地,no-floats 先剥字符串内容再扫数字,否则 "3.14" 这类描述文本会被误判。
三条纪律
- 检查项抽成纯函数,平台与本地共用一套逻辑。每个
check_*都是「读文件 → 返回状态与说明」的纯函数,平台流水线调用它、本地脚本也调用它,两边口径一致;不把判定散进 YAML 的 shell 片段里。 - 失败动作分级。配错、缺文件、命名违规、随机不可播种是
block(拦),体积超预算是warn(不拦)。分级决定退出码,也就决定了平台流水线是红是绿。 - 不可复现处显式标注 X。真实 CI 平台的执行、真实平台后台数据都标 X,本机跑不出;本节给的是同一套检查逻辑的本地可运行版本,能跑、能断言、能复现。
卷二收束
到这里,卷二《引擎、实现与体验打磨》就走完了。回看这条主线:从引擎选型(第 1 章)到工程骨架(第 2 章),从渲染、资源、循环、输入、物理、存档、内存、平台适配(第 3–10 章)到 UI、动效、引导、性能、测试(第 11–15 章),最后落在工程化协作(本章)——代码怎么组织、配置怎么校验、发布怎么收口。贯穿始终的是两条主张:一份 config,三种消费(仿真 / 内核 / 原型读同一组数字)与一份逻辑,两种宿主(mock 与真机逐帧一致)。它们不是口号,而是本节这条流水线每天在守的东西。
下一卷《上线变现与长线运营》接着回答:把这套工程做出来的游戏推上线、赚到钱、活得久。那里会有 eCPM 与 LTV 的仿真、漏斗与留存的模型、A/B 实验与赛季运营——而它们的输入,正是本卷这条流水线守住的配置与逻辑。
阅读导航:上一节:配置驱动与数据表 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。