storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行 (发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/ 菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。 游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio / dungeon / abyss / detective / cipher / teahouse / keeper / bridge / hunt(联机切片): - 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0); 四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt 旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore) - 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动 原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿 - 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/ 台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写 引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record 前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前 状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在 speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。 语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110 废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器 tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a 落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。 大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README / HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
342 lines
14 KiB
JavaScript
342 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;
|
||
if (this.state === 'ACTION') return; // ACTION 态玩法声即状态,不回退等待声
|
||
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;
|