Files
SoundGame/web/simon/game.js
T
SoundGame Dev cc0f8c008d HTML5 重写:6 款游戏 + 用户系统 + 全局设置 + 5 轮迭代重构
- 新增 web/ 纯音频 HTML5 平台:共享引擎(手势/WebAudio 三层/设置/震动)、
  Node 零依赖服务器(静态 + 注册登录 + 进度存档 API)
- 游戏:剑与声(RPG 新作)、声命线、暗室(重设计)、音序回响(重设计)、
  节拍行者(跟随领奏重构)、盲航(同向闪避重构);声东击西删除
- 全局功能:男声/女声双套 TTS 资产、三档语速、BGM 音量三档、
  震动反馈语言(10 种震型)、服务器进度同步
- 资产:899 ogg + 899 m4a(Qwen3-TTS 双性别语音、Stable Audio 3 音效、
  Mureka BGM 4+3 首),终检零缺失
- 文档:docs/design/ 13 篇设计审查文档、docs/audio/ 资产管线、
  web/server/README.md API 文档
2026-08-29 21:55:44 +08:00

503 lines
17 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(不加载不播放,bgm/ 目录已空):只有手势音色 + 语音两条线。
* - 旁白全部整句化:轮次 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 { 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)
export class SimonGame extends Game {
constructor(audio, root) {
super(audio, root);
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/t_try', 'voice/round_01',
]);
this._timer(() => this._runTutorial(), 500); // 开场静默 0.5s 后进教学(无 BGM)
}
onGesture(g) {
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;
}
}
// ---------------- 交互式教学(T0-T7) ----------------
_runTutorial() {
this.phase = 'tutorial';
this._speak(['voice/t0_welcome', '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) {
if (g === '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:收尾两句 → 正式对局
this._speakGuarded(['voice/t7_ready', 'voice/t7_retry'], () => this._beginGame(), 12000);
}
},
});
} 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.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)*25),第 12 轮才到下限(02 §6.1)。 */
_interval() {
return Math.max(420, 700 - (this.round - 1) * 25);
}
_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'); // "轮到你了"
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;
}
_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';
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);
}
/** 成绩分段整句: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._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();
}
}