9.2 情绪曲线的可计算化

单局张力是一条读数,多局张力才是一条曲线。本节把固定种子池上的每局张力按回合取整数均值,并规定「已结束的局按 0 计入」这一释放口径,于是曲线自带上升—高潮—回落的形状;再从曲线抽出峰值、峰值回合、面积、蓄势、释放落差五个整数特征,画成纯 xml.etree SVG。

9.2 情绪曲线的可计算化

站内的独立游戏关卡设计与环境叙事 (约 940 行)里,「情绪曲线」是靠文字描述与关卡节奏来传达的:玩家在这里喘口气、在那里被推一把。本节把这件事换成可算的口径——曲线就是一组按回合排列的整数。《小游戏开发实战》卷一 7.2 难度曲线与心流 也画曲线,但它画的是「难度」随时间的走势,本节画的是张力(战况推进度)随时间的走势,两条曲线的纵轴含义完全不同,不可互相代入。

上一节给的是一局的读数。可要比较两档参数、两条策略,必须先解决一个口径问题:多局怎么合成一条曲线。

从单局到曲线:一个口径问题

一局《灰烬牌库》的回合数不固定:有的 3 回合结束,有的 5 回合。直接对同一回合号取均值会撞上「后半段只剩少数几局还在打」的问题。本节的处置是:已结束的局,在它结束之后的回合里按 0 计入。

这条规则不是凑数,而是语义:一局在决胜回合结束、张力随之释放,之后的回合它就该贡献 0。于是平均曲线 T_t 自然带上「上升 → 高潮 → 回落」的形状——回落段不是「玩家放松了」,而是「一批局已经打完了」。这样定义的好处是每个回合都有全池样本支撑,不靠少数长局撑起曲线末端。

五个整数特征

一条曲线不好直接引用,抽成几个整数才好比较。本节从 T_t 抽五个:

特征定义读作
peak 张力峰值max_t T_t曲线最高点:这一局最紧能有多紧
peakTurn 峰值回合argmax_t T_t高潮落在第几回合
area 曲线面积Σ_t T_t曲线下的整数面积:整局的张力总量
rise 蓄势peak − T_1从开局到高潮涨了多少
drop 释放落差peak − T_last高潮到曲线末端的回落:张力被放掉多少

这五个都是整数,都由同一条曲线算出。peak 与 drop 是一对:前者量「拉多紧」,后者量「松没松开」;9.1 的单局版已经看到「峰值 910 但落差 0」的必败局,聚合版的 drop 会把这种局摊薄。它们仍是代理指标(proxy),只在同口径下比大小。

S 层:平均张力曲线与五个特征

S 层脚本 sim/gd_play_09_curve.py 读只读规格 config/play_spec.json 与曲线配置 config/gd_play_09_curve.json,在固定种子池上对每档 enemyAttack 算一条平均曲线,抽五个特征,并画成纯 xml.etree SVG。全程整数、无浮点、无 random;计调用 perf_counter_ns 的整数纳秒。

#!/usr/bin/env python3
# file: sim/gd_play_09_curve.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_09_curve.py
"""卷二 9.2 情绪曲线的可计算化(S 层):把多局张力收成一条曲线并抽出五个整数特征。

9.1 给的是**一局**的张力读数;要比较两个参数、两条策略,得先把**多局**收成一条曲线。
本脚本在固定种子池上跑若干局,按回合序号对张力取整数均值,得到**平均张力曲线** T_t。
关键口径是**已结束的局按 0 计入**:一局在决胜回合结束、张力随之释放,之后的回合它对
平均值的贡献就是 0。于是曲线自带「上升 → 高潮 → 回落」的形状,不需要额外画释放点。

从曲线抽五个整数特征:

  张力峰值 peak    = max_t T_t        曲线最高点(有全池样本支撑)
  峰值回合 peakTurn = argmax_t T_t      高潮落在第几回合
  曲线面积 area    = Σ_t T_t          曲线下的整数面积(离散积分)
  蓄势 rise        = peak − T_1        从开局到高潮涨了多少
  释放落差 drop    = peak − T_last     高潮到曲线末端的回落(张力被放掉多少)

张力与分量同 9.1 的定义(progress 与 strain 的整数均值),是**代理指标(proxy)**,只作相对比较。
本脚本对 curveAttacks 里的每个 enemyAttack 各算一条曲线,画成纯 xml.etree SVG,并写 JSON。
全整数、无浮点、无 random。产出 out/play09_curve.json + .svg,报告实测耗时。
"""
from __future__ import annotations

