Files
xsl ee9d7efe57 feat(playbook): 26 号交互手册首批落地——storyline/escape/simon 全面重构 + 引擎六模块
按 docs/design/26-audio-interaction-playbook.md 重构三款游戏(对白重写 + 双性别语音重生成 580 文件)。

引擎层(web/engine/,其余 12 款游戏零改动):
- floor.js 发言权管理器:SPEAKING/YOUR_TURN/BUSY/ACTION 四态、交接提示音、
  等待声(220/330Hz 嘟-嘟,20s 后放缓)、跨链音乐压低(audio.js holdDuck)
- receipts.js 输入回执三件套(ok/invalid/busy,100ms 内,原则 4)
- confusion.js 迷茫检测(乱点/无效/静默信号 → 三级接管,§4.5)
- hints.js 等待点 + 三档提示(full→short→提示音,30s 台账跨点去重,原则 9)
- novice.js 手势解锁阶梯(up/down/doubletap 跨局持久,§4.3)
- pipeline.js 输入管道(回执收口、防连点 400ms、双击折算、explainOnce)

游戏层:
- storyline R3:开场 71.7s 规则念白 → 3 秒内动手;双击=重播(反模式5)废除;
  抉择点三档提示+主动重听;断点存档复述(§4.12)
- escape R3:单击=检查+使用合并(双击用法废除);长按=我在哪(四向玩法裁定);
  撞墙归因每方向一次(原则 7);迷茫 L2 缩小/L3 演示;坐标存档续玩
- simon R3:新手三音色,按实力渐进解锁(+up/down/dt);失败表替代机会账
  (归因 2s 内→免费重听→放慢→结算,原则 15);播放期手势=busy 回执

测试:三款 playtest 重写(含 §6.2 乱点机器人:无声操作/被动重复/打断失败全 0,
首操作 ~2s);guide.test 改为 Guide 层单测+rhythm/space 段。全部通过
(38+33+22+72+19+35 断言)。设计单见 docs/design/26-apply-*.md。
2026-10-01 08:11:12 +08:00

