Files
SoundGame/web/rpg/game.js
T

1106 lines
43 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%,即时回调规避 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 { 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.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) {
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._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');
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'); // 窗口内做错手势:留在窗口
}
}
// 其他战斗状态:手势忽略
}
_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._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 });
}
/** 战斗音频:普通 bgm/battle / Boss bgm/boss,400ms 快切(urgency,10 文档 §7);amb 停。 */
_switchBattleAudio(isBoss) {
this._stopAmb();
this.audio.play(isBoss ? 'bgm/boss' : 'bgm/battle', { fadeMs: 400 });
}
/**
* 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;
}
}
// ---------------- 工具 ----------------
/** 当前是否有对白在播(引擎 voice token;探索态单击=skipVoice 的语境判定,12 文档 §1)。 */
_voicePlaying() { return !!this.audio._voice; }
/** 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 = [];
}
}