/** * 《星环舰长》客户端 —— 舰桥指挥:听声辨型后下令。 * 设计: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 }); } } }