6.1 主界面框架与信息层级

把 SLG 主界面写成一张有向图:节点是界面、边是一次跳转、层级决定信息深度。本节用脚本从主城做 BFS,算出每个功能的跳转深度、可达性与没有入口的孤儿界面,并给出主界面扇出与层级规范怎么定的可量化依据,附可跑脚本与 SVG 图。只谈信息架构,不谈视觉与美术。

站内已经有一组 SLG 技术架构专题 ,它的 9 篇讲的是前后端分层、模块拆分与通信协议,索引里写明「不讨论具体玩法数值」。本节接着它没讲的那一半:玩家怎么在界面上找到功能。需要先划清边界——本节只谈信息架构与交互成本,不涉及视觉设计与 UI 美术,配色、动效、图标、字号都不在讨论范围内。那些是美术与视觉稿的事;信息架构要回答的是更靠前的问题:一个功能放在哪一层、从主界面点几下能到、有没有入口。

《王朝纪元》的主界面是一个典型的 SLG hub:主城居中,四周挂着一圈一级入口(世界地图、城建、英雄、部队、联盟、邮件、商城、任务、设置)。这些入口彼此并不平级——有的点一下就到位,有的要先切到某个面板再选对象再确认。这一节把这种「谁通向谁」的关系写成一张有向图,然后用脚本量化它。

界面图:把主界面写成一张有向图

信息架构里最容易吵架的问题是「这个按钮该放主界面还是二级页」。吵不出结果,是因为大家在用感觉讨论。把它变成图,问题就有了确定答案。

我们给界面图下三个定义:

  • 节点是一次可以停留的界面。主城、世界地图、建筑详情、升级确认弹窗,各是一个节点。
  • 边是一次跳转,也就是玩家点一下从 A 到 B。点「城建」进入城建面板,就是一条边。
  • **层级(level)**是节点在信息架构里的位置:0 是主界面 hub,1 是一级面板,2 是二级面板,3 是确认类弹窗。

一旦写成图,三个原本靠感觉的问题立刻可计算:从主界面到某个功能要几跳(深度)、哪些功能根本没有入口(可达性 / 孤儿)、主界面直接挂了几个按钮(扇出)。这三个指标就是信息层级的地基。

四个层级:主界面、一级面板、二级面板、弹窗

《王朝纪元》用四层,和大多数成熟 SLG 一致:

层级角色例子入口来源
L0 主界面唯一 hub,玩家的落脚点主城登录落地
L1 一级面板一个系统的主页世界地图 / 城建 / 英雄 / 部队 / 联盟 / 邮件 / 商城 / 任务 / 设置主界面入口按钮
L2 二级面板系统内的对象或子页地块详情 / 建筑详情 / 英雄详情 / 征兵面板 / 联盟科技 / 商品详情一级面板内选择
L3 弹窗一次动作的确认出征确认 / 升级确认 / 英雄升级 / 征兵确认 / 科技捐献 / 购买确认二级面板内操作

层级不是装饰,它约束了跳转方向:从 L0 只能进 L1,从 L2 的操作进 L3。反过来,每个面板都有一条 back 边回到上一层。这条规则让图既不是纯树(有回边、有跨层快捷入口),也不是一团乱麻。

界面图的配置

界面图是一份配置,config/ch06_ui_graph.json 是唯一真源。节点带 level,边带 cost(这次跳转消耗的点击次数,本节里基本都是 1,下一节会用到它)。结构如下:

