/** * 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;