站内已有一整卷 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 仿真共同消费,因此「单次处置」与「一段会话的预算」能对上账。到这里,「手感」这一章就完整了:缓动曲线管单个值怎么走,时间轴管多个动画谁先谁后,打击感把命中瞬间拆成震动/停顿/脉冲,音效与震动再把反馈扩到另外两个通道。下一节起进入新手引导——教玩家认识这套手感。
阅读导航:上一节:打击感、粒子与反馈 · 下一节:引导系统与状态机 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。