8.1 奖励节奏谱

奖励节奏不是「多久给一次」的拍脑袋,而是一条可算的谱。一次 16 场远征里,每个胜场发 params.rewardGold 金币,节奏单位取玩家动作数而非回合数:实测前 8 场每 9–12 个动作换一次奖励、金币密度 1170‰,后 8 场 95 个动作零奖励。间隔序列、极差、逆序对把「节奏什么时候断」写成数字,同时闭合第 1 章的规格-实现漂移。

留存设计清单 那一篇回答的是「该做什么」:签到、日常、赛季、召回,一条条列开。本章回答另一个问题——这些奖励在时间轴上长什么样。清单说「要有奖励」,谱说「奖励之间隔了多少、密度是多少、什么时候断」。差别在于后者可以算:只要规格里有奖励粒度,就能把一次远征的奖励事件全部落在累计动作轴上,量出间隔、极差与逆序对。全章共用一个整数模型:8.1 做奖励那一半,8.2 做惩罚那一半,8.3 把两半放到同一根轴上比。

8.1 奖励节奏谱

三个要素:粒度、位置、间隔

一份「奖励节奏」要能被算,必须先回答三件事,缺一件就只能退回散文:

要素在规格里的位置在《灰烬牌库》里是什么
粒度params.rewardGold每个胜场发 12 金币;粒度决定「一次奖励有多重」
位置一次远征的场次序列第 k 场敌人血量 = enemyHp + k × difficultyStep
间隔快照的 played(累计)两次奖励之间玩家出了多少张牌

第三行是本节唯一的设计判断:节奏单位取玩家动作数,不取回合数。回合是机制的内部计数,动作才是玩家真正付出的东西——「打了 4 回合」和「出了 12 张牌」在体感上不是一回事,而后者可以直接从快照的 played 累加出来,不需要任何新字段。

这里必须交代一处旧账。第 1 章做机制解剖时发现:params 里的 rewardGold(12)与 punishHp(4)没有任何一条规则读它们,是规格里有、实现里没有的孤儿字段。那一章把它们标成「第 8 章的预留位」。本节就是兑现:gd_mech_08_reward_rhythm.py 是全卷第一个真正消费 rewardGold 的脚本——goldTotal == rewards × rewardGold 这条断言如果失败,说明它仍然没被读。第 1 章的规格-实现漂移,在这一行断言上闭合。

规格字段与派生量

远征模型只用三个整数配置加两个规格字段,全部来自 config/gd_mech_08_rhythm.json 与 play_spec.json:

量取值含义
encounters16一次远征的场次上限
difficultyStep4每场敌人血量的爬升步长
enemyHp50 + 4k第 k 场的敌人血量(规格基准值 + 爬升)
rewardGold12每个胜场的金币(规格字段,本节首次消费)
种子链rng_next 递推seed_{k+1} = seed_k × 48271 mod 2147483647

派生量同样只有整数:金币密度 = 1000 × 总金币 // 到末次奖励的动作数(千分比);间隔极差 = max(间隔) − min(间隔);逆序对 = 间隔序列里 i < j 且 gaps[i] > gaps[j] 的数对个数,用来量「节奏有多乱」。间隔序列单调递增记为减速、单调递减记为加速,其余记为波动。

下面这段是 8.1 的 S 层脚本。它做四件事:按种子链与血量爬升跑完一次远征;把每个胜场记成一个奖励事件并算间隔;把谱落成 CSV 与纯 xml.etree 的 SVG 时间轴;用三条断言锁住「金币合计 = 奖励数 × rewardGold」。

#!/usr/bin/env python3
# file: sim/gd_mech_08_reward_rhythm.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_08_reward_rhythm.py
"""卷一 8.1 奖励节奏谱(S 层):把「多久给一次奖励」算成一条可比的谱。

一次远征 = E 场遭遇,第 k 场敌人血量 = enemyHp + k*step(整数爬升),种子链由共享 PRNG
递推(seed_{k+1} = rng_next(seed_k)),故同一份规格在任何语言下得到同一张谱。节奏单位取
**玩家动作数**(快照的 played)而非回合数:卡牌游戏里「做了多少事才换到一次奖励」才是
玩家真正感到的间隔。每个胜场发 params.rewardGold 金币——`rewardGold` 正是第 1 章标出的
「规格里有、没人读」的预留位,本脚本是它第一次被真正消费,规格-实现漂移在此闭合。
产出 out/mech08_rhythm.csv + out/mech08_rhythm.svg + out/mech08_rhythm.json。
"""
from __future__ import annotations

