12.3 音效与震动反馈

音效与震动是同一个反馈模型的另外两个通道:用一张分级表把「事件 → 音效 → 震动档位 → 是否可关」写死,再用内核处理静音、震动开关、宿主能力探测与同一事件连点的频控,最后用可播种仿真统计一次会话的反馈预算、画出震动分级分布图供策划评估手感。

站内已有一整卷 Phaser 引擎专书 ——那 127 篇是 Phaser 的工程实操(含它的音频与相机系统),讲一套具体引擎的 API 怎么用。本节讲的是与引擎无关的反馈原理:音效与震动为什么共用一套分级模型、静音与震动开关各自关掉什么、同一事件连点时怎么限流——换成任何引擎都成立。上一节把打击感落在画面上:抖动、停顿、放大;但玩家打中一个方块时,耳朵和手心也在等反馈——「啵」的一声、一下轻震,声音与震动是同一套反馈模型的另外两个通道,只是走不同的宿主 API。

把反馈想成一条流水线:游戏里发生一件事(合成、掉落、升级、报错、捡币),这件事是一个事件;事件先查一张分级表,得到「该播哪个音效、该震多强、能不能被玩家关掉」;再经过一层调度(静音了吗?这台设备支持震动吗?同一事件是不是刚震过?),最后才真正调宿主的 playSfx / vibrate。这样分层的好处是:分级表是数据(策划能改),调度是逻辑(工程师保证正确)。三个真实边界正好都落在调度层:静音只该关音效、不该把震动也关掉;震动开关只对玩家可关闭的事件生效,报错提示这种「刚需震动」不能被关;连点(玩家狂点合成)不能每次都震,否则手心发麻。

D 层:反馈分级表

下面这段是 D 层(静态层)的配置 config/e12_feedback.json:六个事件各自映射到音效、震动档位与可关闭性,rateLimitMs 是同一事件的频控窗口。它是纯数据,不含任何逻辑——注意 error 事件的 canDisable 是 false,报错必须有震动提醒、玩家关不掉;hapticTiers 则把档位名映射成整数(none=0),方便宿主直接拿去调强度。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "反馈分级表:事件 → 音效类型 → 震动档位(0=关)→ 可关闭性;rateLimitMs 是同一事件的频控窗口(毫秒整数)。",
  "hapticTiers": { "none": 0, "light": 1, "medium": 2, "heavy": 3 },
  "rateLimitMs": 120,
  "events": [
    { "id": "merge", "sfx": "merge_pop", "haptic": "light", "canDisable": true },
    { "id": "drop", "sfx": "drop_land", "haptic": "none", "canDisable": true },
    { "id": "level_up", "sfx": "level_fanfare", "haptic": "medium", "canDisable": true },
    { "id": "combo", "sfx": "combo_chime", "haptic": "light", "canDisable": true },
    { "id": "error", "sfx": "error_buzz", "haptic": "heavy", "canDisable": false },
    { "id": "coin", "sfx": "coin_pick", "haptic": "none", "canDisable": true }
  ]
}

P 层:反馈调度内核

下面这段是 P 层(内核层)产物 core/src/e12/feedback.ts:宿主能力探测、静音与震动开关、按事件分桶的频控,以及一个 planFeedback 把「这次到底播不播、震不震」算清楚并给出原因。它的返回值里有一个 reason 字段,优先级是「未知事件 > 被频控 > 静音 > 宿主无震动 > 震动开关关 > 宿主无音频 > 正常」;commit 里还有一条容易忽略的规则——被频控或未知的事件不刷新时间戳,否则玩家一直连点、时间戳被一直刷新,反而永远进不了「已停止连点」的状态。全整数、无 Math.random()。

// file: core/src/e12/feedback.ts
// tier: P
// verify: node --test 'core/test/e12_*.test.ts'
//
// 《合成小岛》反馈调度:静音开关、震动可用性探测、频控(同一事件在窗口内不连发)。
// 反馈分级来自 config/e12_feedback.json。全整数、无 Math.random()。
//
// 规则:
// - 静音只关音效;「不可关闭」事件(如错误提示)的震动是刚需,静音也照震。
// - 震动可用性由宿主能力探测决定;不可关闭事件忽略玩家的震动开关。
// - 频控按事件 id 分桶:同一事件在 rateLimitMs 内重复触发被丢弃(不记时间戳)。

export type HapticTier = "none" | "light" | "medium" | "heavy";

