HTML5 重写:6 款游戏 + 用户系统 + 全局设置 + 5 轮迭代重构

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