按 docs/design/26-audio-interaction-playbook.md 重构三款游戏(对白重写 + 双性别语音重生成 580 文件)。 引擎层(web/engine/,其余 12 款游戏零改动): - floor.js 发言权管理器:SPEAKING/YOUR_TURN/BUSY/ACTION 四态、交接提示音、 等待声(220/330Hz 嘟-嘟,20s 后放缓)、跨链音乐压低(audio.js holdDuck) - receipts.js 输入回执三件套(ok/invalid/busy,100ms 内,原则 4) - confusion.js 迷茫检测(乱点/无效/静默信号 → 三级接管,§4.5) - hints.js 等待点 + 三档提示(full→short→提示音,30s 台账跨点去重,原则 9) - novice.js 手势解锁阶梯(up/down/doubletap 跨局持久,§4.3) - pipeline.js 输入管道(回执收口、防连点 400ms、双击折算、explainOnce) 游戏层: - storyline R3:开场 71.7s 规则念白 → 3 秒内动手;双击=重播(反模式5)废除; 抉择点三档提示+主动重听;断点存档复述(§4.12) - escape R3:单击=检查+使用合并(双击用法废除);长按=我在哪(四向玩法裁定); 撞墙归因每方向一次(原则 7);迷茫 L2 缩小/L3 演示;坐标存档续玩 - simon R3:新手三音色,按实力渐进解锁(+up/down/dt);失败表替代机会账 (归因 2s 内→免费重听→放慢→结算,原则 15);播放期手势=busy 回执 测试:三款 playtest 重写(含 §6.2 乱点机器人:无声操作/被动重复/打断失败全 0, 首操作 ~2s);guide.test 改为 Guide 层单测+rhythm/space 段。全部通过 (38+33+22+72+19+35 断言)。设计单见 docs/design/26-apply-*.md。
630 lines
24 KiB
JavaScript
630 lines
24 KiB
JavaScript
/**
|
||
* 《声命线》R3 —— 末世互动广播剧,按 docs/design/26-audio-interaction-playbook.md 全面重构。
|
||
*
|
||
* 交互层(对齐 26 号文档,设计单见 docs/design/26-apply-storyline.md):
|
||
* - 发言权管理器(§4.1):整条语音链 = SPEAKING(音乐压低跨链、句间不掉回「轮到你」
|
||
* 听感);链完 → yieldTurn:交接提示音 → 400ms 后起等待声;玩家一操作等待声
|
||
* 80ms 内淡出。不存在安静态。
|
||
* - 输入回执 + 输入管道(§4.2):ok/invalid/busy 三件套;单击执行「继续/跳过」后
|
||
* 400ms 防连点;滑动打断并执行(floor 层统一裁定)。
|
||
* - 新手手势宪法(§4.3):doubleTapWindow=0(无双击、单击零延迟);双击未解锁按
|
||
* 单击处理;up=「我在哪」(阶段 1 解锁)、down=菜单(阶段 2)、双击=重听(阶段 3)。
|
||
* 双击=整节点重播的旧行为是 26 号 §7 反模式 5,已废除。
|
||
* - 三档提示(§4.4):每个等待点 full → short → 提示音;玩家主动重听 =
|
||
* 「再说一遍:」+ 完整句。开场不再念 26 秒规则(旧 n00_intro 71.7s 首句教学
|
||
* 在 3 秒内、第一次成功在 10 秒内,§4.10 逐秒脚本见设计单)。
|
||
* - 迷茫检测(§4.5):乱点/无效/静默信号 → 三级接管(短句 → 只留左右滑 → 演示)。
|
||
* - 断点复述(§4.12):任意节点自动存档;回来先问「继续上次 / 从头开始」,
|
||
* 复述句 = 状态一句话(位置 → 目标 → 一个动作,原则 3)。
|
||
*
|
||
* 叙事层保留:节点图、剧情标记(companion/knowTrap/betrayed/viaCulvert)、虚拟节点
|
||
* resolve、e1 彩蛋、4 结局。
|
||
*
|
||
* 状态 × 手势矩阵(新手期,完整版见设计单):
|
||
* | 状态 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) |
|
||
* |-----------|---------------|--------------------|-------------|------------------|--------------|
|
||
* | 游戏在说 | 跳过这句+ok | 抉择节点→跳去选项;否则 invalid | 打断,答「我在哪」 | invalid+解释 | 按单击处理 |
|
||
* | 等你继续 | 继续+ok | invalid+解释 | 「我在哪」 | invalid+解释 | (阶段3=重听)|
|
||
* | 等你选择 | 再听选项(hold) | 抉择+ok | 「正在抉择」 | invalid+解释 | (阶段3=重听)|
|
||
* | 结算 | 重新开始+ok | invalid+解释 | 重播结局句 | 菜单(阶段2解锁) | 按单击处理 |
|
||
* | 菜单 | 换一项+播报 | 左=上一项 / 右=确认 | 重听菜单说明 | invalid+解释 | 按单击处理 |
|
||
*/
|
||
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 } from '../engine/hints.js';
|
||
import { GestureLadder } from '../engine/novice.js';
|
||
|
||
/** 剧情节点。voices 顺序播放;prompt 非空表示这是一个选择节点。 */
|
||
function node(voices, opts = {}) {
|
||
return { voices, next: null, prompt: null, left: null, right: null, sfx: null, ending: false, ...opts };
|
||
}
|
||
|
||
const NODES = {
|
||
// 第一幕:地铁站与街面(n00 拆成两句;旧 71.7s n00_intro 的规则念白废除,§4.10)
|
||
n00: node(['voice/n00_b'], { next: 'n01' }),
|
||
n01: node(['voice/n01_wake'], { next: 'n02', sfx: 'sfx/radio_static' }),
|
||
n02: node(['voice/n02_linwan'], { prompt: 'voice/p1', left: 'n03', right: 'n04' }),
|
||
n03: node(['voice/n03_radio', 'voice/n03_msg'], { next: 'n05', sfx: 'sfx/radio_static' }),
|
||
n04: node(['voice/n04_exit'], { next: 'n05', sfx: 'sfx/footsteps' }),
|
||
n05: node(['voice/n05_signal', 'voice/n05_bcast'], { next: 'n06', sfx: 'sfx/radio_static' }),
|
||
n06: node(['voice/n06_whistle'], { next: 'n07', sfx: 'sfx/whistle' }),
|
||
n07: node(['voice/n07_dog'], { prompt: 'voice/p2', left: 'n08', right: 'n09' }),
|
||
n08: node(['voice/n08_vent'], { next: 'n10', sfx: 'sfx/rubble' }),
|
||
n09: node(['voice/n09_dead'], { next: 'n10' }),
|
||
// 第二幕:雨夜与断桥
|
||
n10: node(['voice/n10_rain'], { prompt: 'voice/p3', left: 'n11', right: 'n14', sfx: 'sfx/wind' }),
|
||
n11: node(['voice/n11_child'], { prompt: 'voice/p4', left: 'n12', right: 'n13' }),
|
||
n12: node(['voice/n12_take'], { next: 'n15' }),
|
||
n13: node(['voice/n13_leave'], { next: 'n15', sfx: 'sfx/footsteps' }),
|
||
n14: node(['voice/n14_ignore'], { next: 'n15', sfx: 'sfx/footsteps' }),
|
||
n15: node(['voice/n15_bridge'], { prompt: 'voice/p5', left: 'v_culvert', right: 'v_cable', sfx: 'sfx/wind' }),
|
||
n16: node(['voice/n16_culvert'], { next: 'n18', sfx: 'sfx/water' }),
|
||
n16b: node(['voice/n16b_culvert_c'], { next: 'n18', sfx: 'sfx/water' }),
|
||
n17: node(['voice/n17_cable'], { next: 'n18', sfx: 'sfx/wind' }),
|
||
n17b: node(['voice/n17b_cable_c'], { next: 'n18', sfx: 'sfx/wind' }),
|
||
// 第三幕:广播塔
|
||
n18: node(['voice/n18_dog'], { prompt: 'voice/p6', left: 'n19', right: 'n22', sfx: 'sfx/footsteps' }),
|
||
n19: node(['voice/n19_betray'], { next: 'n20' }),
|
||
n20: node(['voice/n20_convoy'], { prompt: 'voice/p7', left: 'n21', right: 'e3' }),
|
||
n21: node(['voice/n21_redeem'], { next: 'e2', sfx: 'sfx/footsteps' }),
|
||
n22: node(['voice/n22_fight'], { next: 'n23', sfx: 'sfx/gunshot' }),
|
||
n23: node(['voice/n23_tower'], { prompt: 'voice/p8', left: 'n24', right: 'n25', sfx: 'sfx/signal_beep' }),
|
||
n24: node(['voice/n24_open'], { next: 'e4', sfx: 'sfx/signal_beep' }),
|
||
n25: node(['voice/n25_secure'], { next: 'e1' }),
|
||
// 结局
|
||
e1: node(['voice/e1_dawn'], { ending: true }),
|
||
e2: node(['voice/e2_price'], { ending: true }),
|
||
e3: node(['voice/e3_alone'], { ending: true, sfx: 'sfx/radio_static' }),
|
||
e4: node(['voice/e4_static'], { ending: true, sfx: 'sfx/struggle' }),
|
||
};
|
||
|
||
const ALL_SFX = [
|
||
'sfx/footsteps', 'sfx/gunshot', 'sfx/heartbeat', 'sfx/radio_static',
|
||
'sfx/rubble', 'sfx/signal_beep', 'sfx/struggle', 'sfx/ui_confirm',
|
||
'sfx/ui_menu', 'sfx/water', 'sfx/whistle', 'sfx/wind',
|
||
];
|
||
|
||
const MODE = { REOPEN: 'REOPEN', STORY: 'STORY', MENU: 'MENU' };
|
||
|
||
const SAVE_KEY = 'soundgame.storyline.save';
|
||
|
||
// 等待点静默阈值(§4.5:剧情类 18s;开场/抉择更快,对齐旧 CHOICE_HINT 礼仪)
|
||
const SILENCE = {
|
||
intro: [6000, 15000], // 开场第一下:6 秒就提醒(F7:30~60 秒内必须第一次成功)
|
||
continue: [18000, 40000],
|
||
choice: [25000, 50000],
|
||
settle: [20000, 45000],
|
||
reopen: [12000, 30000],
|
||
menu: [20000, 45000],
|
||
};
|
||
|
||
// 状态一句话(原则 3:位置 → 目标 → 现在能做的一个动作,≤20 字)
|
||
const STATUS = { 1: 'voice/status_s1', 2: 'voice/status_s2', 3: 'voice/status_s3' };
|
||
|
||
// 无效操作解释(原则 7:第一次说原因和正确做法,之后只给回执)
|
||
const INVALID_LINES = {
|
||
'locked:up': 'voice/inv_up_locked',
|
||
'locked:down': 'voice/inv_down_locked',
|
||
'locked:longpress': 'voice/inv_generic',
|
||
'story:swipe': 'voice/inv_nosel',
|
||
'settle:swipe': 'voice/sys_hint_again_full',
|
||
'menu:down': 'voice/menu_help',
|
||
};
|
||
|
||
export class StorylineGame extends Game {
|
||
constructor(audio, root) {
|
||
super(audio, root);
|
||
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)
|
||
|
||
this.mode = MODE.STORY;
|
||
this.nodeId = null;
|
||
this.prelude = 0; // 开场前奏步(0=等第一下单击,1=旁白+状态教学已播)
|
||
this.chainKind = 'none'; // 'story' | 'prompt' | 'other':滑动打断时的分流依据
|
||
this.goalMet = false; // 第一个小目标(第一次抉择)已达成 → 解锁上滑
|
||
this.skipHintShown = false;
|
||
this._teachQueue = null; // 待挂到下一段链尾的解锁教学句(§4.3 阶梯)
|
||
|
||
// 剧情标记
|
||
this.companion = false;
|
||
this.knowTrap = false;
|
||
this.betrayed = false;
|
||
this.viaCulvert = false;
|
||
|
||
// 菜单
|
||
this._menuIdx = 0;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Game
|
||
|
||
start() {
|
||
this.audio.play('bgm/dark_drone');
|
||
this.audio.prewarm(ALL_SFX);
|
||
const save = this._loadSave();
|
||
if (save && save.nodeId && save.nodeId !== 'n00') {
|
||
// 断点复述(§4.12):回来先问继续还是重开
|
||
this.mode = MODE.REOPEN;
|
||
this._restoreFlags(save);
|
||
this.floor.speak(['voice/sys_continue_ask'], {
|
||
onDone: () => this.floor.yieldTurn(this._reopenWp()),
|
||
});
|
||
} else {
|
||
this._coldStart();
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
this._save();
|
||
this.floor.interrupt();
|
||
this.audio.stopAll();
|
||
}
|
||
|
||
/** 冷启动首 3 分钟开场(§4.10 逐秒脚本,见设计单)。 */
|
||
_coldStart() {
|
||
this.mode = MODE.STORY;
|
||
this.nodeId = null;
|
||
this.prelude = 0;
|
||
this.audio.play('sfx/heartbeat');
|
||
// 0~3 秒:第一句就让玩家动手。指令句 critical(<3s):抢拍单击给 busy
|
||
// 回执,保证第一次教学完整到达(F7:第一分钟内必须给出第一次成功)
|
||
this.chainKind = 'story';
|
||
this.floor.speak(['voice/sys_first_tap'], {
|
||
critical: true,
|
||
onDone: () => this.floor.yieldTurn(this._introWp()),
|
||
});
|
||
}
|
||
|
||
/** 第一次单击之后:旁白 + 「轮到你了」状态教学(原则 2:状态声第一次出现配解释)。 */
|
||
_afterFirstTap() {
|
||
this.prelude = 1;
|
||
this.chainKind = 'story';
|
||
this.floor.speak(['voice/n00_a', 'voice/sys_turn_teach'], {
|
||
onDone: () => this.floor.yieldTurn(this._continueWp()),
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 手势(pipeline 之后)
|
||
|
||
/** pipeline 收口后的游戏处理;返回 'ok' | 'hold' | 'invalid'。 */
|
||
onGesture(g) {
|
||
if (this.mode === MODE.MENU) return this._onMenuGesture(g);
|
||
if (this.mode === MODE.REOPEN) return this._onReopenGesture(g);
|
||
return this._onStoryGesture(g);
|
||
}
|
||
|
||
stateName() {
|
||
if (this.mode === MODE.MENU) return 'menu';
|
||
if (this.mode === MODE.REOPEN) return 'reopen';
|
||
const node = NODES[this.nodeId];
|
||
if (node && node.ending && !this.floor.speaking) return 'settle';
|
||
return 'story';
|
||
}
|
||
|
||
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_nosel';
|
||
}
|
||
if (state === 'menu' && g === 'down') return INVALID_LINES['menu:down'];
|
||
return INVALID_LINES[`${state}:swipe`] || 'voice/inv_generic';
|
||
}
|
||
|
||
_onStoryGesture(g) {
|
||
// 开场前奏(§4.10):第一下单击 → 旁白+状态教学;第二下单击 → 正式剧情
|
||
if (this.prelude === 0 && g === 'tap') {
|
||
this._afterFirstTap();
|
||
return 'ok';
|
||
}
|
||
if (this.prelude === 1 && g === 'tap') {
|
||
this.prelude = 2;
|
||
this.playNode('n00');
|
||
return 'ok';
|
||
}
|
||
if (this.prelude < 2 && (g === 'left' || g === 'right')) return 'invalid'; // 前奏不选
|
||
|
||
const node = NODES[this.nodeId];
|
||
if (!node) return 'invalid';
|
||
switch (g) {
|
||
case 'tap': {
|
||
// 游戏在说 → pipeline 已按「跳过这句」处理(consumed),到不了这里
|
||
const wp = this.floor.waitPoint;
|
||
if (wp && wp.conf.id === 'choice') {
|
||
// 抉择点空点 = 主动再听选项(「再说一遍:」+ 完整句,原则 9)
|
||
wp.requestReplay();
|
||
return 'hold';
|
||
}
|
||
if (node.ending) { this._restart(); return 'ok'; }
|
||
if (node.next != null) { this.playNode(this.resolve(node.next)); return 'ok'; }
|
||
return 'invalid';
|
||
}
|
||
case 'doubletap': {
|
||
// 阶段 3 解锁的重听(§4.3):等待点 → 重听该点完整句;否则重播当前节点
|
||
const wp = this.floor.waitPoint;
|
||
if (wp) { wp.requestReplay(); return 'hold'; }
|
||
this._replayNode();
|
||
return 'ok';
|
||
}
|
||
case 'left':
|
||
case 'right': {
|
||
if (node.prompt != null) {
|
||
if (this.chainKind === 'story') {
|
||
// 旁白没听完就滑(打断并执行的落点 = 抉择点):只跳去选项,不当场盲选
|
||
this._playPrompt(node);
|
||
return 'ok';
|
||
}
|
||
this._choose(g, node);
|
||
return 'ok';
|
||
}
|
||
return 'invalid'; // inv_nosel:现在不用选
|
||
}
|
||
case 'up': {
|
||
// 「我在哪、我该干嘛」(原则 3):一句话,位置 → 目标 → 动作
|
||
const line = this._isChoiceWaiting(node)
|
||
? 'voice/status_choice' : STATUS[this.actOf(this.nodeId)];
|
||
this.chainKind = 'other';
|
||
this.floor.speak([line], { onDone: () => this.floor.yieldTurn(this._wpFor(node)) });
|
||
return 'ok';
|
||
}
|
||
case 'down': {
|
||
this._openMenu();
|
||
return 'ok';
|
||
}
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
_onReopenGesture(g) {
|
||
const save = this._loadSave();
|
||
switch (g) {
|
||
case 'tap':
|
||
this.mode = MODE.STORY;
|
||
this.prelude = 2;
|
||
this.playNode(save.nodeId, { resume: true });
|
||
return 'ok';
|
||
case 'right':
|
||
this._resetFlags();
|
||
this.prelude = 2;
|
||
this.playNode('n00');
|
||
return 'ok';
|
||
case 'up': {
|
||
const line = STATUS[this.actOf(save.nodeId)];
|
||
this.chainKind = 'other';
|
||
this.floor.speak([line], { onDone: () => this.floor.yieldTurn(this._reopenWp()) });
|
||
return 'ok';
|
||
}
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
_onMenuGesture(g) {
|
||
const items = ['voice/menu_item1', 'voice/menu_item2', 'voice/menu_item3'];
|
||
const announce = () => {
|
||
this.chainKind = 'other';
|
||
this.floor.speak([items[this._menuIdx]], {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
};
|
||
switch (g) {
|
||
case 'tap':
|
||
this._menuIdx = (this._menuIdx + 1) % items.length;
|
||
announce();
|
||
return 'hold';
|
||
case 'left':
|
||
this._menuIdx = (this._menuIdx + items.length - 1) % items.length;
|
||
announce();
|
||
return 'hold';
|
||
case 'right': {
|
||
const sel = this._menuIdx;
|
||
if (sel === 0) this._restart();
|
||
else if (sel === 1) { this.stop(); window.location.href = '../'; }
|
||
else this._closeMenu();
|
||
return 'ok';
|
||
}
|
||
case 'up':
|
||
this.chainKind = 'other';
|
||
this.floor.speak(['voice/menu_help'], {
|
||
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
|
||
});
|
||
return 'hold';
|
||
default:
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 剧情驱动
|
||
|
||
playNode(id, { resume = false } = {}) {
|
||
const node = NODES[id];
|
||
if (!node) {
|
||
console.error('[StorylineGame] 未知节点:', id);
|
||
return;
|
||
}
|
||
this.nodeId = id;
|
||
this.chainKind = 'story';
|
||
this._save();
|
||
if (node.sfx) this.audio.play(node.sfx);
|
||
const seq = [];
|
||
if (resume) seq.push(STATUS[this.actOf(id)]); // 断点复述句(§4.12)
|
||
seq.push(...this.nodeVoices(id));
|
||
if (id === 'n00' && !this.skipHintShown) {
|
||
this.skipHintShown = true;
|
||
seq.push('voice/skip_hint');
|
||
}
|
||
if (id === 'e1' && this.companion) seq.push('voice/e1_child');
|
||
if (node.prompt) seq.push(node.prompt); // 选项句收尾整条链,链完即交出抉择
|
||
// 手势解锁教学(§4.3 阶梯):第一次抉择后教上滑;第一/三局后教菜单/双击
|
||
const teach = this._pendingTeach(id);
|
||
if (teach) seq.push(teach);
|
||
this.floor.speak(seq, {
|
||
onDone: () => {
|
||
if (node.prompt != null) this.chainKind = 'prompt'; // 选项句已听过
|
||
this.floor.yieldTurn(this._wpFor(node));
|
||
},
|
||
});
|
||
}
|
||
|
||
/** 只播选项句并交出抉择(旁白被打断跳来这里)。 */
|
||
_playPrompt(node) {
|
||
this.chainKind = 'prompt';
|
||
this.floor.speak([node.prompt], { onDone: () => this.floor.yieldTurn(this._choiceWp(node)) });
|
||
}
|
||
|
||
_replayNode() {
|
||
const node = NODES[this.nodeId];
|
||
if (!node) return;
|
||
this.chainKind = 'story';
|
||
const seq = [...this.nodeVoices(this.nodeId)];
|
||
if (this.nodeId === 'e1' && this.companion) seq.push('voice/e1_child');
|
||
if (node.prompt) seq.push(node.prompt);
|
||
this.floor.speak(seq, { onDone: () => this.floor.yieldTurn(this._wpFor(node)) });
|
||
}
|
||
|
||
nodeVoices(id) {
|
||
const node = NODES[id];
|
||
return node ? [...node.voices] : [];
|
||
}
|
||
|
||
_choose(g, node) {
|
||
this.audio.play('sfx/ui_confirm');
|
||
// 选择带来的剧情标记(n07 右滑=装死听情报;以此类推)
|
||
if (this.nodeId === 'n07' && g === 'right') this.knowTrap = true;
|
||
if (this.nodeId === 'n11' && g === 'left') this.companion = true;
|
||
if (this.nodeId === 'n15') this.viaCulvert = g === 'left';
|
||
if (this.nodeId === 'n18' && g === 'left') this.betrayed = true;
|
||
// 完成第一个小目标 → 解锁上滑(§4.3 阶梯阶段 1),教学句挂在下一段链尾
|
||
if (!this.goalMet) {
|
||
this.goalMet = true;
|
||
const got = this.ladder.onGoal();
|
||
if (got) this._teachQueue = this.ladder.teachLine(got);
|
||
}
|
||
this.playNode(this.resolve(g === 'left' ? node.left : node.right));
|
||
}
|
||
|
||
/** 结局 → 解锁阶段 2/3 的教学句(挂在结算链尾,onGameDone 由 ladder 计数)。 */
|
||
_pendingTeach(id) {
|
||
const node = NODES[id];
|
||
if (this._teachQueue) { const t = this._teachQueue; this._teachQueue = null; return t; }
|
||
if (node && node.ending) {
|
||
const got = this.ladder.onGameDone();
|
||
if (got) return this.ladder.teachLine(got);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 虚拟目标:根据剧情标记解析到实际节点。 */
|
||
resolve(target) {
|
||
if (target === 'v_culvert') return this.companion ? 'n16b' : 'n16';
|
||
if (target === 'v_cable') return this.companion ? 'n17b' : 'n17';
|
||
return target;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 等待点
|
||
|
||
_wpFor(node) {
|
||
if (node.prompt != null) return this._choiceWp(node);
|
||
if (node.ending) return this._settleWp(node);
|
||
return this._continueWp();
|
||
}
|
||
|
||
_introWp() {
|
||
return new WaitPoint({
|
||
id: 'intro',
|
||
expected: ['tap'],
|
||
full: null, // 主动句刚说过,静默第一档直接短句(§4.10 找猫示例同型)
|
||
short: 'voice/sys_first_tap_short',
|
||
silenceMs: SILENCE.intro,
|
||
onDemo: () => {
|
||
this.chainKind = 'other';
|
||
this.floor.speak(['voice/sys_demo_continue'], {
|
||
onDone: () => this._afterFirstTap(), // 演示「继续」:直接替玩家走一步
|
||
});
|
||
},
|
||
}, { 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: () => {
|
||
const node = NODES[this.nodeId];
|
||
this.chainKind = 'other';
|
||
this.floor.speak(['voice/sys_demo_continue'], {
|
||
onDone: () => {
|
||
if (node && node.next != null && !node.ending) this.playNode(this.resolve(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,
|
||
short: 'voice/sys_hint_choice_short',
|
||
restrictLine: 'voice/sys_only_choice',
|
||
silenceMs: SILENCE.choice,
|
||
onDemo: () => {
|
||
// 演示不替玩家做有后果的抉择:复述两个方向,交回
|
||
this.chainKind = 'other';
|
||
this.floor.speak(['voice/sys_demo_choice'], {
|
||
onDone: () => this.floor.yieldTurn(this._choiceWp(node)),
|
||
});
|
||
},
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_settleWp(node) {
|
||
return new WaitPoint({
|
||
id: 'settle',
|
||
expected: ['tap'],
|
||
full: 'voice/sys_hint_again_full',
|
||
short: 'voice/sys_hint_continue_short',
|
||
silenceMs: SILENCE.settle,
|
||
onDemo: () => {
|
||
this.chainKind = 'other';
|
||
this.floor.speak(['voice/sys_hint_again_full'], {
|
||
onDone: () => this.floor.yieldTurn(this._settleWp(node)),
|
||
});
|
||
},
|
||
}, { 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/menu_help',
|
||
silenceMs: SILENCE.menu,
|
||
onDemo: () => this.floor.chime(),
|
||
}, { floor: this.floor, confusion: this.confusion });
|
||
}
|
||
|
||
_isChoiceWaiting(node) {
|
||
const wp = this.floor.waitPoint;
|
||
return !!(node && node.prompt != null && wp && wp.conf.id === 'choice');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 菜单 / 重开
|
||
|
||
_openMenu() {
|
||
this.mode = MODE.MENU;
|
||
this._menuIdx = 0;
|
||
this.floor.interrupt();
|
||
this.audio.play('sfx/ui_menu');
|
||
this.audio.play('bgm/menu', { fadeMs: 500 });
|
||
this.chainKind = 'other';
|
||
this.floor.speak(['voice/menu_open', 'voice/menu_item1'], {
|
||
onDone: () => this.floor.yieldTurn(this._menuWp()),
|
||
});
|
||
}
|
||
|
||
_closeMenu() {
|
||
this.mode = MODE.STORY;
|
||
this.floor.interrupt();
|
||
this.audio.play('sfx/ui_confirm');
|
||
this.audio.play('bgm/dark_drone', { fadeMs: 800 });
|
||
this.playNode(this.nodeId);
|
||
}
|
||
|
||
_restart() {
|
||
this._resetFlags();
|
||
this.mode = MODE.STORY;
|
||
this.floor.interrupt();
|
||
this.audio.play('sfx/ui_confirm');
|
||
this.audio.play('bgm/dark_drone');
|
||
// 重开不再念开场教学(做对过的事不教第二遍,原则 11)
|
||
this.skipHintShown = true;
|
||
this.nodeId = 'n00';
|
||
this._save();
|
||
this.chainKind = 'story';
|
||
const node = NODES.n00;
|
||
this.floor.speak(this.nodeVoices('n00'), {
|
||
onDone: () => this.floor.yieldTurn(this._wpFor(node)),
|
||
});
|
||
}
|
||
|
||
_resetFlags() {
|
||
this.companion = false;
|
||
this.knowTrap = false;
|
||
this.betrayed = false;
|
||
this.viaCulvert = false;
|
||
}
|
||
|
||
_restoreFlags(save) {
|
||
this.companion = !!save.companion;
|
||
this.knowTrap = !!save.knowTrap;
|
||
this.betrayed = !!save.betrayed;
|
||
this.viaCulvert = !!save.viaCulvert;
|
||
if (save.goalMet) this.goalMet = true;
|
||
if (save.skipHintShown) this.skipHintShown = true;
|
||
if (save.nodeId) this.nodeId = save.nodeId;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 存档(§4.12 / 原则 16)
|
||
|
||
_save() {
|
||
if (!this.nodeId) return;
|
||
try {
|
||
localStorage.setItem(SAVE_KEY, JSON.stringify({
|
||
nodeId: this.nodeId,
|
||
companion: this.companion,
|
||
knowTrap: this.knowTrap,
|
||
betrayed: this.betrayed,
|
||
viaCulvert: this.viaCulvert,
|
||
goalMet: this.goalMet,
|
||
skipHintShown: this.skipHintShown,
|
||
}));
|
||
} catch (_) { /* 隐私模式 */ }
|
||
}
|
||
|
||
_loadSave() {
|
||
try {
|
||
const raw = localStorage.getItem(SAVE_KEY);
|
||
return raw ? JSON.parse(raw) : null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
actOf(id) {
|
||
if (!id) return 1;
|
||
if (id.startsWith('e')) return 3;
|
||
const m = /^n(\d+)/.exec(id);
|
||
const num = m ? parseInt(m[1], 10) : NaN;
|
||
if (Number.isNaN(num)) return 1;
|
||
if (num < 10) return 1;
|
||
if (num < 18) return 2;
|
||
return 3;
|
||
}
|
||
}
|