/** * 《剑与声》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 总表)。 * * 你是盲剑客沈听澜——眼盲,耳即眼。节点式场景图探索 + 回合制战斗: * * 探索态手势:左右滑=切选项(播选项名)/ 单击=确认 / 双击=重听场景描述 / * 上滑=状态播报 / 下滑=菜单。 * 战斗态手势:左右滑=切指令(攻击→破空斩→防御→金疮药)/ 单击=执行 / * 双击=格挡(敌方前摇窗口内,减伤 70%,即时回调规避 tap 延迟)/ * 上滑=敌我状态 / 下滑=逃跑(Lv<3 必成;Lv≥3 70%,失败=敌方一个正常 * 前摇回合;Boss/剧情战无效)。 * 战斗重构(docs/design/11-rpg-battle-rebalance.md):首战四步强制教学 * (menu→attack→parry→defend,教学狼 ATK 2 / 前摇 1.8s / HP 锁底 1); * 新手难度按 stats.battlesWon 分档(前 3 场 1.5s×0.6、4-5 场 1.25s×0.8、 * 6 场起标准、Boss 恒标准);战斗四状态声音标识(turn_player/victory 等)。 * * 音频层叠(10 文档 §7 双层:BGM 层报状态 + sfx 层 amb 报场景): * village=bgm/village;其余探索=bgm/explore 轻垫(volume 0.35)+ amb_* 手动 * BufferSource loop 接 sfx 总线;模态菜单=bgm/menu(amb 不停);战斗 400ms * 快切 bgm/battle|bgm/boss(amb 停);胜利=sfx/victory → 战斗 BGM 淡出 1.5s * → 探索音乐淡入 2s;章结算=bgm/ending。bgm=0 时引擎只记意图不播放。 * 旁白跳过(12 文档 §1):探索态 voice 播放中单击=skipVoice() 跳过本句 * (链继续);战斗指令名 cmd_* 与教学句 tut_b* 不可跳。开场插播一次 * voice/skip_hint。长按=引擎内置旁白 1.7 倍速(游戏零改动)。 * 震动(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 { Guide } from '../engine/guide.js'; import { vibrate } from '../engine/haptics.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: 2, def: 0, exp: 5, gold: 0, tel: 'sfx/tel_wolf', start: 'voice/btl_start_wolf' }, // ATK 3→2(11 文档 §2 教学特例) 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', half: 'voice/btl_half_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/turn_player', 'sfx/victory', '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.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op) 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, battlesWon: 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, boss1_warned: 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', 'voice/skip_hint']); 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(); // 开场插播一次跳过提示(12 文档 §3 预留的 sys_skip_hint 语义,rpg 用既有 skip_hint 资产) this._speakGuarded(['voice/intro_1', 'voice/intro_2', 'voice/intro_3', 'voice/skip_hint', '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; // 旧存档可能缺新增字段(battlesWon 等),合并默认值兜底 this.stats = { kills: 0, deaths: 0, parries: 0, playSec: 0, battlesWon: 0, ...save.stats }; this.visited = new Set(); // 回访描述按本轮会话计(读档后重听完整 desc 更友好) this._wolfDone = false; this.phase = 'explore'; this._segStart = performance.now(); this._enterScene(save.scene || 'village'); } // ---------------- 手势 ---------------- onGesture(g) { this.guide.disarm(); // 任何手势复位 PLAYER_SELECT 看门狗(guide=off 时 no-op) 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, opts.fadeMs || 800); this.audio.play('sfx/step', { volume: 0.8 }); // 进 Boss 战前强制提示(11 文档 §4):Lv<3 首次到寨门插播 boss1_warn,给知情回头机会 const prepend = [...(opts.prepend || [])]; if (id === 'gate' && !this.flags.boss1_done && !this.flags.boss1_warned && this.player.lv < 3) { prepend.push('voice/boss1_warn'); this.flags.boss1_warned = true; } 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([...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') { // voice 播放中单击 = 跳过本句(12 文档 §1:场景 desc/NPC 对白/事件/状态链可跳), // 语音链跳下一句;无 voice 时单击 = 确认选项(原语义)。 if (this._voicePlaying()) { this.audio.skipVoice(); return; } 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.audio.play('bgm/menu'); // 模态菜单状态音乐(10 文档 §7:amb 继续,"游戏还在,界面开了") this.guide.state('menu'); // 状态签名:菜单(与状态 BGM 同点) this._speak(['voice/sys_menu', `voice/${MENU_ITEMS[0]}`]); } _closeMenu() { this.phase = 'explore'; this._segStart = performance.now(); this._switchSceneAudio(this.scene); // 淡回场景状态音乐(village/explore) 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, // 四步强制教学(11 文档 §2):menu(认识指令)→attack→parry→defend→自由击杀 teachStage: enc.teach ? 'menu' : null, teachSwipes: 0, // 难度档位(11 文档 §3):教学战特例前摇 1.8s;其余按 battlesWon 取档,Boss 恒标准 tier: enc.teach ? { telSec: 1.8, dmgMul: 1.0 } : this._battleTier(enc), telRate: 1, }; this._switchBattleAudio(enc.boss); this._nextEnemy(); } /** 新手保护档位(11 文档 §3 数值表):每场战斗开始时按 stats.battlesWon 取档。 */ _battleTier(enc) { if (enc.boss) return { telSec: 1.0, dmgMul: 1.0 }; // Boss 是毕业考,恒标准 const w = this.stats.battlesWon || 0; if (w <= 2) return { telSec: 1.5, dmgMul: 0.6 }; // 保护档:第 1-3 场 if (w <= 4) return { telSec: 1.25, dmgMul: 0.8 }; // 过渡档:第 4-5 场 return { telSec: 1.0, dmgMul: 1.0 }; // 标准档:第 6 场起 } _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 === 'menu') { // R6 首战教学链(tut_battle_1..5,round6-report §4.4):全周期首次才播,二周目起跳过 if (!this._tutIntroSeen()) { ids.push('voice/tut_battle_1', 'voice/tut_battle_2', 'voice/tut_battle_3', 'voice/tut_battle_4', 'voice/tut_battle_5'); this._markTutIntroSeen(); } ids.push('voice/tut_b0'); // T0:战斗里,左右滑选招式,单击出手…… } this._speakGuarded(ids, () => { this._playerSelect(); if (b.teachStage === 'menu') this._armTeachWatchdog('menu'); }, 60000); } _playerSelect() { const b = this._battle; if (!b) return; b.state = 'PLAYER_SELECT'; // 玩家回合的听觉标点(11 文档 §5):turn_player(≤0.4s)→ 留 200ms+ 间隙 → 指令名 this.audio.play('sfx/turn_player'); // 向导层静默看门狗(schemes §2):探索/常规战斗本无卡关计时,PLAYER_SELECT // 挂机永远静默等——60s 无输入 → nudge + gd_stall(一级即止,自动停止) this.guide.watchdog({ arm: true, at: [60000], say: ['gd_stall'], cond: () => this.phase === 'battle' && this._battle === b && b.state === 'PLAYER_SELECT', }); const cmdIdx = b.cmdIdx; this._after(650, () => { if (this._battle === b && b.state === 'PLAYER_SELECT' && b.cmdIdx === cmdIdx) { this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`); } }); } /** 教学步推进(11 文档 §2 四步:menu→attack→parry→defend→free)。 */ _teachAdvance(next) { const b = this._battle; if (!b || !b.teachStage) return; b.teachStage = next === 'free' ? null : next; if (next === 'attack') this._speakGuarded(['voice/tut_b3'], () => this._playerSelect(), 8000); else if (next === 'parry') this._speakGuarded(['voice/tut_b1'], () => this._enemyTelegraph(), 8000); else if (next === 'defend') this._speakGuarded(['voice/tut_b6'], () => this._playerSelect(), 8000); else this._speakGuarded(['voice/tut_b5'], () => this._playerSelect(), 8000); // free:收尾 if (next === 'attack' || next === 'defend') this._armTeachWatchdog(next); } /** * 教学看门狗(11 文档 §2/§7:每段 8s 无操作给一次重复提示;16s 仍无进展兜底 * 推进,绝不卡死)。parry 步无需兜底——前摇计时器会自然结算推进。 */ _armTeachWatchdog(stage) { const b = this._battle; if (!b || !stage) return; const hint = { menu: ['voice/tut_b0'], attack: ['voice/tut_b3'], defend: ['voice/tut_b6'] }[stage]; this._after(8000, () => { if (this._battle === b && b.teachStage === stage && b.state === 'PLAYER_SELECT' && hint) { this._speak(hint); // 重复提示一次 } }); this._after(16000, () => { if (this._battle !== b || b.teachStage !== stage) return; if (b.state !== 'PLAYER_SELECT') return; // 回合交换已在推进,无卡死风险 if (stage === 'menu') this._teachAdvance('attack'); else if (stage === 'attack') this._teachAdvance('parry'); else if (stage === 'defend') { b.teachStage = null; this._speakGuarded(['voice/tut_b5'], () => this._playerSelect(), 8000); } }); } _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]}`); // 教学 T0:切换 2 次(听到指令名切换)即过关 if (b.teachStage === 'menu' && ++b.teachSwipes >= 2) this._teachAdvance('attack'); } 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'); // 窗口内做错手势:留在窗口 // 向导层(schemes §2):窗口内做错原本仅震动无语音(清点 §6.4 薄弱点) // → full 档 gd_parry_miss(已震 error,skipHaptic 免重复;onceKey 每局一次) this.guide.error({ voice: 'gd_parry_miss', onceKey: 'rpg_parry_miss', skipHaptic: true }); } } // 其他战斗状态:手势忽略 } _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}`); // 教学 T1:命中一次(敌未亡)→ 进入格挡教学 T2;教学步之外正常结算 if ((b.teachStage === 'menu' || b.teachStage === 'attack') && e.hp > 0) { this._teachAdvance('parry'); return; } this._checkEnemyDead(); } else if (cmd === 'defend') { b.defending = true; // 无播报,下回合减伤自明(addendum §1) // 教学 T3:执行防御一次 → T4 收尾("接下来靠你自己了",自由击杀) if (b.teachStage === 'defend') { b.teachStage = null; this._speakGuarded(['voice/tut_b5'], () => this._checkEnemyDead(), 8000); return; } 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) { b.teachStage = null; // 教学中被提前击杀:教学结束,走正常胜利(绝不卡死) 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; // 前摇时长按难度档位(11 文档 §3);听风剑加成照旧叠加 const windowSec = b.tier.telSec + (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); dmg = Math.max(1, Math.floor(dmg * b.tier.dmgMul)); // 新手保护伤害系数(向下取整 min 1,11 文档 §3) 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}`); // 教学 T2:格挡成功或受击均可通过 → 进入防御教学 T3(11 文档 §2) if (b.teachStage === 'parry') { b.teachStage = 'defend'; const ids = b.parry ? ['voice/tut_b6'] : ['voice/tut_b4', 'voice/tut_b6']; this._speakGuarded(ids, () => this._playerSelect(), 10000); this._armTeachWatchdog('defend'); 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) { // Boss/剧情战不可逃 vibrate('error'); this._speakGuarded(['voice/btl_noflee'], () => this._playerSelect(), 5000); return; } // 逃跑新手保护(11 文档 §4):Lv<3 必成;Lv≥3 70%,失败代价 = 敌方一个 // 正常前摇回合(可格挡,不白打) const rate = this.player.lv < 3 ? 1.0 : 0.7; if (Math.random() < rate) { b.state = 'FLED'; this.audio.play('sfx/flee'); this._speakGuarded(['voice/btl_flee'], () => this._endBattle('fled'), 6000); } else { b.state = 'FLEE_FAIL'; this._speakGuarded(['voice/btl_flee_fail'], () => this._enemyTelegraph(), 6000); } } _victory() { const b = this._battle; b.state = 'VICTORY'; this.stats.battlesWon = (this.stats.battlesWon || 0) + 1; // 难度档位依据(11 文档 §3) this.audio.play('sfx/victory'); // 胜利标识音(3 音上行短号角,11 文档 §5) 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++; this.audio.stopBgm(2000); // 战败:战斗 BGM 2s 淡出(10 文档 §7) 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', { fadeMs: 1500 }); // 重生即安全区音乐(淡入 1.5s) }, 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; } // 默认:回当前场景探索态。战斗→探索的关键过渡(10 文档 §7): // 战斗 BGM 淡出 1500ms → 探索状态音乐淡入 2000ms + amb 恢复 this.phase = 'explore'; this.audio.stopBgm(1500); this._switchSceneAudio(this.scene, 2000); this._speakOption(); } /** 章 Boss 讨伐:章节结算(开第二章)→ bgm/ending → 回村。 */ _chapterCleared() { this.chapters[1] = 'cleared'; this.chapters[2] = 'open'; // 第一章通关解锁第二章(13 文档 §6 断言) this.flags.boss1_done = true; this._save(); this._stopAmb(); this.audio.play('bgm/ending'); this.guide.state('settle'); // 状态签名:章结算(与状态 BGM 同点) this._speakGuarded(['voice/evt_ch1_end'], () => { this._enterScene('village'); }, 15000); } // ---------------- 音频层叠(10 文档 §7:BGM 层报状态 + sfx 层 amb 报场景) ---------------- /** * 探索场景音频:village=bgm/village;其余=bgm/explore 轻垫(volume 0.35,R6 恢复); * amb_* 环境音(sfx 层手动循环)各场景常开。bgm=0 时引擎只记意图不播放,游戏无需判断。 */ _switchSceneAudio(sceneId, fadeMs = 800) { this._playAmb(`sfx/${SCENES[sceneId].amb}`); if (sceneId === 'village') this.audio.play('bgm/village', { fadeMs }); else this.audio.play('bgm/explore', { volume: 0.35, fadeMs }); this.guide.state('explore'); // 状态签名:探索(与状态 BGM 同点) } /** 战斗音频:普通 bgm/battle / Boss bgm/boss,400ms 快切(urgency,10 文档 §7);amb 停。 */ _switchBattleAudio(isBoss) { this._stopAmb(); this.audio.play(isBoss ? 'bgm/boss' : 'bgm/battle', { fadeMs: 400 }); this.guide.state('battle'); // 状态签名:战斗(与状态 BGM 同点) } /** * 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; } } // ---------------- 工具 ---------------- /** 当前是否有对白在播(探索态单击=skipVoice 的语境判定,12 文档 §1)。 */ _voicePlaying() { return this.audio.isVoicePlaying(); } // 公开 API(原读私有 _voice,清点 §0) /** R6 首战教学链(tut_battle_1..5)全周期只播一次:跨存档持久化,二周目起跳过。 */ _tutIntroSeen() { try { return localStorage.getItem('soundgame.rpg.tutIntroSeen') === '1'; } catch (_) { return false; } } _markTutIntroSeen() { try { localStorage.setItem('soundgame.rpg.tutIntroSeen', '1'); } 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); }, }); } /** 带看门狗的语音链:播完或超时先到先触发 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 = []; } }