序:为什么写一套能跑的 SLG 设计书

产品设计书最容易写成散文:道理都对,落不了地。这篇序讲清楚这套书的写法——把 SLG 的设计写成可验证的模型,每一节配可跑的仿真或原型,一份配置两处消费,改一个数字两边结果都变。也讲清楚它与站内既有的 SLG 技术架构、经济平衡、赛季运营等内容的边界。

产品设计书最容易写成散文。翻开目录,每一章都对:要理解用户、要设计核心循环、要控制通胀、要平衡数值。合上书,你还是不知道明天上班该先动哪个数字。

这套书想解决的就是这个落差。它的写法只有一条规矩:每一节都要留下一个能跑的东西。

一个具体的问题

假设你在做一款 SLG,策划会上有人问:「伐木场升到 5 级,一天的木材产出是多少?」

这个问题有三种回答方式。

第一种,翻配置表,念一个数字。问题是这个数字从哪来没人说得清,改一个系数以后它就作废了。

第二种,讲道理:「产出应该随等级指数增长,但要控制通胀,所以增长率不能太高。」听起来很专业,但没有任何一个数字,散会后谁也不知道该填 1.2 还是 1.5。

第三种,也是这套书要做的:把产出写成公式,把公式写成配置,把配置跑出来。

{
  "id": "lumberyard",
  "name": "伐木场",
  "resource": "wood",
  "maxLevel": 10,
  "outputBase": 60,
  "outputGrowthNum": 122,
  "outputGrowthDen": 100
}

outputBase 是 1 级产出,122/100 是每级增长率。5 级产出就是 60 × (122/100)^4,向下取整得 132。这个数字不是念出来的,是算出来的;改 outputGrowthNum 从 122 到 125,它会自己变。

为什么是「整数比」而不是「1.22」

上面那段配置里藏着一个不起眼但很关键的决定:增长率写成 outputGrowthNum: 122 和 outputGrowthDen: 100 两个整数,而不是直接写 1.22。

原因是这套书要让两种语言对同一份配置算出完全相同的结果。Python 和 TypeScript 对浮点数的幂运算在末位可能不一致——1.22 ** 9 在两个运行时里可能差最后一位,取整后就可能差 1。而 SLG 的经济是逐小时结算的,一天差 1,一个赛季下来就是一笔说不清的钱。

把增长率写成整数比、用整数幂计算,这个不确定性就消失了。这条约定贯穿全书,在附录 B 有完整说明。

这不是洁癖。当设计要和原型、和服务端、和数据分析三方对齐时,「同一个输入必须得到同一个输出」是前提,不是优化。

一份配置,两处消费

这套书围绕一个虚构的 SLG 《王朝纪元》 和一套工具链 bastion 展开:

bastion/
  config/      # 唯一真源:resources / buildings / units / battle / season / pricing
  sim/         # Python:读 config 跑平衡仿真,产出 JSON + CSV + SVG
  proto/       # TypeScript:读 config 跑可玩原型,node --test 断言

config/ 是设计的唯一真源。sim/ 是数值策划的仿真台——它回答「这样配,180 天后经济会怎么样」。proto/ 是原型的玩法台——它回答「这样配,打起来是什么手感」。

两者读的是同一份配置。 这就把「设计」变成了可以回归测试的东西:改一个数字,仿真和原型的结果都跟着变,任何一方说不通就说明设计有问题。

下面这段就是这条主张的最小验证。它读同一份 config/,先算出各建筑的每小时产出,再算 24 小时的资源结算,最后跑一场 300 骑兵对 300 弓兵的确定性战斗:

# file: sim/bastion_core.py
# tier: S
# verify: python3 -c "import sys; sys.path.insert(0,'sim'); import bastion_core as c; print(c.battle_report())"
"""bastion 仿真层核心:与 proto/src/core.ts 公式逐位一致(整数幂 + 整数百分比)。"""

from __future__ import annotations

import json
import os
from dataclasses import dataclass

CONFIG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config")


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


# ---------- 产出 / 成本:整数幂,跨语言逐位一致 ----------

def output_at_level(b: dict, level: int) -> int:
    """每小时的产出。level 从 1 起。"""
    n = b["outputGrowthNum"] ** (level - 1)
    d = b["outputGrowthDen"] ** (level - 1)
    return (b["outputBase"] * n) // d


