From b90238975fec448442b329b4df512fa232e2db89 Mon Sep 17 00:00:00 2001 From: xsl Date: Mon, 18 May 2026 23:00:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=B8=E6=88=8F=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/index.html | 35 +- game/js/audio.js | 182 ++++++--- game/js/engine.js | 929 +++++++++++++++++--------------------------- game/js/gestures.js | 131 +++---- game/js/main.js | 30 +- game/js/story.js | 354 +++++++---------- game/js/utils.js | 37 +- 7 files changed, 703 insertions(+), 995 deletions(-) diff --git a/game/index.html b/game/index.html index 3a4ada6..bb6f5b9 100644 --- a/game/index.html +++ b/game/index.html @@ -76,7 +76,7 @@ transition:opacity 0.4s; } - /* ── 调试条(开发用) ── */ + /* ── 调试条 ── */ #dbg{ display:none; position:fixed;bottom:30px;left:0;right:0;text-align:center; @@ -97,43 +97,38 @@
操 作 说 明
-
-
通用(随时可用)
-
下滑查看当前状态
-
上滑打开收件箱
-
-
来电时
-
单点接听
-
左滑拒接
+
单击 / 右滑接听
+
左滑 / 双击拒接
收到消息
-
单点播放
-
左滑忽略
+
单击播放
+
双击 / 左滑跳过
-
听完消息
-
单点回复"嗯"
-
双点标准回复
-
长按更多回复选项
-
左滑已读不回
+
选择回复
+
单击简单回应
+
右滑温暖回应
+
左滑冷淡回应
+
上滑追问
+
下滑沉默
+
双击不回复
-
选择回复时
-
上下左右滑选择选项
-
单点重听选项
+
播放中
+
双击跳过
· 轻触屏幕开始 ·
建议关灯 · 戴上耳机
- 全程约 60 分钟 + 按你的节奏来
diff --git a/game/js/audio.js b/game/js/audio.js index 5199bbc..6ca2fe7 100644 --- a/game/js/audio.js +++ b/game/js/audio.js @@ -1,15 +1,14 @@ -// audio.js — 音频管理器(HTML Audio + Web Audio API 混用) +// audio.js — 音频管理器 + 合成 UI 音效 class AudioManager { constructor() { this._ctx = null; this._master = null; - this._bgmNode = null; - this._ambNode = null; this._voiceQueue = []; this._voiceEl = null; this._ringEl = null; - this._ringTimer = null; + this._bgmEl = null; + this._ambEl = null; this._preloaded = {}; } @@ -22,19 +21,44 @@ class AudioManager { this._master.connect(this._ctx.destination); } - // ── 预加载短音效(铃声、UI音)── + // ── 合成短音(UI 反馈音效)── + synth({ freq = 440, freq2 = null, dur = 100, type = 'sine', vol = 0.3 } = {}) { + if (!this._ctx) return; + const t = this._ctx.currentTime; + const d = dur / 1000; + + const osc = this._ctx.createOscillator(); + const gain = this._ctx.createGain(); + + osc.type = type; + osc.frequency.setValueAtTime(freq, t); + if (freq2 != null) { + osc.frequency.linearRampToValueAtTime(freq2, t + d); + } + + gain.gain.setValueAtTime(vol, t); + gain.gain.setValueAtTime(vol, t + d * 0.75); + gain.gain.linearRampToValueAtTime(0, t + d); + + osc.connect(gain); + gain.connect(this._master); + osc.start(t); + osc.stop(t + d + 0.05); + } + + // ── 预加载短音效 ── async preload(key, url) { if (this._preloaded[key]) return; try { const res = await fetch(url); const buf = await res.arrayBuffer(); this._preloaded[key] = await this._ctx.decodeAudioData(buf); - } catch(e) { + } catch (e) { console.warn('[Audio] preload failed:', key, e); } } - // ── 播放预加载的短音效 ── + // ── 播放预加载音效 ── playFX(key, { vol = 1.0, loop = false } = {}) { const buf = this._preloaded[key]; if (!buf) return null; @@ -46,17 +70,16 @@ class AudioManager { src.connect(gain); gain.connect(this._master); src.start(); - return { src, gain, stop: () => { try { src.stop(); } catch(_){} } }; + return { src, gain, stop: () => { try { src.stop(); } catch (_) {} } }; } - // ── 铃声(循环直到 stopRingtone)── + // ── 铃声(循环)── async startRingtone(url) { this.stopRingtone(); - // 使用 HTMLAudio 以支持更大文件 this._ringEl = new Audio(url); this._ringEl.loop = true; this._ringEl.volume = 1.0; - try { await this._ringEl.play(); } catch(e) { console.warn('[Audio] ring play error', e); } + try { await this._ringEl.play(); } catch (e) { console.warn('[Audio] ring error', e); } } stopRingtone() { @@ -65,17 +88,16 @@ class AudioManager { this._ringEl.src = ''; this._ringEl = null; } - if (this._ringTimer) { clearTimeout(this._ringTimer); this._ringTimer = null; } } - // ── 语音消息播放(支持多段顺序播放)── - playVoice(urls, { vol = 1.0, onEnd = null, onSegEnd = null } = {}) { + // ── 语音播放(多段顺序)── + playVoice(urls, { vol = 1.0, onEnd = null } = {}) { this.stopVoice(); this._voiceQueue = [...urls]; - this._playNextSegment(vol, onEnd, onSegEnd); + this._playNext(vol, onEnd); } - _playNextSegment(vol, onEnd, onSegEnd) { + _playNext(vol, onEnd) { if (this._voiceQueue.length === 0) { this._voiceEl = null; if (onEnd) onEnd(); @@ -85,22 +107,19 @@ class AudioManager { const el = new Audio(url); el.volume = vol; this._voiceEl = el; - el.onended = () => { - if (onSegEnd) onSegEnd(); - this._playNextSegment(vol, onEnd, onSegEnd); - }; + el.onended = () => this._playNext(vol, onEnd); el.onerror = () => { console.warn('[Audio] voice error:', url); - this._playNextSegment(vol, onEnd, onSegEnd); + this._playNext(vol, onEnd); }; - el.play().catch(e => console.warn('[Audio] voice play error', e)); + el.play().catch(e => console.warn('[Audio] play error', e)); } stopVoice() { this._voiceQueue = []; if (this._voiceEl) { - this._voiceEl.pause(); this._voiceEl.onended = null; + this._voiceEl.pause(); this._voiceEl = null; } } @@ -111,7 +130,6 @@ class AudioManager { // ── BGM(淡入淡出切换)── async startBGM(url, { vol = 0.35, fade = 2000 } = {}) { - // 先淡出现有 if (this._bgmEl) { const old = this._bgmEl; this._fadeOut(old, fade / 2).then(() => { old.pause(); old.src = ''; }); @@ -123,7 +141,7 @@ class AudioManager { try { await el.play(); this._fadeTo(el, vol, fade); - } catch(e) { console.warn('[Audio] BGM error', e); } + } catch (e) { console.warn('[Audio] BGM error', e); } } stopBGM(fade = 2000) { @@ -134,21 +152,18 @@ class AudioManager { } } - // ── 环境音(底噪循环)── + // ── 环境音 ── async startAmbience(url, { vol = 0.25 } = {}) { if (this._ambEl) { this._ambEl.pause(); this._ambEl.src = ''; } const el = new Audio(url); el.loop = true; el.volume = vol; this._ambEl = el; - try { await el.play(); } catch(e) { console.warn('[Audio] ambience error', e); } + try { await el.play(); } catch (e) { console.warn('[Audio] ambience error', e); } } stopAmbience() { - if (this._ambEl) { - this._ambEl.pause(); this._ambEl.src = ''; - this._ambEl = null; - } + if (this._ambEl) { this._ambEl.pause(); this._ambEl.src = ''; this._ambEl = null; } } // ── 音量渐变 ── @@ -160,8 +175,7 @@ class AudioManager { let i = 0; const t = setInterval(() => { el.volume = Math.max(0, Math.min(1, start + dv * i)); - i++; - if (i >= steps) clearInterval(t); + if (++i >= steps) clearInterval(t); }, dt); } @@ -174,38 +188,82 @@ class AudioManager { let i = 0; const t = setInterval(() => { el.volume = Math.max(0, start - dv * i); - i++; - if (i >= steps) { clearInterval(t); resolve(); } + if (++i >= steps) { clearInterval(t); resolve(); } }, dt); }); } - - // ── 键盘声(空间音频:左耳)── - startTyping() { - if (this._typingNode) return; - const buf = this._preloaded['sfx_keyboard_slow']; - if (!buf) return; - const src = this._ctx.createBufferSource(); - src.buffer = buf; - src.loop = true; - const gain = this._ctx.createGain(); - gain.gain.value = 0.15; - // 偏左声道 - const pan = this._ctx.createStereoPanner(); - pan.pan.value = -0.8; - src.connect(gain); - gain.connect(pan); - pan.connect(this._master); - src.start(); - this._typingNode = { src, gain, pan }; - } - - stopTyping() { - if (this._typingNode) { - try { this._typingNode.src.stop(); } catch(_) {} - this._typingNode = null; - } - } } const Snd = new AudioManager(); + +// ══════════════════════════════════════════════════ +// SFX_UI — 合成 UI 音效(不依赖音频文件) +// ══════════════════════════════════════════════════ + +const SFX_UI = { + // 确认操作(单击) + tap() { + Snd.synth({ freq: 880, dur: 45, vol: 0.2 }); + }, + + // 滑动确认 + swipe() { + Snd.synth({ freq: 660, dur: 60, vol: 0.18 }); + }, + + // 无效操作 + error() { + Snd.synth({ freq: 200, dur: 100, type: 'square', vol: 0.1 }); + }, + + // 新消息到达 + notify() { + Snd.synth({ freq: 523, dur: 100, vol: 0.25 }); + setTimeout(() => Snd.synth({ freq: 659, dur: 130, vol: 0.2 }), 130); + }, + + // 进入选择模式(三音上行) + choiceReady() { + Snd.synth({ freq: 440, dur: 80, type: 'triangle', vol: 0.18 }); + setTimeout(() => Snd.synth({ freq: 523, dur: 80, type: 'triangle', vol: 0.18 }), 100); + setTimeout(() => Snd.synth({ freq: 659, dur: 120, type: 'triangle', vol: 0.18 }), 200); + }, + + // 选择:温暖/接受(右滑)— 上行音 + choiceWarm() { + Snd.synth({ freq: 440, freq2: 659, dur: 140, vol: 0.22 }); + }, + + // 选择:冷淡/拒绝(左滑)— 下行音 + choiceCold() { + Snd.synth({ freq: 523, freq2: 349, dur: 140, vol: 0.22 }); + }, + + // 选择:好奇/追问(上滑)— 快速上行 + choiceCurious() { + Snd.synth({ freq: 523, freq2: 784, dur: 120, vol: 0.22 }); + }, + + // 选择:沉默/退缩(下滑)— 极轻低音 + choiceSilent() { + Snd.synth({ freq: 330, dur: 200, vol: 0.08 }); + }, + + // 跳过 / 忽略 + skip() { + Snd.synth({ freq: 587, freq2: 392, dur: 80, vol: 0.12 }); + }, + + // 下一条消息即将到来 + next() { + Snd.synth({ freq: 440, dur: 80, vol: 0.1 }); + setTimeout(() => Snd.synth({ freq: 523, dur: 100, vol: 0.13 }), 120); + }, + + // 结局音效 + ending() { + Snd.synth({ freq: 523, dur: 400, vol: 0.25 }); + setTimeout(() => Snd.synth({ freq: 659, dur: 400, vol: 0.2 }), 450); + setTimeout(() => Snd.synth({ freq: 784, dur: 600, vol: 0.15 }), 900); + }, +}; diff --git a/game/js/engine.js b/game/js/engine.js index 7bed03d..d012a51 100644 --- a/game/js/engine.js +++ b/game/js/engine.js @@ -1,664 +1,368 @@ -// engine.js — 游戏状态机 +// engine.js — 游戏引擎(玩家驱动,无时间依赖) +// +// 状态流:idle → notification/ringing → playing → choosing → idle → …… → ending +// 所有 UI 反馈使用合成短音效(SFX_UI),不使用 TTS 语音播报 class GameEngine { constructor() { - // 游戏状态(通过 setter 自动更新提示栏) - this._state = 'idle'; // idle→playing→ringing→in_call→msg_incoming→msg_playing→await_reply→detail_reply→inbox→ending + // 状态(通过 setter 自动更新提示栏) + this._state = 'idle'; // 剧情变量 this.vars = { MOM_LINK: false, - HE_BACK: null, // null=未定, true=来了, false=拒了 + HE_BACK: null, // null=未定, true=来了, false=拒了 GROUP_REPLY: false, - ZHOUNAN_DEPTH: 0, // 0-3 + ZHOUNAN_DEPTH: 0, // 0-3 ZHOUNAN_SHARE: false, SELF_RECORD: false, - UNREAD: 0, // 未读消息计数 + UNREAD: 0, }; this.profile = new ProfileTracker(); - - // 消息状态 - this.msgState = {}; // msgId → 'unread'|'read'|'replied'|'missed' - this.replayCount = {};// msgId → 重听次数 - - // 当前处理的消息 + this.msgState = {}; // msgId → 'unread'|'read'|'replied'|'missed' this.currentMsg = null; - this.currentChoices = null; // 细回复选项列表 - this.awaitingChoice = false; - // 定时器 - this._timers = []; - this._ringTimer = null; - this._startTime = null; + this._queue = []; // 消息队列 + this._endingTriggered = false; + this._reminderTimer = null; - // 手势引用 this.gest = null; - - // 特殊事件追踪 - this._azheFollowUpDone = false; - this._xiaomeiRecallDone = false; - this._batteryWarned = false; - this._doorbell = false; } - // ── state getter/setter(自动更新提示栏)── + // ── 状态 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 = { - playing: '↓ 查状态 ↑ 收件箱', - ringing: '单点 接听 左滑 拒接', - in_call: '左滑 挂断', - msg_incoming: '单点 播放 左滑 忽略', - msg_playing: '播放中…', - await_reply: '单点 嗯 双点 标准 长按 细回复 左滑 不回', - detail_reply: '上下左右 选择 单点 重听', - inbox: '单点 进入 左滑 返回', - ending: '', idle: '', + notification: '单击 播放 · 双击 跳过', + ringing: '单击 接听 · 左滑 拒接', + playing: '双击 跳过', + choosing: '→温暖 ←冷淡 ↑追问 ↓沉默', + ending: '', }; - el.textContent = H[s] ?? ''; + el.textContent = H[s] || ''; } // ══════════════════════════════════════════ - // 初始化 + // 初始化 & 启动 // ══════════════════════════════════════════ + init(gestureDetector) { this.gest = gestureDetector; - this._bindGestures(); + 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')); } - _bindGestures() { - const G = this.gest; - G.on('singletap', () => this._onSingleTap()); - G.on('doubletap', () => this._onDoubleTap()); - G.on('longpress1s', () => this._onLongPress1s()); - G.on('longpress5s', () => this._onLongPress5s()); - G.on('swipe_left', () => this._onSwipe('left')); - G.on('swipe_right', () => this._onSwipe('right')); - G.on('swipe_up', () => this._onSwipe('up')); - G.on('swipe_down', () => this._onSwipe('down')); - } - - // ══════════════════════════════════════════ - // 游戏开始 - // ══════════════════════════════════════════ async start() { - this._startTime = Date.now(); - this.state = 'playing'; + this._queue = [...MESSAGES]; - // 启动环境底噪 - await Snd.startAmbience(AMBIENCE_DEFAULT, { vol: 0.2 }); + // 环境音 + 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); - // 调度所有消息 - for (const msg of MESSAGES) { - this._schedule(msg.triggerAt * 1000, () => this._triggerMessage(msg)); - } - - // 调度特殊事件 - this._scheduleSpecialEvents(); - - // 02:05 兜底:确保结局被触发 - const endingDelay = (gt(2, 5)) * 1000; - this._schedule(endingDelay, () => { - if (!this._endingTriggered) this.triggerFinalEnding(); - }); - - // 夜间启动彩蛋 - const hour = new Date().getHours(); - if (hour >= 22 || hour < 4) { - await sleep(2000); - await TTS.speak('你也还醒着啊。', { rate: 0.8, volume: 0.6 }); - } - - // 开始主BGM - await sleep(3000); - Snd.startBGM(`${MUS}/lv11_bgm_m1_main_loop_v1.mp3`, { vol: 0.3 }); - } - - _scheduleSpecialEvents() { - // 阿哲撤回(消息4未回复时,10游戏分钟后) - this._schedule(gt(21, 40) * 1000, () => { - if (!this._azheFollowUpDone && this.msgState[4] !== 'replied') { - this._azheFollowUpDone = true; - Snd.startTyping(); - sleep(8000).then(() => { - Snd.stopTyping(); - TTS.speak('阿哲撤回了一条消息。', { rate: 0.85, volume: 0.7 }); - }); - } - }); - - // HE_BACK 为 true 时,门铃音效(23:30左右) - this._schedule(gt(23, 28) * 1000, () => { - if (this.vars.HE_BACK === true && !this._doorbell) { - this._doorbell = true; - Snd.playFX ? null : null; - Snd.playVoice([`${SFX}/sfx_door_knock_gentle_v1.mp3`]); - Haptic.ring(); - } - }); - - // 电量1%警告(结局D触发条件之一) - this._schedule(gt(1, 0) * 1000, () => { - if (!this._batteryWarned) { - this._batteryWarned = true; - const unread = Object.values(this.msgState).filter(s => s === 'unread').length - + (Object.keys(this.msgState).length === 0 ? MESSAGES.length : 0); - if (this.vars.UNREAD >= 8) { - Snd.playVoice([`${TDIR}/lv11_sys_battery_01_wav_v1.mp3`]); - Haptic.system(); - } - } - }); - } - - _schedule(ms, fn) { - const t = setTimeout(fn, ms); - this._timers.push(t); - return t; + // 开始第一条消息 + this._next(); } // ══════════════════════════════════════════ - // 消息触发 + // 消息队列推进 // ══════════════════════════════════════════ - async _triggerMessage(msg) { - if (this.state === 'ringing' || this.state === 'in_call') { - // 已在通话中,延迟处理(放入队列) - this._schedule(5000, () => this._triggerMessage(msg)); + + async _next() { + if (this._endingTriggered) return; + + const msg = this._queue.shift(); + if (!msg) { + this.triggerFinalEnding(); return; } - // 消息16(妈妈长语音):MOM_LINK=false时跳过,直接触发结局 + // 消息16需要 MOM_LINK if (msg.requiresMomLink && !this.vars.MOM_LINK) { this.triggerFinalEnding(); return; } - // 记录为未读 + this.currentMsg = msg; this.msgState[msg.id] = 'unread'; this.vars.UNREAD++; - this.currentMsg = msg; if (msg.type === 'call') { - await this._startRinging(msg); - } else if (msg.type === 'wechat_voice' || msg.type === 'wechat_text') { - await this._notifyMessage(msg); - } else if (msg.type === 'system_notification') { - await this._notifySystem(msg); + 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'; - this.currentMsg = msg; - Haptic.ring(); await Snd.startRingtone(msg.ringtone); - - // 超时自动漏接 - this._ringTimer = setTimeout(() => { - this._missCall(msg); - }, msg.ringDuration * 1000); - - await TTS.speak(`${msg.senderName},来电话了。`, { rate: 0.9, volume: 0.7 }); + this._startReminder(); } - _missCall(msg) { - Snd.stopRingtone(); - Haptic.stop(); - this.state = 'playing'; - this.msgState[msg.id] = 'missed'; - this.profile.add(msg.profileOnMiss); - if (msg.id === 1 || msg.id === 14) { - // 妈妈漏接不影响 MOM_LINK(需主动接才算) - } - } + // ══════════════════════════════════════════ + // 通话流程 + // ══════════════════════════════════════════ _answerCall(msg) { - clearTimeout(this._ringTimer); Snd.stopRingtone(); + Haptic.stop(); Haptic.confirm(); - this.state = 'in_call'; + SFX_UI.tap(); + this._clearReminder(); + + this.state = 'playing'; this.msgState[msg.id] = 'read'; this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1); - this.profile.add(msg.profileOnAnswer); - if (msg.stateOnAnswer) this._applyState(msg.stateOnAnswer); + if (msg.stateOnAnswer) this._applyState(msg.stateOnAnswer); + if (msg.profileOnAnswer) this.profile.add(msg.profileOnAnswer); - // 播放通话内容 Snd.playVoice(msg.audio, { onEnd: () => { - if (msg.inCallChoices) { - this._startInCallChoice(msg); - } else if (msg.autoHangup) { - setTimeout(() => this._endCall(msg), msg.autoHangup * 1000); + 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._endCall(msg); + this._proceedToNext(); } } }); } - async _startInCallChoice(msg) { - this.state = 'detail_reply'; - this.currentChoices = msg.inCallChoices; - this.awaitingChoice = true; - await this._readChoices(msg.inCallChoices); - } - - _endCall(msg) { - this.state = 'playing'; - this.currentMsg = null; - if (msg.sfxAfter) Snd.playVoice([msg.sfxAfter]); - } - _rejectCall(msg) { - clearTimeout(this._ringTimer); Snd.stopRingtone(); Haptic.reject(); - this.state = 'playing'; + SFX_UI.skip(); + this._clearReminder(); this.msgState[msg.id] = 'missed'; - this.profile.add(msg.profileOnMiss); + if (msg.profileOnMiss) this.profile.add(msg.profileOnMiss); + this._proceedToNext(); } - // ── 微信消息流程 ── - async _notifyMessage(msg) { - Haptic.message(); - const type = msg.type === 'wechat_text' ? '文字消息' : '语音'; - await TTS.speak(`${msg.senderName},发来${type}。`, { rate: 0.9, volume: 0.7 }); - this.state = 'msg_incoming'; - // 等待玩家交互(单点播放 / 不动保持未读) - } + // ══════════════════════════════════════════ + // 消息播放 + // ══════════════════════════════════════════ async _playMessage(msg) { - this.state = 'msg_playing'; + this._clearReminder(); + this.state = 'playing'; this.msgState[msg.id] = 'read'; this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1); - // 开始BGM(如果消息指定) - if (msg.bgm) Snd.startBGM(msg.bgm, { vol: 0.25 }); + if (msg.bgm) Snd.startBGM(msg.bgm, { vol: 0.2 }); - if (msg.type === 'wechat_text') { - // 文字消息:TTS 朗读 - await TTS.speak(msg.text, { rate: 0.85 }); - this._afterMessagePlayed(msg); - } else { - // 语音消息:播放音频 + // 系统通知(时光胶囊) + 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: () => { - // 如果是小美醉语音,5秒后撤回 - if (msg.followUp?.action === 'xiaomei_recall') { - setTimeout(() => { - TTS.speak('小美撤回了刚才的语音。但你已经听过了。', { rate: 0.85, volume: 0.7 }); - }, msg.followUp.delaySec * 1000); - } - this._afterMessagePlayed(msg); + if (this.state === 'playing') this._afterPlayed(msg); } }); + } else { + this._afterPlayed(msg); } } - _afterMessagePlayed(msg) { - this.state = 'await_reply'; - this.currentMsg = msg; - // 等待玩家选择回应方式 - // 单点=嗯 / 双点=标准回复 / 长按=细回复 / 双指上滑=已读不回 / 不动=本已读 - } - - // ── 系统通知(时光胶囊)── - async _notifySystem(msg) { - Haptic.system(); - Snd.playVoice([msg.systemAudio]); - await sleep(3000); - await TTS.speak(msg.text, { rate: 0.85, volume: 0.8 }); - this.state = 'msg_incoming'; - this.currentMsg = msg; - // 单点=播放, 双指上滑=跳过 - } - - async _playSystemMessage(msg) { - const profileLabel = this.profile.label; - const audioUrl = msg.audioByProfile[profileLabel]; - this.msgState[msg.id] = 'read'; - this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1); - this._applyState(msg.stateOnPlay || {}); - this.profile.add(msg.profileOnPlay); - - this.state = 'msg_playing'; - Snd.playVoice([audioUrl], { + // ── 系统通知特殊处理 ── + _playSystemNotification(msg) { + Snd.playVoice([msg.systemAudio], { onEnd: () => { - this.state = 'playing'; - this._checkEndingConditions(); + 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(); + } } }); } - // ══════════════════════════════════════════ - // 细回复流程 - // ══════════════════════════════════════════ - async _enterDetailReply(msg) { - if (!msg.detailReplies) return; - this.state = 'detail_reply'; - this.currentChoices = msg.detailReplies; - this.awaitingChoice = true; - Haptic.detailMode(); - await this._readChoices(msg.detailReplies); - } + // ── 消息播放完成 ── + _afterPlayed(msg) { + if (this.state !== 'playing') return; // 防止重复调用 - async _readChoices(choices) { - const nums = ['一', '二', '三', '四']; - for (let i = 0; i < choices.length; i++) { - if (!this.awaitingChoice) return; - await TTS.speak(`${nums[i]}……`, { rate: 0.75, volume: 0.65 }); - await sleep(200); - await TTS.speak(choices[i].label, { rate: 0.85, volume: 0.85 }); - await sleep(400); + if (msg.isEnding) { + this.triggerFinalEnding(); + return; + } + + if (msg.choices) { + this.state = 'choosing'; + SFX_UI.choiceReady(); + Haptic.confirm(); + this._startReminder(); + } else { + this._proceedToNext(); } - // 读完后震动提示"可以选了" - if (this.awaitingChoice) Haptic.confirm(); } - _selectDetailChoice(dir) { - if (!this.awaitingChoice || !this.currentChoices) return; - const choice = this.currentChoices.find(c => c.dir === dir); - if (!choice) { Haptic.error(); return; } + // ══════════════════════════════════════════ + // 选择回复 + // ══════════════════════════════════════════ - this.awaitingChoice = false; - TTS.stop(); + _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(); - const msg = this.currentMsg; - - if (choice.isSilence) { - // 玩家选择不说话 - TTS.speak('(沉默)', { rate: 0.8, volume: 0.5 }); - } else { - TTS.speak(`发送:${choice.label}`, { rate: 0.85, volume: 0.7 }); + if (!choice.isSilence) { this.msgState[msg.id] = 'replied'; - this.profile.onDetailReply(); } - if (choice.state) this._applyState(choice.state); + if (choice.state) this._applyState(choice.state); if (choice.profile) this.profile.add(choice.profile); - // 周南相关深度追踪 - if (choice.state?.ZHOUNAN_DEPTH_ADD) { - this.vars.ZHOUNAN_DEPTH += 1; - } - if (choice.state?.ZHOUNAN_SHARE) { - this.vars.ZHOUNAN_SHARE = true; - } + this._proceedToNext(); + } - this.state = 'playing'; - this.currentChoices = null; - this._checkEndingConditions(); + _skipChoice() { + this._clearReminder(); + SFX_UI.skip(); + Haptic.confirm(); + this.profile.add({ avoidance: 1 }); + this._proceedToNext(); } // ══════════════════════════════════════════ - // 手势处理器 + // 推进到下一条 // ══════════════════════════════════════════ - _onSingleTap() { - const s = this.state; - const msg = this.currentMsg; - if (s === 'ringing') { - this._answerCall(msg); + 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); } - else if (s === 'msg_incoming') { - if (msg.type === 'system_notification') { - this._playSystemMessage(msg); + + // 检查结局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._playMessage(msg); - } - } - else if (s === 'await_reply') { - // 单点 = 发"嗯" - if (msg?.type !== 'call') { - TTS.speak('发送:嗯。', { rate: 0.9, volume: 0.7 }); - this.msgState[msg.id] = 'replied'; - this.profile.onSimpleReply(); - if (msg.stateOnStdReply) this._applyState(msg.stateOnStdReply); - this.state = 'playing'; - this._checkEndingConditions(); - } - } - else if (s === 'detail_reply' && this.awaitingChoice) { - // 在细回复中单点 = 重听选项 - this._readChoices(this.currentChoices); - } - else if (s === 'inbox') { - this._inboxSelect(); - } - else if (s === 'playing') { - const unread = this.vars.UNREAD; - if (unread > 0) { - TTS.speak(`${unread}条未读消息。上滑打开收件箱。`, { rate: 0.9, volume: 0.6 }); - } else { - TTS.speak('暂无新消息。', { rate: 0.9, volume: 0.6 }); - } - } - } - - _onDoubleTap() { - const s = this.state; - const msg = this.currentMsg; - - if (s === 'await_reply' && msg) { - // 双点 = 发标准回复 - const reply = msg.standardReply || '嗯,好的。'; - TTS.speak(`发送:${reply}`, { rate: 0.9, volume: 0.7 }); - this.msgState[msg.id] = 'replied'; - this.profile.onDetailReply(); - if (msg.stateOnStdReply) this._applyState(msg.stateOnStdReply); - this.state = 'playing'; - this._checkEndingConditions(); - } - else if (s === 'msg_incoming') { - // 双点 = 直接回"嗯"不听 - if (msg?.type === 'wechat_text') { - TTS.speak('发送:嗯。', { rate: 0.9, volume: 0.7 }); - this.msgState[msg.id] = 'replied'; - this.profile.onSimpleReply(); - this.state = 'playing'; - } - } - else if (s === 'ringing') { - // 双点来电 = 同样接听 - this._answerCall(msg); - } - } - - _onLongPress1s() { - const s = this.state; - const msg = this.currentMsg; - - if (s === 'await_reply' && msg?.detailReplies) { - this._enterDetailReply(msg); - } - else if (s === 'in_call' && msg?.inCallChoices) { - this._startInCallChoice(msg); - } - else if (s === 'detail_reply' && this.awaitingChoice) { - // 重听选项 - TTS.stop(); - this._readChoices(this.currentChoices); - } - } - - _onLongPress5s() { - // 强制挂断 - if (this.state === 'in_call' || this.state === 'ringing') { - clearTimeout(this._ringTimer); - Snd.stopRingtone(); - Snd.stopVoice(); - Haptic.reject(); - TTS.speak('已挂断。', { rate: 0.9, volume: 0.7 }); - this.state = 'playing'; - } - } - - _onSwipe(dir) { - const s = this.state; - const msg = this.currentMsg; - - // 细回复 / 通话选项:四个方向用于选择 - if ((s === 'detail_reply' || (s === 'in_call' && this.awaitingChoice)) && this.currentChoices) { - this._selectDetailChoice(dir); - return; - } - - // 下滑 = 查询当前状态(任意场景可用) - if (dir === 'down') { - this._announceStatus(); - return; - } - - // 上滑 = 打开收件箱(主屏幕时) - if (dir === 'up') { - if (s === 'playing') this._onInboxOpen(); - return; - } - - // 右滑 = 接听(来电时) - if (dir === 'right') { - if (s === 'ringing') this._answerCall(msg); - return; - } - - // 左滑 = 拒接 / 挂断 / 忽略 / 已读不回 / 返回 - if (dir === 'left') { - if (s === 'ringing') { - this._rejectCall(msg); - } - else if (s === 'in_call') { - Snd.stopVoice(); - Haptic.reject(); - TTS.speak('已挂断。', { rate: 0.9, volume: 0.7 }); - this.state = 'playing'; - this.currentMsg = null; - this.awaitingChoice = false; - } - else if (s === 'msg_incoming') { - if (msg?.type === 'system_notification') { - TTS.speak('已跳过。', { rate: 0.9, volume: 0.6 }); - this.msgState[msg.id] = 'read'; - this.profile.add(msg.profileOnSkip); - } else { - TTS.speak('(消息保持未读)', { rate: 0.85, volume: 0.5 }); - } - this.state = 'playing'; - this.currentMsg = null; - } - else if (s === 'await_reply' && msg) { - TTS.speak('(已读不回)', { rate: 0.85, volume: 0.6 }); - this.msgState[msg.id] = 'read'; - this.profile.onReadNoReply(); - this.state = 'playing'; - } - else if (s === 'inbox') { - this.state = 'playing'; - this._inboxMessages = null; - TTS.speak('返回主屏幕。', { rate: 0.9, volume: 0.6 }); - } - } - } - - // ── 下滑:播报当前状态和可用操作 ── - _announceStatus() { - const s = this.state; - const msg = this.currentMsg; - - if (s === 'playing') { - const unread = this.vars.UNREAD; - if (unread > 0) { - TTS.speak(`主屏幕,${unread}条未读。上滑查看收件箱。`, { rate: 0.9, volume: 0.7 }); - } else { - TTS.speak('主屏幕,暂无新消息。', { rate: 0.9, volume: 0.7 }); - } - } - else if (s === 'ringing') { - TTS.speak(`${msg?.senderName}来电中。单点接听,左滑拒接。`, { rate: 0.9, volume: 0.7 }); - } - else if (s === 'in_call') { - TTS.speak('通话中。左滑挂断。', { rate: 0.9, volume: 0.7 }); - } - else if (s === 'msg_incoming') { - TTS.speak(`${msg?.senderName}的新消息。单点播放,左滑忽略。`, { rate: 0.9, volume: 0.7 }); - } - else if (s === 'msg_playing') { - TTS.speak('消息播放中。', { rate: 0.9, volume: 0.7 }); - } - else if (s === 'await_reply') { - TTS.speak(`${msg?.senderName}的消息已播完。单点回嗯,双点标准回复,长按细回复,左滑已读不回。`, { rate: 0.9, volume: 0.7 }); - } - else if (s === 'detail_reply') { - TTS.speak('细回复选项中。滑动选择,单点重听。', { rate: 0.9, volume: 0.7 }); - } - else if (s === 'inbox') { - TTS.speak('收件箱。单点进入最新消息,左滑返回。', { rate: 0.9, volume: 0.7 }); - } - } - - async _onInboxOpen() { - if (this.state === 'in_call') return; - - const prevState = this.state; - this.state = 'inbox'; - Haptic.system(); - - // 统计未读/未回 - const unreadMsgs = MESSAGES.filter(m => - !this.msgState[m.id] || this.msgState[m.id] === 'unread' - ); - - if (unreadMsgs.length === 0) { - await TTS.speak('收件箱已空。', { rate: 0.9, volume: 0.7 }); - this.state = prevState; - return; - } - - await TTS.speak(`收件箱:${unreadMsgs.length}条未读。`, { rate: 0.9, volume: 0.7 }); - - for (const m of unreadMsgs.slice(0, 5)) { - const sender = SENDERS[m.sender]; - const type = m.type === 'call' ? '未接来电' : m.type === 'wechat_text' ? '文字' : '语音'; - await TTS.speak(`${sender?.name || m.senderName},${type}。`, { rate: 0.85, volume: 0.7 }); - await sleep(200); - } - - await TTS.speak('单点进入最新一条,左滑返回。', { rate: 0.85, volume: 0.6 }); - - this._inboxMessages = unreadMsgs; - // 5秒后自动退出收件箱 - setTimeout(() => { - if (this.state === 'inbox') { - this.state = prevState; - this._inboxMessages = null; + this._clearReminder(); } }, 8000); } - _inboxSelect() { - if (!this._inboxMessages || this._inboxMessages.length === 0) { - this.state = 'playing'; - return; + _clearReminder() { + if (this._reminderTimer) { + clearInterval(this._reminderTimer); + this._reminderTimer = null; } - const msg = this._inboxMessages[0]; - this._inboxMessages = null; - this.currentMsg = msg; - this.state = 'playing'; - // 触发该消息 - this._triggerMessage(msg); } // ══════════════════════════════════════════ - // 状态变量应用 + // 状态变量 // ══════════════════════════════════════════ + _applyState(changes) { for (const [k, v] of Object.entries(changes)) { if (k === 'ZHOUNAN_DEPTH_ADD') { @@ -670,121 +374,194 @@ class GameEngine { } // ══════════════════════════════════════════ - // 结局判定 + // 手势处理 // ══════════════════════════════════════════ - _checkEndingConditions() { - // 只在消息16触发后真正判定结局 - // 这里只做中途检测 - const unread = Object.values(this.msgState).filter(s => s === 'unread').length; - // 结局D:未读≥8时在01:00前触发 - if (unread >= 8 && Date.now() - this._startTime >= gt(1, 0) * 1000) { - if (!this._endingTriggered) this._triggerEnding('D'); + _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; - const unread = Object.values(this.msgState).filter(s => s === 'unread' || !this.msgState[s]).length; - // 结局 E(隐藏) + // 结局E(隐藏) if (v.ZHOUNAN_DEPTH >= 2 && v.ZHOUNAN_SHARE && (this.profile.label === 'engagement' || this.profile.label === 'nostalgia')) { return this._triggerEnding('E'); } - // 结局 A:妈妈连接+自己录音+有回复 + // 结局A:妈妈连接 + 自己录音 if (v.MOM_LINK && v.SELF_RECORD) { return this._triggerEnding('A'); } - // 结局 B:拒了阿哲+回了周南 + // 结局B:拒了阿哲 + 回了周南 if (v.HE_BACK === false && v.ZHOUNAN_DEPTH >= 1) { return this._triggerEnding('B'); } - // 结局 C:全部已读但回复≤2 - const repliedCount = Object.values(this.msgState).filter(s => s === 'replied').length; - const readCount = Object.values(this.msgState).filter(s => s !== 'unread').length; - if (readCount >= MESSAGES.length - 2 && repliedCount <= 2) { + // 结局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(兜底) + // 结局D(兜底) this._triggerEnding('D'); } async _triggerEnding(type) { if (this._endingTriggered) return; this._endingTriggered = true; - this.state = 'ending'; + this.state = 'ending'; + this._clearReminder(); + Snd.stopVoice(); + Snd.stopRingtone(); + TTS.stop(); Snd.stopBGM(3000); await sleep(2000); - const ENDINGS = { - A: this._endingA.bind(this), - B: this._endingB.bind(this), - C: this._endingC.bind(this), - D: this._endingD.bind(this), - E: this._endingE.bind(this), - }; + dbg(`ending: ${type}`); - if (ENDINGS[type]) await ENDINGS[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('明天再说。'); - await sleep(3000); - Snd.playVoice([`${SFX}/sfx_ending_chime_v1.mp3`]); } + // ── 结局B:寄出 ── async _endingB() { - await TTS.speak('寄出。', { rate: 0.8, volume: 0.8 }); - await sleep(4000); + await sleep(2000); + SFX_UI.ending(); + await sleep(1000); this._showCaption('寄出。'); } + // ── 结局C:今晚就这样 ── async _endingC() { - await TTS.speak( - `今晚共${MESSAGES.length}条新消息,已读${Object.values(this.msgState).filter(s => s !== 'unread').length},未回复${MESSAGES.length - Object.values(this.msgState).filter(s => s === 'replied').length}。`, - { rate: 0.85, volume: 0.8 } - ); - await sleep(6000); + await sleep(3000); + SFX_UI.ending(); + await sleep(1000); this._showCaption('今晚就这样。'); } + // ── 结局D:早上好 ── async _endingD() { - Snd.playVoice([`${TDIR}/lv11_sys_battery_01_wav_v1.mp3`]); + Haptic.system(); await sleep(2000); Haptic.ring(); - await sleep(1000); - // 屏幕"彻底黑了"→清晨鸟叫 - await sleep(3000); + 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); - await TTS.speak('周南发来语音。', { rate: 0.9, volume: 0.7 }); - await TTS.speak('那我下次出差来北京,请你吃饭好不好。', { rate: 0.9 }); - await sleep(2000); - // 林夏的轻笑(用self3y音色) Snd.playVoice([`${TDIR}/lv11_sys_goodnight_wav_v1.mp3`]); await sleep(3000); + SFX_UI.ending(); + await sleep(1000); this._showCaption('好。'); - await sleep(2000); - Snd.playVoice([`${SFX}/sfx_ending_chime_v1.mp3`]); } _showCaption(text) { diff --git a/game/js/gestures.js b/game/js/gestures.js index 74c0c28..ab934d0 100644 --- a/game/js/gestures.js +++ b/game/js/gestures.js @@ -1,128 +1,121 @@ -// gestures.js — 触控手势检测器(单指操作) +// gestures.js — 触控手势检测器(简化版) // -// 支持:单点 · 双点 · 长按1s · 长按5s -// 左滑 · 右滑 · 上滑 · 下滑 +// 仅支持 6 种手势:单击 · 双击 · 左滑 · 右滑 · 上滑 · 下滑 +// 无长按,无多指操作 class GestureDetector { constructor(el) { this._el = el; this._handlers = {}; - // 状态 this._startX = 0; this._startY = 0; this._startTime = 0; - this._tapTimer = null; - this._longTimer1 = null; - this._longTimer5 = null; - this._longFired = false; this._tapCount = 0; - this._bind(); + this._bindTouch(); + this._bindMouse(); + this._bindKeyboard(); } on(evt, fn) { this._handlers[evt] = fn; return this; } - _emit(evt, data) { + _emit(evt) { dbg(evt); const fn = this._handlers[evt]; - if (fn) fn(data); + if (fn) fn(); } - _bind() { + // ── 触摸事件 ── + _bindTouch() { const el = this._el; - el.addEventListener('touchstart', e => this._onStart(e), { passive: false }); - el.addEventListener('touchend', e => this._onEnd(e), { passive: false }); - el.addEventListener('touchcancel', e => this._onCancel(e), { passive: false }); + el.addEventListener('touchstart', e => { + e.preventDefault(); + const t = e.touches[0]; + this._startX = t.clientX; + this._startY = t.clientY; + this._startTime = Date.now(); + }, { passive: false }); + + el.addEventListener('touchend', e => { + e.preventDefault(); + const t = e.changedTouches[0]; + this._process(t.clientX, t.clientY); + }, { passive: false }); + + el.addEventListener('touchcancel', () => this._reset(), { passive: false }); } - _onStart(e) { - e.preventDefault(); - const t = e.touches[0]; - this._startX = t.clientX; - this._startY = t.clientY; - this._startTime = Date.now(); - this._longFired = false; - - this._clearTimers(); - - // 1s长按 - this._longTimer1 = setTimeout(() => { - this._longFired = true; - this._emit('longpress1s'); - Haptic.detailMode(); - }, 900); - - // 5s长按(安全机制) - this._longTimer5 = setTimeout(() => { - this._longFired = true; - this._emit('longpress5s'); - Haptic.reject(); - }, 5000); + // ── 鼠标事件(桌面调试)── + _bindMouse() { + this._el.addEventListener('mousedown', e => { + this._startX = e.clientX; + this._startY = e.clientY; + this._startTime = Date.now(); + }); + this._el.addEventListener('mouseup', e => { + this._process(e.clientX, e.clientY); + }); } - _onEnd(e) { - e.preventDefault(); - this._clearTimers(); + // ── 键盘事件(桌面调试)── + _bindKeyboard() { + document.addEventListener('keydown', e => { + switch (e.code) { + case 'Space': e.preventDefault(); this._emit('singletap'); break; + case 'Enter': e.preventDefault(); this._emit('doubletap'); break; + case 'ArrowLeft': e.preventDefault(); this._emit('swipe_left'); break; + case 'ArrowRight': e.preventDefault(); this._emit('swipe_right'); break; + case 'ArrowUp': e.preventDefault(); this._emit('swipe_up'); break; + case 'ArrowDown': e.preventDefault(); this._emit('swipe_down'); break; + } + }); + } - const t = e.changedTouches[0]; - const dx = t.clientX - this._startX; - const dy = t.clientY - this._startY; + // ── 手势判定 ── + _process(endX, endY) { + const dx = endX - this._startX; + const dy = endY - this._startY; const dist = Math.sqrt(dx * dx + dy * dy); const dt = Date.now() - this._startTime; - if (this._longFired) return; - - // ── 滑动判断 ── - const SWIPE_MIN = 35; - const SWIPE_ANG = 45; - - if (dist > SWIPE_MIN) { + // 滑动:距离 > 35px + if (dist > 35) { const angle = Math.abs(Math.atan2(dy, dx) * 180 / Math.PI); let dir = null; - if (angle < SWIPE_ANG) dir = 'right'; - else if (angle > 180 - SWIPE_ANG) dir = 'left'; + if (angle < 45) dir = 'right'; + else if (angle > 135) dir = 'left'; else if (dy < 0 && Math.abs(dy) > Math.abs(dx)) dir = 'up'; else if (dy > 0 && Math.abs(dy) > Math.abs(dx)) dir = 'down'; if (dir) { + this._tapCount = 0; + clearTimeout(this._tapTimer); this._emit(`swipe_${dir}`); - Haptic.confirm(); return; } } - // ── 点击判断 ── + // 点击:距离 < 20px,时间 < 500ms if (dist < 20 && dt < 500) { this._tapCount++; if (this._tapCount === 1) { this._tapTimer = setTimeout(() => { this._tapCount = 0; this._emit('singletap'); - Haptic.confirm(); }, 250); - } else if (this._tapCount === 2) { + } else if (this._tapCount >= 2) { clearTimeout(this._tapTimer); this._tapCount = 0; this._emit('doubletap'); - Haptic.confirm(); } } } - _onCancel(e) { - this._clearTimers(); - this._longFired = false; - } - - _clearTimers() { + _reset() { clearTimeout(this._tapTimer); - clearTimeout(this._longTimer1); - clearTimeout(this._longTimer5); - this._tapTimer = null; - this._longTimer1 = null; - this._longTimer5 = null; + this._tapCount = 0; } } diff --git a/game/js/main.js b/game/js/main.js index 55d15c5..fbd60bc 100644 --- a/game/js/main.js +++ b/game/js/main.js @@ -1,13 +1,5 @@ // main.js — 游戏入口 -// 开发模式(URL加?dev):时间线缩短为1/10 -const DEV_MODE = new URLSearchParams(location.search).has('dev'); -if (DEV_MODE) { - // 直接缩短所有消息的触发时间 - MESSAGES.forEach(m => { m.triggerAt = Math.floor(m.triggerAt / 10); }); - document.title = '林夏 [DEV x10]'; -} - let engine = null; let gestures = null; @@ -17,30 +9,25 @@ async function main() { // 初始化音频(必须在用户手势后) await Snd.init(); - // 预加载关键短音效 - await Promise.allSettled([ - Snd.preload('sfx_keyboard', `${SFX}/sfx_keyboard_slow_v1.mp3`), - Snd.preload('sfx_chime', `${SFX}/sfx_ending_chime_v1.mp3`), - ]); - - // 请求屏幕常亮(Android Chrome 支持) + // 请求屏幕常亮 try { if ('wakeLock' in navigator) { window._wakeLock = await navigator.wakeLock.request('screen'); } - } catch(_) {} + } catch (_) {} - // 绑定手势到游戏区域 + // 绑定手势 const gameEl = document.getElementById('game'); gestures = new GestureDetector(gameEl); - // 创建并启动游戏引擎 + // 创建并启动引擎 engine = new GameEngine(); engine.init(gestures); await engine.start(); - if (DEV_MODE) { + if (DEV) { document.getElementById('dbg').style.display = 'block'; + document.title = '林夏 [DEV]'; } } @@ -66,8 +53,3 @@ document.getElementById('start').addEventListener('click', async function onClic // 防止页面滚动/缩放 document.addEventListener('touchmove', e => e.preventDefault(), { passive: false }); document.addEventListener('gesturestart', e => e.preventDefault()); - -// 页面隐藏时停止打字音效 -document.addEventListener('visibilitychange', () => { - if (document.hidden && Snd) Snd.stopTyping(); -}); diff --git a/game/js/story.js b/game/js/story.js index 4b0cf3b..de18d07 100644 --- a/game/js/story.js +++ b/game/js/story.js @@ -1,4 +1,12 @@ -// story.js — 《林夏》全部16条消息数据 +// story.js — 消息数据(玩家驱动,无时间依赖) +// +// 选择方向的情感含义(全局一致): +// 单击 → 简单回应("嗯") +// 双击 → 不回复 / 跳过 +// 右滑 → 温暖 / 接受 / 开放 +// 左滑 → 冷淡 / 拒绝 / 疏远 +// 上滑 → 好奇 / 主动 / 追问 +// 下滑 → 沉默 / 退缩 const A = '../audio/mp3'; const TDIR = `${A}/tts`; @@ -6,129 +14,93 @@ const MUS = `${A}/music`; const SFX = `${A}/sfx`; const AMB = `${A}/ambience`; -// 游戏时间压缩比:5小时 → 60分钟(实际秒) -// 1 游戏分钟 = 12 实际秒 -// triggerAt = (游戏时间 - 21:00) 的分钟数 × 12 -function gt(hh, mm) { - const gameMin = (hh < 21 ? hh + 24 : hh) * 60 + mm - 21 * 60; - return gameMin * 12; // 实际秒 -} - const MESSAGES = [ - // ── 01 · 21:03 · 妈妈 · 电话 ────────────────────────────────── + // ── 01 · 妈妈 · 电话 ── { - id: 1, gameTime: '21:03', triggerAt: gt(21, 3), - type: 'call', + id: 1, type: 'call', sender: 'mom', senderName: '妈妈', ringtone: `${MUS}/lv11_ring_mom_v1.mp3`, - ringDuration: 28, audio: [`${TDIR}/lv11_2103_mom_call_01_wav_v1.mp3`], - note: '今晚煮了你爱吃的排骨', stateOnAnswer: { MOM_LINK: true }, - profileOnMiss: { avoidance: 2 }, - profileOnAnswer:{ engagement: 1 }, + profileOnMiss: { avoidance: 2 }, + profileOnAnswer: { engagement: 1 }, }, - // ── 02 · 21:15 · 小美 · 微信语音×3(教学关)────────────────── + // ── 02 · 小美 · 微信语音×3 ── { - id: 2, gameTime: '21:15', triggerAt: gt(21, 15), - type: 'wechat_voice', + id: 2, type: 'wechat_voice', sender: 'xiaomei', senderName: '小美', audio: [ `${TDIR}/lv11_2115_xiaomei_voice_01_wav_v1.mp3`, `${TDIR}/lv11_2115_xiaomei_voice_02_wav_v1.mp3`, `${TDIR}/lv11_2115_xiaomei_voice_03_wav_v1.mp3`, ], - note: '你被甩了我都听说了,明天喝酒', - isTutorial: true, - detailReplies: [ - { label: '好啊,明天不见不散。', dir: 'left', profile: { engagement: 1 } }, - { label: '我不太想出门……', dir: 'right', profile: { nostalgia: 1 } }, - { label: '你先去,我再说。', dir: 'up', profile: { avoidance: 1 } }, - { label: '(沉默)', dir: 'down', isSilence: true, profile: { avoidance: 2 } }, - ], - standardReply: '好啊。', - profileOnRead: { engagement: 1 }, - profileOnAvoid: { avoidance: 1 }, + choices: { + tap: { label: '好啊。', profile: { engagement: 1 } }, + right: { label: '好啊,明天不见不散。', profile: { engagement: 2 } }, + up: { label: '我不太想出门……', profile: { nostalgia: 1 } }, + left: { label: '你先去,我再说。', profile: { avoidance: 1 } }, + down: { label: '(沉默)', isSilence: true, profile: { avoidance: 2 } }, + }, }, - // ── 03 · 21:20 · 外卖小哥 · 电话 ───────────────────────────── + // ── 03 · 外卖小哥 · 电话 ── { - id: 3, gameTime: '21:20', triggerAt: gt(21, 20), - type: 'call', + id: 3, type: 'call', sender: 'delivery', senderName: '外卖小哥', ringtone: `${MUS}/lv11_ring_delivery_v1.mp3`, - ringDuration: 20, audio: [`${TDIR}/lv11_2120_delivery_call_01_wav_v1.mp3`], - note: '你好,你的麻辣烫到了', - profileOnMiss: { avoidance: 1 }, - profileOnAnswer:{ engagement: 1 }, + profileOnMiss: { avoidance: 1 }, + profileOnAnswer: { engagement: 1 }, }, - // ── 04 · 21:30 · 阿哲 · 微信语音(第一刀)──────────────────── + // ── 04 · 阿哲 · 微信语音 ── { - id: 4, gameTime: '21:30', triggerAt: gt(21, 30), - type: 'wechat_voice', + id: 4, type: 'wechat_voice', sender: 'azhe', senderName: '阿哲', audio: [`${TDIR}/lv11_2130_azhe_voice_01_wav_v1.mp3`], - note: 'AirPods落你那了,方便寄一下吗', - detailReplies: [ - { label: '好,明天寄。', dir: 'left', state: { HE_BACK: false }, profile: { engagement: 1 } }, - { label: '你怎么不自己来拿。', dir: 'right', state: {}, profile: { engagement: 2 } }, - { label: '你那边……都好吗。', dir: 'up', state: {}, profile: { engagement: 2 } }, - { label: '(什么都不说)', dir: 'down', isSilence: true, state: { HE_BACK: null }, profile: { avoidance: 2 } }, - ], - standardReply: '好,明天寄给你。', - stateOnStdReply: { HE_BACK: false }, - // 10分钟后阿哲撤回消息(如果玩家未回复) - followUp: { delayMin: 10, action: 'azhe_recall' }, - profileOnRead: { engagement: 0 }, - profileOnAvoid: { avoidance: 1 }, + choices: { + tap: { label: '好,明天寄。', state: { HE_BACK: false }, profile: { engagement: 1 } }, + right: { label: '你那边……都好吗。', profile: { engagement: 2 } }, + up: { label: '你怎么不自己来拿。', profile: { engagement: 2 } }, + left: { label: '(不想理)', profile: { avoidance: 1 } }, + down: { label: '(什么都不说)', isSilence: true, state: { HE_BACK: null }, profile: { avoidance: 2 } }, + }, }, - // ── 05 · 21:45 · HR王姐 · 微信语音 ─────────────────────────── + // ── 05 · HR王姐 · 微信语音 ── { - id: 5, gameTime: '21:45', triggerAt: gt(21, 45), - type: 'wechat_voice', + id: 5, type: 'wechat_voice', sender: 'hr', senderName: 'HR王姐', audio: [`${TDIR}/lv11_2145_hr_voice_01_wav_v1.mp3`], - note: '离职流程周三前办完哈', - detailReplies: [ - { label: '好的,我知道了。谢谢王姐。', dir: 'left', profile: { engagement: 1 } }, - { label: '王姐,有什么建议吗?', dir: 'right', profile: { engagement: 2 } }, - { label: '……谢谢你。', dir: 'up', profile: { nostalgia: 1 } }, - { label: '(不回复)', dir: 'down', isSilence: true, profile: { avoidance: 1 } }, - ], - standardReply: '好的,谢谢王姐。', - profileOnRead: { engagement: 1 }, - profileOnAvoid: { avoidance: 1 }, + choices: { + tap: { label: '好的,我知道了。', profile: { engagement: 1 } }, + right: { label: '谢谢王姐。', profile: { engagement: 1 } }, + up: { label: '王姐,有什么建议吗?', profile: { engagement: 2 } }, + left: { label: '……谢谢你。', profile: { nostalgia: 1 } }, + down: { label: '(不回复)', isSilence: true, profile: { avoidance: 1 } }, + }, }, - // ── 06 · 22:00 · 安安 · 微信语音(酒吧背景)───────────────── + // ── 06 · 安安 · 微信语音 ── { - id: 6, gameTime: '22:00', triggerAt: gt(22, 0), - type: 'wechat_voice', + id: 6, type: 'wechat_voice', sender: 'anan', senderName: '安安', audio: [`${TDIR}/lv11_2200_anan_voice_01_wav_v1.mp3`], - note: '我在helens!要不要过来!', - bgUnder: `${AMB}/amb_bar_loud_01_v1.mp3`, // 酒吧底噪混入 - detailReplies: [ - { label: '我今晚不方便,你们玩好啊。', dir: 'left', profile: { avoidance: 1 } }, - { label: '在哪儿啊?我……看看吧。', dir: 'right', profile: { engagement: 1 } }, - { label: '你喝了多少了哈哈。', dir: 'up', profile: { engagement: 1 } }, - { label: '(不回复)', dir: 'down', isSilence: true, profile: { avoidance: 2 } }, - ], - standardReply: '不了,你们玩好~', - profileOnRead: { engagement: 0 }, - profileOnAvoid: { avoidance: 1 }, + choices: { + tap: { label: '不了,你们玩好~', profile: {} }, + right: { label: '在哪儿啊?我……看看吧。', profile: { engagement: 1 } }, + up: { label: '你喝了多少了哈哈。', profile: { engagement: 1 } }, + left: { label: '我今晚不方便。', profile: { avoidance: 1 } }, + down: { label: '(不回复)', isSilence: true, profile: { avoidance: 2 } }, + }, }, - // ── 07 · 22:15 · 大学寝室群 · 5条语音 ──────────────────────── + // ── 07 · 大学寝室群 · 5条语音 ── { - id: 7, gameTime: '22:15', triggerAt: gt(22, 15), - type: 'wechat_voice', - sender: 'dorm', senderName: '我们寝室的(4人群)', + id: 7, type: 'wechat_voice', + sender: 'dorm', senderName: '寝室群', audio: [ `${TDIR}/lv11_2215_dorm_a_voice_01_wav_v1.mp3`, `${TDIR}/lv11_2215_dorm_a_voice_02_wav_v1.mp3`, @@ -136,40 +108,30 @@ const MESSAGES = [ `${TDIR}/lv11_2215_dorm_b_voice_02_wav_v1.mp3`, `${TDIR}/lv11_2215_dorm_c_voice_01_wav_v1.mp3`, ], - note: '室友A升职了,最后她@你', - isGroup: true, - detailReplies: [ - { label: '恭喜恭喜!', dir: 'left', state: { GROUP_REPLY: true }, profile: { engagement: 1 } }, - { label: '哇,快讲讲!', dir: 'right', state: { GROUP_REPLY: true }, profile: { engagement: 2 } }, - { label: '最近有点忙,你们聊~', dir: 'up', profile: { avoidance: 1 } }, - { label: '(沉默)', dir: 'down', isSilence: true, profile: { avoidance: 2 } }, - ], - standardReply: '恭喜!', - stateOnStdReply: { GROUP_REPLY: true }, - profileOnRepeat: { nostalgia: 2 }, - profileOnRead: { engagement: 0 }, - profileOnAvoid: { avoidance: 1 }, + choices: { + tap: { label: '恭喜!', state: { GROUP_REPLY: true }, profile: { engagement: 1 } }, + right: { label: '哇,快讲讲!', state: { GROUP_REPLY: true }, profile: { engagement: 2 } }, + up: { label: '最近有点忙,你们聊~', profile: { avoidance: 1 } }, + left: { label: '(不想参与)', profile: { avoidance: 1 } }, + down: { label: '(沉默)', isSilence: true, profile: { avoidance: 2 } }, + }, }, - // ── 08 · 22:30 · 未知号码 · 电话(悬疑钩子)───────────────── + // ── 08 · 未知号码 · 电话(悬疑)── { - id: 8, gameTime: '22:30', triggerAt: gt(22, 30), - type: 'call', + id: 8, type: 'call', sender: 'unknown', senderName: '未知号码', ringtone: `${MUS}/lv11_ring_unknown_v1.mp3`, - ringDuration: 20, audio: [`${SFX}/sfx_breathing_unknown_v1.mp3`], - note: '接通后只有呼吸声,6秒挂断', - autoHangup: 6, // 接通后自动挂断秒数 + autoHangup: true, sfxAfter: `${SFX}/sfx_heartbeat_fast_v1.mp3`, - profileOnMiss: { avoidance: 1 }, - profileOnAnswer:{ nostalgia: 1 }, + profileOnMiss: { avoidance: 1 }, + profileOnAnswer: { nostalgia: 1 }, }, - // ── 09 · 22:45 · 小美醉语音 · 4段(秘密揭晓)─────────────── + // ── 09 · 小美(醉)· 4段语音 ── { - id: 9, gameTime: '22:45', triggerAt: gt(22, 45), - type: 'wechat_voice', + id: 9, type: 'wechat_voice', sender: 'xiaomei', senderName: '小美', audio: [ `${TDIR}/lv11_2245_xiaomei_voice_01_seg1_wav_v1.mp3`, @@ -177,83 +139,63 @@ const MESSAGES = [ `${TDIR}/lv11_2245_xiaomei_voice_01_seg3_wav_v1.mp3`, `${TDIR}/lv11_2245_xiaomei_voice_01_seg4_wav_v1.mp3`, ], - note: '阿哲大三追过我,我没答应他', - isDrunk: true, - bgm: `${MUS}/lv11_bgm_m2_suspense_v1.mp3`, // 悬疑BGM - detailReplies: [ - { label: '我知道了。', dir: 'left', profile: { avoidance: 1 } }, - { label: '你……为什么现在才说。', dir: 'right', profile: { engagement: 2 } }, - { label: '小美,你还好吗。', dir: 'up', profile: { engagement: 2 } }, - { label: '(什么都不说)', dir: 'down', isSilence: true, profile: { avoidance: 2, nostalgia: 1 } }, - ], - standardReply: '我知道了。', - // 5秒后消息被撤回 - followUp: { delaySec: 5, action: 'xiaomei_recall' }, - profileOnRepeat: { nostalgia: 2 }, - profileOnAvoid: { avoidance: 1, nostalgia: 1 }, + bgm: `${MUS}/lv11_bgm_m2_suspense_v1.mp3`, + choices: { + tap: { label: '我知道了。', profile: { avoidance: 1 } }, + right: { label: '小美,你还好吗。', profile: { engagement: 2 } }, + up: { label: '你……为什么现在才说。', profile: { engagement: 2 } }, + left: { label: '(不想理她)', profile: { avoidance: 1 } }, + down: { label: '(什么都不说)', isSilence: true, profile: { avoidance: 2, nostalgia: 1 } }, + }, }, - // ── 10 · 23:00 · 妈妈 · 微信文字 ───────────────────────────── + // ── 10 · 妈妈 · 文字消息 ── { - id: 10, gameTime: '23:00', triggerAt: gt(23, 0), - type: 'wechat_text', + id: 10, type: 'wechat_text', sender: 'mom', senderName: '妈妈', text: '睡了吗?', - note: '三个字,最痛', - detailReplies: [ - { label: '嗯,要睡了。', dir: 'left', state: { MOM_LINK: true }, profile: { avoidance: 1 } }, - { label: '没有,怎么了。', dir: 'right', state: { MOM_LINK: true }, profile: { engagement: 1 } }, - { label: '妈,你在吗,我想打电话。', dir: 'up', state: { MOM_LINK: true }, profile: { engagement: 2 } }, - { label: '(不回复)', dir: 'down', isSilence: true, profile: { avoidance: 2 } }, - ], - standardReply: '嗯。', - stateOnStdReply: { MOM_LINK: true }, - profileOnRead: { nostalgia: 1 }, - profileOnAvoid: { avoidance: 1 }, + choices: { + tap: { label: '嗯。', state: { MOM_LINK: true } }, + right: { label: '没有,怎么了。', state: { MOM_LINK: true }, profile: { engagement: 1 } }, + up: { label: '妈,我想打电话。', state: { MOM_LINK: true }, profile: { engagement: 2 } }, + left: { label: '嗯,要睡了。', state: { MOM_LINK: true }, profile: { avoidance: 1 } }, + down: { label: '(不回复)', isSilence: true, profile: { avoidance: 2 } }, + }, }, - // ── 11 · 23:15 · 阿哲 · 电话(关键分支)───────────────────── + // ── 11 · 阿哲 · 电话(关键分支)── { - id: 11, gameTime: '23:15', triggerAt: gt(23, 15), - type: 'call', + id: 11, type: 'call', sender: 'azhe', senderName: '阿哲', ringtone: `${MUS}/lv11_ring_azhe_v1.mp3`, - ringDuration: 30, audio: [`${TDIR}/lv11_2315_azhe_call_01_wav_v1.mp3`], - note: '我能过去拿吗?就5分钟', - // 通话中的选择(接通后听完再做) - inCallChoices: [ - { label: '不方便,明天吧。', dir: 'left', state: { HE_BACK: false } }, - { label: '可以,但只有5分钟。', dir: 'right', state: { HE_BACK: true } }, - { label: '(不说话,挂断)', dir: 'down', isSilence: true, state: { HE_BACK: false } }, - ], - profileOnMiss: { avoidance: 2 }, - profileOnAnswer:{ engagement: 1 }, + profileOnMiss: { avoidance: 2 }, + profileOnAnswer: { engagement: 1 }, + choices: { + tap: { label: '不方便,明天吧。', state: { HE_BACK: false } }, + right: { label: '可以,但只有5分钟。', state: { HE_BACK: true } }, + left: { label: '(挂断)', state: { HE_BACK: false } }, + down: { label: '(沉默挂断)', isSilence: true, state: { HE_BACK: false } }, + }, }, - // ── 12 · 23:30 · 周南 · 微信语音(悬疑揭晓)──────────────── + // ── 12 · 周南 · 微信语音 ── { - id: 12, gameTime: '23:30', triggerAt: gt(23, 30), - type: 'wechat_voice', + id: 12, type: 'wechat_voice', sender: 'zhounan', senderName: '周南', audio: [`${TDIR}/lv11_2330_zhounan_voice_01_wav_v1.mp3`], - note: '林夏,是我,周南。初三坐你后桌那个', - detailReplies: [ - { label: '嗯……你好。', dir: 'left', state: { ZHOUNAN_DEPTH: 1 }, profile: { nostalgia: 1 } }, - { label: '周南!你还记得我啊!', dir: 'right', state: { ZHOUNAN_DEPTH: 1 }, profile: { engagement: 2, nostalgia: 1 } }, - { label: '你是……初三坐我后桌那个……?', dir: 'up', state: { ZHOUNAN_DEPTH: 1 }, profile: { nostalgia: 2 } }, - { label: '(不回复)', dir: 'down', isSilence: true }, - ], - standardReply: '嗯……好久不见。', - stateOnStdReply: { ZHOUNAN_DEPTH: 1 }, - profileOnRead: { nostalgia: 1 }, - profileOnAvoid: { avoidance: 1 }, + choices: { + tap: { label: '嗯……你好。', state: { ZHOUNAN_DEPTH: 1 }, profile: { nostalgia: 1 } }, + right: { label: '周南!你还记得我啊!', state: { ZHOUNAN_DEPTH: 1 }, profile: { engagement: 2, nostalgia: 1 } }, + up: { label: '你是……初三那个……?', state: { ZHOUNAN_DEPTH: 1 }, profile: { nostalgia: 2 } }, + left: { label: '(冷淡)', profile: { avoidance: 1 } }, + down: { label: '(不回复)', isSilence: true }, + }, }, - // ── 13 · 23:45 · 周南 · 4条语音(十年故事)───────────────── + // ── 13 · 周南 · 4条长语音 ── { - id: 13, gameTime: '23:45', triggerAt: gt(23, 45), - type: 'wechat_voice', + id: 13, type: 'wechat_voice', sender: 'zhounan', senderName: '周南', audio: [ `${TDIR}/lv11_2345_zhounan_voice_01_wav_v1.mp3`, @@ -261,56 +203,45 @@ const MESSAGES = [ `${TDIR}/lv11_2345_zhounan_voice_03_wav_v1.mp3`, `${TDIR}/lv11_2345_zhounan_voice_04_wav_v1.mp3`, ], - note: '高考、复读、二本、回老家……今天我生日', bgm: `${MUS}/lv11_bgm_m3_zhounan_v1.mp3`, - detailReplies: [ - { label: '你讲了好多。我都听了。', dir: 'left', state: { ZHOUNAN_DEPTH_ADD: 1 }, profile: { engagement: 2, nostalgia: 1 } }, - { label: '你一个人在北京,还好吗?', dir: 'right', state: { ZHOUNAN_DEPTH_ADD: 1 }, profile: { engagement: 2 } }, - { label: '……生日快乐。', dir: 'up', state: { ZHOUNAN_DEPTH_ADD: 1, ZHOUNAN_SHARE: true }, profile: { nostalgia: 2, engagement: 1 } }, - { label: '(沉默)', dir: 'down', isSilence: true, profile: { nostalgia: 1, avoidance: 1 } }, - ], - standardReply: '……谢谢你告诉我。', - stateOnStdReply: { ZHOUNAN_DEPTH_ADD: 1 }, - profileOnRepeat: { nostalgia: 2 }, - profileOnAvoid: { nostalgia: 1, avoidance: 1 }, + choices: { + tap: { label: '谢谢你告诉我。', state: { ZHOUNAN_DEPTH_ADD: 1 } }, + right: { label: '你在北京还好吗?', state: { ZHOUNAN_DEPTH_ADD: 1 }, profile: { engagement: 2 } }, + up: { label: '……生日快乐。', state: { ZHOUNAN_DEPTH_ADD: 1, ZHOUNAN_SHARE: true }, profile: { nostalgia: 2, engagement: 1 } }, + left: { label: '你讲了好多。', state: { ZHOUNAN_DEPTH_ADD: 1 }, profile: { engagement: 2, nostalgia: 1 } }, + down: { label: '(沉默)', isSilence: true, profile: { nostalgia: 1, avoidance: 1 } }, + }, }, - // ── 14 · 00:15 · 妈妈 · 电话(第二次)───────────────────── + // ── 14 · 妈妈 · 第二次电话 ── { - id: 14, gameTime: '00:15', triggerAt: gt(0, 15), - type: 'call', + id: 14, type: 'call', sender: 'mom', senderName: '妈妈', ringtone: `${MUS}/lv11_ring_mom_v1.mp3`, - ringDuration: 30, audio: [`${TDIR}/lv11_0015_mom_call_01_wav_v1.mp3`], - note: '夏夏,你不接电话我有点担心', stateOnAnswer: { MOM_LINK: true }, - profileOnMiss: { avoidance: 2 }, - profileOnAnswer:{ engagement: 2 }, + profileOnMiss: { avoidance: 2 }, + profileOnAnswer: { engagement: 2 }, }, - // ── 15 · 00:30 · 自己 · 时光胶囊(系统通知)──────────────── + // ── 15 · 时光胶囊 · 系统通知 ── { - id: 15, gameTime: '00:30', triggerAt: gt(0, 30), - type: 'system_notification', + id: 15, type: 'system_notification', sender: 'self', senderName: '时光胶囊', - text: '3年前的今晚,你给自己录过一段备忘录。是否播放?', systemAudio: `${TDIR}/lv11_sys_memo_01_wav_v1.mp3`, audioByProfile: { avoidance: `${TDIR}/lv11_0030_self3y_voice_02_wav_v1.mp3`, engagement: `${TDIR}/lv11_0030_self3y_voice_01_wav_v1.mp3`, nostalgia: `${TDIR}/lv11_0030_self3y_voice_03_wav_v1.mp3`, }, - note: 'self-3y,由PROFILE决定版本', - stateOnPlay: { SELF_RECORD: true }, + stateOnPlay: { SELF_RECORD: true }, profileOnPlay: { nostalgia: 2 }, profileOnSkip: { avoidance: 2 }, }, - // ── 16 · 02:00 · 妈妈 · 长语音4分17秒(结局)────────────── + // ── 16 · 妈妈 · 长语音(结局前奏)── { - id: 16, gameTime: '02:00', triggerAt: gt(2, 0), - type: 'wechat_voice', + id: 16, type: 'wechat_voice', sender: 'mom', senderName: '妈妈', audio: [ `${TDIR}/lv11_0200_mom_voice_01_seg1_wav_v1.mp3`, @@ -322,37 +253,24 @@ const MESSAGES = [ `${TDIR}/lv11_0200_mom_voice_01_seg7_wav_v1.mp3`, `${TDIR}/lv11_0200_mom_voice_01_seg8_wav_v1.mp3`, ], - note: '妈妈讲她24岁那年,结局起点', bgm: `${MUS}/lv11_bgm_m4_ending_v1.mp3`, - sfxAfter: [ - `${SFX}/sfx_night_bus_v1.mp3`, - `${SFX}/sfx_cat_yowl_v1.mp3`, - ], isEnding: true, - requiresMomLink: true, // 必须 MOM_LINK=true 才自动播放 + requiresMomLink: true, }, ]; -// 角色→铃声映射(用于收件箱读出) +// 角色信息 const SENDERS = { - mom: { name: '妈妈', ringtone: `${MUS}/lv11_ring_mom_v1.mp3` }, - azhe: { name: '阿哲', ringtone: `${MUS}/lv11_ring_azhe_v1.mp3` }, - xiaomei: { name: '小美', ringtone: `${MUS}/lv11_ring_xiaomei_v1.mp3` }, - delivery: { name: '外卖小哥', ringtone: `${MUS}/lv11_ring_delivery_v1.mp3` }, - hr: { name: 'HR王姐', ringtone: `${MUS}/lv11_ring_hr_v1.mp3` }, - anan: { name: '安安', ringtone: `${MUS}/lv11_ring_anan_v1.mp3` }, - dorm: { name: '寝室群', ringtone: `${MUS}/lv11_ring_dorm_group_v1.mp3` }, - unknown: { name: '未知号码', ringtone: `${MUS}/lv11_ring_unknown_v1.mp3` }, - zhounan: { name: '周南', ringtone: `${MUS}/lv11_ring_zhounan_v1.mp3` }, - self: { name: '时光胶囊', ringtone: null }, -}; - -const SFX_UI = { - wechat_msg: `${SFX}/sfx_deep_breath_v1.mp3`, // 微信消息提示(用sfx模拟) - keyboard: `${SFX}/sfx_keyboard_slow_v1.mp3`, - chime: `${SFX}/sfx_ending_chime_v1.mp3`, - transition: `${SFX}/sfx_transition_chapter_v1.mp3`, + mom: { name: '妈妈' }, + azhe: { name: '阿哲' }, + xiaomei: { name: '小美' }, + delivery: { name: '外卖小哥' }, + hr: { name: 'HR王姐' }, + anan: { name: '安安' }, + dorm: { name: '寝室群' }, + unknown: { name: '未知号码' }, + zhounan: { name: '周南' }, + self: { name: '时光胶囊' }, }; const AMBIENCE_DEFAULT = `${AMB}/amb_apartment_night_01_v1.mp3`; -const AMBIENCE_RAIN = `${AMB}/amb_rain_window_01_v1.mp3`; diff --git a/game/js/utils.js b/game/js/utils.js index e277616..29a1f93 100644 --- a/game/js/utils.js +++ b/game/js/utils.js @@ -1,4 +1,6 @@ -// utils.js — 轻量工具函数 +// utils.js — 工具函数 + +const DEV = new URLSearchParams(location.search).has('dev'); const sleep = ms => new Promise(r => setTimeout(r, ms)); @@ -7,47 +9,30 @@ function dbg(msg) { if (el && el.style.display !== 'none') el.textContent = msg; } -// 语音合成(选项朗读用) +// TTS — 仅用于朗读文字消息内容,不用于任何 UI 反馈 const TTS = { _voices: [], - _ready: false, - init() { if (!window.speechSynthesis) return; - const load = () => { - this._voices = speechSynthesis.getVoices(); - this._ready = true; - }; + const load = () => { this._voices = speechSynthesis.getVoices(); }; load(); speechSynthesis.onvoiceschanged = load; }, - _pick() { - // 优先选中文语音 - const zh = this._voices.find(v => - v.lang.startsWith('zh') || v.name.includes('Chinese') || v.name.includes('中') - ); - return zh || this._voices[0] || null; + return this._voices.find(v => v.lang.startsWith('zh') || v.name.includes('Chinese')) + || this._voices[0] || null; }, - - speak(text, { rate = 0.85, pitch = 1, volume = 0.85 } = {}) { + speak(text, { rate = 0.85, volume = 0.8 } = {}) { return new Promise(resolve => { if (!window.speechSynthesis) { resolve(); return; } speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance(text); - u.lang = 'zh-CN'; - u.rate = rate; - u.pitch = pitch; - u.volume = volume; + u.lang = 'zh-CN'; u.rate = rate; u.volume = volume; const v = this._pick(); if (v) u.voice = v; - u.onend = resolve; - u.onerror = resolve; + u.onend = resolve; u.onerror = resolve; speechSynthesis.speak(u); }); }, - - stop() { - if (window.speechSynthesis) speechSynthesis.cancel(); - }, + stop() { if (window.speechSynthesis) speechSynthesis.cancel(); }, };