Files
SoundGame/web/rpg/game.js
T

1277 lines
56 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 总表)。
*
* 你是盲剑客沈听澜——眼盲,耳即眼。节点式场景图探索 + 回合制战斗:
*
* 探索态手势:左右滑=切选项(播选项名)/ 单击=确认 / 双击=重听场景描述 /
* 上滑=状态播报 / 下滑=菜单。
* 战斗态手势:左右滑=切指令(攻击→破空斩→防御→金疮药)/ 单击=执行 /
* 双击=格挡(敌方前摇窗口内,减伤 70%、擦伤钳 1~3——11 文档 §3「格挡成功
* 2-3 伤」,即时回调规避 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() 跳过本句
* (链继续);在播句为选项报名/边界提示时单击同时=确认当前选项(跳句+
* 补发语义动作,round2-live P1 探索菜单吞 tap 修复)。战斗指令名 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;
// F7:回绕提示去重(同一次菜单打开内每句最多 1 次)
this._wrapShown = new Set();
// round1-live P2:向导插播句(gd_stall)开播时刻(看门狗 onEscalate 记录),
// _speak 据此让结算语音链排队等它播完(不拦腰顶断);_lastGestureAt 供教学
// 8s 重复提示的"玩家有操作则跳过"判活
this._guideVoiceAt = 0;
this._lastGestureAt = 0;
// round2-live P1:探索态当前在播语音的类别('scene' desc 链/'option' 选项报名
// 与边界提示/'status' 状态链/'event' 事件·NPC 句)——语音中单击的语义据此分流:
// 'option' = 跳句+补发确认(执行当前选项),其余 = 只跳句(12 文档 §1 跳句惯例)
this._exploreVoiceKind = 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)
this._lastGestureAt = performance.now(); // 教学 8s 重复提示的判活依据(round1-live P2-3)
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._wrapShown = new Set(); // F7:场景选项列表重新"打开",回绕提示重新可用
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._exploreVoiceKind = 'scene'; // round2-live P1:desc 链在播=只跳句不确认
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(onComplete) {
if (this.phase !== 'explore') return;
const opts = SCENES[this.scene].options;
if (!opts.length) return;
// round2-live P1:选项报名(及其 onComplete 接播的边界提示)在播期间,
// 单击=跳句+确认当前选项(探索菜单吞 tap 修复,见 _onExploreGesture)
this._exploreVoiceKind = 'option';
this.audio.play(`voice/opt_${opts[this.optIdx]}`, { onComplete });
}
/**
* F7(round2):循环列表切回首项/末项的回绕提示(P1「不报回绕」)。full 档
* queue 跟在本项报名之后;同一次菜单打开内每句最多 1 次(_wrapShown),别每滑必播。
*/
_wrapCue(voiceId) {
if (this.guide.level() !== 'full') return;
if (this._wrapShown.has(voiceId)) return;
this._wrapShown.add(voiceId);
this.audio.play(`voice/${voiceId}`, { queue: true });
}
_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;
const wrap = n >= 2 && ((g === 'right' && this.optIdx === 0) || (g === 'left' && this.optIdx === n - 1))
? (this.optIdx === 0 ? 'gd_wrap_first' : 'gd_wrap_last') : null;
// round1-live P3:右滑落到"返回"项(各场景恒为末项)也补一句末位提示
// 「最后一个了。」——给循环列表一个听觉边界,防多滑一格误选返回被弹回
// 上一张图(每场景一次,与回绕提示共用 _wrapShown 去重;序号播报无现成
// 语音资产,按任务裁定做前者,不新增语音 id)
const cue = wrap || (opts[this.optIdx] === 'back' ? 'gd_wrap_last' : null);
// 回绕/末位提示排在本项报名播完之后;被下一次滑动打断则自然作废(不迟到误导)
this._speakOption(cue ? () => this._wrapCue(cue) : undefined);
} else if (g === 'tap') {
// voice 播放中单击 = 跳过本句(12 文档 §1:场景 desc/NPC 对白/事件/状态链可跳),
// 语音链跳下一句;无 voice 时单击 = 确认选项(原语义)。
// round2-live P1:在播句是"选项报名/边界提示"(_exploreVoiceKind==='option')时,
// 单击在跳句同时补发确认语义(执行当前选项)——live 证据:选项报名开播 2.3s 时
// 单击被整个吞掉(语音被掐、场景未切、死寂 72s,1264.6s),「最后一个了。」开播
// 0.16s 时单击"返回"同样被吞(1370.1s);战斗里语音中单击却有效(844.5s),
// 规则必须一致:动作绝不石沉大海。desc/NPC/状态链播放中单击仍只跳句(A5/B6 契约)。
if (this._voicePlaying()) {
// 先取样类别:skipVoice 同步触发 onComplete 推进链(末句会接播选项报名并
// 改写 _exploreVoiceKind),若跳句后再读类别会把"跳 desc"误判成"确认选项"
const kind = this._exploreVoiceKind;
this.audio.skipVoice();
if (kind === 'option') this._executeOption(opts[this.optIdx]);
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._exploreVoiceKind = 'scene'; // round2-live P1:重听链视同 desc(语音中单击=只跳句)
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; }
if (opt === 'back') { this._enterScene(BACK_MAP[this.scene] || 'village'); return; }
// round2-live P1:以下选项原地播事件/NPC/状态语音(非选项报名)——标记后
// 语音中单击=只跳句、不重复触发动作(shop 切 phase 后本标记不再被读取)
this._exploreVoiceKind = 'event';
switch (opt) {
case 'shop':
this.phase = 'shop';
this._shopIdx = 0;
this._wrapShown = new Set(); // 商店子菜单重新"打开"(round1-live P3:离开项末位提示重新可用)
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._exploreVoiceKind = 'status'; // round2-live P1:状态链在播=只跳句不确认
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;
const it = SHOP_ITEMS[this._shopIdx];
// round1-live P3:落到"离开"项(末项)补末位提示「最后一个了。」(每次进商店一次)
this.audio.play(it.priceVoice,
it.type === 'leave' ? { onComplete: () => this._wrapCue('gd_wrap_last') } : undefined);
} 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,
// round1-live P2:resolving=回合结算中(闲置提示禁止插入);
// selectSeq=PLAYER_SELECT 代数(旧回合滞留的指令名定时器据此作废);
// cmdVoiceIdx/cmdVoiceAt=最近一次指令名播报(1.2s 去重防"攻攻击"口吃)
resolving: false,
selectSeq: 0,
cmdVoiceIdx: -1,
cmdVoiceAt: 0,
// round2-live P1:hpBandVoice=玩家"最后被告知"的血量档(开战=当前档)——
// 受伤播报只在实质受击且比它差时播当前实际档(擦伤不触发、同档不重复)
hpBandVoice: this._hpBand(this.player.hp, this.maxHp),
// 难度档位(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';
b.resolving = false; // round1-live P2:回合结算结束、回到等待输入,闲置提示恢复可入
b.selectSeq++; // 指令名定时器代数+1:上一回合滞留的 650ms 定时器据此作废
this._wrapShown = new Set(); // F7:指令循环重新"打开",回绕提示重新可用
// 玩家回合的听觉标点(11 文档 §5):turn_player(≤0.4s)→ 留 200ms+ 间隙 → 指令名
this.audio.play('sfx/turn_player');
// 向导层静默看门狗(round2 F1.3:60s→30s,等待默认低频但不再干晾一分钟):
// PLAYER_SELECT 挂机永远静默等——30s 无输入 → nudge + gd_stall(一级即止)。
// round1-live P2:回合结算/语音链进行中禁止闲置提示插入(cond 加 !b.resolving);
// onEscalate 记开播时刻,_speak 据此让结算语音排队等 gd_stall 播完(不拦腰顶断)
this.guide.watchdog({
arm: true,
at: [30000],
say: ['gd_stall'],
onEscalate: () => { this._guideVoiceAt = performance.now(); },
cond: () => this.phase === 'battle' && this._battle === b && b.state === 'PLAYER_SELECT' && !b.resolving,
});
const cmdIdx = b.cmdIdx;
const seq = b.selectSeq;
this._after(650, () => {
if (this._battle === b && b.state === 'PLAYER_SELECT' && b.cmdIdx === cmdIdx && b.selectSeq === seq) {
this._playCmdVoice();
}
});
}
/**
* 指令名播报(round1-live P2 口吃修复):同一指令 1.2s 内已播过则跳过本次——
* 手势打断刚开播的指令名后,滞留定时器/折返滑不立刻原样重播("攻攻击。"卡碟)。
*/
_playCmdVoice(onComplete) {
const b = this._battle;
if (!b) { if (onComplete) onComplete(); return; }
const now = performance.now();
if (b.cmdVoiceIdx === b.cmdIdx && now - b.cmdVoiceAt < 1200) return; // 去重:跳过本次
b.cmdVoiceIdx = b.cmdIdx;
b.cmdVoiceAt = now;
this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`, onComplete ? { onComplete } : undefined);
}
/** 教学步推进(11 文档 §2 四步:menu→attack→parry→defend→free)。 */
_teachAdvance(next) {
const b = this._battle;
if (!b || !b.teachStage) return;
b.teachStage = next === 'free' ? null : next;
// round1-live P2-3:T1 不再播 tut_b3——其实录为旧两步教学(格挡→攻击)台词
// 「挡住了!轮到你了。左右滑选攻击,单击出剑。」,新顺序 T1 在格挡之前播
// 「挡住了!」与实际不符;改播 tut_battle_2(现有指令教学句,语义一致)。
// tut_b3 资产保留但退出教学链(TTS 缺失不可重录,列遗留)。
if (next === 'attack') this._speakGuarded(['voice/tut_battle_2'], () => 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_battle_2'], defend: ['voice/tut_b6'] }[stage];
// round1-live P2-3:挂载时刻之后玩家有任何操作 → 8s 重复提示跳过
// (玩家正在边滑边想,重复提示=循环重播打断出手);16s 兜底推进不变
const armedAt = performance.now();
this._after(8000, () => {
// round2-live P2:重复提示是"自动轮播"语音——有语音在播(如链尾句未播完)
// 则跳过本次,绝不拦腰顶断(统一"播完再上"治理,live 333s 互掐证据)
if (this._battle === b && b.teachStage === stage && b.state === 'PLAYER_SELECT' && hint
&& this._lastGestureAt <= armedAt && !this._voicePlaying()) {
this._speak(hint); // 重复提示一次
}
});
// round2-live P2:16s 兜底到点遇语音在播(如 8s 重复提示仍在播)→ 每 1.5s
// 重试、等播完再推进,不拦腰斩句(live 333.09s:tut_b0 重复句播到 7.9s 被
// 游戏自己兜底推进的 tut_battle_2 掐断);语音通道空出即推进,不卡死
const tryAdvance = () => {
if (this._battle !== b || b.teachStage !== stage) return;
if (b.state !== 'PLAYER_SELECT') return; // 回合交换已在推进,无卡死风险
if (this._voicePlaying()) { this._after(1500, tryAdvance); 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);
}
};
this._after(16000, tryAdvance);
}
_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;
// F7:指令循环回绕提示(探索菜单同款,full 档,跟在本项报名之后)
const wrap = (g === 'right' && b.cmdIdx === 0) || (g === 'left' && b.cmdIdx === CMDS.length - 1)
? (b.cmdIdx === 0 ? 'gd_wrap_first' : 'gd_wrap_last') : null;
this._playCmdVoice(wrap ? () => this._wrapCue(wrap) : undefined);
// 教学 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._playCmdVoice(); });
} 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';
b.resolving = true; // round1-live P2:回合结算开始,闲置提示禁止插入
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) {
// 格挡减伤 70%(不震 hit,已震 confirm)。round1-live P1:擦伤钳 1~3——
// 对齐 11 文档 §3「格挡成功 2-3 伤」(原实现 round 无封顶,Boss 二阶段
// 单发擦伤可达 4,全格挡 8 刀可把 36 血磨进 low 档,播报与实际战况矛盾);
// min 1 保住"格挡也有擦伤"的痛感(设计意图=擦伤,非全免伤,05-rpg §2 减伤 70%)
dmg = Math.min(3, Math.max(1, Math.round(dmg * 0.3)));
} else {
if (b.defending) dmg = Math.round(dmg * 0.5); // 防御减半(与格挡不叠加)
this.audio.play('sfx/hurt');
vibrate('hit');
}
b.defending = false;
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)。
// F4(round2):格挡**成功**通过时先插 tut_b2b「挡得漂亮。」确认表现,
// 消除 P1/P4「明明挡住了还说来不及格挡」的歧义(失败路径 tut_b4 不变)。
// round1-live P1:两条路径都插 tut_battle_4「没把握就防御,伤害减半。金疮药
// 可以回血。」——在格挡体验点明说减伤经济(格挡≠免伤、防御减半、药回血)。
// 无"格挡也有擦伤"原句资产(TTS 缺失不可新录,列遗留补录),选播现有最近说明句。
if (b.teachStage === 'parry') {
b.teachStage = 'defend';
const ids = b.parry
? ['voice/tut_b2b', 'voice/tut_battle_4', 'voice/tut_b6']
: ['voice/tut_b4', 'voice/tut_battle_4', 'voice/tut_b6'];
// 三句链真实时长 ~16s:兜底放宽到 20s(原 10s 会在链中误触发进 PLAYER_SELECT,
// cmd 指令名拦腰顶断 tut_b6);教学看门狗改到链播完进回合时才挂载,
// 8s/16s 计时从玩家真正可操作起算(16s 兜底不再抢在 tut_b6 播完前跳步)
this._speakGuarded(ids, () => {
this._playerSelect();
this._armTeachWatchdog('defend');
}, 20000);
return;
}
if (p.hp <= 0) { this._defeat(); return; }
// round2-live P1 血量档位播报重连线(设计依据:addendum §1 受击行——st_hp_mid/
// st_hp_low 本就是"受击(未格挡)"的附属播报「降档才播」,不是血量变化的全局
// 钩子;live 事件流证据:全格挡 20 次、0 hurt 音效,播报却精确落在格挡音后
// 0.09s——962.1/984.0s 等 3 次"受伤/命悬一线"全是擦伤跨档触发,警报被驯化成
// 背景音)。裁定:
// a) 格挡擦伤(1~3)静默走血,绝不触发受伤类播报——痛感由格挡音+confirm 震动
// 承担(11 文档 §3"格挡成功 2-3 伤"是技巧收益,不该配受伤警报);
// b) 只在实质受击(格挡失败/未格挡)时播,且播报档=结算后当前实际档——
// hpBandVoice 记"玩家最后被告知档",比它差才播并同步,播报与实际档位
// 恒一致(擦伤暗中跨档后,第一刀实质受击会按当前实际档补播,不会错位);
// c) 节流:同档不重复喊(中途回血升档静默;之后经中间档再次跌破视为新降档,
// 可重播一次)。
if (!b.parry) {
const band = this._hpBand(p.hp, this.maxHp);
const WORSE = { high: 0, mid: 1, low: 2 };
if (WORSE[band] > WORSE[b.hpBandVoice]) {
b.hpBandVoice = band;
if (band === 'low') vibrate('warning');
this._speakGuarded([`voice/st_hp_${band}`], () => { b.round++; this._playerSelect(); }, 5000);
return;
}
b.hpBandVoice = band; // 未降档(含回血后升档):静默同步"最后被告知档"
}
b.round++;
this._playerSelect();
}
_fleeCheck() {
const b = this._battle;
b.resolving = true; // round1-live P2:逃跑判定=回合结算,闲置提示禁止插入
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;
// round1-live P2:向导插播句(gd_stall 等)刚开播还在播时,本链首句排队等它
// 播完,不拦腰顶断("想好了吗?"说半句被结算语音掐掉);余波 6s 后自愈
// (覆盖 gd_stall 男声 ~4.9s 全长),之后恢复默认顶句语义(不影响正常链间切换)
const justGuide = this._guideVoiceAt > 0
&& performance.now() - this._guideVoiceAt < 6000 && this._voicePlaying();
// round2-live P2:刚开播的指令名(cmd_* ≤1.2s 短句)还在播时,本链首句同样
// 排队等它播完——杀敌/半血/降档等结算语音不拦腰斩指令名(live 843.9s:
// 「攻击。」开播 0.9s 被 enemy_die 掐成残句);余波 1.6s 后恢复默认顶句语义
const b = this._battle;
const justCmd = !!(b && b.cmdVoiceAt > 0
&& performance.now() - b.cmdVoiceAt < 1600 && this._voicePlaying());
const opts = {
onComplete: () => {
if (s !== this._session) return;
this._speak(rest, onDone);
},
};
if (justGuide || justCmd) opts.queue = true;
this.audio.play(head, opts);
}
/** 带看门狗的语音链:播完或超时先到先触发 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 = [];
}
}