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:
@@ -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;
|
||||
Reference in New Issue
Block a user