feat(playbook): 26 号交互手册首批落地——storyline/escape/simon 全面重构 + 引擎六模块
按 docs/design/26-audio-interaction-playbook.md 重构三款游戏(对白重写 + 双性别语音重生成 580 文件)。 引擎层(web/engine/,其余 12 款游戏零改动): - floor.js 发言权管理器:SPEAKING/YOUR_TURN/BUSY/ACTION 四态、交接提示音、 等待声(220/330Hz 嘟-嘟,20s 后放缓)、跨链音乐压低(audio.js holdDuck) - receipts.js 输入回执三件套(ok/invalid/busy,100ms 内,原则 4) - confusion.js 迷茫检测(乱点/无效/静默信号 → 三级接管,§4.5) - hints.js 等待点 + 三档提示(full→short→提示音,30s 台账跨点去重,原则 9) - novice.js 手势解锁阶梯(up/down/doubletap 跨局持久,§4.3) - pipeline.js 输入管道(回执收口、防连点 400ms、双击折算、explainOnce) 游戏层: - storyline R3:开场 71.7s 规则念白 → 3 秒内动手;双击=重播(反模式5)废除; 抉择点三档提示+主动重听;断点存档复述(§4.12) - escape R3:单击=检查+使用合并(双击用法废除);长按=我在哪(四向玩法裁定); 撞墙归因每方向一次(原则 7);迷茫 L2 缩小/L3 演示;坐标存档续玩 - simon R3:新手三音色,按实力渐进解锁(+up/down/dt);失败表替代机会账 (归因 2s 内→免费重听→放慢→结算,原则 15);播放期手势=busy 回执 测试:三款 playtest 重写(含 §6.2 乱点机器人:无声操作/被动重复/打断失败全 0, 首操作 ~2s);guide.test 改为 Guide 层单测+rhythm/space 段。全部通过 (38+33+22+72+19+35 断言)。设计单见 docs/design/26-apply-*.md。
This commit is contained in:
@@ -300,6 +300,18 @@ export class WebAudioEngine {
|
||||
this._unduckBgm();
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音链级压低持有(26 号文档 §4.1「音乐压低要跨整条链」):游戏层用
|
||||
* onComplete 一句句接起来的链,句间引擎队列是空的、会恢复音乐音量,
|
||||
* 玩家就听到一次「好像轮到我了」。Floor 在整条链期间持有一份压低,
|
||||
* 链结束/被打断时释放。可重入(计数)。
|
||||
* @param {boolean} on true=持有一份压低;false=释放一份
|
||||
*/
|
||||
holdDuck(on) {
|
||||
this._duckHolds = Math.max(0, (this._duckHolds || 0) + (on ? 1 : -1));
|
||||
if (on) this._duckBgm();
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳过当前对白:停掉当前 voice 并**视为这句自然播完**——触发其
|
||||
* onComplete(链播下一句)并继续等待队列。与 stopVoice 的差异:
|
||||
@@ -443,6 +455,7 @@ export class WebAudioEngine {
|
||||
}
|
||||
|
||||
_unduckBgm() {
|
||||
if (this._duckHolds > 0) return; // Floor 持有链级压低:句间不恢复(26 号 §4.1)
|
||||
if (this._bgmBus) this._fadeGain(this._bgmBus, 1, this.duckMs);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* confusion.js — 迷茫检测(26 号文档 §4.5,P2 第 9 项)。
|
||||
*
|
||||
* 迷茫不只看「不动」,更要从「乱点」里识别。信号(10s 滑动窗口):
|
||||
* | 信号 | 判定 | 权重 |
|
||||
* |----------|----------------------------|------|
|
||||
* | 连续无效 | 10 秒内 ≥3 次 invalid | 2 |
|
||||
* | 反复重听 | 10 秒内主动重听 ≥2 次 | 1 |
|
||||
* | 乱滑 | 2 秒内 ≥3 个不同滑动方向 | 1 |
|
||||
* | 同步失败 | 同一等待点失败 ≥2 次 | 2 |
|
||||
* | 静默 | 动作类 6s / 剧情类 18s 无操作 | 1 |
|
||||
*
|
||||
* 权重累计 ≥2 触发接管,15s 冷却期内不再触发。三级接管由当前等待点实现
|
||||
* (onConfused(level)):1 提示(_short 句)→ 2 缩小(只保留一个正确手势)→
|
||||
* 3 演示(游戏自己做一遍再交回)。同一等待点每触发一次升一级;第 3 级用过后
|
||||
* 该等待点停止接管,只保留等待声。
|
||||
*/
|
||||
const WINDOW_MS = 10000;
|
||||
const COOLDOWN_MS = 15000;
|
||||
const SCORE_TRIGGER = 2;
|
||||
const MAX_LEVEL = 3;
|
||||
|
||||
export class Confusion {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {(level: number) => void} opts.onConfused 三级接管回调(由当前等待点实现)
|
||||
*/
|
||||
constructor({ onConfused } = {}) {
|
||||
this.events = []; // { t, kind, dir? }
|
||||
this.cooldownUntil = 0;
|
||||
this.onConfused = onConfused || null;
|
||||
this.level = 0; // 当前等待点已接管到的级别
|
||||
this.silenceMs = 18000; // 静默阈值:动作类 6s / 剧情类 18s(游戏设定)
|
||||
}
|
||||
|
||||
/** 玩家输入(任意手势;dir=手势名,乱滑检测用)。 */
|
||||
record(g) { this._push({ kind: 'input', dir: g }); }
|
||||
/** 无效操作结果(pipeline 在游戏返回 invalid 时喂入)。 */
|
||||
recordInvalid() { this._push({ kind: 'invalid' }); }
|
||||
/** 玩家主动重听。 */
|
||||
recordReplay() { this._push({ kind: 'replay' }); }
|
||||
/** 同一步骤失败(游戏判定:答错/走错,喂入)。 */
|
||||
recordFail() { this._push({ kind: 'fail' }); }
|
||||
/** 静默(guide.watchdog 到点回调喂入;权重 1)。 */
|
||||
recordSilence() { this._push({ kind: 'silence' }); }
|
||||
|
||||
/** 换等待点:清事件、级别归零。 */
|
||||
resetForWaitPoint() {
|
||||
this.events = [];
|
||||
this.level = 0;
|
||||
}
|
||||
|
||||
/** 当前级别(等待点第 3 级用过后不再升)。 */
|
||||
get exhausted() { return this.level >= MAX_LEVEL; }
|
||||
|
||||
_push(e) {
|
||||
const now = performance.now();
|
||||
this.events.push({ t: now, ...e });
|
||||
this.events = this.events.filter((x) => now - x.t < WINDOW_MS);
|
||||
if (now < this.cooldownUntil) return;
|
||||
if (this._score(now) >= SCORE_TRIGGER && this.level < MAX_LEVEL) {
|
||||
this.level++;
|
||||
this.cooldownUntil = now + COOLDOWN_MS;
|
||||
if (this.onConfused) this.onConfused(this.level);
|
||||
}
|
||||
}
|
||||
|
||||
_score(now) {
|
||||
const n = (k) => this.events.filter((x) => x.kind === k).length;
|
||||
const recentDirs = new Set(this.events
|
||||
.filter((x) => x.kind === 'input' && now - x.t < 2000 && x.dir && x.dir !== 'tap')
|
||||
.map((x) => x.dir));
|
||||
let s = 0;
|
||||
if (n('invalid') >= 3) s += 2;
|
||||
if (n('replay') >= 2) s += 1;
|
||||
if (recentDirs.size >= 3) s += 1;
|
||||
if (n('fail') >= 2) s += 2;
|
||||
if (n('silence') >= 1 && this._onlySilenceRecently(now)) s += 1;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 静默只在「窗口内除静默外几乎没有输入」时计分(有输入就不算静默)。 */
|
||||
_onlySilenceRecently(now) {
|
||||
const inputs = this.events.filter((x) => x.kind === 'input' && now - x.t < WINDOW_MS);
|
||||
return inputs.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default Confusion;
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* floor.js — 发言权管理器(26 号文档 §4.1,P1 第 5 项)。
|
||||
*
|
||||
* 全局只维护一个「现在谁在说话」。四种状态:
|
||||
* | 状态 | 含义 | 听感 | 玩家操作时 |
|
||||
* |-----------|--------------------------|----------------------------------|--------------------------------|
|
||||
* | SPEAKING | 游戏在说一段话(整条链) | 音乐压低;句间不恢复、不起等待声 | tap=跳过这句;滑动=打断并执行;critical=busy 回执 |
|
||||
* | YOUR_TURN | 在等玩家操作 | 交接提示音 → 持续等待声 | 等待声 80ms 内淡出,交给游戏 |
|
||||
* | BUSY | 演出动画音效 / 处理中 | 只有内容音效 | busy 回执,不执行 |
|
||||
* | ACTION | 实时玩法(节奏/答题) | 玩法本身的声音就是状态 | 直接交给游戏,不打断任何东西 |
|
||||
*
|
||||
* 硬规则(§4.1 转换规则 + §4.6 语音优先级):
|
||||
* - 离开 SPEAKING 只能去 YOUR_TURN(yieldTurn)或 BUSY(继续演出),不存在安静态。
|
||||
* - 进入 YOUR_TURN 必须经过 yieldTurn(waitPoint):交接提示音 → 400ms 后起等待声 →
|
||||
* 挂提示计时 + 迷茫检测(waitPoint.arm())。
|
||||
* - 语音链音乐压低跨整条链(audio.holdDuck)。
|
||||
* - 类别:critical(不可打断,须 <3s)/ content(可打断)/ hint / feedback
|
||||
* (只在自己空闲时播,播不出去就丢——绝不排到长链后面迟到)。
|
||||
* - 单句资产缺失看门狗:引擎对缺失资产不回调 onComplete,Floor 轮询
|
||||
* isVoicePlaying(),确认未在播才推进链(对齐 storyline 旧 VOICE_WATCHDOG_MS 语义,
|
||||
* 真在播的长句续约不掐)。
|
||||
*
|
||||
* 等待声(运行时合成,零资产):三角波 220Hz+330Hz 两声「嘟—嘟」,每声 60ms、
|
||||
* 间隔 180ms、每 1.4s 重复;增益 0.03。等待超 20s 间隔拉长到 2.5s(耐心感)。
|
||||
* 交接提示音与 guide.js 的 turn 同规格(1318.5Hz 风铃 + 高频泛音)——它是状态信号
|
||||
* 不是帮助内容,guide=off 也照播(原则 1/2)。
|
||||
*/
|
||||
|
||||
/** 等待声参数(§4.1「等待声怎么做」起步值)。 */
|
||||
const BED = {
|
||||
f1: 220, f2: 330, // 两声「嘟—嘟」
|
||||
toneMs: 60, gapMs: 180, // 每声 60ms,两声间隔 180ms
|
||||
loopMs: 1400, slowMs: 2500,// 默认每 1.4s 重复;20s 后 2.5s
|
||||
gain: 0.03, // ≈ guide 提示音增益 0.12 的四分之一
|
||||
slowAfterMs: 20000,
|
||||
fadeOutMs: 80, // 玩家一操作,等待声 80ms 内淡出
|
||||
};
|
||||
|
||||
/** 交接提示音(turn 风铃同规格)。 */
|
||||
const TURN_SPEC = [
|
||||
{ f: 1318.5, d: 0.28, wave: 'sine', g: 0.12 },
|
||||
{ f: 2637.0, d: 0.12, wave: 'sine', g: 0.03 },
|
||||
];
|
||||
|
||||
/** 单句资产缺失看门狗轮询间隔。 */
|
||||
const SENTINEL_POLL_MS = 500;
|
||||
/** play() 发起后允许的网络+解码宽限(本地资产 <100ms;超时未见开播=缺失)。 */
|
||||
const SENTINEL_GRACE_MS = 3000;
|
||||
|
||||
export class Floor {
|
||||
/**
|
||||
* @param {import('./audio.js').WebAudioEngine} audio
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.turnEarcon=true] yieldTurn 时播交接提示音(测试可关)
|
||||
* @param {boolean} [opts.bed=true] 等待声(虚拟时钟测试关掉,避免周期定时器不排水)
|
||||
*/
|
||||
constructor(audio, opts = {}) {
|
||||
this.audio = audio;
|
||||
this.state = 'BUSY';
|
||||
this.turnEarcon = opts.turnEarcon !== false;
|
||||
this.bedEnabled = opts.bed !== false;
|
||||
|
||||
this._chain = null; // { ids, i, critical, kind, onDone, token }
|
||||
this._token = 0;
|
||||
this._bed = null; // { timer, nodes, startedAt }
|
||||
this._waitPoint = null; // 当前等待点(yieldTurn 挂上,输入/完成时摘下)
|
||||
}
|
||||
|
||||
// ------------------------------------------------ 对外 API
|
||||
|
||||
/**
|
||||
* 播一段话(整条语音链)。已在播的链被顶掉(打断即作废,不触发其 onDone)。
|
||||
* @param {string[]} ids voice id 列表('voice/xxx')
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.critical] 关键句:玩家操作给 busy 回执,不打断(须 <3s)
|
||||
* @param {'content'|'hint'|'feedback'} [opts.kind='content'] 类别(§4.6):
|
||||
* hint/feedback 只在空闲时播,SPEAKING 中直接丢弃(不排队迟到)
|
||||
* @param {() => void} [opts.onDone] 整条链自然播完(不含被打断)后回调;
|
||||
* 由 onDone 决定 yieldTurn 还是继续演出
|
||||
*/
|
||||
speak(ids, opts = {}) {
|
||||
const critical = !!opts.critical;
|
||||
const kind = opts.kind || 'content';
|
||||
if (kind === 'hint' || kind === 'feedback') {
|
||||
// 过期即弃(§4.6):正在说话时不播、不排队
|
||||
if (this.state === 'SPEAKING') return false;
|
||||
}
|
||||
if (!ids || !ids.length) {
|
||||
if (opts.onDone) opts.onDone();
|
||||
return true;
|
||||
}
|
||||
this._stopBed();
|
||||
this._killChain();
|
||||
this.state = 'SPEAKING';
|
||||
const token = ++this._token;
|
||||
this._chain = { ids, i: 0, critical, kind, onDone: opts.onDone, token };
|
||||
this.audio.holdDuck(true); // 压低跨整条链
|
||||
this._next(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 交出话轮:交接提示音 → 400ms 后起等待声 → 挂等待点(提示计时+迷茫检测)。
|
||||
* @param {import('./hints.js').WaitPoint|null} [waitPoint]
|
||||
*/
|
||||
yieldTurn(waitPoint = null) {
|
||||
this._stopBed();
|
||||
if (this._waitPoint && this._waitPoint !== waitPoint) this._waitPoint.complete();
|
||||
this._waitPoint = waitPoint;
|
||||
this.state = 'YOUR_TURN';
|
||||
if (this.turnEarcon) this._playTurnEarcon();
|
||||
const token = ++this._token; // 防竞争:400ms 内又 speak 则不起等待声
|
||||
setTimeout(() => {
|
||||
if (token !== this._token || this.state !== 'YOUR_TURN') return;
|
||||
this._startBed();
|
||||
}, 400);
|
||||
if (waitPoint) waitPoint.arm();
|
||||
}
|
||||
|
||||
/** 进入实时玩法(节奏/答题/探索):玩法声音即状态,输入直接给游戏。 */
|
||||
enterAction() {
|
||||
this._stopBed();
|
||||
if (this._waitPoint) { this._waitPoint.complete(); this._waitPoint = null; } // 清掉残留提示计时
|
||||
this.state = 'ACTION';
|
||||
}
|
||||
|
||||
/** 离开实时玩法回 BUSY(游戏演出/播报前调用)。 */
|
||||
leaveAction() {
|
||||
if (this.state === 'ACTION') this.state = 'BUSY';
|
||||
}
|
||||
|
||||
/**
|
||||
* 提示句播完后回到等待态(等待点内部用):不重播交接提示音、不重挂等待点,
|
||||
* 只恢复 YOUR_TURN + 等待声——档位计数在等待点里保留(原则 9)。
|
||||
*/
|
||||
resumeTurn() {
|
||||
if (this.state === 'SPEAKING') return;
|
||||
this.state = 'YOUR_TURN';
|
||||
this._startBed();
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入先经过这里(pipeline 调用)。返回:
|
||||
* 'consumed' = 发言权层已处理(tap 跳句),游戏不必再处理;
|
||||
* 'busy' = 忽略这次操作,给 busy 回执;
|
||||
* null = 交给游戏处理。
|
||||
*
|
||||
* 打断语义(§4.1 裁定,写进手势表,所有游戏一致):
|
||||
* - 单击:只打断 = 跳过当前这句(链继续走下一句);
|
||||
* - 滑动/上滑:打断并执行(滑动是有明确意图的操作)。
|
||||
*/
|
||||
handleInput(g) {
|
||||
if (this.state === 'SPEAKING') {
|
||||
if (this._chain && this._chain.critical) return 'busy';
|
||||
if (g === 'tap') {
|
||||
this.audio.skipVoice(); // 视为这句自然播完:onComplete → 链继续
|
||||
return 'consumed';
|
||||
}
|
||||
this._killChain(); // 打断并执行:作废旧链,这次操作照常交给游戏
|
||||
this.state = 'BUSY'; // 处理中;游戏决定接下来 yieldTurn / 继续演出 / 回 ACTION
|
||||
return null;
|
||||
}
|
||||
if (this.state === 'BUSY') return 'busy';
|
||||
if (this.state === 'YOUR_TURN') {
|
||||
this._stopBed();
|
||||
if (this._waitPoint) this._waitPoint.rearm(); // 计时复位;档位计数保留(原则 9)
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待点结算。done=true(有效操作完成这个点):摘下等待点(含迷茫计数清零);
|
||||
* done=false(无效操作/未完成):等待点保留(玩家还在这个点),只重排静默计时
|
||||
* ——档位计数保留(原则 9)。
|
||||
*/
|
||||
settleWaitPoint(done = true) {
|
||||
const wp = this._waitPoint;
|
||||
if (!wp) return;
|
||||
if (done) {
|
||||
this._waitPoint = null;
|
||||
wp.complete();
|
||||
return;
|
||||
}
|
||||
wp.rearm();
|
||||
// 打断解释句后(BUSY)也要回到等待态,等待声重新响起
|
||||
if (this.state !== 'SPEAKING' && this.state !== 'ACTION') this.resumeTurn();
|
||||
}
|
||||
|
||||
/** 当前等待点(供游戏查询限制手势等)。 */
|
||||
get waitPoint() { return this._waitPoint; }
|
||||
|
||||
/** 当前链是否在播(含句间)。 */
|
||||
get speaking() { return this.state === 'SPEAKING'; }
|
||||
|
||||
/** 当前正在播的句 id(状态播报用)。 */
|
||||
currentId() {
|
||||
return this._chain ? this._chain.ids[this._chain.i] : null;
|
||||
}
|
||||
|
||||
/** 交接提示音(公开:三档提示的第 3 档只播它,§4.4)。 */
|
||||
chime() { this._playTurnEarcon(); }
|
||||
|
||||
/** 彻底打断:杀链 + 停等待声 + 回 BUSY(重开/进菜单等场景,游戏层调用)。 */
|
||||
interrupt() {
|
||||
this._killChain();
|
||||
this._stopBed();
|
||||
if (this._waitPoint) { this._waitPoint.complete(); this._waitPoint = null; }
|
||||
this.state = 'BUSY';
|
||||
}
|
||||
|
||||
// ------------------------------------------------ 内部实现
|
||||
|
||||
_killChain() {
|
||||
const c = this._chain;
|
||||
this._chain = null;
|
||||
if (!c) return;
|
||||
this._token++; // 作废后续推进
|
||||
this.audio.holdDuck(false); // 释放链级压低
|
||||
this.audio.stopVoice(); // 打断即作废:不触发 onComplete
|
||||
}
|
||||
|
||||
_next(token) {
|
||||
const c = this._chain;
|
||||
if (!c || c.token !== token) return; // 链已被打断/顶掉,作废
|
||||
if (c.i >= c.ids.length) { // 整条链播完
|
||||
this._chain = null;
|
||||
this.audio.holdDuck(false);
|
||||
this.state = 'BUSY'; // 由 onDone 决定 yieldTurn 还是继续演出
|
||||
if (c.onDone) c.onDone();
|
||||
return;
|
||||
}
|
||||
const id = c.ids[c.i];
|
||||
let sawPlaying = false;
|
||||
const poll = () => {
|
||||
if (!this._chain || this._chain.token !== token) return;
|
||||
if (this.audio.isVoicePlaying()) { sawPlaying = true; t = setTimeout(poll, SENTINEL_POLL_MS); return; }
|
||||
if (!sawPlaying && performance.now() - t0 < SENTINEL_GRACE_MS) { t = setTimeout(poll, SENTINEL_POLL_MS); return; }
|
||||
// 资产缺失(引擎静默跳过、永不回调):告警续链不卡死;真在播的长句不会到这
|
||||
console.warn('[Floor] 语音资产缺失,跳过续链:', id);
|
||||
this._advance(token);
|
||||
};
|
||||
const t0 = performance.now();
|
||||
let t = setTimeout(poll, SENTINEL_POLL_MS);
|
||||
this.audio.play(id, {
|
||||
onComplete: () => {
|
||||
clearTimeout(t);
|
||||
if (!this._chain || this._chain.token !== token) return;
|
||||
this._advance(token);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_advance(token) {
|
||||
const c = this._chain;
|
||||
if (!c || c.token !== token) return;
|
||||
c.i++;
|
||||
this._next(token);
|
||||
}
|
||||
|
||||
/** 交接提示音:风铃质感(guide turn 同规格),guide 设置无关。 */
|
||||
_playTurnEarcon() {
|
||||
const ctx = this.audio && this.audio.ctx;
|
||||
if (!ctx || !this.audio._master || !this.audio.unlocked) return;
|
||||
const t0 = ctx.currentTime + 0.01;
|
||||
for (const n of TURN_SPEC) {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = n.wave;
|
||||
osc.frequency.value = n.f;
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.linearRampToValueAtTime(n.g, t0 + 0.008);
|
||||
g.gain.linearRampToValueAtTime(n.g * 0.55, t0 + n.d * 0.4);
|
||||
g.gain.linearRampToValueAtTime(0.0001, t0 + n.d);
|
||||
osc.connect(g);
|
||||
g.connect(this.audio._master);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + n.d + 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
/** 等待声:两声「嘟—嘟」循环。 */
|
||||
_startBed() {
|
||||
if (!this.bedEnabled || this._bed) return;
|
||||
const startedAt = performance.now();
|
||||
const bed = { timer: null, nodes: null, startedAt };
|
||||
this._bed = bed;
|
||||
const cycle = () => {
|
||||
if (this._bed !== bed) return;
|
||||
bed.nodes = this._beep();
|
||||
const waited = performance.now() - startedAt;
|
||||
const interval = waited > BED.slowAfterMs ? BED.slowMs : BED.loopMs;
|
||||
bed.timer = setTimeout(cycle, interval);
|
||||
};
|
||||
cycle();
|
||||
}
|
||||
|
||||
_beep() {
|
||||
const ctx = this.audio && this.audio.ctx;
|
||||
if (!ctx || !this.audio._master || !this.audio.unlocked) return null;
|
||||
const mk = (delay, f) => {
|
||||
const t0 = ctx.currentTime + 0.01 + delay;
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = f;
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.linearRampToValueAtTime(BED.gain, t0 + 0.01);
|
||||
g.gain.setValueAtTime(BED.gain, t0 + BED.toneMs / 1000 - 0.012);
|
||||
g.gain.linearRampToValueAtTime(0.0001, t0 + BED.toneMs / 1000);
|
||||
osc.connect(g);
|
||||
g.connect(this.audio._master);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + BED.toneMs / 1000 + 0.02);
|
||||
return g;
|
||||
};
|
||||
return [mk(0, BED.f1), mk((BED.toneMs + BED.gapMs) / 1000, BED.f2)];
|
||||
}
|
||||
|
||||
_stopBed() {
|
||||
const bed = this._bed;
|
||||
this._bed = null;
|
||||
if (!bed) return;
|
||||
if (bed.timer !== null) clearTimeout(bed.timer);
|
||||
if (bed.nodes) {
|
||||
const ctx = this.audio && this.audio.ctx;
|
||||
for (const g of bed.nodes) {
|
||||
if (!ctx) continue;
|
||||
try {
|
||||
const t = ctx.currentTime;
|
||||
g.gain.cancelScheduledValues(t);
|
||||
g.gain.setValueAtTime(g.gain.value, t);
|
||||
g.gain.linearRampToValueAtTime(0.0001, t + BED.fadeOutMs / 1000);
|
||||
} catch (_) { /* 已停 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Floor;
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* hints.js — 等待点与三档提示(26 号文档 §4.4 / 附录 A,P1 第 6 项)+ 无效操作
|
||||
* 解释一次(原则 7)。
|
||||
*
|
||||
* 等待点 = 需要玩家操作的时刻。配置(附录 A 模板):
|
||||
* { id, expected: ['left','right'], // 期待的手势(迷茫第 2 级只保留它们)
|
||||
* full: 'voice/xxx_full', // 完整提示
|
||||
* short: 'voice/xxx_short', // 短句(≤6 字,只保留动作)
|
||||
* silenceMs: [6000, 16000, 30000], // 静默分级阈值(同时喂迷茫检测的静默信号)
|
||||
* restrictLine: 'voice/xxx_only', // 迷茫第 2 级说明句(缺省用 short)
|
||||
* onDemo: () => {} } // 迷茫第 3 级演示(游戏自己完成这一步再交回)
|
||||
*
|
||||
* 三档选择(§4.4):
|
||||
* 第 n 次被动提示:0 → full;1 → short;≥2 → 只给交接提示音(earcon)。
|
||||
* 游戏主动给的提示永远不原样重复(原则 9)。
|
||||
* 玩家主动要求重听(requestReplay):永远给完整句,前缀「再说一遍:」
|
||||
* (voice/sys_say_again,全项目一句、每游戏各存一份资产)。
|
||||
*
|
||||
* 计数作用范围:按「这一个等待点」算,离开这个点(complete / 重新 arm)就清零;
|
||||
* 玩家做了有效操作但没完成目标(rearm),计数不清零,下次提示接着变短。
|
||||
*/
|
||||
|
||||
/** 提示台账:id → 最近一次播出时刻(模块级,跨等待点共享)。 */
|
||||
const LEDGER_WINDOW_MS = 30000;
|
||||
const hintLedger = new Map();
|
||||
function ledgerSay(id) { hintLedger.set(id, performance.now()); }
|
||||
function ledgerSaidRecently(id) {
|
||||
const t = hintLedger.get(id);
|
||||
return t !== undefined && performance.now() - t < LEDGER_WINDOW_MS;
|
||||
}
|
||||
/** 台账(导出):游戏层接管提示等自发句子复用同一去重规则(原则 9)。 */
|
||||
export { ledgerSay as markHintSaid, ledgerSaidRecently as hintSaidRecently };
|
||||
|
||||
export class WaitPoint {
|
||||
/**
|
||||
* @param {object} conf 附录 A 配置
|
||||
* @param {object} ctx { floor, confusion }
|
||||
*/
|
||||
constructor(conf, ctx) {
|
||||
this.conf = conf;
|
||||
this.floor = ctx.floor;
|
||||
this.confusion = ctx.confusion;
|
||||
this.hintCount = 0; // 本等待点已给的被动提示次数(决定档位)
|
||||
this._timers = [];
|
||||
this._armedAt = 0;
|
||||
this._restricted = false;
|
||||
}
|
||||
|
||||
/** 挂上(floor.yieldTurn 调用):清迷茫计数 + 按阈值排静默提示(阈值绝对)。 */
|
||||
arm() {
|
||||
this._clearTimers();
|
||||
this.hintCount = 0;
|
||||
this._armedAt = performance.now();
|
||||
if (this.confusion) this.confusion.resetForWaitPoint();
|
||||
this._scheduleTiers();
|
||||
}
|
||||
|
||||
/** 玩家动了但没完成这个点:静默计时复位,档位计数保留。 */
|
||||
rearm() {
|
||||
this._clearTimers();
|
||||
this._scheduleTiers();
|
||||
}
|
||||
|
||||
_scheduleTiers() {
|
||||
const th = this.conf.silenceMs || [18000, 40000];
|
||||
for (let k = 0; k < th.length; k++) {
|
||||
this._timers.push(setTimeout(() => this._fireSilence(k), th[k]));
|
||||
}
|
||||
this._armTailSilence(th.length ? th[th.length - 1] : 0);
|
||||
}
|
||||
|
||||
/** 这个等待点完成:清计时、清迷茫计数。 */
|
||||
complete() {
|
||||
this._clearTimers();
|
||||
this.hintCount = 0;
|
||||
this._restricted = false;
|
||||
if (this.confusion) this.confusion.resetForWaitPoint();
|
||||
}
|
||||
|
||||
/** 玩家主动重听:完整句 + 前缀,不重置档位计数(原则 9)。 */
|
||||
requestReplay() {
|
||||
if (this.confusion) this.confusion.recordReplay();
|
||||
const full = this.conf.full;
|
||||
if (!full) return;
|
||||
ledgerSay(full); // 刚主动听过,30s 内被动提示自动降档
|
||||
this.floor.speak(['voice/sys_say_again', full], {
|
||||
kind: 'hint',
|
||||
onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); },
|
||||
});
|
||||
}
|
||||
|
||||
/** 迷茫第 2 级:只保留 expected 手势,其他手势只给 invalid 回执。 */
|
||||
get restricted() { return this._restricted; }
|
||||
|
||||
/** 手势是否被本等待点放行(未受限时永远 true)。 */
|
||||
allows(g) {
|
||||
if (!this._restricted) return true;
|
||||
return (this.conf.expected || []).includes(g);
|
||||
}
|
||||
|
||||
/** 静默到点:喂迷茫静默信号 + 给第 hintCount 档提示。 */
|
||||
_fireSilence() {
|
||||
if (this.confusion) this.confusion.recordSilence();
|
||||
this._givePassiveHint();
|
||||
}
|
||||
|
||||
/**
|
||||
* 阈值用尽后的长间隔静默汇报(只喂迷茫信号,不再升提示档)。
|
||||
* 最多 4 次(≈2 分钟):等待声仍在标记状态(原则 1),接管三级也用尽后
|
||||
* 不再无限催促;有界也让虚拟时钟能排干定时器。
|
||||
*/
|
||||
_armTailSilence(afterMs) {
|
||||
let left = 4;
|
||||
const tick = () => {
|
||||
this._timers = this._timers.filter((t) => t !== id);
|
||||
if (this.confusion) this.confusion.recordSilence();
|
||||
if (--left <= 0) return;
|
||||
id = setTimeout(tick, 30000);
|
||||
this._timers.push(id);
|
||||
};
|
||||
let id = setTimeout(tick, afterMs + 30000);
|
||||
this._timers.push(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 第 hintCount 次被动提示:full → short → earcon(full 缺省时第 0 档就是短句)。
|
||||
* 台账跨等待点:30s 内别的等待点刚说过同一句 → 直接降档(被动原句重复 = 0)。
|
||||
*/
|
||||
_givePassiveHint() {
|
||||
this.hintCount++;
|
||||
const resume = { kind: 'hint', onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); } };
|
||||
const use = (id) => { ledgerSay(id); this.floor.speak([id], resume); };
|
||||
if (this.conf.full && !ledgerSaidRecently(this.conf.full)) {
|
||||
use(this.conf.full);
|
||||
} else if (this.conf.short && !ledgerSaidRecently(this.conf.short)) {
|
||||
use(this.conf.short);
|
||||
} else {
|
||||
this.floor.chime(); // 只给交接提示音
|
||||
}
|
||||
}
|
||||
|
||||
/** 三级接管(confusion.onConfused 接到这里)。 */
|
||||
takeover(level) {
|
||||
const resume = { kind: 'hint', onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); } };
|
||||
if (level === 1) {
|
||||
// 只说下一步的一个动作,用 _short 句(30s 内说过就只给提示音)
|
||||
const s = this.conf.short || this.conf.full;
|
||||
if (s && !ledgerSaidRecently(s)) { ledgerSay(s); this.floor.speak([s], resume); }
|
||||
else this.floor.chime();
|
||||
} else if (level === 2) {
|
||||
this._restricted = true;
|
||||
const line = this.conf.restrictLine || this.conf.short || this.conf.full;
|
||||
if (line) this.floor.speak([line], resume);
|
||||
} else if (level === 3 && this.conf.onDemo) {
|
||||
this._restricted = false;
|
||||
this.conf.onDemo();
|
||||
}
|
||||
}
|
||||
|
||||
_clearTimers() {
|
||||
for (const t of this._timers) clearTimeout(t);
|
||||
this._timers.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无效操作解释一次(原则 7):第一次无效操作说一句原因和正确做法;
|
||||
* 之后只给回执声。解释句按「状态 × 手势」为键,每键只播一次(同局)。
|
||||
*/
|
||||
export class InvalidHints {
|
||||
constructor() {
|
||||
this._said = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key 状态×手势 键(游戏提供,如 'story:swipe')
|
||||
* @param {string|null} voiceId 解释句 id;null = 该格没有解释句,只给回执
|
||||
* @returns {boolean} 是否真的播了
|
||||
*/
|
||||
explainOnce(key, voiceId) {
|
||||
if (!voiceId) return false;
|
||||
if (this._said.has(key)) return false;
|
||||
if (ledgerSaidRecently(voiceId)) { this._said.add(key); return false; } // 同句音频 30s 内不重播
|
||||
this._said.add(key);
|
||||
ledgerSay(voiceId);
|
||||
// 解释句走 idle-only 反馈通道(§4.6):正在说话时丢弃;播完回到等待态
|
||||
// (无效操作不推进游戏,话轮仍属玩家)
|
||||
this._resolveFloor?.speak([voiceId], {
|
||||
kind: 'feedback',
|
||||
onDone: () => { if (this._resolveFloor && this._resolveFloor.waitPoint) this._resolveFloor.resumeTurn(); },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 注入 floor(延迟绑定:构造时游戏可能还没建 floor)。 */
|
||||
bind(floor) { this._resolveFloor = floor; return this; }
|
||||
|
||||
reset() { this._said.clear(); }
|
||||
}
|
||||
|
||||
export { WaitPoint as default };
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* novice.js — 新手手势模式与解锁阶梯(26 号文档 §4.3,P0 第 1 项 + P3 第 11 项)。
|
||||
*
|
||||
* 新手手势表(全部游戏共用,原则 6):
|
||||
* | 手势 | 唯一含义 | 新手期(阶段 0) |
|
||||
* |------------|------------------------|---------------------------------|
|
||||
* | tap | 继续 / 确定 / 听一下 | 开放 |
|
||||
* | left/right | 选择 / 移动 | 开放 |
|
||||
* | up | 我在哪、我该干嘛 | 阶段 1 解锁(完成第一个小目标) |
|
||||
* | down | 菜单 | 阶段 2 解锁(完成第一局) |
|
||||
* | doubletap | 重听 | 阶段 3 解锁(累计 3 局);未解锁按单击处理 |
|
||||
* | longpress | 帮助(四向玩法类游戏) | 仅「滑动是玩法」的游戏开放(§4.3 裁定) |
|
||||
*
|
||||
* 解锁进度跨局持久(localStorage),全局共享(手势表全项目一致,学会一次就算会)。
|
||||
* 游戏在对应时机调 onGoal() / onGameDone(),返回新解锁的手势(null=无新解锁),
|
||||
* 随后按原则 11 教学:先说一句(teachLine),马上用一次。
|
||||
*/
|
||||
const STORAGE_KEY = 'soundgame.gestures';
|
||||
|
||||
/** 解锁教学句 id(每游戏目录各存一份同文案资产,§4.3 阶梯表)。 */
|
||||
const TEACH_IDS = {
|
||||
up: 'sys_unlock_up', // 「想知道自己在哪,就向上滑。」
|
||||
down: 'sys_unlock_down', // 「向下滑,可以打开菜单。」
|
||||
doubletap: 'sys_unlock_dt', // 「没听清,就双击,再听一遍。」
|
||||
};
|
||||
|
||||
const UNLOCK_AT = { 1: 'up', 2: 'down', 3: 'doubletap' };
|
||||
|
||||
export class GestureLadder {
|
||||
constructor(key = STORAGE_KEY) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
_load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.key);
|
||||
if (!raw) return { stage: 0, games: 0 };
|
||||
const o = JSON.parse(raw);
|
||||
return {
|
||||
stage: Math.max(0, Math.min(3, Number(o.stage) || 0)),
|
||||
games: Math.max(0, Number(o.games) || 0),
|
||||
};
|
||||
} catch (_) {
|
||||
return { stage: 0, games: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
_save(o) {
|
||||
try { localStorage.setItem(this.key, JSON.stringify(o)); } catch (_) { /* 隐私模式 */ }
|
||||
}
|
||||
|
||||
/** 当前阶段 0..3。 */
|
||||
stage() { return this._load().stage; }
|
||||
|
||||
/** 手势是否已解锁(tap/left/right 永远开放;longpress 由游戏按玩法裁定)。 */
|
||||
has(g) {
|
||||
if (g === 'tap' || g === 'left' || g === 'right') return true;
|
||||
if (g === 'up') return this.stage() >= 1;
|
||||
if (g === 'down') return this.stage() >= 2;
|
||||
if (g === 'doubletap') return this.stage() >= 3;
|
||||
return false; // longpress 等:走游戏自己的核心玩法裁定
|
||||
}
|
||||
|
||||
/** 完成第一个小目标 → 阶段 1。返回新解锁手势或 null。 */
|
||||
onGoal() { return this._unlock(1); }
|
||||
|
||||
/** 完成一局 → games+1;第 1 局解锁阶段 2,第 3 局解锁阶段 3。 */
|
||||
onGameDone() {
|
||||
const o = this._load();
|
||||
o.games += 1;
|
||||
this._save(o);
|
||||
let got = null;
|
||||
if (o.games >= 1) got = this._unlock(2) || got;
|
||||
if (o.games >= 3) got = this._unlock(3) || got;
|
||||
return got;
|
||||
}
|
||||
|
||||
_unlock(stage) {
|
||||
const o = this._load();
|
||||
if (o.stage >= stage) return null;
|
||||
o.stage = stage;
|
||||
this._save(o);
|
||||
return UNLOCK_AT[stage];
|
||||
}
|
||||
|
||||
/** 老玩家在大厅一键全部解锁。 */
|
||||
unlockAll() {
|
||||
const o = this._load();
|
||||
o.stage = 3;
|
||||
this._save(o);
|
||||
}
|
||||
|
||||
/** 解锁教学句 voice id。 */
|
||||
teachLine(g) { return TEACH_IDS[g] || null; }
|
||||
}
|
||||
|
||||
export default GestureLadder;
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* pipeline.js — 输入管道(26 号文档 §4.2 接线方式 + §4.3 新手手势模式 + 防连点)。
|
||||
*
|
||||
* 每款改造游戏的 attachGestures 回调都换成这个管道(P0 第 1/2/3/4 项一次落地):
|
||||
*
|
||||
* const pipe = makeInputPipeline({ floor, confusion, receipts, invalidHints,
|
||||
* game, ladder });
|
||||
* attachGestures(root, pipe, { doubleTapWindow: 0 }); // 关掉双击 + 去掉 300ms 延迟
|
||||
*
|
||||
* 管道顺序:
|
||||
* 1. 新手手势门(§4.3):未解锁 doubletap 按一次 tap 处理(键盘回车发的
|
||||
* doubletap 同样折算);未解锁 down / 非玩法 longpress → invalid 回执 +
|
||||
* 首次附一句解释(原则 7)。
|
||||
* 2. confusion.record(g)(§4.5 乱点信号)。
|
||||
* 3. floor.handleInput(g)(§4.1):SPEAKING 中 tap=跳句(consumed)、滑动=打断并
|
||||
* 执行、critical=busy;BUSY=busy 回执;YOUR_TURN 停等待声。
|
||||
* 4. 防连点(§4.2):tap 刚执行过「继续/跳过」后的 400ms 内,再来的 tap 只给
|
||||
* busy 回执(点两下不连跳两句)。ACTION 态(答题)不设防。
|
||||
* 5. 等待点第 2 级限制(§4.5 缩小):只放行 expected 手势,其余 invalid 回执。
|
||||
* 6. game.onGesture(g) 返回值收口:
|
||||
* 'ok' 接受(完成当前等待点)
|
||||
* 'hold' 接受但等待点继续等(如抉择点单击=再听选项)
|
||||
* 'silent-ok' 接受且已有明显内容音(省 ok 回执,§4.2)
|
||||
* 'invalid' 拒绝(invalid 回执 + explainOnce + 迷茫信号;等待点计时复位)
|
||||
*/
|
||||
import { GestureLadder } from './novice.js';
|
||||
|
||||
/** 防连点窗口:tap 执行「继续/跳过」后 400ms 内的 tap 只给 busy。 */
|
||||
const TAP_GUARD_MS = 400;
|
||||
|
||||
/** 管道要喂给迷茫检测的手势(收尾/撤销类噪声不计)。 */
|
||||
const REAL_GESTURES = new Set(['tap', 'doubletap', 'up', 'down', 'left', 'right', 'longpress']);
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {import('./floor.js').Floor} floor
|
||||
* @param {import('./confusion.js').Confusion} confusion
|
||||
* @param {import('./receipts.js').Receipts} receipts
|
||||
* @param {import('./hints.js').InvalidHints} invalidHints
|
||||
* @param {{ onGesture: (g: string, info?: object) => string|void,
|
||||
* stateName?: () => string,
|
||||
* invalidHint?: (state: string, g: string) => string|null }} game
|
||||
* @param {import('./novice.js').GestureLadder} [deps.ladder] 手势解锁阶梯(缺省=全新手)
|
||||
* @param {object} [deps.opts]
|
||||
* @param {boolean} [deps.opts.longpressCore=false] 本游戏长按是核心玩法(四向滑动类
|
||||
* 游戏的帮助键,§4.3 裁定);false 时 longpress → invalid
|
||||
* @param {boolean} [deps.opts.swipeCore=false] 四向滑动是本游戏核心玩法(§4.3 裁定):
|
||||
* up/down 不按阶梯锁(它们是移动,不是帮助/菜单);阶梯只管 doubletap
|
||||
*/
|
||||
export function makeInputPipeline({ floor, confusion, receipts, invalidHints, game, ladder, opts = {} }) {
|
||||
const lad = ladder || new GestureLadder();
|
||||
const longpressCore = (opts && opts.longpressCore) === true;
|
||||
const swipeCore = (opts && opts.swipeCore) === true;
|
||||
let lastTapActedAt = -Infinity;
|
||||
|
||||
return (g, info) => {
|
||||
if (g === 'longpressend' || g === 'tapcancel') return; // 手势收尾/撤销噪声,不吃回执
|
||||
|
||||
// ---- 1. 新手手势门(§4.3)----
|
||||
if (g === 'doubletap' && !lad.has('doubletap')) g = 'tap'; // 双击按一次单击处理
|
||||
if (!swipeCore && g === 'up' && !lad.has('up')) {
|
||||
receipts.play('invalid');
|
||||
invalidHints.explainOnce('locked:up', game.invalidHint ? game.invalidHint('locked', 'up') : null);
|
||||
return;
|
||||
}
|
||||
if (!swipeCore && g === 'down' && !lad.has('down')) {
|
||||
receipts.play('invalid');
|
||||
invalidHints.explainOnce('locked:down', game.invalidHint ? game.invalidHint('locked', 'down') : null);
|
||||
return;
|
||||
}
|
||||
if (g === 'longpress' && !longpressCore) {
|
||||
receipts.play('invalid');
|
||||
invalidHints.explainOnce('locked:longpress', game.invalidHint ? game.invalidHint('locked', 'longpress') : null);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 2. 迷茫检测(§4.5)----
|
||||
if (REAL_GESTURES.has(g)) confusion.record(g);
|
||||
|
||||
// ---- 3. 防连点(§4.2):tap 刚执行过「继续/跳过」后的 400ms 内,再来的 tap
|
||||
// 只给 busy 回执(无论它将被解释成跳句还是继续——都不执行)----
|
||||
if (g === 'tap' && floor.state !== 'ACTION' && performance.now() - lastTapActedAt < TAP_GUARD_MS) {
|
||||
receipts.play('busy');
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 4. 发言权(§4.1)----
|
||||
const preState = floor.state;
|
||||
const pre = floor.handleInput(g);
|
||||
if (pre === 'busy') { receipts.play('busy'); return; }
|
||||
if (pre === 'consumed') {
|
||||
receipts.play('ok'); // tap 跳句 = 一次「继续」,进防连点窗口
|
||||
lastTapActedAt = performance.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 5. 迷茫第 2 级:等待点只放行 expected(§4.5 缩小)----
|
||||
const wp = floor.waitPoint;
|
||||
if (wp && wp.restricted && !wp.allows(g)) {
|
||||
receipts.play('invalid');
|
||||
confusion.recordInvalid();
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 6. 游戏处理 + 回执收口 ----
|
||||
const r = game.onGesture(g, info) || 'ok';
|
||||
if (r === 'invalid') {
|
||||
receipts.play('invalid');
|
||||
const state = game.stateName ? game.stateName() : '';
|
||||
invalidHints.explainOnce(`${state}:${g}`, game.invalidHint ? game.invalidHint(state, g) : null);
|
||||
confusion.recordInvalid();
|
||||
// 等待点仍在(无效操作没完成它):静默计时复位、回到等待态(含打断后的 BUSY)
|
||||
if (floor.waitPoint && !floor.speaking) floor.settleWaitPoint(false);
|
||||
return;
|
||||
}
|
||||
if (g === 'tap' && r !== 'hold' && preState !== 'ACTION') {
|
||||
lastTapActedAt = performance.now(); // tap「继续」进防连点窗口
|
||||
}
|
||||
if (r !== 'silent-ok') receipts.play('ok');
|
||||
if (floor.state === 'YOUR_TURN') floor.settleWaitPoint(r !== 'hold');
|
||||
};
|
||||
}
|
||||
|
||||
export default makeInputPipeline;
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* receipts.js — 输入回执三件套(26 号文档 §4.2,P0 第 2 项)。
|
||||
*
|
||||
* 每次玩家操作 100ms 内出声,告知「这下算没算」。三种回执(合成参数为文档起步值):
|
||||
* | kind | 用在哪里 | 听感 |
|
||||
* |----------|------------------------|----------------------|
|
||||
* | ok | 操作被接受并生效 | 清脆的「嗒」 |
|
||||
* | invalid | 收到了,但现在用不上 | 低沉柔和的「咚」 |
|
||||
* | busy | 关键句在说 / 正在处理 | 很轻的「嗯」 |
|
||||
*
|
||||
* 规则(文档 §4.2 / 原则 4):
|
||||
* - 回执由引擎统一播,游戏只告诉管道这次操作的结果是哪一种(见 pipeline.js)。
|
||||
* - 游戏已为这次操作播了明显内容音(脚步、开门)时可以省 ok,invalid/busy 永远要播。
|
||||
* - 音色避开 guide.js 的 turn(1318Hz 风铃)/ nudge(880Hz 木鱼);系统音一律合成音
|
||||
* (原则 10),接 master 总线吃限幅、不被 BGM duck 压低(§4.11 优先级 1)。
|
||||
* - 任何设备都出声,不依赖震动;震动由 gesture/haptics 照常叠加。
|
||||
*/
|
||||
|
||||
/** 合成参数(26 号文档 §4.2 表:合成参数(起步值))。 */
|
||||
const SPECS = {
|
||||
// 三角波 1600Hz,35ms,起音 2ms,快速衰减,增益 0.08 —— 清脆的「嗒」
|
||||
ok: { wave: 'triangle', from: 1600, to: 1600, dur: 0.035, attack: 0.002, gain: 0.08 },
|
||||
// 正弦波 260Hz 滑到 200Hz,120ms,起音 10ms,增益 0.10 —— 低沉柔和的「咚」
|
||||
invalid: { wave: 'sine', from: 260, to: 200, dur: 0.12, attack: 0.01, gain: 0.10 },
|
||||
// 正弦波 520Hz,40ms,增益 0.04 —— 很轻的「嗯」
|
||||
busy: { wave: 'sine', from: 520, to: 520, dur: 0.04, attack: 0.005, gain: 0.04 },
|
||||
};
|
||||
|
||||
export class Receipts {
|
||||
/** @param {import('./audio.js').WebAudioEngine} audio */
|
||||
constructor(audio) {
|
||||
this.audio = audio;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播一种回执。未解锁 / 无 ctx(playtest mock)时静默跳过。
|
||||
* @param {'ok'|'invalid'|'busy'} kind
|
||||
*/
|
||||
play(kind) {
|
||||
const spec = SPECS[kind];
|
||||
const ctx = this.audio && this.audio.ctx;
|
||||
if (!spec || !ctx || !this.audio._master || !this.audio.unlocked) return;
|
||||
const t0 = ctx.currentTime + 0.005;
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = spec.wave;
|
||||
osc.frequency.setValueAtTime(spec.from, t0);
|
||||
if (spec.to !== spec.from) osc.frequency.linearRampToValueAtTime(spec.to, t0 + spec.dur);
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.linearRampToValueAtTime(spec.gain, t0 + spec.attack);
|
||||
// 快速衰减:attack 后线性落到接近 0(听感接近指数衰减)
|
||||
g.gain.linearRampToValueAtTime(spec.gain * 0.35, t0 + spec.attack + (spec.dur - spec.attack) * 0.45);
|
||||
g.gain.linearRampToValueAtTime(0.0001, t0 + spec.dur);
|
||||
osc.connect(g);
|
||||
g.connect(this.audio._master);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + spec.dur + 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
export default Receipts;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user