593 lines
22 KiB
JavaScript
593 lines
22 KiB
JavaScript
/**
|
||
* 音序回响(Simon)R2 —— 音频版西蒙记忆游戏(按 docs/design/02-simon-redesign.md 实现)。
|
||
*
|
||
* R2 要点:
|
||
* - 游玩全程无 BGM(用户硬性要求,02 文档既定):教学/对局/序列播放静默;
|
||
* 仅结算(gameover)与菜单挂 bgm/menu(volume 0.3,比别的游戏更轻,
|
||
* 10 号文档 §4 simon 特例表);单击/菜单重开时 stopBgm(300) 回静默。
|
||
* - 单击跳过旁白(12 号文档):voice 播放中单击 = audio.skipVoice()(截断当前句、
|
||
* 语音链继续跳下一句);无语音时单击 = 原语义。例外:教学"教一个练一个"的
|
||
* 等待输入阶段(teachAwait)单击仍是答题;playback 阶段手势静默忽略、菜单
|
||
* 单击循环菜单项(均维持原裁定)。开场 t0 附近插播一次 voice/skip_hint。
|
||
* - 旁白全部整句化:轮次 round_01..round_20 + round_more 兜底;成绩 score_band1..6
|
||
* 分段整句;删除 di/lun/num_0–9/score_pre/best_pre 拼接体系与旧教学连播。
|
||
* - 交互式教学(T0-T7):教一个音色练一次该手势,做对才教下一个;教学期下滑跳过。
|
||
* - 播放序列每音配 BEAT 轻震(节拍锚点);播完 400ms 给 CONFIRM 震动表示"轮到你了"
|
||
* (第 1 轮同时播 your_turn 语音)。
|
||
* - 复活机制:每局初始 1 次重听机会,每完成 4 轮 +1(上限 2)。输入阶段双击=主动
|
||
* 重听(耗机会);答错有机会则自动消耗重播同一序列(序列不重新随机)。
|
||
* - 反馈全部改震动:对/错 SUCCESS/ERROR,sfx/correct、wrong、newrecord 已删除;
|
||
* sfx/gameover 保留为内容性结束音。
|
||
*
|
||
* 设计冲突的处理(round3 synthesis G5 裁定,替代 round2 的双击语境判定):双击是
|
||
* 六种音色之一(序列可出现 doubletap),历史上又兼「主动重听」。round3 改全局规则:
|
||
* **input 阶段双击永远=作答(第 6 音色)**——期望恰为 doubletap 判对,否则照常判错,
|
||
* 不再承担重听;主动重听改绑**上滑**(期望输入恰为 up 时上滑=作答,六手势全是
|
||
* 答题输入的 00 §5.3 豁免同款)。非输入阶段行为不变(gameover/菜单上滑仍是规则/
|
||
* 帮助)。同一手势在 input 阶段不再有第二种含义(P1 round2 P0 根治)。
|
||
*
|
||
* 手势 → 音色映射:
|
||
* up 上行琶音 / down 下行低音 / left 左声道弹拨 / right 右声道弹拨 /
|
||
* tap 底鼓 / doubletap 双连响
|
||
*
|
||
* 语音 id 权威清单:docs/audio/round2-voice-ids.md。
|
||
* 震动分工:玩家手势的识别震动由 gesture.js 内置(swipe/confirm),本层只发
|
||
* 语义震动(beat/confirm/success/error/warning/pickup/hit)。
|
||
*/
|
||
import { Game } from '../engine/game.js';
|
||
import { Guide } from '../engine/guide.js';
|
||
import { vibrate } from '../engine/haptics.js';
|
||
|
||
const GESTURES = ['up', 'down', 'left', 'right', 'tap', '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',
|
||
};
|
||
|
||
// 教学语音映射(id 用 double,手势名为 doubletap)
|
||
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',
|
||
};
|
||
const TEACH_BLUNT = {
|
||
up: 'voice/t_blunt_up', down: 'voice/t_blunt_down', left: 'voice/t_blunt_left',
|
||
right: 'voice/t_blunt_right', tap: 'voice/t_blunt_tap', doubletap: 'voice/t_blunt_double',
|
||
};
|
||
|
||
const BEST_KEY = 'soundgame.simon.best';
|
||
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
|
||
|
||
// 向导层(14 号文档 + round2 synthesis F1.2):input 阶段静默看门狗——10s 轻提醒
|
||
// +gd_relisten(不改变重听扣机会规则);25s 升级取消自动重播序列(P1:泄题且与
|
||
// 扣机会规则矛盾)→ 改为 confirm 震 + turn earcon(纯标记,无语音)
|
||
const GUIDE_INPUT_WATCHDOG_AT = [10000, 25000];
|
||
|
||
export class SimonGame extends Game {
|
||
constructor(audio, root) {
|
||
super(audio, root);
|
||
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
|
||
this.phase = 'idle'; // idle | tutorial | announce | playback | input | roundend | gameover | menu
|
||
this.sequence = [];
|
||
this.round = 0; // 当前轮次(1 起),序列长度 = round + 1
|
||
this.inputIdx = 0;
|
||
this.chances = 1; // 重听机会(初始 1,每 4 轮 +1,上限 2)
|
||
this.best = this._loadBest(); // 历史最高(完成轮数),localStorage 持久
|
||
// 教学状态
|
||
this.teachIdx = 0;
|
||
this.teachAwait = false; // 是否正在等玩家做手势
|
||
this.teachErrs = 0;
|
||
// 菜单
|
||
this._menuIdx = 0;
|
||
this._session = 0; // 会话令牌:重开后使旧计时器/语音链回调失效
|
||
this._timers = [];
|
||
// G6(round3):轮结算链尾轻量反馈每局各限 1 次
|
||
this._clearSaid = false;
|
||
this._recordSaid = false;
|
||
}
|
||
|
||
start() {
|
||
// 预解码全部短音效与开场语音,消除首次播放延迟
|
||
this.audio.prewarm([
|
||
...GESTURES.map((g) => TONE[g]),
|
||
'sfx/gameover',
|
||
'voice/t0_welcome', 'voice/t0_goal', 'voice/skip_hint', 'voice/t_try', 'voice/round_01',
|
||
]);
|
||
this._timer(() => this._runTutorial(), 500); // 开场静默 0.5s 后进教学(无 BGM)
|
||
}
|
||
|
||
onGesture(g) {
|
||
this.guide.disarm(); // 任何手势复位 input 静默看门狗(guide=off 时 no-op)
|
||
// 单击跳过旁白(12 号文档):voice 播放中单击 = skipVoice()(截断当前句、
|
||
// 链播自然进下一句);无语音在播时单击 = 各阶段原语义。跳过不发震动。
|
||
if (g === 'tap' && this._tapSkipsVoice()) {
|
||
this.audio.skipVoice();
|
||
return;
|
||
}
|
||
switch (this.phase) {
|
||
case 'tutorial':
|
||
this._onTutorialGesture(g);
|
||
break;
|
||
case 'input':
|
||
this._onInput(g);
|
||
break;
|
||
case 'gameover':
|
||
if (g === 'tap') this._restart();
|
||
else if (g === 'up') this._speakRules();
|
||
else if (g === 'down') this._openMenu();
|
||
break;
|
||
case 'menu':
|
||
this._onMenuGesture(g);
|
||
break;
|
||
default:
|
||
// playback/announce/roundend:纯聆听阶段手势静默忽略(02 §4.1)
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 单击是否应判定为"跳过当前旁白句"。规则:有 voice 在播 + 当前阶段允许跳过。
|
||
* - tutorial:仅讲解/反馈语音播放中可跳;teachAwait(等玩家练手势)单击=答题;
|
||
* - announce/roundend/gameover:轮次/成绩/结算播报可跳(高轮次玩家刚需);
|
||
* - playback:维持"序列播放期手势静默忽略"裁定(含第 1 轮 your_turn);
|
||
* - input:答题阶段无语音;menu:单击循环菜单项是原语义,不拦截。
|
||
* 读引擎的 _voice 字段(只读)判断是否有对白在播;无公开 API,属既定约定。
|
||
*/
|
||
_tapSkipsVoice() {
|
||
if (!this.audio.isVoicePlaying()) return false; // 公开 API(原读私有 _voice,清点 §0)
|
||
switch (this.phase) {
|
||
case 'tutorial': return !this.teachAwait;
|
||
case 'announce':
|
||
case 'roundend':
|
||
case 'gameover': return true;
|
||
default: return false;
|
||
}
|
||
}
|
||
|
||
// ---------------- 交互式教学(T0-T7) ----------------
|
||
|
||
_runTutorial() {
|
||
this.phase = 'tutorial';
|
||
// t0 附近插播一次 skip_hint("单击可跳过旁白"),每局教学只此一次
|
||
this._speak(['voice/t0_welcome', 'voice/skip_hint', 'voice/t0_goal'], () => this._teachStep(0));
|
||
}
|
||
|
||
/** 教第 i 个手势:指令句 → 播音色 → "你试试。" → 等玩家做该手势。 */
|
||
_teachStep(i) {
|
||
this.teachIdx = i;
|
||
this.teachErrs = 0;
|
||
this.teachAwait = false;
|
||
const g = GESTURES[i];
|
||
const s = this._session;
|
||
this.audio.play(TEACH_SAY[g], {
|
||
onComplete: () => {
|
||
if (s !== this._session || this.phase !== 'tutorial') return;
|
||
this.audio.play(TONE[g]);
|
||
this._timer(() => {
|
||
if (this.phase !== 'tutorial') return;
|
||
this.audio.play('voice/t_try', {
|
||
onComplete: () => {
|
||
if (s === this._session && this.phase === 'tutorial') this.teachAwait = true;
|
||
},
|
||
});
|
||
// t_try 缺失兜底:1.5s 后直接开放输入
|
||
this._timer(() => {
|
||
if (this.phase === 'tutorial') this.teachAwait = true;
|
||
}, 1500);
|
||
}, 900); // 听完音色再请玩家试
|
||
},
|
||
});
|
||
}
|
||
|
||
_onTutorialGesture(g) {
|
||
// 下滑=跳过教学 与 T2"教下滑手势"冲突,按上下文判定(同双击裁定①的思路):
|
||
// 等待输入阶段且当前教的就是下滑时,下滑=答题;其余教学时刻下滑=跳过。
|
||
if (g === 'down' && !(this.teachAwait && GESTURES[this.teachIdx] === 'down')) {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.stopVoice();
|
||
this._speak(['voice/t_skip'], () => this._beginGame());
|
||
this._timer(() => { if (this.phase === 'tutorial') this._beginGame(); }, 4000); // 兜底
|
||
return;
|
||
}
|
||
if (!this.teachAwait) return; // 讲解播放中,忽略
|
||
const expected = GESTURES[this.teachIdx];
|
||
if (g === expected) {
|
||
this.teachAwait = false;
|
||
this.audio.play(TONE[g]); // 音色回响(识别震动引擎内置)
|
||
const s = this._session;
|
||
this.audio.play('voice/t_ok', {
|
||
onComplete: () => {
|
||
if (s !== this._session || this.phase !== 'tutorial') return;
|
||
if (this.teachIdx + 1 < GESTURES.length) {
|
||
this._teachStep(this.teachIdx + 1);
|
||
} else {
|
||
// T7:收尾两句 + 机会经济一句(round2 F5 → round3 G5 重录:记不住就
|
||
// 向上滑重新听、免费两次、用完扣机会——P1「经济不透明」开局一句话
|
||
// 讲清,且与新手势映射一致)→ 正式对局
|
||
this._speakGuarded(['voice/t7_ready', 'voice/t7_retry', 'voice/gd_economy'],
|
||
() => this._beginGame(), 20000);
|
||
}
|
||
},
|
||
});
|
||
} else {
|
||
this.teachErrs++;
|
||
vibrate('error');
|
||
if (this.teachErrs >= 3) {
|
||
vibrate('warning');
|
||
this.audio.play(TEACH_BLUNT[expected]); // 错 3 次给更直白描述
|
||
this.teachErrs = 0;
|
||
} else {
|
||
this.audio.play(TEACH_WRONG[expected]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------- 对局流程 ----------------
|
||
|
||
_beginGame() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.stopVoice(); // 打断残留语音链(onComplete 不触发,链自然作废)
|
||
this.sequence = [];
|
||
this.round = 0;
|
||
this.chances = 1;
|
||
this._clearSaid = false; // G6:每局重新封顶
|
||
this._recordSaid = false;
|
||
this._nextRound();
|
||
}
|
||
|
||
_restart() {
|
||
this.audio.stopBgm(300); // 停 menu pad,回静默(10 号文档 simon 表)
|
||
this.audio.play('sfx/tone_tap'); // 重开确认音
|
||
this._beginGame(); // 重开跳过教学
|
||
}
|
||
|
||
_nextRound() {
|
||
const s = this._session;
|
||
this.round++;
|
||
if (this.round === 1) {
|
||
this._pushGesture(); // 长度 = round + 1 = 2
|
||
this._pushGesture();
|
||
} else {
|
||
this._pushGesture();
|
||
}
|
||
this.phase = 'announce';
|
||
// 轮次整句:round_01..round_20,超过 20 轮用 round_more 兜底
|
||
const id = this.round <= 20
|
||
? `voice/round_${String(this.round).padStart(2, '0')}`
|
||
: 'voice/round_more';
|
||
let fired = false; // onComplete 与看门狗只准进一次 playback
|
||
const fire = () => {
|
||
if (fired || s !== this._session || this.phase !== 'announce') return;
|
||
fired = true;
|
||
this._playSequence();
|
||
};
|
||
this.audio.play(id, { onComplete: fire });
|
||
// 语音缺失兜底:2.5s 后直接播放序列
|
||
this._timer(fire, 2500);
|
||
}
|
||
|
||
/**
|
||
* 追加一个手势到序列尾部(02 §6.2 约束):
|
||
* - 第 1 轮两个音不同手势;
|
||
* - 前 5 轮禁止同一手势连续 3 次;
|
||
* - 全部轮次禁止连续 4 个同手势。
|
||
*/
|
||
_pushGesture() {
|
||
const seq = this.sequence;
|
||
let cand = GESTURES[0];
|
||
for (let tries = 0; tries < 20; tries++) {
|
||
cand = GESTURES[Math.floor(Math.random() * GESTURES.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;
|
||
}
|
||
|
||
/** 播放间隔:max(420, 700 - (round-1)*26),第 12 轮起到 420ms 下限(02 §6.1 + 13 号验收)。 */
|
||
_interval() {
|
||
return Math.max(420, 700 - (this.round - 1) * 26);
|
||
}
|
||
|
||
_playSequence() {
|
||
this.phase = 'playback';
|
||
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'); // 节拍锚点(02 §4.1)
|
||
i++;
|
||
this._timer(step, this._interval());
|
||
} else {
|
||
this._timer(() => {
|
||
if (s !== this._session || this.phase !== 'playback') return;
|
||
vibrate('confirm'); // "轮到你了"
|
||
this.guide.turn({}); // turn earcon 每轮泛化(第 1 轮另有 your_turn 语音,下行)
|
||
if (this.round === 1) {
|
||
this.audio.play('voice/your_turn', {
|
||
onComplete: () => {
|
||
if (s === this._session && this.phase === 'playback') this._toInput();
|
||
},
|
||
});
|
||
this._timer(() => { if (this.phase === 'playback') this._toInput(); }, 2500); // 兜底
|
||
} else {
|
||
this._toInput();
|
||
}
|
||
}, 400); // 最后一个音播完 400ms 后
|
||
}
|
||
};
|
||
step();
|
||
}
|
||
|
||
_toInput() {
|
||
this.phase = 'input';
|
||
this.inputIdx = 0;
|
||
// 向导层静默看门狗(round2 F1.2 礼仪重构):input 阶段无 BGM 无超时,挂机
|
||
// 永久静默。10s → nudge + gd_relisten 提示(不改变重听扣机会规则);25s 升级
|
||
// **取消免扣机会自动重播序列**(P1:等答案自动重念,双击重听的经济就矛盾了,
|
||
// 还顺手拆了记忆难度)→ 只发 confirm 震 + turn earcon(纯标记,无语音)。
|
||
this.guide.watchdog({
|
||
arm: true,
|
||
at: GUIDE_INPUT_WATCHDOG_AT,
|
||
say: ['gd_relisten', null],
|
||
onEscalate: (n) => { if (n === 2) this.guide.turn({ haptic: true }); },
|
||
cond: () => this.phase === 'input',
|
||
});
|
||
}
|
||
|
||
_onInput(g) {
|
||
// G5(round3):双击永远=作答(第 6 音色)——期望恰为 doubletap 判对,否则
|
||
// 照常判错,不再承担重听。主动重听改绑上滑:期望输入恰为 up 时上滑=作答
|
||
// (六手势全是答题输入,00 §5.3 豁免同款),其余时刻上滑=主动重听(耗机会)。
|
||
if (g === 'up' && this.sequence[this.inputIdx] !== 'up') {
|
||
this._relisten();
|
||
return;
|
||
}
|
||
// 立即回声放玩家手势的音色(sfx 低延迟;识别震动引擎内置,不重复震)
|
||
this.audio.play(TONE[g]);
|
||
if (g !== this.sequence[this.inputIdx]) {
|
||
this._wrongAnswer();
|
||
return;
|
||
}
|
||
this.inputIdx++;
|
||
if (this.inputIdx >= this.sequence.length) this._roundClear();
|
||
}
|
||
|
||
/** 主动重听:消耗一次机会,重播当前序列(进度清零重输,序列不变)。 */
|
||
_relisten() {
|
||
if (this.chances <= 0) {
|
||
vibrate('error');
|
||
this.audio.play('voice/chances_out');
|
||
return;
|
||
}
|
||
this.chances--;
|
||
vibrate('pickup');
|
||
const ids = ['voice/relisten'];
|
||
if (this.chances === 0) ids.push('voice/chances_out'); // 机会归 0 提示
|
||
const s = this._session;
|
||
this.phase = 'roundend'; // 重听播报期间暂停接收输入
|
||
this._speakGuarded(ids, () => { if (s === this._session) this._playSequence(); });
|
||
}
|
||
|
||
_roundClear() {
|
||
this.phase = 'roundend';
|
||
const s = this._session;
|
||
vibrate('success');
|
||
const ids = [];
|
||
if (this.round % 4 === 0) ids.push('voice/correct'); // 每 4 轮播一次,其余仅震动
|
||
// 每完成 4 轮机会 +1(上限 2)并播报
|
||
if (this.round % 4 === 0 && this.chances < 2) {
|
||
this.chances++;
|
||
vibrate('pickup');
|
||
ids.push('voice/chance_plus');
|
||
}
|
||
// G6(round3):轮结算链尾轻量反馈——gd_clear「漂亮,过了。」每局 1 次;
|
||
// 本轮完成数超过历史最佳(且确有旧纪录可破)时追加 gd_record「破纪录了。」
|
||
// 每局 1 次(结算另有 new_record,此处是局中破纪录的即时一刻)。游戏规则句
|
||
// 随链播(全档位,gd_economy 先例),不走向导通道。
|
||
if (!this._clearSaid) {
|
||
this._clearSaid = true;
|
||
ids.push('voice/gd_clear');
|
||
}
|
||
if (!this._recordSaid && this.best > 0 && this.round > this.best) {
|
||
this._recordSaid = true;
|
||
ids.push('voice/gd_record');
|
||
}
|
||
this._speakGuarded(ids, () => {
|
||
if (s !== this._session) return;
|
||
this._timer(() => this._nextRound(), 300);
|
||
});
|
||
}
|
||
|
||
_wrongAnswer() {
|
||
if (this.chances > 0) {
|
||
// 答错且有机会:自动消耗,重播同一序列再试(02 §4.2)
|
||
this.chances--;
|
||
vibrate('error');
|
||
this._timer(() => vibrate('warning'), 300);
|
||
const ids = ['voice/retry_use'];
|
||
if (this.chances === 0) ids.push('voice/chances_out');
|
||
const s = this._session;
|
||
this.phase = 'roundend';
|
||
this._speakGuarded(ids, () => { if (s === this._session) this._playSequence(); });
|
||
return;
|
||
}
|
||
this._gameOver();
|
||
}
|
||
|
||
_gameOver() {
|
||
this.phase = 'gameover';
|
||
this.guide.state('settle'); // 状态签名:结算(与下方 bgm/menu 同点)
|
||
const s = this._session;
|
||
const score = this.round - 1; // 已完成的轮数
|
||
const isRecord = score > this.best;
|
||
if (isRecord) {
|
||
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 ids = ['voice/gameover', this._scoreBandVoice(score)];
|
||
if (isRecord) ids.push('voice/new_record');
|
||
ids.push('voice/replay', 'voice/rules_prompt');
|
||
this._timer(() => {
|
||
if (s === this._session) this._speak(ids, null); // 链播完停在 gameover,等手势
|
||
}, 1400);
|
||
|
||
// 状态 BGM(10 号文档 simon 表):sfx/gameover(约 0.95s,+700ms 起播)
|
||
// 播完 1s 后淡入 menu pad 1000ms,音量 0.3。若玩家已进菜单/重开则不补播。
|
||
this._timer(() => {
|
||
if (s === this._session && this.phase === 'gameover') {
|
||
this.audio.play('bgm/menu', { volume: 0.3, fadeMs: 1000 });
|
||
}
|
||
}, 700 + 950 + 1000);
|
||
}
|
||
|
||
/** 成绩分段整句:0 / 1-3 / 4-6 / 7-9 / 10-14 / ≥15(02 §3.2)。 */
|
||
_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';
|
||
}
|
||
|
||
_speakRules() {
|
||
this._speak(['voice/rules', 'voice/rules_prompt'], null);
|
||
}
|
||
|
||
// ---------------- 菜单 ----------------
|
||
|
||
_openMenu() {
|
||
this._menuIdx = 0;
|
||
this.phase = 'menu';
|
||
this.guide.state('menu'); // 状态签名:菜单(与下方 bgm/menu 同点)
|
||
// 状态 BGM:菜单挂 menu pad(volume 0.3,淡入 600ms;与结算同一首,引擎不重启)
|
||
this.audio.play('bgm/menu', { volume: 0.3, fadeMs: 600 });
|
||
this._speak(['voice/menu_open', 'voice/menu_restart'], null);
|
||
}
|
||
|
||
_onMenuGesture(g) {
|
||
const items = ['voice/menu_restart', 'voice/menu_home', 'voice/menu_back'];
|
||
if (g === 'tap') {
|
||
this._menuIdx = (this._menuIdx + 1) % items.length;
|
||
this.audio.play(items[this._menuIdx]);
|
||
} else if (g === 'up') {
|
||
this._speakRules();
|
||
} else if (g === 'doubletap') {
|
||
const sel = this._menuIdx;
|
||
if (sel === 0) this._restart();
|
||
else if (sel === 1) { this.stop(); window.location.href = '../'; }
|
||
else this.phase = 'gameover'; // 返回:回到结算页
|
||
}
|
||
}
|
||
|
||
// ---------------- 成绩存档 ----------------
|
||
|
||
_loadBest() {
|
||
try {
|
||
return Number(localStorage.getItem(BEST_KEY)) || 0;
|
||
} catch (_) {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/** 破纪录:写 localStorage + (已登录时)同步服务器。 */
|
||
_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 (_) { /* 静默 */ }
|
||
}
|
||
|
||
// ---------------- 工具 ----------------
|
||
|
||
/** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */
|
||
_speak(ids, onDone) {
|
||
if (!ids.length) { if (onDone) onDone(); return; }
|
||
const s = this._session;
|
||
const [head, ...rest] = ids;
|
||
this.audio.play(head, {
|
||
onComplete: () => {
|
||
if (s !== this._session) return;
|
||
this._speak(rest, onDone);
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 带看门狗的语音链:链自然播完或 guardMs 超时(语音缺失/解码失败),
|
||
* 二者先到先触发 onDone 且只触发一次——保证对局流程绝不卡死。
|
||
*/
|
||
_speakGuarded(ids, onDone, guardMs = 10000) {
|
||
const s = this._session;
|
||
let fired = false;
|
||
const fire = () => {
|
||
if (fired || s !== this._session) return;
|
||
fired = true;
|
||
if (onDone) onDone();
|
||
};
|
||
this._speak(ids, fire);
|
||
this._timer(fire, guardMs);
|
||
}
|
||
|
||
_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;
|
||
}
|
||
|
||
stop() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.stopAll();
|
||
}
|
||
}
|