886 lines
31 KiB
JavaScript
Raw Permalink 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.
/**
* 《暗室》R3 —— 3x3 黑暗房间网格密室逃脱,按 docs/design/26-audio-interaction-playbook.md 全面重构。
*
* 交互层(对齐 26 号文档,设计单见 docs/design/26-apply-escape.md):
* - 四向滑动 = 核心玩法(§4.3 裁定:四向是玩法的游戏,帮助换到长按):
* up/down/left/right 永远是移动,不按新手阶梯锁。
* - 单击 = 检查 + 使用(合并):站在铁柜前拿着钥匙,单击就是开柜;双击=用物品的
* 旧映射废除(新手期双击按一次单击处理,绑定重要操作必误触)。
* - 长按 = 「我在哪、我该干嘛」:一句话(目标 + 方位,原则 3)。
* - PLAY 阶段 = 发言权 ACTION 态(§4.1):环境音就是状态声,常响不停;
* 房间描述 = SPEAKING(音乐压低跨链),播完回 ACTION。
* - 撞墙 = 失败表「方向错」:闷响 + invalid 回执 + 「那边是墙,换个方向。」+
* 方位句(归因真话);移动成功 = 脚步声(silent-ok,内容音即回执)。
* - 卡关接管(§4.5 三级):静默 40s 起、乱点(10s 内 3 次撞墙/无效)即触发——
* L1 说目标方位句 → L2 「现在只要向左滑」+只放行该方向 → L3 听我做一次
* (游戏自己走一步)+「现在你来」。进展事件清零重来。
* - 断点复述(§4.12):每步自动存档;回来问「继续上次 / 重新开始」。
*
* 谜题链(不变):储物间单击找钥匙 → 铁柜房单击开柜得保险丝 → 电箱房单击修电箱
* → 大门房单击开门逃脱。
*
* 状态 × 手势矩阵(新手期,完整版见设计单):
* | 状态 | 单击 | 四向滑动 | 长按 | 双击(未解锁) |
* |-----------|-----------------|-------------------|-----------|--------------|
* | 引导答题 | 按步骤答/纠错 | 按步骤答/纠错 | 帮助一句话 | 按单击处理 |
* | 探索(ACTION)| 检查+使用+ok | 移动(脚步)/撞墙(invalid) | 帮助 | 按单击处理 |
* | 描述在播 | 跳过这句+ok | 打断并移动 | 打断,帮助 | 按单击处理 |
* | 结算 | 重新开始+ok | invalid+解释 | 重听结算 | 按单击处理 |
* | 菜单 | 换一项+播报 | 右=确认/左=上一项 | 重听菜单 | 按单击处理 |
*/
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';
const Phase = {
REOPEN: 'REOPEN',
GUIDE: 'GUIDE',
PLAY: 'PLAY',
ENDING: 'ENDING',
MENU: 'MENU',
};
const G = { UP: 'up', DOWN: 'down', LEFT: 'left', RIGHT: 'right', TAP: 'tap', DOUBLE_TAP: 'doubletap', LONGPRESS: 'longpress' };
const SWIPES = [G.UP, G.DOWN, G.LEFT, G.RIGHT];
// 谜题链阶段 → 带路目标房间坐标
const STAGES = {
key: { target: [0, 2] },
cabinet: { target: [2, 1] },
box: { target: [0, 0] },
gate: { target: [2, 2] },
};
// 长按「我在哪、我该干嘛」目标句(原则 3:目标 + 一个动作)
const LP_GOAL = {
key: 'voice/lp_key',
cabinet: 'voice/lp_cabinet',
box: 'voice/lp_box',
gate: 'voice/lp_gate',
};
// 关键房间:坐标 → { 环境音, 首次到达描述 voice }
const KEY_ROOMS = {
'0,2': { amb: 'sfx/amb_storage', room: 'voice/room_storage' },
'2,1': { amb: 'sfx/amb_cabinet', room: 'voice/room_cabinet' },
'0,0': { amb: 'sfx/amb_power', room: 'voice/room_power' },
'2,2': { amb: 'sfx/amb_gate', room: 'voice/room_gate' },
};
// 空房环境音轮换
const EMPTY_AMB = { '1,0': 'sfx/amb_wind', '0,1': 'sfx/amb_hum', '1,2': 'sfx/amb_wind', '2,0': 'sfx/amb_hum' };
// 空房首访描述
const EMPTY_FIRST_VOICE = {
'1,0': 'voice/empty_1', '0,1': 'voice/empty_2', '1,2': 'voice/empty_3', '2,0': 'voice/empty_3',
};
const PREWARM_SFX = [
'sfx/step', 'sfx/wall', 'sfx/pickup', 'sfx/unlock', 'sfx/door_creak',
'sfx/spark', 'sfx/power_on', 'sfx/gate_open', 'sfx/win_chime',
'sfx/amb_storage', 'sfx/amb_cabinet', 'sfx/amb_power', 'sfx/amb_gate',
'sfx/amb_gate_live', 'sfx/amb_drip', 'sfx/amb_wind', 'sfx/amb_hum',
];
const GUIDE_DONE_KEY = 'soundgame.escape.guideDone';
const SAVE_KEY = 'soundgame.escape.save';
const STATS_KEY = 'soundgame.escape.stats';
const AUTH_KEY = 'soundgame.auth';
// 静默喂给迷茫检测的节奏:40s 首次、之后每 20s(两次即累计触发接管)
const SILENCE_FIRST_MS = 40000;
const SILENCE_REPEAT_MS = 20000;
// 无效操作解释(原则 7)
const INVALID_LINES = {
'locked:longpress': null, // 长按是本游戏核心手势,不会走到这
'ending:swipe': 'voice/inv_settle',
'menu:swipe': null, // 菜单左右滑合法
'menu:longpress': 'voice/menu_help',
'guide:swipe': null,
};
export class EscapeGame extends Game {
constructor(audio, root) {
super(audio, root);
this.receipts = new Receipts(audio);
this.floor = new Floor(audio);
this.confusion = new Confusion({ onConfused: (lvl) => this._takeover(lvl) });
this.invalidHints = new InvalidHints().bind(this.floor);
this.ladder = new GestureLadder();
this.audio.setRateBoostEnabled(false); // 长按=帮助(核心手势),不触发旁白快进
this.confusion.silenceMs = SILENCE_FIRST_MS;
this._session = 0;
this._timers = [];
this._silenceTimer = null;
this._restrictDir = null; // 迷茫 L2:只放行的移动方向
this._lastDescribe = null; // 最近一句房间描述(双击重听用)
this._menuIdx = 0;
this._resetState();
this.phase = Phase.REOPEN;
}
/** 管道选项(index.html 读):四向滑动 + 长按都是本作核心手势。 */
get PIPELINE_OPTS() { return { longpressCore: true, swipeCore: true }; }
_resetState() {
this.x = 1;
this.y = 1;
this.hasKey = false;
this.hasFuse = false;
this.cabinetOpen = false;
this.powerOn = false;
this.visited = new Set();
this.emptyVisited = new Set();
this._freshEmpty = null;
this._wallDirAt = {};
this._wallExplained = new Set(); // 撞墙归因:每方向每进展段只解释一次(原则 7)
this._describedRoom = new Set(); // 房间检查描述:每房间每进展段只念一次(原则 9)
this._settleBusy = false;
this.startElapsedMs = 0;
// 引导状态
this.guideStep = -1;
this.guideSwipes = 0;
this.guideErrs = 0;
this._endingBgmStarted = false;
}
// ---------- Game ----------
start() {
this.audio.prewarm(PREWARM_SFX);
this._session++;
this._clearTimers();
this.audio.play('bgm/drone');
const guided = this._flag(GUIDE_DONE_KEY);
const save = this._loadSave();
if (guided && save && save.inProgress) {
// 断点复述(§4.12)
this.phase = Phase.REOPEN;
this.floor.speak(['voice/sys_continue_ask'], {
onDone: () => this.floor.yieldTurn(this._wp({
id: 'reopen', expected: [G.TAP, G.RIGHT],
full: 'voice/sys_continue_ask', short: 'voice/lp_tap',
silenceMs: [12000, 30000],
onDemo: () => this.floor.chime(),
})),
});
} else if (guided) {
this._enterPlay(true, true); // 二周目:直接开始(start_room + 目标句)
} else {
this._startGuide();
}
}
stop() {
this._save(true);
this._session++;
this._clearTimers();
this._stopSilenceFeed();
this.floor.interrupt();
this.audio.stopAll();
}
onGesture(g) {
switch (this.phase) {
case Phase.REOPEN: return this._onReopenGesture(g);
case Phase.GUIDE: return this._onGuideGesture(g);
case Phase.PLAY: return this._onPlayGesture(g);
case Phase.ENDING: return this._onEndingGesture(g);
case Phase.MENU: return this._onMenuGesture(g);
}
return 'invalid';
}
stateName() {
if (this.phase === Phase.PLAY) return 'play';
if (this.phase === Phase.ENDING) return 'ending';
if (this.phase === Phase.MENU) return 'menu';
if (this.phase === Phase.GUIDE) return 'guide';
return 'reopen';
}
invalidHint(state, g) {
if (state === 'locked') return INVALID_LINES[`locked:${g}`] || 'voice/inv_generic';
if (g === 'longpress') return INVALID_LINES[`${state}:longpress`] || 'voice/menu_help';
if (g === 'left' || g === 'right') return INVALID_LINES[`${state}:swipe`] || 'voice/inv_generic';
if (state === 'ending') return 'voice/inv_settle';
return 'voice/inv_generic';
}
// ---------- 引导(首 3 分钟逐秒脚本,§4.10;设计单有完整表) ----------
_startGuide() {
this._session++;
this._clearTimers();
this._resetState();
this.phase = Phase.GUIDE;
this.guideStep = 0;
this.audio.play('sfx/amb_drip', { volume: 0.6 });
// 0~3 秒:第一句就让玩家动手(旧 g0_intro 8 秒念完才教学废除)
this._guideSay(['voice/g0_new'], 0);
}
/** 播引导链 → 交出话轮等步骤答案。expected = 本步骤接受的答选手势。 */
_guideSay(ids, step, expected) {
this.guideStep = step;
this.floor.speak(ids, {
onDone: () => this.floor.yieldTurn(this._wp({
id: `guide${step}`,
expected: expected || SWIPES,
full: null,
short: 'voice/g_hint_swipe',
silenceMs: [6000, 15000],
onDemo: () => this.floor.chime(),
})),
});
}
_onGuideGesture(g) {
// 引导答题:期待手势之外的输入 = 纠错(原则 11:做错不惩罚、当场重教)
const step = this.guideStep;
const isSwipe = SWIPES.includes(g);
switch (step) {
case 0: // 黑暗中先滑一下(任意方向)
if (isSwipe) {
this.audio.play('sfx/step', { volume: 0.8 });
this._guideSay(['voice/g0b'], 1);
return 'silent-ok'; // 脚步声即反馈
}
this._guideWrong('voice/g_wrong_gesture');
return 'invalid';
case 1: // 再滑两下试试墙(撞墙也是反馈:教「无效操作长什么样」)
if (isSwipe) {
this.audio.play('sfx/wall');
vibrate('error');
this.guideSwipes++;
if (this.guideSwipes >= 2) {
this._guideSay(['voice/g3_new'], 2, [G.LEFT]); // wall 刚说过不重播(原则 9)
} else {
this.floor.speak(['voice/wall'], {
onDone: () => { if (this.phase === Phase.GUIDE) this.floor.resumeTurn(); },
});
}
return 'invalid'; // 撞墙 = 失败表「方向错」,invalid 回执 + 归因
}
this._guideWrong('voice/g_wrong_gesture');
return 'invalid';
case 2: // 向左滑 → (0,1)
if (g === G.LEFT) {
this.x = 0; this.y = 1;
this.audio.play('sfx/step', { volume: 0.8 });
this._guideSay(['voice/g4_down'], 3, [G.DOWN]);
return 'silent-ok';
}
if (isSwipe) this._guideWrong('voice/guide_wrong_left');
else this._guideWrong('voice/g_wrong_gesture');
return 'invalid';
case 3: // 向下滑 → (0,2) 储物间
if (g === G.DOWN) {
this.x = 0; this.y = 2;
this.audio.play('sfx/step', { volume: 0.8 });
this.visited.add('0,2');
this.audio.play('sfx/amb_storage', { volume: 0.9 });
this._guideSay(['voice/g5_inspect'], 4, [G.TAP]);
return 'silent-ok';
}
if (isSwipe) this._guideWrong('voice/guide_wrong_down');
else this._guideWrong('voice/g_wrong_gesture');
return 'invalid';
case 4: // 单击检查 → 拾钥匙(谜题链第一环)→ 自由探索
if (g === G.TAP) {
this.guideStep = 5; // 先落步骤号:链被乱手势打断后重进不重播(幂等)
this.hasKey = true;
this.audio.play('sfx/pickup');
vibrate('pickup');
try { localStorage.setItem(GUIDE_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ }
this.ladder.onGoal(); // 第一个小目标达成 → 手势阶梯阶段 1
this.floor.speak(['voice/g5_key', 'voice/g7_goal_new'], {
onDone: () => this._enterPlay(false),
});
return 'silent-ok'; // 拾取音效即反馈
}
this._guideWrong('voice/g_wrong_tap');
return 'invalid';
default:
return 'invalid';
}
}
_guideWrong(voiceId, _g) {
this.guideErrs++;
vibrate('error');
if (this.guideErrs >= 4) return; // 4 次起只给回执(原则 7),交给迷茫接管
const id = this.guideErrs >= 3
? 'voice/guide_blunt_swipe' // 错 3 次给直白描述
: (voiceId || 'voice/g_wrong_gesture');
this.floor.speak([id], {
onDone: () => { if (this.phase === Phase.GUIDE) this.floor.resumeTurn(); },
});
}
// ---------- 进入 / 恢复 / 重开 PLAY ----------
_enterPlay(announce, withGoal = false) {
this._session++;
this._clearTimers();
this.phase = Phase.PLAY;
this.startElapsedMs = Date.now() - (this._savedElapsedMs || 0);
this.floor.enterAction(); // 探索态:环境音即状态声(§4.1 ACTION)
this._startSilenceFeed();
this.confusion.resetForWaitPoint();
if (announce) {
const seq = withGoal
? ['voice/start_room', 'voice/g7_goal_new']
: ['voice/start_room'];
this._say(seq);
}
this._playAmbient();
this._save(true);
}
_restart() {
this._session++;
this._clearTimers();
this._stopSilenceFeed();
this.floor.interrupt();
this._resetState();
this._savedElapsedMs = 0;
this.audio.play('bgm/drone');
this._enterPlay(false);
this._say(['voice/replay', 'voice/g7_goal_new']);
}
_onReopenGesture(g) {
const save = this._loadSave();
switch (g) {
case G.TAP: // 继续上次(§4.12:先播复述句 = 长按帮助句,再交回探索)
if (save) this._restoreState(save);
this.phase = Phase.PLAY;
this._enterPlay(false);
this._longpressHelp();
return 'ok';
case G.RIGHT: // 重新开始
this._resetState();
this._savedElapsedMs = 0;
this._enterPlay(true, true);
return 'ok';
case G.LONGPRESS:
this._longpressHelp();
return 'ok';
default:
return 'invalid';
}
}
_restoreState(save) {
this.x = save.x; this.y = save.y;
this.hasKey = !!save.hasKey;
this.hasFuse = !!save.hasFuse;
this.cabinetOpen = !!save.cabinetOpen;
this.powerOn = !!save.powerOn;
this.visited = new Set(save.visited || []);
this.emptyVisited = new Set(save.emptyVisited || []);
this._savedElapsedMs = save.elapsedMs || 0;
}
// ---------- 探索(ACTION 态) ----------
_onPlayGesture(g) {
let r;
switch (g) {
case G.UP: r = this._move(0, -1); break;
case G.DOWN: r = this._move(0, 1); break;
case G.LEFT: r = this._move(-1, 0); break;
case G.RIGHT: r = this._move(1, 0); break;
case G.TAP: r = this._interact(); break;
case G.LONGPRESS:
this._longpressHelp();
r = 'ok';
break;
case G.DOUBLE_TAP: {
// 阶段 3 解锁的重听:再说一遍最近一句房间描述
const id = this._lastDescribe || 'voice/start_room';
this._say(['voice/sys_say_again', id]);
r = 'ok';
break;
}
default:
r = 'invalid';
}
// 探索态收尾:手势处理完且没起新链 → 回 ACTION(环境音即状态,§4.1)
if (this.phase === Phase.PLAY && !this.floor.speaking) this.floor.enterAction();
return r;
}
/** 长按 = 我在哪、我该干嘛(原则 3:目标 + 方位一个动作,一句话)。 */
_longpressHelp() {
const stage = this._stage();
const target = STAGES[stage].target;
const atTarget = target[0] === this.x && target[1] === this.y;
const ids = atTarget
? [LP_GOAL[stage], 'voice/lp_here']
: [LP_GOAL[stage], ...this._navVoices(target)];
this._say(ids);
}
_move(dx, dy) {
// 迷茫 L2:只放行接管指定的方向(其余手势 invalid 回执,不产生效果)
if (this._restrictDir) {
const dir = dy < 0 ? G.UP : dy > 0 ? G.DOWN : dx < 0 ? G.LEFT : G.RIGHT;
if (dir !== this._restrictDir) return 'invalid'; // pipeline 统一记账+回执
}
const nx = this.x + dx;
const ny = this.y + dy;
if (nx < 0 || nx > 2 || ny < 0 || ny > 2) {
// 失败表「方向错」:闷响 + invalid 回执;归因句每个方向只解释一次
// (原则 7:之后只给回执——闷响本身就是内容回执),10s 冷却后可再提醒方位
this.audio.play('sfx/wall');
vibrate('error');
this.confusion.recordFail();
const dir = dy < 0 ? 'u' : dy > 0 ? 'd' : dx < 0 ? 'l' : 'r';
if (!this._wallExplained.has(dir)) {
const first = this._wallExplained.size === 0;
this._wallExplained.add(dir);
// 通用句整局一次;之后每个新方向只给方位句(渐短,原则 9)
this._say(first ? ['voice/wall', `voice/wall_dir_${dir}`] : [`voice/wall_dir_${dir}`], { kind: 'feedback' });
}
return 'invalid'; // 之后只给闷响 + invalid 回执(原则 7)
}
this.x = nx;
this.y = ny;
this.audio.play('sfx/step', { volume: 0.8 }); // 内容音即回执
this._playAmbient();
this._freshEmpty = null;
this._save();
const key = `${nx},${ny}`;
if (KEY_ROOMS[key] && !this.visited.has(key)) {
this.visited.add(key);
this._progressEvent();
this._say([KEY_ROOMS[key].room]);
} else if (EMPTY_FIRST_VOICE[key] && !this.emptyVisited.has(key)) {
this.emptyVisited.add(key);
this._freshEmpty = key;
this._say([EMPTY_FIRST_VOICE[key]]);
}
return 'silent-ok';
}
/** 单击 = 检查 + 使用(先看能否用,再描述)。 */
_interact() {
this._playAmbient();
const s = this._session;
const roomKey = `${this.x},${this.y}`
+ ((this.x === 2 && this.y === 1) ? `:c${this.cabinetOpen ? 1 : 0}` : '')
+ ((this.x === 0 && this.y === 0) ? `:p${this.powerOn ? 1 : 0}` : '')
+ ((this.x === 2 && this.y === 2) ? `:p${this.powerOn ? 1 : 0}` : '');
const alreadyDescribed = this._describedRoom.has(roomKey);
if (alreadyDescribed) return; // 听过描述的房间:单击 = 重听环境音(渐短档)
const describe = (id, critical = false, onDone) => this._timer(() => {
if (s === this._session) {
this._describedRoom.add(roomKey);
this._say([id], { critical, onDone });
}
}, 800); // 先让环境音进耳朵再播描述
const { x, y } = this;
if (x === 0 && y === 2) { // 储物间:找钥匙(状态一次性,不看 _describedRoom)
if (!this.hasKey && !this.cabinetOpen) {
this.hasKey = true;
this._progressEvent();
this.audio.play('sfx/pickup');
vibrate('pickup');
this._timer(() => {
if (s !== this._session) return;
this._say(['voice/found_key'], {
onDone: () => this._playGoalDir(STAGES.cabinet.target, 'voice/gd_cabinet_dir', 'voice/gd_room_r'),
});
}, 800);
} else describe('voice/empty_2');
} else if (x === 2 && y === 1) { // 铁柜房:单击 = 开柜(拿钥匙时)
if (this.hasKey && !this.cabinetOpen) {
this._unlockCabinet();
} else describe(this.cabinetOpen ? 'voice/empty_1' : 'voice/inspect_cabinet_locked');
} else if (x === 0 && y === 0) { // 电箱房:单击 = 修电箱(拿保险丝时)
if (this.hasFuse && !this.powerOn) {
this._fixBox();
} else describe(this.powerOn ? 'voice/box_fixed' : 'voice/inspect_box_dead');
} else if (x === 2 && y === 2) { // 大门:单击 = 开门(通电时)
if (this.powerOn) {
this._escape();
} else describe('voice/inspect_gate');
} else if (x === 1 && y === 1) {
describe('voice/start_room');
} else {
const key = `${x},${y}`;
if (this._freshEmpty !== key) describe(EMPTY_FIRST_VOICE[key] || 'voice/empty_3');
}
return 'silent-ok'; // 环境音/描述即内容反馈
}
_unlockCabinet() {
this.cabinetOpen = true;
this.hasKey = false;
this.hasFuse = true;
this._progressEvent();
this.audio.play('sfx/unlock');
this.audio.play('sfx/door_creak', { volume: 0.7 });
vibrate('success');
this._say(['voice/unlock_cabinet', 'voice/gd_next_fuse'], {
onDone: () => this._playGoalDir(STAGES.box.target, 'voice/gd_box_dir', 'voice/gd_room_l'),
});
}
_fixBox() {
this.powerOn = true;
this.hasFuse = false;
this._progressEvent();
this.audio.play('sfx/spark');
this.audio.play('sfx/power_on', { volume: 0.8 });
vibrate('success');
this._say(['voice/fix_box'], {
onDone: () => this._playGoalDir(STAGES.gate.target, 'voice/gd_gate_dir', 'voice/gd_room_r'),
});
}
_roomDirVoice([tx, ty]) {
const dx = tx - this.x;
const dy = ty - this.y;
if (dx === 0 && dy === 0) return null;
if (dx !== 0) return dx < 0 ? 'voice/gd_room_l' : 'voice/gd_room_r';
return dy < 0 ? 'voice/gd_room_u' : 'voice/gd_room_d';
}
_playGoalDir(target, namedId, expectedId) {
const dir = this._roomDirVoice(target);
if (!dir) return;
this._say([namedId && dir === expectedId ? namedId : dir]);
}
/** 当前谜题链阶段。 */
_stage() {
if (!this.hasKey && !this.cabinetOpen) return 'key';
if (!this.cabinetOpen) return 'cabinet';
if (!this.powerOn) return 'box';
return 'gate';
}
_ambientId() {
const key = `${this.x},${this.y}`;
if (key === '2,2' && this.powerOn) return 'sfx/amb_gate_live';
if (KEY_ROOMS[key]) return KEY_ROOMS[key].amb;
if (key === '1,1') return 'sfx/amb_drip';
return EMPTY_AMB[key] || 'sfx/amb_wind';
}
_playAmbient() {
this.audio.play(this._ambientId(), { volume: 0.9 });
}
/** 探索期说话(房间描述/归因/帮助):SPEAKING → 播完回 ACTION。 */
_say(ids, { critical = false, kind = 'content', onDone } = {}) {
this._lastDescribe = ids[0].startsWith('voice/') ? ids[0] : this._lastDescribe;
this.floor.speak(ids, {
critical, kind,
onDone: () => {
if (this.phase === Phase.PLAY) this.floor.enterAction();
if (onDone) onDone();
},
});
}
// ---------- 迷茫接管(§4.5 三级:提示 → 缩小 → 演示) ----------
_takeover(level) {
if (this.phase !== Phase.PLAY) return;
if (level === 1) {
// L1:说目标方位(下一步的一个动作;30s 内说过就只给提示音——原则 9)
const target = STAGES[this._stage()].target;
const nav = this._navVoices(target);
if (nav[0] && !hintSaidRecently(nav[0])) {
markHintSaid(nav[0]);
this._say(nav, { kind: 'hint' });
} else {
this.floor.chime();
}
} else if (level === 2) {
// L2:只放行第一个导航方向
const nav = this._navVoices(STAGES[this._stage()].target);
const dirVoice = nav[0];
this._restrictDir = dirVoice === 'voice/nav_left' ? G.LEFT
: dirVoice === 'voice/nav_right' ? G.RIGHT
: dirVoice === 'voice/nav_up' ? G.UP : G.DOWN;
const ONLY = { up: 'voice/sys_only_up', down: 'voice/sys_only_down',
left: 'voice/sys_only_left', right: 'voice/sys_only_right' };
const onlyId = ONLY[this._restrictDir];
markHintSaid(onlyId);
this._say([onlyId], { kind: 'hint' });
} else if (level === 3) {
// L3:听我做一次 —— 游戏自己朝目标走一步,再交回
this._restrictDir = null;
this._say(['voice/sys_demo'], {
kind: 'hint',
onDone: () => {
if (this.phase !== Phase.PLAY) return;
const [tx, ty] = STAGES[this._stage()].target;
const dx = Math.sign(tx - this.x);
const dy = dx === 0 ? Math.sign(ty - this.y) : 0;
this.x += dx;
this.y += dy;
this.audio.play('sfx/step', { volume: 0.8 });
this._playAmbient();
this._say(['voice/sys_now_you'], { kind: 'hint' });
},
});
}
}
/** 静默喂迷茫检测:40s 首次、之后每 20s(有操作即重排)。 */
_startSilenceFeed() {
this._stopSilenceFeed();
const s = this._session;
const tick = () => {
if (s !== this._session || this.phase !== Phase.PLAY) return;
this.confusion.recordSilence();
this._silenceTimer = setTimeout(tick, SILENCE_REPEAT_MS);
};
this._silenceTimer = setTimeout(tick, SILENCE_FIRST_MS);
}
_stopSilenceFeed() {
if (this._silenceTimer) { clearTimeout(this._silenceTimer); this._silenceTimer = null; }
}
/** 进展事件:迷茫计数清零、限制解除,下段从 L1 重新分级。 */
_progressEvent() {
this.confusion.resetForWaitPoint();
this._restrictDir = null;
}
_navVoices([tx, ty]) {
const ids = [];
const dx = tx - this.x;
const dy = ty - this.y;
if (dx !== 0) ids.push(dx < 0 ? 'voice/nav_left' : 'voice/nav_right');
if (dy !== 0) ids.push(dy < 0 ? 'voice/nav_up' : 'voice/nav_down');
return ids;
}
// ---------- 菜单 / 结算 ----------
_onEndingGesture(g) {
switch (g) {
case G.TAP:
if (!this._settleBusy) this._restart();
return 'ok';
case G.DOWN:
this._openMenu();
return 'ok';
case G.LONGPRESS: {
// 重听结算(结果一句)
this._say(['voice/again']);
return 'ok';
}
default:
return 'invalid';
}
}
_openMenu() {
this.phase = Phase.MENU;
this._menuIdx = 0;
this._stopSilenceFeed();
this.floor.interrupt();
this.audio.play('bgm/menu', { volume: 0.5, fadeMs: 400 });
this.floor.speak(['voice/menu_open', 'voice/menu_item1'], {
onDone: () => this.floor.yieldTurn(this._wp({
id: 'menu', expected: [G.TAP, G.LEFT, G.RIGHT],
full: 'voice/menu_help', short: 'voice/menu_help',
silenceMs: [20000, 45000],
onDemo: () => this.floor.chime(),
})),
});
}
_onMenuGesture(g) {
const items = ['voice/menu_item1', 'voice/menu_item2', 'voice/menu_item3'];
const announce = () => {
this.floor.speak([items[this._menuIdx]], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
};
switch (g) {
case G.TAP:
this._menuIdx = (this._menuIdx + 1) % items.length;
announce();
return 'hold';
case G.LEFT:
this._menuIdx = (this._menuIdx + items.length - 1) % items.length;
announce();
return 'hold';
case G.RIGHT: {
const sel = this._menuIdx;
if (sel === 0) this._restart();
else if (sel === 1) { this.stop(); window.location.href = '../'; }
else this._closeMenu();
return 'ok';
}
case G.LONGPRESS:
this.floor.speak(['voice/menu_help'], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
default:
return 'invalid';
}
}
_closeMenu() {
this.phase = Phase.ENDING;
this.audio.play('bgm/menu', { volume: 0.5 });
this.floor.yieldTurn(this._settleWp()); // 回结算等待,不留安静态(§4.1)
}
_settleWp() {
return this._wp({
id: 'settle', expected: [G.TAP],
full: 'voice/again', short: 'voice/lp_tap',
silenceMs: [20000, 45000],
onDemo: () => this.floor.chime(),
});
}
_escape() {
this.phase = Phase.ENDING;
this._progressEvent();
this._stopSilenceFeed();
vibrate('success');
this.audio.stopBgm(2500);
this._endingBgmStarted = false;
this._settleBusy = true;
const totalSec = Math.floor((Date.now() - this.startElapsedMs) / 1000);
this._saveEscape(totalSec);
this._save(false);
const got = this.ladder.onGameDone();
const tail = ['voice/again'];
if (got) tail.push(this.ladder.teachLine(got));
// §4.6:critical 句必须短于 3 秒——只把「单击再来一次」承诺段标 critical
this.floor.speak(['voice/escape', this._timeBandVoice(totalSec)], {
onDone: () => this.floor.speak(tail, {
critical: true,
onDone: () => { this._startEndingBgm(); this.floor.yieldTurn(this._settleWp()); },
}),
});
this._timer(() => {
this.audio.play('sfx/gate_open');
this.audio.play('sfx/win_chime', { volume: 0.8 });
}, 2600);
this._timer(() => this._startEndingBgm(), 30000); // 资产缺失兜底
}
_startEndingBgm() {
if (this._endingBgmStarted || this.phase !== Phase.ENDING) return;
this._settleBusy = false;
this._endingBgmStarted = true;
this.audio.play('bgm/menu', { volume: 0.5, fadeMs: 1500 });
}
_timeBandVoice(sec) {
if (sec < 120) return 'voice/time_band1';
if (sec < 300) return 'voice/time_band2';
if (sec < 600) return 'voice/time_band3';
return 'voice/time_band4';
}
// ---------- 存档 ----------
_flag(key) {
try { return localStorage.getItem(key) === '1'; } catch (_) { return false; }
}
_save(inProgress) {
try {
localStorage.setItem(SAVE_KEY, JSON.stringify({
inProgress: !!inProgress && this.phase === Phase.PLAY,
x: this.x, y: this.y,
hasKey: this.hasKey, hasFuse: this.hasFuse,
cabinetOpen: this.cabinetOpen, powerOn: this.powerOn,
visited: [...this.visited], emptyVisited: [...this.emptyVisited],
elapsedMs: this.startElapsedMs ? Date.now() - this.startElapsedMs : (this._savedElapsedMs || 0),
}));
} catch (_) { /* 隐私模式 */ }
}
_loadSave() {
try {
const raw = localStorage.getItem(SAVE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (_) {
return null;
}
}
_loadStats() {
try {
return JSON.parse(localStorage.getItem(STATS_KEY)) || { escapes: 0, bestTimeSec: null };
} catch (_) {
return { escapes: 0, bestTimeSec: null };
}
}
_saveEscape(totalSec) {
const stats = this._loadStats();
stats.escapes += 1;
if (stats.bestTimeSec === null || totalSec < stats.bestTimeSec) stats.bestTimeSec = totalSec;
try { localStorage.setItem(STATS_KEY, JSON.stringify(stats)); } catch (_) { /* 隐私模式 */ }
const best = stats.bestTimeSec;
const summary = `已逃脱 ${stats.escapes} 次 / 最快 ${Math.floor(best / 60)}:${String(best % 60).padStart(2, '0')}`;
this._putProgress({
summary,
escapes: stats.escapes,
bestTimeSec: best,
schema: 1,
updatedAt: new Date().toISOString(),
});
}
_putProgress(data) {
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: 'escape', data }),
}).catch(() => { /* 离线静默 */ });
} catch (_) { /* 静默 */ }
}
// ---------- 工具 ----------
_wp(conf) {
return new WaitPoint(conf, { floor: this.floor, confusion: this.confusion });
}
_timer(fn, ms) {
const s = this._session;
const id = setTimeout(() => { if (s === this._session) fn(); }, ms);
this._timers.push(id);
return id;
}
_clearTimers() {
for (const id of this._timers) clearTimeout(id);
this._timers.length = 0;
}
}