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 章参数反推的题目。
| 对比 | peak | peakTurn | area | rise | drop |
|---|---|---|---|---|---|
enemyAttack=6 | 558 | 3 | 1400 | 387 | 538 |
enemyAttack=12 | 679 | 3 | 1740 | 460 | 654 |
| 变化 | +121 | 0 | +340 | +73 | +116 |
peakTurn 不变是关键读数:两条曲线的顶点在同一回合,所以它们形状同构,只是幅度不同。若 peakTurn 变了,才是节奏真的被改动。这也给下一节留了口子:形状同构不等于节奏对得上——下一节要问的是「张力涨的时候,行动数是不是也在涨」。
自检表
| 检查项 | 合格标准 |
|---|---|
| 聚合口径写明 | 明确「已结束的局按 0 计入」及其语义(释放) |
| 特征可复算 | 五个特征全部由同一条曲线算出,无隐藏常数 |
| 代理指标声明 | T_t 是 proxy,只作同口径相对比较 |
| 无浮点 | 均值用整数地板除,特征全是整数 |
| SVG 可解析 | ET.parse 通过,纯 xml.etree 生成 |
| 同构判据 | 用 peakTurn 是否变化判断「改幅度」还是「改节奏」 |
阅读导航:上一节:9.1 张力与释放 · 下一节:9.3 情绪与节奏的对齐 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。