9.3 情绪与节奏的对齐
站内的独立游戏关卡设计与环境叙事 (约 940 行)把「节奏」当成一种排布:在哪里放一段安静、在哪里放一次遭遇。本节把节奏换成可算的东西——每回合的行动数,然后问一个二选一的问题:张力往上走的时候,行动数是不是也在往上走? 对得上叫对齐,对不上叫错位。《小游戏开发实战》卷一 7.2 难度曲线与心流 只画单条难度曲线,不比较两条曲线的相位,本节补的正是相位这一维。
上一节画出了张力曲线,但「曲线好看」不等于「节奏对得上」:一条上升的张力曲线,若行动节奏也在上升,玩家是「越紧越有牌打」;若行动节奏在下降,玩家就是「越紧越没牌打」。这两种设计的体验完全不同,可它们共用同一条张力曲线。所以必须另算一个量。
把「节奏」操作化:每回合的行动数
「节奏」在这里定义为 P_t = 第 t 回合里成功打出的牌数(引擎快照 played 的增量)。选它有三个理由:它是玩家真实动作的计数,不是感受;它每回合都在变(受能量与手牌约束);它正是「玩家手里还有没有牌打」的直接读数。
于是对齐问题变成两条整数序列的比较:T_t(张力)与 P_t(行动数)。两条都是代理指标(proxy),只作相对比较。
对齐用同序率,不用浮点相关
常规做法是算皮尔逊相关——但那要开平方、要浮点,本卷禁浮点。替代方案是同序率 concordance‰:对一局里所有回合对 (i<j),看 ΔT = T_j − T_i 与 ΔP = P_j − P_i 是否同号;同号的回合对占全部非并列对(ΔT 或 ΔP 为 0 的对剔除)的比例就是同序率。
1000‰= 两条序列完全同向(张力涨、行动数也涨);0‰= 完全反向(张力涨、行动数反跌);500‰= 随机基线,即「没有关系」。
整个式子只用整数减法与比较,对每局的所有回合对计数、跨局求和——长局自然权重更大,且不需要浮点。
S 层:四种配置的同序率
S 层脚本 sim/gd_play_09_align.py 在固定种子池上比较四种配置:baseline 策略在 energyMax∈{3,4,6},以及后置节奏策略(把动作攒到后面用)在 energyMax=3。它对每局算同序对与非并列对、跨局求和,再画一条把两条曲线各自归一化到千分比的 SVG。全程整数、无浮点、无 random。
#!/usr/bin/env python3
# file: sim/gd_play_09_align.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_09_align.py
"""卷二 9.3 情绪与节奏的对齐(S 层):张力曲线与行动节奏对不对得上。
9.2 量出了张力曲线的形状,但「形状好看」不等于「节奏对得上」。本脚本把**节奏**操作化为
**每回合的行动数** P_t(一回合里成功打出的牌数,来自引擎快照的 played 增量),再问一个可算的
问题:**张力往上走的时候,行动数是不是也在往上走?**
对齐用**同序率 concordance‰**(整数):对一局里所有回合对 (i<j),看 ΔT 与 ΔP 是否同号;
同号的对占全部非并列对的比例就是同序率。1000‰ = 完全同向,0‰ = 完全反向,**500‰ 是随机基线**。
对一局里每个回合对计数、跨局求和——这样长局权重更大,且不需要浮点相关。
本脚本比较四种配置:baseline 策略在 energyMax∈{3,4,6},以及**后置节奏**策略在 energyMax=3
(把动作攒到后面用)。张力与行动数都是**代理指标(proxy)**,同序率只作相对比较。画一条把
两条曲线各自归一化到千分比的纯 xml.etree SVG。全整数、无浮点、无 random。产出 out/play09_align.json。
"""
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
def run(spec: dict, strategy: list) -> 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, plays = p["playerHp"], p["enemyHp"], 0, 0, 0
over = won = False
rows: list = []
def snap() -> tuple:
progress = dealt * 1000 // p["enemyHp"]
strain = (p["playerHp"] - php) * 1000 // p["playerHp"]
return ((progress + strain) // 2, plays)
for cmd in 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
plays += 1
if ehp <= 0:
over = won = True
elif cmd == "e":
absorbed = min(block, p["enemyAttack"])
block -= absorbed
php = max(0, php - (p["enemyAttack"] - absorbed))
rows.append(snap())
if php <= 0:
over = True
break
turn += 1
block = 0
energy = p["energyMax"]
plays = 0
while len(hand) < p["handSize"] and draw:
hand.append(draw.pop(0))
if won:
rows.append(snap())
return rows
def concordance(rows: list) -> tuple[int, int]:
"""同序对与总非并列对:对一局里所有回合对比较 ΔT 与 ΔP 的符号。"""
conc = tot = 0
for i in range(len(rows)):
for j in range(i + 1, len(rows)):
dt = rows[j][0] - rows[i][0]
dp = rows[j][1] - rows[i][1]
if dt == 0 or dp == 0:
continue
tot += 1
if (dt > 0) == (dp > 0):
conc += 1
return conc, tot
def avg_curves(spec: dict, seeds: list, strategy: list, over: dict) -> tuple[list, list]:
"""固定池上按回合取**条件均值**(只对活到该回合的局求均值,与 9.2 的释放口径不同)。
9.2 要画「张力随局结束而释放」的弧线,故把已结束的局按 0 计入;本节要对比**同一批回合里**
的张力与行动数,若也按 0 计入,行动数会在后半段被「局已结束」拖到 0,看不出节奏。故这里对
每个回合只统计**活到该回合的局**,两条曲线同口径,形状可比。
"""
ts: dict[int, int] = {}
ps: dict[int, int] = {}
cnt: dict[int, int] = {}
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update(over)
for i, (t, p_) in enumerate(run(s, strategy)):
ts[i + 1] = ts.get(i + 1, 0) + t
ps[i + 1] = ps.get(i + 1, 0) + p_
cnt[i + 1] = cnt.get(i + 1, 0) + 1
turns = sorted(cnt)
return ([ts[t] // cnt[t] for t in turns], [ps[t] // cnt[t] for t in turns])
def draw(tcurve: list, pcurve: list, path: str) -> int:
W, H, x0, y0, y1 = 620, 280, 70, 44, 220
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, x0, 26, "baseline:张力(绿,上升)与行动数(橙,下降)各归一化到千分比", 14)
mt = max(tcurve) or 1
mp = max(pcurve) or 1
for curve, mx, color in ((tcurve, mt, "#22c55e"), (pcurve, mp, "#f59e0b")):
pts = " ".join(f"{x0 + i * (W - 110) // max(1, len(curve) - 1)},"
f"{y1 - v * 1000 // mx * (y1 - y0) // 1000}" for i, v in enumerate(curve))
ET.SubElement(root, "polyline", {"points": pts, "fill": "none",
"stroke": color, "stroke-width": "2"})
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"]]
base = list(spec["strategy"])
back = cfg["align"]["backloaded"]
variants = [(f"baseline em={em}", base, {"energyMax": em}) for em in cfg["align"]["energyMaxCandidates"]]
variants.append(("backloaded em=3", back, {"energyMax": 3}))
rows_out = []
for label, strat, over in variants:
conc = tot = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"].update(over)
c, t = concordance(run(s, strat))
conc += c
tot += t
rate = 1000 * conc // tot if tot else -1
rows_out.append({"label": label, "concordance": rate, "concordant": conc, "pairs": tot})
tc, pc = avg_curves(spec, seeds, base, {"energyMax": 3})
size = draw(tc, pc, os.path.join(OUT, "play09_align.svg"))
chance = cfg["align"]["chancePermille"]
print(f"seeds={len(seeds)} 随机基线={chance}‰(>500‰ 同向,<500‰ 反向)")
print(f"{'配置':<18}{'同序率':>7}{'同序对':>7}{'非并列对':>9}")
for r in rows_out:
print(f"{r['label']:<18}{r['concordance']:>7}{r['concordant']:>7}{r['pairs']:>9}")
print("\nbaseline 平均曲线(回合:张力/行动数)")
print(" " + " ".join(f"{i + 1}:{t}/{p}" for i, (t, p) in enumerate(zip(tc, pc))))
elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
report = {"id": "gd_play_09_align", "seeds": len(seeds), "chancePermille": chance,
"variants": rows_out, "baselineTension": tc, "baselinePlays": pc,
"elapsedMs": elapsed_ms}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play09_align.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_align.svg"))
print(f"\nelapsed={elapsed_ms} ms svg={size} bytes")
print("wrote out/play09_align.json out/play09_align.svg")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_09_align.py:
seeds=32 随机基线=500‰(>500‰ 同向,<500‰ 反向)
配置 同序率 同序对 非并列对
baseline em=3 290 32 110
baseline em=4 205 14 68
baseline em=6 0 0 43
backloaded em=3 810 180 222
baseline 平均曲线(回合:张力/行动数)
1:171/2 2:374/2 3:558/2 4:592/1 5:650/1
elapsed=10 ms svg=613 bytes
wrote out/play09_align.json out/play09_align.svg
判读:默认节奏与张力反向,把动作后置才对得上
baseline em=3 的同序率是 290‰,明显低于 500‰ 随机基线——张力在涨,行动数在跌。平均曲线把这件事摊开了:张力 171→374→558→592→650 一路涨,行动数 2→2→2→1→1 一路跌。原因在能量预算:每回合的能量是固定的,玩家在开局张力还低的时候就把牌打光,等到张力最高的后半程,手里没牌、能量也不够,行动数自然掉下去。机制把节奏前置了,情绪却后置——两者错位。
调参能验证这个诊断。把 energyMax 从 3 抬到 4、6,同序率不升反降(290‰ → 205‰ → 0‰)。这不反直觉:能量越多,玩家越能在前期一口气把牌打完,前半段的行动数被抬得更高、后半段的落差更大,错位只会更严重。加能量治不了节奏错位,只会加剧它。
真正的修复是改策略的时序:backloaded em=3 把动作攒到后面(开局少打、后段多打),同序率跳到 810‰。同样的规格、同样的能量,只把「什么时候花」换一下,两条曲线就同向了。这说明对齐是一个设计选择,不是参数的自然属性。
| 配置 | 同序率 | 与 500‰ 比 | 读法 |
|---|---|---|---|
baseline em=3 | 290‰ | 反向 | 节奏前置、张力后置,默认错位 |
baseline em=4 | 205‰ | 更反向 | 加能量把前期节奏抬更高 |
baseline em=6 | 0‰ | 完全反向 | 能量越多,前半段越猛、后半段越空 |
backloaded em=3 | 810‰ | 同向 | 把动作后置,节奏与张力对齐 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 节奏可算 | P_t = 每回合成功打出的牌数,来自引擎快照 |
| 对齐口径明确 | 同序率基于所有回合对,剔除并列对,报出样本对数 |
| 基线对照 | 报出 500‰ 随机基线,说明高/低各代表什么 |
| 无浮点 | 同序率是整数千分比,比较只用整数减法 |
| 代理指标声明 | 张力与行动数都是 proxy,同序率只作相对比较 |
| 可复现 | 脚本 exit 0,四种配置读数来自本机实跑 |
到这里,第 9 章把「情绪曲线」拆成了三件可算的事:张力从哪里来(两个分量)、曲线怎么抽特征(五个整数)、节奏对不对得上(同序率)。它们全部是代理指标,只回答「哪种设计更紧、更松、更同步」,不回答「玩家开不开心」。下一章换一个更常被引用的概念——心流,把「挑战与技巧的匹配」从一条曲线升级成一个带宽。
阅读导航:上一节:9.2 情绪曲线的可计算化 · 下一节:10.1 心流:挑战与技巧的匹配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。