Files
SoundGame/web/bridge/game.js
T
xsl 4b39ae2e49 feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
2026-09-14 05:48:13 +08:00

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