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 置完成。
1320 lines
54 KiB
JavaScript
1320 lines
54 KiB
JavaScript
/**
|
||
* 《不眠频率》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;
|