{
  "version": 1,
  "note": "界面图:节点=可停留界面,边=一次跳转。level 0=主界面(hub),1=一级面板,2=二级面板,3=确认弹窗。cost=该次跳转的点击成本(整数)。",
  "screens": [
    { "id": "main_city", "name": "主城", "level": 0, "kind": "hub" },
    { "id": "world_map", "name": "世界地图", "level": 1, "kind": "panel" },
    { "id": "city_build", "name": "城建", "level": 1, "kind": "panel" },
    { "id": "hero", "name": "英雄", "level": 1, "kind": "panel" },
    { "id": "army", "name": "部队", "level": 1, "kind": "panel" },
    { "id": "alliance", "name": "联盟", "level": 1, "kind": "panel" },
    { "id": "mail", "name": "邮件", "level": 1, "kind": "panel" },
    { "id": "shop", "name": "商城", "level": 1, "kind": "panel" },
    { "id": "quest", "name": "任务", "level": 1, "kind": "panel" },
    { "id": "settings", "name": "设置", "level": 1, "kind": "panel" },
    { "id": "tile_detail", "name": "地块详情", "level": 2, "kind": "panel" },
    { "id": "march_panel", "name": "出征编队", "level": 2, "kind": "panel" },
    { "id": "march_confirm", "name": "出征确认", "level": 3, "kind": "dialog" },
    { "id": "build_detail", "name": "建筑详情", "level": 2, "kind": "panel" },
    { "id": "build_upgrade", "name": "升级确认", "level": 3, "kind": "dialog" },
    { "id": "hero_detail", "name": "英雄详情", "level": 2, "kind": "panel" },
    { "id": "hero_levelup", "name": "英雄升级", "level": 3, "kind": "dialog" },
    { "id": "train_panel", "name": "征兵面板", "level": 2, "kind": "panel" },
    { "id": "train_confirm", "name": "征兵确认", "level": 3, "kind": "dialog" },
    { "id": "alliance_tech", "name": "联盟科技", "level": 2, "kind": "panel" },
    { "id": "tech_contribute", "name": "科技捐献", "level": 3, "kind": "dialog" },
    { "id": "shop_item", "name": "商品详情", "level": 2, "kind": "panel" },
    { "id": "purchase_confirm", "name": "购买确认", "level": 3, "kind": "dialog" },
    { "id": "quest_detail", "name": "任务详情", "level": 2, "kind": "panel" },
    { "id": "arena", "name": "竞技场", "level": 1, "kind": "panel" }
  ],
  "edges": [
    { "from": "main_city", "to": "world_map", "action": "open", "cost": 1 },
    { "from": "main_city", "to": "city_build", "action": "open", "cost": 1 },
    { "from": "main_city", "to": "hero", "action": "open", "cost": 1 },
    { "from": "main_city", "to": "army", "action": "open", "cost": 1 },
    { "from": "main_city", "to": "alliance", "action": "open", "cost": 1 },
    { "from": "main_city", "to": "mail", "action": "open", "cost": 1 },
    { "from": "main_city", "to": "shop", "action": "open", "cost": 1 },
    { "from": "main_city", "to": "quest", "action": "open", "cost": 1 },
    { "from": "main_city", "to": "settings", "action": "open", "cost": 1 },
    { "from": "world_map", "to": "tile_detail", "action": "select", "cost": 1 },
    { "from": "tile_detail", "to": "march_panel", "action": "attack", "cost": 1 },
    { "from": "march_panel", "to": "march_confirm", "action": "confirm", "cost": 1 },
    { "from": "city_build", "to": "build_detail", "action": "select", "cost": 1 },
    { "from": "build_detail", "to": "build_upgrade", "action": "upgrade", "cost": 1 },
    { "from": "hero", "to": "hero_detail", "action": "select", "cost": 1 },
    { "from": "hero_detail", "to": "hero_levelup", "action": "levelup", "cost": 1 },
    { "from": "army", "to": "train_panel", "action": "train", "cost": 1 },
    { "from": "train_panel", "to": "train_confirm", "action": "confirm", "cost": 1 },
    { "from": "alliance", "to": "alliance_tech", "action": "open", "cost": 1 },
    { "from": "alliance_tech", "to": "tech_contribute", "action": "contribute", "cost": 1 },
    { "from": "shop", "to": "shop_item", "action": "select", "cost": 1 },
    { "from": "shop_item", "to": "purchase_confirm", "action": "buy", "cost": 1 },
    { "from": "quest", "to": "quest_detail", "action": "select", "cost": 1 },
    { "from": "world_map", "to": "main_city", "action": "back", "cost": 1 },
    { "from": "city_build", "to": "main_city", "action": "back", "cost": 1 },
    { "from": "hero", "to": "main_city", "action": "back", "cost": 1 },
    { "from": "army", "to": "main_city", "action": "back", "cost": 1 },
    { "from": "alliance", "to": "main_city", "action": "back", "cost": 1 },
    { "from": "mail", "to": "main_city", "action": "back", "cost": 1 },
    { "from": "shop", "to": "main_city", "action": "back", "cost": 1 },
    { "from": "quest", "to": "main_city", "action": "back", "cost": 1 },
    { "from": "settings", "to": "main_city", "action": "back", "cost": 1 },
    { "from": "tile_detail", "to": "world_map", "action": "back", "cost": 1 },
    { "from": "march_panel", "to": "tile_detail", "action": "back", "cost": 1 },
    { "from": "build_detail", "to": "city_build", "action": "back", "cost": 1 },
    { "from": "hero_detail", "to": "hero", "action": "back", "cost": 1 },
    { "from": "train_panel", "to": "army", "action": "back", "cost": 1 },
    { "from": "alliance_tech", "to": "alliance", "action": "back", "cost": 1 },
    { "from": "shop_item", "to": "shop", "action": "back", "cost": 1 },
    { "from": "quest_detail", "to": "quest", "action": "back", "cost": 1 }
  ]
}

