/** * 音序回响(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 阶段手势静默忽略、菜单 * 单击循环菜单项(均维持原裁定);**结算(gameover)单击不可跳句**——播报 * 承诺「单击屏幕,再来一局。」,单击必须言行一致直接重开(round1 live P0)。 * 开场 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 语音)。 * - 复活机制:重听分两层账(round2 live P1 改口)——免费重听每局 2 次(教学 * gd_economy 承诺「免费机会有两次」,先说先给、不碰真机会);免费用完后 * 主动重听(input 阶段上滑)才扣 chances。chances 每局初始 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 根治)。 * * round4(docs/design/14-guide-layer.md「round4 v1.3」,synthesis H2~H4): * - H2 占用手势预告(round1 live P2 改口):序列含 up/doubletap 则播 * gd_occ_up/dt/both(游戏反馈层全档位,插在轮次链中),但**每种组合每局只在 * 首次出现轮播一次**(原"每轮必念"12 轮连念 12 遍被点名为 P2 不适);之后仅 * 在答错消耗机会重听时随 retry_use 重播本轮组合提示。被占用轮次 input 看门狗 * 只发 nudge earcon 不播重听建议句(say 动态置空)不变。 * - H3 重听账目真实:主动重听使用时如实报余量(2→1 播 gd_chances_left1、 * 1→0 播 gd_chances_left0,替换该路径旧「用完了」绝对句,全档位)。 * - H4 input 看门狗首级 10s→5s(升级级 25s 与次数不变;非 input 阶段不动)。 * * 手势 → 音色映射: * 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) // 表扬句池(既有台词,轮播;round1 live P1:每轮答对必播一句——原 G6 口径 //「correct 每 4 轮一次 + gd_clear 每局限 1 次」12 轮仅 3 轮有表扬,被推翻) const PRAISE = ['voice/correct', 'voice/gd_clear']; // 向导层(14 号文档 + round2 synthesis F1.2 + round4 H4):input 阶段静默看门狗 // ——首级 5s 轻提醒 +gd_relisten(H4 round4:10s→5s,P4「10 秒干等」;升级级 // 不变、次数不变);25s 升级取消自动重播序列(P1:泄题且与扣机会规则矛盾)→ // 改为 confirm 震 + turn earcon(纯标记,无语音) const GUIDE_INPUT_WATCHDOG_AT = [5000, 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) // 免费重听(round2 live P1):每局 2 次,教学 gd_economy 承诺「免费机会有两次」 // ——承诺几次给几次,先用免费额度不碰 chances,用完主动重听才扣真机会 this.freeRelistens = 2; this.best = this._loadBest(); // 历史最高(完成轮数),localStorage 持久 // 教学状态 this.teachIdx = 0; this.teachAwait = false; // 是否正在等玩家做手势 this.teachErrs = 0; // 菜单 this._menuIdx = 0; this._session = 0; // 会话令牌:重开后使旧计时器/语音链回调失效 this._timers = []; // 轮结算反馈:gd_record 每局限 1 次(表扬每轮必播,见 _roundClear) this._recordSaid = false; // P2(round1 live):占用组合预告每局每种只播首次(答错时重播,见 _wrongAnswer) this._occAnnounced = new Set(); } 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:**不可跳**——结算播报承诺「单击屏幕,再来一局。」,单击必须 * 言行一致直接重开(round1 live P0:玩家照播报单击却只跳过一句,随后进 * 菜单迷路流失); * - 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': 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.freeRelistens = 2; // 免费重听每局重置(round2 live P1) this._recordSaid = false; this._occAnnounced.clear(); // P2:新一局重新封顶(每种组合首次再播) 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 语音,下行) // H2(round4)→ round1 live P2 改口:占用预告每种组合每局只播首次 //(_occAnnounced 记录;之后仅答错重听时随 retry_use 重播,见 _wrongAnswer)。 // 重听键与答案音共用词表的结构性冲突,换手势无解,改为轮次开场讲清判据。 let ids = this._occupancyVoices(); if (ids.length) { if (this._occAnnounced.has(ids[0])) ids = []; else this._occAnnounced.add(ids[0]); } if (this.round === 1) ids.push('voice/your_turn'); if (ids.length) { this._speakGuarded(ids, () => { if (s === this._session && this.phase === 'playback') this._toInput(); }, 2500 * ids.length); // 语音缺失兜底:每句 2.5s } else { this._toInput(); } }, 400); // 最后一个音播完 400ms 后 } }; step(); } /** * H2(round4):本轮占用预告句。序列含 up → gd_occ_up、含 doubletap → * gd_occ_dt、两者皆有 → gd_occ_both;都不含则不播(空链直达 input)。 */ _occupancyVoices() { const hasUp = this.sequence.includes('up'); const hasDt = this.sequence.includes('doubletap'); if (hasUp && hasDt) return ['voice/gd_occ_both']; if (hasUp) return ['voice/gd_occ_up']; if (hasDt) return ['voice/gd_occ_dt']; return []; } _toInput() { this.phase = 'input'; this.inputIdx = 0; // 向导层静默看门狗(round2 F1.2 礼仪重构 + round4 H4/H2):input 阶段无 BGM // 无超时,挂机永久静默。5s → nudge + gd_relisten 提示(H4:首级 10s→5s, // P4「10 秒干等」;升级级 25s 与级数不变);25s 升级**取消免扣机会自动重播 // 序列**(P1:等答案自动重念,双击重听的经济就矛盾了,还顺手拆了记忆难度) // → 只发 confirm 震 + turn earcon(纯标记,无语音)。 // H2:本轮 up 已被占用为作答时,重听建议句不再建议上滑(实现从简:被占用 // 轮次看门狗只发 nudge earcon 不播重听建议句)。 this.guide.watchdog({ arm: true, at: GUIDE_INPUT_WATCHDOG_AT, say: [this.sequence.includes('up') ? null : '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; } // 未支持手势兜底(round1 live P1:答题阶段长按 9 秒零反馈,玩家以为卡死)—— // 即时 error 震,不判错、不耗机会、不进重听。此前 TONE['longpress'] 为 // undefined,audio.play(undefined) 直接抛 TypeError(事件回调内静默崩溃, // 表象正是"手势被吞")。longpressend 是同一动作的收尾,静默不重复反馈。 if (!TONE[g]) { if (g !== 'longpressend') vibrate('error'); 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(); } /** * 主动重听:重播当前序列(进度清零重输,序列不变)。两层账(round2 live P1): * - 免费重听每局 2 次,先用免费额度、**不碰 chances**——教学 gd_economy 承诺 * 「免费机会有两次」,承诺几次给几次(旧账免费第二次要打完第 4 轮才随 * chances +1 发放,玩家第 1 次重听就听报「用完了」、答错一题即死,规则级 * 冤死)。第 1 次用掉(余 1)播 gd_chances_left1「免费重听还剩一次。」、 * 第 2 次用掉(余 0)播 gd_chances_left0「免费重听用完了。之后重听会花一次 * 机会,别怕,慢慢来。」(H3 round4 如实报余量,游戏档全级别)。 * - 免费用完后重听扣 chances(真机会);扣空(chances 1→0)追加 chances_out * 如实报「重听机会用完了,小心。」;免费与真机会皆空时再求重听 → 拒绝 * (error 震 + chances_out,不判错、不耗账)。 * 答错自动消耗路径(retry_use + chances_out)是「答错可以消耗机会重听」的 * 机会账,与主动重听分账,语义如实、维持不变。 */ _relisten() { if (this.freeRelistens <= 0 && this.chances <= 0) { vibrate('error'); this.audio.play('voice/chances_out'); return; } vibrate('pickup'); const ids = ['voice/relisten']; if (this.freeRelistens > 0) { this.freeRelistens--; if (this.freeRelistens === 1) ids.push('voice/gd_chances_left1'); // 余 1:还剩一次 else ids.push('voice/gd_chances_left0'); // 余 0:免费用完,之后扣机会 } else { this.chances--; // 第 3 次起扣真机会 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(); }); } _roundClear() { this.phase = 'roundend'; const s = this._session; vibrate('success'); // 每轮答对都播一句表扬(PRAISE 池轮播)——round1 live P1:小孩每轮都要听到 // 一句夸奖,"过关没声音夸我,我都怀疑是不是其实没答对"。 const ids = [PRAISE[(this.round - 1) % PRAISE.length]]; // 每完成 4 轮机会 +1(上限 2)并播报 if (this.round % 4 === 0 && this.chances < 2) { this.chances++; vibrate('pickup'); ids.push('voice/chance_plus'); } // gd_record「破纪录了。」每局限 1 次:本轮完成数超过历史最佳(且确有旧纪录 // 可破)时追加——局中破纪录的即时一刻(结算另有 new_record)。游戏规则句 // 随链播(全档位,gd_economy 先例),不走向导通道。 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'); // P2(round1 live):答错重听时重播本轮组合占用提示(首次播报之后的唯一 // 重播途径——"能不能只在我答错的时候才提醒我") ids.push(...this._occupancyVoices()); 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']; // 单击/右滑=下一项、左滑=上一项,切换即播报当前项(round1 live P1:原来 // 左右滑只有震动没有语音,"不知道有几项、指在哪项") if (g === 'tap' || g === 'right') { this._menuIdx = (this._menuIdx + 1) % items.length; this.audio.play(items[this._menuIdx]); } else if (g === 'left') { this._menuIdx = (this._menuIdx + items.length - 1) % items.length; this.audio.play(items[this._menuIdx]); } else if (g === 'up') { this._speakRules(); } else if (g === 'down') { this.phase = 'gameover'; // 下滑=关闭菜单回结算页(13 号文档 §7.5 菜单约定) } 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(); } }