一个 SLG 项目做到中期,最常听到的一句话是「这个功能到底归谁」。它不是一个组织问题,而是一个产品结构问题:功能没有明确的主人,改需求时就会同时改三处、上线后互相对不上。这一节要做的,是把「功能归谁」从一句口头共识,变成一棵可以校验的树。
先划边界。站内已有一个 SLG 技术架构专题 ,那 9 篇讲的是技术架构——前后端怎么分层、服务端模块怎么拆、通信协议怎么设计,明确写了「不讨论玩法数值」。本章讲的正好是它没碰的那一半:产品侧的功能边界与数据归属。换句话说,那 9 篇回答「代码怎么写」,本章回答「功能归谁、状态归谁」。服务端工程分层另有站内的《MMO 服务器开发》四卷,本章不重复。
还要和上一章分清。第 4 章用 系统清单与依赖图 回答的是「系统之间谁先谁后、谁依赖谁」——那是一张依赖图,节点是系统。本章换成功能树,节点是玩家可感知的功能,关注的是「这个功能属于哪个模块」。依赖图管时序,功能树管归属,两者互补但不可替代。
模块不是组织架构图
多数团队第一次画模块边界,画的是组织架构图:主策一摊、战斗一摊、数值一摊、运营一摊。这张图的致命问题是——人会走,组织会调,而产品结构不该跟着人事变动。一个新人接手,如果模块边界写在他的前任脑子里,他就要花三个月重新推一遍。
正确的做法是让模块成为一个纯粹的职责单元,判据只有两条:
- 功能归属:一个模块是若干「玩家可感知的功能」的集合;
- 数据归属:这个模块拥有它需要写入的那份状态(第 5.2 节展开)。
把这两条写下来,「这个功能归谁」就不再依赖谁在岗。下面先解决第一条。
一棵树,三种切法
同样一批功能,可以按三种方式切模块,各有代价:
| 切法 | 依据 | 优点 | 代价 |
|---|---|---|---|
| 按玩家入口 | 主界面上的标签页 | 玩家直觉一致,引导好做 | 一个入口背后的数据可能横跨多个模块 |
| 按数据归属 | 谁写哪份状态 | 边界最稳,改动局部化 | 与玩家直觉可能不一致,需要额外做引导 |
| 按迭代节奏 | 谁和谁一起上线 | 排期友好 | 节奏一变,模块就被拆散 |
《王朝纪元》选的是以数据归属为主、玩家入口为辅:模块首先按「谁写哪份状态」切,再用主界面入口去对齐命名,让玩家感知和内部边界尽量重合。迭代节奏只影响模块内部的优先级(P0/P1/P2),不改变模块归属。
落到功能树上,就是八个模块:城建、军务、大地图、联盟、任务、商城、活动、账号。
五条不变量
一棵功能树要能「校验」,必须先说清它该满足什么。我们约定五条:
| 不变量 | 含义 | 违反后果 |
|---|---|---|
| 唯一归属 | 每个功能 id 只出现一次,且只属于一个模块 | 同一功能被两个模块各实现一半 |
| 无悬空引用 | 每个功能的父节点必须可解析(功能 id 或 root) | 树上出现挂空的枝,列表页漏渲染 |
| 无环 | 沿父链上溯必须能走到 root | 递归展开功能树时死循环 |
| 无孤儿 | 每个功能都能从 root 走到 | 功能被写进了配置却永远不显示 |
| 模块覆盖 | 每个模块至少拥有一个功能 | 空模块占着架构位,实际没人维护 |
这五条不是洁癖。它们每一条都对应一个真实事故:悬空引用会让某个功能在新版客户端里静默消失;孤儿功能会在「功能上线清单」里被漏掉;空模块会让排期表上永远挂着一个人力缺口却没人知道该做什么。
把功能树写成配置
下面这段是 D 层配置:一棵可直接被脚本读取的功能树,模块用 modules 声明,功能用 features 声明,每个功能带 owner(唯一归属)与 parent(树结构)。
{
"version": 1,
"game": "dynasty-era",
"note": "产品功能树:模块(modules)是产品侧职责单元,功能(features)是玩家可感知的最小可交付点。每个功能必须且只能归属一个模块(唯一归属),父节点必须可解析(无悬空引用)。",
"modules": [
{ "id": "city", "name": "城建", "owner": "系统策划" },
{ "id": "army", "name": "军务", "owner": "战斗策划" },
{ "id": "map", "name": "大地图", "owner": "世界策划" },
{ "id": "alliance", "name": "联盟", "owner": "社交策划" },
{ "id": "quest", "name": "任务", "owner": "系统策划" },
{ "id": "shop", "name": "商城", "owner": "商业化策划" },
{ "id": "event", "name": "活动", "owner": "运营策划" },
{ "id": "account", "name": "账号", "owner": "平台策划" }
],
"features": [
{ "id": "city.build", "name": "建筑建造", "owner": "city", "parent": "root", "priority": "P0" },
{ "id": "city.upgrade", "name": "建筑升级", "owner": "city", "parent": "city.build", "priority": "P0" },
{ "id": "city.queue", "name": "建造队列", "owner": "city", "parent": "city.build", "priority": "P1" },
{ "id": "city.speedup", "name": "建造加速", "owner": "city", "parent": "city.queue", "priority": "P1" },
{ "id": "army.train", "name": "练兵", "owner": "army", "parent": "root", "priority": "P0" },
{ "id": "army.march", "name": "出征", "owner": "army", "parent": "army.train", "priority": "P0" },
{ "id": "army.battle", "name": "战斗结算", "owner": "army", "parent": "army.march", "priority": "P0" },
{ "id": "map.view", "name": "地图浏览", "owner": "map", "parent": "root", "priority": "P0" },
{ "id": "map.tile", "name": "地块占领", "owner": "map", "parent": "map.view", "priority": "P0" },
{ "id": "map.gather", "name": "资源点采集", "owner": "map", "parent": "map.tile", "priority": "P1" },
{ "id": "alliance.create", "name": "创建联盟", "owner": "alliance", "parent": "root", "priority": "P1" },
{ "id": "alliance.help", "name": "联盟互助", "owner": "alliance", "parent": "alliance.create", "priority": "P1" },
{ "id": "alliance.territory", "name": "联盟领地", "owner": "alliance", "parent": "alliance.create", "priority": "P2" },
{ "id": "quest.main", "name": "主线任务", "owner": "quest", "parent": "root", "priority": "P0" },
{ "id": "quest.daily", "name": "日常任务", "owner": "quest", "parent": "root", "priority": "P0" },
{ "id": "shop.goods", "name": "商品陈列", "owner": "shop", "parent": "root", "priority": "P1" },
{ "id": "shop.purchase", "name": "购买", "owner": "shop", "parent": "shop.goods", "priority": "P0" },
{ "id": "shop.limit", "name": "限购", "owner": "shop", "parent": "shop.goods", "priority": "P1" },
{ "id": "event.season", "name": "赛季活动", "owner": "event", "parent": "root", "priority": "P1" },
{ "id": "event.rank", "name": "排行榜", "owner": "event", "parent": "event.season", "priority": "P2" },
{ "id": "account.login", "name": "登录", "owner": "account", "parent": "root", "priority": "P0" },
{ "id": "account.save", "name": "存档同步", "owner": "account", "parent": "account.login", "priority": "P0" }
]
}
注意功能 id 用的是 模块.功能 的命名,一眼就能看出归属;parent 指向另一个功能 id 或 root。这不是为了好看——是为了让脚本能同时校验「唯一归属」和「树结构」。
校验它
下面这段是 S 层仿真脚本,读同一份 config/,把上面五条不变量逐条跑一遍,产出 JSON 报告、CSV 与一张 SVG 卡片图。它不涉及任何数值仿真,是一个纯粹的结构校验器。
# file: sim/ch05_feature_tree.py
# tier: S
# verify: python3 sim/ch05_feature_tree.py --config config/
"""功能树校验(S 层):唯一归属、无悬空引用、无环、无孤儿、模块覆盖。
产出 JSON(stdout + out/)+ CSV(模块规模)+ SVG(模块卡片)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
NS = "http://www.w3.org/2000/svg"
def load(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def validate(tree: dict) -> dict:
mods = {m["id"]: m for m in tree["modules"]}
feats = tree["features"]
V: list[dict] = []
seen: dict[str, int] = {}
for f in feats:
seen[f["id"]] = seen.get(f["id"], 0) + 1
ids = set(seen)
for fid, n in seen.items():
if n > 1:
V.append({"code": "DUP_FEATURE", "target": fid, "detail": f"功能 id 出现 {n} 次,唯一归属被破坏"})
for f in feats:
if f["owner"] not in mods:
V.append({"code": "BAD_OWNER", "target": f["id"], "detail": f"归属模块不存在:{f['owner']}"})
p = f.get("parent", "root")
if p != "root" and p not in ids:
V.append({"code": "DANGLING_PARENT", "target": f["id"], "detail": f"父节点不存在:{p}"})
parent = {f["id"]: f.get("parent", "root") for f in feats}
for f in feats: # 无环
node, walk = f["id"], set()
while node != "root" and node in parent:
if node in walk:
V.append({"code": "CYCLE", "target": f["id"], "detail": "父链成环"})
break
walk.add(node)
node = parent[node]
children: dict[str, list[str]] = {}
for f in feats:
children.setdefault(f.get("parent", "root"), []).append(f["id"])
reach, stack = set(), ["root"]
while stack: # 无孤儿
for c in children.get(stack.pop(), []):
if c not in reach:
reach.add(c)
stack.append(c)
for f in feats:
if f["id"] not in reach:
V.append({"code": "ORPHAN", "target": f["id"], "detail": "从根不可达(孤儿功能)"})
owned: dict[str, list[dict]] = {mid: [] for mid in mods}
for f in feats:
if f["owner"] in owned:
owned[f["owner"]].append(f)
for mid in mods:
if not owned[mid]:
V.append({"code": "EMPTY_MODULE", "target": mid, "detail": "模块无任何功能"})
per = [{"module": mid, "name": m["name"], "owner": m["owner"], "features": len(owned[mid]),
"p0": sum(1 for x in owned[mid] if x.get("priority") == "P0"),
"p1": sum(1 for x in owned[mid] if x.get("priority") == "P1"),
"p2": sum(1 for x in owned[mid] if x.get("priority") == "P2")} for mid, m in mods.items()]
return {"version": 1, "game": tree.get("game"), "ok": not V,
"counts": {"modules": len(mods), "features": len(feats),
"edges": sum(1 for f in feats if f.get("parent", "root") != "root"),
"violations": len(V)}, "violations": V, "perModule": per}
def write_svg(tree: dict, report: dict, path: str) -> None:
per, feats = report["perModule"], tree["features"]
by_mod: dict[str, list[dict]] = {}
for f in feats:
by_mod.setdefault(f["owner"], []).append(f)
cols, cw, ch, gap, pad = 4, 300, 205, 22, 30
rows = (len(per) + cols - 1) // cols
W, H = pad * 2 + cols * cw + (cols - 1) * gap, 70 + pad * 2 + rows * ch + (rows - 1) * gap
ET.register_namespace("", NS)
svg = ET.Element(f"{{{NS}}}svg", {"width": str(W), "height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, f"{{{NS}}}rect", {"width": str(W), "height": str(H), "fill": "#0f1720"})
ET.SubElement(svg, f"{{{NS}}}text", {"x": str(pad), "y": "30", "fill": "#e2e8f0",
"font-size": "16", "font-family": "sans-serif"}).text = \
f"《王朝纪元》功能树:{report['counts']['modules']} 模块 / {report['counts']['features']} 功能 / " \
f"{report['counts']['edges']} 边 / 违规 {report['counts']['violations']}"
pal = {"P0": "#7c3aed", "P1": "#0ea5e9", "P2": "#475569"}
for i, pm in enumerate(per):
r, c = divmod(i, cols)
x, y = pad + c * (cw + gap), 60 + pad + r * (ch + gap)
ET.SubElement(svg, f"{{{NS}}}rect", {"x": str(x), "y": str(y), "width": str(cw), "height": str(ch),
"rx": "10", "fill": "#1e293b", "stroke": "#334155"})
ET.SubElement(svg, f"{{{NS}}}text", {"x": str(x + 14), "y": str(y + 26), "fill": "#f1f5f9",
"font-size": "15", "font-weight": "bold",
"font-family": "sans-serif"}).text = \
f"{pm['name']} · {pm['module']}({pm['features']})"
for j, f in enumerate(by_mod.get(pm["module"], [])[:7]):
yy = y + 50 + j * 20
ET.SubElement(svg, f"{{{NS}}}rect", {"x": str(x + 14), "y": str(yy - 9), "width": "8",
"height": "8", "rx": "2", "fill": pal.get(f.get("priority"), "#64748b")})
ET.SubElement(svg, f"{{{NS}}}text", {"x": str(x + 30), "y": str(yy), "fill": "#cbd5e1",
"font-size": "12", "font-family": "sans-serif"}).text = \
f"{f['name']} [{f.get('priority')}] {f['id']}"
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "out"))
args = ap.parse_args()
tree = load(args.config, "ch05_feature_tree")
report = validate(tree)
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch05_feature_tree.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch05_feature_tree.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=["module", "name", "owner", "features", "p0", "p1", "p2"])
w.writeheader()
w.writerows(report["perModule"])
write_svg(tree, report, os.path.join(args.out, "ch05_feature_tree.svg"))
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
跑一遍:8 模块 22 功能 0 违规
在本机跑 python3 sim/ch05_feature_tree.py --config config/,得到的报告头是:
{"version": 1, "game": "dynasty-era", "ok": true,
"counts": {"modules": 8, "features": 22, "edges": 13, "violations": 0}}
edges 是 13 条父子边(22 个功能里有 9 个挂在 root 下,其余 13 个挂在别的功能下)。violations 为 0,说明五条不变量全部满足。CSV 里各模块的规模是:
| 模块 | 名称 | 责任人 | 功能数 | P0 | P1 | P2 |
|---|---|---|---|---|---|---|
| city | 城建 | 系统策划 | 4 | 2 | 2 | 0 |
| army | 军务 | 战斗策划 | 3 | 3 | 0 | 0 |
| map | 大地图 | 世界策划 | 3 | 2 | 1 | 0 |
| alliance | 联盟 | 社交策划 | 3 | 0 | 2 | 1 |
| quest | 任务 | 系统策划 | 2 | 2 | 0 | 0 |
| shop | 商城 | 商业化策划 | 3 | 1 | 2 | 0 |
| event | 活动 | 运营策划 | 2 | 0 | 1 | 1 |
| account | 账号 | 平台策划 | 2 | 2 | 0 | 0 |
这张表本身就是一次结构体检:P0 共 12 个、P1 共 8 个、P2 共 2 个,比例约 6:4:1,首发的「必做」和「可延」分得很开;军务模块三个功能全是 P0,说明战斗是《王朝纪元》首发不可省的硬骨头;联盟模块一个 P0 都没有,是典型的「社交后置」——但这正是要警惕的地方,联盟如果从第一天就不是必做项,赛季制的联盟玩法会缺一整条腿。
模块职责卡
功能树只回答「功能归谁」,还差一张卡回答「这个模块负责什么、不负责什么」。给每个模块补一张职责卡,至少写三段:拥有(它写哪些状态,见 5.2)、暴露(它对外提供什么命令,见 5.3)、不做(明确划出去的边界)。
「不做」这一段最容易被省略,也最值钱。比如城建模块的卡上写「不做资源采集——采集归大地图」,就不会有人在城建里又实现一套资源产出。功能树的 owner 字段保证一个功能只有一个实现方,职责卡的「不做」保证一份能力也只有一个实现方。
让功能树有守卫
配置写完只是第一步,价值在于每次改都跑。把 violations 当成一条断言:谁往 features 里加了一个新功能却忘了写 owner,BAD_OWNER 立刻冒出来;谁把 parent 写成了一个不存在的 id,DANGLING_PARENT 立刻冒出来;谁删功能时忘了清理子节点,ORPHAN 立刻冒出来。CI 一红,改动就被拦下。
这样,「模块职责」就从一句会议纪要,变成了一个有守卫的不变量。下一步是把「谁写哪份状态」也钉死——那是功能树之外的另一半,也是模块边界真正稳固的地方。
阅读导航:上一节:耦合与解耦 · 下一节:数据流与状态归属 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。