/** * 《剑与声》R4 —— 纯音频回合制武侠 RPG,按 docs/design/26-audio-interaction-playbook.md * 深度重构(设计单 docs/design/26-apply-rpg.md;正文 05-rpg / 05-rpg-chapter1 / * 05-rpg-addendum / 11-rpg-battle-rebalance)。玩法核心(场景图、战斗状态机、 * 升级装备金币、第一章剧情、服务器存档)不动,交互层全部换成 26 号模式: * * - 发言权管理器(§4.1):旁白/NPC/结算/敌人演出 = SPEAKING(floor.speak, * 音乐压低跨整条链,句间不回「轮到你」听感);探索/战斗选指令 = YOUR_TURN * 等待点(选项句 full / short);敌方前摇窗口 = ACTION(enterAction:玩法声 * 即状态,输入直达游戏)。战斗回合交接音 = sfx/turn_player(11 号 §5), * 期间抑制 floor 风铃不双响。 * - 输入管道(§4.2/§4.3):index.html 用 makeInputPipeline + doubleTapWindow:0; * onGesture 返回 'ok' | 'hold' | 'invalid' | 'silent-ok'(判定反馈=内容音)。 * - 三档提示(§4.4):每个等待点 full → short → 提示音;30s 台账跨等待点去重; * 主动重听(阶段 3 双击)= 「再说一遍:」+ 完整句。 * - 迷茫检测(§4.5):乱点/连无效/静默 → 三级接管(confusion.onConfused → * 当前等待点 takeover:短句 → 只留期待手势 → 演示)。 * - 格挡 = 前摇窗口内**再单击**(11 号再平衡口径;旧「双击格挡」废止—— * 双击在新手期被管道折算成单击,等效);朝来招方向滑也算(空间冗余,不教学)。 * - 开场按 §4.10 重写:第一句就是 critical 指令句「点一下屏幕。」,砍掉 * intro_3 规则念白;教学战一句一教(tut_b0 / tut_b3 / tut_b1+b2 / tut_b6), * 每步 24s 兜底自动推进(11 号 §7 绝不卡死)。 * - 断点复述(§4.12):场景/战斗/商店/菜单存档照旧(summary 字段保留),回来 * 先问 sys_continue_ask,单击后 5 秒内一句复述 st_where_(你在哪 + * 目标 + 一个动作)再交出话轮。 * - 手势阶梯(§4.3):ladder.onGoal() = 第一场教学战胜利(解锁上滑)、 * onGameDone() = 第一章通关(解锁下滑菜单);战斗下滑=逃跑不随菜单解锁走。 * - 菜单豁免(round2 裁定② + 00 号 §5.4):探索态菜单 = 左右滑切换 + 单击确认 * (与探索同构,sys_menu 语音亦按此录制)。 * * 状态 × 手势矩阵(新手期,完整版见 26-apply-rpg.md §4): * | 状态 | 单击 | 左/右滑 | 上滑(未解锁) | 下滑(未解锁) | 双击(未解锁) | 长按 | 连点 | * |--------------------|-------------------|-----------------|-------------------|------------------------|--------------|----------|---------------| * | 开场指令(critical) | busy 回执 | busy | busy | busy | busy | 加速 | busy | * | 旁白/报名在播 | 跳过这句 + ok | 打断并执行 | 打断,状态句 | 打断,菜单/逃跑 | 按单击 | 加速 | 400ms busy | * | 探索 YOUR_TURN | 执行选项 | 切选项+报名 | 状态链(hold) | invalid+解释 | 当单击 | 加速 | busy | * | 商店 / 菜单 | 购买/确认(ok) | 切项+报名 | 状态/重听说明 | 离开/关闭(ok) | 当单击 | 加速 | busy | * | PLAYER_SELECT | 执行指令 | 切指令+报名 | 战场状态(hold) | 逃跑检定 | 阶段3重听 | 加速 | busy | * | 前摇 ACTION | 格挡(silent-ok) | 中央敌=格挡 | invalid+gd_parry | invalid+gd_parry | 当单击=格挡 | 加速 | 首下格挡余busy | * | 继续询问(reopen) | 继续存档(ok) | 右滑=新旅程 | 状态句 | invalid | 当单击 | 加速 | busy | */ import { Game } from '../engine/game.js'; import { Floor } from '../engine/floor.js'; import { Receipts } from '../engine/receipts.js'; import { Confusion } from '../engine/confusion.js'; import { WaitPoint, InvalidHints, markHintSaid } from '../engine/hints.js'; import { GestureLadder } from '../engine/novice.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' }; // 断点复述分区(§4.12:你在哪 + 目标 + 一个动作,按进度三档) const ZONES = { village: 1, path: 1, fork: 2, chest: 2, stream: 2, hunter: 2, deepwood: 3, gate: 3, boss1: 3 }; const RESUME_LINE = { 1: 'voice/st_where_1', // 「村外小路。去黑风寨。左右滑。」 2: 'voice/st_where_2', // 「林子深处。去黑风寨。左右滑。」 3: 'voice/st_where_3', // 「黑风寨。救徒弟。左右滑。」 }; // 敌人表(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', ]; /** * RPG 手势阶梯:战斗态下滑 = 逃跑(11 号 §4;05 号 §0.3「战斗始终=逃跑不是菜单」), * 不随菜单解锁走;探索态下滑(菜单)仍按全局阶梯阶段 2 解锁。 */ class RpgLadder extends GestureLadder { /** @param {RpgGame} game */ constructor(game) { super(); this.game = game; } has(g) { if (g === 'down' && this.game && this.game.phase === 'battle') return true; return super.has(g); } } export class RpgGame extends Game { constructor(audio, root) { super(audio, root); // ---- 26 号交互层(§4.1/§4.2/§4.4/§4.5/§4.3)---- this.receipts = new Receipts(audio); this.floor = new Floor(audio); this.confusion = new Confusion({ onConfused: (lvl) => { const wp = this.floor.waitPoint; if (wp) wp.takeover(lvl); }, }); this.invalidHints = new InvalidHints().bind(this.floor); this.ladder = new RpgLadder(this); // 长按 = 旁白加速(12 号;RPG 非四向玩法,帮助不占长按)→ 引擎快进保持默认开 this.phase = 'idle'; // idle | ask | intro | explore | shop | battle | menu this._session = 0; // 会话令牌:大跳转时作废旧计时器与语音链 this._timers = []; // 探索状态 this.scene = 'village'; this.optIdx = 0; this.visited = new Set(); this._wolfDone = false; // stream 野狼 50% 刷新(一次性,运行期内) this._hunterLine = 0; // 老猎人二周目台词轮换 this._introTapped = false; // 开场前奏:第一下单击之前不收其他手势 this.skipHintShown = false; // 商店/菜单 this._shopIdx = 0; this._menuIdx = 0; // 战斗状态 this._battle = null; // amb 手动循环(sfx 层) this._ambSrc = null; this._ambGain = null; this._ambId = null; // playSec 计时 this._segStart = null; // 回绕/末位提示去重(同一次列表打开内每句最多 1 次) this._wrapShown = new Set(); this._newRun(); } /** 管道选项(index.html 读):长按是本作核心手势(旁白加速),其余走全局阶梯。 */ get PIPELINE_OPTS() { return { longpressCore: true }; } _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/sys_first_tap', 'voice/sys_turn_teach', 'voice/sys_continue_ask', 'voice/desc_village', 'voice/npc_headman_1', 'voice/opt_go_path', ]); this._session++; const save = this._loadSave(); if (save) { // 有存档:继续 / 新旅程(§4.12 断点复述入口) this.phase = 'ask'; markHintSaid('voice/sys_continue_ask'); // 台账:静默提示不原样重播(原则 9) this._sayGuarded(['voice/sys_continue_ask'], () => this.floor.yieldTurn(this._reopenWp()), 8000); } else { this._startNewGame(false); } } stop() { // 原则 16:任何时刻退出都自动存档。例外:继续询问画面(存档尚未恢复进 // player,此时落盘会把真档覆盖成默认值);战斗中不打半场血(结算自会存) if (this.phase !== 'idle' && this.phase !== 'battle' && this.phase !== 'ask') this._save(); this._session++; this._clearTimers(); this._stopAmb(); this.floor.interrupt(); this.audio.stopAll(); } _startNewGame(keepStats) { this._session++; this._clearTimers(); this.floor.interrupt(); this.floor.turnEarcon = true; // 战斗期抑制过风铃的话,此处复位 this._newRun(keepStats); this.visited = new Set(); this._wolfDone = false; this._introTapped = false; this.phase = 'intro'; this._segStart = performance.now(); // §4.10 首 3 分钟:第一句就让玩家动手(critical,抢拍单击=busy,<3s)。 // 旧 intro_1..3 + skip_hint + sys_new_game 的 30 秒开场念白废除。 this.floor.speak(['voice/sys_first_tap'], { critical: true, onDone: () => this.floor.yieldTurn(this._introWp()), }); } /** 第一下单击之后:序章两句 + 跳句提示 + 「轮到你了」状态课(原则 2/11)。 */ _afterIntro() { const ids = ['voice/intro_1', 'voice/intro_2']; if (!this.skipHintShown) { this.skipHintShown = true; ids.push('voice/skip_hint'); } ids.push('voice/sys_new_game', 'voice/sys_turn_teach'); this._sayGuarded(ids, () => { // 首次进村:村长送 50 金(player 默认值即剧情款)+ 商店引导 this._enterScene('village', { prepend: ['voice/npc_headman_1', 'voice/npc_headman_2'] }); }, 30000); } _continueGame(save) { this._session++; this._clearTimers(); this.floor.interrupt(); this.floor.turnEarcon = true; 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', { resume: true }); } // ---------------- 手势(pipeline 收口之后) ---------------- /** * 管道后的游戏处理;返回 'ok' | 'hold' | 'invalid' | 'silent-ok'。 * (harness transcribe 直注 onGesture 时双击折算在此兜底,与管道行为一致。) */ onGesture(g) { if (g === 'doubletap' && !this.ladder.has('doubletap')) g = 'tap'; switch (this.phase) { case 'ask': return this._onAskGesture(g); case 'intro': return this._onIntroGesture(g); case 'explore': return this._onExploreGesture(g); case 'shop': return this._onShopGesture(g); case 'battle': return this._onBattleGesture(g); case 'menu': return this._onMenuGesture(g); default: return 'invalid'; } } stateName() { switch (this.phase) { case 'ask': return 'reopen'; case 'intro': return 'intro'; case 'shop': return 'shop'; case 'menu': return 'menu'; case 'battle': { const b = this._battle; if (b && b.state === 'ENEMY_TELEGRAPH') return 'telegraph'; return 'battle'; } default: return 'explore'; } } invalidHint(state, g) { if (state === 'locked') { if (g === 'up') return 'voice/inv_up_locked'; if (g === 'down') return 'voice/inv_down_locked'; return null; } if (state === 'telegraph') return 'voice/gd_parry_miss'; // 修改稿:「前摇响着呢。点一下,挡住。」 if (state === 'intro') return 'voice/sys_first_tap_short'; if (state === 'reopen') return 'voice/sys_continue_ask'; return null; // 其余格子:invalid 回执即反馈(原则 7) } _onAskGesture(g) { switch (g) { case 'tap': { const save = this._loadSave(); if (save) this._continueGame(save); else this._startNewGame(false); return 'ok'; } case 'right': this._startNewGame(true); // 新游戏保留 stats 累计 return 'ok'; case 'up': { const save = this._loadSave(); const line = RESUME_LINE[ZONES[save && save.scene] || 1]; this._say([line], { kind: 'hint', onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); }, }); return 'hold'; } default: return 'invalid'; } } _onIntroGesture(g) { if (!this._introTapped) { if (g === 'tap') { this._introTapped = true; this._afterIntro(); return 'ok'; } return 'invalid'; // 首次解释 = sys_first_tap_short「点一下。」 } // 序章旁白在播(tap=跳句已被 floor 消费):左右滑 = 不耐烦直达村庄 if (g === 'left' || g === 'right') { this._enterScene('village', { prepend: ['voice/npc_headman_1', 'voice/npc_headman_2'] }); return 'ok'; } return 'invalid'; } // ---------------- 探索态 ---------------- /** * 进入场景:切环境音/音乐 → 脚步 → (复述句/村长/Boss 警告 +)desc → 保存 → * 遭遇检查 → 报当前选项并交出探索话轮。 * @param {object} [opts] opts.prepend 额外前置语音;opts.resume 断点复述(§4.12) */ _enterScene(id, opts = {}) { this._session++; this._clearTimers(); this.scene = id; this.optIdx = 0; this.phase = 'explore'; this._wrapShown = new Set(); // 选项列表重新「打开」,回绕提示重新可用 this._switchSceneAudio(id, opts.fadeMs || 800); this.audio.play('sfx/step', { volume: 0.8 }); const prepend = [...(opts.prepend || [])]; if (opts.resume) prepend.push(RESUME_LINE[ZONES[id] || 1]); // 回来 5 秒内一句复述 // 进 Boss 战前强制提示(11 文档 §4):Lv<3 首次到寨门插播 boss1_warn,给知情回头机会 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); const seq = [...prepend, this._descVoice(id, first)]; this._sayGuarded(seq, () => { if (this.scene !== id || this.phase !== 'explore') return; this._save(); if (this._checkEncounter()) return; this._openExplore(); }, 25000); } _descVoice(id, first) { if (id === 'boss1') return 'voice/desc_hall'; if (id === 'gate') return 'voice/desc_gate'; // gate 无 _re 句:回访也播全句 return first ? `voice/desc_${id}` : `voice/desc_${id}_re`; } /** 场景入口遭遇检查(明雷,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._sayGuarded(['voice/evt_nothing'], () => this._enterScene('gate'), 8000); return true; } return false; } /** 当前挂着的等待点是否就是本场景的探索等待点。 */ _isCurrentExploreWp() { const wp = this.floor.waitPoint; return !!(wp && wp.conf.id === `explore_${this.scene}`); } /** * 报当前选项并交出探索话轮(一律重挂新等待点:场景切换/关菜单/离店后旧 * 等待点已过时;yieldTurn 会顺手完成旧的)。 */ _openExplore() { const opts = SCENES[this.scene].options; this.floor.turnEarcon = true; if (!opts.length) { this.floor.yieldTurn(this._exploreWp()); return; } this._announceIds([`voice/opt_${opts[this.optIdx]}`], () => { if (this.phase !== 'explore') return; this.floor.yieldTurn(this._exploreWp()); }); } /** * 链结束后回到探索等待态:等待点仍是本场景的 → 恢复(不重播风铃);陌生 * 残留(开场 wp 等被链式语音顶掉结算的)→ 直接重挂新等待点,不重播报名。 */ _resumeExplore() { if (this.phase !== 'explore') return; if (this._isCurrentExploreWp()) { if (!this.floor.speaking) this.floor.resumeTurn(); return; } this.floor.turnEarcon = true; this.floor.yieldTurn(this._exploreWp()); } /** 左右滑切选项后的报名(可带回绕/末位提示,每场景每句一次)。 */ _announceExplore(g) { const opts = SCENES[this.scene].options; const n = opts.length; const ids = [`voice/opt_${opts[this.optIdx]}`]; 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; const cue = wrap || (opts[this.optIdx] === 'back' ? 'gd_wrap_last' : null); if (cue && !this._wrapShown.has(cue)) { this._wrapShown.add(cue); ids.push(`voice/${cue}`); } this._announceIds(ids, () => this._resumeExplore()); } /** * 报名/播报链:首句记入 30s 提示台账(原则 9)——刚报名过的句子,等待点 * 被动提示自动降档(full → short),绝不原样重播。 */ _announceIds(ids, onDone) { if (ids.length) markHintSaid(ids[0]); this._say(ids, { onDone }); } _onExploreGesture(g) { const opts = SCENES[this.scene].options; switch (g) { case 'left': case 'right': { if (!opts.length) return 'invalid'; const n = opts.length; this.optIdx = (this.optIdx + (g === 'right' ? 1 : n - 1)) % n; this._announceExplore(g); return 'silent-ok'; // 选项报名即内容反馈 } case 'tap': if (!opts.length) return 'invalid'; return this._executeOption(opts[this.optIdx]); case 'doubletap': { // 阶段 3 解锁的重听(05a §9):「再说一遍:」+ 场景描述 + 当前选项 if (!opts.length) return 'invalid'; this.confusion.recordReplay(); const ids = ['voice/sys_say_again', this._descVoice(this.scene, false), `voice/opt_${opts[this.optIdx]}`]; this._say(ids, { onDone: () => this._resumeExplore() }); return 'hold'; } case 'up': this._speakStatus(); return 'hold'; case 'down': this._openMenu(); return 'ok'; case 'longpress': return 'hold'; // 引擎旁白加速(12 号)即反馈;等待点继续等 default: return 'invalid'; } } _executeOption(opt) { if (GO_TARGET[opt]) { this._enterScene(GO_TARGET[opt]); return 'silent-ok'; } // 脚步声即反馈 if (opt === 'back') { this._enterScene(BACK_MAP[this.scene] || 'village'); return 'silent-ok'; } const resume = () => this._resumeExplore(); switch (opt) { case 'shop': this.phase = 'shop'; this._shopIdx = 0; this._wrapShown = new Set(); this._sayGuarded(['voice/shop_open', SHOP_ITEMS[0].priceVoice], () => this.floor.yieldTurn(this._shopWp()), 10000); return 'ok'; case 'rest': this.player.hp = this.maxHp; this.player.mp = this.maxMp; this.audio.play('sfx/rest'); this._sayGuarded(['voice/sys_rest'], resume, 8000); this._save(); return 'hold'; case 'status': this._speakStatus(); return 'hold'; case 'investigate': if (!this.flags.chest_path) { this.flags.chest_path = true; this.player.gold += 15; this.audio.play('sfx/coin'); this._sayGuarded(['voice/evt_gold15'], resume, 8000); this._save(); } else { this._sayGuarded(['voice/evt_nothing'], resume, 8000); } return 'hold'; 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._sayGuarded(['voice/evt_chest'], resume, 8000); this._save(); } else { this._sayGuarded(['voice/evt_nothing'], resume, 8000); } return 'hold'; case 'drink': this.player.hp = this.maxHp; this.audio.play('sfx/drink_water'); this._sayGuarded(['voice/sys_drink'], resume, 8000); return 'hold'; 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._sayGuarded(['voice/npc_hunter_1', 'voice/npc_hunter_2', 'voice/evt_hunter_gift'], resume, 16000); this._save(); } else { const lines = ['voice/npc_hunter_3', 'voice/npc_hunter_4']; this._say([lines[this._hunterLine++ % lines.length]], { onDone: resume }); } return 'hold'; 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; } return 'ok'; } _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; if (this._hpBand(p.hp, this.maxHp) === 'low') vibrate('warning'); const done = () => { this._resumeTurn(); if (onDone) onDone(); }; this._sayGuarded([`voice/st_hp_${this._hpBand(p.hp, this.maxHp)}`, `voice/foe_hp_${this._hpBand(e.hp, e.maxHp)}`], done, 8000, { kind: 'hint' }); return; } const done = onDone || (() => this._resumeExplore()); 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._sayGuarded([lvVoice, hpVoice, goldVoice, weaponVoice, armorVoice], done, 20000, { kind: 'hint' }); } /** * 战斗链结束后回到选指令等待态:等待点仍是本回合的 → 恢复(不重播交接音); * 陌生残留/丢失 → 直接重挂新等待点(防安静态),不重播指令名。 */ _resumeTurn() { const b = this._battle; if (!b || this.phase !== 'battle' || b.state !== 'PLAYER_SELECT') return; const wp = this.floor.waitPoint; const isTurnWp = wp && (wp.conf.id === 'player_turn' || String(wp.conf.id).startsWith('tut_')); if (isTurnWp) { if (!this.floor.speaking) this.floor.resumeTurn(); return; } this.floor.turnEarcon = false; this.floor.yieldTurn(this._turnWp()); } // ---------------- 商店(菜单态等待点,chapter1 §4.4) ---------------- _onShopGesture(g) { const resume = () => { if (this.phase === 'shop' && this.floor.waitPoint && !this.floor.speaking) this.floor.resumeTurn(); }; switch (g) { case 'left': case 'right': { const n = SHOP_ITEMS.length; this._shopIdx = (this._shopIdx + (g === 'right' ? 1 : n - 1)) % n; const it = SHOP_ITEMS[this._shopIdx]; const ids = [it.priceVoice]; if (it.type === 'leave' && !this._wrapShown.has('gd_wrap_last')) { this._wrapShown.add('gd_wrap_last'); ids.push('voice/gd_wrap_last'); } this._announceIds(ids, resume); return 'silent-ok'; } case 'tap': return this._buy(SHOP_ITEMS[this._shopIdx]); case 'up': this._speakStatus(() => resume()); return 'hold'; case 'down': this._closeShop(); return 'ok'; case 'longpress': return 'hold'; default: return 'invalid'; } } _buy(item) { const p = this.player; const resume = () => { if (this.phase === 'shop' && this.floor.waitPoint && !this.floor.speaking) this.floor.resumeTurn(); }; if (item.type === 'leave') { this._closeShop(); return 'ok'; } const owned = (item.type === 'weapon' && p.weapon === item.id) || (item.type === 'armor' && p.armor === item.id) || (item.type === 'potion' && p.potions >= 5); if (owned) { this._say(['voice/shop_owned'], { onDone: resume }); return 'invalid'; } 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._say(['voice/shop_poor'], { onDone: resume }); return 'invalid'; } 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._say(['voice/shop_buy'], { onDone: resume }); this._save(); // 商店交易后存档 return 'ok'; } _closeShop() { this.phase = 'explore'; this.floor.interrupt(); // 摘掉商店等待点,重挂探索话轮 this._openExplore(); } // ---------------- 菜单(RPG 豁免:左右滑切换 + 单击确认) ---------------- _openMenu() { this._accPlaySec(); this.phase = 'menu'; this._menuIdx = 0; this.floor.interrupt(); this.audio.play('bgm/menu'); // 模态菜单状态音乐(10 文档 §7:amb 继续) this.floor.turnEarcon = true; this._sayGuarded(['voice/sys_menu', `voice/${MENU_ITEMS[0]}`], () => this.floor.yieldTurn(this._menuWp()), 10000); } _closeMenu() { this.phase = 'explore'; this._segStart = performance.now(); this.floor.interrupt(); this._switchSceneAudio(this.scene); // 淡回场景状态音乐(village/explore) this._openExplore(); } _menuHelp() { this._sayGuarded(['voice/help_explore', 'voice/help_battle', 'voice/settings_hint'], () => { if (this.phase === 'menu' && this.floor.waitPoint && !this.floor.speaking) this.floor.resumeTurn(); }, 14000); } _onMenuGesture(g) { const announce = () => { this._announceIds([`voice/${MENU_ITEMS[this._menuIdx]}`], () => { if (this.phase === 'menu' && this.floor.waitPoint) this.floor.resumeTurn(); }); }; switch (g) { case 'left': case 'right': { const n = MENU_ITEMS.length; this._menuIdx = (this._menuIdx + (g === 'right' ? 1 : n - 1)) % n; announce(); return 'silent-ok'; } case 'tap': { const sel = MENU_ITEMS[this._menuIdx]; if (sel === 'sys_menu_resume') { this._closeMenu(); return 'ok'; } if (sel === 'sys_menu_save') { this._save(); this._sayGuarded(['voice/sys_saved'], () => this._goHome(), 8000); return 'ok'; } this._menuHelp(); return 'hold'; } case 'up': // 重听菜单说明(05 号 §0.4) this._menuHelp(); return 'hold'; case 'down': this._closeMenu(); return 'ok'; case 'longpress': return 'hold'; default: return 'invalid'; } } _goHome() { this.stop(); window.location.href = '../'; } // ---------------- 战斗:定时器驱动回合状态机(chapter1 §3 + 11 号) ---------------- _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, resolving: false, selectSeq: 0, // 玩家「最后被告知」的血量档(开战=当前档)——受击播报只在实质受击且 // 比它差时播当前实际档(擦伤不触发、同档不重复,addendum §1) hpBandVoice: this._hpBand(this.player.hp, this.maxHp), // 难度档位(11 文档 §3):教学战特例前摇 1.8s;其余按 battlesWon 取档,Boss 恒标准 tier: enc.teach ? { telSec: 1.8, dmgMul: 1.0 } : this._battleTier(enc), telRate: 1, }; this._switchBattleAudio(enc.boss); this._nextEnemy(); } /** 新手保护档位(11 文档 §3 数值表):每场战斗开始时按 stats.battlesWon 取档。 */ _battleTier(enc) { if (enc.boss) return { telSec: 1.0, dmgMul: 1.0 }; // Boss 是毕业考,恒标准 const w = this.stats.battlesWon || 0; if (w <= 2) return { telSec: 1.5, dmgMul: 0.6 }; // 保护档:第 1-3 场 if (w <= 4) return { telSec: 1.25, dmgMul: 0.8 }; // 过渡档:第 4-5 场 return { telSec: 1.0, dmgMul: 1.0 }; // 标准档:第 6 场起 } _nextEnemy() { const b = this._battle; const key = b.queue.shift(); const base = ENEMIES[key]; b.enemy = { ...base, key, maxHp: base.hp, halfSpoken: false, phase2: false }; b.cmdIdx = 0; // 连战转换:指令重置为攻击 b.round = 1; b.state = 'B_START'; const ids = [base.start]; if (base.npcStart) ids.push(base.npcStart); if (b.teachStage === 'menu') { ids.push('voice/tut_b0'); // T0:一句一教(原则 11) markHintSaid('voice/tut_b0'); // 台账:静默提示降档,不原样重播(原则 9) } this._sayGuarded(ids, () => this._playerSelect(), 15000); } _playerSelect() { const b = this._battle; if (!b) return; b.state = 'PLAYER_SELECT'; b.resolving = false; // 玩家回合的听觉标点(11 文档 §5):turn_player(≤0.4s)即交接提示音, // 期间抑制 floor 风铃不双响;与前摇/指令语音留 250ms 间隙 this.floor.turnEarcon = false; this.audio.play('sfx/turn_player'); this.floor.yieldTurn(this._turnWp()); this._announceCmd(); if (b.teachStage) this._armTeachFallback(b.teachStage); } _announceCmd() { const b = this._battle; if (!b || b.state !== 'PLAYER_SELECT') return; this._announceIds([`voice/cmd_${CMDS[b.cmdIdx]}`], () => this._resumeTurn()); } /** 教学步推进(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') { markHintSaid('voice/tut_b3'); this._sayGuarded(['voice/tut_b3'], () => this._playerSelect(), 10000); } else if (next === 'parry') { // T2:两句链(critical ≤3s/句)——「它要扑了,听前摇。」「就是现在,点一下挡住。」 markHintSaid('voice/tut_b1'); markHintSaid('voice/tut_b2'); this._sayGuarded(['voice/tut_b1', 'voice/tut_b2'], () => this._enemyTelegraph(), 12000, { critical: true }); } else { markHintSaid('voice/tut_b5'); this._sayGuarded(['voice/tut_b5'], () => this._playerSelect(), 10000); } } /** * 教学兜底(11 文档 §7「绝不卡死」):每步 24s 无进展自动推进(等待点三档 * 提示在 8s/16s 已给过;parry 步无兜底——前摇计时器自然结算)。 */ _armTeachFallback(stage) { const b = this._battle; if (!b || b.teachStage !== stage) return; this._after(24000, () => { if (this._battle !== b || b.teachStage !== stage) return; if (stage === 'menu') this._teachAdvance('attack'); else if (stage === 'attack') this._teachAdvance('parry'); else if (stage === 'defend') { b.teachStage = null; this._sayGuarded(['voice/tut_b5'], () => this._playerSelect(), 10000); } }); } _onBattleGesture(g) { const b = this._battle; if (!b) return 'invalid'; if (b.state === 'PLAYER_SELECT') { switch (g) { case 'left': case 'right': { b.cmdIdx = (b.cmdIdx + (g === 'right' ? 1 : CMDS.length - 1)) % CMDS.length; // 教学 T0:切换 2 次(听到指令名切换)即过关 if (b.teachStage === 'menu' && ++b.teachSwipes >= 2) this._teachAdvance('attack'); else this._announceCmd(); return 'silent-ok'; } case 'tap': return this._playerExecute(); case 'doubletap': { // 阶段 3 解锁的重听:等待点完整句(当前指令名,前缀「再说一遍:」) const wp = this.floor.waitPoint; if (wp) wp.requestReplay(); return 'hold'; } case 'up': this._speakStatus(); return 'hold'; case 'down': return this._fleeCheck(); case 'longpress': return 'hold'; // 引擎旁白加速,不占格挡语义 default: return 'invalid'; } } if (b.state === 'ENEMY_TELEGRAPH') { if (performance.now() > b.windowEnd) return 'invalid'; // 窗口已关(结算定时器在路上) // 格挡 = 窗口内再单击(11 号口径);朝来招方向滑也算(ch1 敌全为中央: // 任意左右都认,chapter1 §3 来招方向约定)。双击已被管道折算成单击。 if (g === 'tap' || g === 'doubletap' || g === 'left' || g === 'right') { return this._doParry(); } return 'invalid'; // 上滑/下滑/其他:窗口内只认格挡(gd_parry_miss 首次解释) } return 'invalid'; // 其余战斗态:floor BUSY 已给 busy 回执(此处兜底) } _doParry() { const b = this._battle; b.parry = true; this.stats.parries++; this.audio.play('sfx/parry'); vibrate('confirm'); this._enemyResolve(); return 'silent-ok'; // 格挡音即内容反馈 } _playerExecute() { const b = this._battle; const p = this.player; const e = b.enemy; b.state = 'PLAYER_EXECUTE'; b.resolving = true; // 回合结算开始,闲置提示让位 const cmd = CMDS[b.cmdIdx]; if (cmd === 'skill' && p.mp < 3) { this._sayGuarded(['voice/btl_nomp'], () => this._playerSelect(), 8000); return 'invalid'; // 执行失败:invalid 回执 + 解释句(05 §0.7) } if (cmd === 'item' && p.potions <= 0) { this._sayGuarded(['voice/btl_noitem'], () => this._playerSelect(), 8000); return 'invalid'; } 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 'silent-ok'; } this._checkEnemyDead(); return 'silent-ok'; // 挥剑/命中音即内容反馈 } if (cmd === 'defend') { b.defending = true; // 无播报,下回合减伤自明(addendum §1) // 教学 T3:执行防御一次 → T4 收尾(自由击杀) if (b.teachStage === 'defend') { b.teachStage = null; this._sayGuarded(['voice/tut_b5'], () => this._checkEnemyDead(), 10000); return 'ok'; } this._checkEnemyDead(); return 'ok'; } // 道具 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(); return 'silent-ok'; } _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._sayGuarded(ids, () => { if (b.queue.length > 0) this._nextEnemy(); // 连战:不停 BGM 直接下一只 else this._victory(); }, 14000); 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._sayGuarded([e.phase2Voice], () => this._enemyTelegraph(), 10000); return; } if (e.half) { this._sayGuarded([e.half], () => this._enemyTelegraph(), 10000); return; } } this._enemyTelegraph(); } _enemyTelegraph() { const b = this._battle; if (!b) return; b.state = 'ENEMY_TELEGRAPH'; b.parry = false; this.floor.enterAction(); // 前摇 = ACTION(§4.1):玩法声即状态,输入直达游戏 // 前摇时长按难度档位(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) 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'; this.floor.leaveAction(); const p = this.player; const e = b.enemy; let dmg = this._dmg(e.atk, this.def); dmg = Math.max(1, Math.floor(dmg * b.tier.dmgMul)); // 新手保护伤害系数(向下取整 min 1,11 文档 §3) if (b.parry) { // 格挡减伤 70%(不震 hit,已震 confirm)。擦伤钳 1~3——对齐 11 文档 §3 // 「格挡成功 2-3 伤」(原实现 round 无封顶,Boss 二阶段单发擦伤可达 4, // 全格挡 8 刀可把 36 血磨进 low 档,播报与实际战况矛盾);min 1 保住 // 「格挡也有擦伤」的痛感(05-rpg §2 减伤 70%) dmg = Math.min(3, Math.max(1, Math.round(dmg * 0.3))); } else { if (b.defending) dmg = Math.round(dmg * 0.5); // 防御减半(与格挡不叠加) this.audio.play('sfx/hurt'); vibrate('hit'); } b.defending = false; p.hp -= dmg; if (b.enc.teach) p.hp = Math.max(1, p.hp); // 教学战 HP 锁底 1(必不死) console.log(`[RpgGame] 敌方攻击 dmg=${dmg} 格挡=${b.parry} 玩家HP=${p.hp}/${this.maxHp}`); // 教学 T2:格挡成功或受击均可通过 → 进入防御教学 T3(11 文档 §2); // 成功先插 tut_b2b「挡得漂亮。」确认表现,失败插 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']; ids.forEach(markHintSaid); // 台账:静默提示降档(原则 9) this._sayGuarded(ids, () => this._playerSelect(), 16000); return; } if (p.hp <= 0) { this._defeat(); return; } // 受击播报只挂实质受击 + 档位一致 + 同档节流(addendum §1): // 格挡擦伤静默走血;只在实质受击且比「最后被告知档」差时播当前实际档 if (!b.parry) { const band = this._hpBand(p.hp, this.maxHp); const WORSE = { high: 0, mid: 1, low: 2 }; if (WORSE[band] > WORSE[b.hpBandVoice]) { b.hpBandVoice = band; if (band === 'low') vibrate('warning'); this._sayGuarded([`voice/st_hp_${band}`], () => { b.round++; this._playerSelect(); }, 8000); return; } b.hpBandVoice = band; // 未降档(含回血后升档):静默同步「最后被告知档」 } b.round++; this._playerSelect(); } _fleeCheck() { const b = this._battle; b.resolving = true; // 逃跑判定=回合结算,闲置提示让位 if (!b.enc.flee) { // Boss/剧情战不可逃 vibrate('error'); this._sayGuarded(['voice/btl_noflee'], () => this._playerSelect(), 8000); return 'invalid'; } // 逃跑新手保护(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._sayGuarded(['voice/btl_flee'], () => this._endBattle('fled'), 8000); return 'silent-ok'; } b.state = 'FLEE_FAIL'; this._sayGuarded(['voice/btl_flee_fail'], () => this._enemyTelegraph(), 8000); return 'invalid'; } _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; // 第一个小目标 = 第一场教学战胜利 → 解锁上滑(§4.3 阶梯,教学句挂链尾) if (b.encId === 'enc_teach_wolf') { const got = this.ladder.onGoal(); if (got) ids.push(this._teachVoice(got)); } this._sayGuarded(ids, () => this._endBattle('victory'), 20000); } _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._sayGuarded(['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) }, 20000); } /** 战斗收尾:恢复场景音频、存档、回探索态(或遭遇特定回调)。 */ _endBattle(result) { const b = this._battle; this._battle = null; this.floor.interrupt(); 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._openExplore(); } /** 章 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'); // 第一章通关 = 一局完成 → 手势阶梯(§4.3 阶段 2:下滑菜单,教学句挂链尾) const got = this.ladder.onGameDone(); const ids = ['voice/evt_ch1_end']; if (got) ids.push(this._teachVoice(got)); this._sayGuarded(ids, () => { this.phase = 'explore'; this._enterScene('village'); }, 20000); } // ---------------- 等待点 ---------------- _wp(conf) { return new WaitPoint(conf, { floor: this.floor, confusion: this.confusion }); } _introWp() { return this._wp({ id: 'intro', expected: ['tap'], full: null, // 指令句刚说过,静默第一档直接短句(§4.10 找猫示例同型) short: 'voice/sys_first_tap_short', silenceMs: [6000, 15000], onDemo: () => { this._sayGuarded(['voice/sys_demo_continue'], () => this._afterIntro(), 8000); }, }); } _reopenWp() { return this._wp({ id: 'reopen', expected: ['tap', 'right'], full: 'voice/sys_continue_ask', short: 'voice/hint_explore_short', silenceMs: [12000, 30000], onDemo: () => this.floor.chime(), }); } _exploreWp() { const game = this; const opts = SCENES[this.scene].options; return this._wp({ id: `explore_${this.scene}`, expected: ['tap', 'left', 'right'], // 完整档=当前选项名(动态取值:滑过之后静默提示不再报旧选项) get full() { return opts.length ? `voice/opt_${opts[game.optIdx]}` : null; }, short: 'voice/hint_explore_short', restrictLine: 'voice/only_explore', silenceMs: [18000, 40000], onDemo: () => { // L3:替玩家切回首选项并报名(05 §0.5 village_opt) this.optIdx = 0; this._announceExplore('right'); }, }); } _shopWp() { return this._wp({ id: 'shop_buy', expected: ['tap', 'left', 'right'], full: 'voice/hint_shop_full', short: 'voice/hint_shop_short', silenceMs: [12000, 30000], onDemo: () => this.floor.chime(), }); } _menuWp() { return this._wp({ id: 'menu', expected: ['tap', 'left', 'right'], full: 'voice/sys_menu', short: 'voice/sys_menu', silenceMs: [20000, 45000], onDemo: () => this.floor.chime(), }); } /** 玩家回合等待点(教学步各配 full/short/L3 演示,05 §0.5)。 */ _turnWp() { const b = this._battle; switch (b.teachStage) { case 'menu': return this._wp({ id: 'tut_menu', expected: ['left', 'right'], full: 'voice/tut_b0', short: 'voice/hint_swipe_short', silenceMs: [8000, 16000], onDemo: () => { // 游戏切一次指令并报名 b.cmdIdx = (b.cmdIdx + 1) % CMDS.length; this._announceCmd(); }, }); case 'attack': return this._wp({ id: 'tut_atk', expected: ['tap', 'left', 'right'], full: 'voice/tut_b3', short: 'voice/hint_cmd_short', silenceMs: [8000, 16000], onDemo: () => { // 「听我做一次。」代打一刀(26 号 L3) this._sayGuarded(['voice/sys_demo_continue'], () => { if (this._battle === b && b.teachStage === 'attack' && b.state === 'PLAYER_SELECT') { b.cmdIdx = 0; this._playerExecute(); } }, 8000); }, }); case 'defend': return this._wp({ id: 'tut_def', expected: ['tap', 'left', 'right'], full: 'voice/tut_b6', short: 'voice/tut_def_short', silenceMs: [8000, 16000], onDemo: () => { // 代选防御并执行 this._sayGuarded(['voice/sys_demo_continue'], () => { if (this._battle === b && b.teachStage === 'defend' && b.state === 'PLAYER_SELECT') { b.cmdIdx = CMDS.indexOf('defend'); this._playerExecute(); } }, 8000); }, }); default: return this._wp({ id: 'player_turn', expected: ['tap', 'left', 'right', 'down'], // 指令名刚播过:台账自动降档到短句;动态取值跟随玩家切指令 get full() { return `voice/cmd_${CMDS[b.cmdIdx]}`; }, short: 'voice/hint_cmd_short', silenceMs: [8000, 20000], onDemo: () => this.floor.chime(), }); } } // ---------------- 音频层叠(10 文档 §7:BGM 层报状态 + sfx 层 amb 报场景) ---------------- /** * 探索场景音频:village=bgm/village;其余=bgm/explore 轻垫(volume 0.35); * 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); } // ---------------- 存档(§4.12 / 原则 16) ---------------- _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 id。引擎缺口兜底:novice.js 的 TEACH_IDS 返回裸 id * (如 sys_unlock_up,缺 voice/ 层前缀,audio.play 会按未知层丢弃)—— * 游戏侧补前缀,不改引擎。 */ _teachVoice(got) { const id = this.ladder.teachLine(got); return id ? (id.startsWith('voice/') ? id : `voice/${id}`) : null; } /** * 语音链统一入口(floor.speak 包装):hint/feedback 类在忙时被丢弃(§4.6 * 过期即弃),此时直接走 onDone 不阻塞流程;content/critical 永远播。 */ _say(ids, opts = {}) { if (!ids || !ids.length) { if (opts.onDone) opts.onDone(); return true; } const ok = this.floor.speak(ids, opts); if (!ok && opts.onDone) opts.onDone(); return ok; } /** * 带看门狗的流程链:自然播完或超时先到先触发 onDone 且只一次——被打断的 * 链(滑动打断=作废,onDone 不回调)由看门狗兜底续走,流程绝不卡死。 * guardMs 必须大于链的自然时长(真实句长 2~5s/句)。 */ _sayGuarded(ids, onDone, guardMs = 10000, opts = {}) { const s = this._session; let fired = false; const fire = () => { if (fired || s !== this._session) return; fired = true; if (onDone) onDone(); }; this._say(ids, { ...opts, onDone: 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 = []; } }