import copy
import json
import os
import sys
import xml.etree.ElementTree as ET

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_mech_08_rhythm.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

HEADER = ["encounter", "seed", "enemyHp", "won", "turn", "actions", "cumActions", "gold", "gap"]


def run_run(spec: dict, cfg: dict) -> list[dict]:
    """一次远征:种子链递推 + 敌人血量按 step 爬升;每场规则全部来自共享底座。"""
    base, step = spec["params"]["enemyHp"], cfg["difficultyStep"]
    seed, recs = spec["seed"], []
    for k in range(cfg["encounters"]):
        s = copy.deepcopy(spec)
        s["seed"], s["params"]["enemyHp"] = seed, base + k * step
        snap = ashlib.run_spec(s)
        recs.append({"encounter": k, "seed": seed, "enemyHp": base + k * step, "won": snap["won"],
                     "turn": snap["turn"], "actions": snap["played"]})
        seed = ashlib.rng_next(seed)
    return recs


def shape(gaps: list[int]) -> str:
    if len(gaps) < 2:
        return "样本不足"
    if all(gaps[i] <= gaps[i + 1] for i in range(len(gaps) - 1)):
        return "减速(间隔递增)"
    if all(gaps[i] >= gaps[i + 1] for i in range(len(gaps) - 1)):
        return "加速(间隔递减)"
    return "波动(非单调)"


def spectrum(recs: list[dict], gold: int) -> dict:
    """节奏谱:奖励事件落在累计动作轴上的位置 + 相邻奖励的间隔序列。"""
    cum, prev, rows, gaps = 0, 0, [], []
    for r in recs:
        cum += r["actions"]
        gap = 0
        if r["won"]:
            gap, prev = cum - prev, cum      # 距上一次奖励用了多少动作
            gaps.append(gap)
        rows.append([r["encounter"], r["seed"], r["enemyHp"], r["won"], r["turn"],
                     r["actions"], cum, gold if r["won"] else 0, gap])
    total, rewards = cum, len(gaps)
    return {
        "rows": rows, "gaps": gaps, "rewards": rewards, "rewardGold": gold,
        "actionsTotal": total, "actionsToLastReward": prev, "deadActions": total - prev,
        "goldTotal": rewards * gold,
        "densityPermille": 0 if prev == 0 else 1000 * rewards * gold // prev,
        "gapMin": min(gaps), "gapMax": max(gaps), "gapSpread": max(gaps) - min(gaps),
        "inversions": sum(1 for i in range(rewards) for j in range(i + 1, rewards)
                          if gaps[i] > gaps[j]),
        "shape": shape(gaps),
    }


