- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账) - 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产) - 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿 - radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema) - dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链) - 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿) - 大厅:新增 radio/dungeon 卡片 + GAMES 数组 - harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记 - R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份 - R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
1856 lines
75 KiB
JavaScript
1856 lines
75 KiB
JavaScript
/**
|
||
* 《回声地窟》—— 纯音频听声辨位 Roguelike(按 docs/design/16-dungeon.md 实现;
|
||
* 台词 id 见 16-dungeon-voice-ids.md,125 句 ×2 性别,无 NPC——怪物不说话)。
|
||
*
|
||
* 你是盲眼探窟者:单击=声波脉冲,回声"看"地形(正前距离 4 档 × 材质、左右侧
|
||
* 墙/开口,全部预 pan 资产 + setTimeout + volume 调度,引擎外零实时节点——
|
||
* playtest 虚拟时钟可逐项断言);按住=前进(450ms 起、每格 0.5s、撞墙停无伤);
|
||
* 左右滑=转身;双击=免费重发脉冲;上滑=状态链;下滑=菜单(三件套不豁免)。
|
||
*
|
||
* 状态机:TITLE / DRILL / EXPLORE / BATTLE / DESCEND / SETTLE + MENU 挂起 overlay。
|
||
* 7 层地窟程序生成:mulberry32((seed ^ (floor*0x9E3779B9))>>>0) 每层独立子种子,
|
||
* 房间→L 型走廊(链式生成树)→起点/楼梯(BFS 最远房心)→宝物→怪(BFS≥3 安全区),
|
||
* 教学局 L1 叠加硬约束(正前 2 格内墙 / 门怪 / 房内金币),拒绝采样保证可复现。
|
||
* 战斗:缠斗/绕位/Boss 三档,前摇 1.2s 内双击格挡(面对 ×0.2 / 背后 ×1.0),
|
||
* 逃跑长按必成挨一下(真话),Boss 拒绝;每 4 杀 atkLv+1。
|
||
* 元进度"遗产"5 项(金币自动购置,死亡不清档):行囊药/体魄/护符/回声强化/武器。
|
||
* 死亡归因真话律:die_cause_* / die_adv_* 按 lastHit(击杀者/背刺/未格挡)与
|
||
* run.parries 真实选择;卡关三级提示 60/120/180s(不报答案,L3 只带方向)。
|
||
*
|
||
* 豁免 E1-E4:单击=脉冲、长按=前进/左右滑=转身(E2)、对局期
|
||
* setRateBoostEnabled(false)(E3,EXPLORE/BATTLE/DRILL/DESCEND,TITLE/MENU/SETTLE
|
||
* 恢复 true)、战斗菜单=暂停(E4)。longpressend 仅作停走信号,不开 immediateTap。
|
||
*/
|
||
|
||
import { Game } from '../engine/game.js';
|
||
import { Guide } from '../engine/guide.js';
|
||
import { vibrate } from '../engine/haptics.js';
|
||
|
||
// ================================ 常量表 ================================
|
||
|
||
/** 绝对方位:DIR_ORDER 顺时针(右转 = idx+1,左转 = idx-1,N→W 为逆时针 ✓)。 */
|
||
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 };
|
||
|
||
const PULSE_CD = 1200; // 脉冲冷却(§3.1)
|
||
const TEL_WINDOW = 1200; // 敌方攻击前摇 = 格挡窗口(§7.2)
|
||
const WALK_STEP_MS = 500; // 长按每格耗时(§2.1)
|
||
const STUCK_LEVELS = [60000, 120000, 180000]; // 卡关三级(§10.3)
|
||
const NAV_REPEAT_MS = 60000; // L3 带路重复周期
|
||
const NAV_MIN_GAP_MS = 1500; // 同向导航句最小间隔(R2 D1-3:短窗不重复,保险丝友好)
|
||
const DENSITY_STEP = 12; // 主路径内容密度下限(R2 D0-3①):每 ≤12 格必有遭遇
|
||
const AMB_AFTER_MS = 20000; // 探索呼吸层(R2 D0-3②):静默超 20s 播环境垫
|
||
const AMB_VOLUME = 0.35;
|
||
|
||
/** 回声编码(§3.1):正前距离 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; // 材质尾音跟在回声到达后
|
||
|
||
/** 敌型表(§7.2/§8.1;def 全 0,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' },
|
||
};
|
||
|
||
/** 层规格(§6.2):尺寸 / 怪物(型×数)/ 宝物 / 窟主 / 终局。 */
|
||
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 },
|
||
};
|
||
|
||
/** 元进度遗产(§7.4):按价格升序自动购置(金币累计,死亡不清档)。 */
|
||
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' },
|
||
];
|
||
|
||
const MENU_ITEMS = ['sys_menu_resume', 'sys_menu_save', 'sys_menu_help', 'sys_menu_restart'];
|
||
const TITLE_MENU = ['sys_menu_help', 'sys_menu_resume']; // §2.3:标题/结算菜单(帮助/返回两项)
|
||
|
||
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', 'sfx/turn_tick', // R2:探索呼吸层垫(D0-3②)+ 战斗回合确认音(D1-6)
|
||
];
|
||
|
||
// ================================ 工具 ================================
|
||
|
||
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);
|
||
this.guide = new Guide(audio); // guide='off' 时全部 API 静默 no-op 且零定时器
|
||
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;
|
||
this._battle = null;
|
||
this._drill = null;
|
||
this._walk = null;
|
||
this._pulseAt = -1e9;
|
||
this._lastHit = null; // 归因真话律依据(击杀者/背刺/未格挡)
|
||
this._lastEcho = null; // 最近一次脉冲回声(双击重发一致性断言用)
|
||
this._onStairs = false;
|
||
this._stairsSpokenFloor = 0;
|
||
this._dangerFloorDone = false;
|
||
this._menuPause = null;
|
||
this._menuItems = MENU_ITEMS;
|
||
this._menuIdx = 0;
|
||
this._stuck = { lastAct: 0, shown: new Set(), nav: false, lastFire: 0, pending: [] };
|
||
this._stuckArmed = false;
|
||
this._lastAmbAt = 0; // 探索呼吸层上次播放时刻(R2 D0-3②)
|
||
this._navLastDir = null; // 导航模式上次播的方向(同向短窗节流,R2 D1-3)
|
||
this._navLastAt = 0;
|
||
this._titleChain = [];
|
||
}
|
||
|
||
_defaultMeta() {
|
||
return { gold: 0, unlocks: [], bestFloor: 0, runs: 0, kills: 0, drillDone: false, pending: [] };
|
||
}
|
||
|
||
// ---------------- 派生数值(§8.1) ----------------
|
||
|
||
_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; }
|
||
|
||
// ================================ 地窟生成(§6) ================================
|
||
|
||
/** seed 派生公式(§6.1,逐字照设计):每层独立子种子。 */
|
||
_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 硬约束优先于随机,§6.1 注),
|
||
* 内层全走子种子 rng → 同 seed 两次构造逐格相等(§12.2)。
|
||
* 走廊 = 相邻房间两两 L 型连通(链式 = 生成树,§6.1 ③ 的等价实现)。
|
||
*/
|
||
_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 间(§6.1 ②)
|
||
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 型走廊链式连通(§6.1 ③)
|
||
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); // 东侧走道:D4 长按前进 ≥2 格后撞边界 bump
|
||
carve(3, 5); // 南门:教学怪把门(D8 走向它触发战斗)
|
||
if (grid[1][3] !== 0) return null; // 约束①:起点正前 2 格内有墙(D1 回声演示)
|
||
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 距起点最远房间中心(§6.1 ④);终局层无楼梯,王座=最远房心
|
||
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;
|
||
// 宝物撒入房间格(§6.1 ⑤)
|
||
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') {
|
||
// 约束③:教学金币固定在起点房内(拾取体验)
|
||
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 金(§7.4 经济核算)
|
||
: { type: it });
|
||
}
|
||
// 怪物:窟主守楼梯格 / 终局王座;普通怪 BFS ≥3 安全区(教学局非门怪 ≥4,
|
||
// 避免 D4/D8 演练路径撞上第二只怪)
|
||
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 };
|
||
}
|
||
|
||
/**
|
||
* 主路径内容密度下限(R2 D0-3①):start→楼梯(终局层=王座)的最短路上每
|
||
* ≤ DENSITY_STEP 格必有内容遭遇(怪/宝/楼梯/起点)。生成后沿 BFS 距离梯度
|
||
* 回溯最短路,最长连续无内容段超限 → 段中点补一只本层已有的普通怪型
|
||
* (结构性密度,不加新怪种;教学局补的仍是 ATK0 蝠)。确定性:只用同一 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._session++;
|
||
this._clearTimers();
|
||
this._stopWalk();
|
||
this.audio.stopAll();
|
||
}
|
||
|
||
_goHome() {
|
||
this.stop();
|
||
window.location.href = '../';
|
||
}
|
||
|
||
// ---------------- TITLE(§2.3) ----------------
|
||
|
||
_enterTitle() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._stopWalk();
|
||
this._battle = null;
|
||
this.menuOpen = false;
|
||
this.menuFrom = null;
|
||
this.state = 'TITLE';
|
||
this.audio.stopVoice();
|
||
this.audio.setRateBoostEnabled(true); // E3:非对局恢复长按快进
|
||
this.audio.play('bgm/menu');
|
||
this.guide.state('menu');
|
||
this._titleChain = this.run ? ['voice/sys_continue_ask'] : [];
|
||
if (this._titleChain.length) this._speak(this._titleChain);
|
||
}
|
||
|
||
_titleUpChain() {
|
||
const ids = ['voice/meta_gold_' + this._goldBand(this.meta.gold)];
|
||
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) {
|
||
if (g === 'tap') {
|
||
if (this.run) this._resumeRun();
|
||
else this._newRunFlow();
|
||
} else if (g === 'right') {
|
||
this._newRunFlow(); // 重掷新周目(meta 保留,§2.3)
|
||
} else if (g === 'doubletap') {
|
||
const chain = this._titleChain.length ? this._titleChain : this._titleUpChain();
|
||
this._speak(chain); // 重听 continue_ask / 元进度播报
|
||
} else if (g === 'up') {
|
||
this._titleChain = this._titleUpChain();
|
||
this._speak(this._titleChain);
|
||
} else if (g === 'down') {
|
||
this._openMenu();
|
||
} else {
|
||
this.guide.error({ voice: 'gd_invalid', onceKey: 'dungeon_invalid' }); // error 震 + full 档纠错
|
||
}
|
||
}
|
||
|
||
// ---------------- 新周目 / 继续(§4.2、§11) ----------------
|
||
|
||
_newRunFlow() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._stopWalk();
|
||
this._battle = null;
|
||
this._drill = null;
|
||
this.menuOpen = false;
|
||
this.audio.stopVoice();
|
||
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._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._speakGuarded(ids, () => {
|
||
this.meta.pending = [];
|
||
this._save();
|
||
if (!this.meta.drillDone) this._enterDrill();
|
||
else this._resumeExplore(this._recapChain()); // 新周目直落探索也带局面复述(P3)
|
||
}, 20000);
|
||
}
|
||
|
||
_restartRun(seed) { // 菜单"重新开始本周目":同 seed 重置
|
||
this.run = null;
|
||
this._fixedSeed = seed;
|
||
this._newRunFlow();
|
||
}
|
||
|
||
_recapChain() { // 局面复述(§11):层 → 血量 → 面朝 → 威胁(金币只在显式上滑状态链)
|
||
return ['voice/st_floor_' + this.run.floor,
|
||
'voice/st_hp_' + this._hpBand(this.run.hp, this._maxHp()),
|
||
'voice/st_face_' + DIR_KEY[this.run.face],
|
||
'voice/st_threat_' + this._threatKey()];
|
||
}
|
||
|
||
_resumeRun() {
|
||
const saved = { pos: { ...this.run.pos }, face: this.run.face, veil: this.run.veilUsedFloor };
|
||
this._enterFloor(this.run.floor); // 重放该层 seed 生成(§11)
|
||
this.run.pos = saved.pos;
|
||
this.run.face = saved.face;
|
||
this.run.veilUsedFloor = saved.veil;
|
||
this._resumeExplore(this._recapChain());
|
||
}
|
||
|
||
// ================================ 状态接入 ================================
|
||
|
||
/** 进入探索态(DRILL 完成后 / 战斗后 / 读档后共用)。chain = 前置语音链(可空)。 */
|
||
_resumeExplore(chain) {
|
||
this.state = 'EXPLORE';
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.menuOpen = false;
|
||
this.audio.play('bgm/explore');
|
||
this.audio.setRateBoostEnabled(false); // E3 豁免
|
||
this.guide.state('explore');
|
||
this._startMobs();
|
||
this._stuck.lastAct = performance.now();
|
||
this._lastAmbAt = performance.now(); // 呼吸层从进入探索起算静默(R2 D0-3②)
|
||
this._stuckArm();
|
||
this._armExploreWatchdog();
|
||
if (chain && chain.length) this._speak(chain);
|
||
}
|
||
|
||
// ---------------- 手势总入口 ----------------
|
||
|
||
onGesture(g) {
|
||
this.guide.disarm(); // 任何手势复位向导看门狗(off 档 no-op)
|
||
if (g === 'longpressend') { this._stopWalk(); return; } // 仅停走信号,不承载第二语义
|
||
if (this.menuOpen) { this._onMenuGesture(g); return; }
|
||
this._stuck.pending.length = 0; // 玩家操作即作废未播的卡关提示(§10.3)
|
||
switch (this.state) {
|
||
case 'TITLE': this._onTitleGesture(g); break;
|
||
case 'DRILL': this._onDrillGesture(g); break;
|
||
case 'EXPLORE': this._onExploreGesture(g); break;
|
||
case 'BATTLE': this._onBattleGesture(g); break;
|
||
case 'SETTLE': this._onSettleGesture(g); break;
|
||
default: break; // DESCEND 演出窗口:不收手势
|
||
}
|
||
// 探索静止看门狗:每个手势后仍在探索态则重新武装([10s, 25s] 两级)
|
||
if (this.state === 'EXPLORE' && !this.menuOpen) this._armExploreWatchdog();
|
||
if (this.state === 'EXPLORE' && !this.menuOpen) this._stuck.lastAct = performance.now();
|
||
}
|
||
|
||
// ================================ 探索态(§2.1) ================================
|
||
|
||
_onExploreGesture(g) {
|
||
if (g === 'tap') {
|
||
if (this._onStairs && this._stairsUsable()) { this._descend(false); return; }
|
||
this._pulse();
|
||
} else if (g === 'doubletap') {
|
||
this._pulse(); // 免费重发脉冲(同受 1.2s 冷却闸,§3.1)
|
||
} else if (g === 'left') {
|
||
this._turn(-1);
|
||
} else if (g === 'right') {
|
||
this._turn(1);
|
||
} else if (g === 'up') {
|
||
this._speakStatus();
|
||
} else if (g === 'down') {
|
||
this._openMenu();
|
||
} else if (g === 'longpress') {
|
||
this._startWalk();
|
||
}
|
||
}
|
||
|
||
_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;
|
||
}
|
||
|
||
_turn(d) {
|
||
const i = DIR_ORDER.indexOf(this.run.face);
|
||
this.run.face = DIR_ORDER[(i + d + 4) % 4];
|
||
this.audio.play('sfx/step_turn'); // swipe 震由 gesture.js 内置,不重复
|
||
}
|
||
|
||
/** 声波脉冲(§3.1):全部预生成资产 + setTimeout + volume,可被虚拟时钟断言。 */
|
||
_pulse() {
|
||
const now = performance.now();
|
||
if (now - this._pulseAt < PULSE_CD) { vibrate('error'); return null; } // 冷却期:仅 error 震
|
||
this._pulseAt = now;
|
||
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;
|
||
}
|
||
|
||
/** 正前扫描:最近反射体决定距离与材质(楼梯=铁梯;身后方向永不进入扫描)。
|
||
* R2 D0-2:站在楼梯格上脉冲 → 贴身铁器回声(教学「铁器=楼梯」的兑现:
|
||
* R1 live 165 脉冲 0 次 mat_stair,连站在楼梯上都报石墙)。 */
|
||
_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' };
|
||
// 木门/水地形当前生成器不产出;材质表保留(mat_door/mat_water 供未来层)
|
||
}
|
||
return { dist: S * 2, mat: 'stone' };
|
||
}
|
||
|
||
/**
|
||
* 侧向扫描(side = -1 左 / 1 右):开口(第一格可通行=能走进去)/墙(闷,含距离),
|
||
* 身后不报。R2 D0-2 重接 grid 单源:R1 旧实现把「整条侧射线到边界全通」才判
|
||
* 开口——房间/走廊地型下恒为假,40+ 脉冲 side_open 0 次、明明能走的方向报墙。
|
||
* 设计 §3.1 口径:侧向 2 档(墙=闷 / 开口=空旷回灵),开一格就是开口。
|
||
*/
|
||
_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 组资产音量(§7.4 unlock_echo)。 */
|
||
_sideVol(s) {
|
||
if (this._has('echo')) return s.dist <= 1 ? 0.8 : 0.45;
|
||
return 0.6;
|
||
}
|
||
|
||
// ---------------- 长按前进(§2.1:450ms 起、每格 0.5s、撞墙停无伤) ----------------
|
||
|
||
_startWalk() {
|
||
if (this._walk || this.state !== 'EXPLORE' && this.state !== 'DRILL') return;
|
||
if (this.menuOpen) return;
|
||
this._walk = { steps: 0, bumped: false };
|
||
this._walkStep();
|
||
}
|
||
|
||
_stopWalk() { this._walk = null; }
|
||
|
||
_walkStep() {
|
||
if (!this._walk) return;
|
||
const s = this._session;
|
||
const d = DIR_VEC[this.run.face];
|
||
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.audio.play('sfx/bump'); // 撞墙:无伤自动停(§2.1)
|
||
this._walk.bumped = true;
|
||
this._afterWalkDrill();
|
||
this._stopWalk();
|
||
return;
|
||
}
|
||
this.run.pos = { x: nx, y: ny };
|
||
this._walk.steps++;
|
||
this.audio.play('sfx/step_own', { volume: 0.7 });
|
||
vibrate('swipe');
|
||
// 导航模式(卡关 L3):每步播方向
|
||
if (this._stuck.nav && this.state === 'EXPLORE') this._navSpeakStep();
|
||
// 踏格判定:怪 → 遭遇;宝物 → 拾取;楼梯 → 提示/锁
|
||
const mob = this._floor.mobs.find((m) => m.x === nx && m.y === ny);
|
||
if (mob) { this._encounter(mob); return; } // _encounter 内部停走(session 令牌)
|
||
const k = key(nx, ny);
|
||
const item = this._floor.items.get(k);
|
||
if (item) this._pickup(item, k);
|
||
this._onStairCell(nx, ny);
|
||
this._afterWalkDrill();
|
||
this._after(WALK_STEP_MS, () => { if (s === this._session && this._walk) this._walkStep(); });
|
||
}
|
||
|
||
_afterWalkDrill() {
|
||
const d = this._drill;
|
||
if (!d || d.step !== 4 || !this._walk) return;
|
||
if (this._walk.steps >= 2 || this._walk.bumped) this._drillPass(); // §5 D4:≥2 格(撞墙也算过)
|
||
}
|
||
|
||
_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(); // 踏上楼梯格 = 进展事件(§10.3)
|
||
const spec = FLOOR_SPECS[this.run.floor];
|
||
if (spec.boss && !this.run.cleared.includes(this.run.floor)) {
|
||
this._speak(['voice/evt_stairs_lock']);
|
||
return;
|
||
}
|
||
if (this._stairsSpokenFloor !== this.run.floor) {
|
||
this._stairsSpokenFloor = this.run.floor;
|
||
this._speak(['voice/tut_stairs_on']); // 楼梯就在脚下。单击,下潜。
|
||
}
|
||
}
|
||
|
||
// ---------------- 拾取(§6.2/§7.4) ----------------
|
||
|
||
_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._speak(['voice/evt_coin_' + band]);
|
||
this._progress();
|
||
this._save();
|
||
} else if (item.type === 'potion') {
|
||
if (this.run.potion >= 1) {
|
||
this._speak(['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._speak(['voice/evt_potion']);
|
||
this._progress();
|
||
this._save();
|
||
} 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._speak(['voice/evt_weapon']);
|
||
this._progress();
|
||
this._save();
|
||
} 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._speak(['voice/evt_armor']);
|
||
this._progress();
|
||
this._save();
|
||
}
|
||
}
|
||
|
||
// ---------------- 状态播报(§2.1/§2.2/§3.2) ----------------
|
||
|
||
_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++;
|
||
// 前方/身后不报(同回声降维,§3.2)
|
||
}
|
||
return left && right ? 'both' : left ? 'left' : right ? 'right' : 'none';
|
||
}
|
||
|
||
_speakStatus() { // 上滑:层 → 血量 → 金币 → 面朝 → 威胁(5 段链)
|
||
const band = this._hpBand(this.run.hp, this._maxHp());
|
||
if (band === 4) vibrate('warning');
|
||
this._speak(['voice/st_floor_' + this.run.floor,
|
||
'voice/st_hp_' + band,
|
||
'voice/st_gold_' + this._goldBand(this.meta.gold),
|
||
'voice/st_face_' + DIR_KEY[this.run.face],
|
||
'voice/st_threat_' + this._threatKey()]);
|
||
}
|
||
|
||
// ================================ 怪物游走与遭遇(§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);
|
||
// R2 D1-3:相邻即开战(互视/贴身必扑)——杜绝 R1 live L3 蘑菇「贴身绕圈、
|
||
// 堵格不开战」的软封锁;脚步+心跳预警在前,贴身必然接战(报名=它在面前)。
|
||
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'); // 威胁前置预警窗(方法论 L2)
|
||
}
|
||
|
||
/** 遭遇(明雷 §7.1):播报名即=它在面前(自动面对),BATTLE 三档。 */
|
||
_encounter(mob) {
|
||
this._stopWalk();
|
||
const ms = MOBS[mob.type];
|
||
this._floor.mobs = this._floor.mobs.filter((m) => m !== mob);
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._walk = null;
|
||
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,
|
||
drill: !!(this._drill && this._drill.step === 8),
|
||
drillMob: !!mob.drill,
|
||
};
|
||
this.audio.play(ms.boss ? 'bgm/boss' : 'bgm/battle', { fadeMs: 400 });
|
||
this.guide.state('battle');
|
||
this._speakGuarded(['voice/' + ms.start], () => {
|
||
if (this._battle?.drill) {
|
||
// 教学战(§5 D8):先由教学怪出手,练格挡
|
||
this._speakGuarded(['voice/tut_parry'], () => 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;
|
||
this.guide.turn({ haptic: true }); // turn earcon + confirm 震(§10.2)
|
||
this.guide.watchdog({
|
||
arm: true, at: [15000, 30000], say: ['gd_turn', null],
|
||
cond: () => this.state === 'BATTLE' && this._battle === b && b.state === 'player' && !this.menuOpen,
|
||
onEscalate: (n) => { // R2 D1-6:15s 无操作播合成回执音(standard 档没有 gd_turn 语音也有"还到你"的确认)
|
||
if (n === 1 && this.state === 'BATTLE' && !this.menuOpen) this.audio.play('sfx/turn_tick', { volume: 0.7 });
|
||
},
|
||
});
|
||
}
|
||
|
||
_onBattleGesture(g) {
|
||
const b = this._battle;
|
||
if (!b) return;
|
||
if (b.state === 'player') {
|
||
if (g === 'tap') this._swing();
|
||
else if (g === 'doubletap') vibrate('error'); // 窗口外双击:error 震不生效(§7.2)
|
||
else if (g === 'left') this._battleTurnG(-1);
|
||
else if (g === 'right') this._battleTurnG(1);
|
||
else if (g === 'longpress') this._flee();
|
||
else if (g === 'up') this._speakBattleStatus();
|
||
else if (g === 'down') this._openMenu();
|
||
} else if (b.state === 'telegraph') {
|
||
if (g === 'doubletap' && performance.now() <= b.windowEnd) {
|
||
b.parry = true; // 面对与否在结算时按实际方位判(§7.2);
|
||
// parries 只在结算判成"面对且格挡成功"时计数(归因真话律:背对双击没挡下)
|
||
this.audio.play('sfx/parry');
|
||
vibrate('confirm');
|
||
} else if (g === 'down') {
|
||
this._openMenu(); // E4:战斗任意时刻可暂停——前摇内开菜单 → 冻结剩余窗口
|
||
} else {
|
||
vibrate('error'); // 窗口内做错手势 / 窗口已过:留在窗口不生效
|
||
}
|
||
}
|
||
// 其他状态(intro/enemyMove/busy):手势忽略
|
||
}
|
||
|
||
_swing() {
|
||
const b = this._battle;
|
||
b.state = 'busy';
|
||
this.audio.play('sfx/atk_swing');
|
||
if (this._battleRel() !== REL.FRONT) {
|
||
// 挥空(归因真话,§2.2):它不在你面前
|
||
this._speakGuarded(['voice/btl_swish'], () => this._enemyTurn(), 6000);
|
||
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(§7.2;王 5→6)
|
||
this.guide.state('danger');
|
||
vibrate('warning');
|
||
this._speakGuarded(['voice/' + b.spec.half], next, 8000);
|
||
return;
|
||
}
|
||
next();
|
||
}
|
||
|
||
/** 转身(战斗):只对绕位怪/Boss 有意义;缠斗怪纠错(每场 1 次,§7.2)。 */
|
||
_battleTurnG(d) {
|
||
const b = this._battle;
|
||
if (!b.mover) {
|
||
vibrate('error');
|
||
if (!b.noTurnSaid) {
|
||
b.noTurnSaid = true;
|
||
this._speakGuarded(['voice/btl_no_turn'], () => {}, 5000);
|
||
}
|
||
return;
|
||
}
|
||
const i = DIR_ORDER.indexOf(this.run.face);
|
||
this.run.face = DIR_ORDER[(i + d + 4) % 4];
|
||
this.audio.play('sfx/step_turn');
|
||
}
|
||
|
||
_speakBattleStatus() { // §2.2:我方血量档 → 敌方阶段 →(绕位/Boss)方位
|
||
const b = this._battle;
|
||
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._speak(ids);
|
||
}
|
||
|
||
/** 长按=尝试逃跑(§7.3):普通/绕位必成挨一下;Boss 拒绝。 */
|
||
_flee() {
|
||
const b = this._battle;
|
||
if (b.boss) {
|
||
vibrate('error');
|
||
this._speakGuarded(['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 }); // R2 D1-4:教学战逃跑同样回探索曲
|
||
if (this._drill.step === 8) this._drillApproach();
|
||
return;
|
||
}
|
||
this._resumeExplore();
|
||
};
|
||
// 归因真话律:ATK0 怪(L1 蝠)根本没碰到你,「被抓了一下」是假话 → 静默脱战
|
||
if (dmg > 0) this._speakGuarded(['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;
|
||
// 脚步报新方位(§7.2 绕位怪先移动):pan 按相对方位,背后=中央资产音量减半
|
||
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;
|
||
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() { // 护符:每层首次受击减半(§7.4)
|
||
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(§2.2)
|
||
if (parried) this.run.parries++; // 全程格挡数(§11):只计真正挡下的(面对+窗口内)
|
||
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) { // 教学战(§5 D8):格挡成/败都继续 → 攻击教学
|
||
const ids = [parried ? 'voice/tut_parry_ok' : 'voice/tut_parry_miss', 'voice/tut_attack'];
|
||
this._speakGuarded(ids, () => this._playerTurn(), 12000);
|
||
return;
|
||
}
|
||
this._dangerCheck();
|
||
const drank = this._autoPotion();
|
||
const ids = [];
|
||
if (parried) ids.push('voice/btl_parried'); // 格挡成功(减伤 80%,§9.3 B 组 trigger)
|
||
if (behind) ids.push('voice/btl_behind'); // 背后挨刀归因真话(§7.2)
|
||
if (drank) ids.push('voice/st_hp_' + this._hpBand(this.run.hp, this._maxHp()));
|
||
if (ids.length) this._speakGuarded(ids, () => this._playerTurn(), 8000);
|
||
else this._playerTurn();
|
||
}
|
||
|
||
_dangerCheck() { // danger 签名:每层首次 ≤25% + Boss 半血(§10.1)
|
||
if (!this._dangerFloorDone && this._hpBand(this.run.hp, this._maxHp()) === 4) {
|
||
this._dangerFloorDone = true;
|
||
this.guide.state('danger');
|
||
vibrate('warning');
|
||
}
|
||
}
|
||
|
||
_autoPotion() { // HP≤25% 且腰带有力 → 自动喝(§7.2)
|
||
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._speakGuarded(ids, () => {
|
||
this._battle = null;
|
||
if (this._drill && this._drill.step === 8) {
|
||
// R2 D1-4:教学战胜利回教学也必须切回探索曲(R1 转写 t=35.8 起战斗鼓点
|
||
// 泡全程的根因之一——bgm/explore 此前只在菜单关闭路径恢复)
|
||
this.state = 'DRILL';
|
||
this.audio.play('bgm/explore', { fadeMs: 400 });
|
||
this._drillAdvance(9);
|
||
return;
|
||
}
|
||
if (this._drill) { // D9 途中遇怪等:战后回教学态继续(否则状态卡在探索、drillDone 丢失)
|
||
this.state = 'DRILL';
|
||
this.audio.play('bgm/explore', { fadeMs: 400 });
|
||
this._drillWatchdog(this._drill.step);
|
||
return;
|
||
}
|
||
this._resumeExplore();
|
||
}, 12000);
|
||
}
|
||
|
||
// ================================ 死亡 / 通关结算(§11/§12.7) ================================
|
||
|
||
_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 };
|
||
// 归因真话律(§11/§12 失败即不通过项):句必须与真实死因对应
|
||
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._stopWalk();
|
||
this.state = 'SETTLE';
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.setRateBoostEnabled(true); // E3:结算恢复
|
||
this.audio.play('bgm/menu', { volume: 0.5 });
|
||
this.guide.state('settle');
|
||
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); // 下一局开场播报(§7.4)
|
||
const floorN = this.run.floor;
|
||
// R2 D1-5:金币档与 TITLE 同一分档函数、同一本账(meta.gold,遗产购置后)。
|
||
// R1 live:SETTLE 用 run 金币自设阈值报 meta_gold_3,TITLE 用 _goldBand(meta.gold)
|
||
// 报 meta_gold_2——同一句 id 两套口径,「这趟」的账对不上。
|
||
const goldBand = this._goldBand(this.meta.gold);
|
||
this.run = null; // 死亡清 run 不清 meta
|
||
this._save();
|
||
this._settleChain = ['voice/die_intro', 'voice/die_cause_' + cause, 'voice/die_adv_' + adv,
|
||
'voice/st_floor_' + floorN, 'voice/meta_gold_' + goldBand, 'voice/settle_stronger', 'voice/settle_again'];
|
||
this._speakGuarded(this._settleChain, () => {}, 30000);
|
||
}
|
||
|
||
_settleWin() {
|
||
this._battle = null;
|
||
this._stopWalk();
|
||
this.state = 'SETTLE';
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.setRateBoostEnabled(true);
|
||
this.audio.play('bgm/menu', { volume: 0.5 });
|
||
this.guide.state('settle');
|
||
vibrate('success');
|
||
this.meta.bestFloor = Math.max(this.meta.bestFloor, 7);
|
||
this.meta.runs++;
|
||
const bought = this._buyUnlocks();
|
||
const goldBand = this._goldBand(this.meta.gold); // R2 D1-5:与 TITLE 同函数同账本
|
||
const ids = ['voice/settle_win_1', 'voice/settle_win_2', 'voice/meta_gold_' + goldBand];
|
||
if (bought.length) for (const u of bought) ids.push('voice/unlock_' + u);
|
||
ids.push('voice/settle_again');
|
||
this.run = null;
|
||
this._save();
|
||
this._settleChain = ids; // SETTLE 双击=重听结算链(§2.3)
|
||
this._speakGuarded(ids, () => {}, 30000);
|
||
}
|
||
|
||
_onSettleGesture(g) {
|
||
if (g === 'tap') {
|
||
this._enterTitle();
|
||
} else if (g === 'doubletap') {
|
||
this._speak(this._settleChain && this._settleChain.length
|
||
? this._settleChain : ['voice/settle_again']); // 重听结算链(重听入口)
|
||
} else if (g === 'up') {
|
||
this._speak(this._titleUpChain()); // 重听元进度段
|
||
} else if (g === 'down') {
|
||
this._openMenu();
|
||
} else {
|
||
this.guide.error({ voice: 'gd_invalid', onceKey: 'dungeon_invalid' });
|
||
}
|
||
}
|
||
|
||
// ================================ 下潜(§4.2 DESCEND) ================================
|
||
|
||
_descend(fromDrill) {
|
||
this.state = 'DESCEND';
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._stopWalk();
|
||
this.audio.play('sfx/stairs_down');
|
||
this._speakGuarded(['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._speakGuarded(ids, () => this._resumeExplore(), 8000);
|
||
}, 8000);
|
||
}
|
||
|
||
// ================================ 演练教学 DRILL(§5) ================================
|
||
|
||
_enterDrill() {
|
||
this.state = 'DRILL';
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.play('bgm/explore');
|
||
this.audio.setRateBoostEnabled(false); // E3 豁免(教学=对局)
|
||
this.guide.state('explore');
|
||
this._drill = {
|
||
step: -1, fails: 0, upDone: false, menuDone: false, turnedL: false, turnedR: false,
|
||
bat: null, stairsSpoken: false,
|
||
};
|
||
this._drillEnter(0);
|
||
}
|
||
|
||
_drillWatchdog(step) {
|
||
this.guide.watchdog({
|
||
arm: true, at: [8000], say: [null], // 教学句不重播:纯 nudge(§10.2)
|
||
cond: () => this.state === 'DRILL' && this._drill?.step === step,
|
||
});
|
||
}
|
||
|
||
_drillEnter(step) {
|
||
const d = this._drill;
|
||
d.step = step;
|
||
d.fails = 0;
|
||
if (step === 0) {
|
||
this._speakGuarded(['voice/intro_1', 'voice/intro_2', 'voice/tut_d0'], () => this._drillWatchdog(0), 20000);
|
||
} else if (step === 1) {
|
||
// D1 首次脉冲等待:旁白表为「—」,仍挂教学看门狗(§10.2 每步等待 [8s] 纯 nudge)
|
||
this._drillWatchdog(1);
|
||
} else if (step === 2) {
|
||
this._speakGuarded(['voice/tut_front_rule'], () => this._drillWatchdog(2), 8000);
|
||
} else if (step === 3) {
|
||
this._speakGuarded(['voice/tut_side_rule'], () => this._drillWatchdog(3), 8000);
|
||
} else if (step === 4) {
|
||
// 自动面向东侧走道(走道 2 格 + 撞边界),保证"按住走 ≥2 格"可达成
|
||
while (this.run.face !== 'E') this._turn(1);
|
||
this._speakGuarded(['voice/tut_walk'], () => this._drillWatchdog(4), 8000);
|
||
} else if (step === 5) {
|
||
this._speakGuarded(['voice/tut_turn'], () => this._drillWatchdog(5), 8000);
|
||
} else if (step === 6) {
|
||
this._speakGuarded(['voice/tut_replay'], () => this._drillWatchdog(6), 8000);
|
||
} else if (step === 7) {
|
||
this._speakGuarded(['voice/tut_status', 'voice/tut_menu'], () => this._drillWatchdog(7), 12000);
|
||
} else if (step === 8) {
|
||
this._drillEnter8();
|
||
} else if (step === 9) {
|
||
this._speakGuarded(['voice/tut_stairs'], () => this._drillWatchdog9(), 8000);
|
||
}
|
||
}
|
||
|
||
/** D9 看门狗(R2 D1-2):8s nudge、30s 升级播楼梯暗示(铁器回声提示)。
|
||
* R1 live:D9 是唯一要求自行寻路的步骤,静置 72s 零事件零兜底——它是
|
||
* 教学链里唯一没有救援出口的一步。 */
|
||
_drillWatchdog9() {
|
||
this.guide.watchdog({
|
||
arm: true, at: [8000, 30000], say: [null, 'stair_hint_a'],
|
||
cond: () => this.state === 'DRILL' && this._drill?.step === 9,
|
||
});
|
||
}
|
||
|
||
_drillEnter8() {
|
||
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(9); return; } // 边界:门怪已亡(乱走触发过)→ 自动过
|
||
d.bat = bat;
|
||
while (this.run.face !== this._dominantDirTo(bat.x, bat.y)) this._turn(1); // 遭遇自动面对语义
|
||
this._speakGuarded(['voice/tut_mob'], () => {
|
||
this._drillWatchdog(8);
|
||
this._drillApproach(); // 兜底:教学怪主动扑来,绝不等死
|
||
}, 8000);
|
||
}
|
||
|
||
_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 !== 8 || 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();
|
||
});
|
||
}
|
||
|
||
_drillWrong() {
|
||
const d = this._drill;
|
||
if (!d) return;
|
||
d.fails++;
|
||
vibrate('error');
|
||
if (d.fails >= 2) this._drillPass(); // 做错 ≤2 次后自动演示通过,不卡流程(§5)
|
||
}
|
||
|
||
_drillPass() {
|
||
const d = this._drill;
|
||
if (!d) return;
|
||
d.fails = 0;
|
||
this._drillAdvance(d.step + 1);
|
||
}
|
||
|
||
_drillAdvance(step) {
|
||
if (step > 9) { this._drillDone(); return; }
|
||
this._drillEnter(step);
|
||
}
|
||
|
||
_drillDone() { // 兜底:D9 未走楼梯而步数耗尽(极少见)——按跳过处理
|
||
this.meta.drillDone = true;
|
||
this._drill = null;
|
||
this.guide.disarm(); // R2 D0-1:教学收尾注销全部教学再调度器(看门狗等)
|
||
this._speakGuarded(['voice/tut_skip'], () => this._resumeExplore(), 8000);
|
||
}
|
||
|
||
_drillSkip() { // 下滑跳过教学(§5):标志存进度,二周目不再进 DRILL
|
||
this.meta.drillDone = true;
|
||
this._drill = null;
|
||
this._stopWalk();
|
||
this._save();
|
||
this.guide.disarm(); // R2 D0-1:同上,收尾即注销教学态挂件
|
||
this._speakGuarded(['voice/tut_skip'], () => this._resumeExplore(), 8000);
|
||
}
|
||
|
||
_onDrillGesture(g) {
|
||
const d = this._drill;
|
||
if (!d) { this._onExploreGesture(g); return; }
|
||
// D7 的下滑是"开菜单"教学动作本身;其余步骤下滑=跳过教学(§5)
|
||
if (g === 'down' && d.step !== 7) { this._drillSkip(); return; }
|
||
if (g === 'longpressend') return;
|
||
switch (d.step) {
|
||
case 0:
|
||
case 2:
|
||
case 3:
|
||
if (g === 'tap') this._drillPass();
|
||
else this._drillWrong();
|
||
break;
|
||
case 1: // 首次脉冲(FTUE 首输入点,≈t+15s)
|
||
if (g === 'tap') {
|
||
this._pulse();
|
||
// 反馈句与下一步指令句串行(R2 D0-1:同 tick 双 play 会在引擎
|
||
// _load 竞态下互相顶号——反馈句并入推进链,播完或 8s 兜底再进下一步)
|
||
this._speakGuarded(['voice/tut_echo_come'], () => this._drillPass(), 8000);
|
||
} else this._drillWrong();
|
||
break;
|
||
case 4: // 长按走(_walkStep 钩子里判定通过)
|
||
if (g !== 'longpress') this._drillWrong();
|
||
else this._startWalk();
|
||
break;
|
||
case 5: // 左滑+右滑各 1
|
||
if (g === 'left') { d.turnedL = true; this._turn(-1); }
|
||
else if (g === 'right') { d.turnedR = true; this._turn(1); }
|
||
else { this._drillWrong(); break; }
|
||
if (d.turnedL && d.turnedR) this._drillPass();
|
||
break;
|
||
case 6: // 双击重发(免费)
|
||
if (g === 'doubletap') {
|
||
this._pulse();
|
||
// 同 D1:反馈句并入推进链,绝不与下一步旁白同 tick 双 play(D0-1)
|
||
this._speakGuarded(['voice/tut_echo_come'], () => this._drillPass(), 8000);
|
||
} else this._drillWrong();
|
||
break;
|
||
case 7: // 上滑状态 + 下滑开菜单再关(下滑已被跳过占用,关菜单用菜单内下滑)
|
||
if (g === 'up') { d.upDone = true; this._speakStatus(); }
|
||
else if (g === 'down') { this._openMenu(); break; }
|
||
else { this._drillWrong(); break; }
|
||
if (d.upDone && d.menuDone) this._drillPass();
|
||
break;
|
||
case 8: // 走向教学怪触发战斗(战斗内流程见 _encounter/_victory)
|
||
if (g === 'longpress') this._startWalk();
|
||
else if (g === 'tap') this._pulse();
|
||
else if (g === 'up') this._speakStatus(); // 状态键 D7 已教:教学后可用(§4.3)
|
||
else this._drillWrong();
|
||
break;
|
||
case 9: // 脉冲听铁器回声 → 自行走到楼梯 → 踏上后单击下潜
|
||
if (g === 'tap') {
|
||
if (this._onStairs && this._stairsSpokenFloor === this.run.floor) this._descend(true);
|
||
else this._pulse();
|
||
} else if (g === 'doubletap') this._pulse();
|
||
else if (g === 'left') this._turn(-1);
|
||
else if (g === 'right') this._turn(1);
|
||
else if (g === 'up') this._speakStatus();
|
||
else if (g === 'longpress') this._startWalk();
|
||
// R2 D1-2:D9 每次操作后重挂看门狗(onGesture 的 disarm 会清掉它,
|
||
// 而 D9 的脉冲/转身/走位不推进步骤——不重挂等于看门狗只在静置时活 8s)
|
||
if (this.state === 'DRILL' && this._drill?.step === 9) this._drillWatchdog9();
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ================================ 模态菜单(§2.4/§5.2 全球约定) ================================
|
||
|
||
_openMenu() {
|
||
if (this.menuOpen) return;
|
||
this.menuFrom = this.state;
|
||
this.menuOpen = true;
|
||
this._menuItems = (this.state === 'TITLE' || this.state === 'SETTLE') ? TITLE_MENU : MENU_ITEMS;
|
||
this._menuIdx = 0;
|
||
this._session++;
|
||
this._clearTimers();
|
||
this._stopWalk();
|
||
this._menuPause = null;
|
||
if (this.menuFrom === 'BATTLE' && this._battle?.state === 'telegraph') {
|
||
this._menuPause = { remain: Math.max(0, this._battle.windowEnd - performance.now()) };
|
||
}
|
||
this.audio.setRateBoostEnabled(true); // E3:菜单恢复快进
|
||
this.audio.play('bgm/menu');
|
||
this.guide.state('menu');
|
||
this._speak(['voice/sys_menu', 'voice/' + this._menuItems[0]]);
|
||
}
|
||
|
||
_closeMenu() {
|
||
this.menuOpen = false;
|
||
const from = this.menuFrom;
|
||
this.menuFrom = null;
|
||
// R2 D1-1:关闭回执。R1 live 证据——打开有 menu 签名+sys_menu,关闭零反馈,
|
||
// 盲手玩家只能靠下滑在开/关之间盲赌(「3 次下滑关不掉」实为关了又被下次
|
||
// 下滑重开)。关闭补状态签名 earcon:回到哪个状态就报哪个签名。
|
||
this.guide.state(from === 'BATTLE' ? 'battle'
|
||
: from === 'SETTLE' ? 'settle'
|
||
: (from === 'TITLE' ? 'menu' : 'explore'));
|
||
if (from === 'BATTLE' && this._battle) {
|
||
this.state = 'BATTLE';
|
||
this.audio.play(this._battle.boss ? 'bgm/boss' : 'bgm/battle', { fadeMs: 400 });
|
||
this.audio.setRateBoostEnabled(false);
|
||
if (this._menuPause != null) { // 前摇暂停续播(剩余窗口)
|
||
const b = this._battle;
|
||
const remain = this._menuPause.remain;
|
||
this._menuPause = null;
|
||
b.windowEnd = performance.now() + remain;
|
||
const s = this._session;
|
||
this._after(remain, () => {
|
||
if (s === this._session && this._battle === b && b.state === 'telegraph') this._enemyResolve();
|
||
});
|
||
} else {
|
||
this._playerTurn();
|
||
}
|
||
return;
|
||
}
|
||
if (from === 'DRILL' && this._drill) {
|
||
this.state = 'DRILL';
|
||
this.audio.play('bgm/explore');
|
||
this.audio.setRateBoostEnabled(false);
|
||
const d = this._drill;
|
||
if (d.step === 7) {
|
||
d.menuDone = true;
|
||
if (d.upDone) this._drillPass();
|
||
}
|
||
if (d.step === 8) this._drillApproach(); // 菜单期间逼近暂停过 → 恢复重挂
|
||
return;
|
||
}
|
||
this.state = from || 'EXPLORE';
|
||
if (from === 'EXPLORE') {
|
||
this.audio.play('bgm/explore');
|
||
this.audio.setRateBoostEnabled(false);
|
||
this._startMobs();
|
||
this._stuck.lastAct = performance.now();
|
||
this._lastAmbAt = performance.now();
|
||
this._stuckArm();
|
||
} else {
|
||
this.audio.play('bgm/menu'); // TITLE / SETTLE
|
||
}
|
||
}
|
||
|
||
_onMenuGesture(g) {
|
||
const items = this._menuItems;
|
||
if (g === 'tap' || g === 'right') {
|
||
this._menuIdx = (this._menuIdx + 1) % items.length; // 单击循环切项(每切播项名)
|
||
this.audio.play('voice/' + items[this._menuIdx]);
|
||
} else if (g === 'left') {
|
||
this._menuIdx = (this._menuIdx + items.length - 1) % items.length;
|
||
this.audio.play('voice/' + items[this._menuIdx]);
|
||
} else if (g === 'doubletap') {
|
||
this._menuExec(items[this._menuIdx]);
|
||
} else if (g === 'up') {
|
||
this._speak(['voice/help_explore', 'voice/help_battle', 'voice/settings_hint']);
|
||
} else if (g === 'down') {
|
||
this._closeMenu(); // 下滑=关闭
|
||
}
|
||
}
|
||
|
||
_menuExec(item) {
|
||
if (item === 'sys_menu_resume') {
|
||
this._closeMenu();
|
||
} else if (item === 'sys_menu_save') {
|
||
this._save();
|
||
this._speakGuarded(['voice/sys_saved'], () => this._goHome(), 4000);
|
||
} else if (item === 'sys_menu_help') {
|
||
this._speak(['voice/help_explore', 'voice/help_battle', 'voice/settings_hint']);
|
||
} else if (item === 'sys_menu_restart') {
|
||
const seed = this.run ? this.run.seed : null;
|
||
this.menuOpen = false;
|
||
this.menuFrom = null;
|
||
if (seed != null) this._restartRun(seed);
|
||
else this._closeMenu();
|
||
}
|
||
}
|
||
|
||
// ================================ 卡关三级提示(§10.3) ================================
|
||
|
||
_progress() { // 进展事件:拾取/战斗胜利/踏上楼梯格/击败窟主/下潜 → 重置分级
|
||
this._stuck.shown.clear();
|
||
this._stuck.nav = false;
|
||
this._navLastDir = null;
|
||
this._stuck.lastAct = performance.now();
|
||
}
|
||
|
||
_stuckArm() {
|
||
if (this._stuckArmed) return;
|
||
this._stuckArmed = true;
|
||
const s = this._session;
|
||
const loop = () => {
|
||
if (s !== this._session) { this._stuckArmed = false; return; }
|
||
if (this.state === 'EXPLORE' && !this.menuOpen) {
|
||
this._stuckCheck();
|
||
this._ambBreath();
|
||
}
|
||
this._after(5000, loop);
|
||
};
|
||
this._after(5000, loop);
|
||
}
|
||
|
||
/** 探索呼吸层(R2 D0-3②):静默超 20s(无手势、无人声)播一段洞穴环境垫,
|
||
* 消灭 R1 转写「教学后 350s 零人声零事件」的真空体感。游戏级定时器,
|
||
* guide=off 契约不受影响(off 只静向导层;环境垫是内容层)。 */
|
||
_ambBreath() {
|
||
const now = performance.now();
|
||
if (now - this._lastAmbAt < AMB_AFTER_MS) return;
|
||
if (now - this._stuck.lastAct < AMB_AFTER_MS) return; // 玩家仍在操作 = 不算静默
|
||
if (this.audio.isVoicePlaying()) return; // 有人声就不垫
|
||
this._lastAmbAt = now;
|
||
this.audio.play('sfx/amb_cave', { volume: AMB_VOLUME });
|
||
}
|
||
|
||
_stuckCheck() {
|
||
this._stuckFlush();
|
||
const now = performance.now();
|
||
const idle = now - this._stuck.lastAct;
|
||
if (this._stuck.nav) {
|
||
if (now - this._stuck.lastFire >= NAV_REPEAT_MS) this._stuckFire(3);
|
||
return;
|
||
}
|
||
if (this._stuck.pending.length) return; // 待播中:等声道空出,不重复入队
|
||
if (idle >= STUCK_LEVELS[2] && !this._stuck.shown.has('L3')) this._stuckFire(3);
|
||
else if (idle >= STUCK_LEVELS[1] && !this._stuck.shown.has('L2')) this._stuckFire(2);
|
||
else if (idle >= STUCK_LEVELS[0] && !this._stuck.shown.has('L1')) this._stuckFire(1);
|
||
}
|
||
|
||
_stuckStairDir() { // 楼梯(终局层=王座)绝对方位(与上滑面朝句同坐标系,自洽)
|
||
const st = this._floor.stairs || this._floor.throne;
|
||
if (!st) return 'e';
|
||
const dx = st.x - this.run.pos.x, dy = st.y - this.run.pos.y;
|
||
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'e' : 'w';
|
||
return dy >= 0 ? 's' : 'n';
|
||
}
|
||
|
||
_stuckFire(level) {
|
||
this._stuck.lastFire = performance.now();
|
||
vibrate('warning');
|
||
let ids = [];
|
||
if (level === 1) {
|
||
const spec = FLOOR_SPECS[this.run.floor];
|
||
const bossAlive = (spec.boss && !this.run.cleared.includes(this.run.floor)) || !!spec.final;
|
||
// 终局层无楼梯(目标是王座上的窟中之王)→ 用「大东西守着」句,不说铁器
|
||
ids = [bossAlive ? 'voice/stair_hint_b' : 'voice/stair_hint_a'];
|
||
} else if (level === 2) {
|
||
ids = ['voice/stair_dir_' + this._stuckStairDir()];
|
||
} else {
|
||
// 终局层目标是王座(stairs 为 null 时回退 throne,与 L2 明示同口径)
|
||
const st = this._floor.stairs || this._floor.throne;
|
||
if (st && st.x === this.run.pos.x && st.y === this.run.pos.y) {
|
||
ids = ['voice/nav_done'];
|
||
} else {
|
||
ids = this._navRouteVoices();
|
||
}
|
||
}
|
||
if (this.audio.isVoicePlaying()) {
|
||
this._stuck.pending.push({ level, ids }); // 语音在播 → 入待播队列(escape round2 礼仪)
|
||
// 未播级别不计入 shown:玩家操作作废后,下段静默期可重新触发(§10.3)
|
||
} else {
|
||
this._stuckSpoken(level, ids);
|
||
}
|
||
}
|
||
|
||
_stuckSpoken(level, ids) { // 真正播出才算"已提示"(入队被作废的不算,§10.3)
|
||
if (!ids.length) return;
|
||
if (level === 3) {
|
||
if (ids[0] === 'voice/nav_done') { this._progress(); return; }
|
||
this._stuck.nav = true; // L3 带路:进入导航模式,每步播方向,抵达播 nav_done
|
||
} else {
|
||
this._stuck.shown.add('L' + level);
|
||
}
|
||
this._speak(ids);
|
||
}
|
||
|
||
/**
|
||
* 带路第一步方向(R2 D1-3 导航目标格校验):沿最短路(从目标反向 BFS)的
|
||
* 第一步给方向,不再按直线差值指墙(R1 live:nav_n 咬定北边而北边实测撞墙)。
|
||
* @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;
|
||
}
|
||
|
||
_navRouteVoices() { // L3 触发时的带路(R2:只报最短路第一步,后续每步校正)
|
||
const st = this._floor.stairs || this._floor.throne;
|
||
const dir = this._navFirstStepDir(st);
|
||
if (!dir) return [];
|
||
this._navLastDir = dir; // 首报计入节流:步行第一步同向不再重复
|
||
this._navLastAt = performance.now();
|
||
return ['voice/nav_' + DIR_KEY[dir]];
|
||
}
|
||
|
||
_navSpeakStep() { // 导航模式:按最短路逐步校正方向;抵达播 nav_done(终局层指向王座)
|
||
const st = this._floor.stairs || this._floor.throne;
|
||
if (!st) return;
|
||
if (st.x === this.run.pos.x && st.y === this.run.pos.y) {
|
||
this._speak(['voice/nav_done']);
|
||
this._progress();
|
||
return;
|
||
}
|
||
const dir = this._navFirstStepDir(st);
|
||
if (!dir) return;
|
||
const now = performance.now();
|
||
// R2:同向短窗不重复(导航句既是反馈也是保险丝计数——500ms/步连报同向
|
||
// 会撞引擎风暴熔断,且对玩家是噪声;改向立即播,同向 ≥1.5s 提醒一次)
|
||
if (dir === this._navLastDir && now - this._navLastAt < NAV_MIN_GAP_MS) return;
|
||
this._navLastDir = dir;
|
||
this._navLastAt = now;
|
||
this._speak(['voice/nav_' + DIR_KEY[dir]]);
|
||
}
|
||
|
||
_stuckFlush() {
|
||
if (!this._stuck.pending.length) return;
|
||
if (this.audio.isVoicePlaying()) return;
|
||
const { level, ids } = this._stuck.pending.shift();
|
||
this._stuckSpoken(level, ids);
|
||
}
|
||
|
||
_stuckDiscardPending() { // 玩家操作即作废未播级别(§10.3)
|
||
this._stuck.pending.length = 0;
|
||
}
|
||
|
||
// ================================ 向导看门狗(§10.2) ================================
|
||
|
||
_armExploreWatchdog() {
|
||
this.guide.watchdog({
|
||
arm: true, at: [10000, 25000], say: ['gd_pulse', null],
|
||
cond: () => this.state === 'EXPLORE' && !this.menuOpen,
|
||
});
|
||
}
|
||
|
||
// ================================ 语音链 / 计时器基础设施 ================================
|
||
|
||
_speak(ids, onDone) { // 串行链:自然播完才接下一句;被顶掉/打断则整条作废
|
||
if (!ids.length) { if (onDone) onDone(); return; }
|
||
const s = this._session;
|
||
const [head, ...rest] = ids;
|
||
this.audio.play(head, {
|
||
onComplete: () => {
|
||
if (s !== this._session) return;
|
||
this._speak(rest, onDone);
|
||
},
|
||
});
|
||
}
|
||
|
||
_speakGuarded(ids, onDone, guardMs = 10000) { // 播完或超时先到先触发,流程绝不卡死
|
||
const s = this._session;
|
||
let fired = false;
|
||
const fire = () => {
|
||
if (fired || s !== this._session) return;
|
||
fired = true;
|
||
if (onDone) onDone();
|
||
};
|
||
this._speak(ids, 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 = [];
|
||
this._stuckArmed = false;
|
||
}
|
||
|
||
// ================================ 存档(§11) ================================
|
||
|
||
_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;
|
||
}
|
||
}
|
||
}
|