Files
SoundGame/web/simon/game.js
T
xsl 258ffd5ba3 fix(simon): 音序回响状态透明打磨(玩家代理取证迭代,玩法零改动)
- S2:新增input_open双音柔铃标记「能答了」边界(playback/input边界此前吞手势无标记)
- S1/S3/S4:input阶段长按=状态查询(13句status_*分档整句:长度4档×进度4档×账目5档,两本账口径一致,不耗账不闭窗)
- S5:announce/roundend非单击手势补error震(00 §5.4三秒反馈律)
- S6 P0:gameover下滑进菜单→结算链死锁8.4s修复(结算看守+挂起补播)
- S7:your_turn每局一次(原每轮重念)
- 取证:live两会话+4人设评审,S清单见research/simon-polish/round1/findings.md
- 验证:playtest 156/156 + guide 122 + 终检绿 + 14游戏回归零回退 + live复跑核对局14/14压力局8/8;状态感知2/5→4/5(四席同升),愿意玩改口为是
- 02号文档追加§9修订记录;新增input_open+13句status_*走完整TTS管线(flock串行QC无离群)
2026-09-14 20:17:28 +08:00

767 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 音序回响(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 阶段不动)。
*
* round5(research/simon-polish/round1 取证 S1-S7 → round2 修复,状态透明层;玩法零改动):
* - S2:input 开放标记音 sfx/input_open——confirm 震只标记"播放完毕",占用预告/
* your_turn 语音链之后才真开放(此前作答被静默吞,live-A 行 108-115);input_open
* 在 _toInput 播、游戏层资产不走向导层(guide=off/iOS 无震动也有听觉边界)。
* 无语音链的轮次不再叠向导 turn earcon(input_open 独立作边界标记,二选一不叠响)。
* - S1/S3/S4:input 阶段长按=状态查询(_statusQuery)——长度 4 档+进度 4 档+重听
* 两账 5 档,13 句 status_* 整句预生成(禁运行时拼接);玩家主动查询,三档 guide 全可用。
* - S5:announce/roundend 阶段非单击手势给 error 震(playback 维持静默豁免,02 §4.1)。
* - S6:结算语音链阶段看守——gameover 瞬间进菜单则挂起,回结算屏补播(_deliverSettle);
* 承诺「单击屏幕,再来一局」永远只在结算屏上播(live-B 行 140-174:链在菜单里
* 播完 → 承诺是假的 + 关菜单后结算屏死寂 8.4s)。
* - S7:your_turn 每局只播一次(第 1 轮每次重播都重念,live-B 行 67/96-98)。
*
* 手势 → 音色映射:
* 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',
};
// 状态查询分档整句(round5 S1/S3/S4;游戏层资产三档 guide 全可用,全句预生成禁拼接)
const STATUS_LEN = ['voice/status_len_short', 'voice/status_len_mid',
'voice/status_len_long', 'voice/status_len_xlong']; // 2-4 / 5-8 / 9-12 / 13+ 音
const STATUS_PROG = ['voice/status_prog_start', 'voice/status_prog_early',
'voice/status_prog_half', 'voice/status_prog_late']; // 0 / ≤⅓ / ≤⅓-⅔ / 收尾
const STATUS_ECO = ['voice/status_free2', 'voice/status_free1', 'voice/status_chance2',
'voice/status_chance1', 'voice/status_out']; // 下一次重听消耗哪个桶(两账口径)
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();
// round5 S7:your_turn 每局只播一次(第 1 轮复活/重听重播不再重念)
this._turnHintSaid = false;
// round5 S6:待交付结算链(gameover 瞬间进菜单时挂起,回结算屏补播)
this._settleChain = null;
}
start() {
// 预解码全部短音效与开场语音,消除首次播放延迟
this.audio.prewarm([
...GESTURES.map((g) => TONE[g]),
'sfx/gameover', 'sfx/input_open',
'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);
// round5 S5:announce/roundend(非 playback)的误触给 error 震——00 §5.4
// "做了手势 3 秒内必须有反馈",播报期被静默吞是"不知道自己在哪"的重灾区
// (live-A 行 824-830:roundend 下滑开菜单零反馈;playback 维持静默豁免)。
if (this.phase === 'announce' || this.phase === 'roundend') vibrate('error');
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._turnHintSaid = false; // round5 S7
this._settleChain = null; // round5 S6:上一局挂起的结算链随新局作废
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'); // "播放完毕"("现在能答"由 _toInput 的 input_open 标记)
// 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]);
}
// round5 S7:your_turn 每局只播一次(第 1 轮复活/重听重播不再重念)
if (this.round === 1 && !this._turnHintSaid) {
ids.push('voice/your_turn');
this._turnHintSaid = true;
}
if (ids.length) {
// 有语音链:向导 turn earcon 打头,链尾由 _toInput 的 input_open 收尾
this.guide.turn({});
this._speakGuarded(ids, () => {
if (s === this._session && this.phase === 'playback') this._toInput();
}, 2500 * ids.length); // 语音缺失兜底:每句 2.5s
} else {
// round5 S2:无语音链时不叠 turn earcon——input_open 独立作"能答了"标记
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;
// round5 S2:input 开放标记音(游戏层资产,三档 guide/iOS 全可闻)——语音链
// (占用预告/your_turn)播完才进这里,此前作答被静默吞(live-A 行 108-115)。
this.audio.play('sfx/input_open');
// 向导层静默看门狗(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;
}
// round5 S1/S3/S4:input 阶段长按=状态查询(长度/进度/重听两账,分档整句)——
// 长按是六手势之外的唯一空闲键位(键盘=按住空格),玩家主动查询不耗账不判错;
// longpressend 是同一动作收尾,静默不重复反馈。
if (g === 'longpress') {
this._statusQuery();
return;
}
// 其余未支持手势兜底(round1 live P1:无效手势零反馈,玩家以为卡死)——
// 即时 error 震,不判错、不耗机会、不进重听(此前 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(); });
}
/**
* round5 S1/S3/S4:input 阶段长按=问状态(status_* 13 句整句预生成,数字分档,
* 禁运行时拼接——00 §3.2)。三句链:本轮长度档(2-4/5-8/9-12/13+ 音)→ 已答
* 进度档(0/≤⅓/≤⅔/收尾)→ 重听账目档(下一次重听消耗哪个桶:免费 2/免费 1/
* 机会 2/机会 1/两账空)。不耗账、不判错、不清进度;答错消耗路径与主动重听
* 路径的分账口径与 _relisten 一致(归因真话)。教学不讲这句——查询是发现式
* 辅助(旁白只承诺"记不住就向上滑重听"),主动查询的玩家三档 guide 都能用
* (属玩家发起,不走向导插播通道)。
*/
_statusQuery() {
const len = this.sequence.length;
const idx = this.inputIdx;
vibrate('confirm'); // 收到查询:轻确认震(longpress 无引擎内置震动)
const lenIdx = len <= 4 ? 0 : len <= 8 ? 1 : len <= 12 ? 2 : 3;
const progIdx = idx === 0 ? 0 : idx * 3 <= len ? 1 : idx * 3 <= len * 2 ? 2 : 3;
const ecoIdx = this.freeRelistens >= 2 ? 0 : this.freeRelistens === 1 ? 1
: this.chances >= 2 ? 2 : this.chances === 1 ? 3 : 4;
// 相位保持 input:查询语音不闭作答窗口(玩家可以边听边继续答)。
this._speakGuarded([STATUS_LEN[lenIdx], STATUS_PROG[progIdx], STATUS_ECO[ecoIdx]], null, 7500);
}
_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');
// round5 S6 阶段看守:结算链只在结算屏交付——gameover 瞬间下滑进菜单时
// 链挂起(_deliverSettle 见相位不对不播),回结算屏补播;承诺「单击屏幕,
// 再来一局」永远与所处阶段一致(live-B 行 140-174:链在菜单里播完 → 承诺
// 在菜单内是假的 → 关菜单后结算屏死寂 8.4s)。重开(_beginGame)作废挂起链。
this._settleChain = ids;
this._timer(() => {
if (s !== this._session) return;
this.audio.stopVoice(); // 抢占点火:整链替换重播,listen/ASR 窗口不碎
this._deliverSettle();
}, 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';
}
/**
* round5 S6:交付结算语音链。只在结算屏(phase==='gameover')播;在菜单则
* 挂起(_settleChain 保留),回结算屏(_backToSettle)再补播——链内承诺
* (单击重开/上滑规则/下滑菜单)只对听得见它们的相位负责。
*/
_deliverSettle() {
if (this.phase !== 'gameover' || !this._settleChain) return;
const ids = this._settleChain;
this._settleChain = null;
this._speak(ids, null);
}
/** 菜单返回结算页:补挂起的结算链(S6),并落相位。 */
_backToSettle() {
this.phase = 'gameover';
this._deliverSettle();
}
_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._backToSettle(); // 下滑=关闭菜单回结算页(13 号文档 §7.5 菜单约定;S6 补播结算链)
} else if (g === 'doubletap') {
const sel = this._menuIdx;
if (sel === 0) this._restart();
else if (sel === 1) { this.stop(); window.location.href = '../'; }
else this._backToSettle(); // 返回:回到结算页(S6 补播结算链)
}
}
// ---------------- 成绩存档 ----------------
_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();
}
}