Files
SoundGame/web/engine/floor.js
T
xsl e943227136 feat(playbook): 26 号 P0 推广至全部 15 款游戏——兼容输入管道 + 可闻度调优
真人首测反馈「交互还是很迷惑」:大厅 15 款里只有 3 款新交互,其余 12 款
仍是旧版(无声操作、连点连跳),整体体验割裂。本次:

- engine/pipeline.js 新增 makeCompatPipeline(纯增量,游戏内部逻辑零改动):
  · 手势后 100ms 无任何音频调度 → invalid 回执(消灭无声操作,原则 4)
  · 语音中的手势 → 立即 ok 回执(§4.1 打断即收到)
  · 语音中 tap 400ms 防连点(§4.2,点两下不连跳两句)
  · longpressend/tapcancel 收尾噪声直通
- engine/audio.js 加 playCount 探针(回执启发式用)
- 12 款游戏 index.html 接入兼容管道(rhythm 保留 immediateTap;
  双击判定窗口不动——多款游戏双击是核心玩法/菜单确认,重映射会破坏)
- 可闻度调优(真人首测:笔记本外放偏轻,26 号 §3「起步值,真人测试后再调」):
  回执增益 ok 0.08→0.11 / invalid 0.10→0.13 / busy 0.04→0.06;
  等待声 0.03→0.055
- 大厅:三款深度重构游戏(声命线/暗室/音序回响)置顶 + 「新交互」徽标,
  卡片文案直接写玩法手势,测试不再误入旧交互游戏

测试:新增 compat-pipeline.test(9 断言);全部 15 款 playtest 回归通过
(共 3900+ 断言,0 失败);引擎 input/voice/guide 回归通过。
2026-10-01 21:09:26 +08:00

341 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.055, // ≈ guide 提示音增益 0.12 的一半(0.03 笔记本外放偏轻,
// 真人首测调高;手机外放仍远轻于语音)
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;