四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付: - cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档 - teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0 - keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4 - bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行 公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、 gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00) 验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿 遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
1556 lines
63 KiB
JavaScript
1556 lines
63 KiB
JavaScript
/**
|
||
* 《星环舰长》客户端 —— 舰桥指挥:听声辨型后下令。
|
||
* 设计:docs/design/22-bridge.md(四象限 §3、危机矩阵 §4.2、仲裁 R1-R8 §3.4、
|
||
* 教学 §7、数值 §8、断言清单 §12);台词表:docs/design/22-bridge-voice-ids.md。
|
||
*
|
||
* 架构要点:
|
||
* - 宏观状态:TITLE / PORT(TITLE 子相位) / DRILL(寄生于首局 S1) / ROUTE / CRUISE /
|
||
* CRISIS(DETECT→DECIDE) / DOCK / REPORT(死亡链共用);MENU 为冻结 overlay(§2.6)。
|
||
* - seed 复现:mulberry32(opts.seed),plan 生成期一次性(类型序/方位/陷阱/磨损/余波),
|
||
* 读档复用不重洗(§4.3);同 seed 两次构造 run.plan 深相等(playtest 锁定)。
|
||
* - 巡检链:2.0s 状态音固定序(引擎→护盾→扫描→武器)+0.4s 间隔(SYN 资产节奏写死,
|
||
* playtest 逐毫秒断言);全 ok → tut_all_ok;扫描/武器音 pan 烤进资产(固定舰桥布局)。
|
||
* - 告警仲裁 R1-R8:单告警纪律(R1① 危机关联优先 / R1② 威胁序)+轮转公平(双受损
|
||
* 15/5s、三受损 10/5/5s)+600ms 同 id 去重(R3)+voice 让路(R4,链落声+0.5s 恢复)
|
||
* +决策窗静默无关告警(R5)+双危机 FIFO(R6)+修复即止(R7)+菜单/结算冻结(R8)。
|
||
* - 账目真实三本账:loyalty(§5.1 逐行差分,clamp −3..+3,band 句=ledger)/经济(采集/
|
||
* 修理/奖励逐笔,RP/hull 跨档双向播,播报值=运行值)/评级(§8.2 公式+§8.4-8.6 剧本
|
||
* 逐事件验算在 playtest §12.7)。
|
||
* - 跨档句不截断:_addRp/_hullDamage 只做簿记并返回档位句,由结算链显式编排进语音序。
|
||
* - 复数封装:this.state 为宏观状态;opts.seed 注入;一切计时走 setTimeout(虚拟时钟
|
||
* 友好),菜单打开期间计时器自旋重挂、关闭后续走;语音链单句 8s 兜底推进(§6.3),
|
||
* 危机超时默认结局自动落(永不卡死)。
|
||
*/
|
||
import { Game } from '../engine/game.js';
|
||
import { Guide } from '../engine/guide.js';
|
||
import { vibrate } from '../engine/haptics.js';
|
||
|
||
const SAVE_KEY = 'soundgame.progress.bridge';
|
||
const AUTH_KEY = 'soundgame.auth';
|
||
|
||
// ---------------- 常量(22 §3-§8) ----------------
|
||
const SYS_ORDER = ['engine', 'shield', 'scan', 'weapon']; // 巡检固定序
|
||
const SYS_NAME = { engine: '引擎', shield: '护盾', scan: '扫描', weapon: '武器' };
|
||
const SYS_DMG = { engine: 'sys_engine_dmg', shield: 'sys_shield_dmg', scan: 'sys_scan_dmg', weapon: 'sys_weapon_dmg' };
|
||
const SYS_OFF = { engine: 'sys_engine_off', shield: 'sys_shield_off', scan: 'sys_scan_off', weapon: 'sys_weapon_off' };
|
||
const SYS_TONE = { engine: 'sys_engine', shield: 'sys_shield', scan: 'sys_scan', weapon: 'sys_weapon' };
|
||
const SYS_ALM = { engine: 'alm_engine', shield: 'alm_shield', scan: 'alm_scan', weapon: 'alm_weapon' };
|
||
const ALM_PERIOD = { engine: 8000, shield: 6000, scan: 10000, weapon: 12000 }; // 互质周期 §3.2
|
||
const SYS_DIR = { engine: 'stern', shield: 'bow', scan: 'port', weapon: 'star' };
|
||
// 受殃顺时针下一系统:左舷扫描→右舷武器→舰首护盾→舰尾引擎(§3.1)
|
||
const DIR_SYS = { port: 'scan', star: 'weapon', bow: 'shield', stern: 'engine' };
|
||
const NEXT_SYS = { scan: 'weapon', weapon: 'shield', shield: 'engine', engine: 'scan' };
|
||
const DIR_VOICE = { port: 'dir_port', star: 'dir_star', bow: 'dir_bow', stern: 'dir_stern' };
|
||
const DIR_SIG = { port: '_l', star: '_r', bow: '', stern: '' }; // bow/stern=center+尾音
|
||
const DIR_TAIL = { bow: 'dir_bow', stern: 'dir_stern' };
|
||
const THREAT = ['engine', 'shield', 'weapon', 'scan']; // R1② 威胁序
|
||
const CRISIS_REL = { // R1① 危机关联集(R5 窗口同集)
|
||
meteor: ['engine', 'shield', 'weapon'],
|
||
pirate: ['weapon', 'engine'],
|
||
distress: ['scan'],
|
||
radiation: ['engine', 'shield'],
|
||
};
|
||
const CRISIS = {
|
||
meteor: { win: 12000, evt: 'evt_meteor', opts: ['evade', 'fight', 'breach'], timeout: 'breach' },
|
||
pirate: { win: 15000, evt: 'evt_pirate', opts: ['fight', 'evade', 'ignore'], timeout: 'ignore' },
|
||
distress: { win: 20000, evt: 'evt_distress', opts: ['aid', 'ignore'], timeout: 'ignore' },
|
||
radiation: { win: 10000, evt: 'evt_radiation', opts: ['evade', 'breach'], timeout: 'rad' },
|
||
};
|
||
const OPT_VOICE = { evade: 'opt_evade', fight: 'opt_fight', breach: 'opt_breach', aid: 'opt_aid', ignore: 'opt_ignore' };
|
||
const OPT_SYS = { evade: 'engine', fight: 'weapon', breach: 'shield', aid: 'scan', ignore: null };
|
||
const OPT_SFX = { evade: 'eva_ok', fight: 'wpn_fire', breach: 'shield_bear', aid: 'warp', ignore: null };
|
||
const SEG_MS = [0, 90000, 100000, 110000, 120000]; // §4.3 固定段长
|
||
const WEAR_AT = [0.4, 0.75]; // 磨损时点(富矿第二次)
|
||
const HULL_DANGER = 0.4; // 危急线(按比例)
|
||
const REPORT_GRACE_MS = 800; // §6.4 死窗
|
||
const VOICE_GUARD_MS = 8000; // §6.3 单句兜底
|
||
const CHAIN_GAP_MS = 800; // §6.3 句间
|
||
const TONE_MS = 2000; // §3.2 巡检音
|
||
const TONE_GAP_MS = 400; // §3.2 间隔
|
||
const SIG_DUR_MS = 2500; // §4.1 签名音
|
||
const ALM_DEDUP_MS = 600; // R3 叠爆去重
|
||
const ALM_VOL = 0.8, TONE_VOL = 0.7, FLAT_VOL = 0.7, SIG_VOL = 0.9; // §3.2 音量
|
||
const REPLAY_ALARM_WINDOW_MS = 60000; // E1 重播窗
|
||
const ROTATE = { 2: [15000, 5000], 3: [10000, 5000, 5000] }; // R2 轮转
|
||
const ROTATE4 = [10000, 5000, 5000, 5000];
|
||
const RP_BANDS = [[0, 2, 'st_rp_1'], [3, 6, 'st_rp_2'], [7, Infinity, 'st_rp_3']];
|
||
|
||
const MENU_ITEMS = ['sys_menu_resume', 'sys_menu_save', 'sys_menu_help', 'sys_menu_restart'];
|
||
const UPGRADES = [
|
||
{ id: 'spare', voice: 'upg_spare', price: 20, needs: null },
|
||
{ id: 'harvest', voice: 'upg_harvest', price: 30, needs: null },
|
||
{ id: 'armor1', voice: 'upg_armor1', price: 40, needs: null },
|
||
{ id: 'vet', voice: 'upg_vet', price: 50, needs: null },
|
||
{ id: 'armor2', voice: 'upg_armor2', price: 60, needs: 'armor1' },
|
||
{ id: 'aegis', voice: 'upg_aegis', price: 70, needs: null },
|
||
];
|
||
|
||
const RATE_RANK = { S: 4, A: 3, B: 2, C: 1, D: 0 };
|
||
const RATE_BONUS = { S: 8, A: 5, B: 3, C: 1, D: 0 };
|
||
|
||
function mulberry32(seed) {
|
||
let a = seed >>> 0;
|
||
return function () {
|
||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||
const bandId = (v) => (v <= -2 ? 1 : v >= 2 ? 3 : 2);
|
||
|
||
export class BridgeGame extends Game {
|
||
constructor(audio, root, opts = {}) {
|
||
super(audio, root);
|
||
this.guide = new Guide(audio);
|
||
this._seed = opts.seed ?? null;
|
||
|
||
this.state = 'TITLE';
|
||
this.run = null;
|
||
this.meta = { drillDone: false, merit: 0, unlocks: [], patrols: 0, bestRate: null, aids: 0, kills: 0 };
|
||
|
||
this._menu = { open: false, idx: 0 };
|
||
this._port = { idx: 0 };
|
||
this._route = { idx: 0 };
|
||
this._dock = { idx: 0, items: [] };
|
||
this._dockProgTimers = [];
|
||
this._crisis = null; // {type,dir,optIdx,subphase,echo,trap,remainMs,startedAt,deadlineId,tailId}
|
||
this._crisisQueue = []; // R6 双危机 FIFO
|
||
this._report = null; // {chain, done, merit, rate}
|
||
this._drill = null; // {step, armed}
|
||
this._postCrisisDrill = false; // 教学危机收束期(段计时已停,跳过须补 _endSeg)
|
||
this._graceUntil = 0;
|
||
this._chainSeq = 0;
|
||
this._liveChains = new Set(); // 在播语音链 token(R2 F5)
|
||
this._transitionChains = new Set(); // 带 onDone 副作用的转移承载链(菜单不可掐,顺延)
|
||
this._menuPending = false; // 菜单顺延请求(链播完/下一手势时补开)
|
||
this._protect = null;
|
||
this._goingHome = false;
|
||
this._echoDone = false;
|
||
|
||
// 段/倒计时计时器
|
||
this._segTimer = null;
|
||
this._routeTimer = null;
|
||
this._wearTimers = [];
|
||
|
||
// 巡检链
|
||
this._tourState = null; // {token, kind, phase, i}
|
||
this._lastVoiceId = null;
|
||
|
||
// 告警仲裁(R1-R8)
|
||
this._alm = { current: null, holds: [], rotIdx: 0, rotTimer: null, loops: {}, lastPlay: {}, deferred: [] };
|
||
this._lastAlarmPlay = { at: -1e9, id: null };
|
||
|
||
// NPC 首播记账(每局 1 次)
|
||
this._npcFired = { xo_uneasy: false, eng_gripe: false, doc_ignore: false };
|
||
this._supNoneUsed = false;
|
||
this._dangerSaidSeg = 0;
|
||
|
||
this._timers = new Set();
|
||
}
|
||
|
||
// ================================================================ 基础工具
|
||
|
||
/** 统一计时:全部走 setTimeout(虚拟时钟友好),stop 时批量清。 */
|
||
_later(ms, fn) {
|
||
const id = setTimeout(() => { this._timers.delete(id); fn(); }, ms);
|
||
this._timers.add(id);
|
||
return id;
|
||
}
|
||
_cancel(id) { if (id !== null && id !== undefined) { clearTimeout(id); this._timers.delete(id); } }
|
||
|
||
_sfx(id, opts = {}) { this.audio.play('sfx/' + id, opts); }
|
||
|
||
/**
|
||
* 语音链:整句串行(句间 CHAIN_GAP_MS,§6.3);protect 链不可被顶;
|
||
* 单句 8s 无 onComplete(资产缺失)→ 兜底推进(屏蔽任意 voice 不卡死契约)。
|
||
*/
|
||
_speak(ids, { protect = null, onDone = null, queueFirst = false } = {}) {
|
||
if (!ids.length) { if (onDone) onDone(); return; }
|
||
const token = ++this._chainSeq;
|
||
if (protect) this._protect = protect;
|
||
this._liveChains.add(token);
|
||
if (onDone) this._transitionChains.add(token);
|
||
const step = (i) => {
|
||
if (token !== this._chainSeq) return;
|
||
if (i >= ids.length) {
|
||
this._liveChains.delete(token);
|
||
this._transitionChains.delete(token);
|
||
if (protect && this._protect === protect) this._protect = null;
|
||
this._voiceResume();
|
||
if (onDone) onDone();
|
||
if (!this._liveChains.size && this._menuPending) this._flushPendingMenu();
|
||
return;
|
||
}
|
||
const id = ids[i];
|
||
this._lastVoiceId = id;
|
||
let done = false;
|
||
let guardId = null;
|
||
const finish = () => {
|
||
if (done) return;
|
||
done = true;
|
||
this._cancel(guardId);
|
||
this._later(CHAIN_GAP_MS, () => { if (token === this._chainSeq) step(i + 1); });
|
||
};
|
||
const guard = () => {
|
||
if (done || token !== this._chainSeq) return;
|
||
if (this.audio.isVoicePlaying()) { guardId = this._later(2000, guard); return; }
|
||
finish();
|
||
};
|
||
guardId = this._later(VOICE_GUARD_MS, guard);
|
||
this.audio.play('voice/' + id, { queue: queueFirst && i === 0, onComplete: finish });
|
||
};
|
||
step(0);
|
||
}
|
||
|
||
_invalid() {
|
||
vibrate('error');
|
||
this.guide.error({ voice: 'gd_invalid', onceKey: 'bridge_invalid', skipHaptic: true });
|
||
}
|
||
|
||
/** 中断并作废当前语音链。 */
|
||
_cutChain() {
|
||
this._chainSeq++;
|
||
this._protect = null;
|
||
this._liveChains.clear();
|
||
this._transitionChains.clear();
|
||
}
|
||
|
||
/** 菜单顺延:转移承载链(结算/教学/段末)在播时,菜单等链落声再开(防掐链死锁,R2 F5)。 */
|
||
_flushPendingMenu() {
|
||
this._menuPending = false;
|
||
if (this.state === 'REPORT' && this._report && !this._report.done) return; // 链锁期不开
|
||
this._openMenu();
|
||
}
|
||
|
||
// ================================================================ 进度 / 存档
|
||
|
||
_loadLocal() {
|
||
try {
|
||
const raw = localStorage.getItem(SAVE_KEY);
|
||
if (!raw) return;
|
||
const d = JSON.parse(raw);
|
||
if (d && d.meta) this.meta = { ...this.meta, ...d.meta };
|
||
if (d && d.run) this.run = d.run;
|
||
} catch (_) { /* 损坏回退 */ }
|
||
}
|
||
|
||
_summary() {
|
||
if (this.run) {
|
||
const r = this.run;
|
||
const f = r.hull / this._hullMax();
|
||
const hullBand = f >= 0.75 ? '稳固' : f >= HULL_DANGER ? '带伤' : '危急';
|
||
const worst = this._topAlarmSys();
|
||
return `第${r.seg}巡航段 · 舰体${hullBand} · ${worst ? SYS_NAME[worst] + '异常' : '无异常'} · 资源${r.rp}`;
|
||
}
|
||
const m = this.meta;
|
||
return `功勋${m.merit} · 最佳${m.bestRate ?? '—'} · 改装${m.unlocks.length}/6`;
|
||
}
|
||
|
||
_save(withPut = true) {
|
||
const data = {
|
||
schema: 1, game: 'bridge', updatedAt: new Date().toISOString(),
|
||
summary: this._summary(), meta: this.meta, run: this.run,
|
||
};
|
||
try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); } catch (_) { /* 隐私模式 */ }
|
||
if (!withPut) return;
|
||
try {
|
||
const auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null');
|
||
if (!auth || !auth.token) return;
|
||
fetch('/api/progress', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.token },
|
||
body: JSON.stringify({ game: 'bridge', data }),
|
||
}).catch(() => { /* 离线静默 */ });
|
||
} catch (_) { /* 静默 */ }
|
||
}
|
||
|
||
_pullServerProgress() {
|
||
let auth = null;
|
||
try { auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch (_) { /* */ }
|
||
if (!auth || !auth.token) return;
|
||
fetch('/api/progress?game=bridge', { headers: { Authorization: `Bearer ${auth.token}` } })
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((j) => {
|
||
if (!j || !j.data) return;
|
||
if (j.data.meta) this.meta = { ...this.meta, ...j.data.meta };
|
||
if (j.data.run) this.run = j.data.run;
|
||
})
|
||
.catch(() => { /* 离线 */ });
|
||
}
|
||
|
||
// ================================================================ plan 生成(§4.3)
|
||
|
||
_newRun() {
|
||
const seed = this._seed ?? Math.floor(Math.random() * 1e9);
|
||
const rng = mulberry32(seed);
|
||
const firstRun = this.meta.patrols === 0;
|
||
let types;
|
||
if (firstRun) types = ['distress', 'meteor', 'pirate', 'radiation'];
|
||
else {
|
||
types = ['meteor', 'pirate', 'distress', 'radiation'];
|
||
for (let i = types.length - 1; i > 0; i--) {
|
||
const j = Math.floor(rng() * (i + 1));
|
||
[types[i], types[j]] = [types[j], types[i]];
|
||
}
|
||
if (types[0] === 'radiation') types.push(types.shift()); // S1≠辐射
|
||
}
|
||
const dirs = [0, 0, 0, 0].map(() => ['port', 'star', 'bow', 'stern'][Math.floor(rng() * 4)]);
|
||
const trap = firstRun ? false : rng() < 0.25;
|
||
const wears = [[], [], [], []];
|
||
for (let seg = 2; seg <= 4; seg++) {
|
||
wears[seg - 1] = [SYS_ORDER[Math.floor(rng() * 4)], SYS_ORDER[Math.floor(rng() * 4)]];
|
||
}
|
||
const echo = firstRun ? false : rng() < 0.4;
|
||
const echoDir = echo ? ['port', 'star', 'bow', 'stern'][Math.floor(rng() * 4)] : null;
|
||
return {
|
||
seed, seg: 1, plan: { types, dirs, trap, wears, echo, echoDir },
|
||
hull: this._hullMax(), rp: this.meta.unlocks.includes('spare') ? 2 : 0,
|
||
sys: { engine: 'ok', shield: 'ok', scan: 'ok', weapon: 'ok' },
|
||
crew: {
|
||
xo: this.meta.unlocks.includes('vet') ? 1 : 0,
|
||
eng: this.meta.unlocks.includes('vet') ? 1 : 0,
|
||
doc: this.meta.unlocks.includes('vet') ? 1 : 0,
|
||
},
|
||
aidOk: 0, kills: 0, offlineEver: false,
|
||
hullLostSeg: 0, timeouts: 0, offlineFails: 0,
|
||
aegisUsed: false, route: null, lastHit: null, firstRun,
|
||
drillDone: this.meta.drillDone,
|
||
};
|
||
}
|
||
|
||
_hullMax() {
|
||
return 6 + (this.meta.unlocks.includes('armor1') ? 1 : 0) + (this.meta.unlocks.includes('armor2') ? 1 : 0);
|
||
}
|
||
|
||
// ================================================================ 生命周期
|
||
|
||
start() {
|
||
this.audio.prewarm([
|
||
'sfx/sys_engine_ok', 'sfx/sys_engine_dmg', 'sfx/sys_shield_ok', 'sfx/sys_shield_dmg',
|
||
'sfx/sys_scan_ok', 'sfx/sys_scan_dmg', 'sfx/sys_weapon_ok', 'sfx/sys_weapon_dmg',
|
||
'sfx/sys_offline', 'sfx/alm_engine', 'sfx/alm_shield', 'sfx/alm_scan', 'sfx/alm_weapon',
|
||
'sfx/dir_bow', 'sfx/dir_stern', 'sfx/pa_chime', 'sfx/eva_ok', 'sfx/wpn_fire',
|
||
'sfx/shield_bear', 'sfx/warp', 'sfx/hit_hull', 'sfx/res_pick', 'sfx/wrench',
|
||
'sfx/seg_warp', 'sfx/tick_fast', 'sfx/turn_tick', 'sfx/cruise_ping',
|
||
]);
|
||
this._loadLocal();
|
||
this._pullServerProgress();
|
||
this._enterTitle();
|
||
}
|
||
|
||
stop() {
|
||
this._cutChain();
|
||
for (const id of this._timers) clearTimeout(id);
|
||
this._timers.clear();
|
||
this._almStopAll();
|
||
this.guide.disarm();
|
||
this.audio.stopAll();
|
||
}
|
||
|
||
onGesture(g) {
|
||
if (this._goingHome) return;
|
||
if (performance.now() < this._graceUntil) return; // REPORT 死窗(§6.4)
|
||
if (g === 'longpressend' || g === 'tapcancel') return;
|
||
this.guide.disarm();
|
||
if (this._menuPending && !this._liveChains.size && !this._menu.open) {
|
||
this._menuPending = false;
|
||
this._openMenu();
|
||
return; // 顺延的菜单_openMenu 已播报;本手势不另作处理
|
||
}
|
||
if (this._menu.open) { this._onMenuGesture(g); return; }
|
||
if (g === 'longpress') { this._onLongpress(); return; }
|
||
switch (this.state) {
|
||
case 'TITLE': this._onTitleGesture(g); break;
|
||
case 'PORT': this._onPortGesture(g); break;
|
||
case 'ROUTE': this._onRouteGesture(g); break;
|
||
case 'CRUISE': this._onCruiseGesture(g); break;
|
||
case 'CRISIS': this._onCrisisGesture(g); break;
|
||
case 'DOCK': this._onDockGesture(g); break;
|
||
case 'REPORT': this._onReportGesture(g); break;
|
||
default: break;
|
||
}
|
||
this._rearmWatchdog();
|
||
}
|
||
|
||
/** 长按语义(§2.7/E2):CRUISE 空闲=引擎快进(不设 E3,22 §2.8 登记);决策窗=error。 */
|
||
_onLongpress() {
|
||
if (this.state === 'CRISIS' && this._crisis?.subphase === 'decide') { this._invalid(); return; } // E2
|
||
if (this.state === 'CRUISE' && !this._tourState && !this.audio.isVoicePlaying()) { this._invalid(); return; }
|
||
// 有语音在播 → 引擎快进接管(全状态默认开启)
|
||
}
|
||
|
||
// ================================================================ TITLE / PORT
|
||
|
||
_enterTitle() {
|
||
this.state = 'TITLE';
|
||
this._crisis = null;
|
||
this._menu.open = false;
|
||
this._tourState = null;
|
||
this.audio.play('bgm/menu');
|
||
this.guide.state('menu');
|
||
if (this.run) this._speak(['sys_continue_ask']);
|
||
}
|
||
|
||
_onTitleGesture(g) {
|
||
if (g === 'tap') {
|
||
if (this.run) { this._resumeRun(); return; }
|
||
this._startPatrol();
|
||
return;
|
||
}
|
||
if (g === 'doubletap') {
|
||
// 22 §15-R2 裁定:有档重听 continue_ask;无档重听 intro_1..5 开场链
|
||
this._speak(this.run ? ['sys_continue_ask'] : ['intro_1', 'intro_2', 'intro_3', 'intro_4', 'intro_5']);
|
||
return;
|
||
}
|
||
if (g === 'right') { this._enterPort(); return; }
|
||
if (g === 'up') { this._titleOverview(); return; }
|
||
if (g === 'down') { this._openMenu(); return; }
|
||
this._invalid();
|
||
}
|
||
|
||
/** 舰船概况链:功勋档 → 已购改装逐项效果句(价格=历史成本,真话;§2.1)。 */
|
||
_titleOverview() {
|
||
const ids = [this._meritBand(this.meta.merit)];
|
||
for (const u of UPGRADES) if (this.meta.unlocks.includes(u.id)) ids.push(u.voice);
|
||
this._speak(ids, { queueFirst: true });
|
||
}
|
||
_meritBand(m) { return m <= 5 ? 'merit_1' : m <= 10 ? 'merit_2' : 'merit_3'; }
|
||
|
||
_enterPort() {
|
||
this.state = 'PORT';
|
||
this._port.idx = 0;
|
||
this.audio.play('bgm/menu');
|
||
this.guide.state('choice');
|
||
this.guide.turn({}); // 回合标记(§10.2)
|
||
this._speak(['port_open', this._portIdxVoice(this._portItems())]);
|
||
}
|
||
|
||
_portItems() { return UPGRADES.filter((u) => !u.needs || this.meta.unlocks.includes(u.needs)); }
|
||
|
||
_onPortGesture(g) {
|
||
const items = this._portItems();
|
||
if (g === 'left' || g === 'right') {
|
||
const n = items.length + 1; // +返回项(循环)
|
||
this._port.idx = (this._port.idx + (g === 'right' ? 1 : n - 1)) % n;
|
||
this._speak([this._portIdxVoice(items)]);
|
||
return;
|
||
}
|
||
if (g === 'tap') {
|
||
if (this._port.idx === items.length) { this._enterTitle(); return; } // 返回
|
||
const it = items[this._port.idx];
|
||
if (this.meta.unlocks.includes(it.id)) { this._invalid(); return; } // 不重复购买
|
||
if (this.meta.merit < it.price) { vibrate('error'); this._speak(['port_poor']); return; }
|
||
this.meta.merit -= it.price;
|
||
this.meta.unlocks.push(it.id);
|
||
this._save();
|
||
vibrate('success');
|
||
this._speak(['upg_buy']);
|
||
return;
|
||
}
|
||
if (g === 'doubletap') { this._speak([this._portIdxVoice(items)]); return; }
|
||
if (g === 'up') { this._titleOverview(); return; }
|
||
if (g === 'down') { this._openMenu(); return; }
|
||
this._invalid();
|
||
}
|
||
|
||
_portIdxVoice(items) {
|
||
if (this._port.idx === items.length) return 'port_back';
|
||
const it = items[this._port.idx];
|
||
return this.meta.unlocks.includes(it.id) ? 'upg_done' : it.voice;
|
||
}
|
||
|
||
// ================================================================ 巡逻启动 / 恢复
|
||
|
||
_startPatrol() {
|
||
this.run = this._newRun();
|
||
this._resetRunFlags();
|
||
this._save();
|
||
// sys_new_patrol 落声后再进段(不顶句——live R1 实测原实现开场句被教学链掐掉)
|
||
this._speak(['sys_new_patrol'], { onDone: () => this._enterSeg(1) });
|
||
}
|
||
|
||
_resetRunFlags() {
|
||
this._postCrisisDrill = false;
|
||
this._menuPending = false;
|
||
this._npcFired = { xo_uneasy: false, eng_gripe: false, doc_ignore: false };
|
||
this._supNoneUsed = false;
|
||
this._dangerSaidSeg = 0;
|
||
this._echoDone = false;
|
||
this._crisisQueue = [];
|
||
}
|
||
|
||
_resumeRun() {
|
||
const r = this.run;
|
||
this._resetRunFlags();
|
||
r.aegisUsed = false;
|
||
this.audio.play('bgm/cruise');
|
||
this.guide.state('explore');
|
||
// 中断复述链(§3.3/§11):舰体档 → 最紧要异常 → 巡航段 → recap_go
|
||
const ids = [this._hullBand()];
|
||
const top = this._topAlarmSys();
|
||
if (top) ids.push(r.sys[top] === 'dmg' ? SYS_DMG[top] : SYS_OFF[top]);
|
||
ids.push('st_seg_' + r.seg, 'recap_go');
|
||
this._enterSeg(r.seg, { recap: ids });
|
||
}
|
||
|
||
_enterSeg(seg, { recap = null } = {}) {
|
||
const r = this.run;
|
||
if (!r) return;
|
||
r.seg = seg;
|
||
r.route = null;
|
||
r.hullLostSeg = 0;
|
||
r.aegisUsed = false;
|
||
this._save();
|
||
const routed = !(seg === 1 && r.firstRun && !r.drillDone);
|
||
if (recap) this._speak(recap, { onDone: () => this._enterSegPhase(routed) });
|
||
else this._enterSegPhase(routed);
|
||
}
|
||
|
||
_enterSegPhase(routed) {
|
||
if (!this.run) return;
|
||
if (routed) this._enterRoute();
|
||
else this._enterCruise();
|
||
}
|
||
|
||
// ================================================================ ROUTE(§2.2)
|
||
|
||
_enterRoute() {
|
||
this.state = 'ROUTE';
|
||
this._route.idx = 0;
|
||
this.audio.play('bgm/cruise');
|
||
this.guide.state('choice');
|
||
this.guide.turn({});
|
||
this._speak(['route_ask', 'opt_route_safe']);
|
||
this._armRouteTimer(45000); // 45s 自动稳妥线(结构兜底)
|
||
this._rearmWatchdog();
|
||
}
|
||
|
||
_armRouteTimer(ms) {
|
||
this._cancel(this._routeTimer);
|
||
this._routeTimer = this._later(ms, () => {
|
||
if (this.state !== 'ROUTE') return;
|
||
if (this._menu.open) { this._armRouteTimer(1000); return; } // 菜单冻结,关闭后续走
|
||
this._pickRoute(0);
|
||
});
|
||
}
|
||
|
||
_pickRoute(idx) {
|
||
this._cancel(this._routeTimer);
|
||
this.run.route = idx === 0 ? 'safe' : 'rich';
|
||
this._speak([idx === 0 ? 'opt_route_safe' : 'opt_route_rich'], { onDone: () => this._enterCruise() });
|
||
}
|
||
|
||
_onRouteGesture(g) {
|
||
if (g === 'left' || g === 'right') {
|
||
this._route.idx = this._route.idx === 0 ? 1 : 0;
|
||
this._speak([this._route.idx === 0 ? 'opt_route_safe' : 'opt_route_rich']);
|
||
return;
|
||
}
|
||
if (g === 'tap') { this._pickRoute(this._route.idx); return; }
|
||
if (g === 'doubletap') { this._speak([this._route.idx === 0 ? 'opt_route_safe' : 'opt_route_rich']); return; }
|
||
if (g === 'up') { this._tour('full'); return; }
|
||
if (g === 'down') { this._openMenu(); return; }
|
||
this._invalid();
|
||
}
|
||
|
||
// ================================================================ CRUISE(§2.2)
|
||
|
||
_enterCruise() {
|
||
const r = this.run;
|
||
if (!r) return;
|
||
this.state = 'CRUISE';
|
||
this.audio.play('bgm/cruise');
|
||
this.guide.state('explore');
|
||
// 采集入账(§8.1):跨档句紧随入账之后播
|
||
const gain = (r.route === 'rich' ? 4 : 2) + (this.meta.unlocks.includes('harvest') ? 1 : 0);
|
||
const band = this._addRp(gain);
|
||
this._sfx('res_pick');
|
||
vibrate('pickup');
|
||
if (band) this._speak([band]);
|
||
this._rearmWatchdog();
|
||
// 磨损调度(S1 无;富矿 2 次,§4.3 ④)
|
||
const segLen = SEG_MS[r.seg];
|
||
const wears = r.plan.wears[r.seg - 1] || [];
|
||
const nWear = r.seg === 1 ? 0 : (r.route === 'rich' ? 2 : 1);
|
||
for (let i = 0; i < nWear; i++) {
|
||
const target = wears[i];
|
||
const arm = (ms) => {
|
||
const id = this._later(ms, () => {
|
||
if (this.state !== 'CRUISE') return;
|
||
if (this._menu.open) { arm(1000); return; } // 冻结续走
|
||
this._applyWear(target);
|
||
});
|
||
this._wearTimers.push(id);
|
||
};
|
||
arm(segLen * WEAR_AT[i]);
|
||
}
|
||
// 段计时 → 危机(菜单打开期间自旋重挂)
|
||
const armSeg = (ms) => {
|
||
this._cancel(this._segTimer);
|
||
this._segTimer = this._later(ms, () => {
|
||
if (this.state !== 'CRUISE') return;
|
||
if (this._menu.open) { armSeg(1000); return; }
|
||
this._startCrisis();
|
||
});
|
||
};
|
||
armSeg(segLen);
|
||
// 首局教学寄生于 S1
|
||
if (r.seg === 1 && !r.drillDone) this._startDrill();
|
||
}
|
||
|
||
/** 磨损:目标 ok→dmg / dmg→off / off→换备用槽;alm 脉冲+异常句+工程师 NPC(§8.1)。 */
|
||
_applyWear(target) {
|
||
const r = this.run;
|
||
if (!r) return;
|
||
if (r.sys[target] === 'off') {
|
||
const alt = (r.plan.wears[r.seg - 1] || []).find((s) => s !== target && r.sys[s] !== 'off');
|
||
if (!alt) return;
|
||
target = alt;
|
||
}
|
||
const before = r.sys[target] === 'ok' ? SYS_DMG[target] : SYS_OFF[target];
|
||
this._damageSys(target, 'wear');
|
||
this._almOnDamaged(target);
|
||
this._almKick(target);
|
||
this._speak([before, 'eng_wear']);
|
||
}
|
||
|
||
_damageSys(sys) {
|
||
const r = this.run;
|
||
r.sys[sys] = r.sys[sys] === 'ok' ? 'dmg' : 'off';
|
||
if (r.sys[sys] === 'off') r.offlineEver = true;
|
||
}
|
||
|
||
_onCruiseGesture(g) {
|
||
if (this._drill && this._drillGesture(g)) return;
|
||
if (this._tourState) { this._onTourGesture(g); return; }
|
||
if (g === 'tap') { // E1:重播最近告警 / cruise_ping
|
||
const la = this._lastAlarmPlay;
|
||
if (performance.now() - la.at <= REPLAY_ALARM_WINDOW_MS && la.id) {
|
||
this._sfx(la.id, { volume: ALM_VOL });
|
||
} else {
|
||
this._sfx('cruise_ping', { volume: TONE_VOL });
|
||
}
|
||
return;
|
||
}
|
||
if (g === 'doubletap') { if (this._lastVoiceId) this._speak([this._lastVoiceId], { queueFirst: true }); return; }
|
||
if (g === 'up') { this._tour('full'); return; }
|
||
if (g === 'down') { this._openMenu(); return; }
|
||
this._invalid();
|
||
}
|
||
|
||
// ================================================================ 巡检链(§3.3)
|
||
|
||
/**
|
||
* 全链:4×状态音(固定序,各 2.0s+0.4s 间隔)→(全 ok→tut_all_ok | 逐系统异常句)
|
||
* → st_hull → st_seg → st_rp → 三船员档。简链(决策窗 ≤10s):4×状态音 → st_hull → 榜首异常句。
|
||
*/
|
||
_tour(kind, onDone = null) {
|
||
const r = this.run;
|
||
if (!r) return;
|
||
this._cutChain();
|
||
const token = ++this._chainSeq;
|
||
const tour = { token, kind, phase: 'tones', i: 0, onDone };
|
||
this._tourState = tour;
|
||
const playTone = () => {
|
||
if (!this._tourState || this._tourState.token !== token) return;
|
||
if (tour.i >= 4) { tour.phase = 'voice'; this._tourVoice(tour); return; }
|
||
const sys = SYS_ORDER[tour.i];
|
||
const st = r.sys[sys];
|
||
const id = st === 'ok' ? SYS_TONE[sys] + '_ok' : st === 'dmg' ? SYS_TONE[sys] + '_dmg' : 'sys_offline';
|
||
this._sfx(id, { volume: st === 'off' ? FLAT_VOL : TONE_VOL });
|
||
tour.i++;
|
||
this._later(TONE_MS + TONE_GAP_MS, playTone);
|
||
};
|
||
playTone();
|
||
}
|
||
|
||
_tourVoice(tour) {
|
||
const r = this.run;
|
||
if (!this._tourState || this._tourState.token !== tour.token || !r) { this._tourState = null; return; }
|
||
const token = tour.token;
|
||
const bad = SYS_ORDER.filter((s) => r.sys[s] !== 'ok');
|
||
let ids;
|
||
if (tour.kind === 'short') {
|
||
ids = [this._hullBand()];
|
||
const top = this._topAlarmSys();
|
||
if (top) ids.push(r.sys[top] === 'dmg' ? SYS_DMG[top] : SYS_OFF[top]);
|
||
} else {
|
||
ids = bad.length ? bad.map((s) => (r.sys[s] === 'dmg' ? SYS_DMG[s] : SYS_OFF[s])) : ['tut_all_ok'];
|
||
ids.push(this._hullBand(), 'st_seg_' + r.seg, this._rpBand());
|
||
ids.push('st_xo_' + bandId(r.crew.xo), 'st_eng_' + bandId(r.crew.eng), 'st_doc_' + bandId(r.crew.doc));
|
||
}
|
||
this._speak(ids, {
|
||
onDone: () => {
|
||
if (this._tourState && this._tourState.token === token) {
|
||
this._tourState = null;
|
||
if (tour.onDone) tour.onDone(); // 教学衔接:巡检链落声后再播教学句
|
||
}
|
||
},
|
||
});
|
||
// 离心 NPC 撂话(巡检船员段之后,每局各 1 次,§5.2)
|
||
const fired = [];
|
||
if (r.crew.xo <= -2 && !this._npcFired.xo_uneasy) { this._npcFired.xo_uneasy = true; fired.push('xo_uneasy'); }
|
||
if (r.crew.eng <= -2 && !this._npcFired.eng_gripe) { this._npcFired.eng_gripe = true; fired.push('eng_gripe'); }
|
||
if (r.crew.doc <= -2 && !this._npcFired.doc_ignore) { this._npcFired.doc_ignore = true; fired.push('doc_ignore'); }
|
||
for (const n of fired) this.audio.play('voice/' + n, { queue: true });
|
||
}
|
||
|
||
/** 巡检链播中的手势(§2.2):左右滑静默忽略(保护链播);单击跳段;双击重听当前段。 */
|
||
_onTourGesture(g) {
|
||
const tour = this._tourState;
|
||
if (g === 'left' || g === 'right') return;
|
||
if (g === 'tap') {
|
||
if (tour.phase === 'tones') {
|
||
tour.phase = 'voice';
|
||
this._tourVoice(tour);
|
||
} else {
|
||
this._cutChain(); // 作废语音链 = 跳完所有段
|
||
this._tourState = null;
|
||
this._rearmWatchdog();
|
||
}
|
||
return;
|
||
}
|
||
if (g === 'doubletap') {
|
||
if (tour.phase === 'tones') this._tour(tour.kind);
|
||
else if (this._lastVoiceId) this._speak([this._lastVoiceId]);
|
||
return;
|
||
}
|
||
if (g === 'up') { this._tour(tour.kind); return; } // 重播全链
|
||
if (g === 'down') { this._tourAbort(); this._openMenu(); return; }
|
||
}
|
||
|
||
_tourAbort() { this._tourState = null; this._cutChain(); }
|
||
|
||
_hullBand() {
|
||
const r = this.run;
|
||
const f = r.hull / this._hullMax();
|
||
return f >= 0.75 ? 'st_hull_1' : f >= HULL_DANGER ? 'st_hull_2' : 'st_hull_3';
|
||
}
|
||
_rpBandVal(rp) { return rp <= 2 ? 'st_rp_1' : rp <= 6 ? 'st_rp_2' : 'st_rp_3'; }
|
||
_rpBand() { return this._rpBandVal(this.run.rp); }
|
||
|
||
/** RP 簿记(静默):返回跨档句 id 或 null(播报值=运行值,跨档双向,§8.1)。 */
|
||
_addRp(n) {
|
||
const r = this.run;
|
||
const before = this._rpBandVal(r.rp);
|
||
r.rp = Math.max(0, r.rp + n);
|
||
const after = this._rpBandVal(r.rp);
|
||
return before !== after ? after : null;
|
||
}
|
||
|
||
// ================================================================ 告警仲裁(§3.4 R1-R8)
|
||
|
||
_noteAlarm(id) { this._lastAlarmPlay = { at: performance.now(), id }; }
|
||
_damagedSys() { return this.run ? SYS_ORDER.filter((s) => this.run.sys[s] !== 'ok') : []; }
|
||
|
||
/** R1 榜首:决策窗内仅危机关联集(R5),其内/外都按威胁序取最前。 */
|
||
_almCandidates() {
|
||
const damaged = this._damagedSys();
|
||
if (!damaged.length) return [];
|
||
const inWindow = this.state === 'CRISIS' && this._crisis && this._crisis.subphase === 'decide';
|
||
const rel = inWindow ? CRISIS_REL[this._crisis.type] : null;
|
||
if (rel) {
|
||
const pool = damaged.filter((s) => rel.includes(s));
|
||
if (pool.length) return pool.sort((a, b) => THREAT.indexOf(a) - THREAT.indexOf(b));
|
||
}
|
||
return damaged.sort((a, b) => THREAT.indexOf(a) - THREAT.indexOf(b));
|
||
}
|
||
|
||
_topAlarmSys() { const c = this._almCandidates(); return c.length ? c[0] : null; }
|
||
|
||
/** 系统受损 → 启动周期脉冲循环 + 立即抢占(R1 串行由 current 门控)。 */
|
||
_almOnDamaged(sys) {
|
||
const a = this._alm;
|
||
if (!a.loops[sys]) {
|
||
const loop = () => {
|
||
if (!this.run || this.run.sys[sys] === 'ok') { delete a.loops[sys]; return; }
|
||
this._almKick(sys);
|
||
a.loops[sys] = this._later(ALM_PERIOD[sys], loop);
|
||
};
|
||
a.loops[sys] = this._later(ALM_PERIOD[sys], loop);
|
||
}
|
||
this._almStartRotate();
|
||
this._almKick(sys);
|
||
}
|
||
|
||
/** R7 修复即止:系统恢复 → 截断周期循环/排队,重算轮转。 */
|
||
_almOnRepaired(sys) {
|
||
const a = this._alm;
|
||
this._cancel(a.loops[sys]);
|
||
delete a.loops[sys];
|
||
a.deferred = a.deferred.filter((d) => d.sys !== sys);
|
||
this._almStartRotate();
|
||
}
|
||
|
||
/** R8:REPORT/死亡 → 全部清空(结算链独占)。 */
|
||
_almStopAll() {
|
||
const a = this._alm;
|
||
a.current = null;
|
||
a.holds = [];
|
||
a.deferred = [];
|
||
for (const s of Object.keys(a.loops)) this._cancel(a.loops[s]);
|
||
a.loops = {};
|
||
this._cancel(a.rotTimer);
|
||
a.rotTimer = null;
|
||
}
|
||
|
||
/** R2 轮转公平:按候选序循环占窗(双受损 15/5s,三受损 10/5/5s,四受损 10/5/5/5s)。 */
|
||
_almStartRotate() {
|
||
const a = this._alm;
|
||
this._cancel(a.rotTimer);
|
||
a.rotTimer = null;
|
||
const cand = this._almCandidates();
|
||
if (!cand.length) { a.current = null; a.holds = []; return; }
|
||
const rot = cand.length === 2 ? ROTATE[2] : cand.length === 3 ? ROTATE[3] : ROTATE4;
|
||
a.holds = cand.map((s, i) => ({ sys: s, hold: rot[Math.min(i, rot.length - 1)] }));
|
||
a.rotIdx = 0;
|
||
a.current = a.holds[0].sys;
|
||
this._almArmRotate(a.holds[0].hold);
|
||
}
|
||
|
||
_almArmRotate(ms) {
|
||
const a = this._alm;
|
||
this._cancel(a.rotTimer);
|
||
a.rotTimer = this._later(ms, () => {
|
||
a.rotTimer = null;
|
||
const cand = this._almCandidates();
|
||
if (!cand.length) { a.current = null; return; }
|
||
const names = a.holds.map((h) => h.sys).filter((s) => cand.includes(s));
|
||
if (!names.length || !a.holds.length) { this._almStartRotate(); return; }
|
||
a.rotIdx = (a.rotIdx + 1) % a.holds.length;
|
||
const h = a.holds[a.rotIdx];
|
||
if (!cand.includes(h.sys)) { this._almStartRotate(); return; }
|
||
a.current = h.sys;
|
||
this._almKick(h.sys);
|
||
this._almArmRotate(h.hold);
|
||
});
|
||
}
|
||
|
||
/** 尝试播放某系统告警脉冲:R1 单告警 + R3 去重 + R4 语音让路 + R8 冻结。 */
|
||
_almKick(sys) {
|
||
const a = this._alm;
|
||
if (!this.run || this.run.sys[sys] === 'ok') return;
|
||
if (this._menu.open || this.state === 'REPORT') return; // R8
|
||
if (a.current !== sys) return; // R1 串行
|
||
const now = performance.now();
|
||
if (now - (a.lastPlay[sys] || -1e9) < ALM_DEDUP_MS) return; // R3
|
||
if (this.audio.isVoicePlaying()) { // R4 让路(延迟不丢弃)
|
||
if (!a.deferred.some((d) => d.sys === sys)) a.deferred.push({ sys });
|
||
return;
|
||
}
|
||
a.lastPlay[sys] = now;
|
||
this._sfx(SYS_ALM[sys], { volume: ALM_VOL });
|
||
this._noteAlarm(SYS_ALM[sys]);
|
||
}
|
||
|
||
/** voice 链落声(_speak 每句完成时调用):+0.5s 恢复被让路的告警(R4)。 */
|
||
_voiceResume() {
|
||
const a = this._alm;
|
||
if (!a.deferred.length) return;
|
||
const pending = a.deferred.slice();
|
||
a.deferred = [];
|
||
this._later(500, () => { for (const d of pending) this._almKick(d.sys); });
|
||
}
|
||
|
||
// ================================================================ 危机(§2.3/§4)
|
||
|
||
_startCrisis(echo = false) {
|
||
const r = this.run;
|
||
if (!r) return;
|
||
// R6 双危机排队:已有危机占窗(含结算链期)→ FIFO 顺延至 A 结算句落声+2s
|
||
if (this._crisis || this.state === 'DOCK' || this.state === 'REPORT') {
|
||
this._crisisQueue.push({ echo });
|
||
return;
|
||
}
|
||
const type = echo ? 'meteor' : r.plan.types[r.seg - 1];
|
||
const dir = echo ? r.plan.echoDir : r.plan.dirs[r.seg - 1];
|
||
const isTrap = type === 'distress' && r.plan.trap;
|
||
this._tourAbort();
|
||
this._cancel(this._segTimer);
|
||
this._cancel(this._routeTimer);
|
||
for (const t of this._wearTimers) this._cancel(t);
|
||
this._wearTimers = [];
|
||
this.state = 'CRISIS';
|
||
const c = this._crisis = {
|
||
type, dir, optIdx: 0, subphase: 'detect', echo, trap: isTrap,
|
||
remainMs: CRISIS[type].win, startedAt: 0, deadlineId: null, tailId: null,
|
||
};
|
||
this.audio.play('bgm/crisis');
|
||
this.guide.state('battle');
|
||
this._playDetect(c);
|
||
}
|
||
|
||
/** 检测链:签名音(预 pan/尾音,2.5s)→ evt_* → dir_* →(陷阱 trap_hint+doc_trap)→ cr_decide。 */
|
||
_playDetect(c) {
|
||
const sigId = 'sig_' + c.type + DIR_SIG[c.dir];
|
||
this._sfx(sigId, { volume: SIG_VOL });
|
||
if (DIR_TAIL[c.dir]) this._sfx(DIR_TAIL[c.dir], { volume: SIG_VOL });
|
||
this._later(SIG_DUR_MS, () => {
|
||
if (this._crisis !== c || c.subphase !== 'detect' || this._menu.open) return;
|
||
const ids = [CRISIS[c.type].evt, DIR_VOICE[c.dir]];
|
||
if (c.trap) ids.push('trap_hint', 'doc_trap');
|
||
ids.push('cr_decide');
|
||
this._speak(ids, {
|
||
onDone: () => {
|
||
this._openDecision(c);
|
||
this._drillOnDetectDone(); // D1→D2 衔接(教学在 D1 纯聆听)
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
_openDecision(c) {
|
||
if (this._crisis !== c || c.subphase !== 'detect') return;
|
||
c.subphase = 'decide';
|
||
c.optIdx = 0;
|
||
// 倒计时自 cr_decide 落声起算(§2.3,信息先于计时);R5:窗内仅关联系统告警
|
||
this._armCountdown(c);
|
||
this._almStartRotate();
|
||
this._speak([OPT_VOICE[CRISIS[c.type].opts[0]]]);
|
||
this.guide.turn({});
|
||
this._rearmWatchdog();
|
||
}
|
||
|
||
_armCountdown(c) {
|
||
this._cancel(c.deadlineId);
|
||
this._cancel(c.tailId);
|
||
c.startedAt = performance.now();
|
||
c.deadlineId = this._later(c.remainMs, () => {
|
||
c.deadlineId = null;
|
||
if (this._crisis !== c || c.subphase !== 'decide') return;
|
||
if (this._menu.open) { c.remainMs = 1000; this._pauseCountdown(c); return; }
|
||
this._timeoutCrisis(c);
|
||
});
|
||
this._tailLoop(c);
|
||
}
|
||
|
||
/** 尾段 3s:tick_fast 每 0.5s + warning 震每 1s(§2.3)。 */
|
||
_tailLoop(c) {
|
||
if (this._crisis !== c || c.subphase !== 'decide' || this._menu.open) return;
|
||
const remain = c.startedAt + c.remainMs - performance.now();
|
||
if (remain <= 3000 && remain > 0) this._sfx('tick_fast', { volume: 0.7 });
|
||
c.tailId = this._later(500, () => {
|
||
const r2 = c.startedAt + c.remainMs - performance.now();
|
||
if (r2 <= 3000 && r2 > 0 && Math.ceil(r2 / 500) % 2 === 0) vibrate('warning');
|
||
this._tailLoop(c);
|
||
});
|
||
}
|
||
|
||
_pauseCountdown(c) {
|
||
const elapsed = performance.now() - c.startedAt;
|
||
c.remainMs = Math.max(1000, c.remainMs - elapsed);
|
||
this._cancel(c.deadlineId);
|
||
this._cancel(c.tailId);
|
||
}
|
||
|
||
_resumeCountdown(c) {
|
||
if (this._crisis !== c || c.subphase !== 'decide') return;
|
||
this._armCountdown(c);
|
||
}
|
||
|
||
_timeoutCrisis(c) {
|
||
if (this._crisis !== c || c.subphase !== 'decide') return;
|
||
this.run.timeouts++;
|
||
this._speak(['cr_timeout'], { onDone: () => this._settleCrisis(c, CRISIS[c.type].timeout, { fromTimeout: true }) });
|
||
}
|
||
|
||
_onCrisisGesture(g) {
|
||
const c = this._crisis;
|
||
if (!c) return;
|
||
if (c.subphase === 'detect') {
|
||
if (g === 'down') { this._cutChain(); this._openMenu(); return; } // 冻结检测链,关闭重播
|
||
return; // 纯聆听保护(§2.3)
|
||
}
|
||
if (c.subphase !== 'decide') {
|
||
if (g === 'down') this._openMenu();
|
||
return; // 结算链期:其余手势忽略
|
||
}
|
||
// 教学 D2:预览/下令接管
|
||
if (this._drill && this._drill.step === 2 && this._drillGesture(g)) return;
|
||
const opts = CRISIS[c.type].opts;
|
||
if (g === 'left' || g === 'right') {
|
||
c.optIdx = (c.optIdx + (g === 'right' ? 1 : opts.length - 1)) % opts.length;
|
||
this._speak([OPT_VOICE[opts[c.optIdx]]]);
|
||
return;
|
||
}
|
||
if (g === 'tap') { this._settleCrisis(c, opts[c.optIdx]); return; }
|
||
if (g === 'doubletap') { this._speak([OPT_VOICE[opts[c.optIdx]]]); return; }
|
||
if (g === 'up') { this._tour('short'); return; } // 巡检简链(≤10s)
|
||
if (g === 'down') { this._pauseCountdown(c); this._openMenu(); return; }
|
||
}
|
||
|
||
/**
|
||
* 结算链(§2.3):pa_chime → 执行音 →(系统离线先播 sys_*_off 真话)→ 结果句
|
||
* → 跨档句 → 忠诚句 → NPC 句 → 回巡航/段末。矩阵全量见 22 §4.2/§5.1。
|
||
*/
|
||
_settleCrisis(c, opt, { fromTimeout = false } = {}) {
|
||
if (this._crisis !== c || (c.subphase !== 'decide' && c.subphase !== 'detect')) return;
|
||
c.subphase = 'settled';
|
||
this._tourAbort();
|
||
this._cancel(c.deadlineId);
|
||
this._cancel(c.tailId);
|
||
const needSys = OPT_SYS[opt];
|
||
const st = needSys ? this.run.sys[needSys] : 'ok';
|
||
const out = this._crisisOutcome(c, opt, st, fromTimeout);
|
||
this._sfx('pa_chime', { volume: 0.8 });
|
||
const exec = OPT_SFX[opt];
|
||
if (exec) this._sfx(exec, { volume: 0.9 });
|
||
const pre = [];
|
||
if (st === 'off' && needSys) pre.push(SYS_OFF[needSys]);
|
||
this._speak([...pre, ...out.ids], { onDone: () => this._afterCrisis(c) });
|
||
}
|
||
|
||
/**
|
||
* 矩阵结算(§4.2 × 系统态全组合 + §5.1 忠诚差分):
|
||
* 改账目(hull/RP/crew/aidOk/kills/offlineFails/lastHit),组装语音句序。
|
||
* 返回 {ids};hull 伤害句(res_hurt/res_bad)由 _hullDamage 内部统一补。
|
||
*/
|
||
_crisisOutcome(c, opt, st, fromTimeout) {
|
||
const r = this.run;
|
||
const t = c.type;
|
||
const ids = [];
|
||
const sup = []; // 支援句(下令瞬间判定,播最前)
|
||
const loy = []; // 忠诚句
|
||
const npc = []; // NPC 句
|
||
let band = null; // RP 跨档句
|
||
|
||
if (t === 'meteor') {
|
||
if (opt === 'evade') {
|
||
if (st === 'ok') ids.push('res_clean');
|
||
else ids.push(...this._hurt(st === 'dmg' ? 1 : 2, 'meteor'));
|
||
} else if (opt === 'fight') {
|
||
if (st === 'ok') {
|
||
band = this._addRp(1); r.crew.xo = clamp(r.crew.xo + 1, -3, 3);
|
||
ids.push('res_mine', 'loy_fight_win');
|
||
this._sfx('res_pick', { volume: 0.8 });
|
||
} else if (st === 'dmg') {
|
||
ids.push(...this._hurt(1, 'meteor'));
|
||
} else {
|
||
r.offlineFails++;
|
||
ids.push(...this._hurt(2, 'meteor'));
|
||
}
|
||
} else { // breach / 超时≡breach(含护盾 ok 时的工程师−1,被动硬闯同真话账)
|
||
if (st === 'ok') {
|
||
this._damageSys('shield');
|
||
r.crew.eng = clamp(r.crew.eng - 1, -3, 3);
|
||
ids.push('sys_shield_dmg', 'loy_breach');
|
||
if (r.crew.eng <= -2 && !this._npcFired.eng_gripe) { this._npcFired.eng_gripe = true; npc.push('eng_gripe'); }
|
||
this._almOnDamaged('shield');
|
||
} else {
|
||
ids.push(...this._hurt(2, 'meteor'));
|
||
}
|
||
}
|
||
} else if (t === 'pirate') {
|
||
if (opt === 'fight') {
|
||
if (st === 'off') {
|
||
r.offlineFails++;
|
||
ids.push(...this._hurt(3, 'pirate'));
|
||
} else {
|
||
let dmg = st === 'dmg' ? 2 : 1;
|
||
if (r.crew.xo >= 2) { dmg = Math.max(0, dmg - 1); sup.push('sup_xo', 'xo_fight'); } // 信赖:战果升一级
|
||
else if (r.crew.xo <= -2) { dmg = dmg + 1; } // 离心:降一级
|
||
ids.push(...this._hurt(dmg, 'pirate'));
|
||
band = this._addRp(2); r.kills++;
|
||
r.crew.xo = clamp(r.crew.xo + 1, -3, 3);
|
||
ids.push('res_plunder', 'loy_fight_win', 'xo_win');
|
||
this._sfx('res_pick', { volume: 0.8 });
|
||
}
|
||
} else if (opt === 'evade') {
|
||
if (st === 'ok') { r.crew.xo = clamp(r.crew.xo - 1, -3, 3); ids.push('res_clean', 'loy_evade_pirate'); }
|
||
else ids.push(...this._hurt(st === 'dmg' ? 1 : 2, 'pirate'));
|
||
} else { // ignore / 超时
|
||
ids.push(...this._hurt(2, 'pirate'));
|
||
r.crew.doc = clamp(r.crew.doc - 1, -3, 3);
|
||
loy.push('loy_rad');
|
||
}
|
||
} else if (t === 'distress') {
|
||
if (opt === 'aid') {
|
||
if (st === 'off') {
|
||
r.offlineFails++;
|
||
ids.push(SYS_OFF.scan); // 找不到,无事发生(无惩罚无忠诚账)
|
||
} else if (c.trap) {
|
||
if (r.sys.weapon === 'ok') { // 伏击反杀
|
||
band = this._addRp(2); r.kills++; r.aidOk++;
|
||
r.crew.xo = clamp(r.crew.xo + 1, -3, 3); r.crew.doc = clamp(r.crew.doc + 1, -3, 3);
|
||
ids.push('res_plunder', 'loy_aid', 'xo_aid');
|
||
this._sfx('res_pick', { volume: 0.8 });
|
||
} else { // 陷阱受伏(武器不可用)
|
||
ids.push(...this._hurt(2, 'pirate'), 'res_ambush');
|
||
r.crew.doc = clamp(r.crew.doc - 1, -3, 3);
|
||
loy.push('loy_rad');
|
||
}
|
||
} else if (st === 'ok') {
|
||
band = this._addRp(2); r.aidOk++;
|
||
r.crew.xo = clamp(r.crew.xo + 1, -3, 3); r.crew.doc = clamp(r.crew.doc + 1, -3, 3);
|
||
ids.push('res_reward', 'loy_aid', 'doc_aid');
|
||
this._sfx('res_pick', { volume: 0.8 });
|
||
} else { // 扫描受损:成功但残骸刮蹭(归碎石撞击族,22 §15-R10)
|
||
ids.push(...this._hurt(1, 'meteor'), 'res_reward');
|
||
band = this._addRp(2); r.aidOk++;
|
||
r.crew.xo = clamp(r.crew.xo + 1, -3, 3); r.crew.doc = clamp(r.crew.doc + 1, -3, 3);
|
||
ids.push('loy_aid', 'doc_aid');
|
||
this._sfx('res_pick', { volume: 0.8 });
|
||
}
|
||
} else { // ignore / 超时
|
||
if (c.trap) ids.push('res_clean'); // 明智避开,零损益(22 §15-R7 裁定)
|
||
else {
|
||
r.crew.doc = clamp(r.crew.doc - 1, -3, 3);
|
||
ids.push('loy_ignore');
|
||
if (!this._npcFired.doc_ignore) { this._npcFired.doc_ignore = true; npc.push('doc_ignore'); }
|
||
}
|
||
}
|
||
} else if (t === 'radiation') {
|
||
if (opt === 'evade') {
|
||
if (st === 'ok') ids.push('res_clean');
|
||
else if (st === 'dmg') ids.push(...this._hurt(1, 'radiation'));
|
||
else this._radExpose(c, ids, loy);
|
||
} else if (opt === 'breach') {
|
||
if (st === 'ok') {
|
||
this._damageSys('shield');
|
||
r.crew.eng = clamp(r.crew.eng - 1, -3, 3);
|
||
ids.push('sys_shield_dmg', 'loy_breach');
|
||
this._almOnDamaged('shield');
|
||
} else if (st === 'dmg') ids.push(...this._hurt(2, 'radiation'));
|
||
else this._radExpose(c, ids, loy);
|
||
} else this._radExpose(c, ids, loy); // 超时
|
||
}
|
||
// 句序:支援 → 结果 → RP 跨档 → 忠诚 → NPC
|
||
const out = [...sup];
|
||
for (const id of ids) {
|
||
out.push(id);
|
||
if (id === 'res_reward' || id === 'res_mine' || id === 'res_plunder') {
|
||
if (band) { out.push(band); band = null; }
|
||
}
|
||
}
|
||
if (band) out.push(band);
|
||
out.push(...loy, ...npc);
|
||
return { ids: out };
|
||
}
|
||
|
||
/** 受照结局:hull−1 + 受殃系统受损(已受损→离线;已离线→顺时针下一,§3.1/§4.2)。 */
|
||
_radExpose(c, ids, loy) {
|
||
ids.push(...this._hurt(1, 'radiation'));
|
||
let sys = DIR_SYS[c.dir];
|
||
if (this.run.sys[sys] === 'off') sys = NEXT_SYS[sys];
|
||
this._damageSys(sys);
|
||
ids.push('res_rad', this.run.sys[sys] === 'off' ? SYS_OFF[sys] : SYS_DMG[sys]);
|
||
this.run.crew.doc = clamp(this.run.crew.doc - 1, -3, 3);
|
||
loy.push('loy_rad');
|
||
this._almOnDamaged(sys);
|
||
}
|
||
|
||
/** hull 伤害簿记:aegis 首伤−1(减至 0 则无伤);hit_hull+hit 震。
|
||
* 返回句数组:[结果句(res_clean/res_hurt/res_bad), 跨档句?]——由调用方编排进结算链。 */
|
||
_hurt(n, cause) {
|
||
const r = this.run;
|
||
if (!r || n <= 0) return ['res_clean'];
|
||
if (!r.aegisUsed && this.meta.unlocks.includes('aegis')) {
|
||
r.aegisUsed = true;
|
||
n = Math.max(0, n - 1);
|
||
if (n === 0) return ['res_clean'];
|
||
}
|
||
const before = this._hullBand();
|
||
r.hull = Math.max(0, r.hull - n);
|
||
r.hullLostSeg += n;
|
||
r.lastHit = cause;
|
||
this._sfx('hit_hull', { volume: 0.9 });
|
||
vibrate('hit');
|
||
const after = this._hullBand();
|
||
if (after === 'st_hull_3' && this._dangerSaidSeg !== r.seg) {
|
||
this._dangerSaidSeg = r.seg;
|
||
this.guide.state('danger');
|
||
}
|
||
const out = [n === 1 ? 'res_hurt' : 'res_bad'];
|
||
if (before !== after) out.push(after); // 跨档双向播报
|
||
return out;
|
||
}
|
||
|
||
_afterCrisis(c) {
|
||
if (this._crisis !== c) return;
|
||
this._crisis = null;
|
||
const r = this.run;
|
||
if (!r) return;
|
||
if (r.hull <= 0) { this._enterReport(true); return; }
|
||
if (this._damagedSys().length) this._almStartRotate(); // R5 窗口结束,恢复全量仲裁
|
||
// 教学危机已结算(下令/超时/忽略都走到这里,22 §15-R11):
|
||
// step≥2(决策窗教学后)→ D3 上滑→tut_done→段末;step≤1(检测期超时/跳过)→ 直接收束。
|
||
if (this._drill) {
|
||
this.state = 'CRUISE';
|
||
this.audio.play('bgm/cruise');
|
||
this.guide.state('explore');
|
||
this._postCrisisDrill = true; // 收束期标记:此时段计时已停,跳过教学须补 _endSeg
|
||
if (this._drill.step >= 2) {
|
||
this._drillGoto(3, ['tut_after', 'tut_loyal']);
|
||
} else {
|
||
this._drillGoto(4, ['tut_done'], () => { this._drillFinish(); this._endSeg(); });
|
||
}
|
||
return;
|
||
}
|
||
// R6 双危机 FIFO:排队危机在 A 结算句落声+2s 播检测链(B 窗从 B 检测链起算)
|
||
if (this._crisisQueue.length) {
|
||
const q = this._crisisQueue.shift();
|
||
this._later(2000, () => { if (!this._crisis) this._startCrisis(q.echo); });
|
||
return;
|
||
}
|
||
// S4 余波(§4.3 ⑤,40% 首局无):结算后 2s 陨石余波(绝不叠加两套签名)
|
||
if (r.seg === 4 && r.plan.echo && !this._echoDone) {
|
||
this._echoDone = true;
|
||
this._later(2000, () => { if (!this._crisis && this.state === 'CRISIS') this._startCrisis(true); });
|
||
return;
|
||
}
|
||
if (r.seg < 4) this._endSeg();
|
||
else this._enterReport(false);
|
||
}
|
||
|
||
// ================================================================ 段末 → 支援 → DOCK
|
||
|
||
_endSeg() {
|
||
const r = this.run;
|
||
this._postCrisisDrill = false;
|
||
// 段末支援结算(§5.3,DOCK 进入前)
|
||
const ids = [];
|
||
const damaged = this._damagedSys();
|
||
if (r.crew.eng >= 2 && damaged.length) {
|
||
const worst = damaged.slice().sort((a, b) => ((r.sys[b] === 'off') - (r.sys[a] === 'off')) || THREAT.indexOf(a) - THREAT.indexOf(b))[0];
|
||
r.sys[worst] = r.sys[worst] === 'off' ? 'dmg' : 'ok';
|
||
ids.push('sup_eng', 'eng_sup');
|
||
this._almOnRepaired(worst);
|
||
}
|
||
if (r.crew.doc >= 2 && r.hullLostSeg >= 1 && r.hull < this._hullMax()) {
|
||
r.hull = Math.min(this._hullMax(), r.hull + 1);
|
||
ids.push('sup_doc', 'doc_care');
|
||
}
|
||
if (!ids.length && (damaged.length || r.hullLostSeg >= 1) && !this._supNoneUsed) {
|
||
this._supNoneUsed = true;
|
||
ids.push('sup_none');
|
||
}
|
||
ids.push('evt_dock');
|
||
this._speak(ids, { onDone: () => this._enterDock() });
|
||
this._save();
|
||
}
|
||
|
||
_enterDock() {
|
||
if (!this.run) return;
|
||
this.state = 'DOCK';
|
||
this.audio.play('bgm/menu');
|
||
this.guide.state('choice');
|
||
const damaged = this._damagedSys();
|
||
const items = [];
|
||
for (const s of SYS_ORDER) if (this.run.sys[s] !== 'ok') items.push({ kind: 'repair', sys: s });
|
||
items.push({ kind: 'go' });
|
||
this._dock = { idx: 0, items };
|
||
this._speak(damaged.length ? ['dock_open', this._dockPreview()] : ['dock_empty', 'dock_go']);
|
||
this.guide.turn({});
|
||
this._armDockProg();
|
||
this._rearmWatchdog();
|
||
}
|
||
|
||
/** DOCK 无进展轨(§10.2):45s turn_tick → 90s gd_dock(full)→ 150s hint_dock3+自动预览启航项。 */
|
||
_armDockProg() {
|
||
for (const t of this._dockProgTimers) this._cancel(t);
|
||
this._dockProgTimers = [];
|
||
const step = (ms, fn) => {
|
||
const id = this._later(ms, () => {
|
||
if (this.state !== 'DOCK' || this._menu.open) return;
|
||
fn();
|
||
});
|
||
this._dockProgTimers.push(id);
|
||
};
|
||
step(45000, () => this._sfx('turn_tick', { volume: 0.6 }));
|
||
step(90000, () => { if (this.guide.level() === 'full') this._speak(['gd_dock']); });
|
||
step(150000, () => {
|
||
const goIdx = this._dock.items.length - 1;
|
||
this._dock.idx = goIdx; // 自动预览启航项(不替玩家花资源)
|
||
if (this.guide.level() === 'full') this._speak(['hint_dock3', 'dock_go']);
|
||
});
|
||
}
|
||
|
||
_dockPreview() {
|
||
const it = this._dock.items[this._dock.idx];
|
||
return it.kind === 'go' ? 'dock_go' : 'rep_' + it.sys;
|
||
}
|
||
|
||
_onDockGesture(g) {
|
||
const items = this._dock.items;
|
||
if (g === 'left' || g === 'right') {
|
||
const n = items.length;
|
||
this._dock.idx = (this._dock.idx + (g === 'right' ? 1 : n - 1)) % n;
|
||
this._speak([this._dockPreview()]);
|
||
this._armDockProg(); // 有进展,重置无进展轨
|
||
return;
|
||
}
|
||
if (g === 'tap') { this._dockExec(); return; }
|
||
if (g === 'doubletap') { this._speak([this._dockPreview()]); return; }
|
||
if (g === 'up') { this._tour('full'); return; }
|
||
if (g === 'down') { this._openMenu(); return; }
|
||
this._invalid();
|
||
}
|
||
|
||
_dockExec() {
|
||
const it = this._dock.items[this._dock.idx];
|
||
const r = this.run;
|
||
if (!r) return;
|
||
if (it.kind === 'go') {
|
||
this._sfx('seg_warp', { volume: 0.8 });
|
||
this._speak(['evt_resume'], { onDone: () => this._enterSeg(r.seg + 1) });
|
||
return;
|
||
}
|
||
if (r.rp < 2) { vibrate('error'); this._speak(['rep_poor']); return; }
|
||
const before = this._rpBandVal(r.rp);
|
||
r.rp -= 2;
|
||
r.sys[it.sys] = r.sys[it.sys] === 'off' ? 'dmg' : 'ok';
|
||
if (r.sys[it.sys] === 'ok') this._almOnRepaired(it.sys);
|
||
this._sfx('wrench', { volume: 0.8 });
|
||
vibrate('success');
|
||
const ids = ['rep_ok', 'eng_fix'];
|
||
const after = this._rpBandVal(r.rp);
|
||
if (before !== after) ids.push(after);
|
||
this._speak(ids);
|
||
// 修复即止:该项从列表消失(R7)
|
||
this._dock.items = this._dock.items.filter((x) => x.kind === 'go' || this.run.sys[x.sys] !== 'ok');
|
||
this._dock.idx = Math.min(this._dock.idx, this._dock.items.length - 1);
|
||
this._save();
|
||
}
|
||
|
||
// ================================================================ REPORT / 死亡链(§6.4/§8.2)
|
||
|
||
_enterReport(died) {
|
||
const r = this.run;
|
||
this.state = 'REPORT';
|
||
this._menu.open = false;
|
||
this._tourState = null;
|
||
this._almStopAll(); // R8 结算全静默
|
||
this._graceUntil = performance.now() + REPORT_GRACE_MS; // 死窗
|
||
this.audio.play('bgm/menu', { volume: 0.5 });
|
||
this.guide.state('menu');
|
||
// 评级(§8.2):score = 完成段×2 + 舰体剩余 + 驰援×2 + 击退×2 + 全程无离线×1
|
||
const segDone = died ? r.seg : 4;
|
||
const score = segDone * 2 + (died ? 0 : r.hull) + r.aidOk * 2 + r.kills * 2 + (r.offlineEver ? 0 : 1);
|
||
const rate = died ? 'D' : score >= 18 ? 'S' : score >= 14 ? 'A' : score >= 10 ? 'B' : 'C';
|
||
const merit = r.seg + RATE_BONUS[rate] + Math.floor(r.rp / 3); // 功勋折算(死亡局照入)
|
||
const adv = r.timeouts >= 2 ? 'die_adv_to' : r.offlineFails > 0 ? 'die_adv_sys' : 'die_adv_to';
|
||
const chain = died
|
||
? ['die_intro', 'die_' + (r.lastHit || 'meteor'), adv, 'rep_open', 'rate_d', this._meritBand(merit), 'settle_hook']
|
||
: ['rep_open', 'rate_' + rate.toLowerCase(), this._meritBand(merit), 'settle_hook'];
|
||
this._report = { chain, done: false, merit, rate };
|
||
// meta 入账
|
||
this.meta.merit += merit;
|
||
this.meta.patrols += 1;
|
||
this.meta.aids += r.aidOk;
|
||
this.meta.kills += r.kills;
|
||
if (!this.meta.bestRate || RATE_RANK[rate] > RATE_RANK[this.meta.bestRate]) this.meta.bestRate = rate;
|
||
this.run = null;
|
||
this._save();
|
||
this._speak(chain, { protect: 'report', onDone: () => { if (this._report) this._report.done = true; } });
|
||
}
|
||
|
||
_onReportGesture(g) {
|
||
const rep = this._report;
|
||
if (!rep) return;
|
||
if (!rep.done) return; // 链锁:settle_hook 落声前全吞(§6.4)
|
||
if (g === 'tap') { this._enterTitle(); return; } // settle_hook「单击,返航」
|
||
if (g === 'doubletap') {
|
||
this._speak(rep.chain, { protect: 'report', onDone: () => { if (this._report) this._report.done = true; } });
|
||
return;
|
||
}
|
||
if (g === 'up') {
|
||
const i = rep.chain.indexOf('rate_d') >= 0 ? rep.chain.indexOf('rate_d') : 1;
|
||
this._speak([rep.chain[i], rep.chain[i + 1]], { queueFirst: true });
|
||
return;
|
||
}
|
||
if (g === 'down') { this._openMenu(); return; }
|
||
this._invalid();
|
||
}
|
||
|
||
// ================================================================ DRILL(§7,寄生于首局 S1)
|
||
|
||
_startDrill() {
|
||
this._drill = { step: 0, armed: false };
|
||
this._speak(['intro_1', 'intro_2', 'intro_3', 'intro_4', 'intro_5', 'tut_d0'], { onDone: () => this._drillArm(0) });
|
||
}
|
||
|
||
_drillArm(step) {
|
||
if (!this._drill || this._drill.step !== step) return;
|
||
this._drill.armed = true;
|
||
this._drillTimers(step);
|
||
}
|
||
|
||
/** 教学无进展轨(游戏层,E0-2 先例):30s turn_tick → 60s 重教 → 90s 自动演示通过。
|
||
* R8 一致:菜单打开期间各 rung 顺延(不冻结不跳过——live R1 实测原实现会顶着菜单链开播)。 */
|
||
_drillTimers(step) {
|
||
if (!this._drill || this._drill.step !== step) return;
|
||
const rung = (delay, fn) => {
|
||
this._later(delay, () => {
|
||
if (!this._drill || this._drill.step !== step || !this._drill.armed) return;
|
||
if (this._menu.open) { rung(2000, fn); return; } // 菜单冻结:顺延不丢失
|
||
fn();
|
||
});
|
||
};
|
||
rung(30000, () => this._sfx('turn_tick', { volume: 0.6 }));
|
||
rung(60000, () => {
|
||
const re = { 0: ['tut_d0'], 2: ['tut_decide', 'tut_aid'], 3: ['tut_loyal'] }[step];
|
||
if (re) this._drillGoto(step, re); // drillGoto 自带落声武装(裸 _speak 会掐掉原链丢武装)
|
||
});
|
||
rung(90000, () => {
|
||
if (step === 0 || step === 3) this._drillDoUp();
|
||
else if (step === 2) this._drillOrderAid();
|
||
});
|
||
}
|
||
|
||
_drillGoto(step, ids, onDone) {
|
||
if (!this._drill) return;
|
||
this._drill.step = step;
|
||
this._drill.armed = false;
|
||
const arm = () => { if (this._drill) { this._drill.armed = true; this._drillTimers(step); if (onDone) onDone(); } };
|
||
if (ids && ids.length) this._speak(ids, { onDone: arm });
|
||
else arm();
|
||
}
|
||
|
||
/** DRILL 手势拦截(CRUISE 内):等待步骤的动作/跳过;其余零惩罚 error。 */
|
||
_drillGesture(g) {
|
||
const d = this._drill;
|
||
if (!d || !d.armed) return false;
|
||
if (this._tourState) return false; // 链播保护交给巡检链
|
||
if (g === 'down') { // 下滑跳过(任意步)
|
||
const post = this._postCrisisDrill; // 教学危机收束期跳过 → 补段末(否则段计时已停会卡死)
|
||
this._drill = null;
|
||
this._postCrisisDrill = false;
|
||
this.run.drillDone = true;
|
||
this.meta.drillDone = true;
|
||
this._save();
|
||
this._speak(['tut_skip']);
|
||
if (post) this._endSeg();
|
||
return true;
|
||
}
|
||
if (d.step === 0 && g === 'up') { this._drillDoUp(); return true; }
|
||
if (d.step === 3 && g === 'up') { this._drillDoUp(); return true; }
|
||
if (d.step === 2 && g === 'tap') { this._drillOrderAid(); return true; }
|
||
if (d.step === 2 && (g === 'left' || g === 'right')) return false; // 预览交给危机决策逻辑
|
||
this._invalid(); // 教学期乱按:error 震,零账目(§7 学期零惩罚)
|
||
return true;
|
||
}
|
||
|
||
_drillDoUp() {
|
||
const d = this._drill;
|
||
if (!d) return;
|
||
d.armed = false; // awaited 动作受理即解除武装(60s 重教定时器随之失效,live R1 实测竞态)
|
||
if (d.step === 0) {
|
||
// D0 反馈=巡检全链+tut_all_ok,落声后接 D1 教学句(不顶链)
|
||
this._tour('full', () => this._drillGoto(1, ['tut_crisis']));
|
||
} else if (d.step === 3) {
|
||
this._tour('full', () => {
|
||
// D4 收束:tut_done 落声 → 教学完成 → 进段末(DOCK)
|
||
this._drillGoto(4, ['tut_done'], () => { this._drillFinish(); this._endSeg(); });
|
||
});
|
||
}
|
||
}
|
||
|
||
_drillOrderAid() {
|
||
const d = this._drill;
|
||
const c = this._crisis;
|
||
if (!d || !c || d.step !== 2 || c.subphase !== 'decide') return;
|
||
c.optIdx = 0;
|
||
d.armed = false;
|
||
this._settleCrisis(c, 'aid');
|
||
}
|
||
|
||
_drillFinish() {
|
||
if (!this._drill) return;
|
||
this._drill = null;
|
||
this.run.drillDone = true;
|
||
this.meta.drillDone = true;
|
||
this._save();
|
||
}
|
||
|
||
/** D1→D2 衔接:求救检测链播完(D1 纯聆听)→ tut_decide + tut_aid。 */
|
||
_drillOnDetectDone() {
|
||
if (this._drill && this._drill.step === 1) this._drillGoto(2, ['tut_decide', 'tut_aid']);
|
||
}
|
||
|
||
// ================================================================ MENU(§2.6)
|
||
|
||
_openMenu() {
|
||
if (this.state === 'REPORT' && this._report && !this._report.done) return; // 链锁
|
||
if (this._transitionChains.size) { this._menuPending = true; return; } // 转移链播中:顺延不掐(F5)
|
||
this._menu.open = true;
|
||
this._menu.idx = 0;
|
||
this.guide.state('menu');
|
||
this._speak(['sys_menu', MENU_ITEMS[0], 'settings_hint']);
|
||
}
|
||
|
||
_closeMenu() {
|
||
this._menu.open = false;
|
||
if (this.state === 'CRISIS' || this.state === 'CRUISE') this.guide.state(this.state === 'CRISIS' ? 'battle' : 'explore');
|
||
else if (this.state === 'ROUTE' || this.state === 'DOCK' || this.state === 'PORT') this.guide.state('choice');
|
||
else this.guide.state('menu');
|
||
if (this.state === 'CRISIS' && this._crisis) {
|
||
const c = this._crisis;
|
||
if (c.subphase === 'decide') {
|
||
this._resumeCountdown(c);
|
||
this._speak([OPT_VOICE[CRISIS[c.type].opts[c.optIdx]]]); // 重播当前选项句后续走(§2.3)
|
||
} else if (c.subphase === 'detect') {
|
||
this._cutChain();
|
||
this._playDetect(c); // 检测链重播(签名起)
|
||
}
|
||
}
|
||
this._rearmWatchdog();
|
||
}
|
||
|
||
_onMenuGesture(g) {
|
||
if (g === 'tap') {
|
||
this._menu.idx = (this._menu.idx + 1) % MENU_ITEMS.length;
|
||
this._speak([MENU_ITEMS[this._menu.idx]]);
|
||
return;
|
||
}
|
||
if (g === 'doubletap') {
|
||
const item = MENU_ITEMS[this._menu.idx];
|
||
if (item === 'sys_menu_resume') this._closeMenu();
|
||
else if (item === 'sys_menu_help') this._speak(['sys_help']);
|
||
else if (item === 'sys_menu_save') this._menuSaveHome();
|
||
else this._menuAbortPatrol();
|
||
return;
|
||
}
|
||
if (g === 'up') { this._speak(['sys_help']); return; }
|
||
if (g === 'down') { this._closeMenu(); return; }
|
||
this._invalid();
|
||
}
|
||
|
||
_menuSaveHome() {
|
||
this._save();
|
||
this._speak(['sys_saved'], { onDone: () => this._goHome() });
|
||
}
|
||
|
||
_menuAbortPatrol() {
|
||
this.run = null; // run 清空保 meta(§2.6)
|
||
this._save();
|
||
this._menu.open = false;
|
||
this._enterTitle();
|
||
}
|
||
|
||
_goHome() {
|
||
if (this._goingHome) return;
|
||
this._goingHome = true;
|
||
this.stop();
|
||
try { window.location.href = '../'; } catch (_) { /* mock 环境 */ }
|
||
}
|
||
|
||
// ================================================================ 看门狗(§10.2)
|
||
|
||
_rearmWatchdog() {
|
||
if (this._menu.open) return;
|
||
if (this.state === 'CRUISE' && !this._drill && !this._tourState) {
|
||
this.guide.watchdog({ at: [10000, 25000], say: ['gd_cruise', null], cond: () => this.state === 'CRUISE' && !this._menu.open && !this._drill });
|
||
} else if (this.state === 'ROUTE') {
|
||
this.guide.watchdog({ at: [12000, 30000], say: ['gd_decide', null], cond: () => this.state === 'ROUTE' && !this._menu.open });
|
||
} else if (this.state === 'DOCK') {
|
||
this.guide.watchdog({ at: [15000, 35000], say: ['gd_dock', null], cond: () => this.state === 'DOCK' && !this._menu.open });
|
||
} else if (this.state === 'CRISIS' && this._crisis && this._crisis.subphase === 'decide') {
|
||
const win = CRISIS[this._crisis.type].win;
|
||
this.guide.watchdog({ at: [Math.min(win * 0.5, 12000)], say: ['gd_decide'], cond: () => this.state === 'CRISIS' && this._crisis && this._crisis.subphase === 'decide' && !this._menu.open });
|
||
}
|
||
}
|
||
}
|