/** * 《星环舰长》—— 舰桥指挥:听声辨型后下令。按 docs/design/26-audio-interaction- * playbook.md 深度重构(施工图 docs/design/22-bridge.md 附录 C;施工单 * docs/design/26-apply-bridge.md;台词表 docs/design/22-bridge-voice-ids.md, * 增量清单 tools/voice_lines_26/bridge.md)。 * * 玩法核心不动:四象限系统音(固定序 引擎→护盾→扫描→武器,各 2.0s+0.4s)、 * 危机事件卡(陨石/海盗/求救/辐射,窗口 12/15/20/10s)、忠诚/经济/评级三本账、 * 巡逻 4 段(90/100/110/120s)、seed 复现(plan 生成期一次性,读档复用不重洗)、 * 告警仲裁 R1-R8(单告警纪律/轮转公平/600ms 去重/voice 让路/决策窗静默无关/ * 双危机 FIFO/修复即止/菜单结算冻结——本作生命线)。 * * 交互层(26 号六模块,storyline/radio 范式): * - 发言权(§4.1):语音链走 floor.speak(BGM 压低跨链、句间不掉回「轮到你」); * 等待点(下令/选航线/修理/抉择/结算/菜单)经 floor.yieldTurn:交接提示音 → * 400ms 后等待声;巡航 = ACTION(BGM+告警脉冲即状态,22 附录 C §2)。不存在安静态。 * - 输入管道(§4.2,index.html 接线):ok/invalid/busy 回执三件套 + 400ms 防连点; * onGesture 返回 'ok'|'hold'|'invalid'|'silent-ok'。 * - 手势宪法(§4.3):doubleTapWindow=0;左右滑=换选项、单击=下令/确定、 * 上滑=巡检(我在哪:四系统音+舰体档+一个动作)、下滑=菜单(阶段 2)、 * 双击按单击(阶段 3=重听当前选项句)。旧「双击重听(新手期)/菜单双击确认/ * 单击循环菜单」废止(26 §7 反模式 5)。教学 D0 提前教上滑:D0 教学链起播即 * ladder.onGoal() 解锁(22 附录 C §3 裁定),阶梯教学句挂巡检链尾。 * - 三档提示(§4.4):等待点 full → short → 提示音;主动重听 =「再说一遍:」+ * 完整句;被动提示 30s 台账跨等待点去重(engine/hints.js 统一实现)。 * - 迷茫检测(§4.5):乱点/连无效/静默信号累计 ≥2 → 三级接管。巡航(ACTION * 无等待点)游戏层自接管:L1 gd_cruise 短令 / L2 只留上滑 / L3 替播巡检; * 等待点态走 WaitPoint.takeover(L2 缩小 expected,L3 替玩家做:替点/替选 * 稳妥/替下令/替启航/替播巡检,22 附录 C §5)。 * - 首 3 分钟(§4.10):第一句 = critical 指令「点一下屏幕。」,砍 intro_1..5 * 规则念白;3~10s 教上滑巡检;10~25s 纯聆听检测链;25~40s 预览+下令(首局 * 求救恒无陷阱,学期乱按零资源损失);≈40s pa_chime+报酬=第一次成功。 * - 断点复述(§4.12):任意时刻退出自动存档;回来 sys_continue_ask 问继续, * 复述链 st_hull → 最紧要异常 → st_seg → recap_go(与正在响的告警同源)。 * * 打断与流保护:tap=跳句(floor consumed,链继续走);滑动=打断并执行; * 结算链/死亡链/超时句 critical(22 附录 C「结算链内=吞」)。检测链与巡检链是 * 「必须听完」的内容链:被滑动打断后由游戏层补一句解释并续完剩余句(当前句 * 不原样重播,原则 9)。转移承载链(开场/教学步/段末/启航/复述/入场报名)用 * _sayFlow 兜底推进:链被掐或资产缺失时兜底定时器照常把流程走到下一等待态 * (菜单打开期间顺延,R8 冻结一致),零死锁。 * * 状态 × 手势矩阵(新手期,完整版见 22-bridge.md 附录 C §4): * | 状态 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) | * |------------|-------------------|------------------|--------------|---------------|--------------| * | 检测链在说 | 跳句+ok(签名音=busy) | invalid+解释,续完剩余句 | 打断答方位一句+续完 | invalid | 按单击 | * | 决策窗 | 下令当前预览项 | 换选项并播报名 | 巡检简链 | invalid | 按单击(阶段3=重听选项句)| * | 巡航空闲 | 重播 60s 内告警;无则 ping(silent-ok) | invalid+inv_cruise | 全链巡检 | invalid | 按单击=再听告警 | * | 巡检在播 | 跳段 | invalid+inv_tour,续完剩余 | 重播全链 | invalid | 按单击 | * | 教学等上滑 | invalid+inv_drill | 同左 | 巡检(D0/D3) | invalid | 按单击 | * | 选航线 | 选定 | 换线并报名 | 巡检 | invalid | 按单击 | * | 靠港 | 修理/启航 | 切项并报名 | 巡检 | invalid | 按单击 | * | 结算链内 | busy(critical 吞)| busy | busy | busy | busy | * | 结算链完 | 返航 | invalid+inv_report | 评级一句 | 菜单(阶段2) | 按单击 | * | 菜单 | 确认当前项 | 切项并报名 | 重听说明 | 关闭 | 按单击 | * | 星港 PORT | 购买/返回 | 切项并报名 | 舰船概况 | 菜单 | 按单击 | */ import { Game } from '../engine/game.js'; import { Floor } from '../engine/floor.js'; import { Receipts } from '../engine/receipts.js'; import { Confusion } from '../engine/confusion.js'; import { WaitPoint, InvalidHints, markHintSaid, hintSaidRecently } from '../engine/hints.js'; import { GestureLadder } from '../engine/novice.js'; import { vibrate } from '../engine/haptics.js'; 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 // 受殃顺时针下一系统:左舷扫描→右舷武器→舰首护盾→舰尾引擎(§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 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]; // ---------------- 26 号交互常量 ---------------- /** 等待点静默阈值(22 附录 C §5;决策窗不设档——危机倒计时就是玩法时钟)。 */ const SILENCE = { title: [8000, 20000], inspect: [8000, 18000], route: [12000, 30000], decide: [600000], // 窗内 tick 已催(22 附录 C §5 注),超时兜底 dock: [12000, 30000], settle: [20000, 45000], menu: [20000, 45000], port: [20000, 45000], }; /** 巡航/航线/靠港静默喂信号(迷茫检测的静默通道;等待点态由 WaitPoint 档位喂)。 */ const FEED_FIRST_MS = 12000; const FEED_REPEAT_MS = 8000; /** 转移链兜底推进估算:单句 2.6s + 落地余量(真在播会自旋续期)。 */ const FLOW_EST_PER_ID = 2600; const FLOW_EST_MARGIN = 2200; /** 菜单(00 号 §5.4:左右滑换项 + 单击确认;「放弃巡逻」破坏性 → 二次确认)。 */ const MENU_ITEMS = ['sys_menu_resume', 'sys_menu_save', '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 }; // 无效操作解释(原则 7:每「状态 × 手势」只解释一次;null = 只给回执。 // detect 的打断解释由游戏层并入续播链,不在此重复播) const INVALID_LINES = { 'locked:up': 'voice/inv_up_locked', 'locked:down': 'voice/inv_down_locked', 'locked:longpress': 'voice/gd_invalid', 'cruise:swipe': 'voice/inv_cruise', 'drill:swipe': 'voice/inv_drill', 'drill:tap': 'voice/inv_drill', 'title:swipe': 'voice/inv_title', 'report:swipe': 'voice/inv_report', 'tour:swipe': 'voice/inv_tour', }; 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); const V = (id) => 'voice/' + id; export class BridgeGame extends Game { constructor(audio, root, opts = {}) { super(audio, root); // ---- 26 号六模块接线(index.html 的 makeInputPipeline 依赖这些字段)---- this.receipts = new Receipts(audio); this.floor = new Floor(audio); this.confusion = new Confusion({ onConfused: (lvl) => this._onConfused(lvl), }); this.invalidHints = new InvalidHints().bind(this.floor); this.ladder = new GestureLadder(); this.audio.setRateBoostEnabled(false); // 新手期长按=无效手势(§4.3),不触发旁白快进 this.floor.bedEnabled = true; // 无头测试可关(防周期定时器不排水) 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, confirm: false }; this._port = { idx: 0 }; this._route = { idx: 0 }; this._dock = { idx: 0, items: [] }; this._crisis = null; // {type,dir,optIdx,subphase,echo,trap,remainMs,startedAt,deadlineId,tailId,detectIds} this._crisisQueue = []; // R6 双危机 FIFO this._report = null; // {chain, done, merit, rate} this._drill = null; // {step}(0=D0 等上滑 1=D1 聆听 2=D2 决策 3=D3 等上滑 4=收束) this._drillTeach = null; // D0 阶梯教学句(挂巡检链尾,novice.js 已带 voice/ 前缀) this._postCrisisDrill = false; // 教学危机收束期(段计时已停,收束须补 _endSeg) this._graceUntil = 0; this._goalMet = false; // 第一个小目标(D0 巡检教学)→ 阶段 1 提前解锁上滑 this._orderedOnce = false; // 第一次下令里程碑(ladder.onGoal 幂等兜底) this._goingHome = false; this._cruiseRestrict = null; // 巡航迷茫 L2:只放行的手势(up) // 段/倒计时计时器 this._segTimer = null; this._routeTimer = null; this._wearTimers = []; this._feedTimer = null; // 静默喂信号(巡航/航线/靠港) // 巡检链 this._tourState = null; // {token, kind, phase, i, from, voiceIds, teach?} this._tourSeq = 0; this._lastVoiceStart = null; // {id,t}:打断后续链的游标(audio.play 探针) // 告警仲裁(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._echoDone = false; this._detectExplained = false; // 检测链打断解释(每局 1 次) this._tourExplained = false; this._timers = new Set(); // 语音播发探针:记录最近一句 voice 起播(检测链/巡检链被打断后续播的游标) const origPlay = this.audio.play.bind(this.audio); this.audio.play = (id, playOpts = {}) => { if (typeof id === 'string' && id.startsWith('voice/')) this._lastVoiceStart = { id, t: performance.now() }; return origPlay(id, playOpts); }; } /** 管道选项(index.html 读):本作无四向/长按/双击核心玩法,全走新手阶梯。 */ get PIPELINE_OPTS() { return {}; } // ================================================================ 基础工具 /** 统一计时:全部走 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); } /** * 语音链(floor.speak 包装):BGM 压低跨链;链落声后唤醒被让路的告警(R4)。 * 需要推进流程的链用 _sayFlow(兜底推进);纯播报/报名链用本方法 * (被打断即作废,无流程损失)。 */ _speak(ids, opts = {}) { const onDone = opts.onDone; return this.floor.speak(ids, { critical: !!opts.critical, kind: opts.kind, onDone: () => { this._almResume(); if (onDone) onDone(); }, }); } /** * 转移承载链:链播完 arm;链被滑动掐断或资产缺失时,兜底定时器照常 arm * (菜单打开期间顺延——R8 冻结一致,零死锁)。兜底到点即触发、不等静默 * (dungeon _sayFlow 语义:乱滑风暴下「等静默」会让流程饿死)。 * @param {string[]} ids * @param {() => void} arm 流程落点(挂等待点 / 进下一状态) * @param {object} [opts] { critical }——结算/死亡链用 critical(矩阵=吞) */ _sayFlow(ids, arm, opts = {}) { let armed = false; const go = () => { if (armed) return; armed = true; arm(); }; this._speak(ids, { critical: !!opts.critical, onDone: go, }); const est = ids.length * FLOW_EST_PER_ID + FLOW_EST_MARGIN; const spin = () => { if (armed) return; if (this._menu.open) { this._later(1000, spin); return; } go(); }; this._later(est, spin); } /** 中断并作废巡检链(状态跳转/菜单时;语音链由 floor 统一管理)。 */ _tourAbort() { this._tourState = null; } // ================================================================ 进度 / 存档(summary 保留) _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/cruise_ping', ]); this._loadLocal(); this._pullServerProgress(); this._enterTitle(); } stop() { for (const id of this._timers) clearTimeout(id); this._timers.clear(); this._stopFeed(); this._almStopAll(); this.floor.interrupt(); this.audio.stopAll(); } // ================================================================ 手势总入口(管道收口) /** pipeline 收口后的游戏处理;返回 'ok' | 'hold' | 'invalid' | 'silent-ok'。 */ onGesture(g) { // REPORT 800ms 死窗(§6.4 保留):结算链起播瞬间的 ghost 手势全吞 if (this.state === 'REPORT' && performance.now() < this._graceUntil) return 'silent-ok'; let r; if (this._menu.open) r = this._onMenuGesture(g); else switch (this.state) { case 'TITLE': r = this._onTitleGesture(g); break; case 'PORT': r = this._onPortGesture(g); break; case 'ROUTE': r = this._onRouteGesture(g); break; case 'CRUISE': r = this._onCruiseGesture(g); break; case 'CRISIS': r = this._onCrisisGesture(g); break; case 'DOCK': r = this._onDockGesture(g); break; case 'REPORT': r = this._onReportGesture(g); break; default: r = 'invalid'; break; } return r; } stateName() { if (this._menu.open) return 'menu'; if (this._tourState) return 'tour'; switch (this.state) { case 'TITLE': return 'title'; case 'PORT': return 'port'; case 'ROUTE': return 'route'; case 'CRUISE': { // 教学等上滑步(D0/D3,等待点武装中)与巡航空闲分键解释 if (this._drill && (this._drill.step === 0 || this._drill.step === 3) && this.floor.waitPoint) return 'drill'; return 'cruise'; } case 'CRISIS': { const c = this._crisis; if (!c) return 'cruise'; if (c.subphase === 'detect') return 'detect'; if (c.subphase === 'decide') return 'decide'; return 'crisis-settle'; } case 'DOCK': return 'dock'; case 'REPORT': return 'report'; default: return 'cruise'; } } invalidHint(state, g) { if (state === 'locked') return INVALID_LINES[`locked:${g}`] || 'voice/gd_invalid'; if (g === 'left' || g === 'right') { // 换项/换选项在这些状态是合法操作;detect 的解释由游戏层并入续播链 if (state === 'menu' || state === 'port' || state === 'route' || state === 'dock' || state === 'decide') return null; if (state === 'detect') return null; return INVALID_LINES[`${state}:swipe`] || null; } if (state === 'drill' && g === 'tap') return INVALID_LINES['drill:tap']; return null; } // ================================================================ TITLE / PORT _enterTitle() { this.state = 'TITLE'; this._crisis = null; this._crisisQueue = []; this._menu.open = false; this._tourAbort(); this._stopFeed(); this.audio.play('bgm/menu'); this.floor.interrupt(); // §4.10:第一句 = critical 指令句(有档时为断点询问 §4.12) const ask = this.run ? V('sys_continue_ask') : V('sys_first_tap'); markHintSaid(ask); this._sayFlow([ask], () => { if (this.state === 'TITLE' && !this._menu.open) this.floor.yieldTurn(this._titleWp()); }, { critical: !this.run }); } _titleWp() { return new WaitPoint({ id: 'title', expected: ['tap', 'right'], full: this.run ? V('sys_continue_ask') : null, short: V('sys_first_tap_short'), silenceMs: SILENCE.title, onDemo: () => this.floor.chime(), }, { floor: this.floor, confusion: this.confusion }); } _onTitleGesture(g) { switch (g) { case 'tap': if (this.run) this._resumeRun(); else this._startPatrol(); return 'ok'; case 'doubletap': { // 阶段 3:重听询问句;未解锁时管道已折算为 tap const ask = this.run ? V('sys_continue_ask') : V('sys_first_tap'); markHintSaid(ask); this._speak([ask], { onDone: () => this.floor.yieldTurn(this._titleWp()) }); return 'hold'; } case 'right': this._enterPort(); return 'ok'; case 'up': this._titleOverview(); return 'hold'; case 'down': this._openMenu(); return 'ok'; default: return 'invalid'; // 左滑 } } /** 舰船概况链:功勋档 → 已购改装逐项效果句(价格=历史成本,真话;§2.1)。 */ _titleOverview() { const ids = [V(this._meritBand(this.meta.merit))]; for (const u of UPGRADES) if (this.meta.unlocks.includes(u.id)) ids.push(V(u.voice)); this._speak(ids, { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); } _meritBand(m) { return m <= 5 ? 'merit_1' : m <= 10 ? 'merit_2' : 'merit_3'; } _enterPort() { this.state = 'PORT'; this._port.idx = 0; this._stopFeed(); this.audio.play('bgm/menu'); this.floor.interrupt(); this._sayFlow([V('port_open'), V(this._portIdxVoice(this._portItems()))], () => { if (this.state === 'PORT' && !this._menu.open) this.floor.yieldTurn(this._portWp()); }); } _portItems() { return UPGRADES.filter((u) => !u.needs || this.meta.unlocks.includes(u.needs)); } _portWp() { return new WaitPoint({ id: 'port', expected: ['tap', 'left', 'right', 'down'], full: V('port_open'), short: V('hint_route_short'), silenceMs: SILENCE.port, onDemo: () => this.floor.chime(), }, { floor: this.floor, confusion: this.confusion }); } _onPortGesture(g) { const items = this._portItems(); const announce = () => { const id = V(this._portIdxVoice(items)); markHintSaid(id); this._speak([id], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); }; switch (g) { case 'left': case 'right': { const n = items.length + 1; // +返回项(循环) this._port.idx = (this._port.idx + (g === 'right' ? 1 : n - 1)) % n; announce(); return 'hold'; } case 'tap': case 'doubletap': { // 双击按单击 if (this._port.idx === items.length) { this._enterTitle(); return 'ok'; } // 返回 const it = items[this._port.idx]; if (this.meta.unlocks.includes(it.id)) return 'invalid'; // 不重复购买 if (this.meta.merit < it.price) { vibrate('error'); this._speak([V('port_poor')], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); return 'hold'; } this.meta.merit -= it.price; this.meta.unlocks.push(it.id); this._save(); vibrate('success'); this._speak([V('upg_buy')], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); return 'hold'; } case 'up': this._titleOverview(); return 'hold'; case 'down': this._openMenu(); return 'ok'; default: return '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(); this.floor.interrupt(); this._sayFlow([V('sys_new_patrol')], () => this._enterSeg(1)); } _resetRunFlags() { this._postCrisisDrill = false; this._npcFired = { xo_uneasy: false, eng_gripe: false, doc_ignore: false }; this._supNoneUsed = false; this._dangerSaidSeg = 0; this._echoDone = false; this._detectExplained = false; this._tourExplained = false; this._crisisQueue = []; this._orderedOnce = false; this._goalMet = this._goalMet || this.ladder.stage() >= 1; } _resumeRun() { const r = this.run; this._resetRunFlags(); r.aegisUsed = false; this.floor.interrupt(); // 中断复述链(§4.12):舰体档 → 最紧要异常 → 巡航段 → recap_go const ids = [V(this._hullBand())]; const top = this._topAlarmSys(); if (top) ids.push(V(r.sys[top] === 'dmg' ? SYS_DMG[top] : SYS_OFF[top])); ids.push(V('st_seg_' + r.seg), V('recap_go')); for (const id of ids) markHintSaid(id); this._sayFlow(ids, () => this._enterSegPhase()); } _enterSeg(seg) { const r = this.run; if (!r) return; r.seg = seg; r.route = null; r.hullLostSeg = 0; r.aegisUsed = false; this._save(); this._enterSegPhase(); } /** 段进入分流:首局 S1 教学期不选航线,直落巡航;其余先进 ROUTE。 */ _enterSegPhase() { const r = this.run; if (!r) return; if (r.seg === 1 && r.firstRun && !r.drillDone) this._enterCruise(); else this._enterRoute(); } // ================================================================ ROUTE(§2.2) _enterRoute() { this.state = 'ROUTE'; this._route.idx = 0; this.audio.play('bgm/cruise'); this.floor.interrupt(); this._armFeed(); markHintSaid(V('route_ask')); markHintSaid(V('opt_route_safe')); this._sayFlow([V('route_ask'), V('opt_route_safe')], () => { if (this.state === 'ROUTE' && !this._menu.open) this.floor.yieldTurn(this._routeWp()); }); this._armRouteTimer(45000); // 45s 自动稳妥线(结构兜底) } _routeWp() { return new WaitPoint({ id: 'route', expected: ['left', 'right'], full: V('hint_route_full'), short: V('hint_route_short'), restrictLine: V('hint_route_short'), silenceMs: SILENCE.route, onDemo: () => this._pickRoute(0), // L3 替选稳妥(22 附录 C §5) }, { floor: this.floor, confusion: this.confusion }); } _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._stopFeed(); this.run.route = idx === 0 ? 'safe' : 'rich'; const id = V(idx === 0 ? 'opt_route_safe' : 'opt_route_rich'); markHintSaid(id); this._sayFlow([id], () => this._enterCruise()); } _onRouteGesture(g) { if (this._tourState) return this._onTourGesture(g); switch (g) { case 'left': case 'right': { this._route.idx = this._route.idx === 0 ? 1 : 0; const id = V(this._route.idx === 0 ? 'opt_route_safe' : 'opt_route_rich'); markHintSaid(id); this._speak([id], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); return 'hold'; } case 'tap': this._pickRoute(this._route.idx); return 'ok'; case 'doubletap': { // 阶段 3:重听当前航线句;未解锁按单击=选定 if (this.ladder.has('doubletap')) { const id = V(this._route.idx === 0 ? 'opt_route_safe' : 'opt_route_rich'); this._speak([V('sys_say_again'), id], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); return 'hold'; } this._pickRoute(this._route.idx); return 'ok'; } case 'up': this._tour('full', 'route'); return 'hold'; case 'down': this._openMenu(); return 'ok'; default: return 'invalid'; } } // ================================================================ CRUISE(§2.2;ACTION 态=玩法声即状态) _enterCruise() { const r = this.run; if (!r) return; this.state = 'CRUISE'; this.audio.play('bgm/cruise'); this.floor.enterAction(); // 巡航 BGM + 告警脉冲即状态(22 附录 C §2) this._armFeed(); // 采集入账(§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'); // 磨损调度(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:跨档句并入 D0 教学链首(不互相顶句) if (r.seg === 1 && !r.drillDone) this._startDrill(band); else if (band) this._speak([V(band)]); } /** 磨损:目标 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); this._almOnDamaged(target); this._almKick(target); this._speak([V(before), V('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) { // 教学等上滑步(D0/D3):一次只教上滑,其余 invalid+inv_drill(§6 做错列) if (this._drill && (this._drill.step === 0 || this._drill.step === 3) && this.floor.waitPoint) { if (g === 'up') { this._drillDoUp(); return 'ok'; } return 'invalid'; } if (this._tourState) return this._onTourGesture(g); // 巡航迷茫 L2:只放行上滑(接管缩小) if (this._cruiseRestrict && g !== this._cruiseRestrict) return 'invalid'; switch (g) { case 'tap': case 'doubletap': { // 双击按单击=再听告警(E1) 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 'silent-ok'; // 内容音即回执(§4.2) } case 'up': this._tour('full', 'cruise'); return 'ok'; case 'down': this._openMenu(); return 'ok'; default: return 'invalid'; // 左右滑:inv_cruise(22 §7 失败表) } } // ================================================================ 巡检链(§3.3;上滑=我在哪) /** * 全链:4×状态音(固定序,各 2.0s+0.4s 间隔)→(全 ok→tut_all_ok | 逐系统异常句) * → st_hull → st_seg → st_rp → 三船员档。简链(决策窗 ≤10s):4×状态音 → st_hull → 榜首异常句。 * from = 发起状态(cruise/route/dock/decide),链落声后回该状态的等待态; * teach = D0 阶梯教学句(挂全链尾,一次)。 */ _tour(kind, from, teach = null) { const r = this.run; if (!r) return; this._tourAbort(); const token = ++this._tourSeq; this._tourState = { token, kind, phase: 'tones', i: 0, from: from || this._tourFrom(), voiceIds: null, teach }; const tour = this._tourState; 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(); } _tourFrom() { if (this.state === 'CRISIS' && this._crisis) return 'decide'; if (this.state === 'ROUTE') return 'route'; if (this.state === 'DOCK') return 'dock'; return 'cruise'; } _tourVoice(tour) { const r = this.run; if (!this._tourState || this._tourState.token !== tour.token || !r) { this._tourState = null; return; } const bad = SYS_ORDER.filter((s) => r.sys[s] !== 'ok'); let ids; if (tour.kind === 'short') { ids = [V(this._hullBand())]; const top = this._topAlarmSys(); if (top) ids.push(V(r.sys[top] === 'dmg' ? SYS_DMG[top] : SYS_OFF[top])); } else { ids = bad.length ? bad.map((s) => V(r.sys[s] === 'dmg' ? SYS_DMG[s] : SYS_OFF[s])) : [V('tut_all_ok')]; ids.push(V(this._hullBand()), V('st_seg_' + r.seg), V(this._rpBand())); ids.push(V('st_xo_' + bandId(r.crew.xo)), V('st_eng_' + bandId(r.crew.eng)), V('st_doc_' + bandId(r.crew.doc))); if (tour.teach) ids.push(tour.teach); // D0 阶梯教学句(挂链尾,一次) } tour.voiceIds = ids; for (const id of ids) markHintSaid(id); this._speak(ids, { onDone: () => this._tourDone(tour) }); // 离心 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(V(n), { queue: true }); } /** 巡检链落声:回发起状态的等待态;教学步推进(D0→D1 / D3→D4)。 */ _tourDone(tour) { if (!this._tourState || this._tourState.token !== tour.token) return; this._tourState = null; this._cruiseRestrict = null; // 巡检=进展事件,解除迷茫 L2 缩小 const d = this._drill; if (d && d.step === 0) { this._drillGoto(1, ['tut_crisis']); return; } if (d && d.step === 3) { this._drillGoto(4, ['tut_done'], () => { this._drillFinish(); this._endSeg(); }); return; } if (this.floor.speaking) return; // NPC 排队句还在播:由后续状态跳转收尾 const wp = this.floor.waitPoint; if (wp && (wp.conf.id === 'route' || wp.conf.id === 'dock' || wp.conf.id === 'decide')) { this.floor.resumeTurn(); } else if (this.state === 'CRUISE') { this.floor.enterAction(); } } /** * 巡检链播中的手势(22 附录 C §4 矩阵):单击跳段(tone 段→语音段;语音段= * floor 跳句);左右滑 invalid+解释并续完剩余句(保护链,当前句不重播); * 上滑重播全链(玩家请求);下滑菜单(R8 冻结,关闭后重挂等待态)。 */ _onTourGesture(g) { const tour = this._tourState; if (!tour) return 'invalid'; if (g === 'left' || g === 'right') { if (tour.phase === 'tones') return 'invalid'; // tone 段:回执+inv_tour(explainOnce 通道) const head = this._tourExplained ? [] : [V('inv_tour')]; if (head.length) { this._tourExplained = true; markHintSaid(V('inv_tour')); } this._resumeTourVoice(tour, head); return 'invalid'; } if (g === 'tap' || g === 'doubletap') { // 双击按单击 if (tour.phase === 'tones') { tour.phase = 'voice'; this._tourVoice(tour); return 'ok'; } if (g === 'doubletap') { this._resumeTourVoice(tour, []); return 'ok'; } // 跳过当前句续链 return 'ok'; // tap:floor 已按「跳句」处理(consumed) } if (g === 'up') { this._tour(tour.kind, tour.from, null); return 'ok'; } // 重播全链 if (g === 'down') { this._openMenu(); return 'ok'; } return 'invalid'; } /** 打断后续播:从被掐句的下一句续完(游标=_lastVoiceStart 探针),当前句不重播。 */ _resumeTourVoice(tour, head) { const ids = tour.voiceIds || []; let idx = -1; if (this._lastVoiceStart) idx = ids.indexOf(this._lastVoiceStart.id); const rest = idx >= 0 ? ids.slice(idx + 1) : ids; this._speak([...head, ...rest], { onDone: () => this._tourDone(tour) }); } _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.floor.speaking || 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]); } /** 语音链落声(_speak 包装的 onDone 调用):+0.5s 恢复被让路的告警(R4)。 */ _almResume() { 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); }); } // ================================================================ 静默喂信号 + 巡航接管(§4.5) /** * 迷茫静默通道:巡航/航线/靠港静默超阈值喂信号(说话/巡检/菜单期间自旋跳过)。 * confusion 的静默权重只有 1(纯静默单独到不了阈值 2),纯静默的接管升级由 * 本游戏按喂信号次数走梯子(对齐旧 30/60/90s 无进展轨):第 2 次静默 → L1、 * 第 4 次 → L2、第 6 次 → L3(约 20s/36s/52s),级别与 confusion.level 共享 * (乱点与静默混合时由 confusion 先触发,两边不重复接管)。 */ _armFeed() { this._stopFeed(); this._feedKey = null; this._feedSilent = 0; this._feedLevel = 0; const tick = () => { this._feedTimer = null; if (this._menu.open || this._tourState || this.floor.speaking) { this._feedTimer = this._later(1000, tick); return; } if (this.state === 'CRUISE' || this.state === 'ROUTE' || this.state === 'DOCK') { this.confusion.recordSilence(); this._feedSilent++; this._feedEscalate(); this._feedTimer = this._later(FEED_REPEAT_MS, tick); } }; this._feedTimer = this._later(FEED_FIRST_MS, tick); } /** 纯静默接管梯子(换等待点重置;与 confusion.level 取大不重复)。 */ _feedEscalate() { const key = this.floor.waitPoint ? this.floor.waitPoint.conf.id : this.state; if (key !== this._feedKey || (this.confusion.level === 0 && this._feedLevel > 0)) { this._feedKey = key; this._feedSilent = 1; this._feedLevel = 0; return; } const want = this._feedSilent >= 6 ? 3 : this._feedSilent >= 4 ? 2 : this._feedSilent >= 2 ? 1 : 0; if (want > this._feedLevel && want > this.confusion.level) { this._feedLevel = want; this.confusion.level = want; // 与 confusion 共享级别:本等待点不重复接管 this._onConfused(want); } } _stopFeed() { this._cancel(this._feedTimer); this._feedTimer = null; } _onConfused(lvl) { if (this._menu.open) return; const wp = this.floor.waitPoint; if (wp) { wp.takeover(lvl); return; } if (this.state === 'CRUISE' && !this._tourState) this._cruiseTakeover(lvl); } /** 巡航(ACTION 无等待点)三级接管:L1 短令 → L2 只留上滑 → L3 替播巡检。 */ _cruiseTakeover(lvl) { const say = (id) => { if (hintSaidRecently(id)) { this.floor.chime(); return; } markHintSaid(id); this._speak([id], { kind: 'hint', onDone: () => { if (this.state === 'CRUISE' && !this._menu.open) this.floor.enterAction(); } }); }; if (lvl === 1) { say(V('gd_cruise')); } else if (lvl === 2) { this._cruiseRestrict = 'up'; say(V('hint_inspect_short')); } else { this._cruiseRestrict = null; this._tour('full', 'cruise'); } } // ================================================================ 危机(§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._stopFeed(); this._cancel(this._segTimer); this._cancel(this._routeTimer); for (const t of this._wearTimers) this._cancel(t); this._wearTimers = []; this.floor.interrupt(); // 清残留等待点/杀巡检链(危机独占;floor 回 BUSY=签名音态) 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, detectIds: null, }; this.audio.play('bgm/crisis'); this._playDetect(c); } /** * 检测链:签名音(预 pan/尾音,2.5s,BUSY 态=不可跳)→ evt_* → dir_* → * (陷阱 trap_hint+doc_trap)→ cr_decide → 决策窗(倒计时自 cr_decide 落声起算)。 * 检测链是纯聆听内容链:tap=跳句;滑动打断后补一句解释并续完剩余句(原则 9)。 */ _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 }); const spin = () => { if (this._crisis !== c || c.subphase !== 'detect') return; if (this._menu.open) { this._later(1000, spin); return; } // R8 冻结:关闭后重播检测链 this._sayDetectVoice(c); }; this._later(SIG_DUR_MS, spin); } _sayDetectVoice(c) { const ids = [V(CRISIS[c.type].evt), V(DIR_VOICE[c.dir])]; if (c.trap) ids.push(V('trap_hint'), V('doc_trap')); ids.push(V('cr_decide')); c.detectIds = ids; for (const id of ids) markHintSaid(id); this._speak(ids, { onDone: () => this._openDecision(c) }); } /** 打断后续播检测链:当前句不重播;head=解释句(每局一次)/方位答句。 */ _resumeDetect(c, head = []) { const ids = c.detectIds || []; let idx = -1; if (this._lastVoiceStart) idx = ids.indexOf(this._lastVoiceStart.id); const rest = idx >= 0 ? ids.slice(idx + 1) : ids; const dirId = V(DIR_VOICE[c.dir]); const dedup = head.includes(dirId) ? rest.filter((x) => x !== dirId) : rest; this._speak([...head, ...dedup], { onDone: () => this._openDecision(c) }); } _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(); let ids = [V(OPT_VOICE[CRISIS[c.type].opts[0]])]; if (this._drill && this._drill.step === 1) { this._drill.step = 2; // D1→D2:检测链已听完,决策教学并入首选项链 ids = [V('tut_decide'), V('tut_aid'), ...ids]; } for (const id of ids) markHintSaid(id); this._sayFlow(ids, () => { if (this._crisis === c && c.subphase === 'decide' && !this._menu.open) { this.floor.yieldTurn(this._decideWp(c)); } }); } _decideWp(c) { return new WaitPoint({ id: 'decide', expected: ['tap'], full: V('hint_decide_full'), short: V('sys_first_tap_short'), silenceMs: SILENCE.decide, // 窗内 tick 已催(22 附录 C §5) onDemo: () => { // L3 演示「驰援」:教学 D2 替下令驰援;正式局替下令当前预览项 if (this._crisis !== c || c.subphase !== 'decide') return; if (this._drill && this._drill.step === 2) this._drillOrderAid(); else this._settleCrisis(c, CRISIS[c.type].opts[c.optIdx]); }, }, { floor: this.floor, confusion: this.confusion }); } _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; c.subphase = 'settling'; // 超时句=结算链首(矩阵=吞) this.run.timeouts++; this._speak([V('cr_timeout')], { critical: true, onDone: () => this._settleCrisis(c, CRISIS[c.type].timeout, { fromTimeout: true }), }); } _onCrisisGesture(g) { const c = this._crisis; if (!c) { // 结算链落声→段末/排队危机的空窗(state 仍 CRISIS):只放菜单,其余回执 if (g === 'down') { this._openMenu(); return 'ok'; } return 'invalid'; } if (this._tourState) return this._onTourGesture(g); // 决策窗巡检简链播中 if (c.subphase === 'detect') return this._onDetectGesture(g, c); if (c.subphase === 'settling' || c.subphase === 'settled') return 'ok'; // 结算链 critical=吞(管道已挡) // ---- 决策窗 ---- const opts = CRISIS[c.type].opts; switch (g) { case 'left': case 'right': { c.optIdx = (c.optIdx + (g === 'right' ? 1 : opts.length - 1)) % opts.length; const id = V(OPT_VOICE[opts[c.optIdx]]); markHintSaid(id); this._speak([id], { onDone: () => this._afterOptionAnnounce(c) }); return 'ok'; } case 'tap': this._settleCrisis(c, opts[c.optIdx]); return 'ok'; case 'doubletap': { // 阶段 3:重听当前选项句;未解锁按单击=下令 if (this.ladder.has('doubletap')) { const id = V(OPT_VOICE[opts[c.optIdx]]); this._speak([V('sys_say_again'), id], { onDone: () => this._afterOptionAnnounce(c) }); return 'hold'; } this._settleCrisis(c, opts[c.optIdx]); return 'ok'; } case 'up': this._tour('short', 'decide'); return 'ok'; // 巡检简链(≤10s) case 'down': this._pauseCountdown(c); this._openMenu(); return 'ok'; default: return 'invalid'; } } /** 选项报名落声:决策等待点在播链被顶时重挂,否则恢复等待声。 */ _afterOptionAnnounce(c) { if (this._crisis !== c || c.subphase !== 'decide' || this._menu.open) return; const wp = this.floor.waitPoint; if (wp && wp.conf.id === 'decide') this.floor.resumeTurn(); else this.floor.yieldTurn(this._decideWp(c)); } /** 检测链播中:tap=跳句(floor consumed,无需处理);滑动=invalid+解释+续完;上滑=答方位。 */ _onDetectGesture(g, c) { switch (g) { case 'left': case 'right': { const head = this._detectExplained ? [] : [V('inv_detect')]; if (head.length) { this._detectExplained = true; markHintSaid(V('inv_detect')); } this._resumeDetect(c, head); return 'invalid'; // 先听完类型与方位(22 附录 C §4) } case 'up': { // 打断,答方位一句(22 附录 C §4「busy 或打断答方位一句」),再续完剩余句 this._resumeDetect(c, [V(DIR_VOICE[c.dir])]); return 'ok'; } case 'doubletap': this._resumeDetect(c, []); return 'ok'; // 按单击=跳过当前句 case 'down': this._openMenu(); return 'ok'; // 冻结检测链,关闭后重播 default: return 'invalid'; } } /** * 结算链(§2.3):pa_chime → 执行音 →(系统离线先播 sys_*_off 真话)→ 结果句 * → 跨档句 → 忠诚句 → NPC 句 → 回巡航/段末。critical(矩阵:结算链内=吞)。 */ _settleCrisis(c, opt, { fromTimeout = false } = {}) { if (this._crisis !== c || (c.subphase !== 'decide' && c.subphase !== 'detect' && c.subphase !== 'settling')) 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(V(SYS_OFF[needSys])); for (const id of [...pre, ...out.ids]) markHintSaid(id); this._speak([...pre, ...out.ids], { critical: true, onDone: () => this._afterCrisis(c), }); // 第一次下令 = 第一个小目标里程碑(§4.3 阶梯;上滑已在 D0 提前解锁,此处幂等) if (!this._orderedOnce) { this._orderedOnce = true; this.ladder.onGoal(); } } /** * 矩阵结算(§4.2 × 系统态全组合 + §5.1 忠诚差分): * 改账目(hull/RP/crew/aidOk/kills/offlineFails/lastHit),组装语音句序。 * 返回 {ids}(voice/ 前缀);hull 伤害句(res_hurt/res_bad)由 _hurt 内部统一补。 */ _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.map((id) => V(id)) }; } /** 受照结局: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; vibrate('warning'); // 跌至危急当刻告警震(每段 1 次) } 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._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(); const spin = () => { if (this._menu.open) { this._later(1000, spin); return; } // R8 冻结顺延 if (!this._crisis) this._startCrisis(q.echo); }; this._later(2000, spin); return; } // S4 余波(§4.3 ⑤,40% 首局无):结算后 2s 陨石余波(绝不叠加两套签名) if (r.seg === 4 && r.plan.echo && !this._echoDone) { this._echoDone = true; const spin = () => { if (this._menu.open) { this._later(1000, spin); return; } if (!this._crisis && this.state === 'CRISIS') this._startCrisis(true); }; this._later(2000, spin); 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'); const voiceIds = ids.map((id) => V(id)); for (const id of voiceIds) markHintSaid(id); this._sayFlow(voiceIds, () => this._enterDock()); this._save(); } _enterDock() { if (!this.run) return; this.state = 'DOCK'; this.audio.play('bgm/menu'); this.floor.interrupt(); this._armFeed(); 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 }; const first = V(this._dockPreview()); markHintSaid(first); this._sayFlow(damaged.length ? [V('dock_open'), first] : [V('dock_empty'), first], () => { if (this.state === 'DOCK' && !this._menu.open) this.floor.yieldTurn(this._dockWp()); }); } _dockWp() { return new WaitPoint({ id: 'dock', expected: ['tap'], full: V('hint_dock_full'), short: V('hint_dock_short'), silenceMs: SILENCE.dock, onDemo: () => { // L3 替启航(22 附录 C §5):选择移到启航项再执行(不替玩家花修理资源) if (this.state !== 'DOCK') return; this._dock.idx = this._dock.items.length - 1; this._dockExec(); }, }, { floor: this.floor, confusion: this.confusion }); } _dockPreview() { const it = this._dock.items[this._dock.idx]; return it.kind === 'go' ? 'dock_go' : 'rep_' + it.sys; } _onDockGesture(g) { if (this._tourState) return this._onTourGesture(g); const announce = () => { const id = V(this._dockPreview()); markHintSaid(id); this._speak([id], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); }; switch (g) { case 'left': case 'right': { const n = this._dock.items.length; this._dock.idx = (this._dock.idx + (g === 'right' ? 1 : n - 1)) % n; announce(); return 'hold'; } case 'tap': case 'doubletap': { // 双击按单击(阶段 3 重听当前项句) if (g === 'doubletap' && this.ladder.has('doubletap')) { this._speak([V('sys_say_again'), V(this._dockPreview())], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); return 'hold'; } const before = this.state; this._dockExec(); return before === 'DOCK' && this.state === 'DOCK' ? 'hold' : 'ok'; } case 'up': this._tour('full', 'dock'); return 'hold'; case 'down': this._openMenu(); return 'ok'; default: return 'invalid'; } } _dockExec() { const it = this._dock.items[this._dock.idx]; const r = this.run; if (!r) return; if (it.kind === 'go') { this._stopFeed(); this._sfx('seg_warp', { volume: 0.8 }); this._sayFlow([V('evt_resume')], () => this._enterSeg(r.seg + 1)); return; } if (r.rp < 2) { vibrate('error'); this._speak([V('rep_poor')], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); 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 = [V('rep_ok'), V('eng_fix')]; const after = this._rpBandVal(r.rp); if (before !== after) ids.push(V(after)); for (const id of ids) markHintSaid(id); this._speak(ids, { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); // 修复即止:该项从列表消失(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._crisis = null; this._menu.open = false; this._tourAbort(); this._stopFeed(); this._almStopAll(); // R8 结算全静默 this._graceUntil = performance.now() + REPORT_GRACE_MS; // 死窗(保留) this.audio.play('bgm/menu', { volume: 0.5 }); this.floor.interrupt(); // 评级(§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)] : ['rep_open', 'rate_' + rate.toLowerCase(), this._meritBand(merit)]; // 一局结束 → 手势阶梯阶段 2/3(§4.3:第一次返航后下滑=菜单解锁),教学句挂链尾 const got = this.ladder.onGameDone(); if (got) chain.push(this.ladder.teachLine(got).slice('voice/'.length)); chain.push('settle_hook'); const voiceChain = chain.map((id) => V(id)); for (const id of voiceChain) markHintSaid(id); this._report = { chain: voiceChain, 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(voiceChain, { critical: true, // 结算链=吞(22 附录 C §4) onDone: () => { if (this._report) this._report.done = true; this.floor.yieldTurn(this._settleWp()); }, }); } _settleWp() { return new WaitPoint({ id: 'settle', expected: ['tap'], full: V('settle_hook'), short: V('sys_first_tap_short'), silenceMs: SILENCE.settle, onDemo: () => this.floor.chime(), }, { floor: this.floor, confusion: this.confusion }); } _onReportGesture(g) { const rep = this._report; if (!rep) return 'invalid'; if (!rep.done) return 'ok'; // 链锁:critical 链在播时管道已给 busy,兜底吞 switch (g) { case 'tap': case 'doubletap': // 双击按单击=返航 this._enterTitle(); return 'ok'; case 'up': { // 评级一句(rate + merit) const i = rep.chain.indexOf(V('rate_d')) >= 0 ? rep.chain.indexOf(V('rate_d')) : 1; this._speak([rep.chain[i], rep.chain[i + 1]], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); return 'hold'; } case 'down': this._openMenu(); return 'ok'; default: return 'invalid'; } } // ================================================================ DRILL(§7,寄生于首局 S1) /** * D0:开场第一下单击后进巡航 →「轮到你了」状态教学 + 巡检指令 → 等上滑。 * ladder.onGoal() 在教学链起播前提前解锁上滑(22 附录 C §3 裁定:阶段 1 解锁 * 条件=完成开场第一句后的巡检教学);阶梯教学句挂 D0 巡检链尾。 * @param {string|null} band 采集跨档句(并入链首,不互相顶句) */ _startDrill(band = null) { this._drill = { step: 0 }; if (!this._goalMet) { this._goalMet = true; const got = this.ladder.onGoal(); if (got) this._drillTeach = this.ladder.teachLine(got); // 已带 voice/ 前缀,直接进链 } const ids = []; if (band) ids.push(V(band)); ids.push(V('sys_turn_teach'), V('hint_inspect_full')); for (const id of ids) markHintSaid(id); this._sayFlow(ids, () => { if (this._drill && this._drill.step === 0 && !this._menu.open) this.floor.yieldTurn(this._inspectWp()); }); } _inspectWp() { return new WaitPoint({ id: 'inspect', expected: ['up'], full: V('hint_inspect_full'), short: V('hint_inspect_short'), restrictLine: V('hint_inspect_short'), silenceMs: SILENCE.inspect, onDemo: () => this._drillDoUp(), // L3 替播巡检(22 附录 C §5) }, { floor: this.floor, confusion: this.confusion }); } /** 教学步语音链 + 兜底推进(链被掐/资产缺失不卡死;菜单冻结顺延)。 */ _drillGoto(step, ids, onDone) { if (!this._drill) return; this._drill.step = step; const voiceIds = ids.map((id) => V(id)); for (const id of voiceIds) markHintSaid(id); this._sayFlow(voiceIds, () => { if (!this._drill || this._drill.step !== step) return; if (step === 0 || step === 3) { this._armFeed(); // 教学等待点也吃静默接管梯子(L3 替播巡检自动过步) this.floor.yieldTurn(this._inspectWp()); } else if (onDone) onDone(); else if (this.state === 'CRUISE') this.floor.enterAction(); }); } /** D0/D3:上滑 → 巡检全链(全 ok→tut_all_ok;D0 阶梯教学句挂链尾)。 */ _drillDoUp() { const d = this._drill; if (!d) return; const teach = d.step === 0 ? this._drillTeach : null; this._tour('full', 'cruise', teach); } _drillOrderAid() { const d = this._drill; const c = this._crisis; if (!d || !c || d.step !== 2 || c.subphase !== 'decide') return; c.optIdx = 0; this._settleCrisis(c, 'aid'); } _drillFinish() { if (!this._drill) return; this._drill = null; this.run.drillDone = true; this.meta.drillDone = true; this._save(); } // ================================================================ MENU(00 §5.4 + 22 §2.6,冻结 overlay) _openMenu() { if (this.state === 'REPORT' && this._report && !this._report.done) return; // 链锁(critical 已挡,兜底) this._menu.open = true; this._menu.idx = 0; this._menu.confirm = false; this._cruiseRestrict = null; this._stopFeed(); this.floor.interrupt(); // 摘等待点/杀在播链;流程链由 _sayFlow 兜底(菜单期间顺延) const first = V(MENU_ITEMS[0]); markHintSaid(first); this._sayFlow([V('sys_menu'), first], () => { if (this._menu.open) this.floor.yieldTurn(this._menuWp()); }); } _menuWp() { return new WaitPoint({ id: 'menu', expected: ['tap', 'left', 'right', 'down'], // down 恒可用(关闭是逃生口) full: V('sys_menu'), short: V('hint_route_short'), silenceMs: SILENCE.menu, onDemo: () => this.floor.chime(), }, { floor: this.floor, confusion: this.confusion }); } _closeMenu() { this._menu.open = false; this._menu.confirm = false; this.floor.interrupt(); this._rederiveWait(); } /** 菜单关闭后按当前局面重挂等待态(§4.1:不存在安静态)。 */ _rederiveWait() { if (this._menu.open) return; if (this.state === 'TITLE') { this.floor.yieldTurn(this._titleWp()); return; } if (this.state === 'PORT') { this.floor.yieldTurn(this._portWp()); return; } if (this.state === 'ROUTE') { this.floor.yieldTurn(this._routeWp()); this._armFeed(); return; } if (this.state === 'DOCK') { this.floor.yieldTurn(this._dockWp()); this._armFeed(); return; } if (this.state === 'REPORT') { if (this._report?.done) this.floor.yieldTurn(this._settleWp()); return; } if (this.state === 'CRISIS') { const c = this._crisis; if (!c) return; if (c.subphase === 'detect') { this._playDetect(c); return; } // 检测链重播(签名起) if (c.subphase === 'decide') { this._resumeCountdown(c); const id = V(OPT_VOICE[CRISIS[c.type].opts[c.optIdx]]); this._speak([id], { onDone: () => this._afterOptionAnnounce(c) }); return; } return; // settling/settled:结算链(critical 不被菜单打断)自行续走 } if (this.state === 'CRUISE') { if (this._drill && (this._drill.step === 0 || this._drill.step === 3)) { this._armFeed(); this.floor.yieldTurn(this._inspectWp()); return; } this.floor.enterAction(); this._armFeed(); } } _onMenuGesture(g) { const announce = () => { const id = V(MENU_ITEMS[this._menu.idx]); markHintSaid(id); this._speak([id], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); }; switch (g) { case 'left': case 'right': this._menu.idx = (this._menu.idx + (g === 'right' ? 1 : MENU_ITEMS.length - 1)) % MENU_ITEMS.length; this._menu.confirm = false; announce(); return 'hold'; case 'tap': case 'doubletap': { // 双击按单击=确认 const item = MENU_ITEMS[this._menu.idx]; if (item === 'sys_menu_resume') { this._closeMenu(); return 'ok'; } if (item === 'sys_menu_save') { this._menuSaveHome(); return 'ok'; } // 放弃本次巡逻:破坏性 → 二次确认(原则 6) if (!this._menu.confirm) { this._menu.confirm = true; markHintSaid(V('menu_restart_confirm')); this._speak([V('menu_restart_confirm')], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); return 'hold'; } this._menuAbortPatrol(); return 'ok'; } case 'up': this._speak([V('sys_help'), V('settings_hint')], { onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); } }); return 'hold'; case 'down': this._closeMenu(); return 'ok'; default: return 'invalid'; } } _menuSaveHome() { this._save(); this._speak([V('sys_saved')], { critical: true, 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 环境 */ } } } export default BridgeGame;