- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账) - 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产) - 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿 - radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema) - dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链) - 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿) - 大厅:新增 radio/dungeon 卡片 + GAMES 数组 - harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记 - R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份 - R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
1308 lines
54 KiB
JavaScript
1308 lines
54 KiB
JavaScript
/**
|
||
* 《不眠频率》——深夜电台热线叙事 RPG(docs/design/15-radio.md)。
|
||
*
|
||
* 你是午夜档电台热线主播「今夜不眠」:一晚接进五通电话,9 个抉择点、
|
||
* 4 结局、跨通话三个标量(rep 口碑 / heardArgument 线索 / bond 好感×5)。
|
||
*
|
||
* 6 宏状态(§2.1):INTRO / RING / ONAIR / BRIDGE / ENDING + MENU 覆盖层
|
||
* (另加 ASK = 有存档进游戏的「继续上次?」询问态,属 INTRO 前置瞬态)。
|
||
*
|
||
* 手势(§2.3,6+2 全覆盖零豁免):
|
||
* - tap :播放中=跳过本句(p* 不可跳);叙事节点链末=继续;
|
||
* RING=接听;ENDING 结算态=再来一局(新夜班)
|
||
* - doubletap :重听当前节点台词(含抉择提示);RING=重听上一通话收尾句;
|
||
* ENDING=重听结局链;MENU=确认执行(§5.2 模态约定覆盖重听)
|
||
* - left / right :ONAIR 抉择(左=走心 / 右=克制;p* 播放中缓存 pendingChoice);
|
||
* 其余态无效 → error 震 + gd_invalid(onceKey,零静默)
|
||
* - up :帮助+状态(RING=第几通+口碑档;ENDING=结局主句),随后复述局面
|
||
* - down :模态菜单(单击循环选项 / 双击确认 / 上滑帮助 / 下滑关闭)
|
||
* - longpress :引擎接管旁白 1.7×(叙事类不豁免,12 号 §2.2;游戏不收此手势)
|
||
*
|
||
* 链驱动(storyline playNode 范式):节点链 onComplete 串行推进 + seqToken 打断
|
||
* 作废 + pendingChoice 缓存(盲玩公平)。与 storyline 的差异:INTRO 开场链与
|
||
* 全部过场转移(@ring/@bridge/@ending 等)**自动衔接**(电台节目流,§2.2 转移
|
||
* 图「链完→」),ONAIR 通话内叙事节点链末等待单击(「链末=继续」+ gd_continue
|
||
* 看门狗),危机拍(h1_talko→h1_danger)与签收链自动流不等待。
|
||
*
|
||
* 资产缺失兜底:引擎 console.error 静默跳过且不回调 onComplete;单句语音
|
||
* 看门狗 30s 且 isVoicePlaying()===false 才自动续链(长句续约不掐,storyline
|
||
* N1 先例照抄)——屏蔽任意 voice 资产仍可一路走到结局,不卡死。
|
||
*
|
||
* R2 修复(research/new8/radio/round1/synthesis.md F1-F5):
|
||
* - F1 链末等待态的无效手势不再吞掉「还在吗」看门狗(disarm 后按态立即重挂);
|
||
* - F3 RING 铃声为程序合成无缝循环垫(sfx/phone_ring,游戏侧持句柄,接听即停);
|
||
* - F4 断点续玩恢复后的首句播完才武装跳句(_resumeGuard → _skipLockId);
|
||
* - F5 链末句跳过保护(等待/过场/抉择提示前的末句单击不可跳)。
|
||
*
|
||
* 确定性:铃声为无缝循环垫(无定时器重触发),测试与转写可复现。
|
||
*/
|
||
import { Game } from '../engine/game.js';
|
||
import { Guide } from '../engine/guide.js';
|
||
import { vibrate } from '../engine/haptics.js';
|
||
|
||
// ---------------------------------------------------------------- 节点表
|
||
|
||
/** 叙事节点。voices 顺序播放;prompt 非空 = 抉择节点(播完即选,W2 契约)。 */
|
||
function N(voices, opts = {}) {
|
||
return {
|
||
voices, prompt: null, left: null, right: null, next: null,
|
||
sfx: null, amb: null, dyn: null, auto: false, danger: false, preMs: 0, sfxEcho: null,
|
||
...opts,
|
||
};
|
||
}
|
||
/** 抉择节点:本体无台词,链尾接 p* 提示句(PROMPT_RE 命中 → 不可跳)。 */
|
||
function P(prompt, left, right) {
|
||
return {
|
||
voices: [], prompt, left, right, next: null,
|
||
sfx: null, amb: null, dyn: null, auto: false, danger: false, preMs: 0, sfxEcho: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 全部节点(id = 首句 voice id;台词全文见 15-radio-voice-ids.md,132 句)。
|
||
* next 为 '@x' 虚拟目标:@ring 进铃声 / @bridge 进幕间 / @h1end 回响插播判断 /
|
||
* @ending 进结局;选择目标 '@transfer' 由 resolve() 按 p1 选择解析。
|
||
*/
|
||
const NODES = {
|
||
// ---- INTRO 开场链(台标口播 + 一句规矩 + 今晚目标;自动流,§9.1「链完自动进 RING」)
|
||
b_open: N(['voice/b_open'], { sfx: 'sfx/onair_sting', auto: true, next: 'b_rain' }),
|
||
b_rain: N(['voice/b_rain'], { auto: true, next: 'b_rule' }),
|
||
b_rule: N(['voice/b_rule'], { auto: true, next: 'b_goal' }),
|
||
b_goal: N(['voice/b_goal'], { next: '@ring' }), // 链完自动进 RING(c1)
|
||
|
||
// ---- C1 老周·走丢的老人(教学通 · 2 抉择)
|
||
z1_ring_in: N(['voice/z1_ring_in'], { sfx: 'sfx/line_connect', amb: 'sfx/amb_rain_city', next: 'z1_a' }),
|
||
z1_a: N(['voice/z1_a'], { next: 'z1_b' }),
|
||
z1_b: N(['voice/z1_b'], { next: 'p1' }),
|
||
p1: P('voice/p1', 'z1_song', 'z1_ask'),
|
||
z1_song: N(['voice/z1_song'], { next: 'z1_songo' }),
|
||
z1_songo: N(['voice/z1_songo'], { next: 'z1_sdone' }),
|
||
z1_sdone: N(['voice/z1_sdone'], { next: 'p2' }),
|
||
z1_ask: N(['voice/z1_ask'], { next: 'z1_asko' }),
|
||
z1_asko: N(['voice/z1_asko'], { next: 'z1_adone' }),
|
||
z1_adone: N(['voice/z1_adone'], { next: 'p2' }),
|
||
p2: P('voice/p2', 'z1_stay', '@transfer'), // 右=转接:仅 p1=右 时改走归因组 z1_bad
|
||
z1_stay: N(['voice/z1_stay'], { next: 'z1_stayo' }),
|
||
z1_stayo: N(['voice/z1_stayo'], { next: 'z1_staye' }),
|
||
z1_staye: N(['voice/z1_staye'], { next: 'z1_ok1' }),
|
||
z1_pol: N(['voice/z1_pol'], { next: 'z1_polo' }),
|
||
z1_polo: N(['voice/z1_polo'], { next: 'z1_pole' }),
|
||
z1_pole: N(['voice/z1_pole'], { next: 'z1_ok1' }),
|
||
z1_bad: N(['voice/z1_bad'], { next: 'z1_bade' }),
|
||
z1_bade: N(['voice/z1_bade'], { next: '@bridge' }), // 归因句(真话律)后直接幕间,不播 z1_ok1
|
||
z1_ok1: N(['voice/z1_ok1'], { next: '@bridge' }), // 首通成功收尾确认(全局一次)
|
||
|
||
// ---- C2 小鹿·想退票的年轻人(单抉择 · 呼吸口)
|
||
l1_in: N(['voice/l1_in'], { sfx: 'sfx/line_connect', amb: 'sfx/amb_station', next: 'l1_a' }),
|
||
l1_a: N(['voice/l1_a'], { next: 'l1_b' }),
|
||
l1_b: N(['voice/l1_b'], { next: 'p3' }),
|
||
p3: P('voice/p3', 'l1_go', 'l1_free'),
|
||
l1_go: N(['voice/l1_go'], { next: 'l1_goo' }),
|
||
l1_goo: N(['voice/l1_goo'], { next: 'l1_goe' }),
|
||
l1_goe: N(['voice/l1_goe'], { next: '@bridge' }),
|
||
l1_free: N(['voice/l1_free'], { next: 'l1_freeo' }),
|
||
l1_freeo: N(['voice/l1_freeo'], { next: 'l1_freee' }),
|
||
l1_freee: N(['voice/l1_freee'], { next: '@bridge' }),
|
||
|
||
// ---- C3 糖糖·失眠的孩子(2 抉择 · 埋 heardArgument)
|
||
k1_in: N(['voice/k1_in'], { sfx: 'sfx/line_connect', amb: 'sfx/amb_kid_room', next: 'k1_a' }),
|
||
k1_a: N(['voice/k1_a'], { next: 'k1_b' }),
|
||
// k1_b 垫 arg_muffled 线索音(信息层,起播后再叠一次,「2-3 次」取确定值 2)
|
||
k1_b: N(['voice/k1_b'], { sfx: 'sfx/arg_muffled', sfxEcho: { id: 'sfx/arg_muffled', atMs: 6500 }, next: 'p4' }),
|
||
p4: P('voice/p4', 'k1_lull', 'k1_ask'), // 左=哄睡(错过线索)/ 右=问她(heardArgument=true)
|
||
k1_lull: N(['voice/k1_lull'], { next: 'k1_lullo' }),
|
||
k1_lullo: N(['voice/k1_lullo'], { next: 'k1_lulle' }),
|
||
k1_lulle: N(['voice/k1_lulle'], { next: '@bridge' }),
|
||
k1_ask: N(['voice/k1_ask'], { next: 'k1_asko' }),
|
||
k1_asko: N(['voice/k1_asko'], { next: 'p5' }),
|
||
p5: P('voice/p5', 'k1_truth', 'k1_lie'), // 仅 p4=右 可达
|
||
k1_truth: N(['voice/k1_truth'], { next: 'k1_trutho' }),
|
||
k1_trutho: N(['voice/k1_trutho'], { next: 'k1_truthe' }),
|
||
k1_truthe: N(['voice/k1_truthe'], { next: '@bridge' }),
|
||
k1_lie: N(['voice/k1_lie'], { next: 'k1_lieo' }),
|
||
k1_lieo: N(['voice/k1_lieo'], { next: 'k1_liee' }),
|
||
k1_liee: N(['voice/k1_liee'], { next: '@bridge' }),
|
||
|
||
// ---- C4 陈姐·争吵的夫妻(2 抉择 · 消费 heardArgument)
|
||
w1_in: N(['voice/w1_in'], { sfx: 'sfx/line_connect', amb: 'sfx/amb_street_rain', next: 'w1_a' }),
|
||
w1_a: N(['voice/w1_a'], { next: 'w1_b' }),
|
||
w1_b: N(['voice/w1_b'], { next: 'p6' }),
|
||
p6: P('voice/p6', 'w1_say', 'w1_home'),
|
||
// w1_say 节点内选播(唯一跨来电内容差分之一,不改树形):heardArgument
|
||
// 决定播 w1_say 组还是 w1_say2 组,两条件路线共用同一节点 id,汇于 p7。
|
||
w1_say: N([], { dyn: 'w1say', next: 'p7' }),
|
||
w1_home: N(['voice/w1_home'], { next: 'w1_homeo' }),
|
||
w1_homeo: N(['voice/w1_homeo'], { next: 'w1_homee' }),
|
||
w1_homee: N(['voice/w1_homee'], { next: '@bridge' }), // 右路本通结束,无 p7
|
||
p7: P('voice/p7', 'w1_air', 'w1_priv'), // 仅 p6=左 可达
|
||
w1_air: N(['voice/w1_air'], { next: 'w1_airo' }),
|
||
w1_airo: N(['voice/w1_airo'], { next: 'w1_aire' }),
|
||
w1_aire: N(['voice/w1_aire'], { next: '@bridge' }),
|
||
w1_priv: N(['voice/w1_priv'], { next: 'w1_privq' }),
|
||
w1_privq: N(['voice/w1_privq'], { next: 'w1_prive' }),
|
||
w1_prive: N(['voice/w1_prive'], { next: '@bridge' }),
|
||
|
||
// ---- C5 老韩·雨夜司机(高潮 · 危机拍 + bond_zhou 回响)
|
||
h1_in: N(['voice/h1_in'], { sfx: 'sfx/line_connect', amb: 'sfx/amb_cab', next: 'h1_a' }),
|
||
h1_a: N(['voice/h1_a'], { next: 'h1_b' }),
|
||
h1_b: N(['voice/h1_b'], { next: 'p8' }),
|
||
p8: P('voice/p8', 'h1_talk', 'h1_stop'),
|
||
h1_talk: N(['voice/h1_talk'], { next: 'h1_talko' }),
|
||
// 危机拍整段自动流(戏剧事件不等待单击):talko → danger(前置 2s 预警) → dango → talke → 幕间
|
||
h1_talko: N(['voice/h1_talko'], { auto: true, next: 'h1_danger' }),
|
||
h1_danger: N(['voice/h1_danger'], {
|
||
danger: true, preMs: 2000, sfx: 'sfx/brake_screech', auto: true, next: 'h1_dango',
|
||
}),
|
||
h1_dango: N(['voice/h1_dango'], { auto: true, next: 'h1_talke' }),
|
||
h1_talke: N(['voice/h1_talke'], { next: '@h1end' }),
|
||
h1_stop: N(['voice/h1_stop'], { next: 'h1_stopo' }),
|
||
h1_stopo: N(['voice/h1_stopo'], { next: 'h1_stope' }),
|
||
h1_stope: N(['voice/h1_stope'], { next: '@h1end' }),
|
||
// 回响插播判断:bond_zhou=1 补播 h1_echo(C1→C5 回响),否则直接幕间
|
||
h1_end: N([], { dyn: 'h1echo', next: '@bridge' }),
|
||
|
||
// ---- 签收(SIGNOFF 属 ONAIR,不单设状态)+ ◆p9 心结抉择
|
||
g9_in: N(['voice/g9_in'], { auto: true, next: 'p9' }), // 垫乐起,自动流入签收抉择
|
||
p9: P('voice/p9', 'd9_face', 'd9_avoid'),
|
||
d9_face: N(['voice/d9_face'], { sfx: 'sfx/tape_static', auto: true, next: 'd9_facee' }),
|
||
d9_facee: N(['voice/d9_facee'], { next: '@ending' }),
|
||
d9_avoid: N(['voice/d9_avoid'], { auto: true, next: 'd9_avoide' }),
|
||
d9_avoide: N(['voice/d9_avoide'], { next: '@ending' }),
|
||
};
|
||
|
||
// ---------------------------------------------------------------- 常量表
|
||
|
||
const CALLS = [
|
||
{ key: 'zhou', label: '走丢的老人', entry: 'z1_ring_in' },
|
||
{ key: 'deer', label: '退票的年轻人', entry: 'l1_in' },
|
||
{ key: 'kid', label: '失眠的孩子', entry: 'k1_in' },
|
||
{ key: 'wife', label: '争吵的夫妻', entry: 'w1_in' },
|
||
{ key: 'han', label: '雨夜司机', entry: 'h1_in' },
|
||
];
|
||
const CALL_OF_NODE = { z: 1, l: 2, k: 3, w: 4, h: 5, g: 5, d: 5 }; // 节点前缀 → 通号
|
||
|
||
/** 上滑状态链的「第几通」分档整句(§4.3 数字规避:禁止运行时拼接)。 */
|
||
const PROG_VOICES = [
|
||
'voice/prog_c1', 'voice/prog_c2', 'voice/prog_c3', 'voice/prog_c4', 'voice/prog_c5',
|
||
];
|
||
/** 口碑分档整句(BRIDGE 跨档被动播报 + 上滑状态链复用;rep_low 附 warning 震)。 */
|
||
const TIER_VOICES = { high: 'voice/rep_high', mid: 'voice/rep_mid', low: 'voice/rep_low' };
|
||
|
||
const MENU_ITEMS = [
|
||
{ id: 'resume', voice: 'voice/sys_menu_resume' }, // 回到直播
|
||
{ id: 'restart', voice: 'voice/sys_menu_restart' }, // 重新开始今晚
|
||
{ id: 'lobby', voice: 'voice/sys_menu_lobby' }, // 保存并回大厅
|
||
];
|
||
|
||
/** 4 结局(§3.4 矩阵:rep≥5 高/低口碑 × p9 直面/未解)。 */
|
||
const ENDINGS = {
|
||
e1: { name: '天亮之前', voices: ['voice/e1_a', 'voice/e1_b'] },
|
||
e2: { name: '热线常亮', voices: ['voice/e2_a', 'voice/e2_b'] },
|
||
e3: { name: '一个人的频道', voices: ['voice/e3_a', 'voice/e3_b'] },
|
||
e4: { name: '静音', voices: ['voice/e4_a', 'voice/e4_b'] },
|
||
};
|
||
/** 尾声回响:5 个来电者各播 bond ? 救到 : 错过(P2 回响感)。 */
|
||
const EP_VOICES = {
|
||
zhou: ['voice/ep_zhou_ok', 'voice/ep_zhou_no'],
|
||
deer: ['voice/ep_deer_ok', 'voice/ep_deer_no'],
|
||
kid: ['voice/ep_kid_ok', 'voice/ep_kid_no'],
|
||
wife: ['voice/ep_wife_ok', 'voice/ep_wife_no'],
|
||
han: ['voice/ep_han_ok', 'voice/ep_han_no'],
|
||
};
|
||
|
||
const ALL_SFX = [
|
||
'sfx/onair_sting', 'sfx/phone_ring', 'sfx/line_connect', 'sfx/line_drop',
|
||
'sfx/amb_rain_city', 'sfx/amb_station', 'sfx/amb_kid_room', 'sfx/arg_muffled',
|
||
'sfx/amb_street_rain', 'sfx/amb_cab', 'sfx/brake_screech', 'sfx/tape_static',
|
||
'sfx/ui_confirm', 'sfx/ui_menu',
|
||
];
|
||
|
||
const SAVE_KEY = 'soundgame.progress.radio';
|
||
const AUTH_KEY = 'soundgame.auth';
|
||
|
||
/** 抉择提示音 id(voice/p1~p9):播放中单击不可跳(盲玩公平,12 号 §1.2)。 */
|
||
const PROMPT_RE = /^voice\/p\d/;
|
||
|
||
/** 单句语音看门狗:超时且引擎确认 voice 未在播 = 资产缺失才续链(storyline N1 先例)。 */
|
||
const VOICE_WATCHDOG_MS = 30000;
|
||
|
||
// 向导层看门狗(§7.2;full 档 say 由 guide 播,standard 档 onEscalate 游戏层补播,
|
||
// off 档整体不挂——与 storyline F1.1 / P2-3 同款双档节奏)。
|
||
const GUIDE_WATCHDOG_AT = [25000, 50000]; // full:叙事链末 / ENDING 结算等待
|
||
const IDLE_HINT_AT = [13000, 30000]; // standard:同 storyline P2-3 先例
|
||
const CHOICE_HINT_AT = [25000, 50000]; // 抉择点:两级都重播当前 p* 提示句本身
|
||
const RING_WATCHDOG_AT = [10000, 25000]; // RING 两级**不同句**(gd_ring1 ≠ gd_ring2)
|
||
|
||
// 铃声节奏(双音一响 + 静默)烘进 sfx/phone_ring 文件本身(5.2s ≥4s 一循环,
|
||
// 无缝 loop,见 gen/gen_sfx.py);游戏侧只持一个循环句柄,不再定时重触发。
|
||
const BRIDGE_REST_MS = 3000; // 幕间垫乐喘息(自动链 ≤12s:rep 整句 + 3s 垫乐)
|
||
const DANGER_LEAD_MS = 2000; // 危机拍前置预警窗(brake 渐强 + danger 签名同窗)
|
||
|
||
/** 口碑档位(§4.3):≥5 高 / 1-4 中 / ≤0 低。 */
|
||
function tierOf(rep) {
|
||
if (rep >= 5) return 'high';
|
||
if (rep >= 1) return 'mid';
|
||
return 'low';
|
||
}
|
||
|
||
export class RadioGame extends Game {
|
||
constructor(audio, root) {
|
||
super(audio, root);
|
||
this.guide = new Guide(audio); // 全局向导层(guide='off' 时全部静默 no-op)
|
||
|
||
this.phase = 'INTRO'; // INTRO|RING|ONAIR|BRIDGE|ENDING|MENU|ASK
|
||
this.returnPhase = null; // MENU 关闭后回原状态
|
||
this.returnNode = null;
|
||
|
||
this.nodeId = 'b_open';
|
||
this.callN = 0; // 当前通号(0=开场未接入)
|
||
this.endingId = null;
|
||
|
||
this.voicePlaying = false;
|
||
this.currentVoiceId = null; // 链上当前句 id
|
||
this.promptPlaying = false; // 当前句是抉择提示 p*
|
||
this.choiceArmed = false;
|
||
this.pendingChoice = null;
|
||
this.awaitingRestart = false;
|
||
this.leaving = false; // 菜单「保存并回大厅」已确认(跳转大厅前 true)
|
||
this.seqToken = 0;
|
||
|
||
// 跨通话变量(§4.1)
|
||
this.rep = 0;
|
||
this.knot = false; // 心结,仅 p9=左 置 true
|
||
this.heardArgument = false; // p4=右 置 true,C4 消费
|
||
this.bond = { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0 };
|
||
this.endings = {}; // 结局收集(跨夜保留)
|
||
this.nights = 0;
|
||
this.p1Choice = null; // p1 选择(p2 计分 / @transfer 解析用)
|
||
|
||
this.lastAnnouncedTier = 'low'; // BRIDGE 跨档播报基准(开局 rep=0 → low)
|
||
this.lastCloser = null; // 上一通话收尾句(RING 双击重听用)
|
||
this.sysSkipShown = false; // sys_skip 开场每实例一次(storyline skip_hint 先例)
|
||
this.saved = null; // ASK 态暂存的存档
|
||
this._menuQueue = []; // 菜单项报名队列(游戏侧排队,不截断当前报名)
|
||
|
||
// F4 断点续玩跳句锁:_resume 置位,恢复链首句起播时落为该句 id,播完即清
|
||
//(首句播完才武装跳句——ASK「单击=确认」与「单击=跳句」的语义陷阱,R2 F4)
|
||
this._resumeGuard = false;
|
||
this._skipLockId = null;
|
||
|
||
// F3 铃声循环句柄(sfx/phone_ring 无缝 loop;引擎 sfx 层无 loop/stop,
|
||
// 游戏侧经 ctx 起源并持句柄——guide earcon 同款直用引擎总线,接听/菜单即停)
|
||
this._ringSrc = null;
|
||
|
||
// 计时器句柄(菜单/重开时统一清理)
|
||
this.ambTimer = null;
|
||
this.ambId = null;
|
||
this.restTimer = null;
|
||
|
||
/** 环境音循环开关:通话侧 amb 由游戏层 onEnded 重触发(escape amb 先例);
|
||
* 无头测试/转写置 false(重触发定时器会让虚拟时钟永不空闲)。 */
|
||
this.ambLoopEnabled = true;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Game
|
||
|
||
start() {
|
||
this.audio.play('bgm/onair'); // 全夜班不切(bgm=0 时引擎只记意图,重开自动补播)
|
||
this.audio.prewarm(ALL_SFX); // 预解码短音效(等价 Android SoundPool 音量 0 预热)
|
||
const local = this._loadLocal();
|
||
if (local && local.phase && local.node) {
|
||
this._askContinue(local);
|
||
return;
|
||
}
|
||
// 无本地存档:登录态下发服务器存档(1.5s 内未返回/无存档 → 直接开新夜班)
|
||
let auth = null;
|
||
try { auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch (_) { /* 静默 */ }
|
||
if (!(auth && auth.token)) { this.newNight(); return; }
|
||
let settled = false;
|
||
const goFresh = () => { if (settled) return; settled = true; this.newNight(); };
|
||
fetch('/api/progress?game=radio', { headers: { Authorization: `Bearer ${auth.token}` } })
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((j) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
const d = j && j.data;
|
||
if (d && d.phase && d.node) this._askContinue(d);
|
||
else goFresh();
|
||
})
|
||
.catch(goFresh);
|
||
setTimeout(goFresh, 1500);
|
||
}
|
||
|
||
stop() {
|
||
this.seqToken++; // 作废一切在途链回调:stop 后(页面卸载/测试拆除)中断链
|
||
this._stopRing(); // 不得经 30s 语音看门狗"资产缺失"路径复活(复活会走到
|
||
this._stopAmb(); // enterRing 留下无人接听的永久铃声循环)
|
||
this._resumeGuard = false; // F4 跳句锁随实例拆除
|
||
this._skipLockId = null;
|
||
if (this.restTimer) { clearTimeout(this.restTimer); this.restTimer = null; }
|
||
this.audio.stopAll();
|
||
}
|
||
|
||
onGesture(g) {
|
||
this.guide.disarm(); // 任何手势复位看门狗(guide=off 时 no-op)
|
||
switch (this.phase) {
|
||
case 'ASK': this._onAskGesture(g); break;
|
||
case 'MENU': this._onMenuGesture(g); break;
|
||
case 'RING': this._onRingGesture(g); break;
|
||
case 'ONAIR': case 'INTRO': this._onStoryGesture(g); break;
|
||
case 'BRIDGE': this._onBridgeGesture(g); break;
|
||
case 'ENDING': this._onEndingGesture(g); break;
|
||
default: break;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:ASK
|
||
|
||
_onAskGesture(g) {
|
||
const go = g === 'tap' || g === 'right'; // 推进手势:局面即将改变,不重挂
|
||
if (g === 'tap') this._resume(this.saved);
|
||
else if (g === 'right') this.newNight();
|
||
else if (g === 'longpress' || g === 'longpressend' || g === 'tapcancel') { /* 引擎快进接管 */ }
|
||
else this.guide.error({ voice: 'gd_invalid', onceKey: 'radio_invalid' });
|
||
// F1:非推进手势已被 onGesture 的 guide.disarm() 拆掉 ASK 空闲看门狗,
|
||
// 处理完立即重挂——否则询问态从此无限静默(live t=445.4 同款断音)
|
||
if (!go && this.phase === 'ASK' && !this.voicePlaying) this._armIdleWatchdog('ASK');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:RING
|
||
|
||
_onRingGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
this._stopRing(); // F3:铃声循环垫接听即停
|
||
this.phase = 'ONAIR';
|
||
this.playNode(CALLS[this.callN - 1] ? CALLS[this.callN - 1].entry : 'z1_ring_in');
|
||
break;
|
||
case 'doubletap':
|
||
// 重听上一通话的收尾句(首通前无上一通 → 无效手势零静默;
|
||
// INTRO 链不走此路径——收尾句只来自通话节点)
|
||
if (this.callN <= 1 || !this.lastCloser) {
|
||
this.guide.error({ voice: 'gd_invalid', onceKey: 'radio_invalid' });
|
||
this._rearmRingWatchdog(); // F1:重听请求无效也不拆铃看门狗
|
||
return;
|
||
}
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
this._playSeq([this.lastCloser], () => {
|
||
this.voicePlaying = false;
|
||
if (this.phase === 'RING') this._armRingWatchdog();
|
||
});
|
||
break;
|
||
case 'up': {
|
||
// 状态播报(第几通 + 口碑档),留在 RING
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
const ids = [PROG_VOICES[Math.max(0, this.callN - 1)], TIER_VOICES[tierOf(this.rep)]];
|
||
this._playSeq(ids, () => {
|
||
this.voicePlaying = false;
|
||
if (this.phase === 'RING') this._armRingWatchdog();
|
||
});
|
||
break;
|
||
}
|
||
case 'down':
|
||
this.openMenu();
|
||
break;
|
||
case 'left':
|
||
case 'right':
|
||
// 无效手势零静默(§2.3 RING 行:左右滑 → error 震 + gd_invalid)
|
||
this.guide.error({ voice: 'gd_invalid', onceKey: 'radio_invalid' });
|
||
this._rearmRingWatchdog(); // F1:无效手势不拆铃看门狗(拆后不补=静默死局)
|
||
break;
|
||
default:
|
||
this._rearmRingWatchdog(); // F1:快进等引擎接管手势同款(空闲态才重挂)
|
||
break; // longpress/longpressend/tapcancel:引擎快进接管,游戏不处理
|
||
}
|
||
}
|
||
|
||
/** F1:RING 空闲(无对白在播)时重挂两级铃看门狗;重听/播报在播时不抢挂。 */
|
||
_rearmRingWatchdog() {
|
||
if (this.phase === 'RING' && !this.audio.isVoicePlaying()) this._armRingWatchdog();
|
||
}
|
||
|
||
_callEntry() {
|
||
const call = CALLS[this.callN - 1];
|
||
return call && NODES[call.entry] ? call.entry : 'z1_ring_in';
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:ONAIR / INTRO
|
||
|
||
_onStoryGesture(g) {
|
||
const node = NODES[this.nodeId];
|
||
if (!node) return;
|
||
switch (g) {
|
||
case 'tap':
|
||
if (this.voicePlaying) {
|
||
// 旁白播放中单击 = 跳过本句(链继续);不可跳的三类:
|
||
// ① 抉择提示 p*(PROMPT_RE 门,盲玩公平);
|
||
// ② F5 链末句——等待/过场/抉择交接前的末句(抢跑吃钩子:蛋糕句/点题句);
|
||
// ③ F4 续玩锁——断点恢复后的首句播完才武装跳句。
|
||
if (!this.promptPlaying && !this._isTailSentence()
|
||
&& this.currentVoiceId !== this._skipLockId) {
|
||
this.audio.skipVoice();
|
||
}
|
||
} else if (node.prompt != null && this.choiceArmed) {
|
||
this.armChoiceHint(); // 抉择等待态空点 = 重新挂载兜底(25s 计时重置)
|
||
} else if (node.next != null) {
|
||
this.advance(node.next);
|
||
}
|
||
break;
|
||
case 'doubletap':
|
||
this.replayNode();
|
||
break;
|
||
case 'left':
|
||
case 'right': {
|
||
if (node.prompt == null) {
|
||
// 无效手势零静默(§2.3):error 震 + full 档 gd_invalid(onceKey 同局一次);
|
||
// F1:onGesture 入口的 disarm 已拆链末看门狗,处理完立即重挂
|
||
//(live 局 B t=445.4:无效左滑后 34.2s 零事件,「还在吗」永不来的根因)
|
||
this.guide.error({ voice: 'gd_invalid', onceKey: 'radio_invalid' });
|
||
if (this._inStoryWait()) this._armIdleWatchdog(this.phase);
|
||
return;
|
||
}
|
||
if (this.choiceArmed) this.choose(g);
|
||
else if (this.voicePlaying) this.pendingChoice = g; // p* 播放中缓存(盲玩公平)
|
||
break;
|
||
}
|
||
case 'up':
|
||
this._playHelpAndResume();
|
||
break;
|
||
case 'down':
|
||
this.openMenu();
|
||
break;
|
||
default:
|
||
// longpress/longpressend/tapcancel:引擎快进接管,游戏不处理;
|
||
// F1 同款:链末等待态下它们同样拆掉了看门狗,立即重挂
|
||
if (this._inStoryWait()) this._armIdleWatchdog(this.phase);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/** 当前是否处于 ONAIR/INTRO「链末等待单击」态(F1 重挂判据:与 _onNodeSeqDone 的
|
||
* 等待分支同口径——非抉择节点、有后继、非自动流、非过场转移、且无对白在播)。 */
|
||
_inStoryWait() {
|
||
const node = NODES[this.nodeId];
|
||
return !!(node && !this.voicePlaying && node.prompt == null && node.next != null
|
||
&& !node.auto && !this._isTransition(node.next));
|
||
}
|
||
|
||
/**
|
||
* F5 链末句保护:当前句是本节点末句、且节点把控制权交出(链末等待单击 /
|
||
* @x 过场转移 / 直入抉择提示)时,屏蔽「跳过本句」——单击身兼跳句与继续,
|
||
* 末句被跳过则关键尾句(k1_trutho 的蛋糕句、d9_*e 的点题句)被抢跑吃掉,
|
||
* 下一句应答在没播出的钩子上(live 局 A 三次吃尾的根因)。
|
||
* 自动链中段(b_open/b_rain、危机拍)不受保护,跳句自由保留。
|
||
*/
|
||
_isTailSentence() {
|
||
const node = NODES[this.nodeId];
|
||
if (!node || !this.voicePlaying || this.promptPlaying) return false;
|
||
const seq = this._nodeVoices(this.nodeId);
|
||
const tail = node.prompt ? node.prompt : (seq.length ? seq[seq.length - 1] : null);
|
||
if (tail == null || this.currentVoiceId !== tail) return false; // 仅护末句
|
||
if (!node.auto) return true; // 链末等待型节点(本作全部通话叙事句)
|
||
if (node.next == null || this._isTransition(node.next)) return true; // 自动链终点
|
||
const nx = NODES[node.next];
|
||
return !!(nx && nx.prompt != null); // 自动链直入抉择提示(g9_in→p9)
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:BRIDGE
|
||
|
||
_onBridgeGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
if (this.voicePlaying) this.audio.skipVoice(); // 跳过本句,自动链继续
|
||
break;
|
||
case 'doubletap':
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
this._bridgeChain(this._bridgeAnnounce ? [this._bridgeAnnounce] : []);
|
||
break;
|
||
case 'up': {
|
||
// 帮助 + 状态,自动链继续(垫乐喘息不被打断——链尾照常进下一通)
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
const ids = ['voice/sys_help'];
|
||
if (this.callN < CALLS.length) ids.push(PROG_VOICES[this.callN]); // 下一通
|
||
ids.push(TIER_VOICES[tierOf(this.rep)]);
|
||
this._bridgeChain(ids);
|
||
break;
|
||
}
|
||
case 'down':
|
||
this.openMenu();
|
||
break;
|
||
case 'left':
|
||
case 'right':
|
||
this.guide.error({ voice: 'gd_invalid', onceKey: 'radio_invalid' }); // §2.3 BRIDGE 行
|
||
break;
|
||
default:
|
||
break; // longpress/longpressend/tapcancel:引擎快进接管,游戏不处理
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:ENDING
|
||
|
||
_onEndingGesture(g) {
|
||
switch (g) {
|
||
case 'tap':
|
||
if (this.awaitingRestart) this.newNight();
|
||
else if (this.voicePlaying && this.currentVoiceId !== this._skipLockId) {
|
||
this.audio.skipVoice(); // F4:续玩恢复的首句播完才武装跳句
|
||
}
|
||
break;
|
||
case 'doubletap':
|
||
// 重听结局链(结算态/播放中皆可;重播期间清结算态,播完回到结算态)
|
||
this.awaitingRestart = false;
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
this._playEndingChain();
|
||
break;
|
||
case 'up': {
|
||
// 帮助 + 状态(结局主句=结局名播报 + 口碑档整句,§2.3「结局名+口碑」;
|
||
// storyline P3-2 先例),播完回结算态
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
const def = ENDINGS[this.endingId] || ENDINGS.e1;
|
||
this._playSeq(['voice/sys_help', def.voices[0], TIER_VOICES[tierOf(this.rep)]], () => {
|
||
this.voicePlaying = false;
|
||
if (this.phase === 'ENDING') {
|
||
this.awaitingRestart = true;
|
||
this._armIdleWatchdog('ENDING');
|
||
}
|
||
});
|
||
break;
|
||
}
|
||
case 'down':
|
||
this.openMenu();
|
||
break;
|
||
case 'left':
|
||
case 'right':
|
||
this.guide.error({ voice: 'gd_invalid', onceKey: 'radio_invalid' }); // §2.3 ENDING 行
|
||
// F1:结算等待态的看门狗被 disarm 拆掉后立即重挂(对白在播时本就无看门狗)
|
||
if (this.awaitingRestart && !this.voicePlaying) this._armIdleWatchdog('ENDING');
|
||
break;
|
||
default:
|
||
// F1 同款:快进等引擎接管手势不拆结算等待的看门狗
|
||
if (this.awaitingRestart && !this.voicePlaying) this._armIdleWatchdog('ENDING');
|
||
break; // longpress/longpressend/tapcancel:引擎快进接管,游戏不处理
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势:MENU(§5.2 模态约定)
|
||
|
||
_onMenuGesture(g) {
|
||
switch (g) {
|
||
case 'tap': {
|
||
// 单击循环切换:报名经游戏侧队列**接在当前报名之后**(不截断)。
|
||
// 不用引擎 queue:true——引擎在 onComplete 里先起链播下一句、后 shift
|
||
// 队列,排队句会把链播下一句顶掉(sys_menu→resume 链被吞的根因)。
|
||
this.menuIdx = (this.menuIdx + 1) % MENU_ITEMS.length;
|
||
this._menuQueue.push(MENU_ITEMS[this.menuIdx].voice);
|
||
if (!this.voicePlaying) this._drainMenuQueue();
|
||
break;
|
||
}
|
||
case 'doubletap':
|
||
this._execMenuItem(MENU_ITEMS[this.menuIdx].id); // 双击确认执行
|
||
break;
|
||
case 'up': {
|
||
// 帮助 + 菜单内设置提示 + 复述当前项(清空报名队列:帮助链顶掉一切)
|
||
this._menuQueue = [];
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
this._playSeq(['voice/sys_help', 'voice/sys_settings', MENU_ITEMS[this.menuIdx].voice], () => {
|
||
this.voicePlaying = false;
|
||
});
|
||
break;
|
||
}
|
||
case 'down':
|
||
this.closeMenu(); // 关闭回原状态(重播当前节点 = 局面复述)
|
||
break;
|
||
case 'left':
|
||
case 'right':
|
||
this.guide.error({}); // 菜单内左右滑无效:error 震(§2.3 MENU 行,无语音)
|
||
break;
|
||
default:
|
||
break; // longpress/longpressend/tapcancel:引擎快进接管(快进菜单语音)
|
||
}
|
||
}
|
||
|
||
_execMenuItem(id) {
|
||
this._menuQueue = []; // 确认执行:待播报名作废
|
||
if (id === 'resume') {
|
||
this.closeMenu();
|
||
} else if (id === 'restart') {
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
this.newNight();
|
||
} else if (id === 'lobby') {
|
||
// 保存并回大厅:确认句播出且存档落盘后跳转(§2.2「确认后跳转前播」)
|
||
this.leaving = true;
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
this._save();
|
||
this._playSeq(['voice/sys_menu_lobby'], () => {
|
||
this.voicePlaying = false;
|
||
try {
|
||
if (typeof window !== 'undefined' && window.location) window.location.href = '../';
|
||
} catch (_) { /* 无头环境无 location:留档 leaving 标记 */ }
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 菜单开/关
|
||
|
||
openMenu() {
|
||
this.returnPhase = this.phase;
|
||
this.returnNode = this.nodeId;
|
||
this.phase = 'MENU';
|
||
this.menuIdx = 0;
|
||
this._menuQueue = [];
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
this._stopRing();
|
||
this._cancelRest();
|
||
this.audio.play('sfx/ui_menu');
|
||
this.audio.play('bgm/menu', { fadeMs: 500 }); // 菜单 pad crossfade 500ms
|
||
this.guide.state('menu');
|
||
this._playSeq(['voice/sys_menu', MENU_ITEMS[0].voice], () => {
|
||
this.voicePlaying = false;
|
||
this._drainMenuQueue();
|
||
});
|
||
}
|
||
|
||
/** 菜单报名队列排水:链播完接播玩家循环切换过的项(游戏侧排队,零截断)。 */
|
||
_drainMenuQueue() {
|
||
if (this.phase !== 'MENU' || this._menuQueue.length === 0) return;
|
||
const ids = this._menuQueue.splice(0);
|
||
this._playSeq(ids, () => {
|
||
this.voicePlaying = false;
|
||
this._drainMenuQueue();
|
||
});
|
||
}
|
||
|
||
closeMenu() {
|
||
this.seqToken++;
|
||
this._menuQueue = []; // 未播完的报名作废(局面复述接管)
|
||
this.audio.stopVoice();
|
||
const back = this.returnPhase || 'INTRO';
|
||
this.phase = back;
|
||
this.audio.play('sfx/ui_confirm');
|
||
if (back === 'BRIDGE') this.audio.play('bgm/bridge', { fadeMs: 800 });
|
||
else if (back === 'ENDING') this.audio.play('bgm/ending', { fadeMs: 800 });
|
||
else this.audio.play('bgm/onair', { fadeMs: 800 }); // 关菜单淡回原状态曲
|
||
this.guide.state(back === 'BRIDGE' || back === 'ENDING' ? 'settle' : 'explore');
|
||
if (back === 'RING') {
|
||
this._startRing();
|
||
this._armRingWatchdog();
|
||
} else if (back === 'BRIDGE') {
|
||
this._bridgeChain([]); // 幕间续走(口碑句已播过不重播,账目真实)
|
||
} else if (back === 'ENDING') {
|
||
this._playEndingChain(); // 重播结局链(局面复述)
|
||
} else if (back === 'ASK') {
|
||
this._playSeq(['voice/sys_continue_ask'], () => {
|
||
this.voicePlaying = false;
|
||
this._armIdleWatchdog('ASK');
|
||
});
|
||
} else {
|
||
this.playNode(this.returnNode); // INTRO/ONAIR:重播当前节点
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 夜班流转
|
||
|
||
/** 新夜班:重置跨通话标量(结局收集跨夜保留),回 INTRO(sys_new_night 先行)。 */
|
||
newNight() {
|
||
this.nights += 1;
|
||
this.rep = 0;
|
||
this.knot = false;
|
||
this.heardArgument = false;
|
||
this.bond = { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0 };
|
||
this.p1Choice = null;
|
||
this.callN = 0;
|
||
this.endingId = null;
|
||
this.lastAnnouncedTier = 'low';
|
||
this.lastCloser = null;
|
||
this.leaving = false;
|
||
this.saved = null;
|
||
this.choiceArmed = false; // 清掉上一夜的抉择武装(重启到首链起播前不收滑动手势)
|
||
this.pendingChoice = null;
|
||
this._resumeGuard = false; // F4 跳句锁不跨夜
|
||
this._skipLockId = null;
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
this._stopRing();
|
||
this._stopAmb();
|
||
this._cancelRest();
|
||
this.phase = 'INTRO';
|
||
this.audio.play('bgm/onair'); // 从菜单/结局重开为同首不重启
|
||
this.guide.state('explore');
|
||
this._playSeq(['voice/sys_new_night'], () => this.playNode('b_open'));
|
||
}
|
||
|
||
/** RING:热线接入。铃声循环垫 + turn cue + 两级不同句看门狗,无超时惩罚。 */
|
||
enterRing(n) {
|
||
this.callN = n;
|
||
this.phase = 'RING';
|
||
this._stopAmb();
|
||
this.audio.play('bgm/onair', { fadeMs: 600 }); // 进下一通淡回 onair
|
||
this.guide.turn({ haptic: true }); // 铃声 + turn 签名 =「轮到你接听」(无语音)
|
||
this._save(); // 写入时机:每通电话 RING 进入
|
||
this._startRing();
|
||
this._armRingWatchdog();
|
||
}
|
||
|
||
/**
|
||
* F3:铃声循环垫起播(sfx/phone_ring,程序合成无缝 loop:双音一响 + 静默烘进
|
||
* 文件,循环边界静音零接缝)。引擎 sfx 层「即发即弃」无 loop/stop——游戏侧经
|
||
* 引擎 ctx/_sfxBus 起循环 BufferSource 并持句柄(guide earcon 同款直用总线),
|
||
* 接听/菜单/重开的 _stopRing 即停,不拖尾盖住通话。load 异步:落地前已离开
|
||
* RING(秒接)则不播;资产缺失静默降级(震动 + 看门狗仍在,玩法不受阻)。
|
||
*/
|
||
_startRing() {
|
||
if (this.phase !== 'RING') return;
|
||
this._stopRingSfx(); // 防双循环(closeMenu/resume 重入时旧句柄先清)
|
||
const ctx = this.audio.ctx;
|
||
if (!ctx) return;
|
||
this.audio._load('sfx/phone_ring').then((buf) => {
|
||
if (!buf || this.phase !== 'RING' || this._ringSrc) return;
|
||
const src = ctx.createBufferSource();
|
||
src.buffer = buf;
|
||
src.loop = true;
|
||
const gain = ctx.createGain();
|
||
gain.gain.value = this.audio.sfxVolume;
|
||
src.connect(gain);
|
||
gain.connect(this.audio._sfxBus);
|
||
src.start();
|
||
this._ringSrc = { src, gain };
|
||
}).catch(() => { /* 资产缺失降级:RING 仍有 turn 震 + 两级看门狗 */ });
|
||
}
|
||
|
||
_stopRingSfx() {
|
||
if (this._ringSrc) {
|
||
try { this._ringSrc.src.stop(); } catch (_) { /* 已停止 */ }
|
||
this._ringSrc = null;
|
||
}
|
||
}
|
||
|
||
_stopRing() {
|
||
this._stopRingSfx();
|
||
}
|
||
|
||
/** BRIDGE:幕间喘息。口碑跨档被动播报(分档整句账目真实)→ 垫乐喘息 → 下一通。 */
|
||
enterBridge() {
|
||
this._stopAmb();
|
||
this.phase = 'BRIDGE';
|
||
this.guide.state('settle');
|
||
this.audio.play('sfx/line_drop'); // 挂断咔哒
|
||
this.audio.play('bgm/bridge', { fadeMs: 800 }); // crossfade 进幕间
|
||
const tier = tierOf(this.rep);
|
||
if (tier !== this.lastAnnouncedTier) {
|
||
this.lastAnnouncedTier = tier;
|
||
this._bridgeAnnounce = TIER_VOICES[tier];
|
||
if (tier === 'low') vibrate('warning'); // rep_low 附 warning 震(G0 组注记)
|
||
} else {
|
||
this._bridgeAnnounce = null;
|
||
}
|
||
this._bridgeChain(this._bridgeAnnounce ? [this._bridgeAnnounce] : []);
|
||
}
|
||
|
||
_bridgeChain(ids) {
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
this._playSeq(ids, () => {
|
||
this.voicePlaying = false;
|
||
this._armRest();
|
||
});
|
||
}
|
||
|
||
_armRest() {
|
||
if (this.restTimer) clearTimeout(this.restTimer);
|
||
const token = this.seqToken;
|
||
this.restTimer = setTimeout(() => {
|
||
this.restTimer = null;
|
||
if (this.phase !== 'BRIDGE' || this.seqToken !== token) return; // 已被菜单/手势打断
|
||
this._advanceBridge();
|
||
}, BRIDGE_REST_MS);
|
||
}
|
||
|
||
_cancelRest() {
|
||
if (this.restTimer) { clearTimeout(this.restTimer); this.restTimer = null; }
|
||
}
|
||
|
||
_advanceBridge() {
|
||
if (this.callN >= CALLS.length) this._enterSignoff();
|
||
else this.enterRing(this.callN + 1);
|
||
}
|
||
|
||
/** 签收(C5 幕间后):SIGNOFF 属 ONAIR,g9_in 自动流入 ◆p9。 */
|
||
_enterSignoff() {
|
||
this.phase = 'ONAIR';
|
||
this.playNode('g9_in');
|
||
}
|
||
|
||
/** ENDING:rep×knot 四选一 + 尾声回响链,结算态等待单击重开。 */
|
||
enterEnding(forced) {
|
||
const ending = forced
|
||
|| (this.rep >= 5 ? (this.knot ? 'e1' : 'e2') : (this.knot ? 'e3' : 'e4'));
|
||
this.endingId = ending;
|
||
this.endings[ending] = true;
|
||
this.phase = 'ENDING';
|
||
this.nodeId = ending;
|
||
this.awaitingRestart = false;
|
||
this._stopAmb();
|
||
this.guide.state('settle');
|
||
this.audio.play('bgm/ending', { fadeMs: 1500 }); // p9 结果链播完后淡入
|
||
this._save(); // 写入时机:ENDING 进入
|
||
this._playEndingChain();
|
||
}
|
||
|
||
_playEndingChain() {
|
||
const def = ENDINGS[this.endingId] || ENDINGS.e4;
|
||
const chain = [...def.voices, 'voice/ep_lead'];
|
||
for (const key of Object.keys(EP_VOICES)) {
|
||
chain.push(EP_VOICES[key][this.bond[key] ? 0 : 1]); // 救到 / 错过,点名每一通
|
||
}
|
||
this._playSeq(chain, () => {
|
||
this.voicePlaying = false;
|
||
this.awaitingRestart = true;
|
||
this._armIdleWatchdog('ENDING');
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 存档续玩
|
||
|
||
_askContinue(save) {
|
||
this.saved = save;
|
||
this.phase = 'ASK';
|
||
this._playSeq(['voice/sys_continue_ask'], () => {
|
||
this.voicePlaying = false;
|
||
this._armIdleWatchdog('ASK');
|
||
});
|
||
}
|
||
|
||
/** 断点续玩:恢复 phase+node 并重播当前节点链(一句话局面复述,L5)。 */
|
||
_resume(s) {
|
||
if (!s) { this.newNight(); return; }
|
||
this.saved = null;
|
||
this.rep = s.rep | 0;
|
||
this.knot = !!s.knot;
|
||
this.heardArgument = !!s.heardArgument;
|
||
this.bond = { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0, ...(s.bond || {}) };
|
||
this.endings = { ...(s.endings || {}) };
|
||
this.nights = s.nights || 1;
|
||
this.lastAnnouncedTier = tierOf(this.rep); // 恢复不重播跨档句(账目已播过)
|
||
this.lastCloser = s.lastCloser || null;
|
||
this.callN = s.call || this._callOfNode(s.node) || 1;
|
||
this.leaving = false;
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
this._resumeGuard = true; // F4:恢复链首句播完才武装跳句(经 _playSeq 消费)
|
||
const phase = s.phase;
|
||
if (phase === 'RING') {
|
||
this.phase = 'RING';
|
||
this._startRing();
|
||
this._armRingWatchdog();
|
||
} else if (phase === 'BRIDGE') {
|
||
this.phase = 'BRIDGE';
|
||
this.audio.play('bgm/bridge', { fadeMs: 800 });
|
||
this.guide.state('settle');
|
||
this._bridgeChain([]); // 续走幕间喘息
|
||
} else if (phase === 'ENDING') {
|
||
this.enterEnding(s.ending && ENDINGS[s.ending] ? s.ending : undefined);
|
||
} else {
|
||
this.phase = 'ONAIR';
|
||
this.guide.state('explore');
|
||
this.playNode(NODES[s.node] ? s.node : this._callEntry());
|
||
}
|
||
}
|
||
|
||
_callOfNode(id) {
|
||
const c = typeof id === 'string' ? id[0] : '';
|
||
return CALL_OF_NODE[c] || 1;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 节点链驱动
|
||
|
||
playNode(id) {
|
||
const node = NODES[id];
|
||
if (!node) {
|
||
console.error('[RadioGame] 未知节点:', id);
|
||
return;
|
||
}
|
||
this.nodeId = id;
|
||
this.choiceArmed = false;
|
||
this.pendingChoice = null;
|
||
this.awaitingRestart = false;
|
||
if (node.amb) this._startAmb(node.amb);
|
||
if (id === 'p9') this.audio.play('bgm/onair', { fadeMs: 600 }); // 签收回 onair
|
||
let seq = this._nodeVoices(id);
|
||
// F8 先例:操作口令 gd_brief 前置开场链最前(仅 full 档)
|
||
if (id === 'b_open' && this.guide.level() === 'full') seq = ['voice/gd_brief', ...seq];
|
||
// sys_skip:开场每实例一次(storyline skip_hint 先例)
|
||
if (id === 'b_open' && !this.sysSkipShown) {
|
||
this.sysSkipShown = true;
|
||
seq = [...seq, 'voice/sys_skip'];
|
||
}
|
||
if (node.prompt) seq = [...seq, node.prompt];
|
||
|
||
if (node.danger) {
|
||
// 危机拍:前 2s brake 渐强 + danger 签名/震同窗(L2 前置预警),链占用期间单击不推进
|
||
this.guide.state('danger');
|
||
vibrate('danger');
|
||
if (node.sfx) this.audio.play(node.sfx);
|
||
this.voicePlaying = true;
|
||
this.currentVoiceId = null;
|
||
this.promptPlaying = false;
|
||
const token = ++this.seqToken;
|
||
setTimeout(() => {
|
||
if (token !== this.seqToken) return;
|
||
this._playSeq(this._nodeVoices(id), () => this._onNodeSeqDone(node, this._nodeVoices(id)));
|
||
}, node.preMs || 0);
|
||
return;
|
||
}
|
||
if (node.sfx) this.audio.play(node.sfx);
|
||
if (node.sfxEcho) {
|
||
// k1_b 的 arg_muffled 第二遍(线索音 2-3 次取确定值 2;节点被顶掉则不播)
|
||
setTimeout(() => {
|
||
if (this.nodeId === id && this.phase === 'ONAIR') this.audio.play(node.sfxEcho.id);
|
||
}, node.sfxEcho.atMs);
|
||
}
|
||
this._playSeq(seq, () => this._onNodeSeqDone(node, seq));
|
||
}
|
||
|
||
/** 节点基础语音链(含节点内条件选播:w1_say 组 / h1_echo 插播)。 */
|
||
_nodeVoices(id) {
|
||
const node = NODES[id];
|
||
if (!node) return [];
|
||
if (node.dyn === 'w1say') {
|
||
return this.heardArgument
|
||
? ['voice/w1_say', 'voice/w1_sayo', 'voice/w1_saye']
|
||
: ['voice/w1_say2', 'voice/w1_say2o', 'voice/w1_say2e'];
|
||
}
|
||
if (node.dyn === 'h1echo') {
|
||
return this.bond.zhou ? ['voice/h1_echo'] : [];
|
||
}
|
||
return [...node.voices];
|
||
}
|
||
|
||
/** 双击重听当前节点台词(含抉择提示);ENDING 态重听结局链。 */
|
||
replayNode() {
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
if (this.phase === 'ENDING') {
|
||
this.awaitingRestart = false;
|
||
this._playEndingChain();
|
||
return;
|
||
}
|
||
const node = NODES[this.nodeId];
|
||
if (!node) return;
|
||
this.choiceArmed = false;
|
||
this.pendingChoice = null;
|
||
this.awaitingRestart = false;
|
||
const seq = this._nodeVoices(this.nodeId);
|
||
if (node.prompt) seq.push(node.prompt);
|
||
this._playSeq(seq, () => this._onNodeSeqDone(node, seq));
|
||
}
|
||
|
||
/** '@x' 虚拟目标 = 过场转移(电台节目流自动衔接,§2.2 转移图「链完→」)。 */
|
||
_isTransition(target) {
|
||
return typeof target === 'string' && target.startsWith('@');
|
||
}
|
||
|
||
/**
|
||
* 节点链自然播完的公共收尾:抉择武装 / 自动过场 / 链末等待。
|
||
* 自动衔接仅限两类(§2.2 转移图 + 本文件头部说明):① 节点表标注 auto 的
|
||
* 电台流段落(INTRO 开场链、C5 危机拍 h1_talko→h1_danger→h1_dango、签收链
|
||
* g9_in、p9 结果链 d9_face/d9_avoid);② 全部过场转移(@ring/@bridge/
|
||
* @h1end/@ending)。其余 ONAIR 通话内叙事节点链末**等待单击**(§2.3「链末=
|
||
* 继续」)并挂 gd_continue/gd_again 看门狗(§7.2 叙事链末等待行;
|
||
* storyline onNodeSeqDone 同款——只提示不重播,两级用尽彻底静默)。
|
||
*/
|
||
_onNodeSeqDone(node, seq) {
|
||
this.voicePlaying = false;
|
||
if (seq.length > 0) this.lastCloser = seq[seq.length - 1];
|
||
if (node.prompt != null) {
|
||
this.choiceArmed = true; // W2 契约:播完当刻即可选,零插播
|
||
if (this.pendingChoice) {
|
||
const g = this.pendingChoice;
|
||
this.pendingChoice = null;
|
||
this.choose(g); // 缓存的抉择立即生效(不丢、不重复)
|
||
} else {
|
||
this.armChoiceHint();
|
||
}
|
||
return;
|
||
}
|
||
if (node.next == null) return;
|
||
if (node.auto || this._isTransition(node.next)) {
|
||
this.advance(node.next);
|
||
return;
|
||
}
|
||
this._armIdleWatchdog('ONAIR'); // 链末等待单击(tap=继续,见 _onStoryGesture)
|
||
}
|
||
|
||
/** 链完转移:'@x' 虚拟目标 = 过场自动衔接;普通节点 = 播下一节点。 */
|
||
advance(target) {
|
||
switch (target) {
|
||
case '@ring': this.enterRing(this.callN + 1); return;
|
||
case '@bridge': this.enterBridge(); return;
|
||
case '@h1end': this.playNode('h1_end'); return;
|
||
case '@ending': this.enterEnding(); return;
|
||
default: this.playNode(this.resolve(target));
|
||
}
|
||
}
|
||
|
||
/** 抉择目标解析(跨通话状态只有三个标量,无组合节点)。 */
|
||
resolve(target) {
|
||
if (target === '@transfer') return this.p1Choice === 'left' ? 'z1_pol' : 'z1_bad';
|
||
return target;
|
||
}
|
||
|
||
/** 抉择:计分(§4.2)→ 存档 → 进结果节点。 */
|
||
choose(g) {
|
||
const node = NODES[this.nodeId];
|
||
if (!node || node.prompt == null) return;
|
||
this.choiceArmed = false;
|
||
this.audio.play('sfx/ui_confirm');
|
||
this._applyChoice(this.nodeId, g);
|
||
this._save(); // 写入时机:每个抉择结算后
|
||
this.advance(g === 'left' ? node.left : node.right);
|
||
}
|
||
|
||
_applyChoice(nodeId, g) {
|
||
const L = g === 'left';
|
||
switch (nodeId) {
|
||
case 'p1':
|
||
this.p1Choice = g;
|
||
if (L) this.rep += 1; // 哼老歌:首个正反馈
|
||
break;
|
||
case 'p2':
|
||
// 唯一负分组合(p1=右 且 p2=右 → -1 砸),触发 z1_bad 归因组
|
||
if (L) this.rep += this.p1Choice === 'left' ? 2 : 1;
|
||
else this.rep += this.p1Choice === 'left' ? 1 : -1;
|
||
this.bond.zhou = (!L && this.p1Choice === 'right') ? 0 : 1; // 砸 = 错过
|
||
break;
|
||
case 'p3':
|
||
this.rep += L ? 2 : 1; // 两个选项都体面
|
||
this.bond.deer = L ? 1 : 0;
|
||
break;
|
||
case 'p4':
|
||
// 左=哄睡(错过线索,+0)/ 右=问她(倾听,+1 且 heardArgument=true)。
|
||
// 注:§4.2 表行「p4 问她/哄睡 | 左 | 右」的选项列序与 §3.2 树形/§9 断言
|
||
// 的滑动绑定相反,以 §3.2 + p4 提示句原文(左哄睡/右问她)为准。
|
||
if (!L) { this.rep += 1; this.heardArgument = true; }
|
||
break;
|
||
case 'p5':
|
||
if (L) { this.rep += 1; this.bond.kid = 1; } // 真话 = 完整接住
|
||
break;
|
||
case 'p6':
|
||
if (L) { this.rep += this.heardArgument ? 2 : 1; this.bond.wife = 1; }
|
||
break; // 右=劝回家:本通结束 +0
|
||
case 'p7':
|
||
this.rep += L ? 2 : 1;
|
||
break;
|
||
case 'p8':
|
||
if (L) { this.rep += 1; this.bond.han = 1; } // 陪聊闺女(含危机拍)
|
||
break;
|
||
case 'p9':
|
||
if (L) this.knot = true; // 直面(播出老主播的晚安);不动 rep
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 帮助 / 复述
|
||
|
||
/** 上滑:帮助 + 状态(第几通 + 口碑档整句),随后重播当前节点(局面复述)。 */
|
||
_playHelpAndResume() {
|
||
this.seqToken++;
|
||
this.audio.stopVoice();
|
||
const ids = ['voice/sys_help'];
|
||
if (this.phase === 'ONAIR' && this.callN >= 1) {
|
||
ids.push(PROG_VOICES[this.callN - 1]);
|
||
ids.push(TIER_VOICES[tierOf(this.rep)]);
|
||
}
|
||
this._playSeq(ids, () => {
|
||
this.voicePlaying = false;
|
||
if (this.phase === 'ONAIR' || this.phase === 'INTRO') this.playNode(this.nodeId);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 看门狗
|
||
|
||
/** 叙事等待点(ASK / ONAIR 链末 / ENDING 结算):full 25s/50s,standard 13s/30s。 */
|
||
_armIdleWatchdog(phaseName) {
|
||
const full = this.guide.level() === 'full';
|
||
this.guide.watchdog({
|
||
arm: true,
|
||
at: full ? GUIDE_WATCHDOG_AT : IDLE_HINT_AT,
|
||
say: ['gd_continue', 'gd_again'],
|
||
onEscalate: (n) => {
|
||
// full 档提示句由 guide 的 say 播(guide.test 锁定),不重复播
|
||
if (full) return;
|
||
this.audio.play(n === 1 ? 'voice/gd_continue' : 'voice/gd_again', { queue: true });
|
||
},
|
||
cond: () => this.phase === phaseName && !this.voicePlaying,
|
||
});
|
||
}
|
||
|
||
/** RING 两级新句提示(gd_ring1 ≠ gd_ring2,绝不逐字重播;两级后仅铃声循环)。 */
|
||
_armRingWatchdog() {
|
||
const full = this.guide.level() === 'full';
|
||
this.guide.watchdog({
|
||
arm: true,
|
||
at: RING_WATCHDOG_AT, // full / standard 同参数;standard 由 onEscalate 游戏层补播
|
||
say: ['gd_ring1', 'gd_ring2'],
|
||
onEscalate: (n) => {
|
||
if (full) return;
|
||
this.audio.play(n === 1 ? 'voice/gd_ring1' : 'voice/gd_ring2', { queue: true });
|
||
},
|
||
cond: () => this.phase === 'RING',
|
||
});
|
||
}
|
||
|
||
/** 抉择点静默兜底:25s/50s 重播当前 p* 提示句本身(复述选项 = 合法帮助,N3 先例)。 */
|
||
armChoiceHint() {
|
||
this.guide.watchdog({
|
||
arm: true,
|
||
at: CHOICE_HINT_AT,
|
||
onEscalate: () => {
|
||
const node = NODES[this.nodeId];
|
||
if (node && node.prompt) this.audio.play(node.prompt, { queue: true });
|
||
},
|
||
// 已抉择 / 离开剧情态 / 新链在播 → 到点静默取消
|
||
cond: () => this.phase === 'ONAIR' && this.choiceArmed && !this.voicePlaying,
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 语音链(storyline 范式)
|
||
|
||
/**
|
||
* 顺序播放一组对白,全部自然播完后回调。被打断时回调作废(seqToken 机制)。
|
||
* 单句 30s 看门狗:超时且 isVoicePlaying()===false(资产缺失)才自动续链;
|
||
* voice 真在播(长句)则续约等待,不得掐断(storyline N1 先例照抄)。
|
||
*/
|
||
_playSeq(ids, onDone) {
|
||
const token = ++this.seqToken;
|
||
// F4:消费续玩跳句锁——恢复链的第一句起播时落锁,该句播完(i>0 / 链完)或
|
||
// 被任何新链顶掉(token 更替)即解锁;非续玩链一律无锁。
|
||
this._skipLockId = this._resumeGuard ? (ids[0] || null) : null;
|
||
this._resumeGuard = false;
|
||
this.voicePlaying = true;
|
||
const step = (i) => {
|
||
if (token !== this.seqToken) return;
|
||
if (i >= ids.length) {
|
||
this.currentVoiceId = null;
|
||
this.promptPlaying = false;
|
||
this._skipLockId = null;
|
||
onDone();
|
||
return;
|
||
}
|
||
if (i > 0) this._skipLockId = null; // F4:首句播完,武装跳句
|
||
this.currentVoiceId = ids[i];
|
||
this.promptPlaying = PROMPT_RE.test(ids[i]);
|
||
if (this.promptPlaying) this.guide.state('choice'); // 选项句本身即 turn cue(W2)
|
||
let advanced = false;
|
||
const armWd = () => setTimeout(() => {
|
||
if (advanced || token !== this.seqToken) return;
|
||
if (this.audio.isVoicePlaying()) { wd = armWd(); return; } // 长句在播:续约不掐
|
||
advanced = true;
|
||
console.warn('[RadioGame] 语音看门狗超时(voice 未在播,判定资产缺失),自动续链:', ids[i]);
|
||
step(i + 1);
|
||
}, VOICE_WATCHDOG_MS);
|
||
let wd = armWd();
|
||
this.audio.play(ids[i], {
|
||
onComplete: () => {
|
||
if (advanced || token !== this.seqToken) return;
|
||
advanced = true;
|
||
clearTimeout(wd);
|
||
step(i + 1);
|
||
},
|
||
});
|
||
};
|
||
step(0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 环境音(游戏层循环)
|
||
|
||
/**
|
||
* 通话侧 amb(信息层:雨夜街道/车站/儿童房/公车站/驾驶室)。
|
||
* sfx 层无 loop 选项 → 按解码时长 onEnded 重触发(escape amb 先例);
|
||
* 进 BRIDGE/菜单/重开时 _stopAmb。重触发循环会让无头虚拟时钟永不空闲,
|
||
* 测试置 ambLoopEnabled=false(每通仍起播一次,断言可用)。
|
||
*/
|
||
async _startAmb(id) {
|
||
this._stopAmb();
|
||
this._ambId = id;
|
||
this.audio.play(id, { volume: 0.8 });
|
||
if (!this.ambLoopEnabled) return;
|
||
// 引擎 _load 是 prewarm 同款解码缓存(含 Promise),取 buffer 时长排重触发
|
||
const buf = await this.audio._load(id);
|
||
if (!buf || this._ambId !== id) return;
|
||
this.ambTimer = setTimeout(() => {
|
||
if (this._ambId === id) this._startAmb(id);
|
||
}, Math.max(1200, (buf.duration - 0.4) * 1000));
|
||
}
|
||
|
||
_stopAmb() {
|
||
this._ambId = null;
|
||
if (this.ambTimer) { clearTimeout(this.ambTimer); this.ambTimer = null; }
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 存档(§8)
|
||
|
||
/** data 结构照 §8.1;summary 字符串必带(大厅卡片直接显示)。 */
|
||
_save() {
|
||
const k = Object.keys(this.endings).length;
|
||
const signed = `${this.rep >= 0 ? '+' : ''}${this.rep}`;
|
||
let summary;
|
||
if (this.phase === 'ENDING' && this.endingId) {
|
||
summary = `已收播·${ENDINGS[this.endingId].name} · 结局${k}/4`;
|
||
} else {
|
||
const head = this.callN >= 1
|
||
? `第${this.callN}通·${CALLS[this.callN - 1].label}`
|
||
: '深夜开播';
|
||
summary = `${head} · 口碑${signed} · 结局${k}/4`;
|
||
}
|
||
const data = {
|
||
schema: 1,
|
||
updatedAt: new Date().toISOString(),
|
||
summary,
|
||
phase: this.phase,
|
||
node: this.nodeId,
|
||
call: this.callN,
|
||
ending: this.endingId,
|
||
rep: this.rep,
|
||
knot: this.knot,
|
||
heardArgument: this.heardArgument,
|
||
bond: { ...this.bond },
|
||
endings: { ...this.endings },
|
||
nights: this.nights,
|
||
lastCloser: this.lastCloser,
|
||
};
|
||
try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); } catch (_) { /* 隐私模式 */ }
|
||
// 已登录时同步服务器(未登录静默跳过;大厅负责登录,游戏内只读写)
|
||
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: 'radio', data }),
|
||
}).catch(() => { /* 离线静默 */ });
|
||
} catch (_) { /* 静默 */ }
|
||
}
|
||
|
||
_loadLocal() {
|
||
try {
|
||
const data = JSON.parse(localStorage.getItem(SAVE_KEY));
|
||
if (!data || data.schema !== 1 || !data.phase || !data.node) return null;
|
||
return data;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
export default RadioGame;
|