/** * 音序回响(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; } }