def draw(rows: list[list], total: int, gaps: list[int], path: str) -> int:
    """时间轴:横轴 = 累计动作数;金色条 = 奖励事件(宽 = 该次间隔),红条 = 未胜场。"""
    x0, x1, y1 = 40, 620, 150
    root = ashlib.svg_begin(660, 210)
    ashlib.svg_text(root, 40, 26, "《灰烬牌库》远征奖励节奏谱(单位:玩家动作数)", 14)
    ashlib.svg_text(root, 40, 46, f"总动作 {total};金色 = 奖励 {len(gaps)} 次;红色 = 未胜场", 11,
                    fill="#94a3b8")
    ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x1), "y2": str(y1),
                                 "stroke": "#64748b"})
    for row in rows:
        cum, acts, won = row[6], row[5], row[3]
        xa, xb = x0 + (cum - acts) * (x1 - x0) // total, x0 + cum * (x1 - x0) // total
        if won:
            ET.SubElement(root, "rect", {"x": str(xa), "y": str(y1 - 30),
                                         "width": str(max(2, xb - xa)), "height": "30",
                                         "fill": "#f59e0b", "data-value": str(acts)})
            ashlib.svg_text(root, (xa + xb) // 2, y1 - 36, acts, 10, "#fbbf24", "middle")
        else:
            ET.SubElement(root, "rect", {"x": str(xa), "y": str(y1 + 4),
                                         "width": str(max(2, xb - xa)), "height": "10",
                                         "fill": "#ef4444"})
    ashlib.svg_text(root, x0, y1 + 34, "0", 10, "#94a3b8", "middle")
    ashlib.svg_text(root, x1, y1 + 34, str(total), 10, "#94a3b8", "middle")
    ashlib.svg_text(root, 40, y1 + 62, "间隔序列(动作数):" + "/".join(str(g) for g in gaps), 12)
    ashlib.svg_text(root, 40, y1 + 84, "前段每次奖励约 9–12 个动作;后段 0 次奖励,动作全成空转。",
                    11, fill="#94a3b8")
    return ashlib.svg_save(root, path)


def main() -> int:
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    gold = spec["params"]["rewardGold"]
    sp = spectrum(run_run(spec, cfg), gold)
    for r in sp["rows"]:
        print(f"enc{r[0]:>2} seed={r[1]:>10} ehp={r[2]:>3} {'胜' if r[3] else '负'} turn={r[4]:>2} "
              f"actions={r[5]:>2} cum={r[6]:>3} gold={r[7]:>2} gap={r[8]:>2}")
    print(f"rewardGold={sp['rewardGold']} 奖励 {sp['rewards']} 次 金币合计 {sp['goldTotal']}")
    print(f"总动作={sp['actionsTotal']} 到末次奖励={sp['actionsToLastReward']} "
          f"空转={sp['deadActions']}")
    print(f"金币密度={sp['densityPermille']}‰ 间隔 min/max/spread={sp['gapMin']}/{sp['gapMax']}/"
          f"{sp['gapSpread']} 逆序对={sp['inversions']} 形状={sp['shape']}")
    assert sum(r[7] for r in sp["rows"]) == sp["goldTotal"], "金币合计对不上"
    assert sp["rewards"] * gold == sp["goldTotal"], "rewardGold 未被真正消费"
    n = ashlib.write_csv(os.path.join(OUT, "mech08_rhythm.csv"), HEADER, sp["rows"])
    size = draw(sp["rows"], sp["actionsTotal"], sp["gaps"], os.path.join(OUT, "mech08_rhythm.svg"))
    report = {"id": "gd_mech_08_reward_rhythm", "config": cfg, "rewardGold": gold,
              "encounters": sp["rows"], "gaps": sp["gaps"], "rewards": sp["rewards"],
              "goldTotal": sp["goldTotal"], "actionsTotal": sp["actionsTotal"],
              "actionsToLastReward": sp["actionsToLastReward"], "deadActions": sp["deadActions"],
              "densityPermille": sp["densityPermille"], "gapMin": sp["gapMin"],
              "gapMax": sp["gapMax"], "gapSpread": sp["gapSpread"],
              "inversions": sp["inversions"], "shape": sp["shape"]}
    with open(os.path.join(OUT, "mech08_rhythm.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    ET.parse(os.path.join(OUT, "mech08_rhythm.svg"))
    print(f"wrote out/mech08_rhythm.csv({n} 行 + 表头) out/mech08_rhythm.svg({size} B) "
          f"out/mech08_rhythm.json")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_08_reward_rhythm.py:

enc 0 seed=  20261014 ehp= 50 胜 turn= 4 actions= 9 cum=  9 gold=12 gap= 9
enc 1 seed= 914347409 ehp= 54 胜 turn= 4 actions= 9 cum= 18 gold=12 gap= 9
enc 2 seed=1379866695 ehp= 58 胜 turn= 5 actions=10 cum= 28 gold=12 gap=10
enc 3 seed=1192438993 ehp= 62 胜 turn= 4 actions=11 cum= 39 gold=12 gap=11
enc 4 seed=1218440562 ehp= 66 胜 turn= 5 actions=10 cum= 49 gold=12 gap=10
enc 5 seed=  62244266 ehp= 70 胜 turn= 4 actions=10 cum= 59 gold=12 gap=10
enc 6 seed= 263341933 ehp= 74 胜 turn= 5 actions=11 cum= 70 gold=12 gap=11
enc 7 seed= 822741250 ehp= 78 胜 turn= 5 actions=12 cum= 82 gold=12 gap=12
enc 8 seed=1127794779 ehp= 82 负 turn= 6 actions=12 cum= 94 gold= 0 gap= 0
enc 9 seed=1071325659 ehp= 86 负 turn= 6 actions=12 cum=106 gold= 0 gap= 0
enc10 seed= 407182182 ehp= 90 负 turn= 6 actions=12 cum=118 gold= 0 gap= 0
enc11 seed=1320769978 ehp= 94 负 turn= 6 actions=12 cum=130 gold= 0 gap= 0
enc12 seed= 393095902 ehp= 98 负 turn= 6 actions=12 cum=142 gold= 0 gap= 0
enc13 seed=2114264197 ehp=102 负 turn= 6 actions=11 cum=153 gold= 0 gap= 0
enc14 seed= 634213359 ehp=106 负 turn= 6 actions=12 cum=165 gold= 0 gap= 0
enc15 seed=1733664304 ehp=110 负 turn= 6 actions=12 cum=177 gold= 0 gap= 0
rewardGold=12 奖励 8 次 金币合计 96
总动作=177 到末次奖励=82 空转=95
金币密度=1170‰ 间隔 min/max/spread=9/12/3 逆序对=2 形状=波动(非单调)
wrote out/mech08_rhythm.csv(16 行 + 表头) out/mech08_rhythm.svg(3354 B) out/mech08_rhythm.json

判读:谱断在哪一场

这张谱的形状一句话说完:前 8 场每 9–12 个动作换一次奖励,后 8 场 95 个动作颗粒无收。

量实测值读法
奖励次数 / 金币合计8 次 / 96等于 8 × rewardGold(12),漂移已闭合
到末次奖励的动作数82有奖励的那一段总共只花了 82 个动作
空转动作95全程 177 个动作里有 95 个换不回任何东西
金币密度1170‰有奖励区间内每 1 个动作换到 1.17 金币
间隔 min/max/极差9 / 12 / 3间隔被压在 3 个动作的带宽里,节奏很稳
逆序对2间隔序列只有两处逆序,接近单调
形状波动(非单调)既非加速也非减速,是「稳中带抖」

真正值钱的不是 1170‰ 这个数,而是空转 95。它把「节奏断掉」从一句抱怨变成一个整数:奖励期只占 82 个动作,剩下 95 个动作(53.7%)是纯消耗。难度爬升 difficultyStep = 4 到第 8 场(enemyHp = 82)就把固定策略打穿了——从那一刻起,玩家的每一次输入都换不回奖励,而惩罚还在继续(8.2 会把这半段算出来)。

逆序对 2 这个数也值得停一下。间隔序列是 9/9/10/11/10/10/11/12,看上去几乎递增,但第 4 个间隔(11)比第 5 个(10)大、比第 6 个(10)也大,于是产生两处逆序。这说明即使平均间隔在拉长,局部仍会有回抽——只报「平均间隔 10.25」会把这两处回抽抹掉,而回抽正是玩家感觉「这局有点顺」的来源。

检查项合格标准
粒度真被消费goldTotal == rewards × rewardGold,否则仍是孤儿字段
位置可复现种子链由共享 PRNG 递推,同规格任何语言同谱
间隔有单位单位是玩家动作数(played 累计),不是回合数
谱能断报告空转动作数与它占全程的比例,而不是只报平均值

谱画出来之后,下一个问题顺理成章:断了之后会怎样。奖励停了,惩罚不会停——下一节把「玩家还能忍多久」也算成一个整数阈值。

阅读导航:上一节:7.3 循环的稳定性 · 下一节:8.2 惩罚机制与挫败预算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