3.3 长线目标与追求感

把 SLG 的长线目标做成可校验的台阶刻度:给定每日发展度收入,算出《王朝纪元》8 级目标的达成日与相邻间隔,用「追求感带」判定每个台阶是太密还是太疏,并让 Python 与 TypeScript 两侧算出逐位一致的结果,把追求感从口头判断变成可回归的断言。

前两节讲了循环的结构和节拍。但玩家为什么愿意一圈一圈地跑?因为每一圈都有一个看得见的目标。目标设计得好,玩家会觉得「再玩一会儿就够到下一个了」;设计得差,要么目标唾手可得(毫无追求感),要么遥不可及(直接劝退)。

先划边界。站内的经济平衡长文 讲的是资源的总量平衡(产出多少、消耗多少)。本节讲的不是资源够不够,而是目标之间的距离——同样的资源总量,台阶切得密一点还是疏一点,玩家体验完全不同。这属于 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 给了循环里的目标。下一章我们退一步,把这些系统放进一张更大的系统地图里,看它们谁依赖谁、谁该先解锁。

阅读导航:上一节:日/周/赛季三级节奏 · 下一节:系统清单与依赖图 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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