「手感」在独立游戏核心循环与手感设计 里是一整套参数体系——加速度、顿帧、屏幕震动、镜头跟随,讲的是输入到表现的那一段。本节把手感往前挪一格,问一个更基础的问题:机制产生的那一堆状态变化,有哪些真的走到了玩家眼前?这份「哪些分量被呈现」的名单就是机制与玩家的接口。《小游戏开发实战》卷一《市场品类与产品设计》 从产品口径问「玩家为什么留下」,本节从机制口径问「玩家这一回合到底看见了什么」。
1.3 反馈:机制与玩家的接口
机制每回合产生十几次状态变化,玩家只看见其中一部分。接口就是那张名单——它在 play_spec.json 里没有对应字段,是一条设计声明(与 1.1 状态表里那一栏是同一份名单)。名单之外的分量不是不存在,只是玩家看不见。
| 分量 | 呈现形态 | 信号类型 | 本局变动 |
|---|---|---|---|
enemyHp | 敌方血条 | 数值型(显著) | 7 次,共 −50 |
playerHp | 我方血条 | 数值型(显著) | 2 次,共 −12 |
block | 格挡条 | 数值型(显著) | 3 次(+5 / +5 / −10) |
energy | 能量条 | 数值型 | 每回合重置,每次出牌 −cost |
hand | 手牌区(张数) | 数值型 | 出牌 −1、回合末补牌 +1 |
turn | 回合数 | 事件型 | 每回合 +1 |
over / won | 结算画面 | 事件型 | 末次触发 |
dealt / played / cardCount / damageConserved | 未呈现 | — | 只在快照里,玩家看不到 |
呈现 8 / 12 = 666‰。其中前三个是显著信号(血条与格挡条),进事件流;energy 与 hand 是持续可见的条,只进强度;turn 与 over/won 是事件型信号,不进强度。
S 层:从轨迹里抽出反馈事件与强度
sim/gd_mech_01_feedback.py 逐指令回放,抽出两样东西:反馈事件流(显著信号的每次非零 Δ,加上被拒指令与终局)与反馈强度(每回合的净位移与总位移,覆盖全部 5 个数值型信号)。强度是代理指标,只用于回合之间、参数之间的相对比较,不构成「好玩」的绝对判据。
#!/usr/bin/env python3
# file: sim/gd_mech_01_feedback.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_01_feedback.py
"""卷一 1.3 反馈:机制与玩家的接口(S 层)。
机制与玩家的接口 = 状态里**哪些分量被呈现**。快照的 12 个分量按呈现与否分成两堆,再抽出:
① 反馈事件流:**显著信号**(我方血条 / 敌方血条 / 格挡条)的每次非零 Δ 记一条事件,
加上被拒指令与终局;能量条与手牌区是持续可见的条,只进强度不进事件流;
② 反馈强度:每回合的**净位移**(回合首 → 回合末)与**总位移**(逐指令 |Δ| 累加),
覆盖全部 5 个数值型信号,都是整数。
被拒指令(能量不足)是接口的缺口——机制上什么都没发生,但玩家的输入已经发生。
强度是**代理指标**,只用于相对比较,不构成「好玩」的绝对判据。
产出 out/mech01_feedback.csv 与 out/mech01_feedback.svg(纯 xml.etree)。
"""
from __future__ import annotations
import os
import sys
import ashlib
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
# 呈现与否是**设计声明**(与 1.1 的 SURFACED 同一份名单)。
SURFACED = ["block", "energy", "enemyHp", "hand", "over", "playerHp", "turn", "won"]
NUMERIC = ["playerHp", "enemyHp", "block", "energy", "hand"] # 进强度的 5 个数值型信号
SALIENT = ["playerHp", "enemyHp", "block"] # 进事件流的 3 个显著信号
HEADER = ["seq", "turn", "cmd", "signal", "delta", "note"]
def trace(spec: dict) -> list[dict]:
"""逐指令回放,记下每条指令前后的数值型信号。规则与 ashlib.run_spec 逐条一致。"""
p = spec["params"]
deck = []
for c in spec["cards"]:
deck.extend([c] * c["count"])
deck = ashlib.shuffle_deck(deck, spec["seed"])
hand, draw = deck[: p["handSize"]], deck[p["handSize"]:]
turn, energy = 1, p["energyMax"]
php, ehp, block = p["playerHp"], p["enemyHp"], 0
over = won = False
out = []
for cmd in spec["strategy"]:
if over or turn > p["turnLimit"]:
over = True
break
t, before, reject = turn, [php, ehp, block, energy, len(hand)], False
if cmd.startswith("p"):
i = int(cmd[1:])
if i >= len(hand) or hand[i]["cost"] > energy:
reject = True # 被拒:玩家的输入发生了,状态没变
else:
c = hand.pop(i)
energy -= c["cost"]
block += c["block"]
ehp -= min(c["damage"], ehp)
if ehp <= 0:
over = won = True
elif cmd == "e":
absorbed = min(block, p["enemyAttack"])
block -= absorbed
php = max(0, php - (p["enemyAttack"] - absorbed))
if php <= 0:
over = True
out.append({"turn": t, "cmd": cmd, "before": before, "after": before,
"reject": False, "won": False})
break
turn += 1
block = 0
energy = p["energyMax"]
while len(hand) < p["handSize"] and draw:
hand.append(draw.pop(0))
after = before if reject else [php, ehp, block, energy, len(hand)]
out.append({"turn": t, "cmd": cmd, "before": before, "after": after,
"reject": reject, "won": won})
return out
def events(steps: list[dict]) -> list[list]:
"""事件流:显著信号的非零 Δ + 被拒 + 终局。"""
rows, seq = [], 0
for s in steps:
if s["reject"]:
seq += 1
rows.append([seq, s["turn"], s["cmd"], "rejected", 0, "能量不足,状态未变(零反馈)"])
continue
for k, name in enumerate(NUMERIC):
d = s["after"][k] - s["before"][k]
if name not in SALIENT or d == 0:
continue
seq += 1
if name == "enemyHp":
note = "敌方血条" + ("(致命一击)" if s["after"][k] == 0 else "")
elif name == "playerHp":
note = "我方血条(敌人攻击未被格挡)"
else:
note = "格挡条" + ("(回合开始清零)" if s["after"][k] == 0 else "(出牌获得)")
rows.append([seq, s["turn"], s["cmd"], name, d, note])
if s["won"] and s["after"][1] == 0:
seq += 1
rows.append([seq, s["turn"], s["cmd"], "won", 1, "终局:主目标 enemyHp<=0 达成"])
return rows
def intensity(steps: list[dict]) -> list[list]:
"""每回合的净位移(回合首 → 回合末)与总位移(逐指令 |Δ| 累加),覆盖全部数值型信号。"""
first: dict[int, list[int]] = {}
last: dict[int, list[int]] = {}
move: dict[int, int] = {}
for s in steps:
t = s["turn"]
first.setdefault(t, s["before"])
last[t] = s["after"]
move[t] = move.get(t, 0) + sum(abs(s["after"][k] - s["before"][k]) for k in range(len(NUMERIC)))
return [[t, sum(abs(last[t][k] - first[t][k]) for k in range(len(NUMERIC))), move[t]]
for t in sorted(first)]
def write_svg(inten: list[list], path: str) -> int:
"""每回合两根条:净位移(蓝)与总位移(橙),条长按各自的最大值归一。"""
root = ashlib.svg_begin(560, 46 + len(inten) * 46)
ashlib.svg_text(root, 16, 24, "《灰烬牌库》逐回合反馈强度(代理指标)", 14)
ashlib.svg_text(root, 16, 42, "蓝 = 净位移(回合首→末) 橙 = 总位移(逐指令 |Δ| 累加)", 11,
fill="#94a3b8")
dnet, dmove = max(r[1] for r in inten), max(r[2] for r in inten)
for i, (t, net, mv) in enumerate(inten):
y = 58 + i * 46
ashlib.svg_text(root, 16, y + 12, f"回合 {t}", 12)
ashlib.svg_bar(root, 90, y, 380, 14, net, dnet, fill="#38bdf8", label=f"{net}")
ashlib.svg_bar(root, 90, y + 18, 380, 14, mv, dmove, fill="#f59e0b", label=f"{mv}")
return ashlib.svg_save(root, path)
def main() -> int:
spec = ashlib.load_spec()
steps = trace(spec)
rows, inten = events(steps), intensity(steps)
snap = ashlib.run_spec(spec)
hidden = [k for k in sorted(snap) if k not in SURFACED]
permille = len(SURFACED) * 1000 // len(snap)
print("== 接口:哪些分量被呈现 ==")
print(f"呈现 {len(SURFACED)}/{len(snap)} = {permille}‰ {'/'.join(SURFACED)}")
print(f"隐藏 {'/'.join(hidden)}(外加牌堆与弃牌堆的具体构成)")
print("\n== 反馈事件流 ==")
print(f"{'#':<4}{'回合':<5}{'指令':<7}{'信号':<10}{'Δ':>4} 说明")
for r in rows:
print(f"{r[0]:<4}{r[1]:<5}{r[2]:<7}{r[3]:<10}{r[4]:>4} {r[5]}")
print("\n== 反馈强度(每回合) ==")
print(f"{'回合':<5}{'净位移':>7}{'总位移':>7}")
for t, net, mv in inten:
print(f"{t:<5}{net:>7}{mv:>7}")
rejects = sum(1 for r in rows if r[3] == "rejected")
hi, lo = max(inten, key=lambda r: r[1]), min(inten, key=lambda r: r[1])
print(f"合计:净位移 {sum(r[1] for r in inten)} / 总位移 {sum(r[2] for r in inten)};"
f"事件 {len(rows)} 条,其中零反馈 {rejects} 条")
print(f"最强回合 = {hi[0]}({hi[1]}),最弱回合 = {lo[0]}({lo[1]})")
assert [r[0] for r in inten] == list(range(1, snap["turn"] + 1)), "回合编号不连续"
assert sum(r[4] for r in rows if r[3] == "enemyHp") == -spec["params"]["enemyHp"], "敌方血条位移对不上"
assert sum(r[4] for r in rows if r[3] == "playerHp") == -(
spec["params"]["playerHp"] - snap["playerHp"]), "我方血条位移对不上"
n = ashlib.write_csv(os.path.join(OUT, "mech01_feedback.csv"), HEADER, rows)
size = write_svg(inten, os.path.join(OUT, "mech01_feedback.svg"))
print(f"wrote out/mech01_feedback.csv({n} 行 + 表头) out/mech01_feedback.svg({size} B)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_01_feedback.py:
== 接口:哪些分量被呈现 ==
呈现 8/12 = 666‰ block/energy/enemyHp/hand/over/playerHp/turn/won
隐藏 cardCount/damageConserved/dealt/played(外加牌堆与弃牌堆的具体构成)
== 反馈事件流 ==
# 回合 指令 信号 Δ 说明
1 1 p0 enemyHp -3 敌方血条
2 1 p0 enemyHp -14 敌方血条
3 1 p0 enemyHp -6 敌方血条
4 1 p0 rejected 0 能量不足,状态未变(零反馈)
5 1 e playerHp -6 我方血条(敌人攻击未被格挡)
6 2 p0 block 5 格挡条(出牌获得)
7 2 p0 block 5 格挡条(出牌获得)
8 2 p0 enemyHp -6 敌方血条
9 2 p0 rejected 0 能量不足,状态未变(零反馈)
10 2 e block -10 格挡条(回合开始清零)
11 3 p0 enemyHp -6 敌方血条
12 3 p0 enemyHp -6 敌方血条
13 3 p0 rejected 0 能量不足,状态未变(零反馈)
14 3 p0 rejected 0 能量不足,状态未变(零反馈)
15 3 e playerHp -6 我方血条(敌人攻击未被格挡)
16 4 p0 enemyHp -9 敌方血条(致命一击)
17 4 p0 won 1 终局:主目标 enemyHp<=0 达成
== 反馈强度(每回合) ==
回合 净位移 总位移
1 29 41
2 6 38
3 19 25
4 12 12
合计:净位移 66 / 总位移 116;事件 17 条,其中零反馈 4 条
最强回合 = 1(29),最弱回合 = 2(6)
wrote out/mech01_feedback.csv(17 行 + 表头) out/mech01_feedback.svg(3183 B)
脚本同时落盘 out/mech01_feedback.csv(17 条事件)与 out/mech01_feedback.svg(逐回合两根条:净位移与总位移,纯 xml.etree 生成)。
判读:三处接口问题
反馈空档。回合 1 的净位移 29,回合 2 只有 6。回合 2 玩家打了 2 张 guard 加 1 张 strike,格挡条从 0 涨到 10 又归 0,两条血条纹丝不动(敌人攻击 6 点被完全吸收)。玩家的操作量一点没少,屏幕上的变化却几乎为零。总位移与净位移的分叉正好量出这一点:回合 1 的总位移 41 对净位移 29(约 1.4 倍),回合 2 的总位移 38 对净位移 6(六倍多)。分叉越大,说明「忙了半天没变化」。
零反馈指令。17 条事件里有 4 条是 rejected:能量不足的指令被静默丢弃。机制上什么都没发生,但玩家的输入已经发生了——手指按下了,牌没动。接口上这 4 条是纯缺口。补法有两条:要么给一条显式反馈(能量不足的抖动或提示音),要么承认它是 bug。
反馈延迟。敌人攻击的反馈发生在 e 那一刻(回合 1 的 −6 血),而玩家的出牌发生在同一回合的开头——延迟整整一个回合。玩家按下 heavy 与看见自己掉血之间,还隔着 1 次成功出牌、1 条被拒指令与 1 次结算。第 7 章会把这段延迟写成回路增益里的一个参数。
D 档:接口清单(可复用的检查表)
| 检查项 | 合格标准 | 本局 |
|---|---|---|
| 名单显式 | 每个状态分量都明确标了「呈现 / 不呈现」 | 8 呈现 / 4 不呈现 |
| 显著信号齐全 | 至少有「我方」「敌方」两条对立血条 + 一条资源条 | playerHp / enemyHp / block |
| 事件可编号 | 每条反馈事件有回合、来源指令、信号名、Δ | 17 条全部四元组齐全 |
| 缺口显式 | 被拒指令单独记一类事件,不混进正常反馈 | 4 条 rejected |
| 延迟可声明 | 每个信号的延迟以回合为单位写清 | 敌人攻击延迟 1 回合 |
| 指标诚实 | 强度标为代理指标,只做相对比较 | 正文已声明 |
三个问题里最贵的是第一个:反馈空档不是数值问题,是接口问题。堆高伤害只会让强回合更响,不会让弱回合有声音。接口怎么修,取决于规格里还有哪些位置可以放它——下一章先把 play_spec.json 的字段逐个过一遍,再决定这份名单该落在哪。
阅读导航:上一节:1.2 资源与目标 · 下一节:2.1 play_spec.json 的字段 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。