export interface FeedbackEventDef { id: string; sfx: string; haptic: HapticTier; canDisable: boolean }
export interface FeedbackConfig {
  version: number;
  seed: number;
  hapticTiers: Record<string, number>;
  rateLimitMs: number;
  events: FeedbackEventDef[];
}
export interface HostCaps { hasHaptic: boolean; hasAudio: boolean }
export interface FeedbackState { muted: boolean; hapticEnabled: boolean; lastAtMs: Record<string, number> }
export interface FeedbackPlan {
  event: string;
  playSfx: boolean;
  sfx: string | null;
  hapticTier: HapticTier;
  hapticCode: number;
  reason: string;
}

/** 宿主能力探测:有 vibrate 函数即认为支持震动;audio 显式为 false 才认为无音频。 */
export function probeHostCaps(host: { vibrate?: unknown; audio?: boolean }): HostCaps {
  return { hasHaptic: typeof host.vibrate === "function", hasAudio: host.audio !== false };
}

export function newFeedbackState(caps: HostCaps): FeedbackState {
  return { muted: false, hapticEnabled: caps.hasHaptic, lastAtMs: {} };
}

export function findEvent(cfg: FeedbackConfig, id: string): FeedbackEventDef | null {
  return cfg.events.find((e) => e.id === id) ?? null;
}

/** 计算一次反馈的处置:播不播音效、震不震、震多强,以及被拦的原因。 */
export function planFeedback(
  state: FeedbackState, cfg: FeedbackConfig, caps: HostCaps, eventId: string, nowMs: number,
): FeedbackPlan {
  const none: FeedbackPlan = { event: eventId, playSfx: false, sfx: null, hapticTier: "none", hapticCode: 0, reason: "unknown-event" };
  const ev = findEvent(cfg, eventId);
  if (!ev) return none;

  const last = state.lastAtMs[eventId];
  if (last !== undefined && nowMs - last < cfg.rateLimitMs) {
    return { ...none, reason: "rate-limited" };
  }

  const playSfx = !state.muted && caps.hasAudio;

  let tier: HapticTier = "none";
  if (ev.haptic !== "none") {
    const allowed = ev.canDisable ? (state.hapticEnabled && caps.hasHaptic) : caps.hasHaptic;
    if (allowed) tier = ev.haptic;
  }

  let reason = "ok";
  if (state.muted) reason = "muted";
  else if (ev.haptic !== "none" && !caps.hasHaptic) reason = "no-haptic-host";
  else if (ev.haptic !== "none" && ev.canDisable && !state.hapticEnabled) reason = "haptic-off";
  else if (!caps.hasAudio) reason = "no-audio-host";

  return { event: eventId, playSfx, sfx: playSfx ? ev.sfx : null, hapticTier: tier, hapticCode: cfg.hapticTiers[tier], reason };
}

/** 提交一次处置:非被频控 / 未知事件的才刷新该事件的频控时间戳。 */
export function commit(state: FeedbackState, plan: FeedbackPlan, nowMs: number): FeedbackState {
  if (plan.reason === "rate-limited" || plan.reason === "unknown-event") return state;
  const next: FeedbackState = { muted: state.muted, hapticEnabled: state.hapticEnabled, lastAtMs: { ...state.lastAtMs } };
  next.lastAtMs[plan.event] = nowMs;
  return next;
}

/** 一次性「计划 + 提交」,返回处置与推进后的状态。 */
export function emit(
  state: FeedbackState, cfg: FeedbackConfig, caps: HostCaps, eventId: string, nowMs: number,
): { plan: FeedbackPlan; state: FeedbackState } {
  const plan = planFeedback(state, cfg, caps, eventId, nowMs);
  return { plan, state: commit(state, plan, nowMs) };
}

export function setMuted(state: FeedbackState, muted: boolean): FeedbackState {
  return { muted, hapticEnabled: state.hapticEnabled, lastAtMs: { ...state.lastAtMs } };
}

export function setHapticEnabled(state: FeedbackState, enabled: boolean): FeedbackState {
  return { muted: state.muted, hapticEnabled: enabled, lastAtMs: { ...state.lastAtMs } };
}

下面是 P 层测试 core/test/e12_feedback.test.ts 的实跑输出(同机相对量级,耗时不可移植):

