Files
xsl ee9d7efe57 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。
2026-10-01 08:11:12 +08:00

643 lines
22 KiB
JavaScript
Raw Permalink 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.
/**
* 音序回响(Simon)R3 —— 音频记忆游戏,按 docs/design/26-audio-interaction-playbook.md 全面重构。
*
* 交互层(对齐 26 号文档,设计单见 docs/design/26-apply-simon.md):
* - 手势宪法(§4.3):doubleTapWindow=0;双击未解锁按单击处理。全部六个手势在本作
* 都是「音色」(答题输入)——按 §4.3「滑动是玩法」裁定,帮助(状态查询)在长按。
* - 音色按玩家实力渐进开放(做中学,原则 11):新手只玩 3 个音(单击/左滑/右滑,
* F3「选项不超过 3 个」);清过第 1 轮 +上滑音、清过第 3 轮 +下滑音、清过第 6 轮
* +双击音。新音色第一次出现时现场教一句(「向左滑,是这个声音。」)马上用。
* - 失败表(§4.9 / 原则 15,替代旧「机会账」经济):答错 2 秒内归因
* 「错了。是{X}。」→ 免费重听整串;第 3 次失败「我放慢一些。」(放慢+缩短);
* 第 4 次才结算。新手期失败零消耗。
* - 答题等待 = 发言权 YOUR_TURN(§4.1):交接提示音 + 等待声;静默三档提示
* (轮到你了 → 提示音);迷茫三级接管(L1 短句 → L2 免费重听 → L3 放慢演示)。
* - 序列播放 = BUSY:手势给 busy 回执(旧版静默吞手势是「点了没反应」重灾区)。
* - 长按 = 状态查询(一句话:轮次段位 + 重听余额,原则 3)。
* - 阶段 3 解锁双击后:非答题时双击 = 重听最近播报(「再说一遍:」)。
*
* 玩法核心不变:听一串音色 → 原样复现;每轮 +1 音;间隔渐快。
*/
import { Game } from '../engine/game.js';
import { Floor } from '../engine/floor.js';
import { Receipts } from '../engine/receipts.js';
import { Confusion } from '../engine/confusion.js';
import { WaitPoint, InvalidHints, markHintSaid, hintSaidRecently } from '../engine/hints.js';
import { GestureLadder } from '../engine/novice.js';
import { vibrate } from '../engine/haptics.js';
// 全部音色(解锁顺序 = 玩法难度阶梯)
const ALL_TONES = ['tap', 'left', 'right', 'up', 'down', 'doubletap'];
const TONE = {
up: 'sfx/tone_up',
down: 'sfx/tone_down',
left: 'sfx/tone_left',
right: 'sfx/tone_right',
tap: 'sfx/tone_tap',
doubletap: 'sfx/tone_doubletap',
};
// 音色教学句(新音色第一次出现时现场教一句,马上用——原则 11)
const TEACH_SAY = {
up: 'voice/t1_up', down: 'voice/t2_down', left: 'voice/t3_left',
right: 'voice/t4_right', tap: 'voice/t5_tap', doubletap: 'voice/t6_double',
};
// 教学期答错纠错句(「不是这个,向左滑试试。」)
const TEACH_WRONG = {
up: 'voice/t_wrong_up', down: 'voice/t_wrong_down', left: 'voice/t_wrong_left',
right: 'voice/t_wrong_right', tap: 'voice/t_wrong_tap', doubletap: 'voice/t_wrong_double',
};
// 失败归因句(失败表:错了。是{X}。——2 秒内播出,§4.9)
const WRONGLY = {
up: 'voice/wr_up', down: 'voice/wr_down', left: 'voice/wr_left',
right: 'voice/wr_right', tap: 'voice/wr_tap', doubletap: 'voice/wr_dt',
};
const BEST_KEY = 'soundgame.simon.best';
const AUTH_KEY = 'soundgame.auth';
// 表扬句池(每轮答对必播一句,≤4 字原则 11)
const PRAISE = ['voice/correct', 'voice/gd_clear'];
// 音色解锁阈值:按历史最佳轮数(本作自己的难度阶梯,与全局手势阶梯并行)
const TONE_UNLOCK_AT = { up: 1, down: 3, doubletap: 6 };
// 答题等待点静默阈值
const INPUT_SILENCE = [10000, 25000];
export class SimonGame extends Game {
constructor(audio, root) {
super(audio, root);
this.receipts = new Receipts(audio);
this.floor = new Floor(audio);
this.confusion = new Confusion({ onConfused: (lvl) => this._takeover(lvl) });
this.invalidHints = new InvalidHints().bind(this.floor);
this.ladder = new GestureLadder();
this.audio.setRateBoostEnabled(false); // 新手期长按不快进(长按=状态查询)
this.phase = 'idle'; // idle | tutorial | playback | input | roundend | gameover | menu
this.sequence = [];
this.round = 0;
this.inputIdx = 0;
this.best = this._loadBest();
this.failCount = 0; // 本轮失败计数(失败表)
this.relistensUsed = 0; // 本轮主动重听次数
this.slowFactor = 1; // 失败 3 次后的放慢系数
this._lastAnnounce = null; // 最近播报句(双击重听用)
this._newToneTaught = new Set();
this._taughtTones = new Set(); // 教学已教会(含 t0 第一击的 tap)
this._teaching = null;
this._menuIdx = 0;
this._session = 0;
this._timers = [];
}
/** 管道选项:长按是本作核心手势(状态查询);四向+双击都是答题音色。 */
get PIPELINE_OPTS() { return { longpressCore: true, swipeCore: true }; }
start() {
this.audio.prewarm([
...ALL_TONES.map((g) => TONE[g]),
'sfx/gameover', 'sfx/input_open',
'voice/t0_new', 'voice/t5_tap', 'voice/t3_left', 'voice/t4_right', 'voice/t_try',
'voice/t7_new', 'voice/round_01',
]);
if (this.best > 0) {
this._beginGame(); // 老玩家跳过教学(做对过的事不教第二遍,原则 11)
} else {
this._runTutorial();
}
}
stop() {
this._session++;
this._clearTimers();
this.floor.interrupt();
this.audio.stopAll();
}
// ---------------- 手势(pipeline 之后) ----------------
onGesture(g) {
switch (this.phase) {
case 'tutorial': return this._onTutorialGesture(g);
case 'input': return this._onInput(g);
case 'gameover': return this._onGameoverGesture(g);
case 'menu': return this._onMenuGesture(g);
default:
// playback/roundend = floor BUSY:手势统一 busy 回执(原则 4),
// 不会走到这里;保守返回 invalid。
return 'invalid';
}
}
stateName() {
return this.phase === 'menu' ? 'menu' : this.phase;
}
invalidHint(state, g) {
if (state === 'locked') return 'voice/inv_generic';
if (state === 'menu') return 'voice/menu_help';
return 'voice/inv_busy'; // 播放期只有 busy 回执 + 一句解释
}
// ---------------- 交互式教学(§4.10 首 3 分钟:3 秒内第一下、10 秒内第一次成功) ----------------
_runTutorial() {
this.phase = 'tutorial';
// 0~3 秒:第一句就让玩家动手
this.floor.speak(['voice/t0_new'], {
onDone: () => this.floor.yieldTurn(this._wp({
id: 'tut0', expected: ['tap'],
full: null, short: 'voice/t_retry',
silenceMs: [6000, 15000],
onDemo: () => this.floor.chime(),
})),
});
}
/** 教一个音色:[肯定句] + 指令句(critical,<3s/句)→ 音色 → 交话轮等玩家做。 */
_teachTone(g, withPraise) {
this._teaching = g;
this.guideErrs = 0;
markHintSaid(TEACH_SAY[g]); // 台账:静默复述降档(原则 9)
const chain = withPraise ? ['voice/t_ok', TEACH_SAY[g]] : [TEACH_SAY[g]];
this.floor.speak(chain, {
critical: true, // 教学指令 = 规则的唯一一次说明(§4.6),不可打断
onDone: () => {
this.audio.play(TONE[g]);
// 指令说完就能做(原则 11 教完马上用):先交话轮,再跟进一句「你试试」
this.floor.yieldTurn(this._wp({
id: `tut_${g}`, expected: [g],
full: TEACH_SAY[g], short: 'voice/t_retry',
silenceMs: [6000, 15000],
onDemo: () => this.floor.chime(),
}));
this._timer(() => {
if (this._teaching !== g || this.phase !== 'tutorial') return;
this.floor.speak(['voice/t_try'], {
kind: 'hint',
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
}, 900 + chain.length * 2000);
},
});
}
/** 教学推进(幂等):已教过的音色不再教第二遍(原则 11)。 */
_teachNext() {
const order = ['left', 'right', 'tap'];
const done = this._taughtTones;
const next = order.find((g) => !done.has(g));
if (!next) {
this._teaching = null;
this.floor.speak(['voice/t7_new'], {
critical: true,
onDone: () => this._beginGame(),
});
return;
}
this._teachTone(next, done.size > 0);
}
_onTutorialGesture(g) {
if (!this._teaching) {
// t0 阶段:等第一下单击(对错都进第一个音教学,第一下必有收获——原则 13)
if (g === 'tap' && this._taughtTones.size === 0) {
this._taughtTones.add('tap'); // 第一击本身已听过单击音色
this.audio.play(TONE.tap);
this._teachTone('left', false);
return 'silent-ok';
}
return 'invalid';
}
if (g === this._teaching) {
this.audio.play(TONE[g]); // 音色回响即反馈
this._taughtTones.add(g);
const cur = this._teaching;
this._teaching = null;
this._teachNext();
return 'silent-ok';
}
vibrate('error');
const wid = TEACH_WRONG[this._teaching];
this.guideErrs++;
// 原则 7:同一句纠错 30s 内只说一次,之后只给回执(3 次起完全静默交给接管)
if (this.guideErrs < 3 && !hintSaidRecently(wid)) {
markHintSaid(wid);
this.floor.speak([wid], {
kind: 'feedback',
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
}
return 'invalid';
}
// ---------------- 对局 ----------------
/** 当前解锁的音色池(按历史最佳轮数渐进,原则 11「一次只学一样」)。 */
_tones() {
return ALL_TONES.filter((g) => this.best >= (TONE_UNLOCK_AT[g] || 0));
}
_beginGame() {
this._session++;
this._clearTimers();
this.floor.interrupt();
this.sequence = [];
this.round = 0;
this.failCount = 0;
this.slowFactor = 1;
this._startBest = this.best; // 破纪录按开局时的历史最佳判定
this._newToneTaught.clear();
this._taughtTones.clear();
this._teaching = null;
this._nextRound();
}
_restart() {
this.audio.stopBgm(300);
this.audio.play('sfx/tone_tap');
this._beginGame();
}
_nextRound() {
const s = this._session;
this.round++;
this.failCount = 0;
this.relistensUsed = 0;
if (this.round === 1) this._pushGesture(); // 第 1 轮 2 个音
this._pushGesture();
this.phase = 'roundend'; // 播报期
const id = this.round <= 20
? `voice/round_${String(this.round).padStart(2, '0')}`
: 'voice/round_more';
// 新解锁音色第一次进序列:现场教一句,马上用(原则 11)
const teach = this.sequence.filter((g) => TONE_UNLOCK_AT[g] > 0
&& this.best >= TONE_UNLOCK_AT[g]
&& (this.best - TONE_UNLOCK_AT[g]) < 1 // 刚跨过阈值
&& !this._newToneTaught.has(g));
const ids = [id];
for (const g of teach) {
this._newToneTaught.add(g);
ids.push(TEACH_SAY[g]);
}
// 第一个小目标 / 一局完成 → 全局手势阶梯(教学句挂链尾)
if (this.round === 2 && this.best === 0) this.ladder.onGoal();
if (this.round === 4) {
const got = this.ladder.onGameDone();
if (got) ids.push(this.ladder.teachLine(got));
}
this._lastAnnounce = ids[0];
this.floor.speak(ids, {
onDone: () => { if (s === this._session) this._playSequence(); },
});
// 语音缺失兜底:直接播序列
this._timer(() => { if (s === this._session && this.phase === 'roundend') this._playSequence(); }, 2500 * ids.length);
}
_pushGesture() {
const pool = this._tones();
const seq = this.sequence;
let cand = pool[0];
for (let tries = 0; tries < 20; tries++) {
cand = pool[Math.floor(Math.random() * pool.length)];
if (!this._violates(seq, cand)) break;
}
seq.push(cand);
}
_violates(seq, cand) {
const n = seq.length;
if (this.round === 1 && n === 1 && seq[0] === cand) return true;
if (this.round <= 5 && n >= 2 && seq[n - 1] === cand && seq[n - 2] === cand) return true;
if (n >= 3 && seq[n - 1] === cand && seq[n - 2] === cand && seq[n - 3] === cand) return true;
return false;
}
_interval() {
return Math.max(420, 700 - (this.round - 1) * 26) * this.slowFactor;
}
_playSequence() {
this.phase = 'playback'; // BUSY:手势 → busy 回执(原则 4)
const s = this._session;
let i = 0;
const step = () => {
if (s !== this._session || this.phase !== 'playback') return;
if (i < this.sequence.length) {
this.audio.play(TONE[this.sequence[i]]);
vibrate('beat');
i++;
this._timer(step, this._interval());
} else {
this._timer(() => {
if (s !== this._session || this.phase !== 'playback') return;
vibrate('confirm');
this._toInput();
}, 400);
}
};
step();
}
_toInput() {
this.phase = 'input';
this.inputIdx = 0;
this.audio.play('sfx/input_open'); // 「现在能答了」开放标记
this.floor.yieldTurn(this._inputWp());
}
_inputWp() {
return this._wp({
id: 'input',
expected: ALL_TONES, // 答题输入全开(ACTION 语义)
full: 'voice/your_turn',
short: null, // 第二档只给提示音
silenceMs: INPUT_SILENCE,
});
}
_onInput(g) {
// 长按 = 状态查询(一句话,不闭答题窗口:玩家可以边听边答)
if (g === 'longpress') {
this._statusQuery();
return 'hold';
}
if (!TONE[g] || !this.sequence.includes(g) && !this._tones().includes(g)) {
return 'invalid'; // 音色不在本局池(如双击已全局解锁但本作还没到第 6 音)
}
this.audio.play(TONE[g]); // 立即回声(内容音即反馈)
if (g !== this.sequence[this.inputIdx]) {
this._wrongAnswer();
return 'silent-ok';
}
this.inputIdx++;
if (this.inputIdx >= this.sequence.length) this._roundClear();
return 'silent-ok';
}
/** 长按状态查询(原则 3:一句话,只说最有用的一件事 = 轮次段位)。 */
_statusQuery() {
const r = this.round;
const band = r <= 4 ? 'voice/st_b1' : r <= 9 ? 'voice/st_b2' : r <= 15 ? 'voice/st_b3' : 'voice/st_b4';
const resume = () => {
if (this.phase === 'input') this.floor.yieldTurn(this._inputWp()); // 重挂(答题继续)
else if (this.floor.waitPoint) this.floor.resumeTurn();
};
this.floor.speak([band], { kind: 'hint', onDone: resume });
return 'hold';
}
_roundClear() {
this.phase = 'roundend';
const s = this._session;
vibrate('success');
const ids = [PRAISE[(this.round - 1) % PRAISE.length]];
if (this.round > this.best) {
this.best = this.round;
this._saveBest();
if (this.round > this._startBest) ids.push('voice/gd_record'); // 破开局纪录(局中一刻)
}
this.floor.speak(ids, {
onDone: () => { if (s === this._session) this._timer(() => this._nextRound(), 300); },
});
this._timer(() => { if (s === this._session && this.phase === 'roundend') this._nextRound(); }, 2500 * ids.length + 1500);
}
/** 失败表(§4.9):归因(2 秒内)→ 免费重听;第 3 次放慢;第 4 次结算。 */
_wrongAnswer() {
const s = this._session;
const expected = this.sequence[this.inputIdx];
this.failCount++;
vibrate('error');
this.inputIdx = 0;
this.phase = 'roundend';
if (this.failCount >= 4) {
this._gameOver();
return;
}
const ids = [WRONGLY[expected] || 'voice/wr_tap', 'voice/relisten'];
if (this.failCount === 3) {
this.slowFactor = 1.6; // 我放慢一些(原则 15)
if (this.sequence.length > 2) this.sequence.pop(); // 缩短一音降难度
ids.unshift('voice/slow_down');
}
this.floor.speak(ids, {
onDone: () => { if (s === this._session) this._playSequence(); },
});
this._timer(() => { if (s === this._session && this.phase === 'roundend') this._playSequence(); }, 2500 * ids.length);
}
_gameOver() {
this.phase = 'gameover';
const s = this._session;
const score = this.round - 1;
if (score > this.best) {
this.best = score;
this._saveBest();
}
vibrate('error');
this._timer(() => vibrate('hit'), 300);
this._timer(() => { if (s === this._session) this.audio.play('sfx/gameover'); }, 700);
const got = this.ladder.onGameDone();
const head = ['voice/gameover', this._scoreBandVoice(score)];
if (score === this.best && score > 0) head.push('voice/new_record');
const tail = ['voice/replay'];
if (got) tail.push(this.ladder.teachLine(got));
// §4.6:只把承诺段(「单击,再来一局。」+解锁教学,各 <3s)标 critical
this._timer(() => {
if (s !== this._session) return;
this.floor.interrupt();
this.floor.speak(head, {
onDone: () => this.floor.speak(tail, {
critical: true,
onDone: () => this.floor.yieldTurn(this._settleWp()),
}),
});
}, 1400);
this._timer(() => {
if (s === this._session && this.phase === 'gameover') {
this.audio.play('bgm/menu', { volume: 0.3, fadeMs: 1000 });
}
}, 700 + 950 + 1000);
this._lastAnnounce = 'voice/replay';
}
_onGameoverGesture(g) {
switch (g) {
case 'tap':
this._restart();
return 'ok';
case 'longpress': {
// 重听结算
this.floor.speak(['voice/sys_say_again', this._lastAnnounce || 'voice/gameover'], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'ok';
}
case 'down':
this._openMenu();
return 'ok';
case 'doubletap':
// 阶段 3:重听最近播报
this.floor.speak(['voice/sys_say_again', this._lastAnnounce || 'voice/replay'], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
default:
return 'invalid';
}
}
_settleWp() {
return this._wp({
id: 'settle', expected: ['tap'],
full: 'voice/replay', short: 'voice/t_retry',
silenceMs: [20000, 45000],
onDemo: () => this.floor.chime(),
});
}
/** 迷茫三级接管(§4.5,答题等待点):L1 短句 → L2 免费重听 → L3 放慢演示。 */
_takeover(level) {
if (this.phase !== 'input') return;
if (level === 1) {
this.floor.speak(['voice/your_turn'], {
kind: 'hint',
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
} else if (level === 2) {
this.floor.speak(['voice/relisten'], {
kind: 'hint',
onDone: () => this._playSequence(),
});
} else if (level === 3) {
this.slowFactor = Math.max(this.slowFactor, 1.6);
this.floor.speak(['voice/sys_demo', 'voice/relisten'], {
kind: 'hint',
onDone: () => this._playSequence(),
});
}
}
// ---------------- 菜单 ----------------
_openMenu() {
this.phase = 'menu';
this._menuIdx = 0;
this.floor.interrupt();
this.audio.play('bgm/menu', { volume: 0.3, fadeMs: 600 });
this.floor.speak(['voice/menu_open', 'voice/menu_item1'], {
onDone: () => this.floor.yieldTurn(this._wp({
id: 'menu', expected: ['tap', 'left', 'right'],
full: 'voice/menu_help', short: 'voice/menu_help',
silenceMs: [20000, 45000],
onDemo: () => this.floor.chime(),
})),
});
}
_onMenuGesture(g) {
const items = ['voice/menu_item1', 'voice/menu_item2', 'voice/menu_item3'];
const announce = () => {
this.floor.speak([items[this._menuIdx]], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
};
switch (g) {
case 'tap':
this._menuIdx = (this._menuIdx + 1) % items.length;
announce();
return 'hold';
case 'left':
this._menuIdx = (this._menuIdx + items.length - 1) % items.length;
announce();
return 'hold';
case 'right': {
const sel = this._menuIdx;
if (sel === 0) this._restart();
else if (sel === 1) { this.stop(); window.location.href = '../'; }
else this._backToSettle();
return 'ok';
}
case 'longpress':
this.floor.speak(['voice/menu_help'], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
default:
return 'invalid';
}
}
_backToSettle() {
this.phase = 'gameover';
this.floor.yieldTurn(this._settleWp());
}
// ---------------- 成绩存档 ----------------
_loadBest() {
try {
return Number(localStorage.getItem(BEST_KEY)) || 0;
} catch (_) {
return 0;
}
}
_saveBest() {
try { localStorage.setItem(BEST_KEY, String(this.best)); } catch (_) { /* 隐私模式 */ }
try {
const auth = JSON.parse(localStorage.getItem(AUTH_KEY));
if (!auth || !auth.token) return;
fetch('/api/progress', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + auth.token,
},
body: JSON.stringify({
game: 'simon',
data: {
summary: `最佳成绩:第 ${this.best} 轮`,
bestRound: this.best,
schema: 1,
updatedAt: new Date().toISOString(),
},
}),
}).catch(() => { /* 离线静默 */ });
} catch (_) { /* 静默 */ }
}
_scoreBandVoice(score) {
if (score <= 0) return 'voice/score_band1';
if (score <= 3) return 'voice/score_band2';
if (score <= 6) return 'voice/score_band3';
if (score <= 9) return 'voice/score_band4';
if (score <= 14) return 'voice/score_band5';
return 'voice/score_band6';
}
// ---------------- 工具 ----------------
_wp(conf) {
return new WaitPoint(conf, { floor: this.floor, confusion: this.confusion });
}
_timer(fn, ms) {
const s = this._session;
const id = setTimeout(() => { if (s === this._session) fn(); }, ms);
this._timers.push(id);
return id;
}
_clearTimers() {
for (const id of this._timers) clearTimeout(id);
this._timers.length = 0;
}
}