Files
SoundGame/web/rpg/game.js
T
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

1586 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《剑与声》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_<zone>(你在哪 +
* 目标 + 一个动作)再交出话轮。
* - 手势阶梯(§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 = [];
}
}