✔ 宿主能力探测
✔ 已知事件正常放行:音效 + 对应震动档
✔ 频控:同一事件在窗口内被丢弃
✔ 频控按事件分桶:不同事件互不影响
✔ 被频控不刷新时间戳
✔ 静音:只关音效,可关闭事件的震动照常
✔ 静音下不可关闭事件仍震动
✔ 震动开关:可关闭事件被关,不可关闭事件忽略开关
✔ 宿主无震动:所有事件降为不震
✔ 宿主无音频:不播音效
✔ 未知事件:直接丢弃
✔ 同一事件序列两遍结果一致(无隐藏随机)
ℹ pass 12 / fail 0

静音时震动照常、报错震动关不掉、无震动设备自动降级——这三条最像真实产品需求,把前面说的三个边界逐条钉成了断言。

S 层:反馈预算仿真

反馈调度的效果光看单次处置不直观,得看一段会话里各类反馈各占多少。下面这段是 S 层仿真脚本 sim/e12_feedback_budget.py:它读同一份 config/e12_feedback.json,用可播种 LCG 生成 600 拍事件流(含一段静音窗口),套用与内核一致的规则,统计音效播放数、震动分级分布、被频控吞掉的次数,产出 JSON + CSV + 纯 Python SVG。注意它没有 import random——随机一律来自配置里的 seed 加整数 LCG,所以同一 seed 每次得到同一份事件流,仿真是可复现的。

