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 置完成。
2003 lines
83 KiB
JavaScript
2003 lines
83 KiB
JavaScript
/**
|
||
* 《星环舰长》—— 舰桥指挥:听声辨型后下令。按 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;
|