5.1 功能树与模块职责

把 SLG 的功能清单变成一棵可校验的树:模块是产品侧的职责单元,功能是玩家可感知的最小交付点。用《王朝纪元》8 模块 22 功能的配置,校验唯一归属、无悬空引用、无环、无孤儿与模块覆盖,把「模块职责」从口头分工变成每次改配置都会报警的有守卫的不变量。

一个 SLG 项目做到中期,最常听到的一句话是「这个功能到底归谁」。它不是一个组织问题,而是一个产品结构问题:功能没有明确的主人,改需求时就会同时改三处、上线后互相对不上。这一节要做的,是把「功能归谁」从一句口头共识,变成一棵可以校验的树。

先划边界。站内已有一个 SLG 技术架构专题 ,那 9 篇讲的是技术架构——前后端怎么分层、服务端模块怎么拆、通信协议怎么设计,明确写了「不讨论玩法数值」。本章讲的正好是它没碰的那一半:产品侧的功能边界与数据归属。换句话说,那 9 篇回答「代码怎么写」,本章回答「功能归谁、状态归谁」。服务端工程分层另有站内的《MMO 服务器开发》四卷,本章不重复。

还要和上一章分清。第 4 章用 系统清单与依赖图 回答的是「系统之间谁先谁后、谁依赖谁」——那是一张依赖图,节点是系统。本章换成功能树,节点是玩家可感知的功能,关注的是「这个功能属于哪个模块」。依赖图管时序,功能树管归属,两者互补但不可替代。

模块不是组织架构图

多数团队第一次画模块边界,画的是组织架构图:主策一摊、战斗一摊、数值一摊、运营一摊。这张图的致命问题是——人会走,组织会调,而产品结构不该跟着人事变动。一个新人接手,如果模块边界写在他的前任脑子里,他就要花三个月重新推一遍。

正确的做法是让模块成为一个纯粹的职责单元,判据只有两条:

  1. 功能归属:一个模块是若干「玩家可感知的功能」的集合;
  2. 数据归属:这个模块拥有它需要写入的那份状态(第 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 里各模块的规模是:

模块名称责任人功能数P0P1P2
city城建系统策划4220
army军务战斗策划3300
map大地图世界策划3210
alliance联盟社交策划3021
quest任务系统策划2200
shop商城商业化策划3120
event活动运营策划2011
account账号平台策划2200

这张表本身就是一次结构体检: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 一红,改动就被拦下。

这样,「模块职责」就从一句会议纪要,变成了一个有守卫的不变量。下一步是把「谁写哪份状态」也钉死——那是功能树之外的另一半,也是模块边界真正稳固的地方。

阅读导航:上一节:耦合与解耦 · 下一节:数据流与状态归属 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引