#!/usr/bin/env python3
# file: sim/e12_feedback_budget.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e12_feedback_budget.py --config config/ --out out/
"""S 层仿真:反馈调度的频控与分级预算。

同一份 config/e12_feedback.json 被 TS 内核(core/src/e12/feedback.ts)与本仿真消费:
用可播种 LCG 生成一段事件流,套用与内核一致的规则(静音 / 震动可用性 / 频控),
统计音效播放数、震动分级分布、被频控与静音吞掉的次数,产出 JSON + CSV + SVG。
全整数、无浮点;随机一律来自 seed + 整数 LCG(不用 random 库)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

TICKS = 600                 # 60 秒,每 100ms 一拍
EVENT_CHANCE = 35           # 每拍触发事件的概率(百分比)
MUTE_WINDOW = (100, 150)    # 第 100~149 拍静音(模拟玩家中途关声音)
MULT, INC, MASK = 1103515245, 12345, 0x7FFFFFFF
TIER_ORDER = ("none", "light", "medium", "heavy")


def lcg(state: int) -> int:
    return (state * MULT + INC) & MASK


def load(config_dir: str) -> dict:
    with open(os.path.join(config_dir, "e12_feedback.json"), encoding="utf-8") as f:
        return json.load(f)


def write_svg(path: str, counts: dict) -> int:
    W, H, PAD = 460, 260, 40
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H)})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": str(PAD), "y": "24", "font-size": "15", "fill": "#0f172a"},
                  ).text = "震动分级分布(一次会话)"
    top = max(1, max(counts.values()))
    plot_h = H - 2 * PAD
    bw = (W - 2 * PAD) // len(TIER_ORDER)
    for i, tier in enumerate(TIER_ORDER):
        v = counts[tier]
        bh = v * plot_h // top
        x = PAD + i * bw + bw // 6
        ET.SubElement(svg, "rect", {"x": str(x), "y": str(H - PAD - bh), "width": str(bw * 2 // 3),
                                    "height": str(bh), "fill": "#38bdf8"})
        ET.SubElement(svg, "text", {"x": str(x + bw // 3), "y": str(H - PAD - bh - 6),
                                    "font-size": "12", "text-anchor": "middle", "fill": "#0f172a"}, ).text = str(v)
        ET.SubElement(svg, "text", {"x": str(x + bw // 3), "y": str(H - PAD + 16),
                                    "font-size": "12", "text-anchor": "middle", "fill": "#334155"}, ).text = tier
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config)
    os.makedirs(args.out, exist_ok=True)
    rate_limit_ms = cfg["rateLimitMs"]
    tiers = cfg["hapticTiers"]
    events = cfg["events"]
    by_id = {e["id"]: e for e in events}
    has_haptic, has_audio, haptic_enabled = True, True, True

    state = cfg["seed"]
    last_at: dict[str, int] = {}
    per_event = {e["id"]: {"generated": 0, "sfxPlayed": 0, "haptic": 0} for e in events}
    haptic_counts = {t: 0 for t in TIER_ORDER}
    generated = sfx_played = rate_limited = muted_sfx = 0

    for tick in range(TICKS):
        state = lcg(state)
        if state % 100 >= EVENT_CHANCE:
            continue
        state = lcg(state)
        ev = events[state % len(events)]
        now_ms = tick * 100
        generated += 1
        per_event[ev["id"]]["generated"] += 1
        last = last_at.get(ev["id"])
        if last is not None and now_ms - last < rate_limit_ms:
            rate_limited += 1
            continue
        last_at[ev["id"]] = now_ms
        muted = MUTE_WINDOW[0] <= tick < MUTE_WINDOW[1]
        play = (not muted) and has_audio
        if play:
            sfx_played += 1
            per_event[ev["id"]]["sfxPlayed"] += 1
        elif muted:
            muted_sfx += 1
        tier = "none"
        if ev["haptic"] != "none":
            allowed = (haptic_enabled if ev["canDisable"] else True) and has_haptic
            if allowed:
                tier = ev["haptic"]
        haptic_counts[tier] += 1
        if tier != "none":
            per_event[ev["id"]]["haptic"] += 1

    haptic_delivered = sum(haptic_counts[t] for t in TIER_ORDER if t != "none")
    report = {
        "version": 1, "source": "sim/e12_feedback_budget.py", "tier": "S",
        "ticks": TICKS, "rateLimitMs": rate_limit_ms, "seed": cfg["seed"],
        "generated": generated, "sfxPlayed": sfx_played, "sfxMuted": muted_sfx,
        "rateLimited": rate_limited, "hapticDelivered": haptic_delivered,
        "hapticByTier": haptic_counts, "perEvent": per_event,
    }
    with open(os.path.join(args.out, "e12_feedback_budget.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    with open(os.path.join(args.out, "e12_feedback_budget.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["event", "generated", "sfxPlayed", "haptic", "sfx", "tier"])
        for e in events:
            w.writerow([e["id"], per_event[e["id"]]["generated"], per_event[e["id"]]["sfxPlayed"],
                        per_event[e["id"]]["haptic"], e["sfx"], e["haptic"]])

    svg_bytes = write_svg(os.path.join(args.out, "e12_feedback_budget.svg"), haptic_counts)
    report["svgBytes"] = svg_bytes

    print(json.dumps({
        "generated": generated, "sfxPlayed": sfx_played, "sfxMuted": muted_sfx,
        "rateLimited": rate_limited, "hapticDelivered": haptic_delivered,
        "hapticByTier": haptic_counts, "svgBytes": svg_bytes,
    }, ensure_ascii=False, indent=2))
    print(f"== 反馈预算:事件 {generated} / 音效 {sfx_played} / 震动 {haptic_delivered} / 频控吞 {rate_limited}(同机相对量级,绝对数值不可移植)")
    return 0


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

本机实跑输出如下(同机相对量级,绝对数值不可移植):

$ /opt/homebrew/bin/python3 sim/e12_feedback_budget.py --config config/ --out out/
{
  "generated": 205, "sfxPlayed": 165, "sfxMuted": 15, "rateLimited": 25,
  "hapticDelivered": 118,
  "hapticByTier": { "none": 62, "light": 61, "medium": 21, "heavy": 36 },
  "svgBytes": 1163
}
== 反馈预算:事件 205 / 音效 165 / 震动 118 / 频控吞 25(同机相对量级,绝对数值不可移植)

600 拍里生成了 205 个事件,其中 165 个真的播了音效、15 个落在静音窗口被吞、25 个被频控丢弃(165 + 15 + 25 = 205,对得上)。震动方面 118 次真正送达,按档位分布是 none 62 / light 61 / medium 21 / heavy 36——light 最多,因为合成、连击都用轻档;heavy 只来自报错,次数不多但都是刚需。这张分布图(out/e12_feedback_budget.svg)正好能回答一个策划问题:「一次会话玩家会感到多少次震动、都是什么强度」。

小结与下一步

这一节把反馈补成三通道:画面(上一节)、音效、震动。核心是一张分级表(数据)+ 一层调度(逻辑),后者负责静音、震动开关、宿主能力探测与频控四个边界。同一份配置被 TS 内核与 Python 仿真共同消费,因此「单次处置」与「一段会话的预算」能对上账。到这里,「手感」这一章就完整了:缓动曲线管单个值怎么走,时间轴管多个动画谁先谁后,打击感把命中瞬间拆成震动/停顿/脉冲,音效与震动再把反馈扩到另外两个通道。下一节起进入新手引导——教玩家认识这套手感。

阅读导航:上一节:打击感、粒子与反馈 · 下一节:引导系统与状态机 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制