按 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。
886 lines
31 KiB
JavaScript
886 lines
31 KiB
JavaScript
/**
|
||
* 《暗室》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;
|
||
}
|
||
}
|