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(); },
};