如果你去问一个 SLG 策划「你们的核心循环是什么」,多数人会画一张箭头图:产出 → 消耗 → 成长 → 再产出。这张图没错,但它几乎从不被检验——箭头画反了没人发现,某个环节其实是死路也没人发现,某个资源产出了却没有任何消耗槽也没人发现。这一节要做的事只有一件:把这张图变成一个可以判定的对象。
动手前先划清边界。站内的经济平衡长文 给了一套通用的经济平衡框架,但它举例用的是放置类:离线收益、线性挂机、指数成长。那套框架在放置类里很好用,因为放置类的「循环」几乎是隐式的——玩家关掉游戏,经济照跑。SLG 不一样:SLG 的循环由玩家的行为驱动,玩家不上线,地块不产出、军队不出征、资源不进账。所以本卷第 9–15 章换用 SLG 的大地图地块口径,而本章先只做一件事:把循环的结构刻画清楚。
另外,站内已有一个 SLG 技术架构专题 ,那 9 篇讲的是前后端分层、模块拆分与协议设计,明确写了「不讨论玩法数值」。本章讲的正好是它没碰的那一半:产品与数值里的循环结构。
循环不是四个箭头,是一张有向图
「产出 → 消耗 → 成长 → 再产出」是四个箭头串成一个圈。但真实的 SLG 循环没这么干净:产出不是一种,消耗不是一种,成长会反过来提高产出,而掠夺又会在中间插一脚。把它写成一串箭头,你迟早会画错。
更好的做法是把它建成一张有向图:节点是「阶段」,边是「资源或行为的流向」,每条边上标一个载体(木材、粮草、行动力、战力……)。这样「循环是否闭合」就等价于一个标准的图论问题:这张图是不是一个环。
下面这份配置定义了《王朝纪元》的核心循环。它是 D 层(静态层)的产物——一张被配置化的系统图,本身不产生数字,但可以被脚本判定。
{
"version": 1,
"root": "gather",
"note": "核心循环的结构刻画。节点=阶段,边=资源/行为流向。本章只做闭包与回环判定,不做定量结算(定量闭环见第 12 章)。",
"stages": [
{ "id": "gather", "name": "采集产出", "kind": "source" },
{ "id": "build", "name": "城建消耗", "kind": "sink" },
{ "id": "train", "name": "征兵消耗", "kind": "sink" },
{ "id": "tech", "name": "科技消耗", "kind": "sink" },
{ "id": "grow", "name": "产能成长", "kind": "transform" },
{ "id": "march", "name": "行军队列", "kind": "transform" },
{ "id": "raid", "name": "交战掠夺", "kind": "source" }
],
"edges": [
{ "from": "gather", "to": "build", "carrier": "木材/石料" },
{ "from": "gather", "to": "train", "carrier": "粮草/铁矿" },
{ "from": "gather", "to": "tech", "carrier": "石料/铁矿" },
{ "from": "build", "to": "grow", "carrier": "战力" },
{ "from": "tech", "to": "grow", "carrier": "产能系数" },
{ "from": "train", "to": "march", "carrier": "军队" },
{ "from": "march", "to": "raid", "carrier": "行动力" },
{ "from": "raid", "to": "gather","carrier": "掠夺资源回流" },
{ "from": "grow", "to": "gather","carrier": "更高产能" }
]
}
注意这里其实藏着两条闭合路径:一条是「采集 → 城建 → 产能成长 → 更高产能」的三步小循环,另一条是「采集 → 征兵 → 行军 → 交战掠夺 → 资源回流」的四步大循环。一个好的核心循环从来不是单环,而是多条环路共享同一个起点——玩家无论偏内政还是偏军事,最终都会回到「采集」。
闭包判定要回答四个问题
有了图,判定就变成四个可以机械回答的问题:
- 弱连通:把每条边当成无向的,整张图是不是连在一起?如果「交战掠夺」孤立成一个岛,玩家永远走不到它,那它就不是循环的一部分。
- 强连通分量(SCC):整张图有几个「互相都可达」的极大子集?核心循环应该恰好是一个包含根节点的强连通分量;如果拆成两个,说明循环断成了两截。
- 最短回环:从根节点出发、再回到根节点,最短要走几步?这个数字直接告诉玩家「多久能感到一次完整闭环」。
- 悬空汇点:有没有哪个节点只有进、没有出?那就是一个「资源黑洞」——玩家把资源投进去,却拿不到任何反馈。
四个问题的答案里,只要有一个不对,循环就是坏的。下面这段是 S 层仿真脚本,读 config/ 里的图配置,把四个判定一次算完,并画出循环结构图。
# file: sim/ch03_loop.py
# tier: S
# verify: python3 sim/ch03_loop.py --config config/ --out out/
"""核心循环的结构刻画:把「产出→消耗→成长→再产出」建成有向图,判定它是否闭合成环。
只做结构判定(弱连通 / 强连通分量 / 最短回环 / 悬空汇点);定量闭环见第 12 章。
产出:out/ch03_loop.json + out/ch03_loop_edges.csv + out/ch03_loop.svg
"""
from __future__ import annotations
import argparse, csv, json, math, os, xml.etree.ElementTree as ET
from collections import defaultdict, deque
def load_graph(cfg_dir):
with open(os.path.join(cfg_dir, "ch03_loop.json"), encoding="utf-8") as f:
cfg = json.load(f)
return cfg["root"], cfg["stages"], cfg["edges"]
def adjacency(stages, edges):
adj = {s["id"]: [] for s in stages}
for e in edges:
adj[e["from"]].append(e["to"])
return adj
def weakly_connected(stages, edges) -> bool:
und = defaultdict(set)
for s in stages:
und[s["id"]]
for e in edges:
und[e["from"]].add(e["to"]); und[e["to"]].add(e["from"])
seen, dq = set(), deque([stages[0]["id"]])
while dq:
n = dq.popleft()
if n in seen:
continue
seen.add(n); dq.extend(und[n] - seen)
return len(seen) == len(stages)
def tarjan_scc(adj):
index, low, on, stack, res, cnt = {}, {}, {}, [], [], [0]
def sc(v):
index[v] = low[v] = cnt[0]; cnt[0] += 1
stack.append(v); on[v] = True
for w in adj[v]:
if w not in index:
sc(w); low[v] = min(low[v], low[w])
elif on.get(w):
low[v] = min(low[v], index[w])
if low[v] == index[v]:
comp = []
while True:
w = stack.pop(); on[w] = False; comp.append(w)
if w == v:
break
res.append(sorted(comp))
for v in adj:
if v not in index:
sc(v)
return res
def shortest_cycle(adj, root) -> int:
"""从 root 出发再回到 root 的最短有向回环长度(边数);无回环返回 -1。"""
dq, seen = deque((n, 1) for n in adj[root]), {root}
while dq:
node, dist = dq.popleft()
if node == root:
return dist
if node in seen:
continue
seen.add(node)
for w in adj[node]:
dq.append((w, dist + 1))
return -1
def draw_svg(path, stages, edges, main_loop):
W, H, R, n = 680, 420, 150, len(stages)
cx, cy = W / 2, H / 2 + 12
ang = lambda i: 2 * math.pi * i / n - math.pi / 2
pos = {s["id"]: (cx + R * math.cos(ang(i)), cy + R * math.sin(ang(i))) for i, s in enumerate(stages)}
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "viewBox": f"0 0 {W} {H}"})
for e in edges:
(x1, y1), (x2, y2) = pos[e["from"]], pos[e["to"]]
ET.SubElement(svg, "line", {"x1": f"{x1:.0f}", "y1": f"{y1:.0f}", "x2": f"{x2:.0f}", "y2": f"{y2:.0f}",
"stroke": "#8aa0b6", "stroke-width": "1.4"})
for s in stages:
x, y = pos[s["id"]]
ET.SubElement(svg, "circle", {"cx": f"{x:.0f}", "cy": f"{y:.0f}", "r": "30",
"fill": "#1f6feb" if s["id"] in main_loop else "#cbd5e1"})
ET.SubElement(svg, "text", {"x": f"{x:.0f}", "y": f"{y + 4:.0f}", "font-size": "11",
"text-anchor": "middle", "fill": "#fff"}).text = s["name"]
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/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
root, stages, edges = load_graph(args.config)
adj = adjacency(stages, edges)
sccs = tarjan_scc(adj)
main_loop = next((c for c in sccs if root in c), [])
sinks = sorted(n for n, outs in adj.items() if not outs)
report = {"version": 1, "nodes": len(stages), "edges": len(edges),
"weaklyConnected": weakly_connected(stages, edges), "sccCount": len(sccs),
"mainLoopSize": len(main_loop), "loopLength": shortest_cycle(adj, root),
"danglingSinks": sinks, "closed": len(main_loop) == len(stages) and not sinks}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch03_loop.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch03_loop_edges.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f); w.writerow(["from", "to", "carrier"])
for e in edges:
w.writerow([e["from"], e["to"], e["carrier"]])
draw_svg(os.path.join(args.out, "ch03_loop.svg"), stages, edges, main_loop)
print(json.dumps(report, ensure_ascii=False))
return 0 if report["closed"] else 1
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"version": 1, "nodes": 7, "edges": 9, "weaklyConnected": true, "sccCount": 1,
"mainLoopSize": 7, "loopLength": 3, "danglingSinks": [], "closed": true}
四个判定全部通过:7 个节点、9 条边,弱连通为真,强连通分量恰好 1 个,主循环覆盖全部 7 个节点,最短回环 3 步,悬空汇点为空,整张图 closed: true。脚本还会顺手画出 out/ch03_loop.svg(循环结构图)和 out/ch03_loop_edges.csv(边表),退出码为 0。
最短回环 3 步,说明什么
loopLength: 3 是这张图里信息量最大的一个数。它来自 gather → build → grow → gather 这条路径——玩家采集资源、投进城建、城建提高产能、产能带来更多采集。三步,就是玩家能感到一次完整闭环的最短路径。
这个数字是设计上的「呼吸频率」:三步太短,循环会显得廉价(玩家一眼看到头);但如果最短回环要七八步,玩家要很久才能感到「我做的事有回报」。好的做法是让最短回环足够短(3–4 步,给新玩家即时反馈),同时让最长回环足够长(这里的大循环是 4 步:采集 → 征兵 → 行军 → 掠夺 → 回流),让老玩家在同一个循环里有更深的事可做。
这一步只判定了「循环存在且闭合」。循环跑起来会不会崩——产出与消耗能不能长期平衡、稳态在哪里——那是存量流量模型的事,本章不做。第 12 章会用同一份
config/跑 180 天闭环,把这里的结构图变成一条条会动的曲线。本章给结构,第 12 章给动态。
行为闭环:玩家的每一步都要有下一步
有向图刻画的是「资源怎么流」,但 SLG 的循环真正的主角是玩家行为。同一个节点,从资源视角看是「消耗」,从行为视角看是「玩家点了一下建造按钮」。一个健康的循环,要求每个行为都能接上下一个行为:
- 采集让你有资源可花(下一步:建造);
- 建造让你有产能可涨(下一步:采集更多);
- 征兵让你有兵可派(下一步:行军);
- 行军让你有仗可打(下一步:掠夺);
- 掠夺让你有资源回流(下一步:回到建造或征兵)。
如果某个行为做完之后玩家「不知道下一步干嘛」,那这个行为就是行为闭环的悬空汇点——图上可能还连着边,但玩家体验上断了。所以上面脚本里的 danglingSinks 检查,本质上是在替玩家问一句:这个节点做完,下一步往哪走?
P 层:同一张图,TypeScript 侧也判一遍
结构判定不该只活在 Python 里。原型层用同一份 config/ch03_loop.json 再判一次——如果两边算出的 loopLength 不一致,说明「唯一真源」名不副实。下面这段是 P 层原型,用 Kosaraju/Tarjan 的同构实现重算一遍闭包。
// file: proto/src/ch03_loop.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 与 sim/ch03_loop.py 同构:读同一份 config/ch03_loop.json,判定核心循环是否闭合成环。
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const HERE = dirname(fileURLToPath(import.meta.url));
const CONFIG_DIR = join(HERE, "..", "..", "config");
interface Stage { id: string; name: string; kind: string }
interface Edge { from: string; to: string; carrier: string }
interface LoopConfig { root: string; stages: Stage[]; edges: Edge[] }
export function loadLoopConfig(): LoopConfig {
return JSON.parse(readFileSync(join(CONFIG_DIR, "ch03_loop.json"), "utf-8")) as LoopConfig;
}
export function adjacency(cfg: LoopConfig): Record<string, string[]> {
const adj: Record<string, string[]> = {};
for (const s of cfg.stages) adj[s.id] = [];
for (const e of cfg.edges) adj[e.from].push(e.to);
return adj;
}
export function tarjanScc(adj: Record<string, string[]>): string[][] {
const index: Record<string, number> = {};
const low: Record<string, number> = {};
const onStack: Record<string, boolean> = {};
const stack: string[] = [];
const result: string[][] = [];
let counter = 0;
const sc = (v: string): void => {
index[v] = low[v] = counter++;
stack.push(v); onStack[v] = true;
for (const w of adj[v]) {
if (!(w in index)) { sc(w); low[v] = Math.min(low[v], low[w]); }
else if (onStack[w]) { low[v] = Math.min(low[v], index[w]); }
}
if (low[v] === index[v]) {
const comp: string[] = [];
for (;;) { const w = stack.pop()!; onStack[w] = false; comp.push(w); if (w === v) break; }
result.push(comp.sort());
}
};
for (const v of Object.keys(adj)) if (!(v in index)) sc(v);
return result;
}
export function shortestCycle(adj: Record<string, string[]>, root: string): number {
const q: Array<[string, number]> = adj[root].map((n) => [n, 1] as [string, number]);
const seen = new Set<string>([root]);
while (q.length) {
const [node, dist] = q.shift()!;
if (node === root) return dist;
if (seen.has(node)) continue;
seen.add(node);
for (const w of adj[node]) q.push([w, dist + 1]);
}
return -1;
}
export function closureReport() {
const cfg = loadLoopConfig();
const adj = adjacency(cfg);
const sccs = tarjanScc(adj);
const mainLoop = sccs.find((c) => c.includes(cfg.root)) ?? [];
const sinks = Object.keys(adj).filter((n) => adj[n].length === 0).sort();
return {
nodes: cfg.stages.length, edges: cfg.edges.length, sccCount: sccs.length,
mainLoopSize: mainLoop.length, loopLength: shortestCycle(adj, cfg.root),
danglingSinks: sinks, closed: mainLoop.length === cfg.stages.length && sinks.length === 0,
};
}
对应的断言写进 proto/test/ch03.test.ts,跑 node --test proto/test/ch03.test.ts,5 条全过(含本节这一条):nodes=7、edges=9、sccCount=1、mainLoopSize=7、loopLength=3、danglingSinks=[]、closed=true。Python 与 TypeScript 对同一份配置算出逐位一致的结果,这才是「一份配置、两处消费」的意义。
把闭包判定接进回归
结构判定的价值不在跑一次,而在每次改配置都跑。做法很简单:把 ch03_loop.json 的 closed 当成一条断言——策划往循环里加了一个新系统(比如「宝物强化」),只要它没接回主循环,danglingSinks 就会冒出一个新节点,closed 立刻变 false,CI 直接红。这样,「核心循环闭合」就从一个口头共识变成了一个有守卫的不变量。
下一节我们会给这个循环装上时钟:一天之内玩家的行为密度怎么分布、一周的峰值压在哪、一个赛季分成几个相位——把静态的闭环变成有节拍的东西。
阅读导航:上一节:生命周期阶段与体验目标 · 下一节:日/周/赛季三级节奏 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。