前两节讲了循环的结构和节拍。但玩家为什么愿意一圈一圈地跑?因为每一圈都有一个看得见的目标。目标设计得好,玩家会觉得「再玩一会儿就够到下一个了」;设计得差,要么目标唾手可得(毫无追求感),要么遥不可及(直接劝退)。
先划边界。站内的经济平衡长文 讲的是资源的总量平衡(产出多少、消耗多少)。本节讲的不是资源够不够,而是目标之间的距离——同样的资源总量,台阶切得密一点还是疏一点,玩家体验完全不同。这属于 SLG 的产品与数值口径,也是SLG 技术架构专题 完全不涉及的一层。
追求感 = 看得见的下一个台阶
「追求感」听起来很虚,其实可以拆成一个具体的问题:玩家从当前进度走到下一个目标,需要多少天?
- 如果答案是「半天」,目标太近,玩家够到之后没有成就感;
- 如果答案是「两个月」,目标太远,玩家看不到头,中途就流失了;
- 如果答案是「一周左右」,玩家每天上线都能看到进度条在动,又不会觉得明天就没事干。
所以追求感的数值化,就是给每个目标台阶算一个达成时间,再看相邻台阶之间的时间间隔是否落在一个合理的追求感带里。下面这份配置定义了《王朝纪元》一个赛季的 8 级目标:每天产出发展度(收入按整数比增长,保证跨语言逐位一致),8 个台阶各有目标值。
{
"version": 1,
"note": "赛季长线目标的台阶刻度。每日发展度收入按整数比增长(跨语言逐位一致);产出曲线本身见第 10 章。",
"startPower": 1000,
"seasonDays": 70,
"incomeBase": 420,
"incomeGrowthNum": 115,
"incomeGrowthDen": 100,
"incomeGrowthEveryDays": 7,
"pursuitBandDays": [4, 12],
"steps": [
{ "id": "g1", "name": "立足", "targetPower": 3000 },
{ "id": "g2", "name": "起势", "targetPower": 7000 },
{ "id": "g3", "name": "立城", "targetPower": 12000 },
{ "id": "g4", "name": "蓄锐", "targetPower": 18000 },
{ "id": "g5", "name": "破局", "targetPower": 26000 },
{ "id": "g6", "name": "争雄", "targetPower": 36000 },
{ "id": "g7", "name": "问鼎", "targetPower": 48000 },
{ "id": "g8", "name": "一统", "targetPower": 60000 }
]
}
台阶刻度:目标、达成日、间隔
有了收入模型和台阶表,就能算出每个台阶的达成日和相邻台阶的间隔天数。下面这段是 S 层仿真脚本:它按「每 7 天收入乘以 115%」的整数比累加发展度,找出每个台阶第一次被够到的日子,再按追求感带给软告警,最后画出发展度曲线与台阶位置。
# file: sim/ch03_goals.py
# tier: S
# verify: python3 sim/ch03_goals.py --config config/ --out out/
"""赛季长线目标的台阶刻度:给定每日发展度收入(整数比增长),
计算每个台阶的达成日与相邻台阶的时间间隔,并按「追求感带」给软告警。
产出:out/ch03_goals.json + out/ch03_goals_steps.csv + out/ch03_goals.svg
"""
from __future__ import annotations
import argparse, csv, json, os, xml.etree.ElementTree as ET
def income_at_day(cfg, d: int) -> int:
k = (d - 1) // cfg["incomeGrowthEveryDays"]
return cfg["incomeBase"] * cfg["incomeGrowthNum"] ** k // cfg["incomeGrowthDen"] ** k
def cumulative_power(cfg):
power = {0: cfg["startPower"]}
for d in range(1, cfg["seasonDays"] + 1):
power[d] = power[d - 1] + income_at_day(cfg, d)
return power
def days_to_reach(power, target: int) -> int:
for d, p in power.items():
if d > 0 and p >= target:
return d
return -1
def build_steps(cfg):
power = cumulative_power(cfg)
rows, prev = [], 0
for s in cfg["steps"]:
reach = days_to_reach(power, s["targetPower"])
gap = -1 if reach < 0 else reach - prev
rows.append({"id": s["id"], "name": s["name"], "targetPower": s["targetPower"],
"reachDay": reach, "gapDays": gap})
if reach >= 0:
prev = reach
return rows, power
def draw_svg(path, cfg, rows, power):
W, H = 720, 380
PL, PR, PT, PB = 56, 24, 30, 42
hi = power[cfg["seasonDays"]]
iw, ih = W - PL - PR, H - PT - PB
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg",
"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"})
ET.SubElement(svg, "text", {"x": str(PL), "y": "20", "font-size": "14", "fill": "#111"}).text = \
"《王朝纪元》赛季发展度与目标台阶(70 天 / 8 级)"
ET.SubElement(svg, "line", {"x1": str(PL), "y1": str(PT), "x2": str(PL), "y2": str(H - PB), "stroke": "#333"})
ET.SubElement(svg, "line", {"x1": str(PL), "y1": str(H - PB), "x2": str(W - PR), "y2": str(H - PB), "stroke": "#333"})
pts = [(PL + iw * d / cfg["seasonDays"], PT + ih * (1 - power[d] / hi)) for d in range(0, cfg["seasonDays"] + 1)]
ET.SubElement(svg, "polyline", {"points": " ".join(f"{x:.1f},{y:.1f}" for x, y in pts),
"fill": "none", "stroke": "#1f6feb", "stroke-width": "2"})
for r in rows:
y = PT + ih * (1 - r["targetPower"] / hi)
ET.SubElement(svg, "line", {"x1": str(PL), "y1": f"{y:.1f}", "x2": str(W - PR), "y2": f"{y:.1f}",
"stroke": "#d0d7de", "stroke-dasharray": "4 3"})
if r["reachDay"] > 0:
x = PL + iw * r["reachDay"] / cfg["seasonDays"]
ET.SubElement(svg, "circle", {"cx": f"{x:.1f}", "cy": f"{y:.1f}", "r": "3.5", "fill": "#d9480f"})
ET.SubElement(svg, "text", {"x": f"{x:.1f}", "y": f"{y - 6:.1f}", "font-size": "9",
"text-anchor": "middle", "fill": "#d9480f"}).text = r["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()
with open(os.path.join(args.config, "ch03_goals.json"), encoding="utf-8") as f:
cfg = json.load(f)
rows, power = build_steps(cfg)
lo, hi = cfg["pursuitBandDays"]
violations = [r["id"] for r in rows if r["reachDay"] < 0 or not (lo <= r["gapDays"] <= hi)]
report = {
"version": 1, "seasonDays": cfg["seasonDays"], "startPower": cfg["startPower"],
"endPower": power[cfg["seasonDays"]], "pursuitBandDays": [lo, hi],
"steps": rows, "bandViolations": violations,
}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch03_goals.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch03_goals_steps.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f); w.writerow(["id", "name", "targetPower", "reachDay", "gapDays"])
for r in rows:
w.writerow([r["id"], r["name"], r["targetPower"], r["reachDay"], r["gapDays"]])
draw_svg(os.path.join(args.out, "ch03_goals.svg"), cfg, rows, power)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出(同机相对量级,绝对数值不可移植)。8 个台阶的达成日与间隔是这样的:
| 台阶 | 目标发展度 | 达成日 | 与上一级间隔 |
|---|---|---|---|
| 立足 | 3000 | 第 5 天 | 5 天 |
| 起势 | 7000 | 第 14 天 | 9 天 |
| 立城 | 12000 | 第 23 天 | 9 天 |
| 蓄锐 | 18000 | 第 32 天 | 9 天 |
| 破局 | 26000 | 第 42 天 | 10 天 |
| 争雄 | 36000 | 第 52 天 | 10 天 |
| 问鼎 | 48000 | 第 62 天 | 10 天 |
| 一统 | 60000 | 第 70 天 | 8 天 |
赛季结束时累计发展度 60661,全部 8 个台阶都在 70 天内达成,bandViolations 为空。
追求感带:间隔不能太快也不能太慢
上面表格里最值得看的是间隔这一列:5、9、9、9、10、10、10、8。配置里定义的追求感带是 [4, 12] 天——相邻两个台阶的间隔必须落在这个区间。落进去,玩家既有「够得着」的即时反馈,又有「还有得追」的长期动力。
对照一下两种坏设计:
- 把间隔压到 1–2 天:玩家一周连过五级,两周就「到顶」了,剩下几十天无事可做——追求感太密。
- 把间隔拉到 30 天:玩家每天上线看到进度条纹丝不动,第三周就流失了——追求感太疏。
追求感带的好处是,它把「快慢」变成了一个可回归的断言:策划调收入曲线、调台阶目标值,只要有一个间隔跳出 [4, 12],脚本就会把它记进 bandViolations。这比「感觉有点快」这种口头判断可靠得多。
三级目标的嵌套:日、周、赛季
追求感不是只有赛季一层。回顾上一节的三级节奏,每一层都该有自己的目标:
| 层 | 目标示例 | 达成周期 |
|---|---|---|
| 日目标 | 完成日常任务、收满资源 | 1 天 |
| 周目标 | 联盟周活跃达标、周末大活动 | 7 天 |
| 赛季目标 | 8 级台阶、赛季排名 | 70 天 |
三层目标是嵌套的:赛季台阶(这里间隔约 9 天)跨过了一整个周节奏,而每个赛季台阶的达成,又由许多个日目标累积而成。短目标负责留存(每天有理由上线),长目标负责追求(有远方的山)——只有长目标,玩家会中途放弃;只有短目标,玩家会觉得没方向。
一个实用的检查是:任意一层目标,都不该出现「断档」——即不能出现「这个赛季台阶还要 20 天才能到,而这 20 天里没有任何日/周目标可做」的真空。台阶间隔 [4, 12] 天这个设计,本质上就是保证每 4–12 天必有一个新台阶,中间由日/周目标填满。
P 层:把台阶表搬进原型
台阶表不该只躺在仿真里。原型层用同一份配置,把「达成日」算一遍——两边一致,策划改配置时才能放心。下面这段是 P 层原型,注意收入按整数比增长,115^9 已超 2^53,所以幂运算必须用 BigInt。
// file: proto/src/ch03_goals.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 与 sim/ch03_goals.py 同构:读同一份 config/ch03_goals.json,算台阶达成日与追求感带。
// 关键点:收入按整数比增长,115^9 已超 2^53,故幂运算用 BigInt。
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 Step { id: string; name: string; targetPower: number }
interface GoalsConfig {
startPower: number; seasonDays: number; incomeBase: number;
incomeGrowthNum: number; incomeGrowthDen: number; incomeGrowthEveryDays: number;
pursuitBandDays: [number, number]; steps: Step[];
}
export function loadGoalsConfig(): GoalsConfig {
return JSON.parse(readFileSync(join(CONFIG_DIR, "ch03_goals.json"), "utf-8")) as GoalsConfig;
}
function ipow(base: number, exp: number): bigint {
let acc = 1n;
const b = BigInt(base);
for (let i = 0; i < exp; i++) acc *= b;
return acc;
}
export function incomeAtDay(cfg: GoalsConfig, d: number): number {
const k = Math.floor((d - 1) / cfg.incomeGrowthEveryDays);
return Number((BigInt(cfg.incomeBase) * ipow(cfg.incomeGrowthNum, k)) / ipow(cfg.incomeGrowthDen, k));
}
export function cumulativePower(cfg: GoalsConfig): number[] {
const power = new Array<number>(cfg.seasonDays + 1).fill(0);
power[0] = cfg.startPower;
for (let d = 1; d <= cfg.seasonDays; d++) power[d] = power[d - 1] + incomeAtDay(cfg, d);
return power;
}
export function daysToReach(power: number[], target: number): number {
for (let d = 1; d < power.length; d++) if (power[d] >= target) return d;
return -1;
}
export function goalsReport() {
const cfg = loadGoalsConfig();
const power = cumulativePower(cfg);
const steps = cfg.steps.map((s) => ({ id: s.id, name: s.name, targetPower: s.targetPower, reachDay: daysToReach(power, s.targetPower) }));
const [lo, hi] = cfg.pursuitBandDays;
let prev = 0;
const rows = steps.map((s) => {
const gapDays = s.reachDay < 0 ? -1 : s.reachDay - prev;
if (s.reachDay >= 0) prev = s.reachDay;
return { ...s, gapDays };
});
const bandViolations = rows.filter((r) => r.reachDay < 0 || r.gapDays < lo || r.gapDays > hi).map((r) => r.id);
return { seasonDays: cfg.seasonDays, startPower: cfg.startPower, endPower: power[cfg.seasonDays], steps: rows, bandViolations };
}
跑 node --test proto/test/ch03.test.ts,本节相关断言全部通过:endPower=60661,达成日 [5, 14, 23, 32, 42, 52, 62, 70],间隔 [5, 9, 9, 9, 10, 10, 10, 8],bandViolations=[]——与 Python 侧逐位一致。台阶还额外断言了单调性:目标越高,达成日必须越晚,杜绝「回头台阶」。
到这里第 3 章就闭环了:3.1 给了循环的结构,3.2 给了循环的节拍,3.3 给了循环里的目标。下一章我们退一步,把这些系统放进一张更大的系统地图里,看它们谁依赖谁、谁该先解锁。
阅读导航:上一节:日/周/赛季三级节奏 · 下一节:系统清单与依赖图 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。