/** * 音序回响(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 保留为内容性结束音。 * * 设计冲突的处理(02 文档 §4.2 与 §6.3):双击既是六种音色之一(序列可出现 * doubletap)又是"主动重听"手势。本实现按**上下文**判定:输入阶段若当前期望 * 输入恰为 doubletap,双击视为答题;否则视为重听(有机会时)。两者不会同时 * 歧义落空——序列永远可答,重听仅在"不期望双击"时可用。 * * 手势 → 音色映射: * 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 = []; } 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:记不住就双击重听、免费两次、 // 用完扣机会——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._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) { // 双击的上下文判定(见文件头"设计冲突的处理"): // 期望输入是 doubletap → 答题;否则 → 主动重听(耗机会) if (g === 'doubletap' && this.sequence[this.inputIdx] !== 'doubletap') { 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'); } 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(); } }