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 置完成。
1279 lines
51 KiB
JavaScript
1279 lines
51 KiB
JavaScript
/**
|
||
* 盲航(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);
|
||
}
|
||
}
|