9.1 张力与释放
站内的独立游戏关卡设计与环境叙事 (约 940 行)是一篇散文式指南:讲空间引导、讲难度曲线、讲环境讲故事,全部围绕关卡这一容器展开。本节不碰关卡布局、也不碰叙事——它问一个更靠底层的问题:张力本身从哪里来,能不能算。那篇里的「难度曲线」是关卡内的节奏,本节的张力曲线是机制内的推进度,两者口径完全不同;《小游戏开发实战》卷一 7.2 难度曲线与心流 算的是「挑战与技巧的差」,本节算的是「一局里战况推进到了哪一步」。
先立一条纪律:张力不是「玩家感到紧张」。主观感受命令行量不到,也没有哪个整数能证明它。本节把张力定义成代理指标(proxy)——一个从引擎快照里读出来的整数,只用于相对比较(这一局比那一局更紧、这个参数比那个参数更陡),不用于证明任何感受。全书涉及体验的指标都遵守这条纪律。
张力从哪里来:两个可观测分量
《灰烬牌库》的一局是两条血条的赛跑。张力就是这场赛跑推进到了哪一步:两条战线都推得越远,战况越接近「要分胜负但还没分」,张力越高。分量全部取自引擎快照(run_spec 的返回键),没有一个来自玩家:
| 分量 | 定义(千分比整数) | 取值 | 在《灰烬牌库》里读作 |
|---|---|---|---|
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→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 情绪曲线的可计算化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。