一支队伍为什么强?玩家看到的是「战力 3049」,策划要能回答这个数字从哪来、怎么算、改了会不会崩。属性模型就是回答这三个问题的地基:它规定了有哪些属性、哪些是玩家直接养的、哪些是算出来的、算出来的又依赖谁。
本章只做两件事:把属性讲清楚(1.1 属性分类与派生属性)和把战力公式立起来并校验(1.2、1.3)。它不碰成长曲线——「升一级要多少资源、多久到满级」是第 2 章;也不碰伤害公式——「一刀打多少」是第 3 章。这里只把属性之间的关系说清楚,产出一张可拓扑排序、可检测环的属性依赖图。
如果你读过本站的独立游戏平衡与经济手册 ,先把分工讲明:那是单机/独立游戏口径(Hades、Celeste、杀戮尖塔),数值在通关时结束,平衡的目标是「这一局好不好玩」;本卷是 SLG 持续运营口径,属性要跨版本、跨赛季活着,平衡的目标是「一整个生命周期里排序对不对、膨胀可不可控」。两者对属性的关注点不同:单机关心手感,SLG 关心可排序、可公示、可扩展。
属性分成四类
属性最容易被笼统地叫成「数值」,但在设计文档里必须分清它们从哪来。按来源与计算方式,SLG 的属性可以分成四类:
| 类别 | 从哪来 | 例子 | 特点 |
|---|---|---|---|
| 基础属性 | 单位/武将/科技直接提供 | 攻击、防御、兵力、速度 | 玩家可见、可养成、是「输入」 |
| 派生属性 | 由其他属性算出 | 有效攻击、有效防御、减伤率、有效生命、输出指数、战力 | 不独立存在,改上游就变 |
| 全局修正 | 全队/全势力统一加成 | 阵营加成、联盟科技、赛季增益 | 影响面大,通常是百分比 |
| 状态属性 | 战斗过程中的临时量 | 士气、中毒层数、护盾 | 有时效,不进战力公示 |
关键区分是「基础」与「派生」。 基础属性是输入的末端,玩家养它、面板显示它;派生属性是算出来的,玩家未必看得到公式,但它决定了战斗怎么打、战力怎么排。把两者混为一谈,会出现两种典型事故:一是玩家改了 A 却发现 B 变了却找不到原因(因为 B 是派生属性);二是策划想调战力却去改了一个派生属性(改了没用,它下一帧又被算回去了)。
所以第一件事是给每个属性标注它属于哪一类。下面这段是 D 层配置:一张属性字典,基础属性直接给值,派生属性给一棵整数表达式树。表达式只允许整数运算,这样它才能在 Python 与 TypeScript 两侧算出逐位一致的数。
{
"version": 1,
"note": "属性字典:基础属性(输入)与派生属性(由整数表达式算出)。表达式是整数算术树(const/attr/add/sub/mul/div/pct/min/max),跨语言逐位一致。",
"inputs": [
{ "id": "atk", "name": "攻击", "source": "unit" },
{ "id": "def", "name": "防御", "source": "unit" },
{ "id": "hp", "name": "兵力", "source": "unit" },
{ "id": "speed", "name": "速度", "source": "unit" },
{ "id": "techAtkPct", "name": "科技攻击加成%", "source": "tech" },
{ "id": "techDefPct", "name": "科技防御加成%", "source": "tech" },
{ "id": "heroAtkPct", "name": "武将攻击加成%", "source": "hero" },
{ "id": "heroDefPct", "name": "武将防御加成%", "source": "hero" }
],
"derived": [
{ "id": "atkBuffPct", "name": "攻击加成合计%", "expr": { "add": [{ "attr": "techAtkPct" }, { "attr": "heroAtkPct" }] } },
{ "id": "defBuffPct", "name": "防御加成合计%", "expr": { "add": [{ "attr": "techDefPct" }, { "attr": "heroDefPct" }] } },
{ "id": "effAtk", "name": "有效攻击", "expr": { "pct": [{ "attr": "atk" }, { "add": [{ "const": 100 }, { "attr": "atkBuffPct" }] }] } },
{ "id": "effDef", "name": "有效防御", "expr": { "pct": [{ "attr": "def" }, { "add": [{ "const": 100 }, { "attr": "defBuffPct" }] }] } },
{ "id": "mitigationPct", "name": "减伤率%", "expr": { "min": [{ "pct": [{ "attr": "effDef" }, { "const": 50 }] }, { "const": 75 }] } },
{ "id": "ehp", "name": "有效生命", "expr": { "div": [{ "mul": [{ "attr": "hp" }, { "const": 100 }] }, { "sub": [{ "const": 100 }, { "attr": "mitigationPct" }] }] } },
{ "id": "dps", "name": "输出指数", "expr": { "div": [{ "mul": [{ "attr": "effAtk" }, { "attr": "speed" }] }, { "const": 100 }] } },
{ "id": "power", "name": "战力", "expr": { "add": [{ "mul": [{ "attr": "effAtk" }, { "const": 3 }] }, { "mul": [{ "attr": "effDef" }, { "const": 2 }] }, { "attr": "ehp" }, { "mul": [{ "attr": "dps" }, { "const": 5 }] }] } }
],
"sample": {
"unitId": "cavalry",
"label": "《王朝纪元》骑兵样板队伍",
"values": { "atk": 260, "def": 150, "hp": 360, "speed": 14, "techAtkPct": 20, "techDefPct": 15, "heroAtkPct": 10, "heroDefPct": 5 }
}
}
表达式树里有九种节点:const 常量、attr 引用另一属性、add/mul 连加连乘、sub 减法、div 整数地板除、pct 整数百分比(a*b//100)、min/max 取上下界。没有浮点、没有幂运算——这是跨语言逐位一致的前提。
派生属性必须是一张有向无环图
effAtk 依赖 atkBuffPct,mitigationPct 依赖 effDef,ehp 依赖 mitigationPct,power 依赖 effAtk/effDef/ehp/dps。这些依赖关系连起来就是一张有向图:箭头的方向是「谁算出谁」。
这张图必须满足两个条件:
- 无环(DAG)。如果
A → B → C → A形成环,那么算 A 要先算 C、算 C 要先算 B、算 B 又要先算 A——永远算不出来。写代码时它会变成一个无限递归,最后栈溢出;写策划表时它会变成「A 那格引用 B,B 引用 C,C 引用 A」,打开就报循环引用。 - 可拓扑排序。无环有向图一定能排出一个线性顺序,使得每条边的起点都排在终点之前。按这个顺序从左到右求值,每个属性算到它时,它依赖的属性都已经算完了。这个顺序就是求值顺序。
顺带能得到一个有用的副产品:层号。一个派生属性的层号等于「到它为止的最长依赖链」。层号 0 是直接依赖基础属性的第一层派生(如 atkBuffPct),层号越大说明它离输入越远、越「上层」。层号高的属性一旦算错,会污染它下面所有属性——所以调试数值时,从层号低的属性往上看最快。
下面这段是 S 层仿真脚本,读 config/n01_attrs.json,把派生属性拓扑排序后逐个求值,产出 JSON + CSV + SVG。
# file: sim/n01_attrs.py
# tier: S
# verify: python3 sim/n01_attrs.py --config config/ --out out/
"""属性依赖求解:读 config/n01_attrs.json,把派生属性按依赖关系拓扑排序后逐个求值。
表达式是整数算术树,全程整数运算,与 proto/src/n01_attrs.ts 逐位一致。
用 Kahn 算法做拓扑排序,顺带检测环与悬空引用;层号 = 最长依赖链。
纯标准库,图表用 xml.etree 手写 SVG,零依赖。"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load_config(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
def refs(expr: dict) -> set[str]:
out: set[str] = set()
if "attr" in expr:
out.add(expr["attr"])
for k in ("add", "mul", "min", "max", "sub", "div", "pct"):
for s in expr.get(k, []):
out |= refs(s)
return out
def evaluate(expr: dict, env: dict[str, int]) -> int:
if "const" in expr:
return expr["const"]
if "attr" in expr:
return env[expr["attr"]]
if "add" in expr:
return sum(evaluate(s, env) for s in expr["add"])
if "mul" in expr:
acc = 1
for s in expr["mul"]:
acc *= evaluate(s, env)
return acc
if "sub" in expr:
return evaluate(expr["sub"][0], env) - evaluate(expr["sub"][1], env)
if "div" in expr:
return evaluate(expr["div"][0], env) // evaluate(expr["div"][1], env)
if "pct" in expr:
return evaluate(expr["pct"][0], env) * evaluate(expr["pct"][1], env) // 100
if "min" in expr:
return min(evaluate(s, env) for s in expr["min"])
if "max" in expr:
return max(evaluate(s, env) for s in expr["max"])
raise ValueError(f"未知表达式节点:{expr}")
def deps_of(derived: list[dict]) -> dict[str, set[str]]:
ids = {d["id"] for d in derived}
return {d["id"]: {r for r in refs(d["expr"]) if r in ids} for d in derived}
def topo_order(derived: list[dict]) -> tuple[list[str], list[str]]:
deps = deps_of(derived)
indeg = {i: len(d) for i, d in deps.items()}
ready = sorted([i for i in indeg if indeg[i] == 0])
order: list[str] = []
while ready:
n = ready.pop(0)
order.append(n)
for m in deps:
if n in deps[m]:
indeg[m] -= 1
if indeg[m] == 0:
ready.append(m)
ready.sort()
return order, [i for i in indeg if i not in order]
def layers(derived: list[dict]) -> dict[str, int]:
deps = deps_of(derived)
lay: dict[str, int] = {}
for d in derived:
lay[d["id"]] = 1 + max([lay[x] for x in deps[d["id"]]] or [-1])
return lay
def write_svg(path: str, rows: list[dict]) -> None:
left, top, bh, gap, width = 150, 44, 26, 8, 640
height = top + len(rows) * (bh + gap) + 20
vmax = max(r["value"] for r in rows) or 1
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(width),
"height": str(height), "viewBox": f"0 0 {width} {height}"})
ET.SubElement(svg, "rect", {"width": str(width), "height": str(height), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "16",
"font-family": "sans-serif"}).text = "派生属性取值(骑兵样板队伍)"
span = width - left - 24
for i, r in enumerate(rows):
y = top + i * (bh + gap)
ET.SubElement(svg, "text", {"x": str(left - 8), "y": str(y + bh - 7), "font-size": "12",
"text-anchor": "end", "font-family": "sans-serif"}).text = f"{r['name']} L{r['layer']}"
w = max(int(span * r["value"] / vmax), 1)
ET.SubElement(svg, "rect", {"x": str(left), "y": str(y), "width": str(w), "height": str(bh), "fill": "#1f6feb"})
ET.SubElement(svg, "text", {"x": str(left + w + 6), "y": str(y + bh - 7),
"font-size": "12", "font-family": "sans-serif"}).text = str(r["value"])
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load_config(os.path.join(args.config, "n01_attrs.json"))
inputs, derived = cfg["inputs"], cfg["derived"]
known = {i["id"] for i in inputs} | {d["id"] for d in derived}
dangling = sorted({r for d in derived for r in refs(d["expr"]) if r not in known})
order, cycle = topo_order(derived)
lay = layers(derived) if not cycle else {}
env = dict(cfg["sample"]["values"])
for n in order:
d = next(x for x in derived if x["id"] == n)
env[n] = evaluate(d["expr"], env)
rows = [{"id": d["id"], "name": d["name"], "layer": lay.get(d["id"], -1), "value": env.get(d["id"], 0)}
for d in derived]
summary = {"version": 1, "unitId": cfg["sample"]["unitId"],
"inputs": {i["id"]: env.get(i["id"]) for i in inputs},
"derived": {r["id"]: r["value"] for r in rows},
"topoOrder": order, "hasCycle": bool(cycle), "cycleNodes": cycle,
"danglingRefs": dangling, "maxLayer": max(lay.values()) if lay else 0,
"power": env.get("power", 0)}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "n01_attrs.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "n01_attrs.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "layer", "value"])
for r in rows:
w.writerow([r["id"], r["name"], r["layer"], r["value"]])
write_svg(os.path.join(args.out, "n01_attrs.svg"), rows)
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
本机实跑(节选,python3 sim/n01_attrs.py --config config/ --out out/):
derived: {"atkBuffPct": 30, "defBuffPct": 20, "effAtk": 338, "effDef": 180,
"mitigationPct": 75, "ehp": 1440, "dps": 47, "power": 3049}
topoOrder: [atkBuffPct, defBuffPct, effAtk, dps, effDef, mitigationPct, ehp, power]
maxLayer: 4 hasCycle: false danglingRefs: []
脚本还顺手验证了两件容易出事的东西:hasCycle: false 说明这张依赖图是 DAG;danglingRefs: [] 说明没有属性引用了一个不存在的 id(悬空引用同样会让求值报错)。这两项检查应该进 CI——策划表一改,先跑一遍,环和悬空引用当场拦下。
读一遍这条派生链
拿骑兵样板队伍(攻击 260、防御 150、兵力 360、速度 14,科技 +20%/+15%、武将 +10%/+5%)走一遍:
- 层 0:
atkBuffPct = 20 + 10 = 30,defBuffPct = 15 + 5 = 20。加成先合并。 - 层 1:
effAtk = 260 × 130 ÷ 100 = 338,effDef = 150 × 120 ÷ 100 = 180。有效属性 = 基础属性 ×(1 + 加成)。 - 层 2:
mitigationPct = min(180 × 50 ÷ 100, 75) = 75。减伤率有 75% 的上限——这条min是防止后期防御堆到「打不动」的关键边界。 - 层 3:
ehp = 360 × 100 ÷ (100 − 75) = 1440。有效生命 = 兵力 ÷(1 − 减伤)。减伤 75% 意味着实际能吃下 4 倍的伤害。 - 层 4:
power = 338×3 + 180×2 + 1440 + 47×5 = 3049。战力是各有效属性的加权和。
注意两个设计选择:减伤率有上限(min(..., 75)),有效生命用除法而不是乘法(因为减伤是除法关系)。这两个选择都不是随口定的——它们决定了战力公式在极端数值下的行为,而这正是 1.3 要检验的东西。
小结
- 属性分四类:基础、派生、全局修正、状态。基础与派生必须分开标注,否则改数值时会找不到原因。
- 派生属性构成一张有向无环图:无环保证能算出来,拓扑排序给出求值顺序,层号指出「谁依赖谁」。
- 用整数表达式树描述派生关系,可跨语言逐位一致;
hasCycle与danglingRefs两项检查应进 CI。 - 战力(
power)是这一层的顶点:它是所有有效属性的加权和,也是下一节要校验的对象。
阅读导航:上一节:目录 · 下一节:1.2 战力公式设计与校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。