import copy
import json
import os
import sys
import time
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_play_09_curve.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

COLORS = {"6": "#22c55e", "12": "#f59e0b"}


def tension(p: dict, dealt: int, php: int) -> int:
    progress = dealt * 1000 // p["enemyHp"]
    strain = (p["playerHp"] - php) * 1000 // p["playerHp"]
    return (progress + strain) // 2


def run(spec: dict) -> list:
    """逐步重放,返回每回合结束(含决胜回合)的张力千分比列表。规则同 ashlib.run_spec。"""
    p = spec["params"]
    deck: list = []
    for c in spec["cards"]:
        deck.extend([c] * c["count"])
    deck = ashlib.shuffle_deck(deck, spec["seed"])
    hand = deck[: p["handSize"]]
    draw = deck[p["handSize"]:]
    turn, energy = 1, p["energyMax"]
    php, ehp, block, dealt = p["playerHp"], p["enemyHp"], 0, 0
    over = won = False
    curve: list = []
    for cmd in spec["strategy"]:
        if over:
            break
        if turn > p["turnLimit"]:
            over = True
            break
        if cmd.startswith("p"):
            idx = int(cmd[1:])
            if idx < 0 or idx >= len(hand):
                continue
            c = hand[idx]
            if c["cost"] > energy:
                continue
            hand.pop(idx)
            energy -= c["cost"]
            block += c["block"]
            d = min(c["damage"], ehp)
            ehp -= d
            dealt += d
            if ehp <= 0:
                over = won = True
        elif cmd == "e":
            absorbed = min(block, p["enemyAttack"])
            block -= absorbed
            php = max(0, php - (p["enemyAttack"] - absorbed))
            curve.append(tension(p, dealt, php))
            if php <= 0:
                over = True
                break
            turn += 1
            block = 0
            energy = p["energyMax"]
            while len(hand) < p["handSize"] and draw:
                hand.append(draw.pop(0))
    if won:
        curve.append(tension(p, dealt, php))
    return curve


def aggregate(spec: dict, seeds: list, over: dict) -> list:
    """固定种子池上按回合求张力整数均值;已结束的局之后按 0 计入(张力已释放)。"""
    sums: dict[int, int] = {}
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        s["params"].update(over)
        for i, val in enumerate(run(s)):
            sums[i + 1] = sums.get(i + 1, 0) + val
    return [sums.get(t, 0) // len(seeds) for t in range(1, max(sums) + 1)]


def features(curve: list) -> dict:
    peak = max(curve)
    return {"peak": peak, "peakTurn": curve.index(peak) + 1, "area": sum(curve),
            "rise": peak - curve[0], "drop": peak - curve[-1], "turns": len(curve)}


def draw(series: dict, path: str) -> int:
    W, H, x0, y0, y1 = 660, 300, 70, 44, 232
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, x0, 26, "平均张力曲线 T_t(千分比,固定种子池,已结束的局按 0 计)", 14)
    maxt = max(len(c) for c in series.values())
    for k, (ea, curve) in enumerate(series.items()):
        pts = " ".join(f"{x0 + i * (W - 110) // max(1, maxt - 1)},"
                       f"{y1 - v * (y1 - y0) // 1000}" for i, v in enumerate(curve))
        ET.SubElement(root, "polyline", {"points": pts, "fill": "none",
                                         "stroke": COLORS[ea], "stroke-width": "2"})
        ashlib.svg_text(root, x0, y1 + 18 + 16 * k,
                        f"enemyAttack={ea}  峰值={max(curve)}  面积={sum(curve)}", 12, COLORS[ea])
    return ashlib.svg_save(root, path)


