项目整理:重写主文档、归档过时文档、清理死代码、新增音频制作手册
文档整理: - 重写 game/操作说明.md 为项目主文档(6游戏/6手势/旁白/模型生成音频/TTS两步流程) - 归档过时文档至 other_docs/(林夏原始设计、旧执行文档) - 新增 docs/音频制作执行手册.md(从零制作游戏音频的完整流程) - docs/ 下保留旁白音色提示词、音频脚本 代码清理: - 删除旧架构死代码: story.js / gestures.js / profile.js - 删除耦合旧架构的过时测试目录 game/tests/ 脚本配置修正: - check_tts_env.sh: 启动脚本路径 AutoVideo → tts-server(实际位置) - gen_narrator_samples.py: 硬编码地址改为环境变量,默认对齐 tts-server 端口 8000
This commit is contained in:
+136
-798
@@ -1,842 +1,180 @@
|
||||
// engine.js — 游戏引擎 v2(多轮对话 + 林夏语音 + NPC反应)
|
||||
// engine.js — 场景引擎
|
||||
//
|
||||
// 状态流:idle → notification/ringing → playing → choosing
|
||||
// → responding → reacting → [choosing | idle] → …… → ending
|
||||
// 场景数据结构:
|
||||
// narrate : string | fn(state)→string 旁白文本(TTS 兜底)
|
||||
// narrateAudio : string | fn(state)→string 旁白预录 MP3 路径(优先于 TTS)
|
||||
// audio : string[] 角色语音(播放完后才显示选项)
|
||||
// bgm : string | null | undefined undefined=不变 null=停止 string=切换
|
||||
// delay : number 自动跳转前的等待毫秒
|
||||
// tap : { label, narrate, narrateAudio, audio, state, next } 单击操作
|
||||
// doubleTap : { label, narrate, narrateAudio, audio, state, next } 双击操作
|
||||
// next : string | fn(state) | null 无选项时自动跳转(null=结局)
|
||||
//
|
||||
// 新增状态:
|
||||
// responding — 林夏说话中(打字音效 + 语音播放)
|
||||
// reacting — NPC反应播放中
|
||||
// paused — 游戏暂停(两指长按触发)
|
||||
// state/next 可以是函数 fn(currentState)=>值
|
||||
|
||||
class GameEngine {
|
||||
class SceneEngine {
|
||||
constructor() {
|
||||
this._state = 'idle';
|
||||
|
||||
// 剧情变量
|
||||
this.vars = {
|
||||
MOM_LINK: false,
|
||||
HE_BACK: null,
|
||||
GROUP_REPLY: false,
|
||||
ZHOUNAN_DEPTH: 0,
|
||||
ZHOUNAN_SHARE: false,
|
||||
SELF_RECORD: false,
|
||||
UNREAD: 0,
|
||||
REPLY_COUNT: 0,
|
||||
};
|
||||
|
||||
this.profile = new ProfileTracker();
|
||||
this.msgState = {};
|
||||
this.currentMsg = null;
|
||||
|
||||
this._queue = [];
|
||||
this._endingTriggered = false;
|
||||
this._reminderTimer = null;
|
||||
|
||||
// v2: 对话系统
|
||||
this._currentChoices = null; // 当前可用的选项集
|
||||
this._pendingResolve = null; // 用于中断语音播放的 resolve
|
||||
this._skipRequested = false; // 跳过标志
|
||||
|
||||
// 暂停系统
|
||||
this._pausedState = null; // 暂停前的状态
|
||||
|
||||
this.gest = null;
|
||||
this._game = null;
|
||||
this._state = {};
|
||||
this._scene = null;
|
||||
this._waiting = false;
|
||||
this._input = null;
|
||||
this._onEnd = null;
|
||||
}
|
||||
|
||||
// ── 状态 getter/setter ──
|
||||
get state() { return this._state; }
|
||||
set state(s) {
|
||||
this._state = s;
|
||||
dbg(`state: ${s}`);
|
||||
const el = document.getElementById('hint');
|
||||
if (!el) return;
|
||||
const H = {
|
||||
idle: '',
|
||||
notification: '单击 播放 · 双击/左滑 跳过',
|
||||
ringing: '单击/右滑 接听 · 左滑/双击 拒接',
|
||||
playing: '双击 跳过 · 长按 重播',
|
||||
choosing: '单击回应 · →温暖 ←冷淡 ↑追问 ↓沉默 · 双击跳过',
|
||||
responding: '双击 跳过',
|
||||
reacting: '双击 跳过',
|
||||
paused: '单击 继续 · 三指点击 退出',
|
||||
ending: '',
|
||||
};
|
||||
el.textContent = H[s] || '';
|
||||
}
|
||||
// ── 运行游戏 ──
|
||||
async run(game, input, onEnd) {
|
||||
this._game = game;
|
||||
this._state = JSON.parse(JSON.stringify(game.initialState || {}));
|
||||
this._scene = null;
|
||||
this._waiting = false;
|
||||
this._input = input;
|
||||
this._onEnd = onEnd;
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 初始化 & 启动
|
||||
// ══════════════════════════════════════════
|
||||
// 接管输入
|
||||
input.onTap = () => this._tap();
|
||||
input.onDoubleTap = () => this._dt();
|
||||
|
||||
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'))
|
||||
.on('longpress', () => this._onLongPress())
|
||||
.on('twofinger_longpress', () => this._onPause())
|
||||
.on('threefinger_tap', () => this._onExit());
|
||||
}
|
||||
|
||||
async start() {
|
||||
this._queue = [...MESSAGES];
|
||||
|
||||
// 内容预警
|
||||
await this._playVoiceAsync([`${T}/v2_sys_content_warning_wav_v1.mp3`]);
|
||||
await sleep(1000);
|
||||
|
||||
await Snd.startAmbience(AMBIENCE_DEFAULT, { vol: 0.15 });
|
||||
await sleep(1500);
|
||||
Snd.startBGM(`${MUS}/lv11_bgm_m1_main_loop_v1.mp3`, { vol: 0.1 });
|
||||
await sleep(1500);
|
||||
|
||||
this._next();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 语音播放(异步 + 可中断)
|
||||
// ══════════════════════════════════════════
|
||||
|
||||
_playVoiceAsync(urls) {
|
||||
return new Promise(resolve => {
|
||||
this._pendingResolve = resolve;
|
||||
Snd.playVoice(urls, {
|
||||
onEnd: () => {
|
||||
this._pendingResolve = null;
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_interruptVoice() {
|
||||
// 清场
|
||||
Narrator.stop();
|
||||
Snd.stopVoice();
|
||||
TTS.stop();
|
||||
if (this._pendingResolve) {
|
||||
this._pendingResolve();
|
||||
this._pendingResolve = null;
|
||||
}
|
||||
Snd.stopBGM(0);
|
||||
Snd.stopAmbience();
|
||||
|
||||
dbg(`[engine] start ${game.id}`);
|
||||
|
||||
if (game.bgm) Snd.startBGM(game.bgm, { vol: 0.18 });
|
||||
if (game.ambience) Snd.startAmbience(game.ambience, { vol: 0.15 });
|
||||
|
||||
await Narrator.sayAndWait(game.intro);
|
||||
await this._goto(game.first);
|
||||
}
|
||||
|
||||
// 打字 + 发送音效
|
||||
async _playTypingSend() {
|
||||
SFX_UI.typing();
|
||||
await sleep(800);
|
||||
if (this._skipRequested) return;
|
||||
SFX_UI.send();
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 消息队列推进
|
||||
// ══════════════════════════════════════════
|
||||
|
||||
async _next() {
|
||||
if (this._endingTriggered) return;
|
||||
|
||||
const msg = this._queue.shift();
|
||||
if (!msg) {
|
||||
this.triggerFinalEnding();
|
||||
// ── 跳转到场景 ──
|
||||
async _goto(id) {
|
||||
if (id === null || id === undefined) {
|
||||
this._ending();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.requiresMomLink && !this.vars.MOM_LINK) {
|
||||
this.triggerFinalEnding();
|
||||
const scene = this._game.scenes[id];
|
||||
if (!scene) { console.error('[engine] missing scene:', id); return; }
|
||||
|
||||
this._scene = scene;
|
||||
this._waiting = false;
|
||||
Narrator.clearOptions();
|
||||
|
||||
dbg(`[scene] ${id}`);
|
||||
|
||||
// BGM 切换
|
||||
if (scene.bgm !== undefined) {
|
||||
if (scene.bgm) Snd.startBGM(scene.bgm, { vol: 0.18 });
|
||||
else Snd.stopBGM(1500);
|
||||
}
|
||||
|
||||
// 播放音频(等完)
|
||||
const sceneAudio = this._r(scene.audio);
|
||||
if (sceneAudio && sceneAudio.length > 0) {
|
||||
await this._playAudio(sceneAudio);
|
||||
}
|
||||
|
||||
// 旁白描述(等完)— 优先播放预录音频,否则 TTS
|
||||
if (scene.narrateAudio || scene.narrate) {
|
||||
await this._sayNarrate(scene);
|
||||
}
|
||||
|
||||
// 有选项 → 等待输入
|
||||
if (scene.tap || scene.doubleTap) {
|
||||
this._waiting = true;
|
||||
Narrator.options(
|
||||
scene.tap ? scene.tap.label : null,
|
||||
scene.doubleTap ? scene.doubleTap.label : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentMsg = msg;
|
||||
this._currentChoices = msg.choices || null;
|
||||
this.msgState[msg.id] = 'unread';
|
||||
this.vars.UNREAD++;
|
||||
|
||||
if (msg.type === 'call') {
|
||||
this._startRinging(msg);
|
||||
} else {
|
||||
this._notifyMessage(msg);
|
||||
}
|
||||
// 无选项 → 自动跳转
|
||||
if (scene.delay) await new Promise(r => setTimeout(r, scene.delay));
|
||||
await this._goto(this._r(scene.next));
|
||||
}
|
||||
|
||||
_notifyMessage(msg) {
|
||||
this.state = 'notification';
|
||||
Haptic.message();
|
||||
SFX_UI.notify();
|
||||
this._startReminder();
|
||||
}
|
||||
// ── 执行一个操作 ──
|
||||
async _exec(action) {
|
||||
this._waiting = false;
|
||||
Narrator.clearOptions();
|
||||
Narrator.stop(); // 打断正在播报的选项提示
|
||||
|
||||
async _startRinging(msg) {
|
||||
this.state = 'ringing';
|
||||
Haptic.ring();
|
||||
await Snd.startRingtone(msg.ringtone);
|
||||
this._startReminder();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 通话流程(异步)
|
||||
// ══════════════════════════════════════════
|
||||
|
||||
async _answerCall(msg) {
|
||||
Snd.stopRingtone();
|
||||
Haptic.stop();
|
||||
Haptic.confirm();
|
||||
SFX_UI.tap();
|
||||
this._clearReminder();
|
||||
|
||||
this.state = 'playing';
|
||||
this.msgState[msg.id] = 'read';
|
||||
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
|
||||
|
||||
if (msg.stateOnAnswer) this._applyState(msg.stateOnAnswer);
|
||||
if (msg.profileOnAnswer) this.profile.add(msg.profileOnAnswer);
|
||||
|
||||
this._skipRequested = false;
|
||||
|
||||
// 播放通话内容
|
||||
await this._playVoiceAsync(msg.audio);
|
||||
|
||||
// _onDoubleTap 在 'playing' 状态下会直接处理状态转换
|
||||
// 所以如果状态已不是 'playing',说明跳过已被处理,直接返回
|
||||
if (this.state !== 'playing') return;
|
||||
|
||||
// 自动挂断(MSG-08 未知号码)
|
||||
if (msg.autoHangup) {
|
||||
if (msg.sfxAfter) {
|
||||
const sfx = Array.isArray(msg.sfxAfter) ? msg.sfxAfter[0] : msg.sfxAfter;
|
||||
await this._playVoiceAsync([sfx]);
|
||||
if (this.state !== 'playing') return;
|
||||
}
|
||||
this._proceedToNext();
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动回复(MSG-01/03/14 妈妈电话、外卖、妈妈第二次电话)
|
||||
if (msg.autoReply) {
|
||||
await this._playTypingSend();
|
||||
if (this.state !== 'playing') return;
|
||||
|
||||
this.state = 'responding';
|
||||
await this._playVoiceAsync([msg.autoReply]);
|
||||
|
||||
// 条件反应(MSG-14:先播前导语"真的没事?",再根据 MOM_LINK 播放不同反应)
|
||||
if (!this._skipRequested && msg.autoReact) {
|
||||
this.state = 'reacting';
|
||||
// 前导语(如"真的没事?")
|
||||
if (msg.autoReactPreamble) {
|
||||
await sleep(500);
|
||||
if (!this._skipRequested) {
|
||||
await this._playVoiceAsync([msg.autoReactPreamble]);
|
||||
}
|
||||
}
|
||||
// 条件分支反应
|
||||
if (!this._skipRequested) {
|
||||
const val = this.vars[msg.autoReact.condition];
|
||||
const reactUrl = val ? msg.autoReact['true'] : msg.autoReact['false'];
|
||||
if (reactUrl) {
|
||||
await sleep(400);
|
||||
if (!this._skipRequested) {
|
||||
await this._playVoiceAsync([reactUrl]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this._proceedToNext();
|
||||
return;
|
||||
}
|
||||
|
||||
// 通话后有选择(MSG-11 阿哲)
|
||||
if (msg.choices) {
|
||||
this._enterChoosing(msg.choices);
|
||||
return;
|
||||
}
|
||||
|
||||
this._proceedToNext();
|
||||
}
|
||||
|
||||
_rejectCall(msg) {
|
||||
Snd.stopRingtone();
|
||||
Haptic.reject();
|
||||
SFX_UI.skip();
|
||||
this._clearReminder();
|
||||
this.msgState[msg.id] = 'missed';
|
||||
if (msg.profileOnMiss) this.profile.add(msg.profileOnMiss);
|
||||
this._proceedToNext();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 消息播放(异步)
|
||||
// ══════════════════════════════════════════
|
||||
|
||||
async _playMessage(msg) {
|
||||
this._clearReminder();
|
||||
this.state = 'playing';
|
||||
this.msgState[msg.id] = 'read';
|
||||
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
|
||||
|
||||
if (msg.bgm) Snd.startBGM(msg.bgm, { vol: 0.1 });
|
||||
|
||||
// 系统通知(时光胶囊)
|
||||
if (msg.type === 'system_notification') {
|
||||
this._playSystemNotification(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
this._skipRequested = false;
|
||||
|
||||
// 文字消息(TTS 朗读,不可用时播放提示音)
|
||||
if (msg.type === 'wechat_text' && msg.text) {
|
||||
const ok = await TTS.speak(msg.text);
|
||||
if (!ok) {
|
||||
SFX_UI.notify();
|
||||
await sleep(600);
|
||||
}
|
||||
}
|
||||
// 语音消息
|
||||
else if (msg.audio?.length > 0) {
|
||||
await this._playVoiceAsync(msg.audio);
|
||||
}
|
||||
|
||||
if (this._skipRequested) return;
|
||||
if (this.state !== 'playing') return;
|
||||
|
||||
this._afterPlayed(msg);
|
||||
}
|
||||
|
||||
_playSystemNotification(msg) {
|
||||
Snd.playVoice([msg.systemAudio], {
|
||||
onEnd: () => {
|
||||
if (this.state !== 'playing') return;
|
||||
const audio = msg.audioByProfile?.[this.profile.label];
|
||||
if (audio) {
|
||||
if (msg.stateOnPlay) this._applyState(msg.stateOnPlay);
|
||||
if (msg.profileOnPlay) this.profile.add(msg.profileOnPlay);
|
||||
setTimeout(() => {
|
||||
if (this.state !== 'playing') return;
|
||||
Snd.playVoice([audio], {
|
||||
onEnd: () => this._proceedToNext(),
|
||||
});
|
||||
}, 800);
|
||||
} else {
|
||||
this._proceedToNext();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_afterPlayed(msg) {
|
||||
if (this.state !== 'playing') return;
|
||||
|
||||
if (msg.isEnding) {
|
||||
this.triggerFinalEnding();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.choices) {
|
||||
this._enterChoosing(msg.choices);
|
||||
return;
|
||||
}
|
||||
|
||||
this._proceedToNext();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 选择系统(v2 多轮对话核心)
|
||||
// ══════════════════════════════════════════
|
||||
|
||||
_enterChoosing(choices) {
|
||||
this._currentChoices = choices;
|
||||
this.state = 'choosing';
|
||||
SFX_UI.choiceReady();
|
||||
Haptic.confirm();
|
||||
Snd.playVoice([`${T}/v2_sys_wait_reply_wav_v1.mp3`]);
|
||||
this._startReminder();
|
||||
}
|
||||
|
||||
async _handleChoice(dir) {
|
||||
const choices = this._currentChoices;
|
||||
if (!choices) {
|
||||
SFX_UI.error();
|
||||
Haptic.error();
|
||||
return;
|
||||
}
|
||||
|
||||
const choice = choices[dir];
|
||||
if (!choice) {
|
||||
SFX_UI.error();
|
||||
Haptic.error();
|
||||
return;
|
||||
}
|
||||
|
||||
this._clearReminder();
|
||||
this._skipRequested = false;
|
||||
|
||||
// 1. 播放方向对应的情感音效
|
||||
const sfxMap = {
|
||||
tap: 'tap',
|
||||
right: 'choiceWarm',
|
||||
left: 'choiceCold',
|
||||
up: 'choiceCurious',
|
||||
down: 'choiceSilent',
|
||||
};
|
||||
if (sfxMap[dir]) SFX_UI[sfxMap[dir]]();
|
||||
Haptic.select();
|
||||
|
||||
// 2. 应用状态和画像
|
||||
if (!choice.isSilence) {
|
||||
this.msgState[this.currentMsg.id] = 'replied';
|
||||
this.vars.REPLY_COUNT++;
|
||||
}
|
||||
if (choice.state) this._applyState(choice.state);
|
||||
if (choice.profile) this.profile.add(choice.profile);
|
||||
|
||||
// 3. 林夏回应
|
||||
this.state = 'responding';
|
||||
|
||||
if (choice.isSilence) {
|
||||
// 沉默:短暂停顿
|
||||
await sleep(1500);
|
||||
} else if (choice.linxia) {
|
||||
// 打字 → 发送 → 林夏语音
|
||||
await this._playTypingSend();
|
||||
if (!this._skipRequested) {
|
||||
await this._playVoiceAsync([choice.linxia]);
|
||||
}
|
||||
// 更新状态
|
||||
if (action.state) {
|
||||
const updates = this._r(action.state);
|
||||
Object.assign(this._state, updates);
|
||||
}
|
||||
|
||||
if (this._skipRequested) {
|
||||
// 跳过了林夏回应 → 如有 followUp 进入选择,否则跳到下一条
|
||||
if (choice.followUp?.choices) {
|
||||
this._enterChoosing(choice.followUp.choices);
|
||||
return;
|
||||
}
|
||||
this._proceedToNext();
|
||||
return;
|
||||
// 反应音频(支持函数型)
|
||||
const actionAudio = this._r(action.audio);
|
||||
if (actionAudio && actionAudio.length > 0) {
|
||||
await this._playAudio(actionAudio);
|
||||
}
|
||||
|
||||
// 4. NPC 反应
|
||||
if (choice.npcReact?.length > 0) {
|
||||
this.state = 'reacting';
|
||||
await sleep(400);
|
||||
if (!this._skipRequested) {
|
||||
await this._playVoiceAsync(choice.npcReact);
|
||||
}
|
||||
// 反应旁白
|
||||
if (action.narrateAudio || action.narrate) {
|
||||
await this._sayNarrate(action);
|
||||
}
|
||||
|
||||
if (this._skipRequested) {
|
||||
if (choice.followUp?.choices) {
|
||||
this._enterChoosing(choice.followUp.choices);
|
||||
return;
|
||||
}
|
||||
this._proceedToNext();
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 如有后续对话 → 再次进入选择
|
||||
if (choice.followUp?.choices) {
|
||||
await sleep(300);
|
||||
this._enterChoosing(choice.followUp.choices);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. 推进到下一条
|
||||
this._proceedToNext();
|
||||
await this._goto(this._r(action.next));
|
||||
}
|
||||
|
||||
_skipChoice() {
|
||||
this._clearReminder();
|
||||
SFX_UI.skip();
|
||||
Haptic.confirm();
|
||||
this.profile.add({ avoidance: 1 });
|
||||
this._proceedToNext();
|
||||
_tap() {
|
||||
if (!this._waiting || !this._scene?.tap) return;
|
||||
this._exec(this._scene.tap);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 推进到下一条
|
||||
// ══════════════════════════════════════════
|
||||
|
||||
async _proceedToNext() {
|
||||
const prev = this.currentMsg;
|
||||
this.currentMsg = null;
|
||||
this._currentChoices = null;
|
||||
this.state = 'idle';
|
||||
this._clearReminder();
|
||||
|
||||
if (this._endingTriggered) return;
|
||||
|
||||
// 特殊事件:阿哲来了(门铃)
|
||||
if (prev?.id === 11 && this.vars.HE_BACK === true) {
|
||||
await sleep(1500);
|
||||
Snd.playVoice([`${SFX}/sfx_door_knock_gentle_v1.mp3`]);
|
||||
Haptic.ring();
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
// 检查结局D:未读过多
|
||||
const triggered = MESSAGES.length - this._queue.length;
|
||||
if (this.vars.UNREAD >= 8 && triggered >= 14) {
|
||||
this._triggerEnding('D');
|
||||
return;
|
||||
}
|
||||
|
||||
// 短暂停顿后推进
|
||||
SFX_UI.next();
|
||||
await sleep(DEV ? 200 : 2000);
|
||||
this._next();
|
||||
_dt() {
|
||||
if (!this._waiting || !this._scene?.doubleTap) return;
|
||||
this._exec(this._scene.doubleTap);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 提醒定时器
|
||||
// ══════════════════════════════════════════
|
||||
|
||||
_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 === 'paused') {
|
||||
this._onResume();
|
||||
return;
|
||||
}
|
||||
|
||||
if (s === 'ringing') {
|
||||
this._answerCall(this.currentMsg);
|
||||
} else if (s === 'notification') {
|
||||
SFX_UI.tap();
|
||||
Haptic.confirm();
|
||||
this._playMessage(this.currentMsg);
|
||||
} else if (s === 'choosing') {
|
||||
this._handleChoice('tap');
|
||||
} else if (s === 'idle') {
|
||||
SFX_UI.error();
|
||||
Haptic.error();
|
||||
}
|
||||
}
|
||||
|
||||
_onDoubleTap() {
|
||||
const s = this.state;
|
||||
|
||||
if (s === 'paused') return;
|
||||
|
||||
if (s === 'ringing') {
|
||||
this._rejectCall(this.currentMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (s === 'notification') {
|
||||
this._clearReminder();
|
||||
SFX_UI.skip();
|
||||
Haptic.confirm();
|
||||
const msg = this.currentMsg;
|
||||
if (msg?.profileOnSkip) this.profile.add(msg.profileOnSkip);
|
||||
else this.profile.add({ avoidance: 1 });
|
||||
this._proceedToNext();
|
||||
return;
|
||||
}
|
||||
|
||||
if (s === 'playing') {
|
||||
this._skipRequested = true;
|
||||
this._interruptVoice();
|
||||
SFX_UI.skip();
|
||||
Haptic.confirm();
|
||||
const msg = this.currentMsg;
|
||||
if (msg?.isEnding) {
|
||||
this.triggerFinalEnding();
|
||||
} else if (msg?.choices) {
|
||||
this._enterChoosing(msg.choices);
|
||||
} else {
|
||||
this._proceedToNext();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (s === 'responding' || s === 'reacting') {
|
||||
this._skipRequested = true;
|
||||
this._interruptVoice();
|
||||
SFX_UI.skip();
|
||||
Haptic.confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
if (s === 'choosing') {
|
||||
this._skipChoice();
|
||||
return;
|
||||
}
|
||||
|
||||
if (s === 'idle') {
|
||||
SFX_UI.error();
|
||||
Haptic.error();
|
||||
}
|
||||
}
|
||||
|
||||
_onSwipe(dir) {
|
||||
const s = this.state;
|
||||
|
||||
if (s === 'paused') return;
|
||||
|
||||
if (s === 'ringing') {
|
||||
if (dir === 'left') this._rejectCall(this.currentMsg);
|
||||
else if (dir === 'right') this._answerCall(this.currentMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (s === 'notification') {
|
||||
if (dir === 'left') {
|
||||
this._clearReminder();
|
||||
SFX_UI.skip();
|
||||
Haptic.confirm();
|
||||
this.profile.add({ avoidance: 1 });
|
||||
this._proceedToNext();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (s === 'choosing') {
|
||||
this._handleChoice(dir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (s === 'idle') {
|
||||
SFX_UI.error();
|
||||
Haptic.error();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 长按:重播 / 状态广播 ──
|
||||
_onLongPress() {
|
||||
const s = this.state;
|
||||
|
||||
if (s === 'paused' || s === 'ending') return;
|
||||
|
||||
Haptic.confirm();
|
||||
|
||||
// playing/responding/reacting/choosing → 重播当前消息音频
|
||||
if ((s === 'playing' || s === 'responding' || s === 'reacting' || s === 'choosing') && this.currentMsg) {
|
||||
this._interruptVoice();
|
||||
if (this.currentMsg.audio?.length > 0) {
|
||||
this._playVoiceAsync(this.currentMsg.audio);
|
||||
} else if (this.currentMsg.type === 'wechat_text' && this.currentMsg.text) {
|
||||
TTS.speak(this.currentMsg.text).then(ok => { if (!ok) SFX_UI.notify(); });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// idle/notification → 广播未读数
|
||||
TTS.speak(`当前未读${this.vars.UNREAD}条消息`).then(ok => { if (!ok) SFX_UI.error(); });
|
||||
}
|
||||
|
||||
// ── 暂停 ──
|
||||
_onPause() {
|
||||
const s = this.state;
|
||||
if (s === 'ending' || s === 'paused') return;
|
||||
|
||||
this._pausedState = s;
|
||||
this._interruptVoice();
|
||||
this._clearReminder();
|
||||
Haptic.stop();
|
||||
this.state = 'paused';
|
||||
Snd.playVoice([`${T}/v2_sys_paused_wav_v1.mp3`]);
|
||||
}
|
||||
|
||||
// ── 恢复 ──
|
||||
_onResume() {
|
||||
Snd.stopVoice();
|
||||
const prev = this._pausedState || 'idle';
|
||||
this._pausedState = null;
|
||||
|
||||
// 语音无法断点续播,根据暂停前状态决定恢复行为
|
||||
if (prev === 'choosing' && this._currentChoices) {
|
||||
this.state = 'choosing';
|
||||
SFX_UI.choiceReady();
|
||||
Haptic.confirm();
|
||||
this._startReminder();
|
||||
} else if (prev === 'notification' || prev === 'ringing') {
|
||||
this.state = prev;
|
||||
this._startReminder();
|
||||
} else {
|
||||
// playing/responding/reacting → 无法续播,推进到下一条或回到选择
|
||||
if (this._currentChoices) {
|
||||
this._enterChoosing(this._currentChoices);
|
||||
} else {
|
||||
this._proceedToNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 退出 ──
|
||||
_onExit() {
|
||||
if (this.state !== 'paused') return;
|
||||
|
||||
this._endingTriggered = true;
|
||||
this._interruptVoice();
|
||||
// 退出不需要特殊音频,直接返回开始页
|
||||
setTimeout(() => {
|
||||
const startEl = document.getElementById('start');
|
||||
if (startEl) {
|
||||
startEl.style.opacity = '1';
|
||||
startEl.style.display = 'flex';
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 结局系统
|
||||
// ══════════════════════════════════════════
|
||||
|
||||
async triggerFinalEnding() {
|
||||
if (this._endingTriggered) return;
|
||||
|
||||
const v = this.vars;
|
||||
|
||||
// 结局E(隐藏)— 优先级 1
|
||||
if (v.ZHOUNAN_DEPTH >= 2 && v.ZHOUNAN_SHARE &&
|
||||
(this.profile.label === 'engagement' || this.profile.label === 'nostalgia')) {
|
||||
return this._triggerEnding('E');
|
||||
}
|
||||
|
||||
// 结局A:妈妈连接 + 自己录音 — 优先级 2
|
||||
if (v.MOM_LINK && v.SELF_RECORD) {
|
||||
return this._triggerEnding('A');
|
||||
}
|
||||
|
||||
// 结局B:拒了阿哲 + 回了周南 — 优先级 3
|
||||
if (v.HE_BACK === false && v.ZHOUNAN_DEPTH >= 1) {
|
||||
return this._triggerEnding('B');
|
||||
}
|
||||
|
||||
// 结局C:全部已读但极少回复 — 优先级 4
|
||||
const read = Object.values(this.msgState).filter(s => s !== 'unread').length;
|
||||
if (read >= MESSAGES.length - 2 && v.REPLY_COUNT <= 2) {
|
||||
return this._triggerEnding('C');
|
||||
}
|
||||
|
||||
// 结局D(兜底)— 优先级 5
|
||||
this._triggerEnding('D');
|
||||
}
|
||||
|
||||
async _triggerEnding(type) {
|
||||
if (this._endingTriggered) return;
|
||||
this._endingTriggered = true;
|
||||
|
||||
this.state = 'ending';
|
||||
this._clearReminder();
|
||||
this._interruptVoice();
|
||||
Snd.stopRingtone();
|
||||
// ── 结局 ──
|
||||
async _ending() {
|
||||
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();
|
||||
|
||||
// 结局后播报心理支持热线
|
||||
await sleep(3000);
|
||||
await this._playVoiceAsync([`${T}/v2_sys_hotline_wav_v1.mp3`]);
|
||||
}
|
||||
|
||||
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);
|
||||
Snd.stopAmbience();
|
||||
const id = this._game.getEnding(this._state);
|
||||
const e = this._game.endings?.[id];
|
||||
dbg(`[ending] ${id}`);
|
||||
SFX_UI.ending();
|
||||
this._showCaption('明天再说。');
|
||||
await this._playVoiceAsync([`${T}/v2_ending_a_linxia_wav_v1.mp3`]);
|
||||
if (e) {
|
||||
await this._sayNarrate(e);
|
||||
} else {
|
||||
await Narrator.sayAndWait('游戏结束。');
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
await Narrator.sayAndWait('感谢体验。');
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
if (this._onEnd) this._onEnd();
|
||||
}
|
||||
|
||||
async _endingB() {
|
||||
await sleep(2000);
|
||||
SFX_UI.ending();
|
||||
await sleep(1000);
|
||||
this._showCaption('寄出去了。');
|
||||
await this._playVoiceAsync([`${T}/v2_ending_b_linxia_wav_v1.mp3`]);
|
||||
// ── 旁白:优先预录 MP3,兜底 TTS ──
|
||||
_sayNarrate(node) {
|
||||
const audioPath = this._r(node.narrateAudio);
|
||||
if (audioPath) {
|
||||
return this._playAudio([audioPath]);
|
||||
}
|
||||
const text = this._r(node.narrate);
|
||||
if (text) return Narrator.sayAndWait(text);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async _endingC() {
|
||||
await sleep(3000);
|
||||
SFX_UI.ending();
|
||||
await sleep(1000);
|
||||
this._showCaption('今晚就这样。');
|
||||
await this._playVoiceAsync([`${T}/v2_ending_c_linxia_wav_v1.mp3`]);
|
||||
// ── 播放多段音频(顺序,等完)──
|
||||
_playAudio(urls) {
|
||||
return new Promise(resolve => {
|
||||
Snd.playVoice(urls, { onEnd: resolve });
|
||||
});
|
||||
}
|
||||
|
||||
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('早上好。');
|
||||
await this._playVoiceAsync([`${T}/v2_ending_d_linxia_wav_v1.mp3`]);
|
||||
}
|
||||
|
||||
async _endingE() {
|
||||
Haptic.message();
|
||||
SFX_UI.notify();
|
||||
await sleep(1500);
|
||||
// 周南秒回:"那我下次出差来北京,请你吃饭好不好。"
|
||||
await this._playVoiceAsync([`${T}/v2_ending_e_zhounan_wav_v1.mp3`]);
|
||||
await sleep(2000);
|
||||
SFX_UI.ending();
|
||||
await sleep(1000);
|
||||
this._showCaption('好。');
|
||||
await this._playVoiceAsync([`${T}/v2_ending_e_linxia_wav_v1.mp3`]);
|
||||
}
|
||||
|
||||
_showCaption(text) {
|
||||
const el = document.getElementById('caption');
|
||||
el.textContent = text;
|
||||
el.style.display = 'flex';
|
||||
// ── 解析 string | fn(state) ──
|
||||
_r(val) {
|
||||
return typeof val === 'function' ? val(this._state) : val;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user