按 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。
643 lines
22 KiB
JavaScript
643 lines
22 KiB
JavaScript
/**
|
||
* 音序回响(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;
|
||
}
|
||
}
|