真人首测反馈「交互还是很迷惑」:大厅 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 回归通过。
341 lines
14 KiB
JavaScript
341 lines
14 KiB
JavaScript
/**
|
||
* 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;
|