1.3 反馈:机制与玩家的接口

机制每回合产生十几次状态变化,玩家只看见其中一部分——「哪些分量被呈现」这份名单,就是机制与玩家的接口。本节把《灰烬牌库》的状态按呈现与否分成 8/12 两堆,从轨迹里抽出 17 条反馈事件与逐回合的净位移/总位移:最强回合 29、最弱回合只有 6,还有 4 条指令的反馈是「什么都没发生」。

「手感」在独立游戏核心循环与手感设计 里是一整套参数体系——加速度、顿帧、屏幕震动、镜头跟随,讲的是输入到表现的那一段。本节把手感往前挪一格,问一个更基础的问题:机制产生的那一堆状态变化,有哪些真的走到了玩家眼前?这份「哪些分量被呈现」的名单就是机制与玩家的接口。《小游戏开发实战》卷一《市场品类与产品设计》 从产品口径问「玩家为什么留下」,本节从机制口径问「玩家这一回合到底看见了什么」。

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 的字段 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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