1.1 属性分类与派生属性

属性是 SLG 数值的地基。本篇把属性分成基础、派生、全局修正与状态四类,用一棵整数表达式树描述派生属性的依赖关系,跑出拓扑排序、层号与环检测,并以《王朝纪元》骑兵样板队伍算出从有效攻击、减伤率、有效生命到战力的完整派生链,同时给出应进 CI 的两项配置自检。

一支队伍为什么强?玩家看到的是「战力 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 战力公式设计与校验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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