699 lines
25 KiB
JavaScript
699 lines
25 KiB
JavaScript
/**
|
||
* 盲航(space)R4 —— 太空盲飞躲避,纯音频(按 docs/design/04-space-rebuild.md 实现)。
|
||
*
|
||
* R4 核心变化:
|
||
* - **同向闪避**:听到哪边来就向哪边滑("挥手把陨石拨开"),方位声直达同侧手势,
|
||
* 消除旧版"定位→取反→出手"的推理链。whoosh_* 四资产复用,只改映射。
|
||
* - **D0-D5 交互式演练**:一句一练,演练陨石 4.5s 慢速无伤害;做错 ERROR 震动 +
|
||
* 当场重教(drill_retry_*),同一步失败 2 次 drill_moveon 自动带过不卡流程。
|
||
* 演练期(无演练陨石悬置时)下滑=跳过;二周目开场 second_run_ask 询问。
|
||
* - **前 60s 保护期**:逼近 4.0s / 刷怪 4.0s 恒定;撞击不掉血——训练护盾吸收
|
||
* (shield_block 音 + WARNING 震动 + protect_<dir> 归因句)。60s 播 protect_end
|
||
* 后进入正常难度曲线:刷怪 3.2s→1.2s、逼近 2.8s→1.8s、120s 爬满。
|
||
* - **单击/双击分批引入**:第 20s 暂停刷怪教单击加速(1.8s 持续增益),第 45s 教
|
||
* 双击护盾(6s,30s 充能,shield_ready 语音);8s 内没做不等待直接继续。
|
||
* - **死亡即教学**:保护期后被撞播 hit_<dir> 归因句,第 2 次加 hit_last;
|
||
* 3 次撞击坠毁。结算 time_ 与 dodge_ 分档整句 + new_record + restart_hint。
|
||
* - **里程碑**:躲过 10/25/50/100 颗各一句固定整句(ms_*),CONFIRM 震动。
|
||
* - **菜单**(round2-review 全球约定):下滑呼出(模态暂停)→ 单击循环选项 →
|
||
* 双击确认 → 上滑帮助。下滑与"下滑闪避"冲突的折中:**仅当无下方陨石悬置时**
|
||
* 下滑才呼出菜单(有下方陨石时下滑永远是闪避),见 README。
|
||
* - **状态 BGM**(10 号文档 §6):菜单切 `bgm/menu`(crossfade 300ms)、回对局淡回
|
||
* `bgm/engine`(500ms)、坠毁结算语音链播完淡入 `bgm/menu`(800ms)。
|
||
* - **单击跳过旁白**(12 号文档):演练旁白/feat 引入句/对局归因与里程碑/结算播报
|
||
* 播放中单击 = `skipVoice()` 跳下一句;无语音在播时单击维持原义(加速/重开)。
|
||
* 首局 D0 在 drill_welcome 后插播一次 skip_hint。
|
||
*
|
||
* 震动分工:手势识别反馈引擎内置(swipe/confirm),本层只发语义事件——
|
||
* 撞击 hit / 护盾格挡 success / 警报两档 danger / 护盾就绪 pickup / 里程碑 confirm /
|
||
* 演练做错 error / 保护期吸收 warning / 坠毁 error+hit / 破纪录 success。
|
||
*
|
||
* 语音 id 权威清单:docs/design/04-space-voice-ids.md(52 句双性别);
|
||
* 旧 num 拼接件、n_ 前缀件、intro1-4/hit1/hit2/gameover/restart 全部弃用。
|
||
*/
|
||
import { Game } from '../engine/game.js';
|
||
import { vibrate } from '../engine/haptics.js';
|
||
|
||
// ---- 方位表:来袭方向 -> 逼近音效 id(闪避手势 = 来袭方向本身,同向) ----
|
||
const DIRS = ['left', 'right', 'up', 'down'];
|
||
const APPROACH_SFX = {
|
||
left: 'sfx/whoosh_left',
|
||
right: 'sfx/whoosh_right',
|
||
up: 'sfx/whoosh_up',
|
||
down: 'sfx/whoosh_down',
|
||
};
|
||
|
||
// ---- 调参 ----
|
||
const HULL_MAX = 3; // 撞击上限(保护期后)
|
||
const DODGE_WINDOW_MS = 1500; // 撞击前多久内的正确滑动有效
|
||
const DODGE_GRACE_MS = 250; // 撞击点后允许的迟到滑动宽限
|
||
const ALARM_LEAD_MS = 1000; // 撞击前多久开始警报(两档:1s / 0.5s)
|
||
const SHIELD_DURATION_MS = 6000;
|
||
const SHIELD_COOLDOWN_MS = 30000;
|
||
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 DRILL_APPROACH_MS = 4500; // 演练陨石逼近(慢速、无伤害)
|
||
const BOOST_INTRO_MS = 20000; // 单击引入时刻
|
||
const SHIELD_INTRO_MS = 45000; // 双击引入时刻
|
||
const FEATURE_WAIT_MS = 8000; // 分批引入等待玩家操作上限
|
||
|
||
/** 难度插值:t = 保护期结束后已存活 ms,返回 [0,1] */
|
||
function ramp(t) { return Math.min(1, Math.max(0, t) / RAMP_MS); }
|
||
/** 刷怪间隔:3.2s -> 1.2s */
|
||
function spawnIntervalMs(t) { return 3200 - 2000 * ramp(t); }
|
||
/** 逼近时长:2.8s -> 1.8s */
|
||
function approachMs(t) { return 2800 - 1000 * ramp(t); }
|
||
|
||
// ---- 演练步骤(D1-D4;D0 开场两句、D5 收尾句) ----
|
||
const DRILLS = [
|
||
{ dir: 'left', say: 'voice/drill_left', retry: 'voice/drill_retry_left', good: true },
|
||
{ dir: 'right', say: 'voice/drill_right', retry: 'voice/drill_retry_right', good: false },
|
||
{ dir: 'up', say: 'voice/drill_up', retry: 'voice/drill_retry_up', good: true },
|
||
{ dir: 'down', say: 'voice/drill_down', retry: 'voice/drill_retry_down', good: false },
|
||
];
|
||
|
||
// 里程碑:躲过数 -> 播报 id
|
||
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 BEST_KEY = 'soundgame.space.best';
|
||
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
|
||
|
||
export class SpaceGame extends Game {
|
||
constructor(audio, root) {
|
||
super(audio, root);
|
||
this.phase = 'idle'; // idle | ask | drill | playing | feature | menu | over
|
||
this._session = 0; // 会话令牌:重开/跳过时使旧计时器与语音链失效
|
||
this._timers = [];
|
||
this._resetRunState();
|
||
}
|
||
|
||
_resetRunState() {
|
||
this.hits = 0;
|
||
this.dodges = 0;
|
||
this.runStart = 0;
|
||
this.protectionUntil = 0;
|
||
this._protectAnnounced = 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.boostIntroDone = false;
|
||
this.shieldIntroDone = false;
|
||
this.featureKind = null;
|
||
this._menuIdx = 0;
|
||
}
|
||
|
||
// ---------------- 生命周期 ----------------
|
||
|
||
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',
|
||
'voice/drill_welcome', 'voice/drill_rule', 'voice/second_run_ask',
|
||
'voice/skip_hint',
|
||
]);
|
||
this._session++;
|
||
this._clearTimers();
|
||
const drilled = localStorage.getItem(DRILL_DONE_KEY) === '1';
|
||
if (drilled) {
|
||
// 二周目:上滑重听演练 / 单击直接出发
|
||
this.phase = 'ask';
|
||
this.audio.play('voice/second_run_ask');
|
||
} else {
|
||
this._startDrill(true); // 首局:D0 插播一次 skip_hint
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.stopAll();
|
||
}
|
||
|
||
// ---------------- 手势 ----------------
|
||
|
||
onGesture(g) {
|
||
switch (this.phase) {
|
||
case 'ask':
|
||
if (g === 'up') { this.audio.stopVoice(); this._startDrill(); }
|
||
else if (g === 'tap') { this.audio.stopVoice(); this._startRun(); }
|
||
break;
|
||
case 'drill':
|
||
this._onDrillGesture(g);
|
||
break;
|
||
case 'playing':
|
||
this._onPlayGesture(g);
|
||
break;
|
||
case 'feature':
|
||
this._onFeatureGesture(g);
|
||
break;
|
||
case 'menu':
|
||
this._onMenuGesture(g);
|
||
break;
|
||
case 'over':
|
||
// 结算语音在播时单击 = 跳过本句(链继续),无语音时 = 再飞一次
|
||
if (g === 'tap') {
|
||
if (this._voiceBusy()) this.audio.skipVoice();
|
||
else this._startRun();
|
||
} else if (g === 'down') this._goHome(); // 下滑回大厅(restart_hint 口径)
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ---------------- D0-D5 交互式演练 ----------------
|
||
|
||
_startDrill(withSkipHint = false) {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.stopVoice();
|
||
this._resetRunState();
|
||
this.phase = 'drill';
|
||
// D0:建立预期(无陨石)。首局在 welcome 后插播一次 skip_hint(12 号文档 §3 预留 id)。
|
||
const d0 = withSkipHint
|
||
? ['voice/drill_welcome', 'voice/skip_hint', 'voice/drill_rule']
|
||
: ['voice/drill_welcome', 'voice/drill_rule'];
|
||
this._speakGuarded(d0, () => this._nextDrill());
|
||
}
|
||
|
||
_nextDrill() {
|
||
this.drillIdx++;
|
||
this.drillFails = 0;
|
||
if (this.drillIdx >= DRILLS.length) {
|
||
// D5:收尾 → 进保护期
|
||
this._speakGuarded(['voice/drill_done'], () => {
|
||
try { localStorage.setItem(DRILL_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ }
|
||
this._startRun();
|
||
});
|
||
return;
|
||
}
|
||
// 每步旁白先播完,再 spawn 演练陨石(语音先、音效后,不叠放)
|
||
this._speakGuarded([DRILLS[this.drillIdx].say], () => this._spawnDrillOb());
|
||
}
|
||
|
||
_spawnDrillOb() {
|
||
const d = DRILLS[this.drillIdx];
|
||
const now = performance.now();
|
||
const ob = { dir: d.dir, impactAt: now + DRILL_APPROACH_MS };
|
||
this.obstacles.add(ob);
|
||
this.drillOb = ob;
|
||
this.audio.play(APPROACH_SFX[d.dir]);
|
||
this._after(DRILL_APPROACH_MS, () => this._resolveDrill(ob));
|
||
}
|
||
|
||
_onDrillGesture(g) {
|
||
if (!this.drillOb) {
|
||
// 无演练陨石悬置(旁白/反馈播放中):下滑 = 跳过演练;
|
||
// 旁白在播时单击 = 跳过本句(语音链跳下一句),其余忽略
|
||
if (g === 'down') this._skipDrill();
|
||
else if (g === 'tap' && this._voiceBusy()) this.audio.skipVoice();
|
||
return;
|
||
}
|
||
const d = DRILLS[this.drillIdx];
|
||
if (g === d.dir) {
|
||
// 做对:dodge 音(识别震动引擎内置)+ 短肯定(仅 D1/D3,避免啰嗦)
|
||
this.obstacles.delete(this.drillOb);
|
||
this.drillOb = null;
|
||
this.audio.play('sfx/dodge');
|
||
if (d.good) this._speakGuarded(['voice/drill_good'], () => this._nextDrill());
|
||
else this._nextDrill();
|
||
} else {
|
||
this._drillFail(); // 做错(含超时外的任何错误手势):当场重教
|
||
}
|
||
}
|
||
|
||
/** 演练陨石到点未拨开 = 超时做错。 */
|
||
_resolveDrill(ob) {
|
||
if (!this.obstacles.has(ob) || this.phase !== 'drill') return;
|
||
this._drillFail();
|
||
}
|
||
|
||
_drillFail() {
|
||
if (this.drillOb) {
|
||
this.obstacles.delete(this.drillOb);
|
||
this.drillOb = null;
|
||
}
|
||
this.drillFails++;
|
||
vibrate('error');
|
||
if (this.drillFails >= 2) {
|
||
// 同一步失败 2 次自动演示通过,不卡流程
|
||
this._speakGuarded(['voice/drill_moveon'], () => this._nextDrill());
|
||
} else {
|
||
this._speakGuarded([DRILLS[this.drillIdx].retry], () => this._spawnDrillOb());
|
||
}
|
||
}
|
||
|
||
_skipDrill() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.stopVoice();
|
||
try { localStorage.setItem(DRILL_DONE_KEY, '1'); } catch (_) { /* 隐私模式 */ }
|
||
this._speakGuarded(['voice/drill_skip'], () => this._startRun());
|
||
}
|
||
|
||
// ---------------- 对局 ----------------
|
||
|
||
_startRun() {
|
||
this._session++;
|
||
this._clearTimers();
|
||
this.audio.stopVoice(); // 打断结算/菜单语音链,避免残留到下一局
|
||
this._resetRunState();
|
||
this.phase = 'playing';
|
||
this.runStart = performance.now();
|
||
this.protectionUntil = this.runStart + PROTECTION_MS;
|
||
this.audio.play('bgm/engine');
|
||
this._scheduleSpawn(1500); // 首颗稍缓,给玩家进入状态
|
||
this._after(BOOST_INTRO_MS, () => this._beginFeature('boost'));
|
||
this._after(SHIELD_INTRO_MS, () => this._beginFeature('shield'));
|
||
this._after(PROTECTION_MS, () => this._announceProtectionEnd());
|
||
}
|
||
|
||
_inProtection() {
|
||
return performance.now() < this.protectionUntil;
|
||
}
|
||
|
||
_onPlayGesture(g) {
|
||
const now = performance.now();
|
||
switch (g) {
|
||
case 'up': case 'left': case 'right':
|
||
this.swipes.push({ dir: g, t: now });
|
||
this._trimSwipes(now);
|
||
break;
|
||
case 'down':
|
||
// 下滑冲突折中:有下方陨石悬置时是闪避,否则呼出菜单
|
||
if ([...this.obstacles].some((ob) => ob.dir === 'down')) {
|
||
this.swipes.push({ dir: g, t: now });
|
||
this._trimSwipes(now);
|
||
} else {
|
||
this._openMenu();
|
||
}
|
||
break;
|
||
case 'tap':
|
||
// 归因/里程碑等播报在播时单击 = 跳过本句(12 号文档裁定:space 对局语音可跳)
|
||
if (this._voiceBusy()) { this.audio.skipVoice(); break; }
|
||
// 加速是持续型增益(~1.8s),期间的撞击一律算冲过空隙,
|
||
// 因此 tap 的 ~300ms 判定延迟不影响手感。第 20s 引入后才解锁。
|
||
if (this.boostIntroDone && now >= this.boostReadyAt) {
|
||
this.boostUntil = now + BOOST_DURATION_MS;
|
||
this.boostReadyAt = now + BOOST_COOLDOWN_MS;
|
||
this.audio.play('sfx/boost');
|
||
}
|
||
break;
|
||
case 'doubletap':
|
||
// 第 45s 引入后才解锁
|
||
if (this.shieldIntroDone && now >= this.shieldReadyAt) {
|
||
this.shieldUpUntil = now + SHIELD_DURATION_MS;
|
||
this.shieldReadyAt = now + SHIELD_COOLDOWN_MS;
|
||
this.audio.play('sfx/shield');
|
||
this._scheduleShieldReady();
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
/** 护盾充能完毕播报 + PICKUP 震动(若仍在这一局)。 */
|
||
_scheduleShieldReady(delay = SHIELD_COOLDOWN_MS) {
|
||
this._after(delay, () => {
|
||
if (this.phase === 'playing') {
|
||
vibrate('pickup');
|
||
this.audio.play('voice/shield_ready');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------- 单击/双击分批引入(F 组) ----------------
|
||
|
||
/** 暂停刷怪,播引入旁白,等玩家操作(8s 上限,不等待直接继续)。 */
|
||
_beginFeature(kind) {
|
||
if (this.phase !== 'playing') return;
|
||
if (kind === 'boost' && this.boostIntroDone) return;
|
||
if (kind === 'shield' && this.shieldIntroDone) return;
|
||
if (kind === 'boost') this.boostIntroDone = true;
|
||
else this.shieldIntroDone = true;
|
||
this.phase = 'feature';
|
||
this.featureKind = kind;
|
||
this._clearTimers(); // 暂停刷怪与进行中陨石
|
||
this.obstacles.clear();
|
||
this.swipes = [];
|
||
this._speak([kind === 'boost' ? 'voice/feat_boost' : 'voice/feat_shield']);
|
||
this._after(FEATURE_WAIT_MS, () => this._endFeature());
|
||
}
|
||
|
||
_onFeatureGesture(g) {
|
||
// 引入旁白在播时单击 = 跳过本句(12 号文档:feat_* 引入句可跳);
|
||
// 单击加速教学需旁白播完(或被跳过)后再单击一次触发
|
||
if (g === 'tap' && this._voiceBusy()) { this.audio.skipVoice(); return; }
|
||
const want = this.featureKind === 'boost' ? 'tap' : 'doubletap';
|
||
if (g !== want) return; // 其他手势忽略,等超时自动继续
|
||
const now = performance.now();
|
||
if (this.featureKind === 'boost') {
|
||
this.boostUntil = now + BOOST_DURATION_MS;
|
||
this.boostReadyAt = now + BOOST_COOLDOWN_MS;
|
||
this.audio.play('sfx/boost');
|
||
this._speak(['voice/feat_boost_done']);
|
||
this._endFeature();
|
||
} else {
|
||
this.shieldUpUntil = now + SHIELD_DURATION_MS;
|
||
this.shieldReadyAt = now + SHIELD_COOLDOWN_MS;
|
||
this.audio.play('sfx/shield');
|
||
this._speak(['voice/feat_shield_done']);
|
||
this._endFeature(); // _endFeature → _rearmRunTimers 会按 shieldReadyAt 重排充能播报
|
||
}
|
||
}
|
||
|
||
_endFeature() {
|
||
if (this.phase !== 'feature') return;
|
||
this.featureKind = null;
|
||
this._clearTimers(); // 清掉 8s 等待
|
||
this.phase = 'playing';
|
||
this._scheduleSpawn(2500); // 恢复刷怪稍缓,旁白还在播
|
||
this._rearmRunTimers();
|
||
}
|
||
|
||
/**
|
||
* 恢复被菜单/分批引入清掉的局内一次性调度:单击/双击引入、保护期结束宣告、
|
||
* 护盾充能播报。phase 必须为 'playing'。
|
||
*/
|
||
_rearmRunTimers() {
|
||
const now = performance.now();
|
||
if (!this.boostIntroDone) {
|
||
this._after(Math.max(0, this.runStart + BOOST_INTRO_MS - now), () => this._beginFeature('boost'));
|
||
}
|
||
if (!this.shieldIntroDone) {
|
||
this._after(Math.max(0, this.runStart + SHIELD_INTRO_MS - now), () => this._beginFeature('shield'));
|
||
}
|
||
if (!this._protectAnnounced) {
|
||
const remain = this.protectionUntil - now;
|
||
if (remain <= 0) this._announceProtectionEnd(); // 已过期,立即补播
|
||
else this._after(remain, () => this._announceProtectionEnd());
|
||
}
|
||
if (this.shieldReadyAt > now) {
|
||
this._scheduleShieldReady(this.shieldReadyAt - now);
|
||
}
|
||
}
|
||
|
||
// ---------------- 保护期 ----------------
|
||
|
||
_announceProtectionEnd() {
|
||
if (this._protectAnnounced) return;
|
||
if (this.phase !== 'playing') return; // 菜单/分批引入中,恢复时由 _rearmRunTimers 补播
|
||
this._protectAnnounced = true;
|
||
vibrate('warning');
|
||
this.audio.play('voice/protect_end');
|
||
}
|
||
|
||
// ---------------- 陨石调度 ----------------
|
||
|
||
_difficultyNow() {
|
||
return performance.now() - this.protectionUntil; // 保护期后为正
|
||
}
|
||
|
||
_scheduleSpawn(delay) {
|
||
this._after(delay, () => {
|
||
if (this.phase !== 'playing') return;
|
||
this._spawnObstacle();
|
||
this._scheduleSpawn(
|
||
this._inProtection() ? PROTECT_SPAWN_MS : spawnIntervalMs(this._difficultyNow()),
|
||
);
|
||
});
|
||
}
|
||
|
||
_spawnObstacle() {
|
||
const dir = DIRS[Math.floor(Math.random() * DIRS.length)];
|
||
const now = performance.now();
|
||
const impactAt = now + (this._inProtection() ? PROTECT_APPROACH_MS : approachMs(this._difficultyNow()));
|
||
const ob = { dir, impactAt };
|
||
this.obstacles.add(ob);
|
||
|
||
this.audio.play(APPROACH_SFX[dir]);
|
||
// 警报两档渐强,DANGER 震动同步叠加
|
||
this._after(impactAt - ALARM_LEAD_MS - now, () => {
|
||
if (this.obstacles.has(ob)) {
|
||
this.audio.play('sfx/alarm', { volume: 0.5 });
|
||
vibrate('danger');
|
||
}
|
||
});
|
||
this._after(impactAt - ALARM_LEAD_MS / 2 - now, () => {
|
||
if (this.obstacles.has(ob)) {
|
||
this.audio.play('sfx/alarm', { volume: 1 });
|
||
vibrate('danger');
|
||
}
|
||
});
|
||
this._after(impactAt - now, () => this._resolve(ob));
|
||
}
|
||
|
||
/** 撞击点判定:加速增益 > 同向滑动 > 护盾 > 保护期训练护盾 > 撞击。 */
|
||
_resolve(ob) {
|
||
if (!this.obstacles.has(ob) || this.phase !== 'playing') return;
|
||
this.obstacles.delete(ob);
|
||
const now = performance.now();
|
||
|
||
// 1) 加速增益期间:冲过空隙
|
||
if (now <= this.boostUntil + DODGE_GRACE_MS) {
|
||
this._onDodge();
|
||
return;
|
||
}
|
||
// 2) 同向滑动拨开:撞击前 DODGE_WINDOW 内有过同方向滑动
|
||
const hit = this.swipes.find((s) =>
|
||
s.dir === ob.dir && now - s.t <= DODGE_WINDOW_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.audio.play('voice/shield_block', { queue: true });
|
||
return;
|
||
}
|
||
// 4) 保护期:训练护盾吸收,不掉血,语音解释方位(死亡教学前置)
|
||
if (this._inProtection()) {
|
||
this.audio.play('sfx/shield_block');
|
||
vibrate('warning');
|
||
this.audio.play(`voice/protect_${ob.dir}`);
|
||
return;
|
||
}
|
||
// 5) 实打实撞上:HIT 震动 + 归因语音(第 2 次加"只剩最后一次")
|
||
this.hits += 1;
|
||
this.audio.play('sfx/hit');
|
||
vibrate('hit');
|
||
if (this.hits >= HULL_MAX) {
|
||
this._gameOver();
|
||
} else {
|
||
const ids = [`voice/hit_${ob.dir}`];
|
||
if (this.hits === 2) ids.push('voice/hit_last');
|
||
this._speak(ids);
|
||
}
|
||
}
|
||
|
||
_onDodge() {
|
||
this.dodges += 1;
|
||
this.audio.play('sfx/dodge');
|
||
const ms = MILESTONES[this.dodges];
|
||
if (ms) {
|
||
vibrate('confirm');
|
||
this.audio.play(ms);
|
||
}
|
||
}
|
||
|
||
// ---------------- 菜单(全球约定:下滑呼出 → 单击循环 → 双击确认 → 上滑帮助) ----------------
|
||
|
||
_openMenu() {
|
||
this.phase = 'menu';
|
||
this._clearTimers(); // 模态暂停:刷怪与进行中陨石全部冻结清除
|
||
this.obstacles.clear();
|
||
this.swipes = [];
|
||
this._menuIdx = 0;
|
||
this.audio.play('bgm/menu', { fadeMs: 300 }); // 状态 BGM:进菜单快切(10 号文档 §6)
|
||
this._speak(['voice/paused', 'voice/menu_main', MENU_ITEMS[0]]);
|
||
}
|
||
|
||
_onMenuGesture(g) {
|
||
if (g === 'tap') {
|
||
this._menuIdx = (this._menuIdx + 1) % MENU_ITEMS.length;
|
||
this.audio.play(MENU_ITEMS[this._menuIdx]);
|
||
} else if (g === 'up') {
|
||
// 帮助:操作要点 + 设置引导
|
||
this._speak(['voice/help_recap', 'voice/settings_hint']);
|
||
} else if (g === 'down') {
|
||
this._closeMenu();
|
||
} else if (g === 'doubletap') {
|
||
if (this._menuIdx === 0) this._closeMenu();
|
||
else if (this._menuIdx === 1) this._goHome();
|
||
else this._speak(['voice/help_recap', 'voice/settings_hint']); // menu_help 项
|
||
}
|
||
}
|
||
|
||
_closeMenu() {
|
||
this.phase = 'playing';
|
||
this.audio.play('bgm/engine', { fadeMs: 500 }); // 状态 BGM:回对局淡回 engine
|
||
this._scheduleSpawn(800);
|
||
this._rearmRunTimers();
|
||
}
|
||
|
||
_goHome() {
|
||
this.stop();
|
||
window.location.href = '../';
|
||
}
|
||
|
||
// ---------------- 坠毁结算 ----------------
|
||
|
||
_gameOver() {
|
||
this.phase = 'over';
|
||
this._clearTimers();
|
||
this.obstacles.clear();
|
||
this.audio.stopBgm(600);
|
||
vibrate('error');
|
||
this._after(300, () => vibrate('hit'));
|
||
this.audio.play('sfx/crash');
|
||
const aliveSec = Math.floor((performance.now() - this.runStart) / 1000);
|
||
const isRecord = this._saveScore(aliveSec);
|
||
// 给爆炸声留 1.2s 再开口
|
||
this._after(1200, () => {
|
||
if (isRecord) vibrate('success');
|
||
const ids = [this._timeBandVoice(aliveSec)];
|
||
if (isRecord) ids.push('voice/new_record');
|
||
ids.push(this._dodgeBandVoice(this.dodges), 'voice/restart_hint');
|
||
// 结算语音链播完淡入 menu pad(10 号文档 §6);被跳过的句仍逐句推进链
|
||
this._speak(ids, () => {
|
||
if (this.phase === 'over') this.audio.play('bgm/menu', { fadeMs: 800 });
|
||
});
|
||
});
|
||
}
|
||
|
||
/** 存活时间 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';
|
||
}
|
||
|
||
// ---------------- 成绩存档 ----------------
|
||
|
||
_loadBest() {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(BEST_KEY)) || { bestTimeSec: 0, bestDodges: 0 };
|
||
} catch (_) {
|
||
return { bestTimeSec: 0, bestDodges: 0 };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 结算记分:本地 best 兜底(localStorage),破存活纪录时同步服务器。
|
||
* @returns {boolean} 是否破存活时间纪录
|
||
*/
|
||
_saveScore(aliveSec) {
|
||
const best = this._loadBest();
|
||
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,
|
||
schema: 1,
|
||
updatedAt: new Date().toISOString(),
|
||
},
|
||
}),
|
||
}).catch(() => { /* 离线静默 */ });
|
||
} catch (_) { /* 静默 */ }
|
||
return true;
|
||
}
|
||
|
||
// ---------------- 工具 ----------------
|
||
|
||
/** voice 层是否有对白在播(单击=跳过本句的语境判定依据)。 */
|
||
_voiceBusy() {
|
||
return !!this.audio._voice;
|
||
}
|
||
|
||
/** 语音串行链:自然播完才接下一句;被 stopVoice 打断则整条作废。 */
|
||
_speak(ids, onDone) {
|
||
if (!ids.length) { if (onDone) onDone(); return; }
|
||
const s = this._session;
|
||
const [head, ...rest] = ids;
|
||
this.audio.play(head, {
|
||
onComplete: () => {
|
||
if (s !== this._session) return;
|
||
this._speak(rest, onDone);
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 带看门狗的语音链:链自然播完或 guardMs 超时(语音缺失/解码失败),
|
||
* 二者先到先触发 onDone 且只触发一次——保证流程绝不卡死。
|
||
*/
|
||
_speakGuarded(ids, onDone, guardMs = 10000) {
|
||
const s = this._session;
|
||
let fired = false;
|
||
const fire = () => {
|
||
if (fired || s !== this._session) return;
|
||
fired = true;
|
||
if (onDone) onDone();
|
||
};
|
||
this._speak(ids, fire);
|
||
this._after(guardMs, fire);
|
||
}
|
||
|
||
_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 <= DODGE_WINDOW_MS + 500);
|
||
}
|
||
}
|