Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

2003 lines
83 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《星环舰长》—— 舰桥指挥:听声辨型后下令。按 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;