Files
SoundGame/web/rpg/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

983 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.
/**
* 《剑与声》RPG 第一章《迷雾林》—— 纯音频回合制武侠 RPG
* (按 docs/design/05-rpg.md + 05-rpg-chapter1.md + 05-rpg-addendum.md 实现;
* 语音 id 见 05-rpg-ch1-voice-ids.md 与 docs/audio/round4-report.md §5 id 总表)。
*
* 你是盲剑客沈听澜——眼盲,耳即眼。节点式场景图探索 + 回合制战斗:
*
* 探索态手势:左右滑=切选项(播选项名)/ 单击=确认 / 双击=重听场景描述 /
* 上滑=状态播报 / 下滑=菜单。
* 战斗态手势:左右滑=切指令(攻击→破空斩→防御→金疮药)/ 单击=执行 /
* 双击=格挡(敌方前摇 1.0s 窗口内,减伤 70%,即时回调规避 tap 延迟)/
* 上滑=敌我状态 / 下滑=逃跑(Boss/剧情战无效)。
*
* 音频层叠(addendum §3):village 播 bgm/village 音乐;其余探索场景**无 BGM**,
* amb_* 环境音走 sfx 层手动 BufferSource loop(不受设置 bgm=0 影响,见
* _playAmb);战斗 crossfade 替换为 bgm/battle|bgm/boss,战后回场景 amb。
* 震动(addendum §1 配合总表):前摇开窗 beat / 格挡 confirm / 受击 hit /
* 击杀 success / 升级 success / 窗口内误触 error / 金疮药 pickup /
* Boss 半血 warning / 血量告急 warning。
*
* 菜单约定:RPG 豁免(round2-review 裁决② + addendum §6.6)——探索态菜单
* 保持"左右滑切换 + 单击确认"(与探索范式同构;sys_menu 语音亦按此录制)。
*/
import { Game } from '../engine/game.js';
import { vibrate } from '../engine/haptics.js';
import { settings } from '../engine/settings.js';
// ================================ 数据表 ================================
// 第一章场景图(05-rpg-chapter1.md §1)
const SCENES = {
village: { name: '雾口村', amb: 'amb_village', options: ['go_path', 'shop', 'rest', 'status'] },
path: { name: '林间小路', amb: 'amb_path', options: ['go_fork', 'investigate', 'back'] },
fork: { name: '岔路口', amb: 'amb_fork', options: ['go_chest', 'go_stream', 'back'] },
chest: { name: '枯树洞', amb: 'amb_chest', options: ['open_chest', 'back'] },
stream: { name: '溪流', amb: 'amb_stream', options: ['drink', 'go_hunter', 'back'] },
hunter: { name: '猎人小屋', amb: 'amb_hunter', options: ['talk_hunter', 'go_deepwood', 'back'] },
deepwood: { name: '密林深处', amb: 'amb_deepwood', options: ['go_gate', 'back'] },
gate: { name: '黑风寨门前', amb: 'amb_gate', options: ['assault', 'sneak', 'back'] },
boss1: { name: '聚义厅', amb: 'amb_hall', options: [] },
};
const BACK_MAP = { path: 'village', fork: 'path', chest: 'fork', stream: 'fork', hunter: 'stream', deepwood: 'hunter', gate: 'deepwood' };
const GO_TARGET = { go_path: 'path', go_fork: 'fork', go_chest: 'chest', go_stream: 'stream', go_hunter: 'hunter', go_deepwood: 'deepwood', go_gate: 'gate' };
// 敌人表(chapter1 §4.2;ch1 实际使用的 5 种 + Boss1)
const ENEMIES = {
teach_wolf: { hp: 6, atk: 3, def: 0, exp: 5, gold: 0, tel: 'sfx/tel_wolf', start: 'voice/btl_start_wolf' },
wolf: { hp: 10, atk: 4, def: 0, exp: 8, gold: 5, tel: 'sfx/tel_wolf', start: 'voice/btl_start_wolf' },
bandit: { hp: 16, atk: 6, def: 1, exp: 12, gold: 12, tel: 'sfx/tel_bandit', start: 'voice/btl_start_bandit' },
spider: { hp: 12, atk: 7, def: 0, exp: 12, gold: 8, tel: 'sfx/tel_spider', start: 'voice/btl_start_spider' },
elite: { hp: 24, atk: 8, def: 2, exp: 20, gold: 25, tel: 'sfx/tel_elite', start: 'voice/btl_start_elite',
npcStart: 'voice/npc_elite_1', half: 'voice/npc_elite_half', die: 'voice/npc_elite_die' },
boss1: { hp: 45, atk: 10, def: 2, exp: 50, gold: 100, tel: 'sfx/tel_boss1', start: 'voice/btl_start_boss1',
npcStart: 'voice/npc_boss1_1', phase2Voice: 'voice/npc_boss1_2', die: 'voice/npc_boss1_die',
boss: true, phase2Atk: 12 },
};
// 遭遇战表(chapter1 §2)
const ENCOUNTERS = {
enc_teach_wolf: { queue: ['teach_wolf'], flee: false, teach: true },
enc_wolf: { queue: ['wolf'], flee: true },
enc_bandits: { queue: ['bandit', 'bandit'], flee: false, flag: 'enc_bandits_done' },
enc_elite: { queue: ['elite'], flee: false, flag: 'enc_elite_done' },
enc_sneak: { queue: ['bandit', 'spider'], flee: false, flag: 'enc_sneak_done' },
enc_boss1: { queue: ['boss1'], flee: false, boss: true, flag: 'boss1_done' },
};
// 装备(§4.3;ch1 商店只卖铁剑/皮甲/金疮药)
const WEAPONS = {
iron_sword: { atk: 3, price: 100, stVoice: 'voice/st_weapon_iron' },
steel_sword: { atk: 6, price: 300, stVoice: 'voice/st_weapon_steel' },
wind_sword: { atk: 9, price: 600, stVoice: 'voice/st_weapon_wind', parryBonus: 0.3 },
};
const ARMORS = {
leather: { def: 2, price: 80, stVoice: 'voice/st_armor_leather' },
iron_scale: { def: 4, price: 250, stVoice: 'voice/st_armor_iron' },
};
// 商店子菜单(village,§4.4)
const SHOP_ITEMS = [
{ id: 'iron_sword', type: 'weapon', priceVoice: 'voice/shop_price_sword' },
{ id: 'leather', type: 'armor', priceVoice: 'voice/shop_price_armor' },
{ id: 'potion', type: 'potion', priceVoice: 'voice/shop_price_potion' },
{ id: 'leave', type: 'leave', priceVoice: 'voice/opt_leave' },
];
const CMDS = ['attack', 'skill', 'defend', 'item']; // 指令循环
const MENU_ITEMS = ['sys_menu_resume', 'sys_menu_save', 'sys_menu_help'];
const SAVE_KEY = 'soundgame.progress.rpg';
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
const CHAPTER_NAMES = { 1: '第一章' };
const PREWARM_SFX = [
'sfx/step', 'sfx/coin', 'sfx/chest', 'sfx/drink_water', 'sfx/rest', 'sfx/levelup', 'sfx/door_heavy',
'sfx/atk_swing', 'sfx/atk_hit', 'sfx/parry', 'sfx/skill_slash', 'sfx/enemy_die', 'sfx/potion',
'sfx/hurt', 'sfx/flee',
'sfx/tel_wolf', 'sfx/tel_bandit', 'sfx/tel_spider', 'sfx/tel_elite', 'sfx/tel_boss1',
'sfx/amb_village', 'sfx/amb_path', 'sfx/amb_fork', 'sfx/amb_chest', 'sfx/amb_stream',
'sfx/amb_hunter', 'sfx/amb_deepwood', 'sfx/amb_gate', 'sfx/amb_hall',
];
export class RpgGame extends Game {
constructor(audio, root) {
super(audio, root);
this.phase = 'idle'; // idle | ask | intro | explore | shop | battle | menu | over
this._session = 0; // 会话令牌:大跳转时作废旧计时器与语音链
this._timers = [];
// 探索状态
this.scene = 'village';
this.optIdx = 0;
this.visited = new Set();
this._wolfDone = false; // stream 野狼 50% 刷新(一次性,运行期内)
this._hunterLine = 0; // 老猎人二周目台词轮换
// 商店/菜单
this._shopIdx = 0;
this._menuIdx = 0;
// 战斗状态
this._battle = null;
// amb 手动循环(sfx 层)
this._ambSrc = null;
this._ambGain = null;
this._ambId = null;
// playSec 计时
this._segStart = null;
this._newRun();
}
_newRun(keepStats) {
const stats = keepStats && this.stats ? this.stats : { kills: 0, deaths: 0, parries: 0, playSec: 0 };
this.player = { lv: 1, exp: 0, hp: 20, mp: 4, gold: 50, weapon: null, armor: null, potions: 1 };
this.flags = {
tutorial_battle_done: false, chest_path: false, chest_fork: false,
hunter_quest: false, shop_discount: false,
enc_bandits_done: false, enc_elite_done: false, enc_sneak_done: false, boss1_done: false,
};
this.chapters = { 1: 'open', 2: 'locked', 3: 'locked' };
this.stats = stats;
}
// ---------------- 派生数值(chapter1 §4.1) ----------------
get maxHp() { return 20 + 8 * (this.player.lv - 1); }
get atk() { return 5 + 2 * (this.player.lv - 1) + (this.player.weapon ? WEAPONS[this.player.weapon].atk : 0); }
get def() { return this.player.armor ? ARMORS[this.player.armor].def : 0; }
get maxMp() { return 3 + this.player.lv; }
get expToNext() { return this.player.lv * 20; }
_dmg(atkVal, defVal) {
return Math.max(1, Math.round((atkVal - defVal) * (0.85 + Math.random() * 0.3)));
}
// ---------------- 生命周期 ----------------
start() {
this.audio.prewarm(PREWARM_SFX);
this.audio.prewarm(['voice/intro_1', 'voice/sys_continue_ask', 'voice/desc_village', 'voice/npc_headman_1']);
this._session++;
const save = this._loadSave();
if (save) {
// 有存档:继续 / 新旅程
this.phase = 'ask';
this.audio.play('voice/sys_continue_ask');
} else {
this._startNewGame(false);
}
}
stop() {
this._session++;
this._clearTimers();
this._stopAmb();
this.audio.stopAll();
}
_startNewGame(keepStats) {
this._session++;
this._clearTimers();
this.audio.stopVoice();
this._newRun(keepStats);
this.visited = new Set();
this._wolfDone = false;
this.phase = 'intro';
this._segStart = performance.now();
this._speakGuarded(['voice/intro_1', 'voice/intro_2', 'voice/intro_3', 'voice/sys_new_game'], () => {
// 首次进村:村长送 50 金(player 默认值即剧情款)+ 商店引导
this._enterScene('village', { prepend: ['voice/npc_headman_1', 'voice/npc_headman_2'] });
}, 30000);
}
_continueGame(save) {
this._session++;
this._clearTimers();
this.audio.stopVoice();
this.player = save.player;
this.flags = save.flags;
this.chapters = save.chapters;
this.stats = save.stats;
this.visited = new Set(); // 回访描述按本轮会话计(读档后重听完整 desc 更友好)
this._wolfDone = false;
this.phase = 'explore';
this._segStart = performance.now();
this._enterScene(save.scene || 'village');
}
// ---------------- 手势 ----------------
onGesture(g) {
switch (this.phase) {
case 'ask':
if (g === 'tap') { const s = this._loadSave(); if (s) this._continueGame(s); else this._startNewGame(false); }
else if (g === 'right') this._startNewGame(true); // 新游戏保留 stats 累计
break;
case 'explore':
this._onExploreGesture(g);
break;
case 'shop':
this._onShopGesture(g);
break;
case 'battle':
this._onBattleGesture(g);
break;
case 'menu':
this._onMenuGesture(g);
break;
default:
break; // intro/over:不收手势
}
}
// ---------------- 探索态 ----------------
/**
* 进入场景:切环境音/音乐 → 脚步 → desc(首次/回访)→ 保存 → 遭遇检查 → 报选项。
* @param {object} [opts] opts.prepend 额外前置语音(序章进村等)
*/
_enterScene(id, opts = {}) {
this._session++;
this._clearTimers();
this.scene = id;
this.optIdx = 0;
this.phase = 'explore';
this._switchSceneAudio(id);
this.audio.play('sfx/step', { volume: 0.8 });
const first = !this.visited.has(id);
this.visited.add(id);
let desc = `voice/desc_${id === 'boss1' ? 'hall' : id}`;
if (!first && SCENES[id].options.length) {
desc = `voice/desc_${id}_re`; // gate/boss1 无 _re 句:boss1 不走此分支,gate 有 desc_gate 但无 _re——回退全句
if (id === 'gate') desc = 'voice/desc_gate';
}
this._speakGuarded([...(opts.prepend || []), desc], () => {
this._save();
if (this._checkEncounter()) return;
this._speakOption();
}, 20000);
}
/** 场景入口遭遇检查(明雷,chapter1 §1/§2)。返回 true 表示已触发战斗。 */
_checkEncounter() {
if (this.scene === 'stream' && !this.flags.tutorial_battle_done) {
this._startBattle('enc_teach_wolf');
return true;
}
if (this.scene === 'stream' && !this._wolfDone && Math.random() < 0.5) {
this._startBattle('enc_wolf');
return true;
}
if (this.scene === 'deepwood' && !this.flags.enc_bandits_done) {
this._startBattle('enc_bandits');
return true;
}
if (this.scene === 'boss1') {
if (!this.flags.boss1_done) {
this._startBattle('enc_boss1');
return true;
}
// Boss 已讨伐:聚义厅空了,退回寨门
this._speakGuarded(['voice/evt_nothing'], () => this._enterScene('gate'));
return true;
}
return false;
}
_speakOption() {
if (this.phase !== 'explore') return;
const opts = SCENES[this.scene].options;
if (!opts.length) return;
this.audio.play(`voice/opt_${opts[this.optIdx]}`);
}
_onExploreGesture(g) {
const opts = SCENES[this.scene].options;
if (!opts.length) return;
if (g === 'left' || g === 'right') {
const n = opts.length;
this.optIdx = (this.optIdx + (g === 'right' ? 1 : n - 1)) % n;
this._speakOption();
} else if (g === 'tap') {
this._executeOption(opts[this.optIdx]);
} else if (g === 'doubletap') {
// 重听场景描述 + 当前选项(amb 循环持续,无需重播)
const id = this.scene;
const desc = id === 'boss1' ? 'voice/desc_hall' : (id === 'gate' ? 'voice/desc_gate' : `voice/desc_${id}_re`);
this._speak([desc, `voice/opt_${opts[this.optIdx]}`]);
} else if (g === 'up') {
this._speakStatus();
} else if (g === 'down') {
this._openMenu();
}
}
_executeOption(opt) {
if (GO_TARGET[opt]) { this._enterScene(GO_TARGET[opt]); return; }
switch (opt) {
case 'back':
this._enterScene(BACK_MAP[this.scene] || 'village');
break;
case 'shop':
this.phase = 'shop';
this._shopIdx = 0;
this._speak(['voice/shop_open', SHOP_ITEMS[0].priceVoice]);
break;
case 'rest':
this.player.hp = this.maxHp;
this.player.mp = this.maxMp;
this.audio.play('sfx/rest');
this._speak(['voice/sys_rest']);
this._save();
break;
case 'status':
this._speakStatus(() => this._speakOption());
break;
case 'investigate':
if (!this.flags.chest_path) {
this.flags.chest_path = true;
this.player.gold += 15;
this.audio.play('sfx/coin');
this._speak(['voice/evt_gold15']);
this._save();
} else {
this.audio.play('voice/evt_nothing');
}
break;
case 'open_chest':
if (!this.flags.chest_fork) {
this.flags.chest_fork = true;
this.player.potions = Math.min(5, this.player.potions + 2);
this.audio.play('sfx/chest');
vibrate('pickup');
this._speak(['voice/evt_chest']);
this._save();
} else {
this.audio.play('voice/evt_nothing');
}
break;
case 'drink':
this.player.hp = this.maxHp;
this.audio.play('sfx/drink_water');
this._speak(['voice/sys_drink']);
break;
case 'talk_hunter':
if (!this.flags.hunter_quest) {
this.flags.hunter_quest = true;
this.flags.shop_discount = true;
this.player.potions = Math.min(5, this.player.potions + 1);
vibrate('pickup');
this._speak(['voice/npc_hunter_1', 'voice/npc_hunter_2', 'voice/evt_hunter_gift']);
this._save();
} else {
const lines = ['voice/npc_hunter_3', 'voice/npc_hunter_4'];
this.audio.play(lines[this._hunterLine++ % lines.length]);
}
break;
case 'assault':
if (this.flags.enc_elite_done) { this._enterBossHall(); break; }
this._startBattle('enc_elite', () => this._enterBossHall());
break;
case 'sneak':
if (this.flags.enc_sneak_done) { this._enterBossHall(); break; }
this._startBattle('enc_sneak', () => {
// 绕行:Boss 战前满状态
this.player.hp = this.maxHp;
this.player.mp = this.maxMp;
this._enterBossHall();
});
break;
}
}
_enterBossHall() {
this.audio.play('sfx/door_heavy');
this._enterScene('boss1', { prepend: ['voice/evt_gate_open'] });
}
// ---------------- 状态播报 ----------------
_hpBand(hp, max) {
const r = hp / max;
if (r > 0.7) return 'high';
if (r >= 0.3) return 'mid';
return 'low';
}
/** 上滑状态链(探索:lv→hp→gold→weapon→armor;战斗:己方 hp + 敌方档位)。 */
_speakStatus(onDone) {
const p = this.player;
if (this.phase === 'battle' && this._battle && this._battle.enemy) {
const e = this._battle.enemy;
this._speak([`voice/st_hp_${this._hpBand(p.hp, this.maxHp)}`,
`voice/foe_hp_${this._hpBand(e.hp, e.maxHp)}`], onDone);
if (this._hpBand(p.hp, this.maxHp) === 'low') vibrate('warning');
return;
}
const lvVoice = p.lv <= 2 ? 'voice/st_lv_low' : p.lv <= 5 ? 'voice/st_lv_mid' : 'voice/st_lv_high';
const hpVoice = `voice/st_hp_${this._hpBand(p.hp, this.maxHp)}`;
const goldVoice = p.gold < 30 ? 'voice/st_gold_poor' : p.gold <= 150 ? 'voice/st_gold_some' : 'voice/st_gold_rich';
const weaponVoice = p.weapon ? WEAPONS[p.weapon].stVoice : 'voice/st_weapon_none';
const armorVoice = p.armor ? ARMORS[p.armor].stVoice : 'voice/st_armor_none';
if (this._hpBand(p.hp, this.maxHp) === 'low') vibrate('warning');
this._speak([lvVoice, hpVoice, goldVoice, weaponVoice, armorVoice], onDone);
}
// ---------------- 商店 ----------------
_onShopGesture(g) {
if (g === 'left' || g === 'right') {
const n = SHOP_ITEMS.length;
this._shopIdx = (this._shopIdx + (g === 'right' ? 1 : n - 1)) % n;
this.audio.play(SHOP_ITEMS[this._shopIdx].priceVoice);
} else if (g === 'tap') {
this._buy(SHOP_ITEMS[this._shopIdx]);
} else if (g === 'down') {
this.phase = 'explore';
this._speakOption();
} else if (g === 'up') {
this._speakStatus(() => this.audio.play(SHOP_ITEMS[this._shopIdx].priceVoice));
}
}
_buy(item) {
const p = this.player;
if (item.type === 'leave') {
this.phase = 'explore';
this._speakOption();
return;
}
if (item.type === 'weapon' && p.weapon === item.id) { this.audio.play('voice/shop_owned'); return; }
if (item.type === 'armor' && p.armor === item.id) { this.audio.play('voice/shop_owned'); return; }
if (item.type === 'potion' && p.potions >= 5) { this.audio.play('voice/shop_owned'); return; }
const base = item.type === 'weapon' ? WEAPONS[item.id].price
: item.type === 'armor' ? ARMORS[item.id].price : 30;
const price = this.flags.shop_discount ? Math.floor(base * 0.9) : base; // 猎人支线九折
if (p.gold < price) { this.audio.play('voice/shop_poor'); return; }
p.gold -= price;
if (item.type === 'weapon') p.weapon = item.id;
else if (item.type === 'armor') p.armor = item.id;
else p.potions = Math.min(5, p.potions + 1);
this.audio.play('sfx/coin');
vibrate([15, 40, 15]);
this.audio.play('voice/shop_buy');
this._save(); // 商店交易后存档
}
// ---------------- 菜单(RPG 豁免:左右滑切换 + 单击确认) ----------------
_openMenu() {
this._accPlaySec();
this.phase = 'menu';
this._menuIdx = 0;
this._speak(['voice/sys_menu', `voice/${MENU_ITEMS[0]}`]);
}
_closeMenu() {
this.phase = 'explore';
this._segStart = performance.now();
this._speakOption();
}
_onMenuGesture(g) {
if (g === 'left' || g === 'right') {
const n = MENU_ITEMS.length;
this._menuIdx = (this._menuIdx + (g === 'right' ? 1 : n - 1)) % n;
this.audio.play(`voice/${MENU_ITEMS[this._menuIdx]}`);
} else if (g === 'tap') {
const sel = MENU_ITEMS[this._menuIdx];
if (sel === 'sys_menu_resume') this._closeMenu();
else if (sel === 'sys_menu_save') {
this._save();
this._speakGuarded(['voice/sys_saved'], () => this._goHome(), 4000);
} else {
this._speak(['voice/help_explore', 'voice/help_battle', 'voice/settings_hint']);
}
} else if (g === 'up') {
this._speak(['voice/help_explore', 'voice/help_battle', 'voice/settings_hint']);
} else if (g === 'down') {
this._closeMenu();
}
}
_goHome() {
this.stop();
window.location.href = '../';
}
// ---------------- 战斗:定时器驱动回合状态机(chapter1 §3) ----------------
_startBattle(encId, onVictory) {
this._session++;
this._clearTimers();
const enc = ENCOUNTERS[encId];
this.phase = 'battle';
this._segStart = performance.now();
this._battle = {
encId, enc,
queue: [...enc.queue],
enemy: null,
cmdIdx: 0,
round: 1,
defending: false,
parry: false,
windowEnd: 0,
totalExp: 0,
totalGold: 0,
onVictory: onVictory || null,
teachStage: enc.teach ? 'parry' : null, // 教学战:先学格挡再学出剑
telRate: 1,
};
this._switchBattleAudio(enc.boss);
this._nextEnemy();
}
_nextEnemy() {
const b = this._battle;
const key = b.queue.shift();
const base = ENEMIES[key];
b.enemy = { ...base, key, maxHp: base.hp, halfSpoken: false, phase2: false };
b.cmdIdx = 0; // 连战转换:指令重置为攻击
b.round = 1;
b.state = 'B_START';
const ids = [base.start];
if (base.npcStart) ids.push(base.npcStart);
if (b.teachStage === 'parry') ids.push('voice/tut_b1'); // 它要扑了,听前摇!
this._speakGuarded(ids, () => {
if (b.teachStage === 'parry') this._enemyTelegraph();
else this._playerSelect();
}, 15000);
}
_playerSelect() {
const b = this._battle;
if (!b) return;
b.state = 'PLAYER_SELECT';
this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`);
}
_onBattleGesture(g) {
const b = this._battle;
if (!b) return;
if (b.state === 'PLAYER_SELECT') {
if (g === 'left' || g === 'right') {
b.cmdIdx = (b.cmdIdx + (g === 'right' ? 1 : CMDS.length - 1)) % CMDS.length;
this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`);
} else if (g === 'tap') {
this._playerExecute();
} else if (g === 'up') {
this._speakStatus(() => { if (this._battle === b && b.state === 'PLAYER_SELECT') this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`); });
} else if (g === 'down') {
this._fleeCheck();
}
} else if (b.state === 'ENEMY_TELEGRAPH') {
if (g === 'doubletap' && performance.now() <= b.windowEnd) {
// 格挡成功:即时 confirm + 格挡音 → 直接结算
b.parry = true;
this.stats.parries++;
this.audio.play('sfx/parry');
vibrate('confirm');
this._enemyResolve();
} else {
vibrate('error'); // 窗口内做错手势:留在窗口
}
}
// 其他战斗状态:手势忽略
}
_playerExecute() {
const b = this._battle;
const p = this.player;
const e = b.enemy;
b.state = 'PLAYER_EXECUTE';
const cmd = CMDS[b.cmdIdx];
if (cmd === 'skill' && p.mp < 3) {
this._speakGuarded(['voice/btl_nomp'], () => this._playerSelect(), 4000);
return;
}
if (cmd === 'item' && p.potions <= 0) {
this._speakGuarded(['voice/btl_noitem'], () => this._playerSelect(), 4000);
return;
}
if (cmd === 'attack' || cmd === 'skill') {
if (cmd === 'skill') {
p.mp -= 3;
this.audio.play('sfx/skill_slash');
} else {
this.audio.play('sfx/atk_swing');
}
this.audio.play('sfx/atk_hit');
const dmg = cmd === 'skill' ? this._dmg(this.atk * 2, e.def) : this._dmg(this.atk, e.def);
e.hp = Math.max(0, e.hp - dmg);
console.log(`[RpgGame] 玩家${cmd === 'skill' ? '破空斩' : '攻击'} dmg=${dmg} 敌HP=${e.hp}/${e.maxHp}`);
// 教学战:第一剑命中后收尾教学
if (b.teachStage === 'attack') {
b.teachStage = null;
this._speakGuarded(['voice/tut_b5'], () => this._checkEnemyDead(), 8000);
return;
}
this._checkEnemyDead();
} else if (cmd === 'defend') {
b.defending = true; // 无播报,下回合减伤自明(addendum §1)
this._checkEnemyDead();
} else if (cmd === 'item') {
p.potions--;
p.hp = Math.min(this.maxHp, p.hp + Math.round(this.maxHp * 0.4));
this.audio.play('sfx/potion');
vibrate('pickup');
this._checkEnemyDead();
}
}
_checkEnemyDead() {
const b = this._battle;
const e = b.enemy;
b.state = 'CHECK_ENEMY_DEAD';
if (e.hp <= 0) {
this.stats.kills++;
b.totalExp += e.exp;
b.totalGold += e.gold;
this.audio.play('sfx/enemy_die');
vibrate('success');
const ids = ['voice/btl_enemy_die'];
if (e.die) ids.push(e.die);
this._speakGuarded(ids, () => {
if (b.queue.length > 0) this._nextEnemy(); // 连战:不停 BGM 直接下一只
else this._victory();
}, 10000);
return;
}
// 半血钩子 / Boss 阶段转换
if (!e.halfSpoken && e.hp <= e.maxHp / 2) {
e.halfSpoken = true;
if (e.boss && !e.phase2) {
e.phase2 = true;
e.atk = e.phase2Atk; // 黑风寨主 ATK 10→12
b.telRate = 1.2; // 前摇音急促化(playbackRate 1.2,不加资产)
vibrate('warning');
this._speakGuarded([e.phase2Voice], () => this._enemyTelegraph(), 8000);
return;
}
if (e.half) {
this._speakGuarded([e.half], () => this._enemyTelegraph(), 8000);
return;
}
}
this._enemyTelegraph();
}
_enemyTelegraph() {
const b = this._battle;
b.state = 'ENEMY_TELEGRAPH';
b.parry = false;
const windowSec = 1.0 + (this.player.weapon && WEAPONS[this.player.weapon].parryBonus || 0);
b.windowEnd = performance.now() + windowSec * 1000;
this._playTel(b.enemy.tel, b.telRate);
vibrate('beat'); // "格挡窗口已开"的通道标识(addendum §1)
if (b.teachStage === 'parry') this.audio.play('voice/tut_b2'); // 就是现在,双击格挡!
this._after(windowSec * 1000, () => this._enemyResolve());
}
/** 前摇音播放(支持 playbackRate,Boss 二阶段 1.2 倍急促化)。 */
async _playTel(id, rate = 1) {
const s = this._session;
const buf = await this.audio._load(id);
if (!buf || !this.audio.ctx || s !== this._session) return;
const src = this.audio.ctx.createBufferSource();
src.buffer = buf;
src.playbackRate.value = rate;
const g = this.audio.ctx.createGain();
g.gain.value = this.audio.sfxVolume;
src.connect(g);
g.connect(this.audio._sfxBus);
src.start();
}
_enemyResolve() {
const b = this._battle;
if (!b || b.state !== 'ENEMY_TELEGRAPH') return;
b.state = 'ENEMY_RESOLVE';
const p = this.player;
const e = b.enemy;
let dmg = this._dmg(e.atk, this.def);
if (b.parry) {
dmg = Math.max(0, Math.round(dmg * 0.3)); // 格挡减伤 70%(不震 hit,已震 confirm)
} else {
if (b.defending) dmg = Math.round(dmg * 0.5); // 防御减半(与格挡不叠加)
this.audio.play('sfx/hurt');
vibrate('hit');
}
b.defending = false;
const bandBefore = this._hpBand(p.hp, this.maxHp);
p.hp -= dmg;
if (b.enc.teach) p.hp = Math.max(1, p.hp); // 教学战 HP 锁底 1(必不死)
console.log(`[RpgGame] 敌方攻击 dmg=${dmg} 格挡=${b.parry} 玩家HP=${p.hp}/${this.maxHp}`);
// 教学战格挡反馈
if (b.teachStage === 'parry') {
b.teachStage = 'attack';
const ids = b.parry ? ['voice/tut_b3'] : ['voice/tut_b4'];
this._speakGuarded(ids, () => this._playerSelect(), 8000);
return;
}
if (p.hp <= 0) { this._defeat(); return; }
// 血量降档才播(不每击都播)
const bandAfter = this._hpBand(p.hp, this.maxHp);
if (bandAfter !== bandBefore) {
if (bandAfter === 'low') vibrate('warning');
this._speakGuarded([`voice/st_hp_${bandAfter}`], () => { b.round++; this._playerSelect(); }, 5000);
return;
}
b.round++;
this._playerSelect();
}
_fleeCheck() {
const b = this._battle;
if (b.enc.flee) {
b.state = 'FLED';
this.audio.play('sfx/flee');
this._speakGuarded(['voice/btl_flee'], () => this._endBattle('fled'), 6000);
} else {
vibrate('error');
this._speakGuarded(['voice/btl_noflee'], () => this._playerSelect(), 5000);
}
}
_victory() {
const b = this._battle;
b.state = 'VICTORY';
const p = this.player;
// 战利:exp+gold 分档整句
const loot = b.totalExp + b.totalGold;
const lootVoice = loot < 15 ? 'voice/btl_loot_small' : loot <= 40 ? 'voice/btl_loot_mid' : 'voice/btl_loot_big';
const ids = ['voice/btl_win', lootVoice];
if (b.totalGold > 0) this.audio.play('sfx/coin');
p.gold += b.totalGold;
p.exp += b.totalExp;
// 升级结算(可连升)
while (p.lv < 10 && p.exp >= this.expToNext) {
p.exp -= this.expToNext;
p.lv++;
p.hp = Math.min(this.maxHp, p.hp + 8); // maxHp +8,附等额恢复
p.mp = this.maxMp;
this.audio.play('sfx/levelup');
vibrate('success');
ids.push('voice/btl_levelup');
}
if (b.enc.flag) this.flags[b.enc.flag] = true;
this._speakGuarded(ids, () => this._endBattle('victory'), 15000);
}
_defeat() {
const b = this._battle;
b.state = 'DEFEAT';
this.stats.deaths++;
vibrate('hit');
this._after(300, () => vibrate('error'));
this._speakGuarded(['voice/btl_lose', 'voice/btl_lose_hint'], () => {
// 章首重生:等级/金币/装备/flags 保留,本章非 Boss 遭遇重置
this.player.hp = this.maxHp;
this.player.mp = this.maxMp;
this.flags.enc_bandits_done = false;
this.flags.enc_elite_done = false;
this.flags.enc_sneak_done = false;
this._battle = null;
this._save();
this._enterScene('village');
}, 15000);
}
/** 战斗收尾:恢复场景音频、存档、回探索态(或遭遇特定回调)。 */
_endBattle(result) {
const b = this._battle;
this._battle = null;
this._save();
if (result === 'victory' && b.encId === 'enc_teach_wolf') {
this.flags.tutorial_battle_done = true;
this._save();
}
if (result === 'victory' && b.encId === 'enc_wolf') this._wolfDone = true;
if (result === 'victory' && b.encId === 'enc_boss1') { this._chapterCleared(); return; }
if (result === 'victory' && b.onVictory) { b.onVictory(); return; }
// 默认:回当前场景探索态
this.phase = 'explore';
this._switchSceneAudio(this.scene);
this._speakOption();
}
/** 章 Boss 讨伐:章节结算 → bgm/ending → 回村(ch2 未开放)。 */
_chapterCleared() {
this.chapters[1] = 'cleared';
this.flags.boss1_done = true;
this._save();
this._stopAmb();
this.audio.play('bgm/ending');
this._speakGuarded(['voice/evt_ch1_end'], () => {
this._enterScene('village');
}, 15000);
}
// ---------------- 音频层叠(addendum §3) ----------------
/** 探索场景音频:village=bgm/village 音乐;其余=amb 环境音(sfx 层手动循环)。 */
_switchSceneAudio(sceneId) {
if (sceneId === 'village' && settings.get().bgm > 0) {
this._stopAmb();
this.audio.play('bgm/village');
} else {
this.audio.stopBgm(400);
this._playAmb(`sfx/${SCENES[sceneId].amb}`);
}
}
/** 战斗音频:普通 bgm/battle / Boss bgm/boss;bgm=0 时保留 amb 垫底。 */
_switchBattleAudio(isBoss) {
if (settings.get().bgm > 0) {
this._stopAmb();
this.audio.play(isBoss ? 'bgm/boss' : 'bgm/battle');
}
// bgm=0:amb 照播(环境音属内容层,不受音乐设置影响)
}
/**
* amb 手动循环(addendum §3 对实现的唯一新要求):
* sfx 层即发即弃不支持 loop,手动 BufferSource loop 接 _sfxBus,不受 bgm=0 关停。
*/
async _playAmb(id, volume = 0.6) {
if (this._ambId === id && this._ambSrc) return; // 同一环境音不重启
const s = this._session;
const buf = await this.audio._load(id);
if (!buf || !this.audio.ctx || s !== this._session) return;
this._stopAmb(500);
const src = this.audio.ctx.createBufferSource();
src.buffer = buf;
src.loop = true;
const g = this.audio.ctx.createGain();
g.gain.value = 0;
src.connect(g);
g.connect(this.audio._sfxBus);
// 淡入
const t = this.audio.ctx.currentTime;
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(this.audio.sfxVolume * volume, t + 0.5);
src.start();
this._ambSrc = src;
this._ambGain = g;
this._ambId = id;
}
_stopAmb(fadeMs = 300) {
if (!this._ambSrc || !this.audio.ctx) { this._ambSrc = null; this._ambId = null; return; }
const src = this._ambSrc;
const g = this._ambGain;
this._ambSrc = null;
this._ambGain = null;
this._ambId = null;
const t = this.audio.ctx.currentTime;
g.gain.cancelScheduledValues(t);
g.gain.setValueAtTime(g.gain.value, t);
g.gain.linearRampToValueAtTime(0, t + fadeMs / 1000);
setTimeout(() => { try { src.stop(); } catch (_) { /* 已停止 */ } }, fadeMs + 60);
}
// ---------------- 存档 ----------------
_accPlaySec() {
if (this._segStart) {
this.stats.playSec += Math.floor((performance.now() - this._segStart) / 1000);
this._segStart = null;
}
}
_save() {
this._accPlaySec();
if (this.phase !== 'menu') this._segStart = performance.now();
const sceneName = SCENES[this.scene] ? SCENES[this.scene].name : '未知之地';
const data = {
schema: 1,
updatedAt: new Date().toISOString(),
summary: `${CHAPTER_NAMES[1]}·${sceneName} · Lv${this.player.lv} · 金币${this.player.gold}`,
chapter: 1,
scene: this.scene,
player: {
lv: this.player.lv, exp: this.player.exp,
hp: this.player.hp, maxHp: this.maxHp, mp: this.player.mp,
gold: this.player.gold,
weapon: this.player.weapon, armor: this.player.armor,
potions: this.player.potions,
},
flags: { ...this.flags },
chapters: { ...this.chapters },
stats: { ...this.stats },
};
try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); } 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: 'rpg', data }),
}).catch(() => { /* 离线静默 */ });
} catch (_) { /* 静默 */ }
}
_loadSave() {
try {
const data = JSON.parse(localStorage.getItem(SAVE_KEY));
if (!data || data.schema !== 1 || !data.player) return null; // schema 不符丢弃重来(addendum §6.5)
return data;
} catch (_) {
return null;
}
}
// ---------------- 工具 ----------------
/** 语音串行链:自然播完才接下一句;被 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);
},
});
}
/** 带看门狗的语音链:播完或超时先到先触发 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._after(guardMs, fire);
}
_after(ms, fn) {
const s = this._session;
const id = setTimeout(() => {
this._timers = this._timers.filter((t) => t !== id);
if (s === this._session) fn();
}, ms);
this._timers.push(id);
return id;
}
_clearTimers() {
for (const id of this._timers) clearTimeout(id);
this._timers = [];
}
}