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
+13
View File
@@ -300,6 +300,18 @@ export class WebAudioEngine {
this._unduckBgm();
}
/**
* 语音链级压低持有(26 号文档 §4.1「音乐压低要跨整条链」):游戏层用
* onComplete 一句句接起来的链,句间引擎队列是空的、会恢复音乐音量,
* 玩家就听到一次「好像轮到我了」。Floor 在整条链期间持有一份压低,
* 链结束/被打断时释放。可重入(计数)。
* @param {boolean} on true=持有一份压低;false=释放一份
*/
holdDuck(on) {
this._duckHolds = Math.max(0, (this._duckHolds || 0) + (on ? 1 : -1));
if (on) this._duckBgm();
}
/**
* 跳过当前对白:停掉当前 voice 并**视为这句自然播完**——触发其
* onComplete(链播下一句)并继续等待队列。与 stopVoice 的差异:
@@ -443,6 +455,7 @@ export class WebAudioEngine {
}
_unduckBgm() {
if (this._duckHolds > 0) return; // Floor 持有链级压低:句间不恢复(26 号 §4.1)
if (this._bgmBus) this._fadeGain(this._bgmBus, 1, this.duckMs);
}
+89
View File
@@ -0,0 +1,89 @@
/**
* confusion.js — 迷茫检测(26 号文档 §4.5,P2 第 9 项)。
*
* 迷茫不只看「不动」,更要从「乱点」里识别。信号(10s 滑动窗口):
* | 信号 | 判定 | 权重 |
* |----------|----------------------------|------|
* | 连续无效 | 10 秒内 ≥3 次 invalid | 2 |
* | 反复重听 | 10 秒内主动重听 ≥2 次 | 1 |
* | 乱滑 | 2 秒内 ≥3 个不同滑动方向 | 1 |
* | 同步失败 | 同一等待点失败 ≥2 次 | 2 |
* | 静默 | 动作类 6s / 剧情类 18s 无操作 | 1 |
*
* 权重累计 ≥2 触发接管,15s 冷却期内不再触发。三级接管由当前等待点实现
* (onConfused(level)):1 提示(_short 句)→ 2 缩小(只保留一个正确手势)→
* 3 演示(游戏自己做一遍再交回)。同一等待点每触发一次升一级;第 3 级用过后
* 该等待点停止接管,只保留等待声。
*/
const WINDOW_MS = 10000;
const COOLDOWN_MS = 15000;
const SCORE_TRIGGER = 2;
const MAX_LEVEL = 3;
export class Confusion {
/**
* @param {object} opts
* @param {(level: number) => void} opts.onConfused 三级接管回调(由当前等待点实现)
*/
constructor({ onConfused } = {}) {
this.events = []; // { t, kind, dir? }
this.cooldownUntil = 0;
this.onConfused = onConfused || null;
this.level = 0; // 当前等待点已接管到的级别
this.silenceMs = 18000; // 静默阈值:动作类 6s / 剧情类 18s(游戏设定)
}
/** 玩家输入(任意手势;dir=手势名,乱滑检测用)。 */
record(g) { this._push({ kind: 'input', dir: g }); }
/** 无效操作结果(pipeline 在游戏返回 invalid 时喂入)。 */
recordInvalid() { this._push({ kind: 'invalid' }); }
/** 玩家主动重听。 */
recordReplay() { this._push({ kind: 'replay' }); }
/** 同一步骤失败(游戏判定:答错/走错,喂入)。 */
recordFail() { this._push({ kind: 'fail' }); }
/** 静默(guide.watchdog 到点回调喂入;权重 1)。 */
recordSilence() { this._push({ kind: 'silence' }); }
/** 换等待点:清事件、级别归零。 */
resetForWaitPoint() {
this.events = [];
this.level = 0;
}
/** 当前级别(等待点第 3 级用过后不再升)。 */
get exhausted() { return this.level >= MAX_LEVEL; }
_push(e) {
const now = performance.now();
this.events.push({ t: now, ...e });
this.events = this.events.filter((x) => now - x.t < WINDOW_MS);
if (now < this.cooldownUntil) return;
if (this._score(now) >= SCORE_TRIGGER && this.level < MAX_LEVEL) {
this.level++;
this.cooldownUntil = now + COOLDOWN_MS;
if (this.onConfused) this.onConfused(this.level);
}
}
_score(now) {
const n = (k) => this.events.filter((x) => x.kind === k).length;
const recentDirs = new Set(this.events
.filter((x) => x.kind === 'input' && now - x.t < 2000 && x.dir && x.dir !== 'tap')
.map((x) => x.dir));
let s = 0;
if (n('invalid') >= 3) s += 2;
if (n('replay') >= 2) s += 1;
if (recentDirs.size >= 3) s += 1;
if (n('fail') >= 2) s += 2;
if (n('silence') >= 1 && this._onlySilenceRecently(now)) s += 1;
return s;
}
/** 静默只在「窗口内除静默外几乎没有输入」时计分(有输入就不算静默)。 */
_onlySilenceRecently(now) {
const inputs = this.events.filter((x) => x.kind === 'input' && now - x.t < WINDOW_MS);
return inputs.length === 0;
}
}
export default Confusion;
+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;
+201
View File
@@ -0,0 +1,201 @@
/**
* hints.js — 等待点与三档提示(26 号文档 §4.4 / 附录 A,P1 第 6 项)+ 无效操作
* 解释一次(原则 7)。
*
* 等待点 = 需要玩家操作的时刻。配置(附录 A 模板):
* { id, expected: ['left','right'], // 期待的手势(迷茫第 2 级只保留它们)
* full: 'voice/xxx_full', // 完整提示
* short: 'voice/xxx_short', // 短句(≤6 字,只保留动作)
* silenceMs: [6000, 16000, 30000], // 静默分级阈值(同时喂迷茫检测的静默信号)
* restrictLine: 'voice/xxx_only', // 迷茫第 2 级说明句(缺省用 short)
* onDemo: () => {} } // 迷茫第 3 级演示(游戏自己完成这一步再交回)
*
* 三档选择(§4.4):
* 第 n 次被动提示:0 → full;1 → short;≥2 → 只给交接提示音(earcon)。
* 游戏主动给的提示永远不原样重复(原则 9)。
* 玩家主动要求重听(requestReplay):永远给完整句,前缀「再说一遍:」
* (voice/sys_say_again,全项目一句、每游戏各存一份资产)。
*
* 计数作用范围:按「这一个等待点」算,离开这个点(complete / 重新 arm)就清零;
* 玩家做了有效操作但没完成目标(rearm),计数不清零,下次提示接着变短。
*/
/** 提示台账:id → 最近一次播出时刻(模块级,跨等待点共享)。 */
const LEDGER_WINDOW_MS = 30000;
const hintLedger = new Map();
function ledgerSay(id) { hintLedger.set(id, performance.now()); }
function ledgerSaidRecently(id) {
const t = hintLedger.get(id);
return t !== undefined && performance.now() - t < LEDGER_WINDOW_MS;
}
/** 台账(导出):游戏层接管提示等自发句子复用同一去重规则(原则 9)。 */
export { ledgerSay as markHintSaid, ledgerSaidRecently as hintSaidRecently };
export class WaitPoint {
/**
* @param {object} conf 附录 A 配置
* @param {object} ctx { floor, confusion }
*/
constructor(conf, ctx) {
this.conf = conf;
this.floor = ctx.floor;
this.confusion = ctx.confusion;
this.hintCount = 0; // 本等待点已给的被动提示次数(决定档位)
this._timers = [];
this._armedAt = 0;
this._restricted = false;
}
/** 挂上(floor.yieldTurn 调用):清迷茫计数 + 按阈值排静默提示(阈值绝对)。 */
arm() {
this._clearTimers();
this.hintCount = 0;
this._armedAt = performance.now();
if (this.confusion) this.confusion.resetForWaitPoint();
this._scheduleTiers();
}
/** 玩家动了但没完成这个点:静默计时复位,档位计数保留。 */
rearm() {
this._clearTimers();
this._scheduleTiers();
}
_scheduleTiers() {
const th = this.conf.silenceMs || [18000, 40000];
for (let k = 0; k < th.length; k++) {
this._timers.push(setTimeout(() => this._fireSilence(k), th[k]));
}
this._armTailSilence(th.length ? th[th.length - 1] : 0);
}
/** 这个等待点完成:清计时、清迷茫计数。 */
complete() {
this._clearTimers();
this.hintCount = 0;
this._restricted = false;
if (this.confusion) this.confusion.resetForWaitPoint();
}
/** 玩家主动重听:完整句 + 前缀,不重置档位计数(原则 9)。 */
requestReplay() {
if (this.confusion) this.confusion.recordReplay();
const full = this.conf.full;
if (!full) return;
ledgerSay(full); // 刚主动听过,30s 内被动提示自动降档
this.floor.speak(['voice/sys_say_again', full], {
kind: 'hint',
onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); },
});
}
/** 迷茫第 2 级:只保留 expected 手势,其他手势只给 invalid 回执。 */
get restricted() { return this._restricted; }
/** 手势是否被本等待点放行(未受限时永远 true)。 */
allows(g) {
if (!this._restricted) return true;
return (this.conf.expected || []).includes(g);
}
/** 静默到点:喂迷茫静默信号 + 给第 hintCount 档提示。 */
_fireSilence() {
if (this.confusion) this.confusion.recordSilence();
this._givePassiveHint();
}
/**
* 阈值用尽后的长间隔静默汇报(只喂迷茫信号,不再升提示档)。
* 最多 4 次(≈2 分钟):等待声仍在标记状态(原则 1),接管三级也用尽后
* 不再无限催促;有界也让虚拟时钟能排干定时器。
*/
_armTailSilence(afterMs) {
let left = 4;
const tick = () => {
this._timers = this._timers.filter((t) => t !== id);
if (this.confusion) this.confusion.recordSilence();
if (--left <= 0) return;
id = setTimeout(tick, 30000);
this._timers.push(id);
};
let id = setTimeout(tick, afterMs + 30000);
this._timers.push(id);
}
/**
* 第 hintCount 次被动提示:full → short → earcon(full 缺省时第 0 档就是短句)。
* 台账跨等待点:30s 内别的等待点刚说过同一句 → 直接降档(被动原句重复 = 0)。
*/
_givePassiveHint() {
this.hintCount++;
const resume = { kind: 'hint', onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); } };
const use = (id) => { ledgerSay(id); this.floor.speak([id], resume); };
if (this.conf.full && !ledgerSaidRecently(this.conf.full)) {
use(this.conf.full);
} else if (this.conf.short && !ledgerSaidRecently(this.conf.short)) {
use(this.conf.short);
} else {
this.floor.chime(); // 只给交接提示音
}
}
/** 三级接管(confusion.onConfused 接到这里)。 */
takeover(level) {
const resume = { kind: 'hint', onDone: () => { if (this.floor.waitPoint === this) this.floor.resumeTurn(); } };
if (level === 1) {
// 只说下一步的一个动作,用 _short 句(30s 内说过就只给提示音)
const s = this.conf.short || this.conf.full;
if (s && !ledgerSaidRecently(s)) { ledgerSay(s); this.floor.speak([s], resume); }
else this.floor.chime();
} else if (level === 2) {
this._restricted = true;
const line = this.conf.restrictLine || this.conf.short || this.conf.full;
if (line) this.floor.speak([line], resume);
} else if (level === 3 && this.conf.onDemo) {
this._restricted = false;
this.conf.onDemo();
}
}
_clearTimers() {
for (const t of this._timers) clearTimeout(t);
this._timers.length = 0;
}
}
/**
* 无效操作解释一次(原则 7):第一次无效操作说一句原因和正确做法;
* 之后只给回执声。解释句按「状态 × 手势」为键,每键只播一次(同局)。
*/
export class InvalidHints {
constructor() {
this._said = new Set();
}
/**
* @param {string} key 状态×手势 键(游戏提供,如 'story:swipe')
* @param {string|null} voiceId 解释句 id;null = 该格没有解释句,只给回执
* @returns {boolean} 是否真的播了
*/
explainOnce(key, voiceId) {
if (!voiceId) return false;
if (this._said.has(key)) return false;
if (ledgerSaidRecently(voiceId)) { this._said.add(key); return false; } // 同句音频 30s 内不重播
this._said.add(key);
ledgerSay(voiceId);
// 解释句走 idle-only 反馈通道(§4.6):正在说话时丢弃;播完回到等待态
// (无效操作不推进游戏,话轮仍属玩家)
this._resolveFloor?.speak([voiceId], {
kind: 'feedback',
onDone: () => { if (this._resolveFloor && this._resolveFloor.waitPoint) this._resolveFloor.resumeTurn(); },
});
return true;
}
/** 注入 floor(延迟绑定:构造时游戏可能还没建 floor)。 */
bind(floor) { this._resolveFloor = floor; return this; }
reset() { this._said.clear(); }
}
export { WaitPoint as default };
+97
View File
@@ -0,0 +1,97 @@
/**
* novice.js — 新手手势模式与解锁阶梯(26 号文档 §4.3,P0 第 1 项 + P3 第 11 项)。
*
* 新手手势表(全部游戏共用,原则 6):
* | 手势 | 唯一含义 | 新手期(阶段 0) |
* |------------|------------------------|---------------------------------|
* | tap | 继续 / 确定 / 听一下 | 开放 |
* | left/right | 选择 / 移动 | 开放 |
* | up | 我在哪、我该干嘛 | 阶段 1 解锁(完成第一个小目标) |
* | down | 菜单 | 阶段 2 解锁(完成第一局) |
* | doubletap | 重听 | 阶段 3 解锁(累计 3 局);未解锁按单击处理 |
* | longpress | 帮助(四向玩法类游戏) | 仅「滑动是玩法」的游戏开放(§4.3 裁定) |
*
* 解锁进度跨局持久(localStorage),全局共享(手势表全项目一致,学会一次就算会)。
* 游戏在对应时机调 onGoal() / onGameDone(),返回新解锁的手势(null=无新解锁),
* 随后按原则 11 教学:先说一句(teachLine),马上用一次。
*/
const STORAGE_KEY = 'soundgame.gestures';
/** 解锁教学句 id(每游戏目录各存一份同文案资产,§4.3 阶梯表)。 */
const TEACH_IDS = {
up: 'sys_unlock_up', // 「想知道自己在哪,就向上滑。」
down: 'sys_unlock_down', // 「向下滑,可以打开菜单。」
doubletap: 'sys_unlock_dt', // 「没听清,就双击,再听一遍。」
};
const UNLOCK_AT = { 1: 'up', 2: 'down', 3: 'doubletap' };
export class GestureLadder {
constructor(key = STORAGE_KEY) {
this.key = key;
}
_load() {
try {
const raw = localStorage.getItem(this.key);
if (!raw) return { stage: 0, games: 0 };
const o = JSON.parse(raw);
return {
stage: Math.max(0, Math.min(3, Number(o.stage) || 0)),
games: Math.max(0, Number(o.games) || 0),
};
} catch (_) {
return { stage: 0, games: 0 };
}
}
_save(o) {
try { localStorage.setItem(this.key, JSON.stringify(o)); } catch (_) { /* 隐私模式 */ }
}
/** 当前阶段 0..3。 */
stage() { return this._load().stage; }
/** 手势是否已解锁(tap/left/right 永远开放;longpress 由游戏按玩法裁定)。 */
has(g) {
if (g === 'tap' || g === 'left' || g === 'right') return true;
if (g === 'up') return this.stage() >= 1;
if (g === 'down') return this.stage() >= 2;
if (g === 'doubletap') return this.stage() >= 3;
return false; // longpress 等:走游戏自己的核心玩法裁定
}
/** 完成第一个小目标 → 阶段 1。返回新解锁手势或 null。 */
onGoal() { return this._unlock(1); }
/** 完成一局 → games+1;第 1 局解锁阶段 2,第 3 局解锁阶段 3。 */
onGameDone() {
const o = this._load();
o.games += 1;
this._save(o);
let got = null;
if (o.games >= 1) got = this._unlock(2) || got;
if (o.games >= 3) got = this._unlock(3) || got;
return got;
}
_unlock(stage) {
const o = this._load();
if (o.stage >= stage) return null;
o.stage = stage;
this._save(o);
return UNLOCK_AT[stage];
}
/** 老玩家在大厅一键全部解锁。 */
unlockAll() {
const o = this._load();
o.stage = 3;
this._save(o);
}
/** 解锁教学句 voice id。 */
teachLine(g) { return TEACH_IDS[g] || null; }
}
export default GestureLadder;
+124
View File
@@ -0,0 +1,124 @@
/**
* pipeline.js — 输入管道(26 号文档 §4.2 接线方式 + §4.3 新手手势模式 + 防连点)。
*
* 每款改造游戏的 attachGestures 回调都换成这个管道(P0 第 1/2/3/4 项一次落地):
*
* const pipe = makeInputPipeline({ floor, confusion, receipts, invalidHints,
* game, ladder });
* attachGestures(root, pipe, { doubleTapWindow: 0 }); // 关掉双击 + 去掉 300ms 延迟
*
* 管道顺序:
* 1. 新手手势门(§4.3):未解锁 doubletap 按一次 tap 处理(键盘回车发的
* doubletap 同样折算);未解锁 down / 非玩法 longpress → invalid 回执 +
* 首次附一句解释(原则 7)。
* 2. confusion.record(g)(§4.5 乱点信号)。
* 3. floor.handleInput(g)(§4.1):SPEAKING 中 tap=跳句(consumed)、滑动=打断并
* 执行、critical=busy;BUSY=busy 回执;YOUR_TURN 停等待声。
* 4. 防连点(§4.2):tap 刚执行过「继续/跳过」后的 400ms 内,再来的 tap 只给
* busy 回执(点两下不连跳两句)。ACTION 态(答题)不设防。
* 5. 等待点第 2 级限制(§4.5 缩小):只放行 expected 手势,其余 invalid 回执。
* 6. game.onGesture(g) 返回值收口:
* 'ok' 接受(完成当前等待点)
* 'hold' 接受但等待点继续等(如抉择点单击=再听选项)
* 'silent-ok' 接受且已有明显内容音(省 ok 回执,§4.2)
* 'invalid' 拒绝(invalid 回执 + explainOnce + 迷茫信号;等待点计时复位)
*/
import { GestureLadder } from './novice.js';
/** 防连点窗口:tap 执行「继续/跳过」后 400ms 内的 tap 只给 busy。 */
const TAP_GUARD_MS = 400;
/** 管道要喂给迷茫检测的手势(收尾/撤销类噪声不计)。 */
const REAL_GESTURES = new Set(['tap', 'doubletap', 'up', 'down', 'left', 'right', 'longpress']);
/**
* @param {object} deps
* @param {import('./floor.js').Floor} floor
* @param {import('./confusion.js').Confusion} confusion
* @param {import('./receipts.js').Receipts} receipts
* @param {import('./hints.js').InvalidHints} invalidHints
* @param {{ onGesture: (g: string, info?: object) => string|void,
* stateName?: () => string,
* invalidHint?: (state: string, g: string) => string|null }} game
* @param {import('./novice.js').GestureLadder} [deps.ladder] 手势解锁阶梯(缺省=全新手)
* @param {object} [deps.opts]
* @param {boolean} [deps.opts.longpressCore=false] 本游戏长按是核心玩法(四向滑动类
* 游戏的帮助键,§4.3 裁定);false 时 longpress → invalid
* @param {boolean} [deps.opts.swipeCore=false] 四向滑动是本游戏核心玩法(§4.3 裁定):
* up/down 不按阶梯锁(它们是移动,不是帮助/菜单);阶梯只管 doubletap
*/
export function makeInputPipeline({ floor, confusion, receipts, invalidHints, game, ladder, opts = {} }) {
const lad = ladder || new GestureLadder();
const longpressCore = (opts && opts.longpressCore) === true;
const swipeCore = (opts && opts.swipeCore) === true;
let lastTapActedAt = -Infinity;
return (g, info) => {
if (g === 'longpressend' || g === 'tapcancel') return; // 手势收尾/撤销噪声,不吃回执
// ---- 1. 新手手势门(§4.3)----
if (g === 'doubletap' && !lad.has('doubletap')) g = 'tap'; // 双击按一次单击处理
if (!swipeCore && g === 'up' && !lad.has('up')) {
receipts.play('invalid');
invalidHints.explainOnce('locked:up', game.invalidHint ? game.invalidHint('locked', 'up') : null);
return;
}
if (!swipeCore && g === 'down' && !lad.has('down')) {
receipts.play('invalid');
invalidHints.explainOnce('locked:down', game.invalidHint ? game.invalidHint('locked', 'down') : null);
return;
}
if (g === 'longpress' && !longpressCore) {
receipts.play('invalid');
invalidHints.explainOnce('locked:longpress', game.invalidHint ? game.invalidHint('locked', 'longpress') : null);
return;
}
// ---- 2. 迷茫检测(§4.5)----
if (REAL_GESTURES.has(g)) confusion.record(g);
// ---- 3. 防连点(§4.2):tap 刚执行过「继续/跳过」后的 400ms 内,再来的 tap
// 只给 busy 回执(无论它将被解释成跳句还是继续——都不执行)----
if (g === 'tap' && floor.state !== 'ACTION' && performance.now() - lastTapActedAt < TAP_GUARD_MS) {
receipts.play('busy');
return;
}
// ---- 4. 发言权(§4.1)----
const preState = floor.state;
const pre = floor.handleInput(g);
if (pre === 'busy') { receipts.play('busy'); return; }
if (pre === 'consumed') {
receipts.play('ok'); // tap 跳句 = 一次「继续」,进防连点窗口
lastTapActedAt = performance.now();
return;
}
// ---- 5. 迷茫第 2 级:等待点只放行 expected(§4.5 缩小)----
const wp = floor.waitPoint;
if (wp && wp.restricted && !wp.allows(g)) {
receipts.play('invalid');
confusion.recordInvalid();
return;
}
// ---- 6. 游戏处理 + 回执收口 ----
const r = game.onGesture(g, info) || 'ok';
if (r === 'invalid') {
receipts.play('invalid');
const state = game.stateName ? game.stateName() : '';
invalidHints.explainOnce(`${state}:${g}`, game.invalidHint ? game.invalidHint(state, g) : null);
confusion.recordInvalid();
// 等待点仍在(无效操作没完成它):静默计时复位、回到等待态(含打断后的 BUSY)
if (floor.waitPoint && !floor.speaking) floor.settleWaitPoint(false);
return;
}
if (g === 'tap' && r !== 'hold' && preState !== 'ACTION') {
lastTapActedAt = performance.now(); // tap「继续」进防连点窗口
}
if (r !== 'silent-ok') receipts.play('ok');
if (floor.state === 'YOUR_TURN') floor.settleWaitPoint(r !== 'hold');
};
}
export default makeInputPipeline;
+61
View File
@@ -0,0 +1,61 @@
/**
* receipts.js — 输入回执三件套(26 号文档 §4.2,P0 第 2 项)。
*
* 每次玩家操作 100ms 内出声,告知「这下算没算」。三种回执(合成参数为文档起步值):
* | kind | 用在哪里 | 听感 |
* |----------|------------------------|----------------------|
* | ok | 操作被接受并生效 | 清脆的「嗒」 |
* | invalid | 收到了,但现在用不上 | 低沉柔和的「咚」 |
* | busy | 关键句在说 / 正在处理 | 很轻的「嗯」 |
*
* 规则(文档 §4.2 / 原则 4):
* - 回执由引擎统一播,游戏只告诉管道这次操作的结果是哪一种(见 pipeline.js)。
* - 游戏已为这次操作播了明显内容音(脚步、开门)时可以省 ok,invalid/busy 永远要播。
* - 音色避开 guide.js 的 turn(1318Hz 风铃)/ nudge(880Hz 木鱼);系统音一律合成音
* (原则 10),接 master 总线吃限幅、不被 BGM duck 压低(§4.11 优先级 1)。
* - 任何设备都出声,不依赖震动;震动由 gesture/haptics 照常叠加。
*/
/** 合成参数(26 号文档 §4.2 表:合成参数(起步值))。 */
const SPECS = {
// 三角波 1600Hz,35ms,起音 2ms,快速衰减,增益 0.08 —— 清脆的「嗒」
ok: { wave: 'triangle', from: 1600, to: 1600, dur: 0.035, attack: 0.002, gain: 0.08 },
// 正弦波 260Hz 滑到 200Hz,120ms,起音 10ms,增益 0.10 —— 低沉柔和的「咚」
invalid: { wave: 'sine', from: 260, to: 200, dur: 0.12, attack: 0.01, gain: 0.10 },
// 正弦波 520Hz,40ms,增益 0.04 —— 很轻的「嗯」
busy: { wave: 'sine', from: 520, to: 520, dur: 0.04, attack: 0.005, gain: 0.04 },
};
export class Receipts {
/** @param {import('./audio.js').WebAudioEngine} audio */
constructor(audio) {
this.audio = audio;
}
/**
* 播一种回执。未解锁 / 无 ctx(playtest mock)时静默跳过。
* @param {'ok'|'invalid'|'busy'} kind
*/
play(kind) {
const spec = SPECS[kind];
const ctx = this.audio && this.audio.ctx;
if (!spec || !ctx || !this.audio._master || !this.audio.unlocked) return;
const t0 = ctx.currentTime + 0.005;
const osc = ctx.createOscillator();
osc.type = spec.wave;
osc.frequency.setValueAtTime(spec.from, t0);
if (spec.to !== spec.from) osc.frequency.linearRampToValueAtTime(spec.to, t0 + spec.dur);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.linearRampToValueAtTime(spec.gain, t0 + spec.attack);
// 快速衰减:attack 后线性落到接近 0(听感接近指数衰减)
g.gain.linearRampToValueAtTime(spec.gain * 0.35, t0 + spec.attack + (spec.dur - spec.attack) * 0.45);
g.gain.linearRampToValueAtTime(0.0001, t0 + spec.dur);
osc.connect(g);
g.connect(this.audio._master);
osc.start(t0);
osc.stop(t0 + spec.dur + 0.02);
}
}
export default Receipts;