// engine.js — 游戏引擎(玩家驱动,无时间依赖) // // 状态流:idle → notification/ringing → playing → choosing → idle → …… → ending // 所有 UI 反馈使用合成短音效(SFX_UI),不使用 TTS 语音播报 class GameEngine { constructor() { // 状态(通过 setter 自动更新提示栏) this._state = 'idle'; // 剧情变量 this.vars = { MOM_LINK: false, HE_BACK: null, // null=未定, true=来了, false=拒了 GROUP_REPLY: false, ZHOUNAN_DEPTH: 0, // 0-3 ZHOUNAN_SHARE: false, SELF_RECORD: false, UNREAD: 0, }; this.profile = new ProfileTracker(); this.msgState = {}; // msgId → 'unread'|'read'|'replied'|'missed' this.currentMsg = null; this._queue = []; // 消息队列 this._endingTriggered = false; this._reminderTimer = 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: '→温暖 ←冷淡 ↑追问 ↓沉默', 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]; // 环境音 + BGM await Snd.startAmbience(AMBIENCE_DEFAULT, { vol: 0.15 }); await sleep(1500); Snd.startBGM(`${MUS}/lv11_bgm_m1_main_loop_v1.mp3`, { vol: 0.2 }); await sleep(1500); // 开始第一条消息 this._next(); } // ══════════════════════════════════════════ // 消息队列推进 // ══════════════════════════════════════════ async _next() { if (this._endingTriggered) return; const msg = this._queue.shift(); if (!msg) { this.triggerFinalEnding(); return; } // 消息16需要 MOM_LINK if (msg.requiresMomLink && !this.vars.MOM_LINK) { this.triggerFinalEnding(); return; } this.currentMsg = msg; 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(); } // ══════════════════════════════════════════ // 通话流程 // ══════════════════════════════════════════ _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); Snd.playVoice(msg.audio, { onEnd: () => { if (this.state !== 'playing') return; // 已被跳过 if (msg.autoHangup && msg.sfxAfter) { const sfx = Array.isArray(msg.sfxAfter) ? msg.sfxAfter[0] : msg.sfxAfter; Snd.playVoice([sfx], { onEnd: () => this._proceedToNext() }); } else if (msg.choices) { this.state = 'choosing'; SFX_UI.choiceReady(); Haptic.confirm(); this._startReminder(); } else { 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.2 }); // 系统通知(时光胶囊) if (msg.type === 'system_notification') { this._playSystemNotification(msg); return; } // 文字消息(用 TTS 朗读内容) if (msg.type === 'wechat_text' && msg.text && (!msg.audio || msg.audio.length === 0)) { TTS.speak(msg.text).then(() => { if (this.state === 'playing') this._afterPlayed(msg); }); return; } // 语音消息 if (msg.audio && msg.audio.length > 0) { Snd.playVoice(msg.audio, { onEnd: () => { if (this.state === 'playing') this._afterPlayed(msg); } }); } else { 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.state = 'choosing'; SFX_UI.choiceReady(); Haptic.confirm(); this._startReminder(); } else { this._proceedToNext(); } } // ══════════════════════════════════════════ // 选择回复 // ══════════════════════════════════════════ _choose(dir) { const msg = this.currentMsg; if (!msg?.choices) { SFX_UI.error(); Haptic.error(); return; } const choice = msg.choices[dir]; if (!choice) { SFX_UI.error(); Haptic.error(); return; } this._clearReminder(); // 播放方向对应的情感音效 const sfxMap = { tap: 'tap', right: 'choiceWarm', left: 'choiceCold', up: 'choiceCurious', down: 'choiceSilent', }; if (sfxMap[dir]) SFX_UI[sfxMap[dir]](); Haptic.select(); if (!choice.isSilence) { this.msgState[msg.id] = 'replied'; } if (choice.state) this._applyState(choice.state); if (choice.profile) this.profile.add(choice.profile); 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.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:未读过多(14条消息处理后检查) 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._choose('tap'); } _onDoubleTap() { const s = this.state; if (s === 'ringing') { // 双击来电 = 拒接 this._rejectCall(this.currentMsg); } else 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(); } else if (s === 'playing') { // 跳过当前播放 Snd.stopVoice(); TTS.stop(); SFX_UI.skip(); Haptic.confirm(); const msg = this.currentMsg; // 直接进入后续流程 if (msg?.isEnding) { this.triggerFinalEnding(); } else if (msg?.choices) { this.state = 'choosing'; SFX_UI.choiceReady(); Haptic.confirm(); this._startReminder(); } else { this._proceedToNext(); } } else if (s === 'choosing') { this._skipChoice(); } } _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._choose(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(); Snd.stopVoice(); Snd.stopRingtone(); TTS.stop(); 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(); } // ── 结局A:明天再说 ── 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('明天再说。'); } // ── 结局B:寄出 ── async _endingB() { await sleep(2000); SFX_UI.ending(); await sleep(1000); this._showCaption('寄出。'); } // ── 结局C:今晚就这样 ── async _endingC() { await sleep(3000); SFX_UI.ending(); await sleep(1000); this._showCaption('今晚就这样。'); } // ── 结局D:早上好 ── 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('早上好。'); } // ── 结局E(隐藏):好 ── async _endingE() { Haptic.message(); SFX_UI.notify(); await sleep(2000); Snd.playVoice([`${TDIR}/lv11_sys_goodnight_wav_v1.mp3`]); await sleep(3000); SFX_UI.ending(); await sleep(1000); this._showCaption('好。'); } _showCaption(text) { const el = document.getElementById('caption'); el.textContent = text; el.style.display = 'flex'; } }