def main() -> int:
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    seeds = cfg["seedPool"][: cfg["cellsPerScan"]]
    series, feats = {}, {}
    for ea in cfg["curveAttacks"]:
        curve = aggregate(spec, seeds, {"enemyAttack": ea})
        series[str(ea)] = curve
        feats[str(ea)] = features(curve)

    print(f"seeds={len(seeds)}  enemyAttack∈{cfg['curveAttacks']}(已结束的局按 0 计入)")
    print(f"{'enemyAttack':>11}{'回合数':>7}{'峰值':>6}{'峰值回合':>9}{'面积':>7}{'蓄势':>6}{'释放落差':>9}")
    for ea in cfg["curveAttacks"]:
        f = feats[str(ea)]
        print(f"{ea:>11}{f['turns']:>7}{f['peak']:>6}{f['peakTurn']:>9}{f['area']:>7}"
              f"{f['rise']:>6}{f['drop']:>9}")
    print("\n平均张力曲线(回合:张力)")
    for ea in cfg["curveAttacks"]:
        print(f"  enemyAttack={ea}: " + " ".join(
            f"{i + 1}:{v}" for i, v in enumerate(series[str(ea)])))

    size = draw(series, os.path.join(OUT, "play09_curve.svg"))
    elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
    report = {"id": "gd_play_09_curve", "seeds": len(seeds), "attacks": cfg["curveAttacks"],
              "features": feats, "curves": series, "elapsedMs": elapsed_ms}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play09_curve.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, "play09_curve.svg"))
    print(f"\nelapsed={elapsed_ms} ms  svg={size} bytes")
    print("wrote out/play09_curve.json out/play09_curve.svg")
    return 0


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

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

seeds=32  enemyAttack∈[6, 12](已结束的局按 0 计入)
enemyAttack    回合数    峰值     峰值回合     面积    蓄势     释放落差
          6      5   558        3   1400   387      538
         12      5   679        3   1740   460      654

平均张力曲线(回合:张力)
  enemyAttack=6: 1:171 2:374 3:558 4:277 5:20
  enemyAttack=12: 1:219 2:470 3:679 4:347 5:25

elapsed=12 ms  svg=923 bytes
wrote out/play09_curve.json out/play09_curve.svg

判读:曲线长什么样,特征说明什么

曲线 1:171 2:374 3:558 4:277 5:20(enemyAttack=6)是一条标准的拱形:第 1 到第 3 回合张力从 171 涨到峰值 558,第 4 回合掉到 277、第 5 回合只剩 20。回落不是玩家变轻松,而是多数局在第 3、4 回合就结束了,结束的局按 0 计入——拱形的右半边就是释放的聚合形状。

四个特征里,peak 与 peakTurn 描述顶点,area 与 rise 描述上升段,drop 描述回落段。把 enemyAttack 从 6 抬到 12,五个特征一起变大:峰值 558→679、面积 1400→1740、蓄势 387→460、释放落差 538→654,peakTurn 都是 3。这说明抬高一档 enemyAttack 没有改变曲线的形状(顶点仍在第 3 回合),只是把整条曲线等比抬高——它是一个「整体增益」旋钮,不是「改变节奏」旋钮。要改节奏,得动别的参数(回合上限、手牌容量),那是 15 章参数反推的题目。

对比peakpeakTurnarearisedrop
enemyAttack=655831400387538
enemyAttack=1267931740460654
变化+1210+340+73+116

peakTurn 不变是关键读数:两条曲线的顶点在同一回合,所以它们形状同构,只是幅度不同。若 peakTurn 变了,才是节奏真的被改动。这也给下一节留了口子:形状同构不等于节奏对得上——下一节要问的是「张力涨的时候,行动数是不是也在涨」。

自检表

检查项合格标准
聚合口径写明明确「已结束的局按 0 计入」及其语义(释放)
特征可复算五个特征全部由同一条曲线算出,无隐藏常数
代理指标声明T_t 是 proxy,只作同口径相对比较
无浮点均值用整数地板除,特征全是整数
SVG 可解析ET.parse 通过,纯 xml.etree 生成
同构判据用 peakTurn 是否变化判断「改幅度」还是「改节奏」

阅读导航:上一节:9.1 张力与释放 · 下一节:9.3 情绪与节奏的对齐 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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