9.1 张力与释放

张力不是主观感受,而是可观测状态的代理指标。本节把每回合张力定义为「推进度」与「承压度」两个千分比分量的整数均值,在《灰烬牌库》上量出单局曲线,看峰值如何随 enemyAttack 抬高、取胜时张力怎样在决胜回合被释放、落败时又怎样无处释放;峰值只描述拉得多紧,释放落差才描述松没松开。

9.1 张力与释放

站内的独立游戏关卡设计与环境叙事 (约 940 行)是一篇散文式指南:讲空间引导、讲难度曲线、讲环境讲故事,全部围绕关卡这一容器展开。本节不碰关卡布局、也不碰叙事——它问一个更靠底层的问题:张力本身从哪里来,能不能算。那篇里的「难度曲线」是关卡内的节奏,本节的张力曲线是机制内的推进度,两者口径完全不同;《小游戏开发实战》卷一 7.2 难度曲线与心流 算的是「挑战与技巧的差」,本节算的是「一局里战况推进到了哪一步」。

先立一条纪律:张力不是「玩家感到紧张」。主观感受命令行量不到,也没有哪个整数能证明它。本节把张力定义成代理指标(proxy)——一个从引擎快照里读出来的整数,只用于相对比较(这一局比那一局更紧、这个参数比那个参数更陡),不用于证明任何感受。全书涉及体验的指标都遵守这条纪律。

张力从哪里来:两个可观测分量

《灰烬牌库》的一局是两条血条的赛跑。张力就是这场赛跑推进到了哪一步:两条战线都推得越远,战况越接近「要分胜负但还没分」,张力越高。分量全部取自引擎快照(run_spec 的返回键),没有一个来自玩家:

分量定义(千分比整数)取值在《灰烬牌库》里读作
progress_tdealt_t × 1000 // enemyHp00→1000玩家向「清空敌人血量」推进了多少
strain_t(playerHp0 − playerHp_t) × 1000 // playerHp00→1000玩家被敌人压掉了多少
tension_t(progress_t + strain_t) // 20→1000两条战线推进度的整数均值

这个定义有一个好性质:它在开局接近 0(还没推进、还没承压),在高潮接近峰值(两边都快见底),于是曲线天然是「上升」的;而一局结束时张力会释放——敌人先死,张力不再有意义,落差就等于峰值。释放这个词因此有了可算的口径:张力峰值与「决胜回合」重合,且之后归零。

S 层:单局张力轨迹与释放

S 层脚本 sim/gd_play_09_tension.py 复用 ashlib 的洗牌与常数,自己逐步重放 strategy 以留下每回合状态(ashlib.run_spec 只给终态快照)。它先打印 spec 自带种子在两档 enemyAttack 下的分量轨迹,再扫描五档 enemyAttack,看峰值与释放落差怎么走。全程整数、无浮点、无 random,计时用整数纳秒。

#!/usr/bin/env python3
# file: sim/gd_play_09_tension.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_09_tension.py
"""卷二 9.1 张力与释放(S 层):张力代理指标从哪里来。

张力不是「玩家感到紧张」——那是主观量,命令行量不到。本节把张力定义成**代理指标(proxy)**:
一局里**第 t 回合结束时**的两个可观测分量取整数均值。分量全部来自引擎快照,不读玩家感受:

  progress_t = dealt_t · 1000 // enemyHp0      玩家向目标推进了多少(0→1000)
  strain_t   = (playerHp0 − playerHp_t) · 1000 // playerHp0   玩家被压了多少(0→1000)
  tension_t  = (progress_t + strain_t) // 2     两条战线推进度的整数均值

两条战线都推进得越远,战况越接近「要分胜负但还没分」,张力越高;一局结束时若**敌人先死**(胜),
张力在决胜回合被**释放**(落差 = 峰值 − 0);若**玩家先死**(败),张力到死都没释放。脚本复用
ashlib 的洗牌与常数,自己逐步重放 strategy 以留下每回合状态(ashlib.run_spec 只给终态快照)。
全整数、无浮点、无 random。产出 out/play09_tension.json,报告实测耗时。
"""
from __future__ import annotations

import copy
import json
import os
import sys
import time

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

ATTACKS = [6, 12, 18, 24, 30]


def run(spec: dict) -> tuple[list, bool]:
    """逐步重放 spec['strategy'],返回「每回合结束」与「决胜回合」的行 + 是否取胜。

    与 ashlib.run_spec 的规则逐条对应;差别只在**留下每回合状态**。每行是
    (turn, dealt, playerHp, enemyHp, plays)。结束回合记一行;若一局在回合中途决出胜负,
    再补记一行(决胜回合),保证每个回合恰有一个读数。
    """
    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 = []

    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
            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((turn, dealt, php, ehp, plays))
            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((turn, dealt, php, ehp, plays))
    return rows, won


def tension(p: dict, row: tuple) -> dict:
    """一行状态 → 两个分量与张力(全部千分比整数)。"""
    _, dealt, php, _, plays = row
    progress = dealt * 1000 // p["enemyHp"]
    strain = (p["playerHp"] - php) * 1000 // p["playerHp"]
    return {"progress": progress, "strain": strain, "tension": (progress + strain) // 2,
            "plays": plays}


def series(spec: dict) -> tuple[list, bool]:
    p = spec["params"]
    rows, won = run(spec)
    return [tension(p, r) for r in rows], won


