/**
* 盲航(space)R6 —— 太空盲飞躲避,纯音频。
* 按 docs/design/26-audio-interaction-playbook.md 深度重构:设计单正文见
* docs/design/04-space-rebuild.md 附录 C,施工记录见 docs/design/26-apply-space.md。
*
* 玩法核心(04 号 §1,不动):同向闪避——听到陨石从哪边来,就向哪边滑,把它拨开。
* 判定窗覆盖呼啸全程(spawn ~ 撞击点 + 250ms 宽限);whoosh 四向资产、前 60s
* 保护期(撞击不掉血 + protect_
归因教学)、死亡归因分方位(hit_ +
* hit_last)、里程碑 10/25/50/100 整句,全部保留。
*
* 交互层(26 号模式):
* - 发言权(§4.1):对局 = Floor ACTION 态(引擎声即状态声,不起等待声);
* 演练指令/归因/里程碑 = SPEAKING(音乐压低跨链);结算链播完 yieldTurn 交出
* settle 等待点(单击 = 再飞一次)。
* - 手势宪法(§4.3 裁定:四向滑动是玩法 → 帮助一律长按):
* 四向 = 闪避(swipeCore,不按阶梯锁);长按 = 「我在哪」一句话(longpressCore,
* 全期关闭旁白快进);单击 = 演练/结算里「继续」、对局里 = 加速(阶段 4 解锁后,
* 未解锁 = invalid + 首次「现在只要滑。」);双击 = 护盾(仅 ladder.stage()>=3
* 的会话,index.html 按现值分档 attach 双击窗口)。
* - 演练(04 §2):删 D0 零操作欢迎,第一句 = D1 指令(critical,3 秒内动手),
* 第一次成功 ≤10s;做错当场重教、2 次后演示通过;等待点 d1~d4 三档提示 +
* 迷茫接管(L2 只认该方向 / L3 演示拨开)。
* - 失败表(04 §0.7 / 原则 15):归因真话(没出手说「没拨到」,禁止说「慢了」);
* 首局前两次失败锁血吸收;第 3 次失败起自动降刷怪。
* - 语音优先级(§4.6 + R5-2 保留):对局语音走游戏层语音队列(_say/_pumpVoice):
* dir 呼喊 = 生存优先可顶断一切;hit_*、hit_last、protect_end 被顶断时登记重排
* 一次(不排长链尾,重播整链先因后果);里程碑/护盾/教学 FIFO;hint/feedback
* 类空闲才播、播不出去就丢。protect_* 教学句去重(R5:上一句在播即跳过)。
* - 新手期:无双击、无加速/护盾开场课(04 §3 废止)。加速 = 第一局结束或大厅
* 全部解锁后教一次;护盾 = 阶段 3 以后(累计 3 局或大厅)单独一课。
* - 断点复述(§4.12):里程碑/受击/stop() 自动存档;回来问继续/重开,续玩先播
* 复述句,再以裸飞缓冲 count-in 慢速陨石(round2 R2 机制复用)。
*
* 状态 × 手势矩阵(新手期,完整版见 04 号附录 C):
* | 状态 | 单击 | 四向滑动 | 长按 | 双击(未解锁) |
* |-------------------|-------------------------|---------------------------|-------------|--------------|
* | 演练指令(critical)| busy 回执 | busy(说完再出陨石) | busy | busy |
* | 演练陨石(轮到你) | invalid+「向声音那边滑」| 同向=拨开;错向=纠正重来 | 帮助(不闭窗)| 当单击 |
* | 对局(ACTION) | 未解锁=invalid+首次解释;已解锁=加速/冷却拒绝 | 同向闪避 / 挥空 whiff+invalid | 帮助 | 当单击 |
* | 归因在播(SPEAKING)| 跳过这句 | 打断并闪避 | 打断,帮助 | 当单击 |
* | 结算(轮到你) | 再飞一次 | invalid+解释 | 重听结算 | 当单击 |
* | 菜单 | 确认当前项 | 左右滑换项;上下滑 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';
/**
* 带台账的等待点:L1/L2 接管句播出前先登记(30s 内不原样重播,原则 9)。
* 引擎 WaitPoint.takeover 自己不写台账(hints.js 不动),游戏层子类补上。
*/
class MarkingWaitPoint extends WaitPoint {
takeover(level) {
const line = this.conf.restrictLine || this.conf.short || this.conf.full;
if (level === 1 && this.conf.short) markHintSaid(this.conf.short);
if (level === 2 && line) markHintSaid(line);
super.takeover(level);
}
}
// ---- 方位表:来袭方向 -> 逼近音效 id(闪避手势 = 来袭方向本身,同向) ----
const DIRS = ['left', 'right', 'up', 'down'];
const SWIPES = ['left', 'right', 'up', 'down'];
const APPROACH_SFX = {
left: 'sfx/whoosh_left',
right: 'sfx/whoosh_right',
up: 'sfx/whoosh_up',
down: 'sfx/whoosh_down',
};
// ---- 调参(04 号 §1/§3:判定常量与难度曲线不动) ----
const HULL_MAX = 3; // 撞击上限(保护期后)
const DODGE_GRACE_MS = 250; // 判定宽限:spawn 前/撞击点后各让 250ms(窗口 = 呼啸全程)
const ALARM_LEAD_MS = 1000; // 撞击前多久开始警报(两档:1s / 0.5s)
const SHIELD_DURATION_MS = 6000;
const SHIELD_COOLDOWN_MS = 15000;
const DENY_DEBOUNCE_MS = 4000; // 护盾/加速不可用应答(语音+震动)共用去抖(R5)
const BOOST_DURATION_MS = 1800;
const BOOST_COOLDOWN_MS = 3000;
const RAMP_MS = 120000; // 难度爬升到满所需时长(保护期后起算)
const PROTECTION_MS = 60000; // 保护期
const PROTECT_APPROACH_MS = 4000;
const PROTECT_SPAWN_MS = 4000;
const BARE_GRACE_METEORS = 3; // 保护期结束/恢复对局后前 N 颗保持慢节奏(不断崖 + count-in)
const DRILL_APPROACH_MS = 4500; // 演练陨石逼近(慢速、无伤害)
const PROTECT_SOON_MS = 50000; // 保护期还剩 10s 预告
const NOVICE_ABSORB = 2; // 原则 15:首局前两次失败锁血吸收
const EASE_FACTOR = 1.35; // 第 3 次失败起自动降难度(刷怪间隔 ×,逼近 +400ms)
const EASE_APPROACH_BONUS_MS = 400;
const TEACH_BOOST_AT_MS = 6000; // 加速解锁教学句时刻(对局开始后;不暂停刷怪)
const TEACH_SHIELD_AT_MS = 10000; // 护盾解锁教学句时刻(单独一课,不与加速同句)
/** 难度插值:t = 保护期结束后已存活 ms,返回 [0,1] */
function ramp(t) { return Math.min(1, Math.max(0, t) / RAMP_MS); }
/** 刷怪间隔:3.2s -> 1.2s(失败降难度另乘 EASE_FACTOR) */
function spawnIntervalMs(t) { return 3200 - 2000 * ramp(t); }
/** 逼近时长:2.8s -> 1.8s(失败降难度另加宽限) */
function approachMs(t) { return 2800 - 1000 * ramp(t); }
// ---- 演练步骤(D1-D4 一句一练;D5 收尾句;04 §2 表) ----
const DRILLS = [
{ dir: 'left', say: 'voice/drill_left', retry: 'voice/drill_retry_left' },
{ dir: 'right', say: 'voice/drill_right', retry: 'voice/drill_retry_right' },
{ dir: 'up', say: 'voice/drill_up', retry: 'voice/drill_retry_up' },
{ dir: 'down', say: 'voice/drill_down', retry: 'voice/drill_retry_down' },
];
// 里程碑:躲过数 -> 播报 id(超出 100 不播)
const MILESTONES = { 10: 'voice/ms_10', 25: 'voice/ms_25', 50: 'voice/ms_50', 100: 'voice/ms_100' };
const MENU_ITEMS = ['voice/menu_resume', 'voice/menu_lobby', 'voice/menu_help'];
const DRILL_DONE_KEY = 'soundgame.space.drillDone';
const LP_TAUGHT_KEY = 'soundgame.space.lpTaught';
const TURN_TAUGHT_KEY = 'soundgame.space.turnTaught';
const ADV_KEY = 'soundgame.space.adv'; // 高级手势教学标记 { boost, shield }
const RUN_KEY = 'soundgame.space.run'; // 断点存档(§4.12)
const BEST_KEY = 'soundgame.space.best'; // { bestTimeSec, bestDodges, runs }
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
export class SpaceGame extends Game {
constructor(audio, root) {
super(audio, root);
// ---- 26 号引擎六模块接线(§4.1/§4.2/§4.3/§4.5/原则 7) ----
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); // 长按=帮助(核心手势),全期不快进(12 号豁免)
this.phase = 'idle'; // idle | reopen | ask | drill | playing | over | menu | leaving
this._session = 0; // 会话令牌:重开/回大厅时使旧计时器与语音链失效
this._timers = [];
this._voiceGate = Promise.resolve(); // voice 播放安全门(见 _voicePlay,round4 R1)
this._origPlay = audio.play.bind(audio);
this.audio.play = (id, opts) => (
typeof id === 'string' && id.startsWith('voice/')
? this._voicePlay(id, opts)
: this._origPlay(id, opts)
);
// 双击窗口分档快照(与 index.html 的条件 attach 同一时点读取):
// ladder.stage()>=3 = 双击已识别(累计 3 局或大厅全部解锁),本会话双击 = 护盾。
this._dtRecognized = this.ladder.stage() >= 3;
this._advTaught = this._loadAdvTaught();
this._runExplained = new Set(); // 对局「状态×手势」首次解释(原则 7)
this._vq = []; // 对局语音队列(§4.6,见 _say/_pumpVoice)
this._vqActive = null;
this._goingHome = false; // 「回大厅收尾中」标记(_goHome 幂等 + leaving 态)
this._resetRunState();
}
/** 管道选项(index.html 读):四向滑动 + 长按都是本作核心手势。 */
get PIPELINE_OPTS() { return { swipeCore: true, longpressCore: true }; }
/** 双击窗口分档(index.html 读):true = 双击已识别(默认窗口 + doubletapCore)。 */
get DT_CORE() { return this._dtRecognized; }
_resetRunState() {
this.hits = 0;
this.dodges = 0;
this.runStart = 0;
this.protectionUntil = 0;
this._protectAnnounced = false;
this._protectSoonAnnounced = false;
this.swipes = [];
this.obstacles = new Set();
this.drillOb = null;
this.drillIdx = -1;
this.drillFails = 0;
this.shieldUpUntil = 0;
this.shieldReadyAt = 0;
this.boostUntil = 0;
this.boostReadyAt = 0;
this._menuIdx = 0;
this._bareCount = 0; // 慢节奏裸飞陨石计数(断崖过渡/恢复缓冲/count-in)
this._denyAt = -Infinity; // 护盾/加速不可用应答共用去抖(R5)
this._restrictRunDir = null; // 迷茫 L2:对局只放行的方向(04 §0.5)
this._noviceAbsorb = 0; // 首局前两次失败锁血(原则 15)
this._failCount = 0; // 本局失败总数(含锁血吸收)
this._eased = false; // 第 3 次失败起自动降难度
this._goalFired = false; // 第一个小目标(第一次成功闪避)已上报阶梯
this._settleIds = null; // 结算成绩链(长按/双击重听用)
// F6:警报去重 / danger 去抖时间戳(soft/loud 分档 + danger 全局)
this._alarmSoftAt = -Infinity;
this._alarmLoudAt = -Infinity;
this._dangerAt = -Infinity;
}
// ---------------- 生命周期 ----------------
start() {
this.audio.prewarm([
'sfx/whoosh_left', 'sfx/whoosh_right', 'sfx/whoosh_up', 'sfx/whoosh_down',
'sfx/alarm', 'sfx/boost', 'sfx/shield', 'sfx/shield_block',
'sfx/dodge', 'sfx/hit', 'sfx/crash', 'sfx/whiff',
// 对局期可能并发的语音全量预热(voice 播放安全门配套:缓存热 = 令牌落位
// 只需数个 microtask,门内零等待)
'voice/dir_left', 'voice/dir_right', 'voice/dir_up', 'voice/dir_down',
'voice/hit_left', 'voice/hit_right', 'voice/hit_up', 'voice/hit_down', 'voice/hit_last',
'voice/protect_left', 'voice/protect_right', 'voice/protect_up', 'voice/protect_down',
'voice/protect_end', 'voice/protect_soon', 'voice/shield_ready', 'voice/shield_block',
'voice/feat_boost', 'voice/feat_shield', 'voice/feat_shield_done', 'voice/boost_not_ready',
'voice/ms_10', 'voice/ms_25', 'voice/ms_50', 'voice/ms_100',
'voice/lp_run', 'voice/hint_swipe', 'voice/miss',
'voice/d_short_left', 'voice/d_short_right', 'voice/d_short_up', 'voice/d_short_down',
'voice/drill_left', 'voice/drill_right', 'voice/drill_up', 'voice/drill_down',
'voice/drill_retry_left', 'voice/drill_retry_right', 'voice/drill_retry_up', 'voice/drill_retry_down',
'voice/drill_good', 'voice/drill_moveon', 'voice/drill_done',
'voice/second_run_ask', 'voice/resume_ask', 'voice/resume_recap',
'voice/time_1', 'voice/time_2', 'voice/time_3', 'voice/time_4',
'voice/dodge_1', 'voice/dodge_2', 'voice/dodge_3', 'voice/dodge_4',
'voice/new_record', 'voice/restart_hint', 'voice/hint_tap',
'voice/menu_main', 'voice/menu_resume', 'voice/menu_lobby', 'voice/menu_help',
'voice/help_recap', 'voice/menu_lobby',
'voice/sys_say_again', 'voice/sys_turn_teach', 'voice/sys_unlock_down',
'voice/sys_demo', 'voice/sys_now_you', 'voice/lp_teach',
'voice/inv_tap', 'voice/inv_whiff',
]);
this._session++;
this._clearTimers();
this._goingHome = false;
this._vq = [];
this._vqActive = null;
const drilled = this._flag(DRILL_DONE_KEY);
const save = this._loadRun();
if (save && save.inProgress) {
this._startReopen(save); // 断点复述(§4.12):回来问继续/重开
} else if (drilled) {
this._startAsk(); // 回访者:点一下出发(04 §2)
} else {
this._startDrill(); // 首局:第一句就让玩家拨一颗(§4.10)
}
}
stop() {
if (this.phase === 'playing') this._saveRun(); // 任意时刻退出自动存档(原则 16)
this._session++;
this._clearTimers();
this.floor.interrupt();
this._vq = [];
this._vqActive = null;
this.audio.setRateBoostEnabled(true); // 离开游戏恢复默认
this.audio.stopAll();
}
// ---------------- 手势(pipeline 之后) ----------------
onGesture(g) {
switch (this.phase) {
case 'reopen': return this._onReopenGesture(g);
case 'ask': return this._onAskGesture(g);
case 'drill': return this._onDrillGesture(g);
case 'playing': return this._onPlayGesture(g);
case 'over': return this._onOverGesture(g);
case 'menu': return this._onMenuGesture(g);
case 'leaving':
// 回大厅收尾中:单击跳过确认句(链自然推进跳转,看门狗兜底)
if (g === 'tap' && this.audio.isVoicePlaying()) this.audio.skipVoice();
return 'ok';
default:
return 'invalid';
}
}
stateName() {
return this.phase === 'idle' ? 'idle' : this.phase;
}
/**
* 无效操作首次解释(原则 7)。对局(playing)返回 null:对局没有等待点,
* 解释句由游戏层经语音队列自己播(播完回 ACTION),不走 InvalidHints 的
* resumeTurn 路径——否则解释句播完 Floor 落在 BUSY,下一次手势全变 busy。
*/
invalidHint(state, g) {
if (state === 'playing') return null;
if (state === 'drill') return (g === 'tap' || g === 'doubletap') ? 'voice/hint_swipe' : null;
if (state === 'over') return 'voice/restart_hint';
if (state === 'menu') return 'voice/menu_main';
if (state === 'ask') return 'voice/second_run_ask';
if (state === 'reopen') return 'voice/resume_ask';
return null;
}
// ---------------- 回访者 / 断点复述(§4.12) ----------------
_startAsk() {
this._session++;
this._clearTimers();
this.floor.interrupt();
this._resetRunState();
this.phase = 'ask';
markHintSaid('voice/second_run_ask'); // 30s 内等待点被动复述降档(原则 9)
this.floor.speak(['voice/second_run_ask'], {
onDone: () => this.floor.yieldTurn(this._wp({
id: 'ask', expected: ['tap', 'up'],
full: 'voice/second_run_ask', short: 'voice/hint_tap',
silenceMs: [12000, 30000],
onDemo: () => this.floor.chime(),
})),
});
}
_onAskGesture(g) {
switch (g) {
case 'tap':
this._startRun();
return 'ok';
case 'up': // 重听演练(04 §2 回访者可重听;up 在本作是玩法方向,不与帮助冲突)
this._startDrill();
return 'ok';
case 'longpress':
this.floor.speak(['voice/second_run_ask'], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
default:
return 'invalid';
}
}
_startReopen(save) {
this._session++;
this._clearTimers();
this.floor.interrupt();
this._resetRunState();
this.phase = 'reopen';
this._pendingSave = save;
markHintSaid('voice/resume_ask');
this.floor.speak(['voice/resume_ask'], {
onDone: () => this.floor.yieldTurn(this._wp({
id: 'reopen', expected: ['tap', 'right'],
full: 'voice/resume_ask', short: 'voice/hint_tap',
silenceMs: [12000, 30000],
onDemo: () => this.floor.chime(),
})),
});
}
_onReopenGesture(g) {
switch (g) {
case 'tap': // 继续上次航程(先复述,再 count-in 慢速陨石)
this._continueRun(this._pendingSave);
return 'ok';
case 'right': // 重新开始
this._clearRunSave();
this._startRun();
return 'ok';
case 'longpress':
this.floor.speak(['voice/resume_ask'], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
default:
return 'invalid';
}
}
// ---------------- D1-D4 交互式演练(04 §2;首 3 分钟 §4.10) ----------------
_startDrill() {
this._session++;
this._clearTimers();
this.floor.interrupt();
this._resetRunState();
this.phase = 'drill';
this.drillIdx = -1;
this._nextDrill();
}
_nextDrill() {
this.drillIdx++;
this.drillFails = 0;
if (this.drillIdx >= DRILLS.length) {
// D5:收尾 → 进保护期(critical:流程驱动句,被打断会卡死流程)
markHintSaid('voice/drill_done');
this.floor.speak(['voice/drill_done'], {
critical: true,
onDone: () => {
try { localStorage.setItem(DRILL_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ }
this._startRun();
},
});
return;
}
// 每步指令先播完(critical:唯一一次规则说明,≤3s),再 spawn 演练陨石(不与 whoosh 叠)
markHintSaid(DRILLS[this.drillIdx].say);
this.floor.speak([DRILLS[this.drillIdx].say], {
critical: true,
onDone: () => this._spawnDrillOb(),
});
}
_spawnDrillOb() {
const d = DRILLS[this.drillIdx];
const now = performance.now();
const ob = { dir: d.dir, impactAt: now + DRILL_APPROACH_MS, spawnAt: now };
this.obstacles.add(ob);
this.drillOb = ob;
this.audio.play(APPROACH_SFX[d.dir]);
// 演练陨石 = 等待点(04 §0.5 d1~d4):三档提示 + 迷茫接管(L2 只认该方向 / L3 演示)
this.floor.yieldTurn(this._wp({
id: `d${this.drillIdx + 1}_${d.dir}`, expected: [d.dir],
full: DRILLS[this.drillIdx].say, short: `voice/d_short_${d.dir}`,
silenceMs: [DRILL_APPROACH_MS + 1500, 15000],
onDemo: () => this._drillDemo(),
}));
this._after(DRILL_APPROACH_MS, () => this._resolveDrill(ob));
}
_onDrillGesture(g) {
const wp = this.floor.waitPoint;
if (g === 'longpress') {
// 帮助(不闭窗,矩阵行 2):一句话,播完回等待态
this.floor.speak(['voice/lp_run'], {
kind: 'hint',
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
}
if (!this.drillOb) return 'invalid'; // 指令/纠错链均为 critical:正常接线走不到这
if (wp && wp.restricted && !wp.allows(g)) return 'invalid'; // 直连(harness)时同样尊重 L2
const d = DRILLS[this.drillIdx];
if (g === d.dir) {
this.obstacles.delete(this.drillOb);
this.drillOb = null;
this.audio.play('sfx/dodge');
this._afterDrillSuccess();
return 'silent-ok'; // dodge 音即反馈
}
if (SWIPES.includes(g)) {
this._drillFail(true); // 错向:当场重教(04 §0.7)
return 'invalid';
}
return 'invalid'; // tap/doubletap:invalid 回执 + 首次「向声音那边滑。」(invalidHint)
}
/** 演练陨石到点未拨开 = 没出手(超时)。 */
_resolveDrill(ob) {
if (!this.obstacles.has(ob) || this.phase !== 'drill') return;
this._drillFail(false);
}
_drillFail(wrongDir) {
if (this.drillOb) {
this.obstacles.delete(this.drillOb);
this.drillOb = null;
}
this.drillFails++;
vibrate('error');
this.confusion.recordFail();
const retry = DRILLS[this.drillIdx].retry;
if (this.drillFails >= 2) {
// 同一步失败 2 次:演示拨开 + 带过(04 §0.6「2 次后演示通过」)。
// 带过句 30s 台账去重(原则 9):连着几步都带过时不原样重播,只演示。
this.audio.play('sfx/dodge');
if (hintSaidRecently('voice/drill_moveon')) { this._nextDrill(); return; }
markHintSaid('voice/drill_moveon');
this.floor.speak(['voice/drill_moveon'], { critical: true, onDone: () => this._nextDrill() });
return;
}
// 纠错句 30s 内不原样重播(原则 9):刚听过就只给回执,同一颗直接重来。
// 没出手先说真话「没拨到」(禁止说慢了,04 §0.7)。
if (hintSaidRecently(retry)) { this._spawnDrillOb(); return; }
markHintSaid(retry);
const chain = wrongDir ? [retry]
: (hintSaidRecently('voice/miss') ? [retry] : ['voice/miss', retry]);
this.floor.speak(chain, { critical: true, onDone: () => this._spawnDrillOb() });
}
/** 迷茫 L3 / 演示通过:游戏自己拨开这颗,再交回(04 §0.5「游戏演示拨开」)。 */
_drillDemo() {
if (this.drillOb) {
this.obstacles.delete(this.drillOb);
this.drillOb = null;
}
this.audio.play('sfx/dodge');
this.floor.speak(['voice/sys_demo'], { critical: true, onDone: () => this._nextDrill() });
}
/** 演练做对:肯定(仅 D1)+ 一次性的两个教学插入(原则 11:做对过的不教第二遍)。 */
_afterDrillSuccess() {
const ids = [];
if (this.drillIdx === 0) {
ids.push('voice/drill_good'); // 「漂亮。」(≤4 字肯定)
this.ladder.onGoal(); // 第一次成功闪避(D1)= 第一个小目标 → 阶梯阶段 1
if (!this._flag(LP_TAUGHT_KEY)) {
ids.push('voice/lp_teach'); // 马上教长按帮助(04 §0.3 阶段 1)
try { localStorage.setItem(LP_TAUGHT_KEY, '1'); } catch (_) { /* 隐私模式 */ }
}
} else if (this.drillIdx === 1 && !this._flag(TURN_TAUGHT_KEY)) {
ids.push('voice/sys_turn_teach'); // 10~30s 窗口:教会「轮到你」这个状态(§4.10 模板)
try { localStorage.setItem(TURN_TAUGHT_KEY, '1'); } catch (_) { /* 隐私模式 */ }
}
if (!ids.length) { this._nextDrill(); return; }
this.floor.speak(ids, { critical: true, onDone: () => this._nextDrill() });
}
// ---------------- 对局 ----------------
_startRun() {
this._session++;
this._clearTimers();
this.floor.interrupt();
this._resetRunState();
this._vq = [];
this._vqActive = null;
this.phase = 'playing';
this.runStart = performance.now();
this.protectionUntil = this.runStart + PROTECTION_MS;
this._noviceAbsorb = this._runs() === 0 ? NOVICE_ABSORB : 0; // 原则 15:首局锁血
this.floor.enterAction(); // 对局 = ACTION 态(玩法声即状态,不起等待声)
this.audio.play('bgm/engine');
this._scheduleSpawn(1500); // 首颗稍缓,给玩家进入状态
this._after(PROTECT_SOON_MS, () => this._announceProtectionSoon());
this._after(PROTECTION_MS, () => this._announceProtectionEnd());
this._maybeScheduleTeach();
this._saveRun();
}
/** 断点续玩(§4.12):恢复计时/成绩,先播复述句,再 count-in 慢速陨石。 */
_continueRun(save) {
this._session++;
this._clearTimers();
this.floor.interrupt();
this._resetRunState();
this._vq = [];
this._vqActive = null;
this.phase = 'playing';
const now = performance.now();
const elapsed = Math.max(0, save.elapsedMs || 0);
this.runStart = now - elapsed;
this.dodges = save.dodges || 0;
this.hits = save.hits || 0;
this._noviceAbsorb = save.noviceAbsorb || 0;
this._failCount = save.failCount || 0;
this._eased = !!save.eased;
const remain = PROTECTION_MS - elapsed;
this.protectionUntil = remain > 0 ? now + remain : now;
this._protectAnnounced = remain <= 0;
this._protectSoonAnnounced = elapsed >= PROTECT_SOON_MS;
this._bareCount = 0; // count-in:前 BARE_GRACE_METEORS 颗慢速(复用恢复缓冲机制)
this.floor.enterAction();
this.audio.play('bgm/engine');
// 复述句播完才开始 count-in(04 §0.9:先复述,再恢复刷怪)
this._say(['voice/resume_recap'], {
kind: 'content',
onDone: () => { if (this.phase === 'playing') this._scheduleSpawn(1200); },
});
if (!this._protectAnnounced) this._after(this.protectionUntil - now, () => this._announceProtectionEnd());
if (!this._protectSoonAnnounced) {
const soonIn = this.runStart + PROTECT_SOON_MS - now;
if (soonIn > 0) this._after(soonIn, () => this._announceProtectionSoon());
}
this._maybeScheduleTeach();
}
_inProtection() {
return performance.now() < this.protectionUntil;
}
_onPlayGesture(g) {
const now = performance.now();
let r;
switch (g) {
case 'up': case 'down': case 'left': case 'right': {
// 迷茫 L2:只放行最近一颗的正确方向(其余 invalid 回执,不产生效果)
if (this._restrictRunDir && g !== this._restrictRunDir) { r = 'invalid'; break; }
this.swipes.push({ dir: g, t: now });
this._trimSwipes(now);
r = this._whiff(g); // 同向 = ok(撞击点 dodge 音确认);挥空 = invalid + whiff 音
break;
}
case 'longpress':
this._longpressHelp();
r = 'ok';
break;
case 'tap':
r = this._tapAction(now);
break;
case 'doubletap':
r = this._doubletapAction(now);
break;
default:
r = 'invalid';
}
// 对局收尾:手势处理完且没起新链 → 回 ACTION;被手势打断的队列包作废(原则 5)
if (this.phase === 'playing' && !this.floor.speaking) {
if (this._vqActive) this._vqActive = null;
this.floor.enterAction();
this._pumpVoice();
}
return r;
}
/** 长按 = 我在哪(原则 3:位置 → 目标 → 一个动作,≤20 字,经语音队列不顶呼喊)。 */
_longpressHelp() {
this._say(['voice/lp_run'], { kind: 'hint' });
}
/** 挥空(04 §0.7):whiff 音 + invalid 回执 + 首次解释「现在没有这边的陨石。」 */
_whiff(dir) {
if ([...this.obstacles].some((ob) => ob.dir === dir)) return 'ok';
this.audio.play('sfx/whiff', { volume: 0.55 });
this._explainOnceRun('whiff', 'voice/inv_whiff');
return 'invalid';
}
/** 对局单击:未解锁加速 = invalid + 首次「现在只要滑。」;已解锁 = 加速 / 冷却拒绝。 */
_tapAction(now) {
if (!this._boostOn()) {
this._explainOnceRun('tap', 'voice/inv_tap');
return 'invalid';
}
if (now >= this.boostReadyAt) {
this.boostUntil = now + BOOST_DURATION_MS;
this.boostReadyAt = now + BOOST_COOLDOWN_MS;
this.audio.play('sfx/boost');
vibrate('confirm'); // 激活确认(04 §5)
return 'silent-ok'; // boost 音即反馈
}
this._deny(now, 'voice/boost_not_ready'); // R5-4:冷却拒绝必须出声
return 'invalid';
}
/** 对局双击 = 护盾(仅双击已识别的会话);未解锁折算成单击语义。 */
_doubletapAction(now) {
if (!this._shieldOn()) return this._tapAction(now);
if (now >= this.shieldReadyAt) {
this.shieldUpUntil = now + SHIELD_DURATION_MS;
this.shieldReadyAt = now + SHIELD_COOLDOWN_MS;
this.audio.play('sfx/shield');
vibrate('confirm'); // 激活确认(04 §5)
this._scheduleShieldReady();
return 'silent-ok'; // shield 音即反馈
}
this._deny(now, 'voice/feat_shield_done'); // 不可用必须有回音(round4 R1)
return 'invalid';
}
/** 加速解锁:第一局结束或大厅全部解锁(04 §4)。 */
_boostOn() { return this._runs() >= 1 || this.ladder.stage() >= 3; }
/** 护盾解锁:双击已识别(累计 3 局或大厅全部解锁,04 §0.3/§4)。 */
_shieldOn() { return this._dtRecognized; }
/**
* 高级手势教学(04 §4):解锁后教一次(跨局持久),一句 + 马上能用——不暂停
* 刷怪、不插保护期课(§3 废止),经语音队列在空闲点播出。
*/
_maybeScheduleTeach() {
if (!this._advTaught.boost && this._boostOn()) {
this._after(TEACH_BOOST_AT_MS, () => {
if (this.phase !== 'playing' || this._advTaught.boost) return;
this._advTaught.boost = true;
this._saveAdvTaught();
this._say(['voice/feat_boost'], { kind: 'content' });
});
}
if (!this._advTaught.shield && this._shieldOn()) {
this._after(TEACH_SHIELD_AT_MS, () => {
if (this.phase !== 'playing' || this._advTaught.shield) return;
this._advTaught.shield = true;
this._saveAdvTaught();
this._say(['voice/feat_shield'], { kind: 'content' });
});
}
}
/**
* 无效手势轻反馈(归因真话,R5 保留)。对局层「状态×手势」首次解释(原则 7):
* 不走 InvalidHints(无等待点可 resume,见 invalidHint 注释),经语音队列播完回 ACTION。
*/
_explainOnceRun(key, voiceId) {
if (this._runExplained.has(key)) return;
this._runExplained.add(key);
if (hintSaidRecently(voiceId)) return; // 同句音频 30s 内不重播(原则 9)
markHintSaid(voiceId);
this._say([voiceId], { kind: 'feedback' });
}
/** 护盾/加速不可用的即时应答:HEARTBEAT 震 + 语音,4s 共用去抖(R5)。 */
_deny(now, voiceId) {
if (now - this._denyAt < DENY_DEBOUNCE_MS) return;
this._denyAt = now;
vibrate('heartbeat');
this._say([voiceId], { kind: 'feedback' });
}
/** 护盾充能完毕播报 + PICKUP 震动(仅已解锁玩家,04 §4)。 */
_scheduleShieldReady(delay = SHIELD_COOLDOWN_MS) {
this._after(delay, () => {
if (this.phase === 'playing' && this._shieldOn()) {
vibrate('pickup');
this._say(['voice/shield_ready'], { kind: 'content' });
}
});
}
// ---------------- 对局语音队列(§4.6 优先级 + R5-2 防顶断登记) ----------------
/**
* 对局期所有语音经此队列(一条链一条链播,引擎只播一条):
* - interrupt(dir 呼喊):生存优先,立刻顶断当前链;被顶断的 defend 包
* (hit_*、hit_last、protect_end)整链重排一次(R5-2:登记残留补播,先因后果);
* 其余内容包(lp/里程碑/教学)作废——针对旧局面(原则 5)。
* - defend(归因/保护结束/最后一命):排在呼喊之后、其他内容之前;FIFO 互不插队。
* - 其余(里程碑/护盾/教学/帮助):FIFO 尾;hint/feedback 空闲才播、过期即丢。
* 玩家手势打断链(滑动打断并闪避):_onPlayGesture 收尾把 _vqActive 作废(原则 5)。
*/
_say(ids, opts = {}) {
const p = {
ids,
kind: opts.kind || 'content',
defend: !!opts.defend,
interrupt: !!opts.interrupt,
protect: !!opts.protect,
onDone: opts.onDone || null,
replayed: false,
};
if (p.interrupt) {
const old = this._vqActive;
if (old && old.defend && !old.replayed) this._vq.unshift({ ...old, replayed: true });
this._vqActive = null; // 旧链由下面 pump 的 floor.speak 顶断
this._vq.unshift(p);
} else if (p.defend) {
let i = 0; // 排在已有 defend 之后(先因后果,FIFO)
while (i < this._vq.length && this._vq[i].defend) i++;
this._vq.splice(i, 0, p);
} else {
this._vq.push(p);
}
this._pumpVoice();
}
_pumpVoice() {
if (!this._vq.length) return;
const p = this._vq[0];
if (this._vqActive && !p.interrupt) return; // 有包在播:只有呼喊能顶断
this._vq.shift();
this._vqActive = p;
this.floor.speak(p.ids, {
kind: p.kind,
onDone: () => {
if (this._vqActive === p) this._vqActive = null;
if (p.onDone) p.onDone();
if (this.phase === 'playing' && !this.floor.speaking) this.floor.enterAction();
this._pumpVoice();
},
});
}
// ---------------- 保护期 ----------------
/** R5:50s 预告(hint 类,空闲才播;04 §0.8)。 */
_announceProtectionSoon() {
if (this._protectSoonAnnounced || this.phase !== 'playing') return;
this._protectSoonAnnounced = true;
this._say(['voice/protect_soon'], { kind: 'hint' });
}
/** 保护结束宣告:走防顶断登记(04 §0.8:protect_end 顶断后补播一次)。 */
_announceProtectionEnd() {
if (this._protectAnnounced || this.phase !== 'playing') return;
this._protectAnnounced = true;
vibrate('warning');
this._say(['voice/protect_end'], { kind: 'feedback', defend: true });
this._saveRun();
}
/**
* 保护期吸收的教学句(04 §3):上一句在播即跳过(R5 去重,每句完整送达);
* 同方位 30s 台账去重(原则 9:被动原句重复 = 0)——被跳过的那次仍有
* shield_block 音 + WARNING 震落位。
*/
_protectSay(dir) {
const id = `voice/protect_${dir}`;
const busy = (this._vqActive && this._vqActive.protect)
|| this._vq.some((p) => p.protect);
if (busy || hintSaidRecently(id)) return;
markHintSaid(id);
this._say([id], { kind: 'feedback', protect: true });
}
// ---------------- 陨石调度 ----------------
_difficultyNow() {
return performance.now() - this.protectionUntil; // 保护期后为正
}
_scheduleSpawn(delay) {
this._after(delay, () => {
if (this.phase !== 'playing') return;
this._spawnObstacle();
const slow = this._inProtection() || this._bareCount < BARE_GRACE_METEORS;
const base = slow ? PROTECT_SPAWN_MS : spawnIntervalMs(this._difficultyNow());
this._scheduleSpawn(this._eased ? base * EASE_FACTOR : base);
});
}
_spawnObstacle() {
const dir = DIRS[Math.floor(Math.random() * DIRS.length)];
const now = performance.now();
const inProt = this._inProtection();
// 断崖过渡/count-in:慢节奏裸飞陨石不收缩;失败降难度再加宽(原则 15)
const grace = !inProt && this._bareCount < BARE_GRACE_METEORS;
let approach = inProt || grace ? PROTECT_APPROACH_MS : approachMs(this._difficultyNow());
if (this._eased && !inProt && !grace) approach += EASE_APPROACH_BONUS_MS;
const impactAt = now + approach;
const ob = { dir, impactAt, spawnAt: now };
this.obstacles.add(ob);
if (!inProt) this._bareCount++;
this.audio.play(APPROACH_SFX[dir]);
// dir_ 超短方向呼喊与呼啸同窗直出(round4 R1:反应窗口起点);撞击点落在
// 保护期内的不播(protect_ 长句教学优先,呼喊会把它顶断)。经语音队列:
// 生存优先顶断(defend 包自动登记重排,R5-2)。
if (impactAt > this.protectionUntil) {
this._say([`voice/dir_${dir}`], { kind: 'content', interrupt: true });
}
// 警报两档渐强,DANGER 震动同步叠加(F6:同档 600ms 去重 + danger ≥400ms 去抖)
this._after(impactAt - ALARM_LEAD_MS - now, () => {
if (this.obstacles.has(ob)) this._alarm(0.5);
});
this._after(impactAt - ALARM_LEAD_MS / 2 - now, () => {
if (this.obstacles.has(ob)) this._alarm(1);
});
this._after(impactAt - now, () => this._resolve(ob));
}
/** 一段警报:同档 600ms 内只起一波(F6);danger 震全局限 ≥400ms 间隔。 */
_alarm(volume) {
const now = performance.now();
const key = volume < 1 ? '_alarmSoftAt' : '_alarmLoudAt';
if (now - this[key] < 600) return;
this[key] = now;
if (now - this._dangerAt >= 400) {
this._dangerAt = now;
vibrate('danger');
}
this.audio.play('sfx/alarm', { volume });
}
/** 撞击点判定:加速增益 > 同向滑动 > 护盾 > 新手锁血/保护期训练护盾 > 撞击。 */
_resolve(ob) {
if (!this.obstacles.has(ob) || this.phase !== 'playing') return;
this.obstacles.delete(ob);
this._restrictRunDir = null; // 迷茫 L2 随最近一颗的结算解除
const now = performance.now();
// 1) 加速增益期间:冲过空隙
if (now <= this.boostUntil + DODGE_GRACE_MS) {
this._onDodge();
return;
}
// 2) 同向滑动拨开:呼啸响起(spawn)到撞击点 + 宽限之间的同向滑都有效(round4 R1)
const hit = this.swipes.find((s) =>
s.dir === ob.dir
&& s.t >= (ob.spawnAt || 0) - DODGE_GRACE_MS
&& s.t - ob.impactAt <= DODGE_GRACE_MS);
if (hit) {
this.swipes.splice(this.swipes.indexOf(hit), 1); // 一次滑动只躲一颗
this._onDodge();
return;
}
// 3) 护盾挡下
if (now <= this.shieldUpUntil) {
this.shieldUpUntil = 0;
this.audio.play('sfx/shield_block');
vibrate('success');
this._say(['voice/shield_block'], { kind: 'feedback' });
return;
}
// 4) 新手锁血(首局前两次失败不扣资源,原则 15):归因照说真话(hit_),只是不掉血
if (!this._inProtection() && this._noviceAbsorb > 0) {
this._noviceAbsorb--;
this._failCount++;
this._easeMaybe();
this.audio.play('sfx/shield_block');
vibrate('warning');
this.confusion.recordFail();
this._say([`voice/hit_${ob.dir}`], { kind: 'feedback', defend: true });
return;
}
// 5) 保护期训练护盾:吸收不掉血 + 分方位教学(04 §3)
if (this._inProtection()) {
this._failCount++;
this._easeMaybe();
this.audio.play('sfx/shield_block');
vibrate('warning');
this._protectSay(ob.dir);
return;
}
// 6) 实打实撞上:HIT 震动 + 分方位归因(第 2 次整链接「最后一命」)
this.hits += 1;
this._failCount++;
this._easeMaybe();
this.audio.play('sfx/hit');
vibrate('hit');
this.confusion.recordFail();
if (this.hits >= HULL_MAX) {
this._gameOver();
return;
}
const ids = [`voice/hit_${ob.dir}`];
if (this.hits === 2) ids.push('voice/hit_last');
this._say(ids, { kind: 'feedback', defend: true }); // 被呼喊顶断时整链重排一次(R5-2)
this._saveRun();
}
/** 第 3 次失败起自动降难度(原则 15 / 04 §0.7「第 3 次可降刷怪」)。 */
_easeMaybe() {
if (!this._eased && this._failCount >= 3) this._eased = true;
}
_onDodge() {
this.dodges += 1;
this.audio.play('sfx/dodge');
if (!this._goalFired) {
this._goalFired = true;
this.ladder.onGoal(); // 第一个小目标(第一次成功闪避)→ 阶梯阶段 1
// space:up 是玩法方向、帮助在长按——不播 sys_unlock_up(教长按已在演练做)
}
this.confusion.resetForWaitPoint(); // 进展事件:迷茫计数清零,下段从 L1 重新分级
const ms = MILESTONES[this.dodges];
if (ms) {
vibrate('confirm');
this._say([ms], { kind: 'content' });
this._saveRun(); // 里程碑存档(§4.12)
}
}
// ---------------- 迷茫接管(§4.5 三级:提示 → 缩小 → 演示) ----------------
_takeover(level) {
if (this.phase === 'drill') return; // 演练等待点自己接管(wp.takeover)
if (this.phase !== 'playing') return;
const once = (id, ids) => {
// 30s 台账去重(原则 9:说过就只给交接提示音)
if (hintSaidRecently(id)) { this.floor.chime(); return; }
markHintSaid(id);
this._say(ids, { kind: 'hint' });
};
if (level === 1) {
// L1:只说下一步的一个动作(04 §0.5 短句)
once('voice/hint_swipe', ['voice/hint_swipe']);
} else if (level === 2) {
// L2:只认当前最近一颗的正确方向
const ob = this._nearestOb();
if (!ob) return;
this._restrictRunDir = ob.dir;
once(`voice/d_short_${ob.dir}`, [`voice/d_short_${ob.dir}`]);
} else if (level === 3) {
// L3:自动拨开最近一颗(04 §0.5:保护期或不扣血演示),再把话轮交回
this._restrictRunDir = null;
const ob = this._nearestOb();
if (!ob) return;
this.obstacles.delete(ob);
this.audio.play('sfx/dodge');
once('voice/sys_demo', ['voice/sys_demo', 'voice/sys_now_you']);
}
}
_nearestOb() {
let best = null;
for (const ob of this.obstacles) {
if (!best || ob.impactAt < best.impactAt) best = ob;
}
return best;
}
// ---------------- 菜单(00 §5.4:左右滑切换 + 单击确认;阶段 2 后,结算呼出) ----------------
_openMenu() {
this.phase = 'menu';
this._clearTimers(); // 模态暂停:停刷怪调度(结算态本就已停)
this._vq = [];
this._vqActive = null;
this._menuIdx = 0;
this.floor.interrupt();
this.audio.play('bgm/menu', { volume: 0.5, fadeMs: 300 });
markHintSaid('voice/menu_main');
this.floor.speak(['voice/menu_main', MENU_ITEMS[0]], {
onDone: () => this.floor.yieldTurn(this._wp({
id: 'menu', expected: ['tap', 'left', 'right'],
full: 'voice/menu_main', short: 'voice/menu_main',
silenceMs: [20000, 45000],
onDemo: () => this.floor.chime(),
})),
});
}
_onMenuGesture(g) {
const announce = () => {
this.floor.speak([MENU_ITEMS[this._menuIdx]], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
};
switch (g) {
case 'tap': // 确认当前项
if (this._menuIdx === 0) { this._startRun(); return 'ok'; } // 继续飞行
if (this._menuIdx === 1) { this._goHome(); return 'ok'; } // 回大厅
this.floor.speak(['voice/help_recap'], { // 教学要点
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
case 'left':
this._menuIdx = (this._menuIdx + MENU_ITEMS.length - 1) % MENU_ITEMS.length;
announce();
return 'hold';
case 'right':
this._menuIdx = (this._menuIdx + 1) % MENU_ITEMS.length;
announce();
return 'hold';
case 'longpress':
case 'doubletap': // 重听菜单说明(双击=重听,阶段 3 语义)
this.floor.speak(['voice/menu_main'], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
default:
return 'invalid';
}
}
/**
* 回大厅(rhythm 同款收尾):leaving 收尾态停刷怪/BGM,播确认句,播完
*(或 2.5s 看门狗兜底)再跳转——不落无声死界面(round2 R2)。
*/
_goHome() {
if (this._goingHome) return;
this._goingHome = true;
this.phase = 'leaving';
this._clearTimers();
this.obstacles.clear();
this._vq = [];
this._vqActive = null;
this.audio.stopBgm(300);
const leave = () => {
if (!this._goingHome) return;
this._goingHome = false;
this.stop();
window.location.href = '../';
};
this.floor.speak(['voice/menu_lobby'], { onDone: leave });
this._after(2500, leave);
}
// ---------------- 坠毁结算(04 §5:整句 + 单击再飞) ----------------
_gameOver() {
this.phase = 'over';
this._clearTimers();
this.obstacles.clear();
this.swipes = [];
this._vq = [];
this._vqActive = null;
this._restrictRunDir = null;
this.audio.stopBgm(600);
this.floor.interrupt(); // 坠毁即掐断警告链,结算链干净开场不叠播(round2 R2 B4)
vibrate('error');
this._after(300, () => vibrate('hit'));
this.audio.play('sfx/crash');
this._clearRunSave(); // 本局已结束
const aliveSec = Math.floor((performance.now() - this.runStart) / 1000);
const isRecord = this._saveScore(aliveSec);
const got = this.ladder.onGameDone(); // 一局结束 → 阶段 2/3(space 只教 down=菜单)
// 给爆炸声留 1.2s 再开口
this._after(1200, () => {
if (isRecord) vibrate('success');
const head = [this._timeBandVoice(aliveSec)];
if (isRecord) head.push('voice/new_record');
head.push(this._dodgeBandVoice(this.dodges));
this._settleIds = [...head];
markHintSaid('voice/restart_hint'); // 结算等待点 full:20s 被动复述降档(原则 9)
const tail = ['voice/restart_hint'];
if (got === 'down') tail.push('voice/sys_unlock_down'); // 阶段 2:下滑=菜单
this.floor.speak(head, {
onDone: () => this.floor.speak(tail, {
critical: true, // 承诺段(「单击,再飞一次。」+解锁教学,各 <3s)
onDone: () => {
this.audio.play('bgm/menu', { fadeMs: 800 });
this.floor.yieldTurn(this._settleWp());
},
}),
});
});
}
_settleWp() {
return this._wp({
id: 'settle', expected: ['tap'],
full: 'voice/restart_hint', short: 'voice/hint_tap',
silenceMs: [20000, 45000],
onDemo: () => this.floor.chime(),
});
}
_onOverGesture(g) {
switch (g) {
case 'tap': // 再飞一次
this._startRun();
return 'ok';
case 'down': // 菜单(阶段 2 解锁后;对局中下滑永远是闪避,04 §0.3)
if (this.ladder.has('down')) { this._openMenu(); return 'ok'; }
return 'invalid';
case 'longpress':
case 'doubletap': { // 重听结算(双击=重听,阶段 3 语义)
const ids = ['voice/sys_say_again', ...(this._settleIds || ['voice/restart_hint'])];
this.floor.speak(ids, {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
}
default:
return 'invalid';
}
}
/** 存活时间 4 段整句:<30 / 30~60 / 60~120 / ≥120。 */
_timeBandVoice(sec) {
if (sec < 30) return 'voice/time_1';
if (sec < 60) return 'voice/time_2';
if (sec < 120) return 'voice/time_3';
return 'voice/time_4';
}
/** 躲避数 4 段整句:<5 / 5~14 / 15~49 / ≥50。 */
_dodgeBandVoice(n) {
if (n < 5) return 'voice/dodge_1';
if (n < 15) return 'voice/dodge_2';
if (n < 50) return 'voice/dodge_3';
return 'voice/dodge_4';
}
// ---------------- 存档与成绩 ----------------
_flag(key) {
try { return localStorage.getItem(key) === '1'; } catch (_) { return false; }
}
_loadBest() {
try {
return JSON.parse(localStorage.getItem(BEST_KEY))
|| { bestTimeSec: 0, bestDodges: 0, runs: 0 };
} catch (_) {
return { bestTimeSec: 0, bestDodges: 0, runs: 0 };
}
}
_runs() { return this._loadBest().runs || 0; }
/** 断点存档(§4.12:里程碑/受击/退出时写)。 */
_saveRun() {
try {
localStorage.setItem(RUN_KEY, JSON.stringify({
inProgress: true,
elapsedMs: performance.now() - this.runStart,
dodges: this.dodges,
hits: this.hits,
noviceAbsorb: this._noviceAbsorb,
failCount: this._failCount,
eased: this._eased,
}));
} catch (_) { /* 隐私模式 */ }
}
_loadRun() {
try {
const raw = localStorage.getItem(RUN_KEY);
return raw ? JSON.parse(raw) : null;
} catch (_) {
return null;
}
}
_clearRunSave() {
try { localStorage.removeItem(RUN_KEY); } catch (_) { /* 隐私模式 */ }
}
_loadAdvTaught() {
try { return JSON.parse(localStorage.getItem(ADV_KEY)) || {}; } catch (_) { return {}; }
}
_saveAdvTaught() {
try { localStorage.setItem(ADV_KEY, JSON.stringify(this._advTaught)); } catch (_) { /* 隐私模式 */ }
}
/**
* 结算记分:本地 best 兜底(localStorage),破存活纪录时同步服务器
*(04 §0.9 进度:{ bestSec, totalRuns, summary })。
* @returns {boolean} 是否破存活时间纪录
*/
_saveScore(aliveSec) {
const best = this._loadBest();
best.runs += 1; // totalRuns:加速解锁条件(第一局结束)
const isRecord = aliveSec > best.bestTimeSec;
best.bestTimeSec = Math.max(best.bestTimeSec, aliveSec);
best.bestDodges = Math.max(best.bestDodges, this.dodges);
try { localStorage.setItem(BEST_KEY, JSON.stringify(best)); } catch (_) { /* 隐私模式 */ }
if (!isRecord) return false;
const m = Math.floor(best.bestTimeSec / 60);
const ss = String(best.bestTimeSec % 60).padStart(2, '0');
try {
const auth = JSON.parse(localStorage.getItem(AUTH_KEY));
if (!auth || !auth.token) return true;
fetch('/api/progress', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + auth.token,
},
body: JSON.stringify({
game: 'space',
data: {
summary: `最长存活 ${m}:${ss} / 躲避 ${best.bestDodges} 颗`,
bestTimeSec: best.bestTimeSec,
bestDodges: best.bestDodges,
totalRuns: best.runs,
schema: 1,
updatedAt: new Date().toISOString(),
},
}),
}).catch(() => { /* 离线静默 */ });
} catch (_) { /* 静默 */ }
return true;
}
// ---------------- 工具 ----------------
/**
* voice 播放安全门(round4 R1 活体实测发现):_playVoice 的令牌要等异步加载
* 完成才落位,落位前同 tick 第二个 play() 会漏杀前一个——两条管线并播。
* 本层所有 voice play 走此门串行:等上一次 play 的令牌落位再发下一次。
* 等待是纯 microtask(不挂定时器,虚拟时钟/真浏览器行为一致):对局可能
* 并发的语音全部 prewarm 缓存热;资产缺失等异常路径 64 轮后放行,门绝不卡死。
* 会话令牌失效(重开/回大厅)后未发的门内播放直接作废。
*/
_voicePlay(id, opts = {}) {
const s = this._session;
this._voiceGate = this._voiceGate.then(async () => {
if (s !== this._session) return;
this._origPlay(id, opts);
for (let i = 0; i < 64 && !this.audio.isVoicePlaying(); i++) await Promise.resolve();
});
return this._voiceGate;
}
_wp(conf) {
return new MarkingWaitPoint(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();
}, Math.max(0, ms));
this._timers.push(id);
return id;
}
_clearTimers() {
for (const id of this._timers) clearTimeout(id);
this._timers = [];
}
/** 清掉过期的滑动记录(超过任何可能的判定窗口 = 最长逼近时长 + 宽限)。 */
_trimSwipes(now) {
this.swipes = this.swipes.filter((s) => now - s.t <= PROTECT_APPROACH_MS + DODGE_GRACE_MS + 500);
}
}