// engine.js — 游戏引擎 v2(多轮对话 + 林夏语音 + NPC反应) // // 状态流:idle → notification/ringing → playing → choosing // → responding → reacting → [choosing | idle] → …… → ending // // 新增状态: // responding — 林夏说话中(打字音效 + 语音播放) // reacting — NPC反应播放中 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, }; 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.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: '双击 跳过', 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')); } async start() { this._queue = [...MESSAGES]; 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(); 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'; } 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 === '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 === '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 === '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; } } // ══════════════════════════════════════════ // 结局系统 // ══════════════════════════════════════════ async triggerFinalEnding() { if (this._endingTriggered) return; const v = this.vars; // 结局E(隐藏) if (v.ZHOUNAN_DEPTH >= 2 && v.ZHOUNAN_SHARE && (this.profile.label === 'engagement' || this.profile.label === 'nostalgia')) { return this._triggerEnding('E'); } // 结局A:妈妈连接 + 自己录音 if (v.MOM_LINK && v.SELF_RECORD) { return this._triggerEnding('A'); } // 结局B:拒了阿哲 + 回了周南 if (v.HE_BACK === false && v.ZHOUNAN_DEPTH >= 1) { return this._triggerEnding('B'); } // 结局C:全部已读但极少回复 const replied = Object.values(this.msgState).filter(s => s === 'replied').length; const read = Object.values(this.msgState).filter(s => s !== 'unread').length; if (read >= MESSAGES.length - 2 && replied <= 2) { return this._triggerEnding('C'); } // 结局D(兜底) 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(); } 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('明天再说。'); } async _endingB() { await sleep(2000); SFX_UI.ending(); await sleep(1000); this._showCaption('寄出。'); } async _endingC() { await sleep(3000); SFX_UI.ending(); await sleep(1000); this._showCaption('今晚就这样。'); } 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('早上好。'); } 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('好。'); } _showCaption(text) { const el = document.getElementById('caption'); el.textContent = text; el.style.display = 'flex'; } }