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 置完成。
2220 lines
90 KiB
JavaScript
2220 lines
90 KiB
JavaScript
/**
|
||
* 《回声地窟》—— 纯音频听声辨位 Roguelike,按 docs/design/26-audio-interaction-playbook.md
|
||
* 深度重构(设计单 docs/design/26-apply-dungeon.md;玩法正文 docs/design/16-dungeon.md;
|
||
* 台词 id 见 16-dungeon-voice-ids.md,新句清单 tools/voice_lines_26/dungeon.md)。
|
||
*
|
||
* 交互层(26 号模式,对齐 escape 参考实现):
|
||
* - 四向滑动 = 面朝相对移动(§4.3 裁定:四向是玩法的游戏,帮助换到长按):
|
||
* up=前进一格;left/right=向侧走一格并转向该向;down=后退一格(面朝不变)。
|
||
* swipeCore——up/down 不按新手阶梯锁,阶梯只对 doubletap 生效。
|
||
* - 单击 = 声波脉冲(探索期 floor.enterAction(),玩法声音即状态);楼梯格 = 下潜
|
||
* 确认;战斗我的回合 = 挥击;敌方前摇窗内 = 格挡(旧「双击格挡」「双击免冷却
|
||
* 重发」废除——连点不绑破坏性/重播操作)。
|
||
* - 长按 = 「我在哪」(层[+血 if 伤]+面朝+目标方位,≤20 字);旧「按住前进」废除;
|
||
* 对局期旁白快进关闭(帮助占用长按)。
|
||
* - 移动成功 = 脚步声(silent-ok,内容音即回执);撞墙 = bump + invalid 回执 +
|
||
* 归因句 wall + 方位真话 wall_dir_*(每方向整局一次,之后只闷响+回执,失败表 §4.9)。
|
||
* - 语音链走 floor.speak(BGM 压低跨链);描述播完回 ACTION;等待点(教学步、
|
||
* TITLE 询问、战斗回合、结算、菜单)用 WaitPoint 配 full/short/silenceMs/onDemo。
|
||
* - 开场按 §4.10 重写:第一句 = critical 指令「黑暗。点一下,听回声。」,砍规则念白。
|
||
* - 断点复述(§4.12):退出自动存档;回来 sys_continue_ask → 单击恢复 + 复述链。
|
||
* - 孤儿 BUSY 看门狗(_healOrphanBusy,800ms):invalid 解释链播完若无等待点可回,
|
||
* floor 会停在 BUSY 吞手势——按当前态拉回规范态(§4.1「不存在安静态」硬规则)。
|
||
* - 迷茫接管(§4.5 三级):静默 40s 起每 20s 喂信号 + 乱点/连无效;探索态 L1 目标句
|
||
* → L2 只放行导航方向(sys_only_*)→ L3 听我做一次(游戏自己走一步);
|
||
* 教学态 L1 短句 → L2 重教 → L3 游戏替你完成本步;进展事件清零重来。
|
||
* - 手势阶梯:D4 拾到金币 = 第一个小目标 → ladder.onGoal();死亡/通关 = 一局结束
|
||
* → ladder.onGameDone()(结算链尾挂解锁教学句)。
|
||
*
|
||
* 玩法核心不动:7 层地窟 mulberry32 程序生成(房间→L 走廊→BFS 最远房楼梯→怪/宝
|
||
* 安全区→主路径密度)、回声编码(正前 4 档延迟×音量 + 材质尾音 + 侧向预 pan)、
|
||
* 战斗三档(缠斗/绕位/Boss,前摇 1.2s 格挡窗 ×0.2/×1.0,半血 ATK+1,逃跑必成挨
|
||
* 一下、Boss 拒绝)、遗产经济 5 项、死亡归因真话律(die_cause_N / die_adv_N 按
|
||
* lastHit 与真实格挡/逃跑账)、到达楼梯格即报贴身铁器回声、存档 summary 字段。
|
||
*
|
||
* 状态 × 手势矩阵(新手期,完整版见设计单 §4):
|
||
* | 状态 | 单击 | 四向滑动 | 长按 | 双击(未解锁) |
|
||
* |--------------|-----------------|-----------------------|-----------|--------------|
|
||
* | TITLE | 开始/继续 | 右=新周目/上=元进度 | 重听询问 | 按单击 |
|
||
* | 教学作答 | 按步骤答 | 按步骤走;步骤外 invalid| 帮助 | 按单击 |
|
||
* | 探索(ACTION) | 脉冲/下潜(silent-ok);冷却 invalid | 移动(脚步)/撞墙(bump+invalid+归因) | 帮助 | 按单击(=再脉冲) |
|
||
* | 描述在播 | 跳过这句+ok | 打断并移动 | 打断,帮助 | 按单击 |
|
||
* | 战斗·我的回合 | 挥击 | 左右=转身;下=逃跑;上=状态 | 帮助 | 按单击 |
|
||
* | 战斗·敌前摇 | 格挡 | 左右=转身 | invalid | 按单击=格挡 |
|
||
* | DESCEND 演出 | 跳句+ok | 打断(兜底续流程) | 同左 | 按单击 |
|
||
* | 结算(critical)| busy | busy | busy | busy |
|
||
* | 结算(等待) | 再探一次 | 上=元进度;左=invalid;下=菜单(阶段2) | 重听结算 | (阶段3)重听 |
|
||
* | 菜单 | 确认当前项 | 左右=换项;上=帮助 | 重听菜单 | 按单击=确认 |
|
||
*/
|
||
|
||
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, hintSaidRecently } from '../engine/hints.js';
|
||
import { GestureLadder } from '../engine/novice.js';
|
||
import { vibrate } from '../engine/haptics.js';
|
||
|
||
// ================================ 常量表 ================================
|
||
|
||
/** 绝对方位:DIR_ORDER 顺时针(右转 = idx+1,左转 = idx-1)。 */
|
||
const DIR_ORDER = ['N', 'E', 'S', 'W'];
|
||
const DIR_VEC = { N: { dx: 0, dy: -1 }, E: { dx: 1, dy: 0 }, S: { dx: 0, dy: 1 }, W: { dx: -1, dy: 0 } };
|
||
const DIR_KEY = { N: 'n', E: 'e', S: 's', W: 'w' };
|
||
|
||
/** 相对方位(战斗/移动共用):0 前 / 1 右 / 2 后 / 3 左。 */
|
||
const REL = { FRONT: 0, RIGHT: 1, BEHIND: 2, LEFT: 3 };
|
||
/** 相对方位 → 手势名(四向滑动 = 面朝相对移动,§4.3)。 */
|
||
const REL_GESTURE = ['up', 'right', 'down', 'left'];
|
||
const SWIPE = ['up', 'down', 'left', 'right'];
|
||
|
||
const PULSE_CD = 1200; // 脉冲冷却(16 号 §3)
|
||
const TEL_WINDOW = 1200; // 敌方攻击前摇 = 格挡窗口(16 号 §5)
|
||
const DENSITY_STEP = 12; // 主路径内容密度下限:每 ≤12 格必有遭遇
|
||
const AMB_AFTER_MS = 20000; // 探索呼吸层:静默超 20s 播环境垫
|
||
const AMB_VOLUME = 0.35;
|
||
const SILENCE_FIRST_MS = 40000; // 迷茫静默信号:40s 首次、之后每 8s(< 10s 窗口,
|
||
const SILENCE_REPEAT_MS = 8000; // 两记静默可在窗口内累计权重 2 → 纯静默也能触发接管)
|
||
const DRILL_FALLBACK_MS = 75000;// 教学每步兜底:75s 未完成自动过步(有界,防卡死)
|
||
|
||
/** 回声编码(16 号 §3):正前距离 4 档 → 延迟 / 音量。 */
|
||
const ECHO_DELAY = [0, 250, 500, 800, 1100];
|
||
const ECHO_VOL = [0, 0.9, 0.7, 0.5, 0.35];
|
||
const SIDE_AT = 350; // 侧向回声延后
|
||
const MAT_TAIL_AT = 120; // 材质尾音跟在回声到达后
|
||
|
||
/** 敌型表(数值不动;cls=脚步/前摇音色类,gold=掉落)。 */
|
||
const MOBS = {
|
||
bat: { hp: 4, atk: 0, def: 0, cls: 'small', gold: 3, start: 'btl_bat' },
|
||
rat: { hp: 6, atk: 1, def: 0, cls: 'small', gold: 5, start: 'btl_rat' },
|
||
mush: { hp: 8, atk: 2, def: 0, cls: 'bug', gold: 8, start: 'btl_mush' },
|
||
crab: { hp: 20, atk: 3, def: 0, cls: 'heavy', gold: 50, start: 'btl_crab', boss: true, mover: true, half: 'btl_half_crab' },
|
||
crawler: { hp: 10, atk: 2, def: 0, cls: 'bug', gold: 10, start: 'btl_crawler', mover: true },
|
||
spider: { hp: 12, atk: 3, def: 0, cls: 'bug', gold: 12, start: 'btl_spider' },
|
||
leech: { hp: 26, atk: 4, def: 0, cls: 'heavy', gold: 80, start: 'btl_leech', boss: true, mover: true, half: 'btl_half_leech' },
|
||
guard: { hp: 16, atk: 4, def: 0, cls: 'heavy', gold: 15, start: 'btl_guard', mover: true },
|
||
king: { hp: 36, atk: 5, def: 0, cls: 'heavy', gold: 0, start: 'btl_king', boss: true, mover: true, half: 'btl_half_king' },
|
||
};
|
||
|
||
/** 层规格(数值不动):尺寸 / 怪物(型×数)/ 宝物 / 窟主 / 终局。 */
|
||
const FLOOR_SPECS = {
|
||
1: { size: 7, mobs: { bat: 2 }, items: ['coin'], drill: true },
|
||
2: { size: 7, mobs: { rat: 3 }, items: ['coin', 'potion'] },
|
||
3: { size: 9, mobs: { mush: 3, crab: 1 }, items: ['coin', 'weapon1'], boss: 'crab' },
|
||
4: { size: 9, mobs: { crawler: 2, spider: 2 }, items: ['coin', 'armor1'] },
|
||
5: { size: 11, mobs: { spider: 3, crawler: 2 }, items: ['coin', 'potion'] },
|
||
6: { size: 11, mobs: { spider: 2, crawler: 2, leech: 1 }, items: ['coin', 'weapon2'], boss: 'leech' },
|
||
7: { size: 11, mobs: { guard: 3, king: 1 }, items: ['coin'], final: true },
|
||
};
|
||
|
||
/** 元进度遗产:按价格升序自动购置(金币累计,死亡不清档)。 */
|
||
const LEGACIES = [
|
||
{ id: 'potion', price: 90, voice: 'unlock_potion' },
|
||
{ id: 'vigor', price: 150, voice: 'unlock_vigor' },
|
||
{ id: 'veil', price: 220, voice: 'unlock_veil' },
|
||
{ id: 'echo', price: 300, voice: 'unlock_echo' },
|
||
{ id: 'blade', price: 450, voice: 'unlock_blade' },
|
||
];
|
||
|
||
/** 菜单(00 §5.4:左右滑换项 + 单击确认;重开 = 破坏性,二次确认)。 */
|
||
const TITLE_MENU = ['sys_menu_restart', 'sys_menu_help'];
|
||
const SETTLE_MENU = ['sys_menu_restart', 'sys_menu_save'];
|
||
|
||
const HP_BAND_TEXT = { 1: '毫发无损', 2: '受了点伤', 3: '伤势不轻', 4: '命悬一线' };
|
||
|
||
const SAVE_KEY = 'soundgame.progress.dungeon';
|
||
const AUTH_KEY = 'soundgame.auth';
|
||
|
||
const PREWARM_SFX = [
|
||
'sfx/pulse', 'sfx/echo_1', 'sfx/echo_2', 'sfx/echo_3', 'sfx/echo_4',
|
||
'sfx/mat_stone', 'sfx/mat_door', 'sfx/mat_stair', 'sfx/mat_water',
|
||
'sfx/side_wall_l', 'sfx/side_wall_r', 'sfx/side_open_l', 'sfx/side_open_r',
|
||
'sfx/step_own', 'sfx/step_turn', 'sfx/bump',
|
||
'sfx/coin', 'sfx/potion', 'sfx/gear', 'sfx/stairs_down',
|
||
'sfx/mob_step_small_l', 'sfx/mob_step_small_c', 'sfx/mob_step_small_r',
|
||
'sfx/mob_step_bug_l', 'sfx/mob_step_bug_c', 'sfx/mob_step_bug_r',
|
||
'sfx/mob_step_heavy_l', 'sfx/mob_step_heavy_c', 'sfx/mob_step_heavy_r',
|
||
'sfx/tel_small', 'sfx/tel_small_l', 'sfx/tel_small_r',
|
||
'sfx/tel_bug', 'sfx/tel_bug_l', 'sfx/tel_bug_r',
|
||
'sfx/tel_heavy', 'sfx/tel_heavy_l', 'sfx/tel_heavy_r',
|
||
'sfx/atk_swing', 'sfx/atk_hit', 'sfx/parry', 'sfx/hurt', 'sfx/enemy_die', 'sfx/levelup',
|
||
'sfx/amb_cave',
|
||
];
|
||
|
||
// ================================ 工具 ================================
|
||
|
||
const key = (x, y) => x + ',' + y;
|
||
const range = (a, b) => { const r = []; const s = a <= b ? 1 : -1; for (let i = a; a <= b ? i <= b : i >= b; i += s) r.push(i); return r; };
|
||
|
||
// ================================ 主类 ================================
|
||
|
||
export class DungeonGame extends Game {
|
||
constructor(audio, root, opts = {}) {
|
||
super(audio, root);
|
||
// ---- 26 号六模块接线(engine/*,见设计单 §3)----
|
||
this.receipts = new Receipts(audio);
|
||
this.floor = new Floor(audio); // 发言权管理器(this._floor 仍是「当前层」数据,勿混淆)
|
||
this.confusion = new Confusion({ onConfused: (lvl) => this._onConfused(lvl) });
|
||
this.invalidHints = new InvalidHints().bind(this.floor);
|
||
this.ladder = new GestureLadder();
|
||
this.audio.setRateBoostEnabled(false); // 长按 = 帮助(核心手势),不触发旁白快进
|
||
|
||
this._fixedSeed = opts.seed != null ? opts.seed : null; // playtest 注入(仅首个新周目生效)
|
||
this.state = 'boot'; // TITLE / DRILL / EXPLORE / BATTLE / DESCEND / SETTLE
|
||
this.menuOpen = false; // MENU = 挂起 overlay(记录返回态 menuFrom)
|
||
this.menuFrom = null;
|
||
this._session = 0; // 会话令牌:状态大跳转作废旧计时器
|
||
this._timers = [];
|
||
this.meta = this._defaultMeta();
|
||
this.run = null;
|
||
this._floor = null; // 当前层生成数据(grid/items/mobs/stairs…)
|
||
this._battle = null;
|
||
this._drill = null;
|
||
this._pulseAt = -1e9;
|
||
this._lastHit = null; // 归因真话律依据(击杀者/背刺/未格挡)
|
||
this._lastEcho = null; // 最近一次脉冲回声(一致性断言用)
|
||
this._onStairs = false;
|
||
this._stairsSpokenFloor = 0;
|
||
this._dangerFloorDone = false;
|
||
this._menuItems = SETTLE_MENU;
|
||
this._menuIdx = 0;
|
||
this._menuConfirm = false; // 菜单「重新开始」二次确认
|
||
this._askLine = null; // TITLE 询问句(有档 continue_ask / 无档 first_tap)
|
||
this._restrictDir = null; // 迷茫 L2:探索态只放行的移动手势
|
||
this._wallExplained = new Set(); // 撞墙归因:每方向每局一次(原则 7)
|
||
this._silenceTimer = null;
|
||
this._lastActAt = 0; // 最近一次玩家操作(呼吸层静默判据)
|
||
this._lastAmbAt = 0; // 呼吸层上次播放时刻
|
||
this._settleChain = []; // 结算链(重听用)
|
||
this._startWatchdog(); // 孤儿 BUSY 看门狗(§4.1 硬规则:不存在安静态)
|
||
}
|
||
|
||
/** 管道选项(index.html 读):四向滑动 + 长按都是本作核心手势(§4.3 裁定)。 */
|
||
get PIPELINE_OPTS() { return { longpressCore: true, swipeCore: true }; }
|
||
|
||
_defaultMeta() {
|
||
return { gold: 0, unlocks: [], bestFloor: 0, runs: 0, kills: 0, drillDone: false, pending: [] };
|
||
}
|
||
|
||
// ---------------- 派生数值(不动) ----------------
|
||
|
||
_has(id) { return this.meta.unlocks.includes(id); }
|
||
_maxHp() { return 20 + (this._has('vigor') ? 6 : 0); }
|
||
_playerAtk() { return 2 + this.run.atkLv + this.run.weapon; }
|
||
_armor() { return this.run.armor; }
|
||
_hpBand(hp, max) {
|
||
const r = hp / max;
|
||
if (r > 0.75) return 1;
|
||
if (r > 0.5) return 2;
|
||
if (r > 0.25) return 3;
|
||
return 4;
|
||
}
|
||
_goldBand(gold) { return gold < 50 ? 1 : gold <= 200 ? 2 : gold <= 600 ? 3 : 4; }
|
||
|
||
/** 金币档账目(不动):band1(0<金<50)播 gold_some 真话句——SETTLE/TITLE
|
||
* (元进度链)共用同一分档与账本(旧上滑状态链已废,st_gold_* 下线)。 */
|
||
_metaGoldVoice() {
|
||
if (this.meta.gold > 0 && this._goldBand(this.meta.gold) === 1) return 'voice/gold_some';
|
||
return 'voice/meta_gold_' + this._goldBand(this.meta.gold);
|
||
}
|
||
|
||
// ================================ 地窟生成(§6,不动) ================================
|
||
|
||
_floorSeedOf(seed, floor) {
|
||
return (seed ^ (floor * 0x9E3779B9)) >>> 0;
|
||
}
|
||
|
||
_mulberry32(a) {
|
||
return function () {
|
||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
_bfs(grid, start) {
|
||
const S = grid.length;
|
||
const dist = Array.from({ length: S }, () => new Array(S).fill(-1));
|
||
const q = [[start.x, start.y]];
|
||
dist[start.y][start.x] = 0;
|
||
while (q.length) {
|
||
const [x, y] = q.shift();
|
||
for (const d of Object.values(DIR_VEC)) {
|
||
const nx = x + d.dx, ny = y + d.dy;
|
||
if (nx < 0 || ny < 0 || nx >= S || ny >= S || grid[ny][nx] !== 1 || dist[ny][nx] >= 0) continue;
|
||
dist[ny][nx] = dist[y][x] + 1;
|
||
q.push([nx, ny]);
|
||
}
|
||
}
|
||
return dist;
|
||
}
|
||
|
||
/**
|
||
* 生成一层:外层拒绝采样(教学局 L1 硬约束优先于随机),内层全走子种子 rng →
|
||
* 同 seed 两次构造逐格相等。走廊 = 相邻房间两两 L 型连通(链式 = 生成树)。
|
||
*/
|
||
_generateFloor(seed, floor) {
|
||
const spec = FLOOR_SPECS[floor];
|
||
const base = this._mulberry32(this._floorSeedOf(seed, floor));
|
||
for (let attempt = 0; attempt < 80; attempt++) {
|
||
const rng = this._mulberry32((base() * 4294967296) >>> 0);
|
||
const f = this._tryGenFloor(rng, floor, spec);
|
||
if (f) return f;
|
||
}
|
||
throw new Error(`dungeon: floor ${floor} generation failed (seed=${seed})`);
|
||
}
|
||
|
||
_tryGenFloor(rng, floor, spec) {
|
||
const S = spec.size;
|
||
const grid = Array.from({ length: S }, () => new Array(S).fill(0));
|
||
const rooms = [];
|
||
if (spec.drill) rooms.push({ x: 2, y: 2, w: 3, h: 3 }); // 教学房固定:起点恒 (3,3)
|
||
const want = 3 + Math.floor(rng() * 4); // 3..6 间
|
||
for (let guard = 0; rooms.length < want && guard < 300; guard++) {
|
||
const w = 2 + Math.floor(rng() * 2); // 2..3
|
||
const h = 2 + Math.floor(rng() * 3); // 2..4
|
||
const x = Math.floor(rng() * (S - w + 1));
|
||
const y = Math.floor(rng() * (S - h + 1));
|
||
if (rooms.some((r) => x < r.x + r.w && r.x < x + w && y < r.y + r.h && r.y < y + h)) continue; // 不重叠
|
||
rooms.push({ x, y, w, h });
|
||
}
|
||
if (rooms.length < 3) return null;
|
||
for (const r of rooms) {
|
||
for (let yy = r.y; yy < r.y + r.h; yy++) for (let xx = r.x; xx < r.x + r.w; xx++) grid[yy][xx] = 1;
|
||
}
|
||
const centerOf = (r) => ({ x: r.x + (r.w >> 1), y: r.y + (r.h >> 1) });
|
||
const carve = (x, y) => { if (x >= 0 && y >= 0 && x < S && y < S) grid[y][x] = 1; };
|
||
for (let i = 1; i < rooms.length; i++) { // L 型走廊链式连通
|
||
const a = centerOf(rooms[i - 1]);
|
||
const b = centerOf(rooms[i]);
|
||
if (rng() < 0.5) {
|
||
for (const x of range(a.x, b.x)) carve(x, a.y);
|
||
for (const y of range(a.y, b.y)) carve(b.x, y);
|
||
} else {
|
||
for (const y of range(a.y, b.y)) carve(a.x, y);
|
||
for (const x of range(a.x, b.x)) carve(x, b.y);
|
||
}
|
||
}
|
||
const start = spec.drill ? { x: 3, y: 3 } : centerOf(rooms[0]);
|
||
if (spec.drill) {
|
||
carve(5, 3); // 东侧走道(教学自由探索余地)
|
||
carve(3, 5); // 南门:教学怪把门
|
||
if (grid[1][3] !== 0) return null; // 约束①:起点正前 2 格内有墙(D0 回声演示)
|
||
if (grid[2][3] !== 1 || grid[4][3] !== 1) return null;
|
||
}
|
||
const dist = this._bfs(grid, start);
|
||
const floorCells = [];
|
||
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
|
||
if (grid[y][x] === 1) {
|
||
if (dist[y][x] < 0) return null; // 连通性不变式
|
||
floorCells.push({ x, y, d: dist[y][x] });
|
||
}
|
||
}
|
||
// 楼梯 = BFS 距起点最远房间中心;终局层无楼梯,王座=最远房心
|
||
let far = null, farD = -1;
|
||
for (const r of rooms) {
|
||
const c = centerOf(r);
|
||
if (dist[c.y][c.x] > farD) { farD = dist[c.y][c.x]; far = c; }
|
||
}
|
||
const stairs = spec.final ? null : { x: far.x, y: far.y };
|
||
const throne = spec.final ? { x: far.x, y: far.y } : null;
|
||
// 宝物撒入房间格
|
||
const items = new Map();
|
||
const occupied = new Set([key(start.x, start.y)]);
|
||
if (stairs) occupied.add(key(stairs.x, stairs.y));
|
||
if (throne) occupied.add(key(throne.x, throne.y));
|
||
for (const it of spec.items) {
|
||
let placed = null;
|
||
for (let g2 = 0; g2 < 120 && !placed; g2++) {
|
||
let c;
|
||
if (spec.drill && it === 'coin') {
|
||
// 约束③:教学金币固定在起点房内(D4 拾取体验)
|
||
const cells = [];
|
||
for (let yy = 2; yy <= 4; yy++) for (let xx = 2; xx <= 4; xx++) {
|
||
if (!(xx === 3 && yy === 3) && grid[yy][xx] === 1) cells.push({ x: xx, y: yy });
|
||
}
|
||
if (!cells.length) return null;
|
||
c = cells[Math.floor(rng() * cells.length)];
|
||
} else {
|
||
const room = rooms[Math.floor(rng() * rooms.length)];
|
||
c = { x: room.x + Math.floor(rng() * room.w), y: room.y + Math.floor(rng() * room.h) };
|
||
}
|
||
if (grid[c.y]?.[c.x] !== 1 || dist[c.y][c.x] < 1 || occupied.has(key(c.x, c.y))) continue;
|
||
placed = c;
|
||
}
|
||
if (!placed) return null;
|
||
occupied.add(key(placed.x, placed.y));
|
||
items.set(key(placed.x, placed.y), it === 'coin'
|
||
? { type: 'coin', gold: 10 + Math.floor(rng() * 21) } // 堆 10-30 金
|
||
: { type: it });
|
||
}
|
||
// 怪物:窟主守楼梯格 / 终局王座;普通怪 BFS ≥3 安全区(教学局非门怪 ≥4)
|
||
const mobs = [];
|
||
const mobTypes = [];
|
||
for (const [type, n] of Object.entries(spec.mobs)) for (let i = 0; i < n; i++) mobTypes.push(type);
|
||
for (let i = mobTypes.length - 1; i > 0; i--) {
|
||
const j = Math.floor(rng() * (i + 1));
|
||
[mobTypes[i], mobTypes[j]] = [mobTypes[j], mobTypes[i]];
|
||
}
|
||
let doorBatPlaced = false;
|
||
const minMobDist = spec.drill ? 4 : 3;
|
||
for (const type of mobTypes) {
|
||
const ms = MOBS[type];
|
||
let cell = null;
|
||
let isDoorBat = false;
|
||
if (ms.boss) cell = spec.final ? { ...throne } : { ...stairs };
|
||
else if (spec.drill && type === 'bat' && !doorBatPlaced) { cell = { x: 3, y: 5 }; isDoorBat = true; doorBatPlaced = true; }
|
||
if (!cell) {
|
||
for (let g3 = 0; g3 < 150 && !cell; g3++) {
|
||
const c = floorCells[Math.floor(rng() * floorCells.length)];
|
||
if (c.d < minMobDist || occupied.has(key(c.x, c.y))) continue;
|
||
cell = { x: c.x, y: c.y };
|
||
}
|
||
if (!cell) return null;
|
||
}
|
||
occupied.add(key(cell.x, cell.y));
|
||
mobs.push({ type, x: cell.x, y: cell.y, hp: ms.hp, maxHp: ms.hp, drill: isDoorBat });
|
||
}
|
||
this._fillMainPathDensity(spec, { grid, dist, start, stairs, throne, mobs, occupied, rng, minMobDist });
|
||
return { floor, size: S, grid, rooms, start, stairs, throne, items, mobs, dist };
|
||
}
|
||
|
||
/**
|
||
* 主路径内容密度下限:start→楼梯(终局层=王座)的最短路上每 ≤ DENSITY_STEP 格
|
||
* 必有内容遭遇。生成后沿 BFS 距离梯度回溯最短路,最长连续无内容段超限 → 段中点
|
||
* 补一只本层已有的普通怪型(结构性密度,不加新怪种)。确定性:只用同一 rng。
|
||
*/
|
||
_fillMainPathDensity(spec, ctx) {
|
||
const { grid, dist, start, stairs, throne, mobs, occupied, rng, minMobDist } = ctx;
|
||
const goal = spec.final ? throne : stairs;
|
||
if (!goal) return;
|
||
const S = grid.length;
|
||
const path = [{ x: goal.x, y: goal.y }];
|
||
let cur = { x: goal.x, y: goal.y };
|
||
while (cur.x !== start.x || cur.y !== start.y) { // 距离梯度回溯 = 最短路(固定扫描序,确定)
|
||
for (const d of Object.values(DIR_VEC)) {
|
||
const nx = cur.x + d.dx, ny = cur.y + d.dy;
|
||
if (nx < 0 || ny < 0 || nx >= S || ny >= S || grid[ny][nx] !== 1 || dist[ny][nx] !== dist[cur.y][cur.x] - 1) continue;
|
||
cur = { x: nx, y: ny };
|
||
break;
|
||
}
|
||
path.push(cur);
|
||
}
|
||
const extraTypes = Object.keys(spec.mobs).filter((t) => !MOBS[t].boss);
|
||
for (let round = 0; round < 8 && extraTypes.length; round++) {
|
||
let bestA = -1, bestLen = 0, runA = 0;
|
||
for (let i = 0; i <= path.length; i++) {
|
||
const filled = i < path.length && occupied.has(key(path[i].x, path[i].y));
|
||
if (!filled) continue; // 段在内容格(或路径尽头)闭合
|
||
if (i - runA > bestLen) { bestLen = i - runA; bestA = runA; }
|
||
runA = i + 1;
|
||
}
|
||
if (bestLen <= DENSITY_STEP) return; // 密度达标
|
||
let mid = bestA + (bestLen >> 1);
|
||
while (mid < path.length - 1 && dist[path[mid].y][path[mid].x] < minMobDist) mid++; // 安全区内偏后
|
||
const cell = path[mid];
|
||
if (dist[cell.y][cell.x] < minMobDist || occupied.has(key(cell.x, cell.y))) return; // 放不下:保底放弃
|
||
const type = extraTypes[round % extraTypes.length];
|
||
const ms = MOBS[type];
|
||
occupied.add(key(cell.x, cell.y));
|
||
mobs.push({ type, x: cell.x, y: cell.y, hp: ms.hp, maxHp: ms.hp, drill: false });
|
||
}
|
||
}
|
||
|
||
/** 进入/重生成一层(下潜、读档重放共用);已拾宝物与已清窟主按 run 过滤。 */
|
||
_enterFloor(floor) {
|
||
const r = this.run;
|
||
r.floor = floor;
|
||
this._floor = this._generateFloor(r.seed, floor);
|
||
r.pos = { ...this._floor.start };
|
||
r.face = 'N';
|
||
r.veilUsedFloor = false;
|
||
this._dangerFloorDone = false;
|
||
this._pulseAt = -1e9;
|
||
this._onStairs = false;
|
||
this._stairsSpokenFloor = 0;
|
||
for (const k of r.looted) this._floor.items.delete(k);
|
||
if (FLOOR_SPECS[floor].boss && r.cleared.includes(floor)) {
|
||
this._floor.mobs = this._floor.mobs.filter((m) => !MOBS[m.type].boss);
|
||
}
|
||
}
|
||
|
||
// ================================ 生命周期 ================================
|
||
|
||
start() {
|
||
this.audio.prewarm(PREWARM_SFX);
|
||
const save = this._loadSave();
|
||
if (save) {
|
||
this.meta = { ...this._defaultMeta(), ...save.meta, unlocks: [...(save.meta?.unlocks || [])], pending: [...(save.meta?.pending || [])] };
|
||
if (save.run) {
|
||
this.run = { ...save.run, pos: { x: save.run.pos[0], y: save.run.pos[1] } };
|
||
}
|
||
}
|
||
this._enterTitle();
|
||
}
|
||
|
||
stop() {
|
||
this._save(); // 退出自动存档(§4.12)
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._stopSilenceFeed();
|
||
this._stopWatchdog();
|
||
this.floor.interrupt();
|
||
this.audio.stopAll();
|
||
}
|
||
|
||
_goHome() {
|
||
this.stop();
|
||
window.location.href = '../';
|
||
}
|
||
|
||
// ---------------- 手势总入口(管道收口,返回 'ok'|'hold'|'invalid'|'silent-ok') ----------------
|
||
|
||
onGesture(g) {
|
||
if (g === 'longpressend' || g === 'tapcancel') return; // 手势收尾/撤销噪声(直呼保护)
|
||
this._lastActAt = performance.now();
|
||
this._idleHintGiven = false; // 玩家动了:本段静默提示作废
|
||
let r;
|
||
if (this.menuOpen) r = this._onMenuGesture(g);
|
||
else switch (this.state) {
|
||
case 'TITLE': r = this._onTitleGesture(g); break;
|
||
case 'DRILL': r = this._onDrillGesture(g); break;
|
||
case 'EXPLORE': r = this._onExploreGesture(g); break;
|
||
case 'BATTLE': r = this._onBattleGesture(g); break;
|
||
case 'SETTLE': r = this._onSettleGesture(g); break;
|
||
default: r = 'ok'; break; // DESCEND 演出窗口:链可能已被滑动打断,兜底定时器续流程
|
||
}
|
||
// 探索态收尾:手势处理完且没起新链 → 回 ACTION(环境音即状态,§4.1)
|
||
if (this.state === 'EXPLORE' && !this.menuOpen && !this.floor.speaking) this.floor.enterAction();
|
||
// 教学作答步兜底:手势处理完若不在播、不在 ACTION、等待点又不在(被管道
|
||
// settleWaitPoint(true) 摘掉 / 解释链被打断),重新挂回本步等待点——否则
|
||
// invalid 解释链播完无人恢复话轮,floor 停在 BUSY 吞掉后续手势。
|
||
// (invalid 路径管道随后 settleWaitPoint(false) 会保住它;有效作答路径本步
|
||
// 即将推进,挂上再被摘也无害。)
|
||
if (this.state === 'DRILL' && !this.menuOpen && !this.floor.speaking
|
||
&& this.floor.state !== 'ACTION' && !this.floor.waitPoint
|
||
&& this._drillExpected(this._drill?.step)) {
|
||
this._drillArm(this._drill.step);
|
||
}
|
||
return r;
|
||
}
|
||
|
||
stateName() {
|
||
if (this.menuOpen) return 'menu';
|
||
if (this.state === 'BATTLE' && this._battle?.state === 'telegraph') return 'tel';
|
||
return { TITLE: 'title', DRILL: 'drill', EXPLORE: 'explore', BATTLE: 'battle', SETTLE: 'settle', DESCEND: 'descend' }[this.state] || 'explore';
|
||
}
|
||
|
||
/** 无效操作解释句(原则 7:每「状态 × 手势」只解释一次,管道调用)。 */
|
||
invalidHint(state, g) {
|
||
if (state === 'locked') return null; // 四向 + 长按都是核心手势,锁不生效
|
||
if (state === 'explore') return g === 'tap' ? 'voice/inv_pulse' : null;
|
||
if (state === 'title') return g === 'down' ? 'voice/inv_menu_locked' : 'voice/inv_title';
|
||
if (state === 'settle') return g === 'down' ? 'voice/inv_menu_locked' : 'voice/inv_settle';
|
||
if (state === 'drill') return 'voice/inv_drill';
|
||
if (state === 'tel') return 'voice/inv_tel';
|
||
if (state === 'menu') return 'voice/menu_help';
|
||
return null;
|
||
}
|
||
|
||
// ---------------- TITLE(§2.3) ----------------
|
||
|
||
_enterTitle() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._battle = null;
|
||
this.menuOpen = false;
|
||
this.menuFrom = null;
|
||
this.state = 'TITLE';
|
||
this.audio.play('bgm/menu');
|
||
this.floor.interrupt();
|
||
// 第一句让玩家动手(§4.10):无档「点一下屏幕。」;有档 = 断点复述询问(§4.12)
|
||
this._askLine = this.run ? 'voice/sys_continue_ask' : 'voice/sys_first_tap';
|
||
markHintSaid(this._askLine); // 被动提示 30s 台账(避免询问句原样重播)
|
||
this.floor.speak([this._askLine], {
|
||
onDone: () => { if (this.state === 'TITLE' && !this.menuOpen) this.floor.yieldTurn(this._titleWp()); },
|
||
});
|
||
}
|
||
|
||
_titleWp() {
|
||
return this._wp({
|
||
id: 'title', expected: ['tap', 'right'],
|
||
full: this._askLine, short: 'voice/sys_first_tap_short',
|
||
silenceMs: [12000, 30000],
|
||
onDemo: () => this.floor.chime(),
|
||
});
|
||
}
|
||
|
||
_sayTitle(ids) {
|
||
this.floor.speak(ids, {
|
||
onDone: () => { if (this.state === 'TITLE' && !this.menuOpen) this.floor.yieldTurn(this._titleWp()); },
|
||
});
|
||
}
|
||
|
||
_titleUpChain() {
|
||
const ids = [this._metaGoldVoice()];
|
||
const best = this.meta.bestFloor;
|
||
ids.push(best < 3 ? 'voice/st_best_1' : best < 6 ? 'voice/st_best_2' : 'voice/st_best_3');
|
||
if (this.meta.unlocks.length === 0) ids.push('voice/unlock_none');
|
||
else for (const u of this.meta.unlocks) ids.push('voice/unlock_' + u);
|
||
return ids;
|
||
}
|
||
|
||
_onTitleGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
if (this.run) this._resumeRun();
|
||
else this._newRunFlow();
|
||
return 'ok';
|
||
case 'right':
|
||
this._newRunFlow(); // 重掷新周目(meta 保留)
|
||
return 'ok';
|
||
case 'up':
|
||
this._sayTitle(this._titleUpChain()); // 我在哪 = 元进度播报
|
||
return 'ok';
|
||
case 'longpress':
|
||
this._sayTitle([this._askLine]); // 重听询问句
|
||
return 'ok';
|
||
case 'doubletap':
|
||
this._sayTitle(['voice/sys_say_again', this._askLine]); // 阶段 3:重听等待点句
|
||
return 'ok';
|
||
case 'down':
|
||
if (this.ladder.has('down')) { this._openMenu(); return 'ok'; }
|
||
return 'invalid'; // 菜单阶段 2 才开放(00 §5.3)
|
||
default:
|
||
return 'invalid'; // left
|
||
}
|
||
}
|
||
|
||
// ---------------- 新周目 / 继续(§4.12 断点复述) ----------------
|
||
|
||
_newRunFlow() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._battle = null;
|
||
this._drill = null;
|
||
this.menuOpen = false;
|
||
this.floor.interrupt();
|
||
const seed = this._fixedSeed != null ? this._fixedSeed : Math.floor(Math.random() * 1e9);
|
||
this._fixedSeed = null;
|
||
this.run = {
|
||
seed, floor: 1, pos: { x: 0, y: 0 }, face: 'N',
|
||
hp: this._maxHp(), atkLv: 0,
|
||
weapon: this._has('blade') ? 1 : 0, armor: 0,
|
||
potion: this._has('potion') ? 1 : 0,
|
||
veilUsedFloor: false, killsRun: 0, looted: [], cleared: [],
|
||
parries: 0, flees: 0, gold: 0,
|
||
};
|
||
this._lastHit = null;
|
||
this._wallExplained.clear();
|
||
this._enterFloor(1);
|
||
this._save();
|
||
const ids = ['voice/sys_new_run'];
|
||
if (this.meta.pending.length) {
|
||
ids.push('voice/unlock_lead');
|
||
for (const u of this.meta.pending) ids.push('voice/unlock_' + u);
|
||
}
|
||
this._sayFlow(ids, () => {
|
||
this.meta.pending = [];
|
||
this._save();
|
||
if (!this.meta.drillDone) this._enterDrill();
|
||
else this._resumeExplore(this._recapChain()); // 新周目直落探索也带局面复述
|
||
}, 20000);
|
||
}
|
||
|
||
_restartRun(seed) { // 菜单「重新开始本周目」:同 seed 重置
|
||
this.run = null;
|
||
this._fixedSeed = seed;
|
||
this._newRunFlow();
|
||
}
|
||
|
||
/** 局面复述(§4.12):层 → 血量 → 面朝 → 楼梯方位(目标句计入 30s 台账,帮助链降档复用)。 */
|
||
_recapChain() {
|
||
const ids = ['voice/st_floor_' + this.run.floor,
|
||
'voice/st_hp_' + this._hpBand(this.run.hp, this._maxHp()),
|
||
'voice/st_face_' + DIR_KEY[this.run.face]];
|
||
const goal = this._goalVoice();
|
||
if (goal) { markHintSaid(goal); ids.push(goal); }
|
||
return ids;
|
||
}
|
||
|
||
/** 目标句:终局层=王座报名;窟主未清=守梯提醒;否则=楼梯绝对方位。 */
|
||
_goalVoice() {
|
||
if (!this._floor) return null;
|
||
const spec = FLOOR_SPECS[this.run.floor];
|
||
if (spec.final) return 'voice/btl_king';
|
||
const target = this._floor.stairs || this._floor.throne;
|
||
if (!target) return null;
|
||
if (spec.boss && !this.run.cleared.includes(this.run.floor)) return 'voice/stair_hint_b';
|
||
return 'voice/stair_dir_' + DIR_KEY[this._dominantDirTo(target.x, target.y)];
|
||
}
|
||
|
||
_resumeRun() {
|
||
const saved = { pos: { ...this.run.pos }, face: this.run.face, veil: this.run.veilUsedFloor };
|
||
this._enterFloor(this.run.floor); // 重放该层 seed 生成
|
||
this.run.pos = saved.pos;
|
||
this.run.face = saved.face;
|
||
this.run.veilUsedFloor = saved.veil;
|
||
if (!this.meta.drillDone) { this._enterDrill(); return; } // 教学未完:从头再练(步骤不存档)
|
||
this._resumeExplore(this._recapChain());
|
||
}
|
||
|
||
// ================================ 探索态(ACTION:玩法声音即状态) ================================
|
||
|
||
_resumeExplore(chain) {
|
||
this.state = 'EXPLORE';
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.menuOpen = false;
|
||
this.audio.play('bgm/explore');
|
||
this.floor.enterAction();
|
||
this._startMobs();
|
||
this.confusion.resetForWaitPoint();
|
||
this._restrictDir = null;
|
||
this._lastActAt = performance.now();
|
||
this._lastAmbAt = performance.now();
|
||
this._startSilenceFeed();
|
||
this._startAmbLoop();
|
||
if (chain && chain.length) this._sayContent(chain);
|
||
this._save();
|
||
}
|
||
|
||
_onExploreGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
case 'doubletap': { // 双击(阶段 3)= 冷却外再脉冲,不绑「免冷却重播」
|
||
if (this._onStairs && this._stairsUsable()) { this._descend(false); return 'ok'; }
|
||
return this._pulse() ? 'silent-ok' : 'invalid'; // 回声组 = 明显内容音
|
||
}
|
||
case 'up':
|
||
case 'down':
|
||
case 'left':
|
||
case 'right':
|
||
return this._moveRel(g);
|
||
case 'longpress':
|
||
this._longpressHelp();
|
||
return 'ok';
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
_stairsUsable() {
|
||
const spec = FLOOR_SPECS[this.run.floor];
|
||
if (!this._floor.stairs || !this._onStairs) return false;
|
||
if (spec.boss && !this.run.cleared.includes(this.run.floor)) return false; // 窟主未清
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 四向滑动 = 面朝相对移动(§4.3):up=前进;left/right=侧移并转向;down=后退。
|
||
* 移动成功 = 脚步声(silent-ok);撞墙 = bump + invalid + 归因 + 方位真话(§4.9)。
|
||
*/
|
||
_moveRel(g) {
|
||
if (this._restrictDir && g !== this._restrictDir) return 'invalid'; // 迷茫 L2:只放行接管方向
|
||
const rel = g === 'up' ? REL.FRONT : g === 'right' ? REL.RIGHT : g === 'down' ? REL.BEHIND : REL.LEFT;
|
||
const fi = DIR_ORDER.indexOf(this.run.face);
|
||
const dir = DIR_ORDER[(fi + rel) % 4];
|
||
const d = DIR_VEC[dir];
|
||
const nx = this.run.pos.x + d.dx;
|
||
const ny = this.run.pos.y + d.dy;
|
||
const S = this._floor.size;
|
||
if (nx < 0 || ny < 0 || nx >= S || ny >= S || this._floor.grid[ny][nx] !== 1) {
|
||
this._bump(rel);
|
||
return 'invalid';
|
||
}
|
||
if (rel === REL.RIGHT || rel === REL.LEFT) {
|
||
this.run.face = dir; // 侧移附带转向
|
||
this.audio.play('sfx/step_turn');
|
||
}
|
||
this.run.pos = { x: nx, y: ny };
|
||
this._drillAlive();
|
||
this.audio.play('sfx/step_own', { volume: 0.7 }); // 内容音即回执
|
||
vibrate('swipe');
|
||
this._save();
|
||
this._arrive(nx, ny); // 怪 → 遭遇;宝物 → 拾取;楼梯 → 到达即报
|
||
return 'silent-ok';
|
||
}
|
||
|
||
/** 撞墙失败表(§4.9):闷响 + invalid 回执;归因句每方向整局一次(原则 7)。 */
|
||
_bump(rel) {
|
||
this.audio.play('sfx/bump');
|
||
vibrate('error');
|
||
this.confusion.recordFail();
|
||
const K = ['front', 'right', 'back', 'left'][rel];
|
||
if (this._wallExplained.has(K)) return; // 之后只给闷响 + invalid 回执
|
||
const first = this._wallExplained.size === 0;
|
||
this._wallExplained.add(K);
|
||
const ids = first ? ['voice/wall', 'voice/wall_dir_' + K] : ['voice/wall_dir_' + K];
|
||
this._sayContent(ids, { kind: 'feedback' });
|
||
}
|
||
|
||
/** 踏格判定:怪 → 遭遇;宝物 → 拾取;楼梯 → 到达即报(不占单击通道)。 */
|
||
_arrive(nx, ny) {
|
||
const mob = this._floor.mobs.find((m) => m.x === nx && m.y === ny);
|
||
if (mob) { this._encounter(mob); return; }
|
||
const k = key(nx, ny);
|
||
const item = this._floor.items.get(k);
|
||
if (item) this._pickup(item, k);
|
||
this._onStairCell(nx, ny);
|
||
}
|
||
|
||
/** 声波脉冲(§3,不动):预生成资产 + setTimeout + volume,可被虚拟时钟断言。
|
||
* 冷却期返回 null(管道给 invalid 回执 + inv_pulse 解释一次)。 */
|
||
_pulse() {
|
||
const now = performance.now();
|
||
if (now - this._pulseAt < PULSE_CD) return null;
|
||
this._pulseAt = now;
|
||
this._drillAlive();
|
||
this.audio.play('sfx/pulse', { volume: 0.8 });
|
||
const s = this._session;
|
||
const f = this._echoScan(this.run.pos.x, this.run.pos.y, this.run.face);
|
||
const band = f.dist <= 1 ? 1 : f.dist === 2 ? 2 : f.dist <= 4 ? 3 : 4;
|
||
const delay = ECHO_DELAY[band];
|
||
const vol = ECHO_VOL[band];
|
||
this._after(delay, () => { if (s === this._session) this.audio.play('sfx/echo_' + band, { volume: vol }); });
|
||
this._after(delay + MAT_TAIL_AT, () => { if (s === this._session) this.audio.play('sfx/mat_' + f.mat, { volume: 0.8 }); });
|
||
const L = this._sideScan(-1);
|
||
const R = this._sideScan(1);
|
||
this._after(SIDE_AT, () => {
|
||
if (s !== this._session) return;
|
||
this.audio.play('sfx/side_' + L.kind + '_l', { volume: this._sideVol(L) });
|
||
this.audio.play('sfx/side_' + R.kind + '_r', { volume: this._sideVol(R) });
|
||
});
|
||
this._lastEcho = { band, dist: f.dist, mat: f.mat, left: L.kind, right: R.kind, delay, vol };
|
||
return this._lastEcho;
|
||
}
|
||
|
||
/** 正前扫描:最近反射体决定距离与材质(楼梯=铁梯;身后方向永不进入扫描)。
|
||
* 站在楼梯格上脉冲 → 贴身铁器回声。 */
|
||
_echoScan(x, y, face) {
|
||
const st = this._floor.stairs;
|
||
if (st && x === st.x && y === st.y) return { dist: 1, mat: 'stair' };
|
||
const d = DIR_VEC[face];
|
||
const S = this._floor.size;
|
||
for (let i = 1; i <= S * 2; i++) {
|
||
const cx = x + d.dx * i, cy = y + d.dy * i;
|
||
if (cx < 0 || cy < 0 || cx >= S || cy >= S || this._floor.grid[cy][cx] === 0) return { dist: i, mat: 'stone' };
|
||
if (st && cx === st.x && cy === st.y) return { dist: i, mat: 'stair' };
|
||
}
|
||
return { dist: S * 2, mat: 'stone' };
|
||
}
|
||
|
||
/**
|
||
* 侧向扫描(side = -1 左 / 1 右):开口(第一格可通行=能走进去)/墙(闷),
|
||
* 身后不报。开一格就是开口(真话)。
|
||
*/
|
||
_sideScan(side) {
|
||
const fi = DIR_ORDER.indexOf(this.run.face);
|
||
const d = DIR_VEC[DIR_ORDER[(fi + side + 4) % 4]];
|
||
const S = this._floor.size;
|
||
let x = this.run.pos.x, y = this.run.pos.y;
|
||
for (let i = 1; i <= S; i++) {
|
||
x += d.dx; y += d.dy;
|
||
if (x < 0 || y < 0 || x >= S || y >= S || this._floor.grid[y][x] === 0) {
|
||
return { kind: 'wall', dist: i };
|
||
}
|
||
if (i === 1) return { kind: 'open', dist: 1 }; // 侧向第一格是地板 = 开口(真话)
|
||
}
|
||
return { kind: 'open', dist: S };
|
||
}
|
||
|
||
/** 侧向音量:回声强化解锁后按远近换 2 组资产音量。 */
|
||
_sideVol(s) {
|
||
if (this._has('echo')) return s.dist <= 1 ? 0.8 : 0.45;
|
||
return 0.6;
|
||
}
|
||
|
||
// ---------------- 长按 = 我在哪(原则 3:一句话 ≤20 字) ----------------
|
||
|
||
/** 玩家主动请求(§4.4):永远给完整链(降档只作用于被动提示);说完计入台账,
|
||
* 随后的被动提示/接管 L1 自动变短(原则 9)。 */
|
||
_longpressHelp(onDone) {
|
||
if (this.state === 'BATTLE') { this._speakBattleStatus(); return; }
|
||
if (!this.run) { this._sayTitle([this._askLine || 'voice/sys_first_tap']); return; }
|
||
if (this.state !== 'EXPLORE' && this.state !== 'DRILL') return;
|
||
const band = this._hpBand(this.run.hp, this._maxHp());
|
||
const ids = ['voice/st_floor_' + this.run.floor];
|
||
if (band >= 2) ids.push('voice/st_hp_' + band); // 只有受伤才报血(语言经济)
|
||
ids.push('voice/st_face_' + DIR_KEY[this.run.face]);
|
||
if (this._onStairs) {
|
||
ids.push('voice/tut_stairs_on');
|
||
markHintSaid('voice/tut_stairs_on');
|
||
} else {
|
||
const goal = this._goalVoice();
|
||
if (goal) { markHintSaid(goal); ids.push(goal); }
|
||
}
|
||
this._sayContent(ids, { onDone });
|
||
}
|
||
|
||
// ---------------- 拾取(数值/账目不动) ----------------
|
||
|
||
_pickup(item, k) {
|
||
if (item.type === 'coin') {
|
||
this.meta.gold += item.gold; // 金币实时入元进度(死亡不清档)
|
||
this.run.gold += item.gold;
|
||
this._floor.items.delete(k);
|
||
this.run.looted.push(k);
|
||
this.audio.play('sfx/coin');
|
||
vibrate('pickup');
|
||
const band = item.gold < 16 ? 1 : item.gold < 24 ? 2 : 3;
|
||
this._progress();
|
||
this._save();
|
||
// D4:拾到金币 = 第一个小目标(ladder 阶段 1)→ 进长按教学。推进走有界定时:
|
||
// evt_coin 链被乱手势打断也不丢推进(对齐 _sayFlow 语义,步号先守恒)。
|
||
if (this._drill && this._drill.step === 4) {
|
||
this._drillDoneStep(4, () => {
|
||
this.ladder.onGoal();
|
||
this._drillAdvance(5);
|
||
}, 1600);
|
||
}
|
||
this._sayContent(['voice/evt_coin_' + band]);
|
||
} else if (item.type === 'potion') {
|
||
if (this.run.potion >= 1) {
|
||
this._sayContent(['voice/evt_potion_full']); // 腰带满:药留在原地
|
||
return;
|
||
}
|
||
this.run.potion = 1;
|
||
this._floor.items.delete(k);
|
||
this.run.looted.push(k);
|
||
this.audio.play('sfx/potion');
|
||
vibrate('pickup');
|
||
this._progress();
|
||
this._save();
|
||
this._sayContent(['voice/evt_potion']);
|
||
} else if (item.type === 'weapon1' || item.type === 'weapon2') {
|
||
const lvl = item.type === 'weapon1' ? 1 : 2;
|
||
if (this.run.weapon >= lvl) return; // 已有更优:留在原地
|
||
this.run.weapon = lvl;
|
||
this._floor.items.delete(k);
|
||
this.run.looted.push(k);
|
||
this.audio.play('sfx/gear');
|
||
vibrate('pickup');
|
||
this._progress();
|
||
this._save();
|
||
this._sayContent(['voice/evt_weapon']);
|
||
} else if (item.type === 'armor1') {
|
||
if (this.run.armor >= 1) return;
|
||
this.run.armor = 1;
|
||
this._floor.items.delete(k);
|
||
this.run.looted.push(k);
|
||
this.audio.play('sfx/gear');
|
||
vibrate('pickup');
|
||
this._progress();
|
||
this._save();
|
||
this._sayContent(['voice/evt_armor']);
|
||
}
|
||
}
|
||
|
||
// ---------------- 楼梯格(到达即报,不占输入通道) ----------------
|
||
|
||
_onStairCell(x, y) {
|
||
const st = this._floor.stairs;
|
||
if (!st || st.x !== x || st.y !== y) { this._onStairs = false; return; }
|
||
this._onStairs = true;
|
||
this._progress(); // 踏上楼梯格 = 进展事件
|
||
const s = this._session;
|
||
this.audio.play('sfx/echo_1', { volume: ECHO_VOL[1] });
|
||
this._after(MAT_TAIL_AT, () => { if (s === this._session) this.audio.play('sfx/mat_stair', { volume: 0.8 }); });
|
||
const spec = FLOOR_SPECS[this.run.floor];
|
||
if (spec.boss && !this.run.cleared.includes(this.run.floor)) {
|
||
this._sayContent(['voice/evt_stairs_lock']);
|
||
return;
|
||
}
|
||
if (this._stairsSpokenFloor !== this.run.floor) {
|
||
this._stairsSpokenFloor = this.run.floor;
|
||
markHintSaid('voice/tut_stairs_on');
|
||
this._sayContent(['voice/tut_stairs_on']); // 楼梯就在脚下。单击,下潜。
|
||
}
|
||
}
|
||
|
||
// ---------------- 威胁感知(不动) ----------------
|
||
|
||
_threatKey() {
|
||
let left = 0, right = 0;
|
||
const fi = DIR_ORDER.indexOf(this.run.face);
|
||
for (const m of this._floor.mobs) {
|
||
const dx = m.x - this.run.pos.x, dy = m.y - this.run.pos.y;
|
||
if (Math.abs(dx) + Math.abs(dy) > 5) continue;
|
||
const dirIdx = dx > 0 ? 1 : dx < 0 ? 3 : dy > 0 ? 2 : 0;
|
||
const rel = (dirIdx - fi + 4) % 4;
|
||
if (rel === REL.LEFT) left++;
|
||
else if (rel === REL.RIGHT) right++;
|
||
// 前方/身后不报(同回声降维)
|
||
}
|
||
return left && right ? 'both' : left ? 'left' : right ? 'right' : 'none';
|
||
}
|
||
|
||
// ================================ 怪物游走与遭遇(§7.1,不动) ================================
|
||
|
||
_startMobs() {
|
||
for (const m of this._floor.mobs) {
|
||
if (MOBS[m.type].boss) continue; // 窟主守楼梯格,不游走
|
||
this._mobLoop(m);
|
||
}
|
||
}
|
||
|
||
_mobLoop(m) {
|
||
const s = this._session;
|
||
this._after(1500 + Math.floor(Math.random() * 1000), () => {
|
||
if (s !== this._session) return;
|
||
if (this.state === 'EXPLORE' && !this.menuOpen) this._mobStep(m);
|
||
this._mobLoop(m);
|
||
});
|
||
}
|
||
|
||
_mobStep(m) {
|
||
const S = this._floor.size;
|
||
const opts = [];
|
||
for (const d of Object.values(DIR_VEC)) {
|
||
const nx = m.x + d.dx, ny = m.y + d.dy;
|
||
if (nx >= 0 && ny >= 0 && nx < S && ny < S && this._floor.grid[ny][nx] === 1) opts.push({ x: nx, y: ny });
|
||
}
|
||
if (!opts.length) return;
|
||
const into = opts.find((c) => c.x === this.run.pos.x && c.y === this.run.pos.y);
|
||
if (into) { this._encounter(m); return; } // 怪走进你格
|
||
const c = opts[Math.floor(Math.random() * opts.length)];
|
||
m.x = c.x; m.y = c.y;
|
||
this._mobStepSound(m.type, m.x, m.y);
|
||
// 相邻即开战(互视/贴身必扑)
|
||
if (Math.abs(m.x - this.run.pos.x) + Math.abs(m.y - this.run.pos.y) === 1) this._encounter(m);
|
||
}
|
||
|
||
/** 脚步报位(§7.1,不动):pan 按相对方位、音量按距离档;背后=中央资产音量减半。 */
|
||
_mobStepSound(type, x, y) {
|
||
const dx = x - this.run.pos.x, dy = y - this.run.pos.y;
|
||
const dist = Math.abs(dx) + Math.abs(dy);
|
||
if (dist > 8) return; // 更远不播
|
||
const fi = DIR_ORDER.indexOf(this.run.face);
|
||
const dirIdx = dx > 0 ? 1 : dx < 0 ? 3 : dy > 0 ? 2 : 0;
|
||
const rel = (dirIdx - fi + 4) % 4;
|
||
const pan = rel === REL.LEFT ? 'l' : rel === REL.RIGHT ? 'r' : 'c';
|
||
let vol = dist <= 2 ? 0.9 : dist <= 4 ? 0.6 : 0.35;
|
||
if (rel === REL.BEHIND) vol *= 0.5;
|
||
this.audio.play('sfx/mob_step_' + MOBS[type].cls + '_' + pan, { volume: vol });
|
||
if (dist <= 2) vibrate('heartbeat'); // 威胁前置预警窗
|
||
}
|
||
|
||
/** 遭遇(明雷):播报名即=它在面前(遭遇自动面对,16 号 §5)。 */
|
||
_encounter(mob) {
|
||
const ms = MOBS[mob.type];
|
||
// 遭遇自动面对:相邻扑来/走进怪格都先把怪转到正面(报名即真话)
|
||
const dx = mob.x - this.run.pos.x, dy = mob.y - this.run.pos.y;
|
||
if (dx !== 0 || dy !== 0) {
|
||
this.run.face = dx > 0 ? 'E' : dx < 0 ? 'W' : dy > 0 ? 'S' : 'N';
|
||
}
|
||
this._floor.mobs = this._floor.mobs.filter((m) => m !== mob);
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.state = 'BATTLE';
|
||
this._battle = {
|
||
type: mob.type, spec: ms, hp: mob.hp, maxHp: ms.hp, atk: ms.atk, def: ms.def,
|
||
cls: ms.cls, boss: !!ms.boss, mover: !!ms.mover, cell: { x: mob.x, y: mob.y },
|
||
bearing: DIR_ORDER.indexOf(this.run.face), moveN: 0, phase2: false,
|
||
state: 'intro', windowEnd: 0, parry: false, noTurnSaid: false, drillTaught: false,
|
||
swishSaid: false, parriedSaid: false, behindSaid: false, wp: null, // 归因句每场一次(原则 7)
|
||
drill: !!(this._drill && this._drill.step === 6),
|
||
drillMob: !!mob.drill,
|
||
};
|
||
this.audio.play(ms.boss ? 'bgm/boss' : 'bgm/battle', { fadeMs: 400 });
|
||
this._sayFlow(['voice/' + ms.start], () => {
|
||
if (this._battle?.drill) {
|
||
// 教学战:先由教学怪出手,练单击格挡
|
||
this._sayFlow(['voice/tut_parry_tap'], () => this._enemyTurn(), 8000);
|
||
} else {
|
||
this._playerTurn();
|
||
}
|
||
}, 10000);
|
||
}
|
||
|
||
// ================================ 战斗(§7,数值不动) ================================
|
||
|
||
_battleRel() {
|
||
return (this._battle.bearing - DIR_ORDER.indexOf(this.run.face) + 4) % 4;
|
||
}
|
||
|
||
_playerTurn() {
|
||
const b = this._battle;
|
||
if (!b) return;
|
||
b.state = 'player';
|
||
b.parry = false;
|
||
if (!b.wp) {
|
||
b.wp = this._wp({
|
||
id: 'battle', expected: ['tap', 'left', 'right'],
|
||
full: 'voice/gd_turn', short: 'voice/btl_turn_short',
|
||
silenceMs: [8000, 15000],
|
||
onDemo: () => { if (this._battle === b && b.state === 'player') this._swing(); }, // L3:替你挥一拳
|
||
});
|
||
}
|
||
this.floor.yieldTurn(b.wp); // 交接提示音 → 等待声 → 三档提示
|
||
}
|
||
|
||
_onBattleGesture(g) {
|
||
const b = this._battle;
|
||
if (!b) return 'ok';
|
||
if (b.state === 'player') {
|
||
switch (g) {
|
||
case 'tap':
|
||
case 'doubletap':
|
||
this._swing();
|
||
return 'silent-ok'; // 挥击音即回执
|
||
case 'left':
|
||
return this._battleTurnG(-1);
|
||
case 'right':
|
||
return this._battleTurnG(1);
|
||
case 'down':
|
||
this._flee(); // 下滑 = 尝试逃跑(普通怪必成挨一下;Boss 拒绝)
|
||
return 'ok';
|
||
case 'up':
|
||
case 'longpress':
|
||
this._speakBattleStatus(); // 我在哪:血量 + 敌方阶段/方位
|
||
return 'ok';
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
if (b.state === 'telegraph') {
|
||
if ((g === 'tap' || g === 'doubletap') && performance.now() <= b.windowEnd) {
|
||
b.parry = true; // 面对与否在结算时按实际方位判
|
||
this.audio.play('sfx/parry');
|
||
vibrate('confirm');
|
||
return 'silent-ok';
|
||
}
|
||
if (g === 'left') return this._battleTurnG(-1); // 窗口内来得及则转身面对
|
||
if (g === 'right') return this._battleTurnG(1);
|
||
return 'invalid'; // 前摇窗只留格挡与转身(up/down/longpress → inv_tel 解释一次)
|
||
}
|
||
return 'ok'; // intro/enemyMove/resolve:链在播则管道已处理(tap=跳句)
|
||
}
|
||
|
||
_swing() {
|
||
const b = this._battle;
|
||
b.state = 'busy';
|
||
this.audio.play('sfx/atk_swing');
|
||
if (this._battleRel() !== REL.FRONT) {
|
||
// 挥空(归因真话):它不在你面前——每场一次,之后只挥空声(原则 7)
|
||
vibrate('error');
|
||
if (!b.swishSaid) {
|
||
b.swishSaid = true;
|
||
this._sayFlow(['voice/btl_swish'], () => this._enemyTurn());
|
||
} else {
|
||
this._enemyTurn();
|
||
}
|
||
return;
|
||
}
|
||
this.audio.play('sfx/atk_hit');
|
||
const dmg = Math.max(0, this._playerAtk() - b.def);
|
||
b.hp -= dmg;
|
||
if (b.hp <= 0) { this._victory(); return; }
|
||
this._halfCheck(() => this._enemyTurn());
|
||
}
|
||
|
||
_halfCheck(next) {
|
||
const b = this._battle;
|
||
if (!b.phase2 && b.spec.half && b.hp <= b.maxHp / 2) {
|
||
b.phase2 = true;
|
||
b.atk += 1; // 半血 ATK+1(王 5→6)
|
||
vibrate('warning');
|
||
this._sayFlow(['voice/' + b.spec.half], next, 8000);
|
||
return;
|
||
}
|
||
next();
|
||
}
|
||
|
||
/** 转身(战斗):只对绕位怪/Boss 有意义;缠斗怪纠错(每场 1 次,之后仅回执)。
|
||
* 玩家回合中转身不消耗回合 → hold 保住战斗等待点(转身声即内容音,hold 只补
|
||
* ok 回执);前摇窗内(ACTION)转身仍走 silent-ok。 */
|
||
_battleTurnG(d) {
|
||
const b = this._battle;
|
||
if (!b.mover) {
|
||
vibrate('error');
|
||
if (!b.noTurnSaid) {
|
||
b.noTurnSaid = true;
|
||
// 播完回到战斗等待点(无 onDone 的话链播完 floor 停 BUSY,吞后续手势)
|
||
this.floor.speak(['voice/btl_no_turn'], {
|
||
kind: 'feedback',
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
}
|
||
return 'invalid';
|
||
}
|
||
const i = DIR_ORDER.indexOf(this.run.face);
|
||
this.run.face = DIR_ORDER[(i + d + 4) % 4];
|
||
this.audio.play('sfx/step_turn');
|
||
return this.floor.state === 'YOUR_TURN' ? 'hold' : 'silent-ok';
|
||
}
|
||
|
||
_speakBattleStatus() { // 我在哪(战斗):血量档 → 敌方阶段 →(绕位/Boss)方位
|
||
const b = this._battle;
|
||
if (!b) return;
|
||
const band = this._hpBand(this.run.hp, this._maxHp());
|
||
if (band === 4) vibrate('warning');
|
||
const ids = ['voice/st_hp_' + band];
|
||
if (b.spec.half && b.hp <= b.maxHp / 2) ids.push('voice/' + b.spec.half);
|
||
if (b.mover) {
|
||
const rel = this._battleRel();
|
||
if (rel === REL.BEHIND) ids.push('voice/btl_behind');
|
||
else if (rel === REL.LEFT) ids.push('voice/st_threat_left');
|
||
else if (rel === REL.RIGHT) ids.push('voice/st_threat_right');
|
||
// 正面:脚步/前摇音已报位,不占台词预算
|
||
}
|
||
this.floor.speak(ids, {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
}
|
||
|
||
/** 下滑 = 尝试逃跑:普通/绕位必成挨一下(真实账目,无格挡);Boss 拒绝。 */
|
||
_flee() {
|
||
const b = this._battle;
|
||
if (b.boss) {
|
||
vibrate('error');
|
||
this._sayFlow(['voice/btl_flee_boss'], () => this._playerTurn(), 5000);
|
||
return;
|
||
}
|
||
b.state = 'busy';
|
||
this.run.flees++;
|
||
let dmg = Math.max(0, b.atk - this._armor()); // 撤退挨一下:真实账目
|
||
if (dmg > 0) {
|
||
if (this._applyVeil()) dmg = Math.floor(dmg / 2);
|
||
this.run.hp -= dmg;
|
||
this.audio.play('sfx/hurt');
|
||
vibrate('hit');
|
||
}
|
||
this._lastHit = { type: b.type, boss: b.boss, behind: false, parried: false, unparried: true, flee: true };
|
||
if (this.run.hp <= 0) { this._die(); return; }
|
||
const afterFlee = () => {
|
||
this._battle = null;
|
||
const restored = { type: b.type, x: b.cell.x, y: b.cell.y, hp: b.hp, maxHp: b.maxHp, drill: b.drillMob };
|
||
this._floor.mobs.push(restored); // 怪留原格
|
||
if (this._drill?.bat) this._drill.bat = restored;
|
||
this._progress();
|
||
this._save();
|
||
if (this._drill) {
|
||
this.state = 'DRILL';
|
||
this.audio.play('bgm/explore', { fadeMs: 400 });
|
||
this._drillArm(this._drill.step); // 回教学等待态(作答步交话轮 / 自由步回 ACTION)
|
||
if (this._drill.step === 6) this._drillApproach();
|
||
return;
|
||
}
|
||
this._resumeExplore();
|
||
};
|
||
// 归因真话律:ATK0 怪(L1 蝠)根本没碰到你,「被抓了一下」是假话 → 静默脱战
|
||
if (dmg > 0) this._sayFlow(['voice/btl_flee'], afterFlee, 8000);
|
||
else afterFlee();
|
||
}
|
||
|
||
/** 敌方回合:绕位怪每 2 回合移动(脚步报新方位)→ 攻击前摇 1.2s → 结算。 */
|
||
_enemyTurn() {
|
||
const b = this._battle;
|
||
if (!b) return;
|
||
b.state = 'enemyMove';
|
||
b.parry = false;
|
||
b.moveN++;
|
||
if (b.mover && b.moveN % 2 === 0) {
|
||
const seq = [REL.RIGHT, REL.BEHIND, REL.LEFT]; // 每 2 回合换位(确定性轮换)
|
||
const rel = seq[(b.moveN / 2 - 1) % seq.length];
|
||
b.bearing = (DIR_ORDER.indexOf(this.run.face) + rel) % 4;
|
||
const pan = rel === REL.LEFT ? 'l' : rel === REL.RIGHT ? 'r' : 'c';
|
||
const vol = rel === REL.BEHIND ? 0.45 : 0.9;
|
||
this.audio.play('sfx/mob_step_' + b.cls + '_' + pan, { volume: vol });
|
||
}
|
||
this._telegraph();
|
||
}
|
||
|
||
_telegraph() {
|
||
const b = this._battle;
|
||
b.state = 'telegraph';
|
||
b.windowEnd = performance.now() + TEL_WINDOW;
|
||
this.floor.enterAction(); // 前摇窗 = 实时玩法:单击直接格挡(§4.1 ACTION)
|
||
const rel = this._battleRel();
|
||
const pan = rel === REL.LEFT ? '_l' : rel === REL.RIGHT ? '_r' : '';
|
||
this.audio.play('sfx/tel_' + b.cls + pan);
|
||
vibrate('beat'); // 「格挡窗口已开」通道标识
|
||
const s = this._session;
|
||
this._after(TEL_WINDOW, () => {
|
||
if (s === this._session && this._battle === b && b.state === 'telegraph') this._enemyResolve();
|
||
});
|
||
}
|
||
|
||
_applyVeil() { // 护符:每层首次受击减半
|
||
if (this._has('veil') && !this.run.veilUsedFloor) {
|
||
this.run.veilUsedFloor = true;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
_enemyResolve() {
|
||
const b = this._battle;
|
||
b.state = 'resolve';
|
||
const rel = this._battleRel();
|
||
const facing = rel === REL.FRONT;
|
||
const behind = rel === REL.BEHIND;
|
||
let dmg = Math.max(0, b.atk - this._armor());
|
||
const parried = b.parry && facing; // 面对攻击源 ×0.2,未面对 ×1.0
|
||
if (parried) this.run.parries++; // 只计真正挡下的(面对+窗口内)
|
||
if (parried) dmg = Math.floor(dmg * 0.2);
|
||
if (dmg > 0 && this._applyVeil()) dmg = Math.floor(dmg / 2);
|
||
this.run.hp -= dmg;
|
||
this._lastHit = { type: b.type, boss: b.boss, behind, parried, unparried: !parried, flee: false };
|
||
if (!parried) {
|
||
if (dmg > 0) {
|
||
this.audio.play('sfx/hurt');
|
||
vibrate('hit');
|
||
}
|
||
}
|
||
if (this.run.hp <= 0) { this._die(); return; }
|
||
if (b.drill) { // 教学战:首次挨打讲格挡反馈 + 攻击教学;之后不再重复(原则 11)
|
||
if (!b.drillTaught) {
|
||
b.drillTaught = true;
|
||
const ids = [parried ? 'voice/tut_parry_ok' : 'voice/tut_parry_miss', 'voice/tut_attack'];
|
||
this._sayFlow(ids, () => this._playerTurn(), 12000);
|
||
return;
|
||
}
|
||
this._playerTurn();
|
||
return;
|
||
}
|
||
this._dangerCheck();
|
||
const drank = this._autoPotion();
|
||
const ids = [];
|
||
if (parried && !b.parriedSaid) { b.parriedSaid = true; ids.push('voice/btl_parried'); } // 每场一次
|
||
if (behind && !b.behindSaid) { b.behindSaid = true; ids.push('voice/btl_behind'); } // 每场一次
|
||
if (drank) ids.push('voice/st_hp_' + this._hpBand(this.run.hp, this._maxHp()));
|
||
if (ids.length) this._sayFlow(ids, () => this._playerTurn(), 8000);
|
||
else this._playerTurn();
|
||
}
|
||
|
||
_dangerCheck() { // 血 ≤25%:每层首次 warning 震(战斗曲已是状态声)
|
||
if (!this._dangerFloorDone && this._hpBand(this.run.hp, this._maxHp()) === 4) {
|
||
this._dangerFloorDone = true;
|
||
vibrate('warning');
|
||
}
|
||
}
|
||
|
||
_autoPotion() { // HP≤25% 且腰带有力 → 自动喝
|
||
if (this.run.hp > 0 && this._hpBand(this.run.hp, this._maxHp()) === 4 && this.run.potion > 0) {
|
||
this.run.potion = 0;
|
||
this.run.hp = Math.min(this._maxHp(), this.run.hp + Math.ceil(this._maxHp() * 0.4));
|
||
this.audio.play('sfx/potion');
|
||
vibrate('pickup');
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
_victory() {
|
||
const b = this._battle;
|
||
this.audio.play('sfx/enemy_die');
|
||
vibrate('success');
|
||
this.run.killsRun++;
|
||
this.meta.kills++;
|
||
const atkBefore = this.run.atkLv;
|
||
if (this.run.killsRun % 4 === 0 && this.run.atkLv < 4) this.run.atkLv++;
|
||
const justLeveled = this.run.atkLv > atkBefore;
|
||
this.meta.gold += b.spec.gold; // 金币实时入元进度
|
||
this.run.gold += b.spec.gold;
|
||
if (b.spec.gold > 0) this.audio.play('sfx/coin');
|
||
if (b.boss) this.run.cleared.push(this.run.floor);
|
||
this._progress();
|
||
this._save();
|
||
if (b.type === 'king') { this._settleWin(); return; } // 终 Boss = 通关
|
||
const lootBand = b.spec.gold < 6 ? 1 : b.spec.gold < 20 ? 2 : 3;
|
||
const ids = b.drill ? ['voice/tut_kill', 'voice/btl_loot_' + lootBand] : ['voice/btl_kill', 'voice/btl_loot_' + lootBand];
|
||
if (justLeveled) ids.push('voice/btl_lvup');
|
||
this._sayFlow(ids, () => {
|
||
this._battle = null;
|
||
if (this._drill && this._drill.step === 6) {
|
||
// 教学战胜利:回教学态,进入 D7(自行寻楼梯)
|
||
this.state = 'DRILL';
|
||
this.audio.play('bgm/explore', { fadeMs: 400 });
|
||
this._drillAdvance(7);
|
||
return;
|
||
}
|
||
if (this._drill) { // D4/D7 途中遇怪等:战后回教学态继续(等待态照常挂回)
|
||
this.state = 'DRILL';
|
||
this.audio.play('bgm/explore', { fadeMs: 400 });
|
||
this._drillArm(this._drill.step);
|
||
return;
|
||
}
|
||
this._resumeExplore();
|
||
}, 12000);
|
||
}
|
||
|
||
// ================================ 死亡 / 通关结算(§11;critical 链 = 结局,不许打断) ================================
|
||
|
||
_buyUnlocks() {
|
||
const bought = [];
|
||
for (const l of LEGACIES) {
|
||
if (this.meta.unlocks.includes(l.id)) continue;
|
||
if (this.meta.gold >= l.price) {
|
||
this.meta.gold -= l.price;
|
||
this.meta.unlocks.push(l.id);
|
||
bought.push(l.id);
|
||
} else break; // 按价格升序,买不起即止
|
||
}
|
||
return bought;
|
||
}
|
||
|
||
_die() {
|
||
const info = this._lastHit || { type: '?', boss: false, behind: false, unparried: true, parried: false };
|
||
// 归因真话律:句必须与真实死因对应
|
||
const cause = info.behind ? 3 : (info.boss && info.unparried ? 2 : 1);
|
||
const adv = info.behind ? 3 : (this.run.parries === 0 ? 1 : (this.run.flees === 0 ? 2 : 1));
|
||
this._deathInfo = { cause, adv, lastHit: { ...info }, parries: this.run.parries, flees: this.run.flees };
|
||
this._battle = null;
|
||
this.state = 'SETTLE';
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._stopSilenceFeed();
|
||
this.audio.play('bgm/menu', { volume: 0.5 });
|
||
vibrate('hit');
|
||
this.meta.bestFloor = Math.max(this.meta.bestFloor, this.run.floor);
|
||
this.meta.runs++;
|
||
const newUnlocks = this._buyUnlocks();
|
||
this.meta.pending.push(...newUnlocks); // 下一局开场播报
|
||
const floorN = this.run.floor;
|
||
this.run = null; // 死亡清 run 不清 meta
|
||
this._save();
|
||
const got = this.ladder.onGameDone(); // 一局结束 → 手势阶梯(阶段 2/3 解锁)
|
||
this._settleChain = ['voice/die_intro', 'voice/die_cause_' + cause, 'voice/die_adv_' + adv,
|
||
'voice/st_floor_' + floorN, this._metaGoldVoice(), 'voice/settle_stronger'];
|
||
const teachD = got && this.ladder.teachLine(got);
|
||
if (teachD) this._settleChain.push(teachD); // novice.js teachLine 已带 voice/ 前缀
|
||
this._settleChain.push('voice/settle_again');
|
||
// 结局 = critical(§4.6):在途手势不截杀结算链(旧 H1 问题的根治),播完交出话轮
|
||
this.floor.speak(this._settleChain, {
|
||
critical: true,
|
||
onDone: () => { if (this.state === 'SETTLE' && !this.menuOpen) this.floor.yieldTurn(this._settleWp()); },
|
||
});
|
||
}
|
||
|
||
_settleWin() {
|
||
this._battle = null;
|
||
this.state = 'SETTLE';
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._stopSilenceFeed();
|
||
this.audio.play('bgm/menu', { volume: 0.5 });
|
||
vibrate('success');
|
||
this.meta.bestFloor = Math.max(this.meta.bestFloor, 7);
|
||
this.meta.runs++;
|
||
const bought = this._buyUnlocks();
|
||
const ids = ['voice/settle_win_1', 'voice/settle_win_2', this._metaGoldVoice()];
|
||
if (bought.length) for (const u of bought) ids.push('voice/unlock_' + u);
|
||
this.run = null;
|
||
this._save();
|
||
const got = this.ladder.onGameDone();
|
||
this._settleChain = ids;
|
||
const teachW = got && this.ladder.teachLine(got);
|
||
if (teachW) this._settleChain.push(teachW); // teachLine 已带 voice/ 前缀
|
||
this._settleChain.push('voice/settle_again');
|
||
this.floor.speak(this._settleChain, {
|
||
critical: true,
|
||
onDone: () => { if (this.state === 'SETTLE' && !this.menuOpen) this.floor.yieldTurn(this._settleWp()); },
|
||
});
|
||
}
|
||
|
||
_settleWp() {
|
||
return this._wp({
|
||
id: 'settle', expected: ['tap'],
|
||
full: 'voice/settle_again', short: 'voice/sys_first_tap_short',
|
||
silenceMs: [20000, 45000],
|
||
onDemo: () => this.floor.chime(),
|
||
});
|
||
}
|
||
|
||
_saySettle(ids) {
|
||
this.floor.speak(ids, {
|
||
onDone: () => { if (this.state === 'SETTLE' && !this.menuOpen) this.floor.yieldTurn(this._settleWp()); },
|
||
});
|
||
}
|
||
|
||
_onSettleGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
this._newRunFlow(); // 再探一次(settle_again 承诺)
|
||
return 'ok';
|
||
case 'doubletap':
|
||
this._saySettle(['voice/sys_say_again', 'voice/settle_again']); // 阶段 3:重听等待点句
|
||
return 'ok';
|
||
case 'up':
|
||
this._saySettle(this._titleUpChain()); // 我在哪 = 元进度播报
|
||
return 'ok';
|
||
case 'longpress':
|
||
this._saySettle(['voice/settle_again']); // 重听结算一句
|
||
return 'ok';
|
||
case 'down':
|
||
if (this.ladder.has('down')) { this._openMenu(); return 'ok'; }
|
||
return 'invalid'; // 菜单阶段 2 才开放
|
||
default:
|
||
return 'invalid'; // left
|
||
}
|
||
}
|
||
|
||
// ================================ 下潜(DESCEND 演出) ================================
|
||
|
||
_descend(fromDrill) {
|
||
this.state = 'DESCEND';
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.play('sfx/stairs_down');
|
||
this._sayFlow(['voice/evt_descend'], () => {
|
||
const next = this.run.floor + 1;
|
||
this._enterFloor(next);
|
||
if (fromDrill) {
|
||
this.meta.drillDone = true;
|
||
this._drill = null;
|
||
}
|
||
this._save();
|
||
const ids = fromDrill ? ['voice/tut_done', 'voice/st_floor_' + next] : ['voice/st_floor_' + next];
|
||
this._sayFlow(ids, () => this._resumeExplore(), 8000);
|
||
}, 8000);
|
||
}
|
||
|
||
// ================================ 演练教学 DRILL(§5;首 3 分钟逐秒脚本见设计单 §6) ================================
|
||
|
||
_enterDrill() {
|
||
this.state = 'DRILL';
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.play('bgm/explore');
|
||
this.audio.play('sfx/amb_cave', { volume: 0.6 });
|
||
this._drill = { step: -1, bat: null, deadline: 0 };
|
||
this._lastActAt = performance.now();
|
||
this._startAmbLoop();
|
||
this._startDrillWatch();
|
||
this._drillEnter(0);
|
||
}
|
||
|
||
/** 每步等待点:full=指令句 / short=短句 / 静默 6s、15s / L3 演示=游戏替你做。 */
|
||
_drillWp(step, expected) {
|
||
const FULL = {
|
||
0: 'voice/d0_new', 1: 'voice/sys_first_tap', 2: 'voice/tut_move', 3: 'voice/tut_side_move',
|
||
4: 'voice/tut_coin', 5: 'voice/tut_help_hold', 6: 'voice/tut_mob', 7: 'voice/tut_stairs',
|
||
};
|
||
const SHORT = { 0: 'voice/sys_first_tap_short', 1: 'voice/sys_first_tap_short', 2: 'voice/hint_swipe', 3: 'voice/hint_swipe', 4: 'voice/hint_swipe', 7: 'voice/hint_swipe' };
|
||
return this._wp({
|
||
id: 'drill' + step, expected,
|
||
full: FULL[step], short: SHORT[step] || FULL[step],
|
||
silenceMs: [6000, 15000],
|
||
onDemo: () => this._drillDemo(step),
|
||
});
|
||
}
|
||
|
||
_drillYield(step, expected) {
|
||
this.floor.yieldTurn(this._drillWp(step, expected));
|
||
}
|
||
|
||
/** 每步期待的手势:作答步 0/1/2/3/5;引导自由行动步 4/6/7 = ACTION(接管兜底)。 */
|
||
_drillExpected(step) {
|
||
return { 0: ['tap'], 1: ['tap'], 2: ['up'], 3: ['left', 'right'], 5: ['longpress'] }[step] || null;
|
||
}
|
||
|
||
/** 教学步进入等待:作答步 = 交话轮(提示/接管);自由行动步 = ACTION。 */
|
||
_drillArm(step) {
|
||
const expected = this._drillExpected(step);
|
||
if (expected) this._drillYield(step, expected);
|
||
else this.floor.enterAction();
|
||
}
|
||
|
||
/**
|
||
* 教学链 → 进入本步等待(有界):链被乱手势打断或资产缺失时,兜底定时器照样
|
||
* 把等待态挂上(流程绝不卡死;对齐旧 _speakGuarded 语义)。
|
||
*/
|
||
_drillChain(ids, step, after, opts = {}) {
|
||
const s = this._session;
|
||
let armed = false;
|
||
const arm = () => {
|
||
if (armed || s !== this._session || !this._drill || this._drill.step !== step) return;
|
||
armed = true;
|
||
if (opts.rearm) this.floor.resumeTurn(); // 等待点已挂:链播完回等待态(不重播交接音)
|
||
else this._drillArm(step);
|
||
if (after) after();
|
||
};
|
||
markHintSaid(this._drillWp(step, this._drillExpected(step) || SWIPE).conf.full); // 30s 台账
|
||
this.floor.speak(ids, { critical: !!opts.critical, onDone: arm });
|
||
this._after(ids.length * 1800 + 2500, arm);
|
||
}
|
||
|
||
/** 教学活跃度:有效动作(脉冲/移动)把兜底死线后推——只罚「真卡住」,不罚慢玩家。 */
|
||
_drillAlive() {
|
||
if (this._drill && this.state === 'DRILL') this._drill.deadline = performance.now() + DRILL_FALLBACK_MS;
|
||
}
|
||
|
||
/** 教学兜底轮询(5s):死线到点自动过步(有界:接管三级用尽也不永远卡死)。 */
|
||
_startDrillWatch() {
|
||
const s = this._session;
|
||
const d = this._drill;
|
||
this._drillAlive();
|
||
const loop = () => {
|
||
if (s !== this._session || this._drill !== d) return;
|
||
if (this.state === 'DRILL' && !this.menuOpen && performance.now() >= d.deadline) {
|
||
this._drillAdvance(d.step + 1);
|
||
return;
|
||
}
|
||
this._after(5000, loop);
|
||
};
|
||
this._after(5000, loop);
|
||
}
|
||
|
||
/** 步骤回调(玩家做对后的推进):只在本步仍有效时触发一次(防连点双推进)。 */
|
||
_drillDoneStep(step, fn, delayMs = 0) {
|
||
const d = this._drill;
|
||
const s = this._session;
|
||
this._after(delayMs, () => {
|
||
if (s !== this._session || this._drill !== d || d.step !== step) return;
|
||
fn();
|
||
});
|
||
}
|
||
|
||
_drillEnter(step) {
|
||
const d = this._drill;
|
||
d.step = step;
|
||
this._drillAlive(); // 每步重置兜底死线
|
||
switch (step) {
|
||
case 0:
|
||
// 0~3 秒:第一句 = critical 指令句,让玩家动手(§4.10)
|
||
this._drillChain(['voice/d0_new'], 0, null, { critical: true });
|
||
break;
|
||
case 1: {
|
||
// 巩固:先等回声组落地(+1.3s 不叠声),再讲「对。」+ 规则句;链播完交出
|
||
// 话轮等第二次单击。遵守 escape 契约:不在 onGesture 返回路径上同步
|
||
// yieldTurn——管道 settleWaitPoint 会把同步挂上的等待点当已完成摘掉,
|
||
// 之后 invalid 解释链播完无人恢复话轮 → BUSY 卡死吞手势。
|
||
const s1 = this._session;
|
||
this._after(1300, () => {
|
||
if (s1 !== this._session || this._drill?.step !== 1) return;
|
||
this._drillChain(['voice/tut_praise', 'voice/tut_front_rule'], 1);
|
||
});
|
||
break;
|
||
}
|
||
case 2:
|
||
// 10~30 秒档:教会「轮到你」这个状态(sys_turn_teach),再教前进
|
||
this._drillChain(['voice/sys_turn_teach', 'voice/tut_move'], 2);
|
||
break;
|
||
case 3:
|
||
this._drillChain(['voice/tut_praise', 'voice/tut_side_move'], 3);
|
||
break;
|
||
case 4:
|
||
// 肯定句不逐字重复(原则 9):D2/D3 已各说过一次「对。」,D4 直接布置任务
|
||
this._drillChain(['voice/tut_coin'], 4); // 自由走到金币格(ACTION + 接管兜底)
|
||
break;
|
||
case 5:
|
||
this._drillChain(['voice/tut_help_hold'], 5);
|
||
break;
|
||
case 6:
|
||
this._drillEnter6();
|
||
break;
|
||
case 7:
|
||
this._drillChain(['voice/tut_stairs'], 7); // 自由寻路(ACTION + 接管兜底)
|
||
break;
|
||
default:
|
||
this._drillDone();
|
||
}
|
||
}
|
||
|
||
/** D6:教学怪把门——先听脚步,遭遇后练单击格挡 + 单击挥击(ATK0 学期零惩罚)。 */
|
||
_drillEnter6() {
|
||
const d = this._drill;
|
||
const bat = this._floor.mobs.find((m) => m.drill) || this._floor.mobs.find((m) => m.type === 'bat');
|
||
if (!bat) { this._drillAdvance(7); return; } // 边界:门怪已亡(乱走触发过)→ 自动过
|
||
d.bat = bat;
|
||
while (this.run.face !== this._dominantDirTo(bat.x, bat.y)) {
|
||
const i = DIR_ORDER.indexOf(this.run.face);
|
||
this.run.face = DIR_ORDER[(i + 1) % 4]; // 遭遇自动面对语义(不播转身声)
|
||
}
|
||
this._drillChain(['voice/tut_mob'], 6, () => this._drillApproach()); // 兜底:教学怪主动扑来,绝不等死
|
||
}
|
||
|
||
_dominantDirTo(tx, ty) {
|
||
const dx = tx - this.run.pos.x, dy = ty - this.run.pos.y;
|
||
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'E' : 'W';
|
||
return dy >= 0 ? 'S' : 'N';
|
||
}
|
||
|
||
_stepTowards(m, target) {
|
||
const S = this._floor.size;
|
||
const cand = [];
|
||
for (const dd of Object.values(DIR_VEC)) {
|
||
const nx = m.x + dd.dx, ny = m.y + dd.dy;
|
||
if (nx < 0 || ny < 0 || nx >= S || ny >= S || this._floor.grid[ny][nx] !== 1) continue;
|
||
cand.push({ x: nx, y: ny, d: Math.abs(nx - target.x) + Math.abs(ny - target.y) });
|
||
}
|
||
cand.sort((a, b) => a.d - b.d);
|
||
return cand[0] || null;
|
||
}
|
||
|
||
_drillApproach() { // 教学怪每 1.2s 向玩家逼近一步,走进玩家格即触发战斗
|
||
const s = this._session;
|
||
this._after(1200, () => {
|
||
if (s !== this._session) return;
|
||
const d = this._drill;
|
||
if (!d || d.step !== 6 || this.state !== 'DRILL' || this.menuOpen) return;
|
||
const bat = d.bat;
|
||
if (!bat || !this._floor.mobs.includes(bat)) return;
|
||
const step = this._stepTowards(bat, this.run.pos);
|
||
if (step) {
|
||
if (step.x === this.run.pos.x && step.y === this.run.pos.y) { this._encounter(bat); return; }
|
||
bat.x = step.x; bat.y = step.y;
|
||
this._mobStepSound('bat', bat.x, bat.y);
|
||
}
|
||
this._drillApproach();
|
||
});
|
||
}
|
||
|
||
/** L3 演示:游戏自己完成这一步(等价于替玩家做对一次)。 */
|
||
_drillDemo(step) {
|
||
switch (step) {
|
||
case 0:
|
||
case 1:
|
||
this._pulse();
|
||
this._after(1600, () => this._drillAdvance(step + 1));
|
||
break;
|
||
case 2:
|
||
this._moveRel('up');
|
||
this._drillAdvance(3);
|
||
break;
|
||
case 3:
|
||
this._moveRel('left');
|
||
this._drillAdvance(4);
|
||
break;
|
||
case 4: { // 替玩家拾起教学金币(onGoal 照常触发)
|
||
const entry = [...this._floor.items.entries()].find(([, v]) => v.type === 'coin');
|
||
if (!entry) { this._drillAdvance(5); break; }
|
||
const [k, item] = entry;
|
||
const [x, y] = k.split(',').map(Number);
|
||
this.run.pos = { x, y };
|
||
this._pickup(item, k);
|
||
break;
|
||
}
|
||
case 5:
|
||
this._longpressHelp();
|
||
this._drillAdvance(6);
|
||
break;
|
||
case 7: { // 游戏朝楼梯走一步(脚步声演示),剩余交给玩家
|
||
const dir = this._navFirstStepDir(this._floor.stairs);
|
||
if (dir) {
|
||
const rel = (DIR_ORDER.indexOf(dir) - DIR_ORDER.indexOf(this.run.face) + 4) % 4;
|
||
this._moveRel(REL_GESTURE[rel]);
|
||
}
|
||
break;
|
||
}
|
||
default:
|
||
this._drillAdvance(step + 1);
|
||
}
|
||
}
|
||
|
||
_drillAdvance(step) {
|
||
if (step > 7) { this._drillDone(); return; }
|
||
this._drillEnter(step);
|
||
}
|
||
|
||
_drillDone() { // 兜底:D7 未走楼梯而步数耗尽(极少见)——按跳过处理
|
||
this.meta.drillDone = true;
|
||
this._drill = null;
|
||
this._sayFlow(['voice/tut_skip'], () => this._resumeExplore(), 8000);
|
||
}
|
||
|
||
_onDrillGesture(g) {
|
||
const d = this._drill;
|
||
if (!d) return this._onExploreGesture(g);
|
||
switch (d.step) {
|
||
case 0: // 首次脉冲(做对即推进:步号先落位,规则句在下一步链里等回声落地再讲)
|
||
if (g === 'tap') {
|
||
if (!this._pulse()) return 'invalid'; // 冷却中(管道给 invalid 回执)
|
||
this._drillAdvance(1);
|
||
return 'silent-ok';
|
||
}
|
||
return 'invalid';
|
||
case 1: // 巩固:再点一次 = 第一次成功(奖励音在回声之后)
|
||
if (g === 'tap') {
|
||
if (!this._pulse()) return 'invalid';
|
||
this._drillDoneStep(1, () => {
|
||
this.audio.play('sfx/levelup', { volume: 0.5 });
|
||
this._drillAdvance(2);
|
||
}, 1400);
|
||
return 'silent-ok';
|
||
}
|
||
return 'invalid';
|
||
case 2: // 前进一格(肯定句并入 D3 链,杜绝连点双链)
|
||
if (g === 'up') {
|
||
const r = this._moveRel('up');
|
||
if (r !== 'invalid') this._drillAdvance(3);
|
||
return r;
|
||
}
|
||
return 'invalid';
|
||
case 3: // 侧移 + 转向(肯定句并入 D4 链)
|
||
if (g === 'left' || g === 'right') {
|
||
const r = this._moveRel(g);
|
||
if (r !== 'invalid') this._drillAdvance(4);
|
||
return r;
|
||
}
|
||
return 'invalid';
|
||
case 4: // 自由走到金币格(拾取在 _pickup 钩子推进);允许脉冲探路
|
||
if (SWIPE.includes(g)) return this._moveRel(g);
|
||
if (g === 'tap') return this._pulse() ? 'silent-ok' : 'invalid';
|
||
if (g === 'longpress') { this._longpressHelp(); return 'ok'; }
|
||
return 'invalid';
|
||
case 5: // 长按 = 我在哪(帮助链即答案;播完再进 D6,不顶句)
|
||
if (g === 'longpress') {
|
||
const s5 = this._session;
|
||
let adv5 = false;
|
||
const advance5 = () => {
|
||
if (adv5 || s5 !== this._session || !this._drill || this._drill.step !== 5) return;
|
||
adv5 = true;
|
||
this._drillAdvance(6);
|
||
};
|
||
this._longpressHelp(advance5);
|
||
this._after(8000, advance5); // 链被打断/资产缺失兜底
|
||
return 'ok';
|
||
}
|
||
return 'invalid';
|
||
case 6: // 等教学怪扑来:允许脉冲/移动/帮助
|
||
if (g === 'tap') return this._pulse() ? 'silent-ok' : 'invalid';
|
||
if (SWIPE.includes(g)) return this._moveRel(g);
|
||
if (g === 'longpress') { this._longpressHelp(); return 'ok'; }
|
||
return 'invalid';
|
||
case 7: // 自由寻路到楼梯:四向移动 + 脉冲;楼梯格单击 = 下潜(教学出口)
|
||
if (SWIPE.includes(g)) return this._moveRel(g);
|
||
if (g === 'tap') {
|
||
if (this._onStairs && this._stairsUsable()) { this._descend(true); return 'ok'; }
|
||
return this._pulse() ? 'silent-ok' : 'invalid';
|
||
}
|
||
if (g === 'longpress') { this._longpressHelp(); return 'ok'; }
|
||
return 'invalid';
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// ================================ 迷茫接管(§4.5 三级) ================================
|
||
|
||
_onConfused(level) {
|
||
// 教学态走游戏侧接管:等待点 takeover 第 2 级不带台账去重,会把刚说过的指令句
|
||
// 原样重播(被动原句重复,原则 9);_drillTakeover 三级全部过台账。
|
||
if (this.state === 'DRILL' && !this.menuOpen) return this._drillTakeover(level);
|
||
const wp = this.floor.waitPoint;
|
||
if (wp) wp.takeover(level); // 等待点自带三级(提示 → 缩小 → 演示)
|
||
else this._takeover(level); // 探索态(ACTION,无等待点)
|
||
}
|
||
|
||
_takeover(level) {
|
||
if (this.state === 'DRILL') return this._drillTakeover(level);
|
||
if (this.state !== 'EXPLORE' || this.menuOpen) return;
|
||
if (level === 1) {
|
||
// L1:说目标(铁器暗示 / 窟主守梯);30s 台账说过就只给提示音(原则 9)
|
||
const id = this._stairHintId();
|
||
if (!hintSaidRecently(id)) {
|
||
markHintSaid(id);
|
||
this._sayContent([id], { kind: 'hint' });
|
||
} else {
|
||
this.floor.chime();
|
||
}
|
||
} else if (level === 2) {
|
||
// L2:只放行最短路首步方向(面朝相对换算成手势)
|
||
const g = this._navGesture();
|
||
this._restrictDir = g;
|
||
const only = 'voice/sys_only_' + g;
|
||
markHintSaid(only);
|
||
this._sayContent([only], { kind: 'hint' });
|
||
} else if (level === 3) {
|
||
// L3:听我做一次——游戏自己朝楼梯走一步,再交回
|
||
this._restrictDir = null;
|
||
this._sayContent(['voice/sys_demo'], {
|
||
kind: 'hint',
|
||
onDone: () => {
|
||
if (this.state !== 'EXPLORE') return;
|
||
this._demoStep();
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
/** 卡关 L1 目标句:窟主未清/终局层 → 「大东西守着」;否则 → 铁器暗示。 */
|
||
_stairHintId() {
|
||
const spec = FLOOR_SPECS[this.run.floor];
|
||
const bossAlive = (spec.boss && !this.run.cleared.includes(this.run.floor)) || !!spec.final;
|
||
return bossAlive ? 'voice/stair_hint_b' : 'voice/stair_hint_a';
|
||
}
|
||
|
||
/** 教学态接管:L1 短句 → L2 重教指令句 → L3 游戏替你完成本步。 */
|
||
_drillTakeover(level) {
|
||
const d = this._drill;
|
||
if (!d || this.menuOpen) return;
|
||
if (level === 1) {
|
||
if (!hintSaidRecently('voice/hint_swipe')) {
|
||
markHintSaid('voice/hint_swipe');
|
||
this._sayContent(['voice/hint_swipe'], { kind: 'hint' });
|
||
} else {
|
||
this.floor.chime();
|
||
}
|
||
} else if (level === 2) {
|
||
const wp = this._drillWp(d.step, this._drillExpected(d.step) || SWIPE);
|
||
const line = wp.conf.full;
|
||
if (line && !hintSaidRecently(line)) {
|
||
markHintSaid(line);
|
||
this._sayContent([line], { kind: 'hint' });
|
||
} else {
|
||
this.floor.chime();
|
||
}
|
||
} else if (level === 3) {
|
||
this._drillDemo(d.step);
|
||
}
|
||
}
|
||
|
||
/** 探索态最短路首步 → 相对手势(四向滑动 = 面朝相对移动)。 */
|
||
_navGesture() {
|
||
const dir = this._navFirstStepDir(this._floor.stairs || this._floor.throne);
|
||
if (!dir) return 'up';
|
||
const rel = (DIR_ORDER.indexOf(dir) - DIR_ORDER.indexOf(this.run.face) + 4) % 4;
|
||
return REL_GESTURE[rel];
|
||
}
|
||
|
||
/** L3 演示:游戏自己朝楼梯走一步(脚步声即演示),再交回「现在,你来」。 */
|
||
_demoStep() {
|
||
const dir = this._navFirstStepDir(this._floor.stairs || this._floor.throne);
|
||
if (!dir) { this.floor.chime(); return; }
|
||
const rel = (DIR_ORDER.indexOf(dir) - DIR_ORDER.indexOf(this.run.face) + 4) % 4;
|
||
this._moveRel(REL_GESTURE[rel]); // 遇怪 → 遭遇(演示自然中断);拾取/楼梯照常
|
||
if (this.state === 'EXPLORE') this._sayContent(['voice/sys_now_you'], { kind: 'hint' });
|
||
}
|
||
|
||
/**
|
||
* 带路第一步方向:沿最短路(从目标反向 BFS)的第一步给方向,不按直线差值指墙。
|
||
* @returns {'N'|'E'|'S'|'W'|null} null = 不可达/已抵达
|
||
*/
|
||
_navFirstStepDir(target) {
|
||
if (!target) return null;
|
||
if (target.x === this.run.pos.x && target.y === this.run.pos.y) return null;
|
||
const dist = this._bfs(this._floor.grid, target);
|
||
const d0 = dist[this.run.pos.y][this.run.pos.x];
|
||
if (d0 < 0) return null;
|
||
const S = this._floor.size;
|
||
for (const [dir, dv] of Object.entries(DIR_VEC)) {
|
||
const nx = this.run.pos.x + dv.dx, ny = this.run.pos.y + dv.dy;
|
||
if (nx < 0 || ny < 0 || nx >= S || ny >= S || this._floor.grid[ny][nx] !== 1) continue;
|
||
if (dist[ny][nx] === d0 - 1) return dir;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 进展事件:迷茫计数清零、限制解除,下段从 L1 重新分级。 */
|
||
_progress() {
|
||
this.confusion.resetForWaitPoint();
|
||
this._restrictDir = null;
|
||
this._idleHintGiven = false;
|
||
}
|
||
|
||
/** 静默喂迷茫检测:40s 首次、之后每 20s(有操作即重排)。 */
|
||
_startSilenceFeed() {
|
||
this._stopSilenceFeed();
|
||
const s = this._session;
|
||
const tick = () => {
|
||
if (s !== this._session || this.state !== 'EXPLORE' || this.menuOpen) return;
|
||
this.confusion.recordSilence();
|
||
this._exploreIdleHint();
|
||
this._silenceTimer = setTimeout(tick, SILENCE_REPEAT_MS);
|
||
};
|
||
this._silenceTimer = setTimeout(tick, SILENCE_FIRST_MS);
|
||
}
|
||
|
||
/** 首段静默到点 = L1 目标句(16 号 §5「静默 40s 起」;台账去重、每段静默一次)。 */
|
||
_exploreIdleHint() {
|
||
if (this.confusion.level > 0 || this._idleHintGiven) return;
|
||
if (this.floor.speaking || this.menuOpen) return;
|
||
this._idleHintGiven = true;
|
||
const id = this._stairHintId();
|
||
if (!hintSaidRecently(id)) {
|
||
markHintSaid(id);
|
||
this._sayContent([id], { kind: 'hint' });
|
||
}
|
||
}
|
||
|
||
_stopSilenceFeed() {
|
||
if (this._silenceTimer) { clearTimeout(this._silenceTimer); this._silenceTimer = null; }
|
||
}
|
||
|
||
/** 探索呼吸层(内容层):静默超 20s(无手势、无人声)播一段洞穴环境垫。 */
|
||
_startAmbLoop() {
|
||
const s = this._session;
|
||
const loop = () => {
|
||
if (s !== this._session) return;
|
||
if (this.state === 'EXPLORE' && !this.menuOpen) this._ambBreath();
|
||
this._after(5000, loop);
|
||
};
|
||
this._after(5000, loop);
|
||
}
|
||
|
||
_ambBreath() {
|
||
const now = performance.now();
|
||
if (now - this._lastAmbAt < AMB_AFTER_MS) return;
|
||
if (now - this._lastActAt < AMB_AFTER_MS) return; // 玩家仍在操作 = 不算静默
|
||
if (this.audio.isVoicePlaying()) return; // 有人声就不垫
|
||
this._lastAmbAt = now;
|
||
this.audio.play('sfx/amb_cave', { volume: AMB_VOLUME });
|
||
}
|
||
|
||
// ================================ 模态菜单(00 §5.4:左右滑换项 + 单击确认) ================================
|
||
|
||
_openMenu() {
|
||
if (this.menuOpen) return;
|
||
this.menuFrom = this.state;
|
||
this.menuOpen = true;
|
||
this._menuItems = this.menuFrom === 'TITLE' ? TITLE_MENU : SETTLE_MENU;
|
||
this._menuIdx = 0;
|
||
this._menuConfirm = false;
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.play('bgm/menu');
|
||
this.floor.interrupt();
|
||
this.floor.speak(['voice/menu_open', 'voice/' + this._menuItems[0]], {
|
||
onDone: () => { if (this.menuOpen) this.floor.yieldTurn(this._menuWp()); },
|
||
});
|
||
}
|
||
|
||
_menuWp() {
|
||
return this._wp({
|
||
id: 'menu', expected: ['tap', 'left', 'right'],
|
||
full: 'voice/menu_help', short: 'voice/menu_help',
|
||
silenceMs: [20000, 45000],
|
||
onDemo: () => this.floor.chime(),
|
||
});
|
||
}
|
||
|
||
_onMenuGesture(g) {
|
||
const items = this._menuItems;
|
||
const announce = () => this.floor.speak(['voice/' + items[this._menuIdx]], {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
switch (g) {
|
||
case 'right':
|
||
this._menuIdx = (this._menuIdx + 1) % items.length;
|
||
announce();
|
||
return 'hold';
|
||
case 'left':
|
||
this._menuIdx = (this._menuIdx + items.length - 1) % items.length;
|
||
announce();
|
||
return 'hold';
|
||
case 'up':
|
||
case 'longpress': // 重听菜单说明
|
||
this.floor.speak(['voice/menu_help'], {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
return 'hold';
|
||
case 'down':
|
||
this._closeMenu();
|
||
return 'ok';
|
||
case 'tap':
|
||
case 'doubletap':
|
||
return this._menuExec(items[this._menuIdx]);
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
_menuExec(item) {
|
||
if (item === 'sys_menu_restart') {
|
||
if (!this._menuConfirm) { // 破坏性操作二次确认(原则 6)
|
||
this._menuConfirm = true;
|
||
this.floor.speak(['voice/menu_restart_confirm'], {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
return 'hold';
|
||
}
|
||
const from = this.menuFrom;
|
||
const seed = this.run ? this.run.seed : null;
|
||
this.menuOpen = false;
|
||
this.menuFrom = null;
|
||
if (from === 'SETTLE' || seed == null) this._newRunFlow();
|
||
else this._restartRun(seed);
|
||
return 'ok';
|
||
}
|
||
if (item === 'sys_menu_save') {
|
||
this._save();
|
||
this._sayFlow(['voice/sys_saved'], () => this._goHome(), 4000);
|
||
return 'ok';
|
||
}
|
||
if (item === 'sys_menu_help') {
|
||
this.floor.speak(['voice/help_explore', 'voice/help_battle', 'voice/settings_hint'], {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
return 'hold';
|
||
}
|
||
return 'ok';
|
||
}
|
||
|
||
_closeMenu() {
|
||
this.menuOpen = false;
|
||
const from = this.menuFrom;
|
||
this.menuFrom = null;
|
||
this._menuConfirm = false;
|
||
this.floor.interrupt();
|
||
this.audio.play('bgm/menu');
|
||
if (from === 'SETTLE') this.floor.yieldTurn(this._settleWp()); // 回结算等待(不留安静态)
|
||
else this.floor.yieldTurn(this._titleWp()); // 回 TITLE 等待
|
||
}
|
||
|
||
// ================================ 语音链 / 计时器基础设施 ================================
|
||
|
||
/**
|
||
* 内容链(描述/归因/帮助/拾取):SPEAKING → 播完回到等待态——
|
||
* EXPLORE 与教学的自由行动步(4/6/7)回 ACTION(玩法声音即状态);
|
||
* 教学作答步由 _drillChain/_drillArm 交话轮,此处不越权(有等待点时不碰)。
|
||
*/
|
||
_sayContent(ids, opts = {}) {
|
||
this.floor.speak(ids, {
|
||
kind: opts.kind || 'content',
|
||
critical: !!opts.critical,
|
||
onDone: () => {
|
||
if (!this.menuOpen && !this.floor.waitPoint
|
||
&& (this.state === 'EXPLORE' || (this.state === 'DRILL' && !this._drillExpected(this._drill?.step)))) {
|
||
this.floor.enterAction();
|
||
}
|
||
if (opts.onDone) opts.onDone();
|
||
},
|
||
});
|
||
}
|
||
|
||
/** 流程链:自然播完或超时先到先推进(打断/资产缺失都不卡死流程)。 */
|
||
_sayFlow(ids, onDone, guardMs) {
|
||
const s = this._session;
|
||
let fired = false;
|
||
const fire = () => {
|
||
if (fired || s !== this._session) return;
|
||
fired = true;
|
||
if (onDone) onDone();
|
||
};
|
||
this.floor.speak(ids, { onDone: fire });
|
||
this._after(guardMs != null ? guardMs : ids.length * 1800 + 3000, fire);
|
||
}
|
||
|
||
_wp(conf) {
|
||
return new WaitPoint(conf, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_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 = [];
|
||
}
|
||
|
||
// ---------------- 孤儿 BUSY 看门狗(§4.1:不存在安静态) ----------------
|
||
|
||
/**
|
||
* invalid 解释链(引擎 InvalidHints)播完后,只有挂着等待点才会恢复话轮;
|
||
* 探索/教学自由步没有等待点,链一播完 floor 就停在 BUSY——后续手势全部
|
||
* 变 busy 回执(管道在 floor.handleInput 就拦下,游戏层永远收不到)。
|
||
* 看门狗按当前态把孤儿 BUSY 拉回规范状态:探索=ACTION(环境音即状态)、
|
||
* 等待点态=重新交话轮、战斗前摇=ACTION。800ms 周期,会话令牌守护。
|
||
*/
|
||
_startWatchdog() {
|
||
if (this._watchdogOn) return;
|
||
this._watchdogOn = true;
|
||
const step = () => {
|
||
// 自持周期定时器(不经 _after:状态跳转会话令牌翻转不灭狗,只有 stop() 停)
|
||
this._watchdogTid = setTimeout(() => {
|
||
if (!this._watchdogOn) return;
|
||
this._healOrphanBusy();
|
||
step();
|
||
}, 800);
|
||
};
|
||
step();
|
||
}
|
||
|
||
_stopWatchdog() {
|
||
this._watchdogOn = false;
|
||
if (this._watchdogTid) { clearTimeout(this._watchdogTid); this._watchdogTid = null; }
|
||
}
|
||
|
||
_healOrphanBusy() {
|
||
const f = this.floor;
|
||
if (this.menuOpen || f.state !== 'BUSY' || f.speaking) return;
|
||
switch (this.state) {
|
||
case 'EXPLORE':
|
||
f.enterAction();
|
||
return;
|
||
case 'DRILL': {
|
||
if (!this._drill) return;
|
||
if (this._drillExpected(this._drill.step)) this._drillArm(this._drill.step);
|
||
else f.enterAction();
|
||
return;
|
||
}
|
||
case 'BATTLE': {
|
||
const b = this._battle;
|
||
if (!b) return;
|
||
if (b.state === 'player') this._playerTurn();
|
||
else if (b.state === 'telegraph') f.enterAction();
|
||
return; // intro/enemyMove/resolve:链与兜底定时器自己在推进
|
||
}
|
||
case 'TITLE':
|
||
f.yieldTurn(this._titleWp());
|
||
return;
|
||
case 'SETTLE':
|
||
f.yieldTurn(this._settleWp());
|
||
return;
|
||
default:
|
||
return; // boot/DESCEND:演出兜底定时器负责
|
||
}
|
||
}
|
||
|
||
// ================================ 存档(§11;summary 字段不变) ================================
|
||
|
||
_summary() {
|
||
const n = this.meta.unlocks.length;
|
||
if (!this.run) return `深处归来 · 金币${this.meta.gold} · 遗产${n}/5`;
|
||
const band = this._hpBand(this.run.hp, this._maxHp());
|
||
return `第${this.run.floor}层 · ${HP_BAND_TEXT[band]} · 金币${this.meta.gold} · 遗产${n}/5`;
|
||
}
|
||
|
||
_save() {
|
||
const r = this.run;
|
||
const data = {
|
||
schema: 1,
|
||
game: 'dungeon',
|
||
updatedAt: new Date().toISOString(),
|
||
summary: this._summary(),
|
||
meta: {
|
||
gold: this.meta.gold, unlocks: [...this.meta.unlocks], bestFloor: this.meta.bestFloor,
|
||
runs: this.meta.runs, kills: this.meta.kills, drillDone: this.meta.drillDone,
|
||
pending: [...this.meta.pending],
|
||
},
|
||
run: r ? {
|
||
seed: r.seed, floor: r.floor, pos: [r.pos.x, r.pos.y], face: r.face,
|
||
hp: r.hp, atkLv: r.atkLv, weapon: r.weapon, armor: r.armor, potion: r.potion,
|
||
veilUsedFloor: r.veilUsedFloor, killsRun: r.killsRun, looted: [...r.looted],
|
||
cleared: [...r.cleared], parries: r.parries, flees: r.flees, gold: r.gold,
|
||
} : null,
|
||
};
|
||
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: 'dungeon', data }),
|
||
}).catch(() => { /* 离线静默 */ });
|
||
} catch (_) { /* 静默 */ }
|
||
}
|
||
|
||
_loadSave() {
|
||
try {
|
||
const data = JSON.parse(localStorage.getItem(SAVE_KEY));
|
||
if (!data || data.schema !== 1 || !data.meta) return null;
|
||
return data;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|