Files
SoundGame/web/radio/game.js
T
xsl d870a75a26 feat(new8/batch1): 两款新 RPG《不眠频率》radio +《回声地窟》dungeon 全套(多agent流水线:策划→资产→开发→测试→玩家R1→修复→R2)
- 程序章程 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(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
2026-09-02 18:52:37 +08:00

1308 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《不眠频率》——深夜电台热线叙事 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;