这是完整配置:25 个节点、40 条边,覆盖主城到各系统的全部跳转与回边。配置用整数 cost,不写浮点,这样 Python 与 TypeScript 读同一份配置能得到逐位一致的结果——这是全卷「一份配置、两处消费」的地基。

读界面图:从主城做 BFS

下面这段是 S 层仿真脚本,读 config/ch06_ui_graph.json,从主界面做一次无权重 BFS,得到每个界面的最短跳数、可达集合、孤儿节点,并把深度分布画成 SVG。

# file: sim/ch06_ui_graph.py
# tier: S
# verify: python3 sim/ch06_ui_graph.py --config config/ --out out/
"""第 6 章界面图分析:从主界面做 BFS,得到信息层级深度、可达性与孤儿节点。

只做信息架构,不涉及任何视觉/美术。产出:
  out/ch06_ui_graph.json  深度分布、可达性、孤儿、主界面扇出
  out/ch06_ui_graph.svg   各深度层级的界面数量分布
"""

from __future__ import annotations

import argparse
import json
import os
import xml.etree.ElementTree as ET
from collections import deque

SVG_NS = "http://www.w3.org/2000/svg"


def load(config_dir: str, name: str) -> dict:
    with open(os.path.join(config_dir, f"{name}.json"), encoding="utf-8") as f:
        return json.load(f)


def build_adj(edges: list[dict]) -> dict[str, list[str]]:
    adj: dict[str, list[str]] = {}
    for e in edges:
        adj.setdefault(e["from"], []).append(e["to"])
    for k in adj:
        adj[k].sort()
    return adj


def bfs_depth(adj: dict, start: str) -> dict[str, int]:
    """无权重 BFS(跳数):从 start 到各节点的最短跳数。"""
    dist = {start: 0}
    q = deque([start])
    while q:
        cur = q.popleft()
        for nxt in adj.get(cur, []):
            if nxt not in dist:
                dist[nxt] = dist[cur] + 1
                q.append(nxt)
    return dist


def analyze(cfg: dict) -> dict:
    ids = [s["id"] for s in cfg["screens"]]
    adj = build_adj(cfg["edges"])
    inbound = {i: 0 for i in ids}
    for e in cfg["edges"]:
        inbound[e["to"]] = inbound.get(e["to"], 0) + 1
    start = next(s["id"] for s in cfg["screens"] if s["level"] == 0)
    dist = bfs_depth(adj, start)

    reachable = [i for i in ids if i in dist]
    unreachable = [i for i in ids if i not in dist]
    orphans = [i for i in ids if inbound.get(i, 0) == 0 and i != start]
    hist: dict[str, int] = {}
    for i in reachable:
        hist[str(dist[i])] = hist.get(str(dist[i]), 0) + 1
    hist = {k: hist[k] for k in sorted(hist, key=int)}
    return {
        "version": 1, "start": start, "screens": len(ids), "edges": len(cfg["edges"]),
        "reachable": len(reachable), "unreachable": unreachable, "orphans": orphans,
        "hubFanout": len(adj.get(start, [])), "depthHistogram": hist,
        "depthOf": {i: dist[i] for i in reachable},
    }


