Files
blind/game/js/engine.js
T
xslandClaude Opus 4.6 d03bcb14e4 v2收尾:手势升级 + 暂停退出 + 结局语音化 + 分支补全 + 12条新音频
gestures.js: 新增长按(1s)、两指长按(暂停)、三指点击(退出),支持多指检测
engine.js: REPLY_COUNT变量、长按重播/状态广播、暂停恢复退出机制、
  选择提示音、结局台词预录音频替换TTS、内容预警+心理热线
story.js: MSG-10/11 follow-up补全down(沉默)选项
index.html: 操作指南添加长按/安全手势、内容预警文字
batch_tts_voxcpm.py: 新增12条台词(结局旁白+系统提示+分支NPC反应)
audio/mp3/tts: 12个VoxCPM2生成的林夏声线MP3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-19 23:22:04 +08:00

826 lines
23 KiB
JavaScript

// engine.js — 游戏引擎 v2(多轮对话 + 林夏语音 + NPC反应)
//
// 状态流:idle → notification/ringing → playing → choosing
// → responding → reacting → [choosing | idle] → …… → ending
//
// 新增状态:
// responding — 林夏说话中(打字音效 + 语音播放)
// reacting — NPC反应播放中
// paused — 游戏暂停(两指长按触发)
class GameEngine {
constructor() {
this._state = 'idle';
// 剧情变量
this.vars = {
MOM_LINK: false,
HE_BACK: null,
GROUP_REPLY: false,
ZHOUNAN_DEPTH: 0,
ZHOUNAN_SHARE: false,
SELF_RECORD: false,
UNREAD: 0,
REPLY_COUNT: 0,
};
this.profile = new ProfileTracker();
this.msgState = {};
this.currentMsg = null;
this._queue = [];
this._endingTriggered = false;
this._reminderTimer = null;
// v2: 对话系统
this._currentChoices = null; // 当前可用的选项集
this._pendingResolve = null; // 用于中断语音播放的 resolve
this._skipRequested = false; // 跳过标志
// 暂停系统
this._pausedState = null; // 暂停前的状态
this.gest = null;
}
// ── 状态 getter/setter ──
get state() { return this._state; }
set state(s) {
this._state = s;
dbg(`state: ${s}`);
const el = document.getElementById('hint');
if (!el) return;
const H = {
idle: '',
notification: '单击 播放 · 双击 跳过',
ringing: '单击 接听 · 左滑 拒接',
playing: '双击 跳过 · 长按 重播',
choosing: '→温暖 ←冷淡 ↑追问 ↓沉默',
responding: '双击 跳过',
reacting: '双击 跳过',
paused: '单击 继续 · 三指点击 退出',
ending: '',
};
el.textContent = H[s] || '';
}
// ══════════════════════════════════════════
// 初始化 & 启动
// ══════════════════════════════════════════
init(gestureDetector) {
this.gest = gestureDetector;
this.gest
.on('singletap', () => this._onTap())
.on('doubletap', () => this._onDoubleTap())
.on('swipe_left', () => this._onSwipe('left'))
.on('swipe_right', () => this._onSwipe('right'))
.on('swipe_up', () => this._onSwipe('up'))
.on('swipe_down', () => this._onSwipe('down'))
.on('longpress', () => this._onLongPress())
.on('twofinger_longpress', () => this._onPause())
.on('threefinger_tap', () => this._onExit());
}
async start() {
this._queue = [...MESSAGES];
// 内容预警
await this._playVoiceAsync([`${T}/v2_sys_content_warning_wav_v1.mp3`]);
await sleep(1000);
await Snd.startAmbience(AMBIENCE_DEFAULT, { vol: 0.15 });
await sleep(1500);
Snd.startBGM(`${MUS}/lv11_bgm_m1_main_loop_v1.mp3`, { vol: 0.1 });
await sleep(1500);
this._next();
}
// ══════════════════════════════════════════
// 语音播放(异步 + 可中断)
// ══════════════════════════════════════════
_playVoiceAsync(urls) {
return new Promise(resolve => {
this._pendingResolve = resolve;
Snd.playVoice(urls, {
onEnd: () => {
this._pendingResolve = null;
resolve();
},
});
});
}
_interruptVoice() {
Snd.stopVoice();
TTS.stop();
if (this._pendingResolve) {
this._pendingResolve();
this._pendingResolve = null;
}
}
// 打字 + 发送音效
async _playTypingSend() {
SFX_UI.typing();
await sleep(800);
if (this._skipRequested) return;
SFX_UI.send();
await sleep(300);
}
// ══════════════════════════════════════════
// 消息队列推进
// ══════════════════════════════════════════
async _next() {
if (this._endingTriggered) return;
const msg = this._queue.shift();
if (!msg) {
this.triggerFinalEnding();
return;
}
if (msg.requiresMomLink && !this.vars.MOM_LINK) {
this.triggerFinalEnding();
return;
}
this.currentMsg = msg;
this._currentChoices = msg.choices || null;
this.msgState[msg.id] = 'unread';
this.vars.UNREAD++;
if (msg.type === 'call') {
this._startRinging(msg);
} else {
this._notifyMessage(msg);
}
}
_notifyMessage(msg) {
this.state = 'notification';
Haptic.message();
SFX_UI.notify();
this._startReminder();
}
async _startRinging(msg) {
this.state = 'ringing';
Haptic.ring();
await Snd.startRingtone(msg.ringtone);
this._startReminder();
}
// ══════════════════════════════════════════
// 通话流程(异步)
// ══════════════════════════════════════════
async _answerCall(msg) {
Snd.stopRingtone();
Haptic.stop();
Haptic.confirm();
SFX_UI.tap();
this._clearReminder();
this.state = 'playing';
this.msgState[msg.id] = 'read';
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
if (msg.stateOnAnswer) this._applyState(msg.stateOnAnswer);
if (msg.profileOnAnswer) this.profile.add(msg.profileOnAnswer);
this._skipRequested = false;
// 播放通话内容
await this._playVoiceAsync(msg.audio);
// _onDoubleTap 在 'playing' 状态下会直接处理状态转换
// 所以如果状态已不是 'playing',说明跳过已被处理,直接返回
if (this.state !== 'playing') return;
// 自动挂断(MSG-08 未知号码)
if (msg.autoHangup) {
if (msg.sfxAfter) {
const sfx = Array.isArray(msg.sfxAfter) ? msg.sfxAfter[0] : msg.sfxAfter;
await this._playVoiceAsync([sfx]);
if (this.state !== 'playing') return;
}
this._proceedToNext();
return;
}
// 自动回复(MSG-01/03/14 妈妈电话、外卖、妈妈第二次电话)
if (msg.autoReply) {
await this._playTypingSend();
if (this.state !== 'playing') return;
this.state = 'responding';
await this._playVoiceAsync([msg.autoReply]);
// 条件反应(MSG-14:先播前导语"真的没事?",再根据 MOM_LINK 播放不同反应)
if (!this._skipRequested && msg.autoReact) {
this.state = 'reacting';
// 前导语(如"真的没事?")
if (msg.autoReactPreamble) {
await sleep(500);
if (!this._skipRequested) {
await this._playVoiceAsync([msg.autoReactPreamble]);
}
}
// 条件分支反应
if (!this._skipRequested) {
const val = this.vars[msg.autoReact.condition];
const reactUrl = val ? msg.autoReact['true'] : msg.autoReact['false'];
if (reactUrl) {
await sleep(400);
if (!this._skipRequested) {
await this._playVoiceAsync([reactUrl]);
}
}
}
}
this._proceedToNext();
return;
}
// 通话后有选择(MSG-11 阿哲)
if (msg.choices) {
this._enterChoosing(msg.choices);
return;
}
this._proceedToNext();
}
_rejectCall(msg) {
Snd.stopRingtone();
Haptic.reject();
SFX_UI.skip();
this._clearReminder();
this.msgState[msg.id] = 'missed';
if (msg.profileOnMiss) this.profile.add(msg.profileOnMiss);
this._proceedToNext();
}
// ══════════════════════════════════════════
// 消息播放(异步)
// ══════════════════════════════════════════
async _playMessage(msg) {
this._clearReminder();
this.state = 'playing';
this.msgState[msg.id] = 'read';
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
if (msg.bgm) Snd.startBGM(msg.bgm, { vol: 0.1 });
// 系统通知(时光胶囊)
if (msg.type === 'system_notification') {
this._playSystemNotification(msg);
return;
}
this._skipRequested = false;
// 文字消息(TTS 朗读)
if (msg.type === 'wechat_text' && msg.text) {
await TTS.speak(msg.text);
}
// 语音消息
else if (msg.audio?.length > 0) {
await this._playVoiceAsync(msg.audio);
}
if (this._skipRequested) return;
if (this.state !== 'playing') return;
this._afterPlayed(msg);
}
_playSystemNotification(msg) {
Snd.playVoice([msg.systemAudio], {
onEnd: () => {
if (this.state !== 'playing') return;
const audio = msg.audioByProfile?.[this.profile.label];
if (audio) {
if (msg.stateOnPlay) this._applyState(msg.stateOnPlay);
if (msg.profileOnPlay) this.profile.add(msg.profileOnPlay);
setTimeout(() => {
if (this.state !== 'playing') return;
Snd.playVoice([audio], {
onEnd: () => this._proceedToNext(),
});
}, 800);
} else {
this._proceedToNext();
}
},
});
}
_afterPlayed(msg) {
if (this.state !== 'playing') return;
if (msg.isEnding) {
this.triggerFinalEnding();
return;
}
if (msg.choices) {
this._enterChoosing(msg.choices);
return;
}
this._proceedToNext();
}
// ══════════════════════════════════════════
// 选择系统(v2 多轮对话核心)
// ══════════════════════════════════════════
_enterChoosing(choices) {
this._currentChoices = choices;
this.state = 'choosing';
SFX_UI.choiceReady();
Haptic.confirm();
Snd.playVoice([`${T}/v2_sys_wait_reply_wav_v1.mp3`]);
this._startReminder();
}
async _handleChoice(dir) {
const choices = this._currentChoices;
if (!choices) {
SFX_UI.error();
Haptic.error();
return;
}
const choice = choices[dir];
if (!choice) {
SFX_UI.error();
Haptic.error();
return;
}
this._clearReminder();
this._skipRequested = false;
// 1. 播放方向对应的情感音效
const sfxMap = {
tap: 'tap',
right: 'choiceWarm',
left: 'choiceCold',
up: 'choiceCurious',
down: 'choiceSilent',
};
if (sfxMap[dir]) SFX_UI[sfxMap[dir]]();
Haptic.select();
// 2. 应用状态和画像
if (!choice.isSilence) {
this.msgState[this.currentMsg.id] = 'replied';
this.vars.REPLY_COUNT++;
}
if (choice.state) this._applyState(choice.state);
if (choice.profile) this.profile.add(choice.profile);
// 3. 林夏回应
this.state = 'responding';
if (choice.isSilence) {
// 沉默:短暂停顿
await sleep(1500);
} else if (choice.linxia) {
// 打字 → 发送 → 林夏语音
await this._playTypingSend();
if (!this._skipRequested) {
await this._playVoiceAsync([choice.linxia]);
}
}
if (this._skipRequested) {
// 跳过了林夏回应 → 如有 followUp 进入选择,否则跳到下一条
if (choice.followUp?.choices) {
this._enterChoosing(choice.followUp.choices);
return;
}
this._proceedToNext();
return;
}
// 4. NPC 反应
if (choice.npcReact?.length > 0) {
this.state = 'reacting';
await sleep(400);
if (!this._skipRequested) {
await this._playVoiceAsync(choice.npcReact);
}
}
if (this._skipRequested) {
if (choice.followUp?.choices) {
this._enterChoosing(choice.followUp.choices);
return;
}
this._proceedToNext();
return;
}
// 5. 如有后续对话 → 再次进入选择
if (choice.followUp?.choices) {
await sleep(300);
this._enterChoosing(choice.followUp.choices);
return;
}
// 6. 推进到下一条
this._proceedToNext();
}
_skipChoice() {
this._clearReminder();
SFX_UI.skip();
Haptic.confirm();
this.profile.add({ avoidance: 1 });
this._proceedToNext();
}
// ══════════════════════════════════════════
// 推进到下一条
// ══════════════════════════════════════════
async _proceedToNext() {
const prev = this.currentMsg;
this.currentMsg = null;
this._currentChoices = null;
this.state = 'idle';
this._clearReminder();
if (this._endingTriggered) return;
// 特殊事件:阿哲来了(门铃)
if (prev?.id === 11 && this.vars.HE_BACK === true) {
await sleep(1500);
Snd.playVoice([`${SFX}/sfx_door_knock_gentle_v1.mp3`]);
Haptic.ring();
await sleep(3000);
}
// 检查结局D:未读过多
const triggered = MESSAGES.length - this._queue.length;
if (this.vars.UNREAD >= 8 && triggered >= 14) {
this._triggerEnding('D');
return;
}
// 短暂停顿后推进
SFX_UI.next();
await sleep(DEV ? 200 : 2000);
this._next();
}
// ══════════════════════════════════════════
// 提醒定时器
// ══════════════════════════════════════════
_startReminder() {
this._clearReminder();
this._reminderTimer = setInterval(() => {
if (this.state === 'notification') {
SFX_UI.notify();
Haptic.message();
} else if (this.state === 'choosing') {
SFX_UI.choiceReady();
Haptic.confirm();
} else if (this.state === 'ringing') {
Haptic.ring();
} else {
this._clearReminder();
}
}, 8000);
}
_clearReminder() {
if (this._reminderTimer) {
clearInterval(this._reminderTimer);
this._reminderTimer = null;
}
}
// ══════════════════════════════════════════
// 状态变量
// ══════════════════════════════════════════
_applyState(changes) {
for (const [k, v] of Object.entries(changes)) {
if (k === 'ZHOUNAN_DEPTH_ADD') {
this.vars.ZHOUNAN_DEPTH += (v || 1);
} else if (v !== null) {
this.vars[k] = v;
}
}
}
// ══════════════════════════════════════════
// 手势处理
// ══════════════════════════════════════════
_onTap() {
const s = this.state;
// 暂停中 → 恢复
if (s === 'paused') {
this._onResume();
return;
}
if (s === 'ringing') {
this._answerCall(this.currentMsg);
} else if (s === 'notification') {
SFX_UI.tap();
Haptic.confirm();
this._playMessage(this.currentMsg);
} else if (s === 'choosing') {
this._handleChoice('tap');
}
}
_onDoubleTap() {
const s = this.state;
if (s === 'paused') return;
if (s === 'ringing') {
this._rejectCall(this.currentMsg);
return;
}
if (s === 'notification') {
this._clearReminder();
SFX_UI.skip();
Haptic.confirm();
const msg = this.currentMsg;
if (msg?.profileOnSkip) this.profile.add(msg.profileOnSkip);
else this.profile.add({ avoidance: 1 });
this._proceedToNext();
return;
}
if (s === 'playing') {
this._skipRequested = true;
this._interruptVoice();
SFX_UI.skip();
Haptic.confirm();
const msg = this.currentMsg;
if (msg?.isEnding) {
this.triggerFinalEnding();
} else if (msg?.choices) {
this._enterChoosing(msg.choices);
} else {
this._proceedToNext();
}
return;
}
if (s === 'responding' || s === 'reacting') {
this._skipRequested = true;
this._interruptVoice();
SFX_UI.skip();
Haptic.confirm();
return;
}
if (s === 'choosing') {
this._skipChoice();
return;
}
}
_onSwipe(dir) {
const s = this.state;
if (s === 'paused') return;
if (s === 'ringing') {
if (dir === 'left') this._rejectCall(this.currentMsg);
else if (dir === 'right') this._answerCall(this.currentMsg);
return;
}
if (s === 'notification') {
if (dir === 'left') {
this._clearReminder();
SFX_UI.skip();
Haptic.confirm();
this.profile.add({ avoidance: 1 });
this._proceedToNext();
}
return;
}
if (s === 'choosing') {
this._handleChoice(dir);
return;
}
}
// ── 长按:重播 / 状态广播 ──
_onLongPress() {
const s = this.state;
if (s === 'paused' || s === 'ending') return;
Haptic.confirm();
// playing/responding/reacting/choosing → 重播当前消息音频
if ((s === 'playing' || s === 'responding' || s === 'reacting' || s === 'choosing') && this.currentMsg) {
this._interruptVoice();
if (this.currentMsg.audio?.length > 0) {
this._playVoiceAsync(this.currentMsg.audio);
} else if (this.currentMsg.type === 'wechat_text' && this.currentMsg.text) {
TTS.speak(this.currentMsg.text);
}
return;
}
// idle/notification → 广播未读数
TTS.speak(`当前未读${this.vars.UNREAD}条消息`);
}
// ── 暂停 ──
_onPause() {
const s = this.state;
if (s === 'ending' || s === 'paused') return;
this._pausedState = s;
this._interruptVoice();
this._clearReminder();
Haptic.stop();
this.state = 'paused';
Snd.playVoice([`${T}/v2_sys_paused_wav_v1.mp3`]);
}
// ── 恢复 ──
_onResume() {
Snd.stopVoice();
const prev = this._pausedState || 'idle';
this._pausedState = null;
// 语音无法断点续播,根据暂停前状态决定恢复行为
if (prev === 'choosing' && this._currentChoices) {
this.state = 'choosing';
SFX_UI.choiceReady();
Haptic.confirm();
this._startReminder();
} else if (prev === 'notification' || prev === 'ringing') {
this.state = prev;
this._startReminder();
} else {
// playing/responding/reacting → 无法续播,推进到下一条或回到选择
if (this._currentChoices) {
this._enterChoosing(this._currentChoices);
} else {
this._proceedToNext();
}
}
}
// ── 退出 ──
_onExit() {
if (this.state !== 'paused') return;
this._endingTriggered = true;
this._interruptVoice();
// 退出不需要特殊音频,直接返回开始页
setTimeout(() => {
const startEl = document.getElementById('start');
if (startEl) {
startEl.style.opacity = '1';
startEl.style.display = 'flex';
}
}, 2000);
}
// ══════════════════════════════════════════
// 结局系统
// ══════════════════════════════════════════
async triggerFinalEnding() {
if (this._endingTriggered) return;
const v = this.vars;
// 结局E(隐藏)— 优先级 1
if (v.ZHOUNAN_DEPTH >= 2 && v.ZHOUNAN_SHARE &&
(this.profile.label === 'engagement' || this.profile.label === 'nostalgia')) {
return this._triggerEnding('E');
}
// 结局A:妈妈连接 + 自己录音 — 优先级 2
if (v.MOM_LINK && v.SELF_RECORD) {
return this._triggerEnding('A');
}
// 结局B:拒了阿哲 + 回了周南 — 优先级 3
if (v.HE_BACK === false && v.ZHOUNAN_DEPTH >= 1) {
return this._triggerEnding('B');
}
// 结局C:全部已读但极少回复 — 优先级 4
const read = Object.values(this.msgState).filter(s => s !== 'unread').length;
if (read >= MESSAGES.length - 2 && v.REPLY_COUNT <= 2) {
return this._triggerEnding('C');
}
// 结局D(兜底)— 优先级 5
this._triggerEnding('D');
}
async _triggerEnding(type) {
if (this._endingTriggered) return;
this._endingTriggered = true;
this.state = 'ending';
this._clearReminder();
this._interruptVoice();
Snd.stopRingtone();
Snd.stopBGM(3000);
await sleep(2000);
dbg(`ending: ${type}`);
const fn = {
A: () => this._endingA(),
B: () => this._endingB(),
C: () => this._endingC(),
D: () => this._endingD(),
E: () => this._endingE(),
}[type];
if (fn) await fn();
// 结局后播报心理支持热线
await sleep(3000);
await this._playVoiceAsync([`${T}/v2_sys_hotline_wav_v1.mp3`]);
}
async _endingA() {
Snd.startAmbience(`${AMB}/amb_kitchen_morning_v1.mp3`, { vol: 0.3 });
await sleep(2000);
Snd.playVoice([`${SFX}/sfx_kettle_whistle_v1.mp3`]);
await sleep(4000);
SFX_UI.ending();
this._showCaption('明天再说。');
await this._playVoiceAsync([`${T}/v2_ending_a_linxia_wav_v1.mp3`]);
}
async _endingB() {
await sleep(2000);
SFX_UI.ending();
await sleep(1000);
this._showCaption('寄出去了。');
await this._playVoiceAsync([`${T}/v2_ending_b_linxia_wav_v1.mp3`]);
}
async _endingC() {
await sleep(3000);
SFX_UI.ending();
await sleep(1000);
this._showCaption('今晚就这样。');
await this._playVoiceAsync([`${T}/v2_ending_c_linxia_wav_v1.mp3`]);
}
async _endingD() {
Haptic.system();
await sleep(2000);
Haptic.ring();
await sleep(4000);
Snd.startAmbience(`${AMB}/amb_dawn_birds_v1.mp3`, { vol: 0.4 });
await sleep(4000);
this._showCaption('早上好。');
await this._playVoiceAsync([`${T}/v2_ending_d_linxia_wav_v1.mp3`]);
}
async _endingE() {
Haptic.message();
SFX_UI.notify();
await sleep(1500);
// 周南秒回:"那我下次出差来北京,请你吃饭好不好。"
await this._playVoiceAsync([`${T}/v2_ending_e_zhounan_wav_v1.mp3`]);
await sleep(2000);
SFX_UI.ending();
await sleep(1000);
this._showCaption('好。');
await this._playVoiceAsync([`${T}/v2_ending_e_linxia_wav_v1.mp3`]);
}
_showCaption(text) {
const el = document.getElementById('caption');
el.textContent = text;
el.style.display = 'flex';
}
}