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

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

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

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

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

1279 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 盲航(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_<dir> 归因教学)、死亡归因分方位(hit_<dir> +
* 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_<dir> 超短方向呼喊与呼啸同窗直出(round4 R1:反应窗口起点);撞击点落在
// 保护期内的不播(protect_<dir> 长句教学优先,呼喊会把它顶断)。经语音队列:
// 生存优先顶断(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_<dir>),只是不掉血
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);
}
}