def render_svg(rep: dict, out_path: str) -> None:
    hist = rep["depthHistogram"]
    depths = sorted(hist, key=int)
    W, H = 520, 34 + len(depths) * 34
    svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
    ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H), "fill": "#ffffff"})
    t = ET.SubElement(svg, "text", {"x": "90", "y": "20", "font-size": "13", "fill": "#111"})
    t.text = "信息层级深度分布(主城为深度 0)"
    mx = max(hist.values()) if hist else 1
    for i, d in enumerate(depths):
        y = 30 + i * 34
        w = int(320 * hist[d] / mx)
        ET.SubElement(svg, "rect", {"x": "90", "y": str(y), "width": str(max(w, 2)), "height": "22",
                                    "rx": "3", "fill": "#1f6feb", "opacity": "0.85"})
        lab = ET.SubElement(svg, "text", {"x": "84", "y": str(y + 15), "font-size": "11",
                                          "text-anchor": "end", "fill": "#333"})
        lab.text = f"深度 {d}"
        val = ET.SubElement(svg, "text", {"x": str(96 + max(w, 2)), "y": str(y + 15), "font-size": "11", "fill": "#111"})
        val.text = f"{hist[d]} 个界面"
    os.makedirs(os.path.dirname(out_path), exist_ok=True)
    ET.ElementTree(svg).write(out_path, encoding="utf-8", xml_declaration=True)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config")
    ap.add_argument("--out", default="out")
    args = ap.parse_args()
    cfg = load(args.config, "ch06_ui_graph")
    rep = analyze(cfg)
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "ch06_ui_graph.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    render_svg(rep, os.path.join(args.out, "ch06_ui_graph.svg"))
    print(json.dumps({k: rep[k] for k in ("screens", "edges", "reachable", "unreachable", "orphans",
                                          "hubFanout", "depthHistogram")}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑一遍:

# snippet: 运行命令(在工作区 bastion/ 下执行)
python3 sim/ch06_ui_graph.py --config config/ --out out/

输出(同机相对量级,绝对数值不可移植):

# snippet: 实跑输出(同机相对量级,绝对数值不可移植)
{"screens": 25, "edges": 40, "reachable": 24, "unreachable": ["arena"], "orphans": ["arena"], "hubFanout": 9, "depthHistogram": {"0": 1, "1": 9, "2": 7, "3": 6, "4": 1}}

三个可量化指标

把上面一行拆开看,就是信息架构的三张体检表。

可达性:25 个界面里 24 个可达,arena(竞技场)不可达——它在配置里定义了,却没有任何一条边指向它。脚本把它同时列进 unreachable 和 orphans。这正是信息架构最常见的上线事故:系统做完了,入口忘了挂,玩家永远找不到。靠肉眼审图很难发现,靠 BFS 一秒定位。没有入边的节点一定是孤儿,这条判据可以直接进你的配置检查流水线。

深度分布:{"0": 1, "1": 9, "2": 7, "3": 6, "4": 1}。主城(深度 0)1 个,一级面板 9 个,二级面板 7 个,三级弹窗 6 个,还有一个深度 4 的 march_confirm(出征确认)——它是路径最长的功能:主城 → 世界地图 → 地块详情 → 出征编队 → 出征确认,正好 4 跳。深度本身不是罪,但深度 ≥ 4 的功能要考虑给快捷入口,因为每次操作都要走完整条链,高频操作会累。

主界面扇出:hubFanout = 9,主城直接挂了 9 个一级入口。扇出是主界面密度的直接度量。经验上,一级入口超过 7~9 个,主界面就会显得拥挤、按钮抢注意力;这时要么把弱入口收进「更多」折叠菜单,要么把相关入口合并成一个二级面板。这里的 9 已经踩在线上,下一节会用点击成本给出更硬的依据。

把这三张表接进流水线很简单:配置一改就跑一次脚本,然后按下面三条判定:

  • 可达性必须 100%:出现不可达或孤儿节点直接报错,不许合并。arena 这类问题就该在这一步被拦住。
  • 深度超阈值要告警:深度 ≥ 4 的功能逐个过一遍,确认它是不是高频操作。
  • 扇出超阈值要告警:主界面一级入口超过 9 个就触发复核。

这样界面图就不再只是设计文档,而是一份可回归的契约——改一个入口,脚本立刻告诉你有没有把某个功能变成孤儿。

深度不是越浅越好

看到深度分布,一个自然的冲动是把所有功能都压到 L1,让主界面直接通向一切。这会让主界面扇出爆掉——20 个一级入口挤在一屏里,等于没有层级。信息架构的取舍是扁平与收纳之间的平衡:

  • 高频、单目标的入口(邮件、任务、设置)适合放浅,1 跳直达。
  • 低频、需要选对象的操作(出征、购买、科技捐献)天然要经过「选对象 → 确认」两步,压到 1 跳反而危险,因为缺少确认会让误操作代价变大。
  • 判断标准不是「越浅越好」,而是该操作的操作频率是否配得上它的路径长度。

这条判断在下一节会被量化成一个数字:把每个操作的日均频率当权重,算加权平均点击成本,超预算的才需要动。这里先把图搭好。

跨层快捷入口:深链接

层级规则说「L0 只能进 L1」,但真实产品一定需要深链接:点邮件里的「前往支援」直接跳到某块地;点任务里的「去升级」直接打开某建筑的升级确认。这些边跨越了层级,让图不再是一棵树。

深链接在配置里就是一条额外的边。它的价值是把高频操作的最长路径截断:march_confirm 平时要 4 跳,但从任务入口深链接过去可能只要 1 跳。代价是图变复杂、入口分散,容易造成「同一个功能有好几条路,维护时漏改一条」。所以深链接要克制,只给频率足够高的动作开,并且每开一条都要在界面图里显式登记,别让它藏在代码里。

小结

这一节只解决了一个问题:玩家找得到吗。做法是把界面写成有向图,用 BFS 算深度、可达性与扇出,把「按钮放哪」从感觉问题变成可计算问题。arena 的孤儿检测说明这套方法不只是画图好看,它能在配置阶段就拦住一类真实事故。

但「找得到」不等于「点得动」。march_confirm 深度 4,意味着一次出征要连点 4 下——这 4 下值不值、哪些高频操作的点击成本超了预算,是下一节 6.2 操作路径与点击成本 要量化的。

阅读导航:上一节:跨模块契约与接口约定 · 下一节:操作路径与点击成本 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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