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:
xsl
2026-10-01 08:11:12 +08:00
parent d15a9b9635
commit ee9d7efe57
1183 changed files with 3945 additions and 4588 deletions
+339
View File
@@ -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;