def upgrade_cost_at_level(b: dict, level: int) -> dict[str, int]:
    """从 level 升到 level+1 的花费。"""
    n = b["upgradeCostGrowthNum"] ** (level - 1)
    d = b["upgradeCostGrowthDen"] ** (level - 1)
    return {r: (v * n) // d for r, v in b["upgradeCostBase"].items()}


def production_per_hour(levels: dict[str, int]) -> dict[str, int]:
    """给定各建筑等级,返回每小时各资源产出。"""
    out: dict[str, int] = {}
    for b in load_config("buildings")["buildings"]:
        lv = levels.get(b["id"], 0)
        if lv <= 0:
            continue
        out[b["resource"]] = out.get(b["resource"], 0) + output_at_level(b, lv)
    return out


def settle(levels: dict[str, int], hours: int, start: dict[str, int] | None = None) -> dict[str, int]:
    """资源结算 tick:把产出按小时累加,受资源上限截断。"""
    caps = {r["id"]: r["cap"] for r in load_config("resources")["resources"]}
    rates = production_per_hour(levels)
    stock = dict(start or {})
    for r in caps:
        stock.setdefault(r, 0)
    for _ in range(hours):
        for r, rate in rates.items():
            stock[r] = min(caps[r], stock[r] + rate)
    return stock


# ---------- 战斗:确定性回合结算 ----------

@dataclass
class Stack:
    unit_id: str
    count: int
    hp_pool: int


def damage_per_unit(atk_unit: dict, def_unit: dict, units_cfg: dict, rules: dict, terrain_pct: int = 0) -> int:
    pct = units_cfg["counterMultiplierPct"] if units_cfg["counter"].get(atk_unit["id"]) == def_unit["id"] else 100
    raw = atk_unit["atk"] * pct - def_unit["def"] * rules["defenseFactorPct"]
    raw = raw * (100 + terrain_pct) // 100
    return max(rules["minDamage"], raw // 100)


def make_stack(unit_id: str, count: int, units_cfg: dict) -> Stack:
    unit = next(u for u in units_cfg["units"] if u["id"] == unit_id)
    return Stack(unit_id, count, count * unit["hp"])


def alive_count(stack: Stack, unit: dict) -> int:
    """存活单位数 = ceil(hpPool / hp),用整数运算保证与 TS 侧逐位一致。"""
    if stack.hp_pool <= 0:
        return 0
    return (stack.hp_pool + unit["hp"] - 1) // unit["hp"]


def resolve_battle(a_unit_id: str, a_count: int, d_unit_id: str, d_count: int, terrain_pct: int = 0) -> dict:
    units_cfg = load_config("units")
    rules = load_config("battle")["rules"]
    unit_of = {u["id"]: u for u in units_cfg["units"]}

    a = make_stack(a_unit_id, a_count, units_cfg)
    d = make_stack(d_unit_id, d_count, units_cfg)
    rounds = 0
    log = []

    while a.hp_pool > 0 and d.hp_pool > 0 and rounds < rules["maxRounds"]:
        rounds += 1
        a_alive = alive_count(a, unit_of[a_unit_id])
        d_alive = alive_count(d, unit_of[d_unit_id])
        a_dmg = a_alive * damage_per_unit(unit_of[a_unit_id], unit_of[d_unit_id], units_cfg, rules, terrain_pct)
        d_dmg = d_alive * damage_per_unit(unit_of[d_unit_id], unit_of[a_unit_id], units_cfg, rules, 0)
        a.hp_pool -= d_dmg
        d.hp_pool -= a_dmg
        log.append({"round": rounds, "aHp": a.hp_pool, "dHp": d.hp_pool, "aDmg": a_dmg, "dDmg": d_dmg})

    winner = "a" if d.hp_pool <= 0 and a.hp_pool > 0 else ("d" if a.hp_pool <= 0 and d.hp_pool > 0 else "draw")
    return {
        "winner": winner,
        "rounds": rounds,
        "aSurvivors": alive_count(a, unit_of[a_unit_id]),
        "dSurvivors": alive_count(d, unit_of[d_unit_id]),
        "aHp": max(0, a.hp_pool),
        "dHp": max(0, d.hp_pool),
        "log": log,
    }


def battle_report() -> dict:
    """门禁 3/4 的对照报告:同一份 config,Python 侧的数字。"""
    levels = {"lumberyard": 5, "quarry": 5, "ironworks": 3, "farm": 6}
    return {
        "version": 1,
        "productionPerHour": production_per_hour(levels),
        "settle24h": settle(levels, 24),
        "lumberyardCostFrom5": upgrade_cost_at_level(
            next(b for b in load_config("buildings")["buildings"] if b["id"] == "lumberyard"), 5
        ),
        "battle": resolve_battle("cavalry", 300, "archer", 300),
    }


if __name__ == "__main__":
    print(json.dumps(battle_report(), ensure_ascii=False, indent=2))

本机实跑的输出是:

{"wood": 132, "stone": 110, "iron": 27, "grain": 199}

同一份配置,TypeScript 侧 proto/src/core.ts 用 BigInt 做整数幂,跑出来是逐位相同的一组数字。这不是巧合,是设计出来的。

与站内既有内容的分工

这套书不是凭空起楼。站内已经有相当多相邻的内容,写清楚分工比重复写一遍更有价值:

关于 SLG 的技术架构,站内 SLG 技术架构专题 已经有 9 篇,讲的是前后端分层、模块拆分、塔防战斗通信协议、客户端消息分发。它的索引页写得很明确:「不讨论具体玩法数值」。那 9 篇是工程视角,这套书是产品视角——它们讲的正是这套书不讲的,这套书讲的正是它们不讲的。

关于经济平衡,站内有一篇 322 行的经济平衡长文 ,给了一套完整的通用框架:货币分层、产出消耗建模、通胀控制。但它举例用的是放置类。放置类的经济里没有大地图、没有行军、没有联盟共研。本卷第 9–15 章换成 SLG 的大地图地块口径,而且每一章都配可跑的闭环仿真。

关于单机游戏的平衡,站内有一份独立游戏平衡与经济手册 ,用 Hades、Celeste、Slay the Spire 做拆解,非常扎实。但那是单机口径——单机的经济在通关那一刻就结束了。SLG 的经济要跑几个赛季,要面对存量的持续累积。这两者的建模方式不同。

关于运营与赛季,站内经济系统运营 与赛季重置 两篇是产业 checklist,给的是清单与原则。本卷给的是能跑出数字的模型——比如第 13.3 节会真跑一遍赛季货币回收,看存量在几个赛季后收敛到什么水平。

关于原型,站内有一批原型 PRD,包括城市 SLG 原型 。那些是文档。本套的 bastion 是一套工程:配置驱动、可断言、可回放。

六个真空白

把站内内容盘点清楚以后,这套书该落在哪里也就清楚了。站内不是真空白,而是**「架构层已重、数值/经济/付费层为通用内容、SLG 专属建模为真空白」**。六个具体落点是:

  1. SLG 经济系统的产出/回收闭环建模——以大地图地块产出、行军消耗、联盟共研为对象的闭环模型。
  2. 赛季制数值膨胀控制——世界地图重开、排行榜结算、跨服赛季下的膨胀控制(卷二)。
  3. LTV 与付费点设计映射——把 LTV 拆到 SLG 的付费点上(卷二)。
  4. SLG 兵种 / 地块 / 战力数值平衡——兵种克制、地块等级、战力公式(卷二)。
  5. 联盟 / GVG 的玩法与数值设计——报名、匹配、战场与结算(卷二)。
  6. SLG 产品设计方法论——立项、竞品拆解、核心循环(本卷第 1–8 章)。

卷一覆盖第 1 与第 6 点,以及第 2 点的经济部分。卷二覆盖其余。

两条纪律

写这套书的过程中,有两条纪律是不能破的。

第一条:不编造竞品后台数字。 率土之滨、三国志·战略版、万国觉醒、COK 这些真实竞品,在这套书里只作「公开可观察机制」的对照引用——比如它的赛季周期多长、它有几个资源层级、它的兵种克制环怎么设计,这些都是外部能观察到的,而且会标注观察时点。任何需要进后台才能拿到的数字,这套书不会写,更不会写成「实测」。 这类内容在正文里统一标为 X 层:需真实业务环境,命令行不可复现。

第二条:所有数字来自本机实跑。 正文里出现的每一个数字,都是脚本真跑出来的输出。而且会写明「同机相对量级,绝对数值不可移植」——因为一次结算的耗时、一条曲线的稳态值,都跟你机器的性能有关。这套书不会写「优化后提升 3 倍」这种话,只会写「同机同输入下,A 方案比 B 方案少 X 个结算轮次」。

接下来

如果你只想看结论,可以直接跳到目录页 的「常见问题速查」表。如果你想从头开始,前言 会告诉你三条阅读路径、需要准备的环境,以及怎么验证这套书里的代码真的能跑。

如果只能记住一句话,那就是:这份设计不是被写出来的,是被跑出来的。

这套书不做什么

把边界说清楚,比把范围说大更有用。下面这些内容这套书不讲:

  • 客户端表现与美术管线。怎么把一张地图画好看、怎么做出兵种的打击感,是表现层的问题,不影响经济与数值的自洽性。
  • UI 视觉设计。第 6 章讲的是信息架构与操作路径的量化(点击成本、可达性),不是配色和排版。
  • 引擎与渲染。站内已有《游戏引擎实现原理》四卷本 从零实现渲染引擎的内容。
  • 服务端实现。站内已有《MMO 服务器开发》四卷本 ,从网络内核讲到跨服与运营。这套书只在需要解释「为什么某个设计会带来服务端压力」时点到为止。
  • 买量与投放实操。这需要真实的投放账户与素材,命令行不可复现,属于 X 层。
  • 真实竞品后台数据。同上,只引用公开可观察的机制。
  • 法务与合规、发行与渠道商务。这两块是业务侧的事,跟产品设计的方法论不在一个层面。

数值平衡与商业化(成长曲线、兵种战力、抽卡与掉落、付费点、定价、LTV、留存漏斗、A/B 实验、赛季运营、联盟 GVG)在卷二《数值平衡与商业化运营》,共 16 章 48 节。卷一不碰这些,只在第 8 章讲清楚指标该怎么定义与采集,为卷二的分析打地基。

本页是序。接着读前言 ,或直接进入第 1 章 1.1 SLG 品类图谱与细分 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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