def main() -> int:
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    p = spec["params"]

    print("== 单局分量轨迹(spec 自带 seed 与 strategy)==")
    print("enemyAttack  turn  progress  strain  tension  plays")
    traces = {}
    for ea in (6, 30):
        s = copy.deepcopy(spec)
        s["params"]["enemyAttack"] = ea
        seq, won = series(s)
        traces[str(ea)] = {"curve": seq, "won": won}
        for i, pt in enumerate(seq):
            print(f"{ea:>11}  {i + 1:>4}  {pt['progress']:>8}  {pt['strain']:>6}  "
                  f"{pt['tension']:>7}  {pt['plays']:>5}")
        print(f"{'':>11}  → 峰值={max(x['tension'] for x in seq)} "
              f"释放落差={max(x['tension'] for x in seq) if won else 0} "
              f"结局={'胜(已释放)' if won else '败(未释放)'}")

    print("\n== 扫描 enemyAttack:峰值与释放(spec seed,单局)==")
    print(f"{'enemyAttack':>11}{'回合数':>7}{'峰值':>7}{'释放落差':>9}{'结局':>8}")
    scan = []
    for ea in ATTACKS:
        s = copy.deepcopy(spec)
        s["params"]["enemyAttack"] = ea
        seq, won = series(s)
        peak = max(x["tension"] for x in seq)
        drop = peak if won else 0
        scan.append({"enemyAttack": ea, "turns": len(seq), "peak": peak,
                     "releaseDrop": drop, "won": won})
        print(f"{ea:>11}{len(seq):>7}{peak:>7}{drop:>9}{'胜' if won else '败':>8}")

    elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
    report = {"id": "gd_play_09_tension", "attacks": ATTACKS, "scan": scan,
              "traces": traces, "elapsedMs": elapsed_ms}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play09_tension.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"\nelapsed={elapsed_ms} ms")
    print("wrote out/play09_tension.json")
    return 0


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

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

== 单局分量轨迹(spec 自带 seed 与 strategy)==
enemyAttack  turn  progress  strain  tension  plays
          6     1       460     100      280      3
          6     2       580     100      340      3
          6     3       820     200      510      2
          6     4      1000     200      600      1
             → 峰值=600 释放落差=600 结局=胜(已释放)
         30     1       460     500      480      3
         30     2       580     833      706      3
         30     3       820    1000      910      2
             → 峰值=910 释放落差=0 结局=败(未释放)

== 扫描 enemyAttack:峰值与释放(spec seed,单局)==
enemyAttack    回合数     峰值     释放落差      结局
          6      4    600      600       胜
         12      4    716      716       胜
         18      4    866      866       胜
         24      3    910        0       败
         30      3    910        0       败

elapsed=1 ms
wrote out/play09_tension.json

判读:张力是涨上去的,释放是掉下来的

轨迹里有三件值得拆开的事。

第一,张力是单调上涨的。enemyAttack=6 那一局,张力从 280 一路涨到 600;两条分量里 progress 从 460 涨到 1000(打得越来越顺)、strain 从 100 涨到 200(被磨掉一些血)。张力是这两条线的均值,只要任一条继续推进,它就继续涨——这就是「赛跑越跑越接近终点,越紧」。

第二,峰值随 enemyAttack 抬高,到 910 封顶。扫描表里 enemyAttack 从 6 涨到 18,峰值从 600 抬到 866;但到 24、30 时峰值停在 910、回合数还掉到 3。原因在分量:敌人打得越狠,strain 越快顶到 1000(玩家血被打光),于是 tension = (progress + strain) // 2 在 progress 还没走满时就冲到高位——高张力的来源从「推进」换成了「承压」。910 是「玩家血清零」这一项能贡献的上限,再高的 enemyAttack 也抬不动它。

第三,也是最重要的一点:释放只在取胜时发生。enemyAttack≤18 时一局以「敌人先死」结束,张力在决胜回合冲到峰值后归零(落差 = 峰值);enemyAttack≥24 时一局以「玩家先死」结束,张力到最后一回合还挂在 910,敌人血量没清空,张力无处释放。同样是「峰值 910」,一个是「拉到顶再松手」,一个是「拉到顶就断了」——这正是「张力与释放」要分开量的原因:峰值只描述拉得多紧,落差才描述松没松开。

场景峰值释放落差设计读法
轻松局(enemyAttack 低)低= 峰值张力小,但完整释放,体验平顺
势均局(enemyAttack 中)中高= 峰值张力足且释放,理想的高潮—回落
必败局(enemyAttack 高)高(封顶 910)0张力拉满却不释放,挫败预算超支

这张表把 8.2 的惩罚预算接上了:enemyAttack 就是惩罚旋钮,调高它抬高张力峰值,但越过高点之后释放落差塌成 0——张力只涨不落不是更强的体验,而是没有回落的高墙。

自检表

检查项合格标准
代理指标声明张力是 proxy,只作相对比较,不写「证明玩家紧张」
分量来自快照progress / strain 全部由引擎快照算出,不读主观数据
无浮点分量与张力都是千分比整数,均值用地板除
峰值与释放分开峰值量「拉多紧」,落差量「松没松开」,两个数分别报
结局标注每局显式标「胜(已释放)」或「败(未释放)」
可复现脚本 exit 0,读数来自本机实跑

单局的张力曲线到此为止。可要比较两条参数、两条策略,得先把多局收成一条曲线——下一节把这件事做成可算的五个特征,并画出平均张力曲线。

阅读导航:上一节:8.3 类型的分布与漂移 · 下一节:9.2 情绪曲线的可计算化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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