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

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

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

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

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

1320 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.
/**
* 《不眠频率》R4 —— 深夜电台热线叙事 RPG,按 docs/design/26-audio-interaction-playbook.md
* 深度重构(设计单 docs/design/26-apply-radio.md;玩法正文 docs/design/15-radio.md)。
*
* 玩法核心不动:节点链剧情树、五通来电、rep/bond/heardArgument 数值、4 结局、
* 尾声回响全部保留;交互层整体换成 26 号六模块(storyline 范式):
* - 发言权(§4.1):整条语音链 = SPEAKING(BGM 压低跨链、句间不掉回「轮到你」
* 听感);链完 → floor.yieldTurn(等待点):交接提示音 → 400ms 后等待声——RING
* 用铃声循环代替合成等待声(15 号 §2 声音身份);不存在安静态,任何打断后
* 由 _ensureWait 按当前局面重新交出话轮。
* - 输入管道(§4.2):ok/invalid/busy 三件套回执 + 400ms 防连点;新手手势门、
* 迷茫检测、等待点缩圈全在 engine/pipeline.js(index.html 接线)。
* - 手势宪法(§4.3):doubleTapWindow=0;双击未解锁按单击处理;up=我在哪
* (阶段 1)、down=菜单(阶段 2)、双击=重听当前等待点完整句(阶段 3)。
* 旧「双击=重听整段 / RING 重听收尾句 / 菜单单击循环+双击确认」全部废止
* (26 §7 反模式 5;00 号 §0 菜单废止行)。
* - 三档提示(§4.4):每个等待点 full → short → 提示音;主动重听 =「再说一遍:」
* + 完整句;被动提示永不原样重复(选项句链播即登 30s 台账,静默提示自动降档)。
* - 迷茫检测(§4.5):乱点/连无效/静默信号累计 ≥2 → 三级接管(短句 → 只留期待
* 手势 → 演示;抉择点演示只复述方向,不替玩家做有后果的选择)。
* - 首 3 分钟(§4.10):第一句 =「点一下屏幕。」(critical 指令句),砍掉开场
* 规矩念白(b_rule 废除);RING 状态声首现配「热线灯亮了。点一下,接听。」;
* 「轮到你」状态配 sys_turn_teach;首个抉择给「对。」并解锁上滑(教学句挂
* 下一段链尾,原则 11)。
* - 断点复述(§4.12):每通 RING、每个抉择、ENDING 自动存档;回来先问
* 「单击继续上次 / 右滑从头开始」(sys_continue_ask),单击后重播当前节点。
* - 跳过纪律(15 号 G5 的 26 号化):p* 首播 critical——tap/滑动都给 busy 回执
* (盲玩公平);其余句 tap=跳句、滑动=打断并执行,链尾交接由 floor 的 onDone
* 保证不被吃掉(旧 F4 恢复锁 / F5 链末句保护被结构性消解;复播天然可掐)。
*
* 状态 × 手势矩阵(新手期,完整版见设计单):
* | 状态 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) |
* |------------|------------------|-----------------------|------------------|---------------|--------------|
* | 游戏在说 | 跳过这句+ok(p*=busy) | 打断→invalid+解释(不盲选) | 打断,答「我在哪」 | invalid+解释 | 按单击处理 |
* | 等你继续 | 继续+ok | invalid(inv_nosel) | 我在哪 | invalid+解释 | (阶段3=重听)|
* | RING 等接听 | 接听+line_connect(silent-ok) | invalid(inv_ring) | 我在哪(通号+口碑) | invalid+解释 | (阶段3=重听)|
* | 等你选择 | 再听选项(hold) | 抉择+ok | 「正在抉择。左右滑。」 | invalid+解释 | (阶段3=重听)|
* | 结算 | 新夜班+ok | invalid(sys_hint_again_full) | 结局主句+口碑 | 菜单(阶段2) | 按单击处理 |
* | 菜单 | 确认当前项+ok | 左=上一项 / 右=下一项,每项报名 | 重听菜单说明 | 关闭 | (阶段3=重听)|
*/
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 } from '../engine/hints.js';
import { GestureLadder } from '../engine/novice.js';
import { vibrate } from '../engine/haptics.js';
// ---------------------------------------------------------------- 节点表
/** 叙事节点。voices 顺序播放;prompt 非空 = 抉择节点(p* 链 critical,播完即选)。 */
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* 提示句一句(critical)。 */
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)。
* next 为 '@x' 虚拟目标:@ring 进铃声 / @bridge 进幕间 / @h1end 回响插播判断 /
* @ending 进结局;选择目标 '@transfer' 由 resolve() 按 p1 选择解析。
* R4:b_rule(开场规矩念白)按 §4.10 废除;INTRO 链只在重开夜班时走。
*/
const NODES = {
// ---- INTRO 开场链(重开夜班的台标口播,自动流;冷启动不再念,§4.10)
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_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_studio', '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';
// 等待点静默阈值(15 号 §5 等待点配置表;剧情类偏慢、RING/开场偏快)
const SILENCE = {
intro: [6000, 15000], // 开场第一下:6 秒就提醒(F7:30~60 秒内必须第一次成功)
ring: [8000, 18000], // 铃声在闪:8 秒提醒,18 秒降短句
continue: [18000, 40000],
choice: [25000, 50000],
settle: [20000, 45000],
reopen: [12000, 30000],
menu: [20000, 45000],
};
// 无效操作解释(原则 7:第一次说原因和正确做法,之后只给回执;键 = 状态×手势)
const INVALID_LINES = {
'locked:up': 'voice/inv_up_locked',
'locked:down': 'voice/inv_down_locked',
'locked:longpress': 'voice/inv_generic',
'ring:swipe': 'voice/inv_ring',
'story:swipe': 'voice/inv_nosel',
'settle:swipe': 'voice/sys_hint_again_full',
};
const BRIDGE_REST_MS = 3000; // 幕间垫乐喘息(自动链 ≤12s:rep 整句 + 3s 垫乐)
/** 口碑档位(§4 数值表):≥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);
// ---- 26 号六模块接线(index.html 的 makeInputPipeline 依赖这些字段)----
this.receipts = new Receipts(audio);
this.floor = new Floor(audio);
this.confusion = new Confusion({
onConfused: (lvl) => { const wp = this.floor.waitPoint; if (wp) wp.takeover(lvl); },
});
this.invalidHints = new InvalidHints().bind(this.floor);
this.ladder = new GestureLadder();
this.audio.setRateBoostEnabled(false); // 新手期长按=无效手势(§4.3,storyline 同款)
// ---- 宏状态(玩法骨架不动:INTRO/RING/ONAIR/BRIDGE/ENDING + MENU/ASK)----
this.phase = 'INTRO'; // INTRO|RING|ONAIR|BRIDGE|ENDING|MENU|ASK
this.returnPhase = null; // MENU 关闭后回原状态
this.returnNode = null;
this.nodeId = null;
this.callN = 0; // 当前通号(0=开场未接入)
this.endingId = null;
this.leaving = false; // 菜单「保存并回大厅」已确认(跳转大厅前 true)
this.saved = null; // ASK 态暂存的存档
this.menuIdx = 0;
// 链代际令牌:危机拍前置窗 / 线索音回放 / 幕间喘息定时器的作废判据
this.chainGen = 0;
this._bridgeGen = 0;
// ---- 跨通话变量(§4 数值表,玩法核心不动)----
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)
// ---- 教学标志(存档携带;做对过的事不教第二遍,原则 11)----
this.ringTaught = false; // RING 状态声首现教学(sys_ring_teach,全局一次)
this.turnTaught = false; // 等待声首现教学(sys_turn_teach,挂 z1_a 链尾,全局一次)
this.skipHintShown = false; // sys_skip 每实例一次(storyline skip_hint 先例)
this.goalMet = false; // 第一个抉择完成 → 解锁上滑(§4.3 阶梯)
this._teachHead = null; // 挂下一段链头:首抉择肯定句「对。」
this._teachTail = null; // 挂下一段链尾:解锁教学句(§4.3 阶梯)
// ---- 铃声循环 / 环境音循环 / 幕间喘息句柄 ----
this._ringSrc = null;
this.ambTimer = null;
this.ambId = null;
this.restTimer = null;
/** 环境音循环开关:无头测试/转写置 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._coldStart(); return; }
let settled = false;
const goFresh = () => { if (settled) return; settled = true; this._coldStart(); };
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.chainGen++; // 作废危机拍前置窗 / 线索音回放等在途定时器
this._bridgeGen++; // 作废幕间喘息
this.floor.interrupt();
this._stopRing();
this._stopAmb();
this._cancelRest();
this.audio.stopAll();
}
/** 冷启动首 3 分钟开场(§4.10 逐秒脚本,见设计单)。 */
_coldStart() {
this.phase = 'INTRO';
this.nodeId = null;
if (!this.nights) this.nights = 1;
this.audio.play('sfx/onair_sting');
this.audio.play('sfx/amb_studio', { volume: 0.8 });
// 0~3 秒:第一句就让玩家动手。critical 指令句(<3s):抢拍单击给 busy 回执,
// 保证唯一一次教学完整到达(F7:第一分钟内必须给出第一次成功)
this.floor.speak(['voice/sys_first_tap'], {
critical: true,
onDone: () => this.floor.yieldTurn(this._introWp()),
});
}
/** 开场第一下单击之后:RING 状态声首现教学一句 → 铃声 + 交出话轮(§2 声音身份)。 */
_beginFirstRing() {
this.ringTaught = true;
this.floor.speak(['voice/sys_ring_teach'], {
onDone: () => this.enterRing(1),
});
}
// ---------------------------------------------------------------- 手势(pipeline 之后)
/** pipeline 收口后的游戏处理;返回 'ok' | 'hold' | 'invalid' | 'silent-ok'。 */
onGesture(g) {
switch (this.phase) {
case 'ASK': return this._onAskGesture(g);
case 'MENU': return this._onMenuGesture(g);
case 'RING': return this._onRingGesture(g);
case 'BRIDGE': return this._onBridgeGesture(g);
case 'ENDING': return this._onEndingGesture(g);
default: return this._onStoryGesture(g); // INTRO / ONAIR
}
}
stateName() {
if (this.phase === 'MENU') return 'menu';
if (this.phase === 'RING') return 'ring';
if (this.phase === 'ASK') return 'reopen';
if (this.phase === 'ENDING') return 'settle';
return 'story'; // INTRO / ONAIR / BRIDGE(BRIDGE 为自动流,手势到不了游戏层)
}
invalidHint(state, g) {
if (state === 'locked') return INVALID_LINES[`locked:${g}`] || 'voice/inv_generic';
if (g === 'left' || g === 'right') {
if (state === 'menu') return null; // 菜单内左右滑=换项(合法操作)
return INVALID_LINES[`${state}:swipe`] || 'voice/inv_generic';
}
return null; // 其余格子只给回执(原则 7:不重复解释)
}
/**
* 当前等待点是否为抉择点(链末交接出的话轮)。真等待态判据:链在播时
* waitPoint 可能是上一点的残留(floor.speak 不摘旧点),不可当真等待——
* 管道正常不会在链播中把手势递进来,这里兜底直调(无头会话/转写器)场景。
*/
_atChoice() {
const wp = this.floor.waitPoint;
return !!(wp && wp.conf.id === 'choice' && !this.floor.speaking);
}
/** 阶段 3 的双击=重听当前等待点完整句(§4.3);无等待点时按各态语义重播。 */
_replayWaitPoint() {
const wp = this.floor.waitPoint;
if (wp && !this.floor.speaking) { wp.requestReplay(); return 'hold'; }
return null;
}
_onStoryGesture(g) {
const node = NODES[this.nodeId];
switch (g) {
case 'tap': {
// 游戏在说 → pipeline 已按「跳过这句」处理(consumed),到不了这里
if (this._atChoice()) {
this.floor.waitPoint.requestReplay(); // 空点=主动再听选项(原则 9)
return 'hold';
}
if (this.floor.waitPoint && this.floor.waitPoint.conf.id === 'intro') {
// 开场第一下(或开场教学链被打断后的恢复):进第一通(§4.10)
this._beginFirstRing();
return 'ok';
}
if (node && node.next != null) { this.advance(node.next); return 'ok'; }
this._ensureWait();
return 'invalid';
}
case 'doubletap': {
const r = this._replayWaitPoint();
if (r) return r;
this._replayNode();
return 'ok';
}
case 'left':
case 'right': {
if (node && node.prompt != null && this._atChoice()) {
this.choose(g);
return 'ok';
}
// 非抉择点滑动(含打断旁白):不盲选,解释一次(inv_nosel)
this._ensureWait();
return 'invalid';
}
case 'up': {
if (this._atChoice()) {
this.floor.speak(['voice/status_choice'], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'ok';
}
return this._speakStatus() ? 'ok' : 'invalid';
}
case 'down':
this.openMenu();
return 'ok';
default:
this._ensureWait();
return 'invalid';
}
}
_onRingGesture(g) {
switch (g) {
case 'tap':
this._stopRing(); // 铃声循环垫接听即停
this.floor.bedEnabled = true;
this.phase = 'ONAIR';
this.playNode(this._callEntry());
return 'silent-ok'; // line_connect 线路音即内容反馈(§4.2 已有明显内容音)
case 'doubletap': {
const r = this._replayWaitPoint();
if (r) return r;
this._ensureWait();
return 'invalid';
}
case 'up':
return this._speakStatus() ? 'ok' : 'invalid';
case 'down':
this.openMenu();
return 'ok';
default:
this._ensureWait();
return 'invalid'; // 左右滑:inv_ring「现在不用选。点一下,接听。」
}
}
_onBridgeGesture(g) {
// 幕间为自动流(BUSY 演出态):正常到不了这里(floor 给 busy 回执);
// 链被打断的兜底也不重交话轮——喘息定时器会照常把节目流推进到下一通
if (g === 'down') { this.openMenu(); return 'ok'; }
return 'invalid';
}
_onEndingGesture(g) {
const settled = this._atSettle();
switch (g) {
case 'tap':
if (settled) { this.newNight(); return 'ok'; }
// 结局/尾声链被打断后无等待点:重播结局链(局面复述)兜底
this._playEndingChain();
return 'ok';
case 'doubletap': {
const r = this._replayWaitPoint();
if (r) return r;
this._playEndingChain();
return 'ok';
}
case 'up':
return this._speakStatus() ? 'ok' : 'invalid';
case 'down':
this.openMenu();
return 'ok';
default:
this._ensureWait();
return 'invalid'; // 左右滑:解释句 = settle full「单击,重新开始。」
}
}
_onAskGesture(g) {
const s = this.saved;
switch (g) {
case 'tap':
this._resume(s);
return 'ok';
case 'right':
this.saved = null;
this.newNight();
return 'ok';
case 'doubletap': {
const r = this._replayWaitPoint();
return r || 'invalid';
}
case 'up': {
// 询问态的状态一句话:第几通 + 口碑档(存档里的账)
const n = (s && s.call) || 0;
if (n < 1 || n > CALLS.length) return 'invalid';
const ids = [PROG_VOICES[n - 1], TIER_VOICES[tierOf(s ? s.rep : 0)]];
this._tierCue(ids[1]);
this.floor.speak(ids, {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'ok';
}
default:
this._ensureWait();
return 'invalid';
}
}
_onMenuGesture(g) {
const announce = () => {
this.floor.speak([MENU_ITEMS[this.menuIdx].voice], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
};
switch (g) {
case 'left': // 上一项,报名(00 号 §5.4:左右滑切换、单击确认)
this.menuIdx = (this.menuIdx + MENU_ITEMS.length - 1) % MENU_ITEMS.length;
announce();
return 'hold';
case 'right': // 下一项,报名
this.menuIdx = (this.menuIdx + 1) % MENU_ITEMS.length;
announce();
return 'hold';
case 'tap': // 确认当前项(单击的全局含义=确定,菜单里不改)
this._execMenuItem(MENU_ITEMS[this.menuIdx].id);
return 'ok';
case 'up': // 重听菜单说明 + 设置提示(设置只在大厅改)
this.floor.speak(['voice/menu_help', 'voice/sys_settings'], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
case 'doubletap': {
const r = this._replayWaitPoint();
return r || 'invalid';
}
case 'down': // 再下滑关闭(00 号 §5.4)
this.closeMenu();
return 'ok';
default:
return 'invalid';
}
}
/**
* 打断兜底(§4.1 不允许安静态):滑动打断了当前链且本游戏判 invalid 时,链已死、
* 等待点可能已被消费——按当前局面重新交出话轮,等待声/铃声重新响起。
* (floor.speak 不摘旧等待点,pipeline 的 settleWaitPoint(false) 只在有等待点时
* 复位;这里补齐「无等待点」的分支,连点/乱滑永不留死局。)
*/
_ensureWait() {
if (this.floor.speaking || this.floor.waitPoint) return;
if (this.phase === 'MENU') { this.floor.yieldTurn(this._menuWp()); return; }
if (this.phase === 'ASK') { this.floor.yieldTurn(this._reopenWp()); return; }
if (this.phase === 'RING') { this.floor.yieldTurn(this._ringWp()); return; }
if (this.phase === 'ENDING') { this.floor.yieldTurn(this._settleWp()); return; }
if (this.phase === 'BRIDGE') return; // 自动流:喘息定时器照走
if (!this.nodeId) { this.floor.yieldTurn(this._introWp()); return; } // 冷启动开场被打断
const node = NODES[this.nodeId];
if (node && node.prompt != null) this.floor.yieldTurn(this._choiceWp(node));
else this.floor.yieldTurn(this._continueWp());
}
// ---------------------------------------------------------------- 上滑「我在哪」(原则 3)
/** 状态一句话的分档整句(位置=第几通 → 目标=口碑档;动作由等待声/铃声承担)。 */
_statusIds() {
if (this.phase === 'ENDING' && this.endingId) {
const def = ENDINGS[this.endingId] || ENDINGS.e1;
return [def.voices[0], TIER_VOICES[tierOf(this.rep)]]; // 结局主句 → 口碑
}
if (this.callN >= 1 && this.callN <= CALLS.length) {
return [PROG_VOICES[this.callN - 1], TIER_VOICES[tierOf(this.rep)]];
}
return [];
}
/** 播状态一句话,播完回到原等待点(不重播节点;口径=G6 状态在前)。 */
_speakStatus() {
const ids = this._statusIds();
if (!ids.length) return false;
this._tierCue(ids[ids.length - 1]);
this.floor.speak(ids, {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return true;
}
// ---------------------------------------------------------------- 夜班流转
/** 新夜班:重置跨通话标量(结局收集跨夜保留),台标口播后回 RING(c1)。 */
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.leaving = false;
this.saved = null;
this._teachHead = null;
this._teachTail = null;
this.chainGen++;
this._bridgeGen++;
this.floor.interrupt();
this._stopRing();
this._stopAmb();
this._cancelRest();
this.floor.bedEnabled = true;
this.phase = 'INTRO';
this.audio.play('bgm/onair'); // 从菜单/结局重开为同首不重启
this.nodeId = 'b_open';
this._save();
// 重开不再念开场教学(做对过的事不教第二遍,原则 11);台标口播照走
this.floor.speak(['voice/sys_new_night'], {
onDone: () => this.playNode('b_open'),
});
}
/** RING:热线接入。铃声循环垫即等待声(不起合成「嘟—嘟」)+ 交出话轮。 */
enterRing(n) {
this.callN = n;
this.phase = 'RING';
this.nodeId = this._callEntry(); // 存档落「本通入口」:RING 断点重进即重铃本通
this._stopAmb();
this.audio.play('bgm/onair', { fadeMs: 600 }); // 进下一通淡回 onair
this.floor.bedEnabled = false; // 15 号 §2:RING 用铃声循环代替等待声
this._save(); // 写入时机:每通电话 RING 进入
this._startRing();
this.floor.yieldTurn(this._ringWp());
}
/**
* 铃声循环垫起播(sfx/phone_ring,程序合成无缝 loop):引擎 sfx 层「即发即弃」
* 无 loop/stop——游戏侧经引擎 ctx/_sfxBus 起循环 BufferSource 并持句柄,接听/
* 菜单/重开的 _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 仍有交接待声 + 等待点提示 */ });
}
_stopRingSfx() {
if (this._ringSrc) {
try { this._ringSrc.src.stop(); } catch (_) { /* 已停止 */ }
this._ringSrc = null;
}
}
_stopRing() {
this._stopRingSfx();
}
/** BRIDGE:幕间喘息(自动流,BUSY 演出态)。口碑跨档被动播报 → 3s 垫乐 → 下一通。 */
enterBridge() {
this._stopAmb();
this.phase = 'BRIDGE';
this.floor.interrupt(); // 自动流不挂等待点;摘掉残留等待点/杀在途链
this.audio.play('sfx/line_drop'); // 挂断咔哒
this.audio.play('bgm/bridge', { fadeMs: 800 }); // crossfade 进幕间
const gen = ++this._bridgeGen;
const tier = tierOf(this.rep);
const ids = [];
if (tier !== this.lastAnnouncedTier) {
this.lastAnnouncedTier = tier;
ids.push(TIER_VOICES[tier]);
this._tierCue(TIER_VOICES[tier]); // rep_low 附 warning 震
}
this.floor.speak(ids, { onDone: () => this._armRest(gen) });
}
_armRest(gen) {
if (this.restTimer) clearTimeout(this.restTimer);
this.restTimer = setTimeout(() => {
this.restTimer = null;
if (this.phase !== 'BRIDGE' || gen !== this._bridgeGen) 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._stopAmb();
this.audio.play('bgm/ending', { fadeMs: 1500 }); // p9 结果链播完后淡入
// 一局结束 → 手势阶梯阶段 2/3(§4.3),教学句挂结局链尾(做完最后一件事才教)
const got = this.ladder.onGameDone();
if (got) this._teachTail = this._teachLineOf(got);
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]); // 救到 / 错过,点名每一通
}
const teach = this._takeTeach();
if (teach) chain.push(teach);
this.floor.speak(chain, {
onDone: () => this.floor.yieldTurn(this._settleWp()),
});
}
// ---------------------------------------------------------------- 存档续玩(§4.12)
_askContinue(save) {
this.saved = save;
this.nights = save.nights || 1; // 右滑新夜班 → nights+1 的基数从存档起
this.phase = 'ASK';
this.floor.speak(['voice/sys_continue_ask'], {
onDone: () => this.floor.yieldTurn(this._reopenWp()),
});
}
/** 断点续玩:恢复变量并重播当前局面(RING=铃声;ONAIR=重播当前节点=局面复述)。 */
_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.callN = s.call || this._callOfNode(s.node) || 1;
this.goalMet = !!s.goalMet || this.ladder.stage() >= 1;
this.skipHintShown = !!s.skipHintShown;
this.ringTaught = !!s.ringTaught;
this.turnTaught = !!s.turnTaught;
this.leaving = false;
this.chainGen++;
this._bridgeGen++;
this.floor.interrupt();
this._stopRing();
this._stopAmb();
this._cancelRest();
const phase = s.phase === 'MENU' ? 'ONAIR' : s.phase;
if (phase === 'RING') {
this.phase = 'RING';
this.floor.bedEnabled = false;
this._startRing();
this.floor.yieldTurn(this._ringWp());
} else if (phase === 'BRIDGE') {
this.phase = 'BRIDGE';
this.audio.play('bgm/bridge', { fadeMs: 800 });
this._armRest(++this._bridgeGen); // 续走幕间喘息
} else if (phase === 'ENDING') {
this.enterEnding(s.ending && ENDINGS[s.ending] ? s.ending : undefined);
} else {
this.phase = 'ONAIR';
this.playNode(NODES[s.node] ? s.node : this._callEntry());
}
}
_callOfNode(id) {
const c = typeof id === 'string' ? id[0] : '';
return CALL_OF_NODE[c] || 1;
}
_callEntry() {
const call = CALLS[this.callN - 1];
return call && NODES[call.entry] ? call.entry : 'z1_ring_in';
}
// ---------------------------------------------------------------- 节点链驱动(floor 范式)
playNode(id) {
const node = NODES[id];
if (!node) {
console.error('[RadioGame] 未知节点:', id);
return;
}
this.nodeId = id;
this._save(); // 写入时机:每个节点进入时(§4.12 任意时刻退出都在本节点上)
if (node.amb) this._startAmb(node.amb);
if (id === 'p9') this.audio.play('bgm/onair', { fadeMs: 600 }); // 签收回 onair
if (node.danger) {
// 危机拍:danger 震 + 前置预警窗(brake_screech 资产自带渐强),BUSY 演出态
this.floor.interrupt();
vibrate('danger');
if (node.sfx) this.audio.play(node.sfx);
const gen = ++this.chainGen;
setTimeout(() => {
if (gen !== this.chainGen) return;
this._speakNode(node);
}, node.preMs || 0);
return;
}
this._speakNode(node);
}
/** 节点语音链:链头/链尾附加(教学)→ floor.speak → 链完交出对应等待点。 */
_speakNode(node) {
const id = this.nodeId;
++this.chainGen;
const seq = this._nodeVoices(id);
if (node.prompt) seq.push(node.prompt);
if (this._teachHead) { seq.unshift(this._teachHead); this._teachHead = null; } // 「对。」
const teach = this._takeTeach(); // 解锁教学句挂链尾(§4.3 阶梯)
if (teach) seq.push(teach);
// 「轮到你」状态声首现教学(原则 2):挂老周开口(z1_a)链尾,全局一次
if (id === 'z1_a' && !this.turnTaught) {
this.turnTaught = true;
seq.push('voice/sys_turn_teach');
}
// sys_skip:开场每实例一次(「tap=跳句」的可发现性,storyline skip_hint 先例)
if (id === 'z1_b' && !this.skipHintShown) {
this.skipHintShown = true;
seq.push('voice/sys_skip');
}
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);
}
if (node.prompt) markHintSaid(node.prompt); // 原则 9:选项句已播,被动提示自动降档
this.floor.speak(seq, {
critical: !!node.prompt, // p* 首播不可跳(盲玩公平,15 号 G5;tap/滑动=busy)
onDone: () => this._onNodeDone(node),
});
}
/** 节点基础语音链(含节点内条件选播: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];
}
/** 双击重听(无等待点时)/ 打断兜底:重播当前节点链(复播可掐;p* 复播仍 critical)。 */
_replayNode() {
const node = NODES[this.nodeId];
if (!node) { this._ensureWait(); return; }
this._speakNode(node);
}
/** '@x' 虚拟目标 = 过场转移(电台节目流自动衔接,§2.2 转移图「链完→」)。 */
_isTransition(target) {
return typeof target === 'string' && target.startsWith('@');
}
/**
* 节点链自然播完的公共收尾(floor 保证跳句/打断不会吃掉这次交接):
* 抉择节点 → choice 等待点;auto / @x 过场 → 自动衔接;其余 → continue 等待点。
*/
_onNodeDone(node) {
if (node.prompt != null) {
this.floor.yieldTurn(this._choiceWp(node)); // 播完当刻即可选,零插播(W2)
return;
}
if (node.next == null) { this._ensureWait(); return; }
if (node.auto || this._isTransition(node.next)) {
this.advance(node.next);
return;
}
this.floor.yieldTurn(this._continueWp());
}
/** 链完转移:'@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 数值表)→ 存档 → 首目标解锁上滑(教学句挂下一段链头/尾)→ 进结果节点。 */
choose(g) {
const node = NODES[this.nodeId];
if (!node || node.prompt == null) return;
this.audio.play('sfx/ui_confirm');
this._applyChoice(this.nodeId, g);
this._save(); // 写入时机:每个抉择结算后
// 完成第一个小目标 → 解锁上滑(§4.3 阶段 1):
// 「对。」挂下一段链头(原则 11 肯定 ≤4 字),教学句挂链尾(一次只教一个)
if (!this.goalMet) {
this.goalMet = true;
const got = this.ladder.onGoal();
if (got) {
this._teachHead = 'voice/sys_praise';
this._teachTail = this._teachLineOf(got);
}
}
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 表行「p4 问她/哄睡」的列序与 §3.2 树形/提示句相反,以树形为准。
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;
}
}
/** 解锁教学句 voice id(novice.teachLine 已返回带 voice/ 前缀的 id,直接进链)。 */
_teachLineOf(got) { return this.ladder.teachLine(got); }
_takeTeach() {
const t = this._teachTail;
this._teachTail = null;
return t;
}
// ---------------------------------------------------------------- 等待点(§4.4 附录 A 配置)
_introWp() {
return new WaitPoint({
id: 'intro',
expected: ['tap'],
full: null, // 指令句刚说过:静默第一档直接短句(§4.10 找猫示例同型)
short: 'voice/sys_first_tap_short',
silenceMs: SILENCE.intro,
onDemo: () => {
// 迷茫第 3 级:替玩家点一下(直接进第一通),话轮在通话链末交回
this.floor.speak(['voice/sys_demo_continue'], { onDone: () => this._beginFirstRing() });
},
}, { floor: this.floor, confusion: this.confusion });
}
_ringWp() {
return new WaitPoint({
id: 'ring',
expected: ['tap'],
full: 'voice/gd_ring1',
short: 'voice/sys_hint_ring_short',
silenceMs: SILENCE.ring,
onDemo: () => {
// 演示接听:替玩家走一步(进通话),话轮在通话链末交回
this.floor.speak(['voice/sys_demo_continue'], {
onDone: () => {
this._stopRing();
this.floor.bedEnabled = true;
this.phase = 'ONAIR';
this.playNode(this._callEntry());
},
});
},
}, { floor: this.floor, confusion: this.confusion });
}
_continueWp() {
return new WaitPoint({
id: 'continue',
expected: ['tap'],
full: 'voice/sys_hint_continue_full',
short: 'voice/sys_hint_continue_short',
silenceMs: SILENCE.continue,
onDemo: () => {
// 演示继续:替玩家走一步(进下一节点),话轮在新链末交回
this.floor.speak(['voice/sys_demo_continue'], {
onDone: () => {
const node = NODES[this.nodeId];
if (node && node.next != null && node.prompt == null) this.advance(node.next);
else this.floor.yieldTurn(this._continueWp());
},
});
},
}, { floor: this.floor, confusion: this.confusion });
}
_choiceWp(node) {
return new WaitPoint({
id: 'choice',
expected: ['left', 'right'],
full: node.prompt, // 主动重听给完整选项;被动提示因台账自动降档(原则 9)
short: 'voice/sys_hint_choice_short',
restrictLine: 'voice/sys_only_choice',
silenceMs: SILENCE.choice,
onDemo: () => {
// 演示不替玩家做有后果的抉择:复述两个方向,交回(storyline 同款)
this.floor.speak(['voice/sys_demo_choice'], {
onDone: () => this.floor.yieldTurn(this._choiceWp(node)),
});
},
}, { floor: this.floor, confusion: this.confusion });
}
_settleWp() {
return new WaitPoint({
id: 'settle',
expected: ['tap'],
full: 'voice/sys_hint_again_full',
short: 'voice/sys_hint_continue_short',
silenceMs: SILENCE.settle,
onDemo: () => this.floor.chime(),
}, { floor: this.floor, confusion: this.confusion });
}
_reopenWp() {
return new WaitPoint({
id: 'reopen',
expected: ['tap', 'right'],
full: 'voice/sys_continue_ask',
short: 'voice/sys_hint_continue_short',
silenceMs: SILENCE.reopen,
onDemo: () => this.floor.chime(),
}, { floor: this.floor, confusion: this.confusion });
}
_menuWp() {
return new WaitPoint({
id: 'menu',
expected: ['tap', 'left', 'right'],
full: 'voice/menu_help',
short: 'voice/sys_hint_menu_short',
silenceMs: SILENCE.menu,
onDemo: () => this.floor.chime(),
}, { floor: this.floor, confusion: this.confusion });
}
_atSettle() {
const wp = this.floor.waitPoint;
return !!(this.phase === 'ENDING' && wp && wp.conf.id === 'settle' && !this.floor.speaking);
}
// ---------------------------------------------------------------- 菜单(00 号 §5.4:左右滑切换、单击确认)
openMenu() {
this.returnPhase = this.phase;
this.returnNode = this.nodeId;
this.phase = 'MENU';
this.menuIdx = 0;
this.floor.interrupt();
this._stopRing();
this._cancelRest();
this.audio.play('sfx/ui_menu');
this.audio.play('bgm/menu', { fadeMs: 500 }); // 菜单 pad crossfade 500ms
this.floor.speak(['voice/sys_menu', MENU_ITEMS[0].voice], {
onDone: () => this.floor.yieldTurn(this._menuWp()),
});
}
_execMenuItem(id) {
if (id === 'resume') {
this.closeMenu();
} else if (id === 'restart') {
this.newNight();
} else if (id === 'lobby') {
// 保存并回大厅:确认句播出且存档落盘后跳转(「确认后跳转前播」)
this.leaving = true;
this._save();
this.floor.speak(['voice/sys_menu_lobby'], {
onDone: () => {
try {
if (typeof window !== 'undefined' && window.location) window.location.href = '../';
} catch (_) { /* 无头环境无 location:留档 leaving 标记 */ }
},
});
}
}
closeMenu() {
this.floor.interrupt(); // 摘掉菜单等待点、杀残留报名链
const back = this.returnPhase || 'ONAIR';
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 }); // 关菜单淡回原状态曲
if (back === 'RING') {
this.floor.bedEnabled = false;
this._startRing();
this.floor.yieldTurn(this._ringWp());
} else if (back === 'BRIDGE') {
this._armRest(++this._bridgeGen); // 幕间续走(口碑句已播过不重播,账目真实)
} else if (back === 'ENDING') {
this._playEndingChain(); // 重播结局链(局面复述)
} else if (back === 'ASK') {
this.floor.speak(['voice/sys_continue_ask'], {
onDone: () => this.floor.yieldTurn(this._reopenWp()),
});
} else {
this.playNode(this.returnNode); // INTRO/ONAIR:重播当前节点(局面复述)
}
}
// ---------------------------------------------------------------- 口碑提示(G2:rep_low 附 warning 震)
_tierCue(tierId) {
if (tierId === TIER_VOICES.low) vibrate('warning');
}
// ---------------------------------------------------------------- 环境音(游戏层循环)
/**
* 通话侧 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; }
}
// ---------------------------------------------------------------- 存档(§7;summary 服务器进度卡片用)
/** data 结构照 §7;summary 字符串必带(大厅卡片直接显示)。 */
_save() {
if (!this.nodeId) return;
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 === 'MENU' ? (this.returnPhase || 'ONAIR') : 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,
goalMet: this.goalMet,
skipHintShown: this.skipHintShown,
ringTaught: this.ringTaught,
turnTaught: this.turnTaught,
};
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;