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 置完成。
1586 lines
62 KiB
JavaScript
1586 lines
62 KiB
JavaScript
/**
|
||
* 《剑与声》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 = [];
|
||
}
|
||
}
|