573 lines
16 KiB
JavaScript
573 lines
16 KiB
JavaScript
// engine.js — 游戏引擎(玩家驱动,无时间依赖)
|
|
//
|
|
// 状态流:idle → notification/ringing → playing → choosing → idle → …… → ending
|
|
// 所有 UI 反馈使用合成短音效(SFX_UI),不使用 TTS 语音播报
|
|
|
|
class GameEngine {
|
|
constructor() {
|
|
// 状态(通过 setter 自动更新提示栏)
|
|
this._state = 'idle';
|
|
|
|
// 剧情变量
|
|
this.vars = {
|
|
MOM_LINK: false,
|
|
HE_BACK: null, // null=未定, true=来了, false=拒了
|
|
GROUP_REPLY: false,
|
|
ZHOUNAN_DEPTH: 0, // 0-3
|
|
ZHOUNAN_SHARE: false,
|
|
SELF_RECORD: false,
|
|
UNREAD: 0,
|
|
};
|
|
|
|
this.profile = new ProfileTracker();
|
|
this.msgState = {}; // msgId → 'unread'|'read'|'replied'|'missed'
|
|
this.currentMsg = null;
|
|
|
|
this._queue = []; // 消息队列
|
|
this._endingTriggered = false;
|
|
this._reminderTimer = null;
|
|
|
|
this.gest = null;
|
|
}
|
|
|
|
// ── 状态 getter/setter ──
|
|
get state() { return this._state; }
|
|
set state(s) {
|
|
this._state = s;
|
|
dbg(`state: ${s}`);
|
|
const el = document.getElementById('hint');
|
|
if (!el) return;
|
|
const H = {
|
|
idle: '',
|
|
notification: '单击 播放 · 双击 跳过',
|
|
ringing: '单击 接听 · 左滑 拒接',
|
|
playing: '双击 跳过',
|
|
choosing: '→温暖 ←冷淡 ↑追问 ↓沉默',
|
|
ending: '',
|
|
};
|
|
el.textContent = H[s] || '';
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 初始化 & 启动
|
|
// ══════════════════════════════════════════
|
|
|
|
init(gestureDetector) {
|
|
this.gest = gestureDetector;
|
|
this.gest
|
|
.on('singletap', () => this._onTap())
|
|
.on('doubletap', () => this._onDoubleTap())
|
|
.on('swipe_left', () => this._onSwipe('left'))
|
|
.on('swipe_right', () => this._onSwipe('right'))
|
|
.on('swipe_up', () => this._onSwipe('up'))
|
|
.on('swipe_down', () => this._onSwipe('down'));
|
|
}
|
|
|
|
async start() {
|
|
this._queue = [...MESSAGES];
|
|
|
|
// 环境音 + BGM
|
|
await Snd.startAmbience(AMBIENCE_DEFAULT, { vol: 0.15 });
|
|
await sleep(1500);
|
|
Snd.startBGM(`${MUS}/lv11_bgm_m1_main_loop_v1.mp3`, { vol: 0.2 });
|
|
await sleep(1500);
|
|
|
|
// 开始第一条消息
|
|
this._next();
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 消息队列推进
|
|
// ══════════════════════════════════════════
|
|
|
|
async _next() {
|
|
if (this._endingTriggered) return;
|
|
|
|
const msg = this._queue.shift();
|
|
if (!msg) {
|
|
this.triggerFinalEnding();
|
|
return;
|
|
}
|
|
|
|
// 消息16需要 MOM_LINK
|
|
if (msg.requiresMomLink && !this.vars.MOM_LINK) {
|
|
this.triggerFinalEnding();
|
|
return;
|
|
}
|
|
|
|
this.currentMsg = msg;
|
|
this.msgState[msg.id] = 'unread';
|
|
this.vars.UNREAD++;
|
|
|
|
if (msg.type === 'call') {
|
|
this._startRinging(msg);
|
|
} else {
|
|
this._notifyMessage(msg);
|
|
}
|
|
}
|
|
|
|
// ── 非通话消息通知 ──
|
|
_notifyMessage(msg) {
|
|
this.state = 'notification';
|
|
Haptic.message();
|
|
SFX_UI.notify();
|
|
this._startReminder();
|
|
}
|
|
|
|
// ── 来电铃声 ──
|
|
async _startRinging(msg) {
|
|
this.state = 'ringing';
|
|
Haptic.ring();
|
|
await Snd.startRingtone(msg.ringtone);
|
|
this._startReminder();
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 通话流程
|
|
// ══════════════════════════════════════════
|
|
|
|
_answerCall(msg) {
|
|
Snd.stopRingtone();
|
|
Haptic.stop();
|
|
Haptic.confirm();
|
|
SFX_UI.tap();
|
|
this._clearReminder();
|
|
|
|
this.state = 'playing';
|
|
this.msgState[msg.id] = 'read';
|
|
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
|
|
|
|
if (msg.stateOnAnswer) this._applyState(msg.stateOnAnswer);
|
|
if (msg.profileOnAnswer) this.profile.add(msg.profileOnAnswer);
|
|
|
|
Snd.playVoice(msg.audio, {
|
|
onEnd: () => {
|
|
if (this.state !== 'playing') return; // 已被跳过
|
|
if (msg.autoHangup && msg.sfxAfter) {
|
|
const sfx = Array.isArray(msg.sfxAfter) ? msg.sfxAfter[0] : msg.sfxAfter;
|
|
Snd.playVoice([sfx], { onEnd: () => this._proceedToNext() });
|
|
} else if (msg.choices) {
|
|
this.state = 'choosing';
|
|
SFX_UI.choiceReady();
|
|
Haptic.confirm();
|
|
this._startReminder();
|
|
} else {
|
|
this._proceedToNext();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
_rejectCall(msg) {
|
|
Snd.stopRingtone();
|
|
Haptic.reject();
|
|
SFX_UI.skip();
|
|
this._clearReminder();
|
|
this.msgState[msg.id] = 'missed';
|
|
if (msg.profileOnMiss) this.profile.add(msg.profileOnMiss);
|
|
this._proceedToNext();
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 消息播放
|
|
// ══════════════════════════════════════════
|
|
|
|
async _playMessage(msg) {
|
|
this._clearReminder();
|
|
this.state = 'playing';
|
|
this.msgState[msg.id] = 'read';
|
|
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
|
|
|
|
if (msg.bgm) Snd.startBGM(msg.bgm, { vol: 0.2 });
|
|
|
|
// 系统通知(时光胶囊)
|
|
if (msg.type === 'system_notification') {
|
|
this._playSystemNotification(msg);
|
|
return;
|
|
}
|
|
|
|
// 文字消息(用 TTS 朗读内容)
|
|
if (msg.type === 'wechat_text' && msg.text && (!msg.audio || msg.audio.length === 0)) {
|
|
TTS.speak(msg.text).then(() => {
|
|
if (this.state === 'playing') this._afterPlayed(msg);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 语音消息
|
|
if (msg.audio && msg.audio.length > 0) {
|
|
Snd.playVoice(msg.audio, {
|
|
onEnd: () => {
|
|
if (this.state === 'playing') this._afterPlayed(msg);
|
|
}
|
|
});
|
|
} else {
|
|
this._afterPlayed(msg);
|
|
}
|
|
}
|
|
|
|
// ── 系统通知特殊处理 ──
|
|
_playSystemNotification(msg) {
|
|
Snd.playVoice([msg.systemAudio], {
|
|
onEnd: () => {
|
|
if (this.state !== 'playing') return;
|
|
// 根据画像播放对应版本
|
|
const audio = msg.audioByProfile?.[this.profile.label];
|
|
if (audio) {
|
|
if (msg.stateOnPlay) this._applyState(msg.stateOnPlay);
|
|
if (msg.profileOnPlay) this.profile.add(msg.profileOnPlay);
|
|
setTimeout(() => {
|
|
if (this.state !== 'playing') return;
|
|
Snd.playVoice([audio], {
|
|
onEnd: () => this._proceedToNext()
|
|
});
|
|
}, 800);
|
|
} else {
|
|
this._proceedToNext();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 消息播放完成 ──
|
|
_afterPlayed(msg) {
|
|
if (this.state !== 'playing') return; // 防止重复调用
|
|
|
|
if (msg.isEnding) {
|
|
this.triggerFinalEnding();
|
|
return;
|
|
}
|
|
|
|
if (msg.choices) {
|
|
this.state = 'choosing';
|
|
SFX_UI.choiceReady();
|
|
Haptic.confirm();
|
|
this._startReminder();
|
|
} else {
|
|
this._proceedToNext();
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 选择回复
|
|
// ══════════════════════════════════════════
|
|
|
|
_choose(dir) {
|
|
const msg = this.currentMsg;
|
|
if (!msg?.choices) {
|
|
SFX_UI.error();
|
|
Haptic.error();
|
|
return;
|
|
}
|
|
|
|
const choice = msg.choices[dir];
|
|
if (!choice) {
|
|
SFX_UI.error();
|
|
Haptic.error();
|
|
return;
|
|
}
|
|
|
|
this._clearReminder();
|
|
|
|
// 播放方向对应的情感音效
|
|
const sfxMap = {
|
|
tap: 'tap',
|
|
right: 'choiceWarm',
|
|
left: 'choiceCold',
|
|
up: 'choiceCurious',
|
|
down: 'choiceSilent',
|
|
};
|
|
if (sfxMap[dir]) SFX_UI[sfxMap[dir]]();
|
|
Haptic.select();
|
|
|
|
if (!choice.isSilence) {
|
|
this.msgState[msg.id] = 'replied';
|
|
}
|
|
|
|
if (choice.state) this._applyState(choice.state);
|
|
if (choice.profile) this.profile.add(choice.profile);
|
|
|
|
this._proceedToNext();
|
|
}
|
|
|
|
_skipChoice() {
|
|
this._clearReminder();
|
|
SFX_UI.skip();
|
|
Haptic.confirm();
|
|
this.profile.add({ avoidance: 1 });
|
|
this._proceedToNext();
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 推进到下一条
|
|
// ══════════════════════════════════════════
|
|
|
|
async _proceedToNext() {
|
|
const prev = this.currentMsg;
|
|
this.currentMsg = null;
|
|
this.state = 'idle';
|
|
this._clearReminder();
|
|
|
|
if (this._endingTriggered) return;
|
|
|
|
// 特殊事件:阿哲来了(门铃)
|
|
if (prev?.id === 11 && this.vars.HE_BACK === true) {
|
|
await sleep(1500);
|
|
Snd.playVoice([`${SFX}/sfx_door_knock_gentle_v1.mp3`]);
|
|
Haptic.ring();
|
|
await sleep(3000);
|
|
}
|
|
|
|
// 检查结局D:未读过多(14条消息处理后检查)
|
|
const triggered = MESSAGES.length - this._queue.length;
|
|
if (this.vars.UNREAD >= 8 && triggered >= 14) {
|
|
this._triggerEnding('D');
|
|
return;
|
|
}
|
|
|
|
// 短暂停顿后推进
|
|
SFX_UI.next();
|
|
await sleep(DEV ? 200 : 2000);
|
|
this._next();
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 提醒定时器(玩家不操作时重复提示音)
|
|
// ══════════════════════════════════════════
|
|
|
|
_startReminder() {
|
|
this._clearReminder();
|
|
this._reminderTimer = setInterval(() => {
|
|
if (this.state === 'notification') {
|
|
SFX_UI.notify();
|
|
Haptic.message();
|
|
} else if (this.state === 'choosing') {
|
|
SFX_UI.choiceReady();
|
|
Haptic.confirm();
|
|
} else if (this.state === 'ringing') {
|
|
Haptic.ring();
|
|
} else {
|
|
this._clearReminder();
|
|
}
|
|
}, 8000);
|
|
}
|
|
|
|
_clearReminder() {
|
|
if (this._reminderTimer) {
|
|
clearInterval(this._reminderTimer);
|
|
this._reminderTimer = null;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 状态变量
|
|
// ══════════════════════════════════════════
|
|
|
|
_applyState(changes) {
|
|
for (const [k, v] of Object.entries(changes)) {
|
|
if (k === 'ZHOUNAN_DEPTH_ADD') {
|
|
this.vars.ZHOUNAN_DEPTH += (v || 1);
|
|
} else if (v !== null) {
|
|
this.vars[k] = v;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 手势处理
|
|
// ══════════════════════════════════════════
|
|
|
|
_onTap() {
|
|
const s = this.state;
|
|
if (s === 'ringing') this._answerCall(this.currentMsg);
|
|
else if (s === 'notification') {
|
|
SFX_UI.tap();
|
|
Haptic.confirm();
|
|
this._playMessage(this.currentMsg);
|
|
}
|
|
else if (s === 'choosing') this._choose('tap');
|
|
}
|
|
|
|
_onDoubleTap() {
|
|
const s = this.state;
|
|
if (s === 'ringing') {
|
|
// 双击来电 = 拒接
|
|
this._rejectCall(this.currentMsg);
|
|
}
|
|
else if (s === 'notification') {
|
|
// 跳过消息
|
|
this._clearReminder();
|
|
SFX_UI.skip();
|
|
Haptic.confirm();
|
|
const msg = this.currentMsg;
|
|
if (msg?.profileOnSkip) this.profile.add(msg.profileOnSkip);
|
|
else this.profile.add({ avoidance: 1 });
|
|
this._proceedToNext();
|
|
}
|
|
else if (s === 'playing') {
|
|
// 跳过当前播放
|
|
Snd.stopVoice();
|
|
TTS.stop();
|
|
SFX_UI.skip();
|
|
Haptic.confirm();
|
|
const msg = this.currentMsg;
|
|
// 直接进入后续流程
|
|
if (msg?.isEnding) {
|
|
this.triggerFinalEnding();
|
|
} else if (msg?.choices) {
|
|
this.state = 'choosing';
|
|
SFX_UI.choiceReady();
|
|
Haptic.confirm();
|
|
this._startReminder();
|
|
} else {
|
|
this._proceedToNext();
|
|
}
|
|
}
|
|
else if (s === 'choosing') {
|
|
this._skipChoice();
|
|
}
|
|
}
|
|
|
|
_onSwipe(dir) {
|
|
const s = this.state;
|
|
|
|
if (s === 'ringing') {
|
|
if (dir === 'left') this._rejectCall(this.currentMsg);
|
|
else if (dir === 'right') this._answerCall(this.currentMsg);
|
|
return;
|
|
}
|
|
|
|
if (s === 'notification') {
|
|
if (dir === 'left') {
|
|
// 左滑忽略消息
|
|
this._clearReminder();
|
|
SFX_UI.skip();
|
|
Haptic.confirm();
|
|
this.profile.add({ avoidance: 1 });
|
|
this._proceedToNext();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (s === 'choosing') {
|
|
this._choose(dir);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════
|
|
// 结局系统
|
|
// ══════════════════════════════════════════
|
|
|
|
async triggerFinalEnding() {
|
|
if (this._endingTriggered) return;
|
|
|
|
const v = this.vars;
|
|
|
|
// 结局E(隐藏)
|
|
if (v.ZHOUNAN_DEPTH >= 2 && v.ZHOUNAN_SHARE &&
|
|
(this.profile.label === 'engagement' || this.profile.label === 'nostalgia')) {
|
|
return this._triggerEnding('E');
|
|
}
|
|
|
|
// 结局A:妈妈连接 + 自己录音
|
|
if (v.MOM_LINK && v.SELF_RECORD) {
|
|
return this._triggerEnding('A');
|
|
}
|
|
|
|
// 结局B:拒了阿哲 + 回了周南
|
|
if (v.HE_BACK === false && v.ZHOUNAN_DEPTH >= 1) {
|
|
return this._triggerEnding('B');
|
|
}
|
|
|
|
// 结局C:全部已读但极少回复
|
|
const replied = Object.values(this.msgState).filter(s => s === 'replied').length;
|
|
const read = Object.values(this.msgState).filter(s => s !== 'unread').length;
|
|
if (read >= MESSAGES.length - 2 && replied <= 2) {
|
|
return this._triggerEnding('C');
|
|
}
|
|
|
|
// 结局D(兜底)
|
|
this._triggerEnding('D');
|
|
}
|
|
|
|
async _triggerEnding(type) {
|
|
if (this._endingTriggered) return;
|
|
this._endingTriggered = true;
|
|
|
|
this.state = 'ending';
|
|
this._clearReminder();
|
|
Snd.stopVoice();
|
|
Snd.stopRingtone();
|
|
TTS.stop();
|
|
Snd.stopBGM(3000);
|
|
await sleep(2000);
|
|
|
|
dbg(`ending: ${type}`);
|
|
|
|
const fn = {
|
|
A: () => this._endingA(),
|
|
B: () => this._endingB(),
|
|
C: () => this._endingC(),
|
|
D: () => this._endingD(),
|
|
E: () => this._endingE(),
|
|
}[type];
|
|
if (fn) await fn();
|
|
}
|
|
|
|
// ── 结局A:明天再说 ──
|
|
async _endingA() {
|
|
Snd.startAmbience(`${AMB}/amb_kitchen_morning_v1.mp3`, { vol: 0.3 });
|
|
await sleep(2000);
|
|
Snd.playVoice([`${SFX}/sfx_kettle_whistle_v1.mp3`]);
|
|
await sleep(4000);
|
|
SFX_UI.ending();
|
|
this._showCaption('明天再说。');
|
|
}
|
|
|
|
// ── 结局B:寄出 ──
|
|
async _endingB() {
|
|
await sleep(2000);
|
|
SFX_UI.ending();
|
|
await sleep(1000);
|
|
this._showCaption('寄出。');
|
|
}
|
|
|
|
// ── 结局C:今晚就这样 ──
|
|
async _endingC() {
|
|
await sleep(3000);
|
|
SFX_UI.ending();
|
|
await sleep(1000);
|
|
this._showCaption('今晚就这样。');
|
|
}
|
|
|
|
// ── 结局D:早上好 ──
|
|
async _endingD() {
|
|
Haptic.system();
|
|
await sleep(2000);
|
|
Haptic.ring();
|
|
await sleep(4000);
|
|
Snd.startAmbience(`${AMB}/amb_dawn_birds_v1.mp3`, { vol: 0.4 });
|
|
await sleep(4000);
|
|
this._showCaption('早上好。');
|
|
}
|
|
|
|
// ── 结局E(隐藏):好 ──
|
|
async _endingE() {
|
|
Haptic.message();
|
|
SFX_UI.notify();
|
|
await sleep(2000);
|
|
Snd.playVoice([`${TDIR}/lv11_sys_goodnight_wav_v1.mp3`]);
|
|
await sleep(3000);
|
|
SFX_UI.ending();
|
|
await sleep(1000);
|
|
this._showCaption('好。');
|
|
}
|
|
|
|
_showCaption(text) {
|
|
const el = document.getElementById('caption');
|
|
el.textContent = text;
|
|
el.style.display = 'flex';
|
|
}
|
|
}
|