产品设计书最容易写成散文。翻开目录,每一章都对:要理解用户、要设计核心循环、要控制通胀、要平衡数值。合上书,你还是不知道明天上班该先动哪个数字。
这套书想解决的就是这个落差。它的写法只有一条规矩:每一节都要留下一个能跑的东西。
一个具体的问题
假设你在做一款 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 专属建模为真空白」**。六个具体落点是:
- SLG 经济系统的产出/回收闭环建模——以大地图地块产出、行军消耗、联盟共研为对象的闭环模型。
- 赛季制数值膨胀控制——世界地图重开、排行榜结算、跨服赛季下的膨胀控制(卷二)。
- LTV 与付费点设计映射——把 LTV 拆到 SLG 的付费点上(卷二)。
- SLG 兵种 / 地块 / 战力数值平衡——兵种克制、地块等级、战力公式(卷二)。
- 联盟 / GVG 的玩法与数值设计——报名、匹配、战场与结算(卷二)。
- 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 品类图谱与细分 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。