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

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

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

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

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

2220 lines
90 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《回声地窟》—— 纯音频听声辨位 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;
}
}
}