1148 lines
46 KiB
JavaScript
1148 lines
46 KiB
JavaScript
/**
|
||
* 《剑与声》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;
|
||
// F7:回绕提示去重(同一次菜单打开内每句最多 1 次)
|
||
this._wrapShown = new Set();
|
||
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._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._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;
|
||
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;
|
||
// 回绕提示排在本项报名播完之后;被下一次滑动打断则自然作废(不迟到误导)
|
||
this._speakOption(wrap ? () => this._wrapCue(wrap) : undefined);
|
||
} 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';
|
||
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(一级即止)
|
||
this.guide.watchdog({
|
||
arm: true,
|
||
at: [30000],
|
||
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;
|
||
// 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.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`,
|
||
wrap ? { onComplete: () => 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.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)。
|
||
// F4(round2):格挡**成功**通过时先插 tut_b2b「挡得漂亮。」确认表现,
|
||
// 消除 P1/P4「明明挡住了还说来不及格挡」的歧义(失败路径 tut_b4 不变)。
|
||
if (b.teachStage === 'parry') {
|
||
b.teachStage = 'defend';
|
||
const ids = b.parry ? ['voice/tut_b2b', '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 = [];
|
||
}
|
||
}
|