第二批反馈:引擎改造(限幅/SoundTouch不变调变速/长按快进/跳句/iOS闪光降级) + 6游戏状态BGM与试玩测试全绿

This commit is contained in:
SoundGame Dev
2026-08-30 16:45:42 +08:00
parent cc0f8c008d
commit 9a915d62c0
1163 changed files with 6346 additions and 208 deletions
+177 -54
View File
@@ -8,12 +8,22 @@
* 探索态手势:左右滑=切选项(播选项名)/ 单击=确认 / 双击=重听场景描述 /
* 上滑=状态播报 / 下滑=菜单。
* 战斗态手势:左右滑=切指令(攻击→破空斩→防御→金疮药)/ 单击=执行 /
* 双击=格挡(敌方前摇 1.0s 窗口内,减伤 70%,即时回调规避 tap 延迟)/
* 上滑=敌我状态 / 下滑=逃跑(Boss/剧情战无效)。
* 双击=格挡(敌方前摇窗口内,减伤 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 等)。
*
* 音频层叠(addendum §3):village 播 bgm/village 音乐;其余探索场景**无 BGM**,
* amb_* 环境音走 sfx 层手动 BufferSource loop(不受设置 bgm=0 影响,见
* _playAmb);战斗 crossfade 替换为 bgm/battle|bgm/boss,战后回场景 amb。
* 音频层叠(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。
@@ -24,7 +34,6 @@
import { Game } from '../engine/game.js';
import { vibrate } from '../engine/haptics.js';
import { settings } from '../engine/settings.js';
// ================================ 数据表 ================================
@@ -46,9 +55,10 @@ const GO_TARGET = { go_path: 'path', go_fork: 'fork', go_chest: 'chest', go_stre
// 敌人表(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' },
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' },
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' },
@@ -96,7 +106,7 @@ 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/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',
@@ -129,11 +139,11 @@ export class RpgGame extends Game {
}
_newRun(keepStats) {
const stats = keepStats && this.stats ? this.stats : { kills: 0, deaths: 0, parries: 0, playSec: 0 };
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,
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' };
@@ -156,7 +166,7 @@ export class RpgGame extends Game {
start() {
this.audio.prewarm(PREWARM_SFX);
this.audio.prewarm(['voice/intro_1', 'voice/sys_continue_ask', 'voice/desc_village', 'voice/npc_headman_1']);
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) {
@@ -184,7 +194,8 @@ export class RpgGame extends Game {
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'], () => {
// 开场插播一次跳过提示(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);
@@ -197,7 +208,8 @@ export class RpgGame extends Game {
this.player = save.player;
this.flags = save.flags;
this.chapters = save.chapters;
this.stats = save.stats;
// 旧存档可能缺新增字段(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';
@@ -242,9 +254,16 @@ export class RpgGame extends Game {
this.scene = id;
this.optIdx = 0;
this.phase = 'explore';
this._switchSceneAudio(id);
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}`;
@@ -252,7 +271,7 @@ export class RpgGame extends Game {
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._speakGuarded([...prepend, desc], () => {
this._save();
if (this._checkEncounter()) return;
this._speakOption();
@@ -300,6 +319,9 @@ export class RpgGame extends Game {
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 循环持续,无需重播)
@@ -473,12 +495,14 @@ export class RpgGame extends Game {
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();
}
@@ -528,13 +552,26 @@ export class RpgGame extends Game {
totalExp: 0,
totalGold: 0,
onVictory: onVictory || null,
teachStage: enc.teach ? 'parry' : 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();
@@ -545,18 +582,70 @@ export class RpgGame extends Game {
b.state = 'B_START';
const ids = [base.start];
if (base.npcStart) ids.push(base.npcStart);
if (b.teachStage === 'parry') ids.push('voice/tut_b1'); // 它要扑了,听前摇!
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, () => {
if (b.teachStage === 'parry') this._enemyTelegraph();
else this._playerSelect();
}, 15000);
this._playerSelect();
if (b.teachStage === 'menu') this._armTeachWatchdog('menu');
}, 60000);
}
_playerSelect() {
const b = this._battle;
if (!b) return;
b.state = 'PLAYER_SELECT';
this.audio.play(`voice/cmd_${CMDS[b.cmdIdx]}`);
// 玩家回合的听觉标点(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) {
@@ -566,6 +655,8 @@ export class RpgGame extends Game {
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') {
@@ -614,15 +705,20 @@ export class RpgGame extends Game {
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);
// 教学 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--;
@@ -638,6 +734,7 @@ export class RpgGame extends Game {
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;
@@ -674,7 +771,8 @@ export class RpgGame extends Game {
const b = this._battle;
b.state = 'ENEMY_TELEGRAPH';
b.parry = false;
const windowSec = 1.0 + (this.player.weapon && WEAPONS[this.player.weapon].parryBonus || 0);
// 前摇时长按难度档位(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)
@@ -704,6 +802,7 @@ export class RpgGame extends Game {
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 {
@@ -716,11 +815,12 @@ export class RpgGame extends Game {
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 = 'attack';
const ids = b.parry ? ['voice/tut_b3'] : ['voice/tut_b4'];
this._speakGuarded(ids, () => this._playerSelect(), 8000);
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; }
@@ -737,19 +837,30 @@ export class RpgGame extends Game {
_fleeCheck() {
const b = this._battle;
if (b.enc.flee) {
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 {
vibrate('error');
this._speakGuarded(['voice/btl_noflee'], () => this._playerSelect(), 5000);
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;
@@ -776,6 +887,7 @@ export class RpgGame extends Game {
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'], () => {
@@ -787,7 +899,7 @@ export class RpgGame extends Game {
this.flags.enc_sneak_done = false;
this._battle = null;
this._save();
this._enterScene('village');
this._enterScene('village', { fadeMs: 1500 }); // 重生即安全区音乐(淡入 1.5s)
}, 15000);
}
@@ -803,15 +915,18 @@ export class RpgGame extends Game {
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._switchSceneAudio(this.scene);
this.audio.stopBgm(1500);
this._switchSceneAudio(this.scene, 2000);
this._speakOption();
}
/** 章 Boss 讨伐:章节结算 → bgm/ending → 回村(ch2 未开放)。 */
/** 章 Boss 讨伐:章节结算(开第二章)→ bgm/ending → 回村。 */
_chapterCleared() {
this.chapters[1] = 'cleared';
this.chapters[2] = 'open'; // 第一章通关解锁第二章(13 文档 §6 断言)
this.flags.boss1_done = true;
this._save();
this._stopAmb();
@@ -821,26 +936,22 @@ export class RpgGame extends Game {
}, 15000);
}
// ---------------- 音频层叠(addendum §3) ----------------
// ---------------- 音频层叠(10 文档 §7:BGM 层报状态 + sfx 层 amb 报场景) ----------------
/** 探索场景音频: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}`);
}
/**
* 探索场景音频: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;bgm=0 时保留 amb 垫底。 */
/** 战斗音频:普通 bgm/battle / Boss bgm/boss,400ms 快切(urgency,10 文档 §7);amb 停。 */
_switchBattleAudio(isBoss) {
if (settings.get().bgm > 0) {
this._stopAmb();
this.audio.play(isBoss ? 'bgm/boss' : 'bgm/battle');
}
// bgm=0:amb 照播(环境音属内容层,不受音乐设置影响)
this._stopAmb();
this.audio.play(isBoss ? 'bgm/boss' : 'bgm/battle', { fadeMs: 400 });
}
/**
@@ -939,6 +1050,18 @@ export class RpgGame extends Game {
// ---------------- 工具 ----------------
/** 当前是否有对白在播(引擎 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; }