9.3 情绪与节奏的对齐

曲线画得好不等于节奏对得上。本节把「节奏」操作化为每回合的行动数,用整数同序率量张力曲线与行动节奏是否同向:baseline 只有 290‰(低于 500‰ 随机基线,说明节奏前置、张力后置),把动作后置的策略能升到 810‰,而调高能量反而把同序率压到 0‰。

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=3290‰反向节奏前置、张力后置,默认错位
baseline em=4205‰更反向加能量把前期节奏抬更高
baseline em=60‰完全反向能量越多,前半段越猛、后半段越空
backloaded em=3810‰同向把动作后置,节奏与张力对齐

自检表

检查项合格标准
节奏可算P_t = 每回合成功打出的牌数,来自引擎快照
对齐口径明确同序率基于所有回合对,剔除并列对,报出样本对数
基线对照报出 500‰ 随机基线,说明高/低各代表什么
无浮点同序率是整数千分比,比较只用整数减法
代理指标声明张力与行动数都是 proxy,同序率只作相对比较
可复现脚本 exit 0,四种配置读数来自本机实跑

到这里,第 9 章把「情绪曲线」拆成了三件可算的事:张力从哪里来(两个分量)、曲线怎么抽特征(五个整数)、节奏对不对得上(同序率)。它们全部是代理指标,只回答「哪种设计更紧、更松、更同步」,不回答「玩家开不开心」。下一章换一个更常被引用的概念——心流,把「挑战与技巧的匹配」从一条曲线升级成一个带宽。

阅读导航:上一节:9.2 情绪曲线的可计算化 · 下一节:10.1 心流:挑战与技巧的匹配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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