4.1 系统清单与依赖图
第三章把核心循环拆成了资源闭环与三级节奏,但那些循环是「玩法视角」的描述。到了要排期、要分工、要决定「先做哪个系统」的时候,产品侧需要的是一张系统地图:有哪些系统、谁先谁后、改一个会不会带倒一片。这一章就以 D 静态层为主,把这张地图写成结构化配置,并用脚本把它校验到能放心当作后续章节的输入。
需要先划清边界。站内 SLG 技术架构系列 的九篇讲的是前后端分层、模块拆分与协议设计,明确说「不讨论玩法数值」;本节讲的是产品侧的系统地图——系统之间的业务先后关系与解锁顺序,而不是代码模块怎么分层。两者看的是同一款游戏,但一张图回答「先做哪个功能」,另一张图回答「代码放哪个服务」。
4.1.1 为什么先画系统地图
新手常犯的错误是拿一张功能树当系统地图用。功能树回答「界面上有什么入口」,系统地图回答「业务上谁依赖谁」。这两件事经常不一致:功能树上「联盟」和「邮件」可能并排放在主界面同一层,但业务上联盟依赖世界地图、世界地图依赖战斗,而邮件只依赖账号——它们的前置链长度差了三层。
系统地图的第一个用途是排期。如果 12 个系统里有 4 个都要等「战斗结算」做完,那战斗就是关键路径,砍它的工期收益最大。第二个用途是解锁节奏,这正是 4.2 的主题:玩家第几天见到第几个系统,本质上是在这张图上逐层开放。第三个用途是变更影响面,4.3 会用同一张图算出「改动某个系统会波及多少下游」。
要得到这三个用途,系统地图必须满足几个基本性质:不能有环(A 依赖 B、B 又依赖 A,谁也做不出来)、不能有孤儿(既无前置也无人依赖的孤立节点,多半是漏写了关系)、每个系统都要能从一个根出发到达(否则它永远解锁不了)。这些性质靠人眼看不可靠——十八个系统两两之间就是三百多种可能关系,得靠脚本。
还有一个容易混淆的点:系统地图和后面第五章的功能树不是一张图。系统地图描述业务先后,节点是「系统」;功能树描述界面归属,节点是「模块」。同一批业务系统,可以按不同的界面组织方式落到不同的功能树里——比如「邮件」在系统地图里只依赖账号,但它在功能树上可能挂在主界面顶栏、也可能挂在联盟页里。先把系统地图钉死,功能树才有稳定的业务锚点。
4.1.2 系统清单的字段设计
把《王朝纪元》的系统清单落成一份 JSON 配置。每个系统四个字段:id 是稳定的英文标识,name 是展示名,category 只用于分组着色、不参与拓扑判定,deps 是前置系统 id 列表。方向约定为「前置 → 本系统」,也就是 deps 里的每个 id 都表示「先有它,才有本系统」。
| 字段 | 类型 | 作用 | 参与拓扑判定 |
|---|---|---|---|
id | string | 稳定英文标识,被 deps 引用 | 是 |
name | string | 展示名,只影响可读性 | 否 |
category | string | 分组着色(core/economy/combat/social/meta) | 否 |
deps | string[] | 前置系统 id 列表 | 是 |
下面这份是 D 层配置:它只是数据,本机校验它的方式是跑 4.1.3 的 S 层脚本。
{
"version": 1,
"note": "《王朝纪元》系统清单。deps 为前置系统 id(有向边:前置 -> 本系统)。category 只用于分组着色,不参与拓扑判定。",
"systems": [
{ "id": "account", "name": "账号", "category": "core", "deps": [] },
{ "id": "city", "name": "主城", "category": "core", "deps": ["account"] },
{ "id": "mail", "name": "邮件", "category": "meta", "deps": ["account"] },
{ "id": "shop", "name": "商店内购", "category": "meta", "deps": ["account"] },
{ "id": "production", "name": "资源产出", "category": "economy", "deps": ["city"] },
{ "id": "quest", "name": "任务引导", "category": "meta", "deps": ["city"] },
{ "id": "hero", "name": "英雄养成", "category": "combat", "deps": ["city"] },
{ "id": "building", "name": "建筑升级", "category": "economy", "deps": ["city", "production"] },
{ "id": "research", "name": "科技研究", "category": "economy", "deps": ["building"] },
{ "id": "troop", "name": "兵种训练", "category": "combat", "deps": ["building", "research"] },
{ "id": "battle", "name": "战斗结算", "category": "combat", "deps": ["troop", "hero"] },
{ "id": "worldmap", "name": "世界地图", "category": "meta", "deps": ["battle"] },
{ "id": "march", "name": "行军出征", "category": "combat", "deps": ["worldmap"] },
{ "id": "occupy", "name": "地块占领", "category": "economy", "deps": ["march"] },
{ "id": "rank", "name": "排行榜", "category": "social", "deps": ["battle"] },
{ "id": "alliance", "name": "联盟", "category": "social", "deps": ["worldmap"] },
{ "id": "alliance-war", "name": "联盟战", "category": "social", "deps": ["alliance", "occupy"] },
{ "id": "season", "name": "赛季", "category": "meta", "deps": ["alliance-war", "rank"] }
]
}
十八个系统覆盖了从账号到赛季的完整业务链。注意几处刻意的设计:battle 同时依赖 troop 与 hero,说明「先有兵、再有将、才能打」;alliance-war 同时依赖 alliance 与 occupy,说明联盟战要等联盟系统和地块占领都上线;season 放在最末端,依赖联盟战与排行榜——这符合多数 SLG 把赛季作为长线收口的做法。这些关系不是技术上的「代码 import」,而是产品上的「没有前置,本系统跑不起来」。
把清单写成 JSON 而不是写进 PRD 正文,是为了让它同时被人和脚本消费。人读它时看到的是「谁依赖谁」;脚本读它时看到的是「一张有向图」。同一份文件,两种用法——这正是本卷贯穿的「一份配置多处消费」:4.2 的解锁链、4.3 的耦合度量,都会直接读这份 ch04_systems.json,不需要重新抄一遍系统关系。
4.1.3 依赖图校验四问
下面这段是 S 层仿真脚本,读 config/ 跑四问校验并产出拓扑序 CSV 与分层 SVG。四问是:悬空依赖(deps 指向了清单里不存在的 id)、环(Kahn 拓扑排序排不满全部节点)、孤儿(既无前置也无人依赖)、根可达(从根节点出发能否到达全部节点)。拓扑排序的就绪集用最小堆,保证同样的输入永远得到同样的顺序。
# file: sim/ch04_dependency_graph.py
# tier: S
# verify: python3 sim/ch04_dependency_graph.py --config config/
"""系统清单 -> 依赖图校验:悬空依赖、环、孤儿、根可达性,并产出拓扑序 CSV 与分层 SVG。
依赖语义:systems[].deps 里每个前置 p 表示一条有向边 p -> s(先有 p 才有 s)。
"""
from __future__ import annotations
import argparse
import csv
import heapq
import json
import os
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "..", "out")
NS = "http://www.w3.org/2000/svg"
COLORS = {"core": "#1f6feb", "economy": "#2ea043", "combat": "#d1242f", "social": "#8250df", "meta": "#9a6700"}
def load(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
def validate(systems: list[dict]) -> dict:
ids = [s["id"] for s in systems]
idset = set(ids)
deps = {s["id"]: list(s["deps"]) for s in systems}
dependents: dict[str, list[str]] = {i: [] for i in ids}
for s, ps in deps.items():
for p in ps:
dependents.setdefault(p, []).append(s)
dangling = sorted({(s, p) for s, ps in deps.items() for p in ps if p not in idset})
indeg = {i: len([p for p in deps[i] if p in idset]) for i in ids}
ready = [i for i in ids if indeg[i] == 0]
heapq.heapify(ready)
order: list[str] = []
while ready: # Kahn 拓扑排序
i = heapq.heappop(ready)
order.append(i)
for d in dependents.get(i, []):
indeg[d] -= 1
if indeg[d] == 0:
heapq.heappush(ready, d)
roots = sorted(i for i in ids if not deps[i])
seen: set[str] = set()
stack = list(roots)
while stack: # 根可达性
i = stack.pop()
if i in seen:
continue
seen.add(i)
stack += dependents.get(i, [])
layer: dict[str, int] = {}
for i in order: # 最长前置路径 = 分层
ps = [p for p in deps[i] if p in idset]
layer[i] = 1 if not ps else 1 + max(layer[p] for p in ps)
return {"ids": ids, "deps": deps, "roots": roots, "order": order, "layer": layer,
"dangling": [list(x) for x in dangling], "cycle": sorted(idset - set(order)),
"orphan": sorted(i for i in ids if not deps[i] and not dependents.get(i)),
"unreachable": sorted(idset - seen), "maxLayer": max(layer.values(), default=0)}
def write_svg(path: str, res: dict, names: dict, cat: dict) -> None:
by: dict[int, list[str]] = {}
for i in res["order"]:
by.setdefault(res["layer"][i], []).append(i)
bw, bh, gx, gy, pad = 116, 34, 64, 20, 30
ncol, rows = max(by), max(len(v) for v in by.values())
W, H = pad * 2 + ncol * bw + (ncol - 1) * gx, pad * 2 + 24 + rows * bh + (rows - 1) * gy
pos = {i: (pad + (l - 1) * (bw + gx), pad + 24 + k * (bh + gy)) for l, ns in by.items() for k, i in enumerate(ns)}
svg = ET.Element("svg", {"xmlns": NS, "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#fff"})
ET.SubElement(svg, "text", {"x": str(pad), "y": "18", "font-size": "13"}).text = \
f"《王朝纪元》系统依赖图({len(res['ids'])} 节点 / {sum(len(v) for v in res['deps'].values())} 边 / {res['maxLayer']} 层)"
for i, ps in res["deps"].items():
for p in ps:
if p in pos:
(x1, y1), (x2, y2) = pos[i], pos[p]
ET.SubElement(svg, "line", {"x1": f"{x2 + bw}", "y1": f"{y2 + bh / 2}", "x2": f"{x1}", "y2": f"{y1 + bh / 2}", "stroke": "#9aa4b2"})
for i in res["order"]:
x, y = pos[i]
ET.SubElement(svg, "rect", {"x": f"{x}", "y": f"{y}", "width": str(bw), "height": str(bh), "rx": "6", "fill": "#f6f8fa", "stroke": COLORS.get(cat.get(i), "#333")})
ET.SubElement(svg, "text", {"x": f"{x + bw / 2}", "y": f"{y + bh / 2 + 4}", "font-size": "11", "text-anchor": "middle"}).text = names.get(i, i)
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
args = ap.parse_args()
cfg_dir = args.config if os.path.isabs(args.config) else os.path.join(HERE, "..", args.config)
systems = load(os.path.join(cfg_dir, "ch04_systems.json"))["systems"]
res = validate(systems)
names = {s["id"]: s["name"] for s in systems}
cat = {s["id"]: s.get("category", "core") for s in systems}
report = {k: res[k] for k in ("roots", "dangling", "cycle", "orphan", "unreachable", "maxLayer")}
report.update({"nodes": len(res["ids"]), "edges": sum(len(v) for v in res["deps"].values()), "topoOrder": res["order"]})
report["ok"] = not (res["dangling"] or res["cycle"] or res["orphan"] or res["unreachable"])
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ch04_graph.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(OUT, "ch04_topo.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["order", "id", "name", "layer"])
for k, i in enumerate(res["order"], 1):
w.writerow([k, i, names[i], res["layer"][i]])
write_svg(os.path.join(OUT, "ch04_graph.svg"), res, names, cat)
print(json.dumps(report, ensure_ascii=False))
return 0 if report["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())
脚本分四块:validate 做四问校验与分层,write_svg 按层号横排画图,main 读配置、写 JSON/CSV/SVG。所有输出都是确定性的——就绪集用最小堆、SVG 坐标全是整数运算,同样的 config/ 跑多少次结果都一样。
4.1.4 实跑结果
本机运行上面脚本,输出(JSON 一行,便于脚本消费):
{"roots": ["account"], "dangling": [], "cycle": [], "orphan": [], "unreachable": [], "maxLayer": 12, "nodes": 18, "edges": 22, "topoOrder": ["account", "city", "hero", "mail", "production", "building", "quest", "research", "shop", "troop", "battle", "rank", "worldmap", "alliance", "march", "occupy", "alliance-war", "season"], "ok": true}
四问全部通过:悬空、环、孤儿、不可达都是空列表,ok 为 true。唯一根是 account,说明整张图从账号出发可达全部 18 个节点;最大层数为 12,即最长的前置链有 12 层。同机相对量级下,22 条边对应 18 个节点,平均入度约 1.2,属于「链式为主、少量汇聚」的形状——这类图的脆弱点通常在汇聚节点(如 battle、alliance-war),而不是链上的单点。
拓扑序 CSV 的前几行如下,layer 列就是最长前置路径长度:
| order | id | name | layer |
|---|---|---|---|
| 1 | account | 账号 | 1 |
| 2 | city | 主城 | 2 |
| 3 | hero | 英雄养成 | 3 |
| 4 | 邮件 | 2 | |
| 5 | production | 资源产出 | 3 |
| 6 | building | 建筑升级 | 4 |
| … | … | … | … |
| 17 | alliance-war | 联盟战 | 11 |
| 18 | season | 赛季 | 12 |
注意 mail 出现在 hero 之后,是因为拓扑排序在就绪节点里按 id 字典序取;它和 hero 同处第 2、3 层之间,谁先出现只影响输出顺序、不影响层级。真正有意义的是 layer:season 在第 12 层,意味着从账号到赛季最长要经过 11 个中间系统,这条链就是排期上的关键路径。
4.1.5 故意破坏一次,确认脚本真的会报错
一个只会对好数据说 ok 的校验器没有价值。把清单改坏——让 a 依赖 c、b 依赖 a、c 依赖 b 造一个环,再塞一个依赖不存在 id 的节点和一个孤立节点——脚本会分别给出 cycle、dangling、orphan 三组非空结果。这四问的价值在于:它们把「人眼扫一遍觉得没问题」变成「脚本明确告诉你哪里有问题」,在系统数从 18 涨到 40 之后,人工检查就不再可行。
值得强调,环检测靠的是 Kahn 算法的一个副作用:如果图里有环,拓扑排序结束后 order 的长度会小于节点总数,差集就是环上(以及被环卡住)的节点。我们不需要单独写一个 DFS 找环,只要比较 len(order) 和 len(ids) 就能定位。这也是把「校验」和「排序」合成一个函数的原因——同一趟遍历顺带把两件事都做了。
4.1.6 从静态图到下一节
到这一步,我们手里有了一份可校验的系统清单和一张分层依赖图。但「有依赖」不等于「按什么节奏放给玩家」:同样 18 个系统,一天全开和分 20 天逐层开放,玩家的开荒体验完全不同。下一节就用这份清单算出解锁时间轴,把「第 N 天解锁第几个系统」变成可跑的仿真。
本章的原型层还会把这份图用 TypeScript 重写一遍,用 node --test 断言拓扑序、环检测与解锁节奏同 Python 侧逐位一致——同一份 config/,两种语言消费,这正是 bastion 工具链要保证的性质。
阅读导航:上一节:3.3 长线目标与成长曲线 · 下一节:4.2 解锁节奏与前置链 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。