save code
This commit is contained in:
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
<meta name="theme-color" content="#000000">
|
||||||
|
<title>林夏</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html,body{
|
||||||
|
width:100%;height:100%;background:#000;
|
||||||
|
overflow:hidden;touch-action:none;
|
||||||
|
-webkit-tap-highlight-color:transparent;
|
||||||
|
user-select:none;-webkit-user-select:none;
|
||||||
|
font-family:-apple-system,'PingFang SC','Hiragino Sans GB',sans-serif;
|
||||||
|
}
|
||||||
|
#game{position:fixed;inset:0;background:#000;}
|
||||||
|
|
||||||
|
/* ── 开始界面 ── */
|
||||||
|
#start{
|
||||||
|
position:fixed;inset:0;background:#000;z-index:80;
|
||||||
|
display:flex;flex-direction:column;align-items:center;
|
||||||
|
color:#fff;text-align:center;padding:40px 32px 48px;
|
||||||
|
overflow-y:auto;-webkit-overflow-scrolling:touch;
|
||||||
|
}
|
||||||
|
#start h1{font-size:38px;font-weight:200;letter-spacing:14px;margin-top:48px;margin-bottom:28px}
|
||||||
|
#start .sub{font-size:14px;color:#777;line-height:2.4}
|
||||||
|
#start .hint{margin-top:40px;font-size:12px;color:#444;animation:pulse 2.5s ease-in-out infinite}
|
||||||
|
#start .tip{margin-top:16px;font-size:11px;color:#2a2a2a;line-height:1.9}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.2}}
|
||||||
|
|
||||||
|
/* ── 操作说明 ── */
|
||||||
|
#controls{
|
||||||
|
margin-top:44px;width:100%;max-width:320px;text-align:left;
|
||||||
|
}
|
||||||
|
#controls .ctrl-title{
|
||||||
|
font-size:10px;letter-spacing:4px;color:#333;text-align:center;margin-bottom:20px;
|
||||||
|
}
|
||||||
|
#controls .ctrl-group{
|
||||||
|
margin-bottom:20px;
|
||||||
|
}
|
||||||
|
#controls .ctrl-scene{
|
||||||
|
font-size:10px;letter-spacing:2px;color:#3a3a3a;margin-bottom:8px;padding-bottom:6px;
|
||||||
|
border-bottom:1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
#controls .ctrl-row{
|
||||||
|
display:flex;justify-content:space-between;align-items:baseline;
|
||||||
|
padding:5px 0;border-bottom:1px solid #111;
|
||||||
|
}
|
||||||
|
#controls .ctrl-row:last-child{border-bottom:none;}
|
||||||
|
#controls .ctrl-gesture{font-size:11px;color:#555;white-space:nowrap;flex-shrink:0;}
|
||||||
|
#controls .ctrl-action{font-size:11px;color:#2e2e2e;text-align:right;margin-left:12px;}
|
||||||
|
|
||||||
|
/* ── 字幕层(结局文字)── */
|
||||||
|
#caption{
|
||||||
|
position:fixed;inset:0;z-index:60;
|
||||||
|
display:none;align-items:center;justify-content:center;
|
||||||
|
color:#fff;font-size:18px;font-weight:300;letter-spacing:4px;
|
||||||
|
text-align:center;line-height:2;padding:40px;
|
||||||
|
animation:fadein .8s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes fadein{from{opacity:0}to{opacity:1}}
|
||||||
|
|
||||||
|
/* ── 操作提示栏 ── */
|
||||||
|
#hint{
|
||||||
|
position:fixed;bottom:0;left:0;right:0;z-index:70;
|
||||||
|
padding:12px 24px 20px;
|
||||||
|
text-align:center;
|
||||||
|
font-size:11px;letter-spacing:1.5px;color:#444;
|
||||||
|
background:linear-gradient(transparent,rgba(0,0,0,0.85));
|
||||||
|
pointer-events:none;
|
||||||
|
min-height:52px;
|
||||||
|
display:flex;align-items:flex-end;justify-content:center;
|
||||||
|
transition:opacity 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 调试条 ── */
|
||||||
|
#dbg{
|
||||||
|
display:none;
|
||||||
|
position:fixed;bottom:30px;left:0;right:0;text-align:center;
|
||||||
|
font-size:11px;color:#333;z-index:200;pointer-events:none;font-family:monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="game"></div>
|
||||||
|
|
||||||
|
<div id="start">
|
||||||
|
<h1>林夏</h1>
|
||||||
|
<div class="sub">
|
||||||
|
今晚 16 条消息<br>
|
||||||
|
你接谁,不接谁<br>
|
||||||
|
决定明天的自己
|
||||||
|
</div>
|
||||||
|
<div id="controls">
|
||||||
|
<div class="ctrl-title">操 作 说 明</div>
|
||||||
|
|
||||||
|
<div class="ctrl-group">
|
||||||
|
<div class="ctrl-scene">来电时</div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">单击 / 右滑</span><span class="ctrl-action">接听</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">左滑 / 双击</span><span class="ctrl-action">拒接</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ctrl-group">
|
||||||
|
<div class="ctrl-scene">收到消息</div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">单击</span><span class="ctrl-action">播放</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">双击 / 左滑</span><span class="ctrl-action">跳过</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ctrl-group">
|
||||||
|
<div class="ctrl-scene">选择回复</div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">单击</span><span class="ctrl-action">简单回应</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">右滑</span><span class="ctrl-action">温暖回应</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">左滑</span><span class="ctrl-action">冷淡回应</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">上滑</span><span class="ctrl-action">追问</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">下滑</span><span class="ctrl-action">沉默</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">双击</span><span class="ctrl-action">不回复</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ctrl-group">
|
||||||
|
<div class="ctrl-scene">播放中</div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">双击</span><span class="ctrl-action">跳过</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">长按</span><span class="ctrl-action">重播</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ctrl-group">
|
||||||
|
<div class="ctrl-scene">随时可用</div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">两指长按</span><span class="ctrl-action">暂停</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">三指点击</span><span class="ctrl-action">退出(暂停后)</span></div>
|
||||||
|
<div class="ctrl-row"><span class="ctrl-gesture">长按</span><span class="ctrl-action">播报未读数</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint">· 轻触屏幕开始 ·</div>
|
||||||
|
<div class="tip">
|
||||||
|
建议关灯 · 戴上耳机<br>
|
||||||
|
按你的节奏来
|
||||||
|
</div>
|
||||||
|
<div class="tip" style="margin-top:20px;color:#333;">
|
||||||
|
本游戏涉及孤独、家庭关系等情感话题<br>
|
||||||
|
请在舒适的状态下体验
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="caption"></div>
|
||||||
|
<div id="hint"></div>
|
||||||
|
<div id="dbg"></div>
|
||||||
|
|
||||||
|
<script src="js/utils.js"></script>
|
||||||
|
<script src="js/haptics.js"></script>
|
||||||
|
<script src="js/audio.js"></script>
|
||||||
|
<script src="js/gestures.js"></script>
|
||||||
|
<script src="js/profile.js"></script>
|
||||||
|
<script src="js/story.js"></script>
|
||||||
|
<script src="js/engine.js"></script>
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
// audio.js — 音频管理器 + 合成 UI 音效
|
||||||
|
|
||||||
|
class AudioManager {
|
||||||
|
constructor() {
|
||||||
|
this._ctx = null;
|
||||||
|
this._master = null;
|
||||||
|
this._voiceQueue = [];
|
||||||
|
this._voiceEl = null;
|
||||||
|
this._ringEl = null;
|
||||||
|
this._bgmEl = null;
|
||||||
|
this._ambEl = null;
|
||||||
|
this._preloaded = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 初始化(必须在用户手势后调用)──
|
||||||
|
async init() {
|
||||||
|
this._ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (this._ctx.state === 'suspended') await this._ctx.resume();
|
||||||
|
this._master = this._ctx.createGain();
|
||||||
|
this._master.gain.value = 1.0;
|
||||||
|
this._master.connect(this._ctx.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 合成短音(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) {
|
||||||
|
console.warn('[Audio] preload failed:', key, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 播放预加载音效 ──
|
||||||
|
playFX(key, { vol = 1.0, loop = false } = {}) {
|
||||||
|
const buf = this._preloaded[key];
|
||||||
|
if (!buf) return null;
|
||||||
|
const src = this._ctx.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
src.loop = loop;
|
||||||
|
const gain = this._ctx.createGain();
|
||||||
|
gain.gain.value = vol;
|
||||||
|
src.connect(gain);
|
||||||
|
gain.connect(this._master);
|
||||||
|
src.start();
|
||||||
|
return { src, gain, stop: () => { try { src.stop(); } catch (_) {} } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 铃声(循环)──
|
||||||
|
async startRingtone(url) {
|
||||||
|
this.stopRingtone();
|
||||||
|
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 error', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
stopRingtone() {
|
||||||
|
if (this._ringEl) {
|
||||||
|
this._ringEl.pause();
|
||||||
|
this._ringEl.src = '';
|
||||||
|
this._ringEl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 语音播放(多段顺序)──
|
||||||
|
playVoice(urls, { vol = 1.0, onEnd = null } = {}) {
|
||||||
|
this.stopVoice();
|
||||||
|
this._voiceQueue = [...urls];
|
||||||
|
this._playNext(vol, onEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
_playNext(vol, onEnd) {
|
||||||
|
if (this._voiceQueue.length === 0) {
|
||||||
|
this._voiceEl = null;
|
||||||
|
if (onEnd) onEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = this._voiceQueue.shift();
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.volume = vol;
|
||||||
|
this._voiceEl = el;
|
||||||
|
el.onended = () => this._playNext(vol, onEnd);
|
||||||
|
el.onerror = () => {
|
||||||
|
console.warn('[Audio] voice error:', url);
|
||||||
|
this._playNext(vol, onEnd);
|
||||||
|
};
|
||||||
|
el.play().catch(e => console.warn('[Audio] play error', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
stopVoice() {
|
||||||
|
this._voiceQueue = [];
|
||||||
|
if (this._voiceEl) {
|
||||||
|
this._voiceEl.onended = null;
|
||||||
|
this._voiceEl.pause();
|
||||||
|
this._voiceEl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isVoicePlaying() {
|
||||||
|
return !!(this._voiceEl && !this._voiceEl.paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 = ''; });
|
||||||
|
}
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.loop = true;
|
||||||
|
el.volume = 0;
|
||||||
|
this._bgmEl = el;
|
||||||
|
try {
|
||||||
|
await el.play();
|
||||||
|
this._fadeTo(el, vol, fade);
|
||||||
|
} catch (e) { console.warn('[Audio] BGM error', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
stopBGM(fade = 2000) {
|
||||||
|
if (this._bgmEl) {
|
||||||
|
const el = this._bgmEl;
|
||||||
|
this._bgmEl = null;
|
||||||
|
this._fadeOut(el, fade).then(() => { el.pause(); el.src = ''; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 环境音 ──
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAmbience() {
|
||||||
|
if (this._ambEl) { this._ambEl.pause(); this._ambEl.src = ''; this._ambEl = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 音量渐变 ──
|
||||||
|
_fadeTo(el, target, duration) {
|
||||||
|
const start = el.volume;
|
||||||
|
const steps = 30;
|
||||||
|
const dt = duration / steps;
|
||||||
|
const dv = (target - start) / steps;
|
||||||
|
let i = 0;
|
||||||
|
const t = setInterval(() => {
|
||||||
|
el.volume = Math.max(0, Math.min(1, start + dv * i));
|
||||||
|
if (++i >= steps) clearInterval(t);
|
||||||
|
}, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
_fadeOut(el, duration) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const start = el.volume;
|
||||||
|
const steps = 20;
|
||||||
|
const dt = duration / steps;
|
||||||
|
const dv = start / steps;
|
||||||
|
let i = 0;
|
||||||
|
const t = setInterval(() => {
|
||||||
|
el.volume = Math.max(0, start - dv * i);
|
||||||
|
if (++i >= steps) { clearInterval(t); resolve(); }
|
||||||
|
}, dt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 打字/录音音效(~800ms)
|
||||||
|
typing() {
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
setTimeout(() => {
|
||||||
|
Snd.synth({ freq: 600 + Math.random() * 200, dur: 30, type: 'sine', vol: 0.1 });
|
||||||
|
}, i * 120);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 发送音效
|
||||||
|
send() {
|
||||||
|
Snd.synth({ freq: 400, freq2: 900, dur: 120, type: 'sine', vol: 0.18 });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 结局音效
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
// engine.js — 游戏引擎 v2(多轮对话 + 林夏语音 + NPC反应)
|
||||||
|
//
|
||||||
|
// 状态流:idle → notification/ringing → playing → choosing
|
||||||
|
// → responding → reacting → [choosing | idle] → …… → ending
|
||||||
|
//
|
||||||
|
// 新增状态:
|
||||||
|
// responding — 林夏说话中(打字音效 + 语音播放)
|
||||||
|
// reacting — NPC反应播放中
|
||||||
|
// paused — 游戏暂停(两指长按触发)
|
||||||
|
|
||||||
|
class GameEngine {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 状态 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] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 初始化 & 启动
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
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() {
|
||||||
|
Snd.stopVoice();
|
||||||
|
TTS.stop();
|
||||||
|
if (this._pendingResolve) {
|
||||||
|
this._pendingResolve();
|
||||||
|
this._pendingResolve = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打字 + 发送音效
|
||||||
|
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();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.requiresMomLink && !this.vars.MOM_LINK) {
|
||||||
|
this.triggerFinalEnding();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 通话流程(异步)
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
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) {
|
||||||
|
await TTS.speak(msg.text);
|
||||||
|
}
|
||||||
|
// 语音消息
|
||||||
|
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 (this._skipRequested) {
|
||||||
|
// 跳过了林夏回应 → 如有 followUp 进入选择,否则跳到下一条
|
||||||
|
if (choice.followUp?.choices) {
|
||||||
|
this._enterChoosing(choice.followUp.choices);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._proceedToNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. NPC 反应
|
||||||
|
if (choice.npcReact?.length > 0) {
|
||||||
|
this.state = 'reacting';
|
||||||
|
await sleep(400);
|
||||||
|
if (!this._skipRequested) {
|
||||||
|
await this._playVoiceAsync(choice.npcReact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
_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._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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 提醒定时器
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
_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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 长按:重播 / 状态广播 ──
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// idle/notification → 广播未读数
|
||||||
|
TTS.speak(`当前未读${this.vars.UNREAD}条消息`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 暂停 ──
|
||||||
|
_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();
|
||||||
|
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);
|
||||||
|
SFX_UI.ending();
|
||||||
|
this._showCaption('明天再说。');
|
||||||
|
await this._playVoiceAsync([`${T}/v2_ending_a_linxia_wav_v1.mp3`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _endingB() {
|
||||||
|
await sleep(2000);
|
||||||
|
SFX_UI.ending();
|
||||||
|
await sleep(1000);
|
||||||
|
this._showCaption('寄出去了。');
|
||||||
|
await this._playVoiceAsync([`${T}/v2_ending_b_linxia_wav_v1.mp3`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _endingC() {
|
||||||
|
await sleep(3000);
|
||||||
|
SFX_UI.ending();
|
||||||
|
await sleep(1000);
|
||||||
|
this._showCaption('今晚就这样。');
|
||||||
|
await this._playVoiceAsync([`${T}/v2_ending_c_linxia_wav_v1.mp3`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// gestures.js — 触控手势检测器
|
||||||
|
//
|
||||||
|
// 支持 9 种手势:
|
||||||
|
// 单击 · 双击 · 长按(1s) · 左滑 · 右滑 · 上滑 · 下滑
|
||||||
|
// 两指长按(1s) · 三指点击
|
||||||
|
|
||||||
|
class GestureDetector {
|
||||||
|
constructor(el) {
|
||||||
|
this._el = el;
|
||||||
|
this._handlers = {};
|
||||||
|
|
||||||
|
this._startX = 0;
|
||||||
|
this._startY = 0;
|
||||||
|
this._startTime = 0;
|
||||||
|
this._tapTimer = null;
|
||||||
|
this._tapCount = 0;
|
||||||
|
|
||||||
|
// 长按检测
|
||||||
|
this._longPressTimer = null;
|
||||||
|
this._longPressTriggered = false;
|
||||||
|
|
||||||
|
// 多指检测
|
||||||
|
this._maxTouches = 0; // 本次触摸中同时出现的最大手指数
|
||||||
|
this._multiTouchLPTimer = null; // 两指长按计时器
|
||||||
|
this._multiTouchHandled = false;
|
||||||
|
|
||||||
|
this._bindTouch();
|
||||||
|
this._bindMouse();
|
||||||
|
this._bindKeyboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
on(evt, fn) { this._handlers[evt] = fn; return this; }
|
||||||
|
|
||||||
|
_emit(evt) {
|
||||||
|
dbg(evt);
|
||||||
|
const fn = this._handlers[evt];
|
||||||
|
if (fn) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 触摸事件 ──
|
||||||
|
_bindTouch() {
|
||||||
|
const el = this._el;
|
||||||
|
|
||||||
|
el.addEventListener('touchstart', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const count = e.touches.length;
|
||||||
|
|
||||||
|
// 记录本次触摸出现过的最大手指数
|
||||||
|
if (count > this._maxTouches) this._maxTouches = count;
|
||||||
|
|
||||||
|
if (count === 1) {
|
||||||
|
// 单指:记录起点 + 启动长按计时
|
||||||
|
const t = e.touches[0];
|
||||||
|
this._startX = t.clientX;
|
||||||
|
this._startY = t.clientY;
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._longPressTriggered = false;
|
||||||
|
|
||||||
|
this._longPressTimer = setTimeout(() => {
|
||||||
|
this._longPressTriggered = true;
|
||||||
|
this._longPressTimer = null;
|
||||||
|
this._emit('longpress');
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
} else if (count === 2) {
|
||||||
|
// 两指:取消单指长按,启动两指长按计时
|
||||||
|
this._cancelLongPress();
|
||||||
|
this._multiTouchLPTimer = setTimeout(() => {
|
||||||
|
this._multiTouchHandled = true;
|
||||||
|
this._multiTouchLPTimer = null;
|
||||||
|
this._emit('twofinger_longpress');
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
} else if (count >= 3) {
|
||||||
|
// 三指+:取消所有计时
|
||||||
|
this._cancelLongPress();
|
||||||
|
this._cancelMultiTouchLP();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
el.addEventListener('touchmove', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
// 移动超 15px 取消长按
|
||||||
|
if (this._longPressTimer && e.touches.length === 1) {
|
||||||
|
const t = e.touches[0];
|
||||||
|
const dx = t.clientX - this._startX;
|
||||||
|
const dy = t.clientY - this._startY;
|
||||||
|
if (Math.sqrt(dx * dx + dy * dy) > 15) {
|
||||||
|
this._cancelLongPress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 两指移动也取消两指长按
|
||||||
|
if (this._multiTouchLPTimer) {
|
||||||
|
this._cancelMultiTouchLP();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
el.addEventListener('touchend', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 还有手指在屏幕上,等全部抬起再处理
|
||||||
|
if (e.touches.length > 0) return;
|
||||||
|
|
||||||
|
// 全部手指抬起 — 根据 maxTouches 判定手势类型
|
||||||
|
const max = this._maxTouches;
|
||||||
|
|
||||||
|
// 清理计时器
|
||||||
|
this._cancelLongPress();
|
||||||
|
this._cancelMultiTouchLP();
|
||||||
|
|
||||||
|
// 已在计时器中处理过(长按 / 两指长按)
|
||||||
|
if (this._longPressTriggered || this._multiTouchHandled) {
|
||||||
|
this._resetMultiTouch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 三指点击
|
||||||
|
if (max >= 3) {
|
||||||
|
this._resetMultiTouch();
|
||||||
|
this._emit('threefinger_tap');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两指快速触摸(< 1s)→ 忽略
|
||||||
|
if (max === 2) {
|
||||||
|
this._resetMultiTouch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单指:正常处理(滑动 / 点击)
|
||||||
|
this._resetMultiTouch();
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
this._process(t.clientX, t.clientY);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
el.addEventListener('touchcancel', () => {
|
||||||
|
this._cancelLongPress();
|
||||||
|
this._cancelMultiTouchLP();
|
||||||
|
this._resetMultiTouch();
|
||||||
|
this._reset();
|
||||||
|
}, { passive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 鼠标事件(桌面调试)──
|
||||||
|
_bindMouse() {
|
||||||
|
this._el.addEventListener('mousedown', e => {
|
||||||
|
this._startX = e.clientX;
|
||||||
|
this._startY = e.clientY;
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._longPressTriggered = false;
|
||||||
|
|
||||||
|
this._longPressTimer = setTimeout(() => {
|
||||||
|
this._longPressTriggered = true;
|
||||||
|
this._longPressTimer = null;
|
||||||
|
this._emit('longpress');
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
this._el.addEventListener('mouseup', e => {
|
||||||
|
this._cancelLongPress();
|
||||||
|
if (this._longPressTriggered) {
|
||||||
|
this._longPressTriggered = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._process(e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
this._el.addEventListener('mousemove', e => {
|
||||||
|
if (this._longPressTimer) {
|
||||||
|
const dx = e.clientX - this._startX;
|
||||||
|
const dy = e.clientY - this._startY;
|
||||||
|
if (Math.sqrt(dx * dx + dy * dy) > 15) {
|
||||||
|
this._cancelLongPress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 键盘事件(桌面调试)──
|
||||||
|
_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;
|
||||||
|
case 'KeyL': e.preventDefault(); this._emit('longpress'); break;
|
||||||
|
case 'KeyP': e.preventDefault(); this._emit('twofinger_longpress'); break;
|
||||||
|
case 'KeyQ': e.preventDefault(); this._emit('threefinger_tap'); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 手势判定(单指) ──
|
||||||
|
_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;
|
||||||
|
|
||||||
|
// 滑动:距离 > 35px
|
||||||
|
if (dist > 35) {
|
||||||
|
const angle = Math.abs(Math.atan2(dy, dx) * 180 / Math.PI);
|
||||||
|
let dir = null;
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击:距离 < 20px,时间 < 500ms
|
||||||
|
if (dist < 20 && dt < 500) {
|
||||||
|
this._tapCount++;
|
||||||
|
if (this._tapCount === 1) {
|
||||||
|
this._tapTimer = setTimeout(() => {
|
||||||
|
this._tapCount = 0;
|
||||||
|
this._emit('singletap');
|
||||||
|
}, 250);
|
||||||
|
} else if (this._tapCount >= 2) {
|
||||||
|
clearTimeout(this._tapTimer);
|
||||||
|
this._tapCount = 0;
|
||||||
|
this._emit('doubletap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 工具方法 ──
|
||||||
|
|
||||||
|
_cancelLongPress() {
|
||||||
|
if (this._longPressTimer) {
|
||||||
|
clearTimeout(this._longPressTimer);
|
||||||
|
this._longPressTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancelMultiTouchLP() {
|
||||||
|
if (this._multiTouchLPTimer) {
|
||||||
|
clearTimeout(this._multiTouchLPTimer);
|
||||||
|
this._multiTouchLPTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_resetMultiTouch() {
|
||||||
|
this._maxTouches = 0;
|
||||||
|
this._multiTouchHandled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_reset() {
|
||||||
|
clearTimeout(this._tapTimer);
|
||||||
|
this._tapCount = 0;
|
||||||
|
this._longPressTriggered = false;
|
||||||
|
this._resetMultiTouch();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// haptics.js — 震动反馈(Android Chrome 支持,iOS 降级为无震动)
|
||||||
|
|
||||||
|
const Haptic = {
|
||||||
|
_ok: typeof navigator !== 'undefined' && !!navigator.vibrate,
|
||||||
|
|
||||||
|
_v(pattern) {
|
||||||
|
if (this._ok) navigator.vibrate(pattern);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 来电震动(长震 + 停顿,循环由来电循环调用)
|
||||||
|
ring() { this._v([400, 200, 400]); },
|
||||||
|
|
||||||
|
// 微信消息(两短震)
|
||||||
|
message() { this._v([80, 60, 80]); },
|
||||||
|
|
||||||
|
// 确认操作
|
||||||
|
confirm() { this._v(60); },
|
||||||
|
|
||||||
|
// 拒绝 / 挂断
|
||||||
|
reject() { this._v([100, 50, 100, 50, 100]); },
|
||||||
|
|
||||||
|
// 进入细回复模式
|
||||||
|
detailMode() { this._v([30, 30, 60]); },
|
||||||
|
|
||||||
|
// 选项选中
|
||||||
|
select() { this._v(80); },
|
||||||
|
|
||||||
|
// 系统通知
|
||||||
|
system() { this._v([200, 100, 100, 100, 200]); },
|
||||||
|
|
||||||
|
// 错误 / 无效手势
|
||||||
|
error() { this._v([50, 30, 50]); },
|
||||||
|
|
||||||
|
stop() { if (this._ok) navigator.vibrate(0); },
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// main.js — 游戏入口
|
||||||
|
|
||||||
|
let engine = null;
|
||||||
|
let gestures = null;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
TTS.init();
|
||||||
|
|
||||||
|
// 初始化音频(必须在用户手势后)
|
||||||
|
await Snd.init();
|
||||||
|
|
||||||
|
// 请求屏幕常亮
|
||||||
|
try {
|
||||||
|
if ('wakeLock' in navigator) {
|
||||||
|
window._wakeLock = await navigator.wakeLock.request('screen');
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 绑定手势
|
||||||
|
const gameEl = document.getElementById('game');
|
||||||
|
gestures = new GestureDetector(gameEl);
|
||||||
|
|
||||||
|
// 创建并启动引擎
|
||||||
|
engine = new GameEngine();
|
||||||
|
engine.init(gestures);
|
||||||
|
await engine.start();
|
||||||
|
|
||||||
|
if (DEV) {
|
||||||
|
document.getElementById('dbg').style.display = 'block';
|
||||||
|
document.title = '林夏 [DEV]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触摸启动(手机)
|
||||||
|
document.getElementById('start').addEventListener('touchend', async function onStart(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.removeEventListener('touchend', onStart);
|
||||||
|
this.style.transition = 'opacity 0.8s';
|
||||||
|
this.style.opacity = '0';
|
||||||
|
setTimeout(() => { this.style.display = 'none'; }, 800);
|
||||||
|
await main();
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
// 鼠标点击(电脑调试)
|
||||||
|
document.getElementById('start').addEventListener('click', async function onClick() {
|
||||||
|
this.removeEventListener('click', onClick);
|
||||||
|
this.style.transition = 'opacity 0.8s';
|
||||||
|
this.style.opacity = '0';
|
||||||
|
setTimeout(() => { this.style.display = 'none'; }, 800);
|
||||||
|
await main();
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
// 防止页面滚动/缩放
|
||||||
|
document.addEventListener('touchmove', e => e.preventDefault(), { passive: false });
|
||||||
|
document.addEventListener('gesturestart', e => e.preventDefault());
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// profile.js — 行为画像追踪器(avoidance / engagement / nostalgia)
|
||||||
|
|
||||||
|
class ProfileTracker {
|
||||||
|
constructor() {
|
||||||
|
this.scores = { avoidance: 0, engagement: 0, nostalgia: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
add(effects) {
|
||||||
|
if (!effects) return;
|
||||||
|
for (const k of ['avoidance', 'engagement', 'nostalgia']) {
|
||||||
|
if (effects[k]) this.scores[k] += effects[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 漏接电话
|
||||||
|
onMissedCall() { this.add({ avoidance: 2 }); }
|
||||||
|
// 单点回"嗯"
|
||||||
|
onSimpleReply() { this.add({ avoidance: 1 }); }
|
||||||
|
// 双指上滑已读不回
|
||||||
|
onReadNoReply() { this.add({ avoidance: 2 }); }
|
||||||
|
// 进入细回复
|
||||||
|
onDetailReply() { this.add({ engagement: 2 }); }
|
||||||
|
// 细回复选"主动追问"(engagement 方向)
|
||||||
|
onEngage() { this.add({ engagement: 2 }); }
|
||||||
|
// 细回复选"分享自己"
|
||||||
|
onShare() { this.add({ engagement: 1, nostalgia: 1 }); }
|
||||||
|
// 反复重听同一条
|
||||||
|
onReplay() { this.add({ nostalgia: 2 }); }
|
||||||
|
// 听完长语音未回应
|
||||||
|
onListenNoReact(){ this.add({ avoidance: 1, nostalgia: 1 }); }
|
||||||
|
|
||||||
|
// 获取最终画像标签
|
||||||
|
get label() {
|
||||||
|
const s = this.scores;
|
||||||
|
if (s.engagement >= s.avoidance && s.engagement >= s.nostalgia) return 'engagement';
|
||||||
|
if (s.nostalgia >= s.avoidance) return 'nostalgia';
|
||||||
|
return 'avoidance';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
// story.js — 消息数据 v2(CosyVoice2 语音 + 多轮对话)
|
||||||
|
//
|
||||||
|
// 选择方向的情感含义(全局一致):
|
||||||
|
// 单击(tap) → 简单回应
|
||||||
|
// 双击 → 不回复 / 跳过
|
||||||
|
// 右滑(right) → 温暖 / 接受 / 开放
|
||||||
|
// 左滑(left) → 冷淡 / 拒绝 / 疏远
|
||||||
|
// 上滑(up) → 好奇 / 主动 / 追问
|
||||||
|
// 下滑(down) → 沉默 / 退缩
|
||||||
|
//
|
||||||
|
// 每个 choice 结构:
|
||||||
|
// linxia: 林夏回应的音频路径(null/缺省 = 沉默)
|
||||||
|
// npcReact: NPC反应音频数组(缺省 = 无反应)
|
||||||
|
// isSilence: 沉默选项标记
|
||||||
|
// state: 剧情状态变更
|
||||||
|
// profile: 画像分数变更
|
||||||
|
// followUp: 可选的下一轮对话 { choices: { ... } }
|
||||||
|
|
||||||
|
const A = 'mp3';
|
||||||
|
const T = `${A}/tts`; // CosyVoice2 生成的所有语音
|
||||||
|
const MUS = `${A}/music`;
|
||||||
|
const SFX = `${A}/sfx`;
|
||||||
|
const AMB = `${A}/ambience`;
|
||||||
|
|
||||||
|
// ── 快捷函数 ──
|
||||||
|
const npc = f => `${T}/${f}`; // NPC Round 1 台词
|
||||||
|
const lx = f => `${T}/${f}`; // 林夏回应
|
||||||
|
const rx = f => `${T}/${f}`; // NPC 反应
|
||||||
|
|
||||||
|
const MESSAGES = [
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 01 · 妈妈 · 电话
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 1, type: 'call',
|
||||||
|
sender: 'mom', senderName: '妈妈',
|
||||||
|
ringtone: `${MUS}/lv11_ring_mom_v1.mp3`,
|
||||||
|
audio: [npc('lv11_2103_mom_call_01_wav_v1.mp3')],
|
||||||
|
autoReply: lx('v2_msg01_linxia_auto_wav_v1.mp3'),
|
||||||
|
stateOnAnswer: { MOM_LINK: true },
|
||||||
|
profileOnMiss: { avoidance: 2 },
|
||||||
|
profileOnAnswer: { engagement: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 02 · 小美 · 语音 ×3(教学关)
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 2, type: 'wechat_voice',
|
||||||
|
sender: 'xiaomei', senderName: '小美',
|
||||||
|
audio: [
|
||||||
|
npc('lv11_2115_xiaomei_voice_01_wav_v1.mp3'),
|
||||||
|
npc('lv11_2115_xiaomei_voice_02_wav_v1.mp3'),
|
||||||
|
npc('lv11_2115_xiaomei_voice_03_wav_v1.mp3'),
|
||||||
|
],
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg02_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg02_react_warm_wav_v1.mp3')],
|
||||||
|
profile: { engagement: 2 },
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg02_linxia_cold_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg02_react_cold_wav_v1.mp3')],
|
||||||
|
profile: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg02_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg02_react_curious_wav_v1.mp3')],
|
||||||
|
profile: { engagement: 1 },
|
||||||
|
followUp: {
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg02_r3_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg02_r3_react_wav_v1.mp3')],
|
||||||
|
profile: { nostalgia: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
npcReact: [rx('v2_msg02_react_silent_wav_v1.mp3')],
|
||||||
|
profile: { avoidance: 2 },
|
||||||
|
},
|
||||||
|
tap: {
|
||||||
|
linxia: lx('v2_msg02_linxia_tap_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg02_react_tap_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 03 · 外卖小哥 · 电话
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 3, type: 'call',
|
||||||
|
sender: 'delivery', senderName: '外卖小哥',
|
||||||
|
ringtone: `${MUS}/lv11_ring_delivery_v1.mp3`,
|
||||||
|
audio: [npc('lv11_2120_delivery_call_01_wav_v1.mp3')],
|
||||||
|
autoReply: lx('v2_msg03_linxia_auto_wav_v1.mp3'),
|
||||||
|
profileOnMiss: { avoidance: 1 },
|
||||||
|
profileOnAnswer: { engagement: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 04 · 阿哲 · 语音(前男友要东西)
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 4, type: 'wechat_voice',
|
||||||
|
sender: 'azhe', senderName: '阿哲',
|
||||||
|
audio: [npc('lv11_2130_azhe_voice_01_wav_v1.mp3')],
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg04_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg04_react_warm_wav_v1.mp3')],
|
||||||
|
profile: { engagement: 2 },
|
||||||
|
followUp: {
|
||||||
|
// 阿哲问"你呢?"后
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg04_r3w_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg04_r3w_react_warm_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg04_r3w_linxia_cold_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg04_r3w_react_cold_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
npcReact: [rx('v2_msg04_r3w_react_silent_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg04_linxia_cold_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg04_react_cold_wav_v1.mp3')],
|
||||||
|
state: { HE_BACK: false },
|
||||||
|
profile: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg04_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg04_react_curious_wav_v1.mp3')],
|
||||||
|
profile: { engagement: 2 },
|
||||||
|
followUp: {
|
||||||
|
// 阿哲说"不太方便"后
|
||||||
|
choices: {
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg04_r3c_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg04_r3c_react_curious_wav_v1.mp3')],
|
||||||
|
state: { HE_BACK: false },
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg04_r3c_linxia_warm_wav_v1.mp3'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
npcReact: [rx('v2_msg04_react_silent_wav_v1.mp3')],
|
||||||
|
state: { HE_BACK: null },
|
||||||
|
profile: { avoidance: 2 },
|
||||||
|
},
|
||||||
|
tap: {
|
||||||
|
linxia: lx('v2_msg04_linxia_tap_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg04_react_tap_wav_v1.mp3')],
|
||||||
|
state: { HE_BACK: false },
|
||||||
|
profile: { engagement: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 05 · HR王姐 · 语音
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 5, type: 'wechat_voice',
|
||||||
|
sender: 'hr', senderName: 'HR王姐',
|
||||||
|
audio: [npc('lv11_2145_hr_voice_01_wav_v1.mp3')],
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg05_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg05_react_warm_wav_v1.mp3')],
|
||||||
|
profile: { engagement: 1 },
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg05_linxia_cold_wav_v1.mp3'),
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg05_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg05_react_curious_wav_v1.mp3')],
|
||||||
|
profile: { engagement: 2 },
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
profile: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
tap: {
|
||||||
|
linxia: lx('v2_msg05_linxia_tap_wav_v1.mp3'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 06 · 安安 · 语音(酒吧邀约)
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 6, type: 'wechat_voice',
|
||||||
|
sender: 'anan', senderName: '安安',
|
||||||
|
audio: [npc('lv11_2200_anan_voice_01_wav_v1.mp3')],
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg06_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg06_react_warm_wav_v1.mp3')],
|
||||||
|
profile: { engagement: 2 },
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg06_linxia_cold_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg06_react_cold_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg06_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg06_react_curious_wav_v1.mp3')],
|
||||||
|
profile: { engagement: 1 },
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
npcReact: [rx('v2_msg06_react_silent_wav_v1.mp3')],
|
||||||
|
profile: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
tap: {
|
||||||
|
linxia: lx('v2_msg06_linxia_tap_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg06_react_tap_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 07 · 寝室群 · 5条语音
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 7, type: 'wechat_voice',
|
||||||
|
sender: 'dorm', senderName: '寝室群',
|
||||||
|
audio: [
|
||||||
|
npc('lv11_2215_dorm_a_voice_01_wav_v1.mp3'),
|
||||||
|
npc('lv11_2215_dorm_b_voice_01_wav_v1.mp3'),
|
||||||
|
npc('lv11_2215_dorm_c_voice_01_wav_v1.mp3'),
|
||||||
|
npc('lv11_2215_dorm_a_voice_02_wav_v1.mp3'),
|
||||||
|
npc('lv11_2215_dorm_b_voice_02_wav_v1.mp3'),
|
||||||
|
],
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg07_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg07_react_warm_wav_v1.mp3')],
|
||||||
|
state: { GROUP_REPLY: true },
|
||||||
|
profile: { engagement: 2 },
|
||||||
|
followUp: {
|
||||||
|
// 室友A问"你最近怎么样"后
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg07_r3_linxia_warm_wav_v1.mp3'),
|
||||||
|
profile: { engagement: 1 },
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg07_r3_linxia_cold_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg07_r3_react_cold_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg07_linxia_cold_wav_v1.mp3'),
|
||||||
|
profile: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg07_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg07_react_curious_wav_v1.mp3')],
|
||||||
|
state: { GROUP_REPLY: true },
|
||||||
|
profile: { engagement: 2 },
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
profile: { avoidance: 2 },
|
||||||
|
},
|
||||||
|
tap: {
|
||||||
|
linxia: lx('v2_linxia_generic_hmm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg07_react_tap_wav_v1.mp3')],
|
||||||
|
state: { GROUP_REPLY: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 08 · 未知号码 · 电话(悬疑钩子)
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 8, type: 'call',
|
||||||
|
sender: 'unknown', senderName: '未知号码',
|
||||||
|
ringtone: `${MUS}/lv11_ring_unknown_v1.mp3`,
|
||||||
|
audio: [`${SFX}/sfx_breathing_unknown_v1.mp3`],
|
||||||
|
autoHangup: true,
|
||||||
|
sfxAfter: `${SFX}/sfx_heartbeat_fast_v1.mp3`,
|
||||||
|
profileOnMiss: { avoidance: 1 },
|
||||||
|
profileOnAnswer: { nostalgia: 1 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 09 · 小美(醉)· 4段语音
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 9, type: 'wechat_voice',
|
||||||
|
sender: 'xiaomei', senderName: '小美',
|
||||||
|
audio: [
|
||||||
|
npc('lv11_2245_xiaomei_voice_01_seg1_wav_v1.mp3'),
|
||||||
|
npc('lv11_2245_xiaomei_voice_01_seg2_wav_v1.mp3'),
|
||||||
|
npc('lv11_2245_xiaomei_voice_01_seg3_wav_v1.mp3'),
|
||||||
|
npc('lv11_2245_xiaomei_voice_01_seg4_wav_v1.mp3'),
|
||||||
|
],
|
||||||
|
bgm: `${MUS}/lv11_bgm_m2_suspense_v1.mp3`,
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg09_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg09_react_warm_wav_v1.mp3')],
|
||||||
|
profile: { engagement: 2 },
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg09_linxia_cold_wav_v1.mp3'),
|
||||||
|
profile: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg09_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg09_react_curious_wav_v1.mp3')],
|
||||||
|
profile: { engagement: 2 },
|
||||||
|
followUp: {
|
||||||
|
// 小美说"怕你恨我"后
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg09_r3_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg09_r3_react_warm_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg09_r3_linxia_cold_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg09_r3_react_cold_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
npcReact: [rx('v2_msg09_r3_react_silent_q_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
profile: { avoidance: 2, nostalgia: 1 },
|
||||||
|
},
|
||||||
|
tap: {
|
||||||
|
linxia: lx('v2_msg09_linxia_tap_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg09_react_tap_wav_v1.mp3')],
|
||||||
|
profile: { nostalgia: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 10 · 妈妈 · 文字消息(多层嵌套对话)
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 10, type: 'wechat_text',
|
||||||
|
sender: 'mom', senderName: '妈妈',
|
||||||
|
text: '睡了吗?',
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg10_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg10_react_warm_wav_v1.mp3')],
|
||||||
|
state: { MOM_LINK: true },
|
||||||
|
profile: { engagement: 2 },
|
||||||
|
followUp: {
|
||||||
|
// 妈妈问"今天怎么样"后
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg10_rw_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg10_rw_react_warm_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg10_rw_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg10_rw_react_curious_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
npcReact: [rx('v2_msg10_rw_react_silent_wav_v1.mp3')],
|
||||||
|
profile: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg10_linxia_cold_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg10_react_cold_wav_v1.mp3')],
|
||||||
|
state: { MOM_LINK: true },
|
||||||
|
profile: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg10_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg10_call_mom_wav_v1.mp3')],
|
||||||
|
state: { MOM_LINK: true },
|
||||||
|
profile: { engagement: 3 },
|
||||||
|
followUp: {
|
||||||
|
// 妈妈打来电话说"怎么了夏夏?"后
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg10_call_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg10_call_react_warm_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg10_call_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg10_call_react_curious_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
npcReact: [rx('v2_msg10_call_react_down_wav_v1.mp3')],
|
||||||
|
profile: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
npcReact: [rx('v2_msg10_react_silent_wav_v1.mp3')],
|
||||||
|
profile: { avoidance: 2 },
|
||||||
|
},
|
||||||
|
tap: {
|
||||||
|
linxia: lx('v2_msg10_linxia_tap_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg10_react_tap_wav_v1.mp3')],
|
||||||
|
state: { MOM_LINK: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 11 · 阿哲 · 电话(关键分支:来不来)
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 11, type: 'call',
|
||||||
|
sender: 'azhe', senderName: '阿哲',
|
||||||
|
ringtone: `${MUS}/lv11_ring_azhe_v1.mp3`,
|
||||||
|
audio: [npc('lv11_2315_azhe_call_01_wav_v1.mp3')],
|
||||||
|
profileOnMiss: { avoidance: 2 },
|
||||||
|
profileOnAnswer: { engagement: 1 },
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg11_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg11_react_warm_wav_v1.mp3')],
|
||||||
|
state: { HE_BACK: true },
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg11_linxia_cold_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg11_react_cold_wav_v1.mp3')],
|
||||||
|
state: { HE_BACK: false },
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg11_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg11_react_curious_wav_v1.mp3')],
|
||||||
|
followUp: {
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg11_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg11_react_warm_wav_v1.mp3')],
|
||||||
|
state: { HE_BACK: true },
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg11_linxia_cold_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg11_react_cold_wav_v1.mp3')],
|
||||||
|
state: { HE_BACK: false },
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
npcReact: [rx('v2_msg11_r3_react_silent_wav_v1.mp3')],
|
||||||
|
state: { HE_BACK: false },
|
||||||
|
profile: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
state: { HE_BACK: false },
|
||||||
|
},
|
||||||
|
tap: {
|
||||||
|
linxia: lx('v2_msg11_linxia_tap_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg11_react_tap_wav_v1.mp3')],
|
||||||
|
state: { HE_BACK: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 12 · 周南 · 语音(老同学)
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 12, type: 'wechat_voice',
|
||||||
|
sender: 'zhounan', senderName: '周南',
|
||||||
|
audio: [npc('lv11_2330_zhounan_voice_01_wav_v1.mp3')],
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg12_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg12_react_warm_wav_v1.mp3')],
|
||||||
|
state: { ZHOUNAN_DEPTH: 1 },
|
||||||
|
profile: { engagement: 2, nostalgia: 1 },
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg12_linxia_cold_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg12_react_cold_wav_v1.mp3')],
|
||||||
|
state: { ZHOUNAN_DEPTH: 1 },
|
||||||
|
profile: { nostalgia: 1 },
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg12_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg12_react_curious_wav_v1.mp3')],
|
||||||
|
state: { ZHOUNAN_DEPTH: 1 },
|
||||||
|
profile: { nostalgia: 2 },
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
npcReact: [rx('v2_msg12_react_silent_wav_v1.mp3')],
|
||||||
|
profile: { nostalgia: 1 },
|
||||||
|
},
|
||||||
|
tap: {
|
||||||
|
linxia: lx('v2_msg12_linxia_tap_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg12_react_tap_wav_v1.mp3')],
|
||||||
|
state: { ZHOUNAN_DEPTH: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 13 · 周南 · 4条长语音(他的十年)
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 13, type: 'wechat_voice',
|
||||||
|
sender: 'zhounan', senderName: '周南',
|
||||||
|
audio: [
|
||||||
|
npc('lv11_2345_zhounan_voice_01_wav_v1.mp3'),
|
||||||
|
npc('lv11_2345_zhounan_voice_02_wav_v1.mp3'),
|
||||||
|
npc('lv11_2345_zhounan_voice_03_wav_v1.mp3'),
|
||||||
|
npc('lv11_2345_zhounan_voice_04_wav_v1.mp3'),
|
||||||
|
],
|
||||||
|
bgm: `${MUS}/lv11_bgm_m3_zhounan_v1.mp3`,
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg13_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg13_react_warm_wav_v1.mp3')],
|
||||||
|
state: { ZHOUNAN_DEPTH_ADD: 1 },
|
||||||
|
profile: { engagement: 2 },
|
||||||
|
followUp: {
|
||||||
|
// 周南问"你在北京过得好吗"后
|
||||||
|
choices: {
|
||||||
|
right: {
|
||||||
|
linxia: lx('v2_msg13_r3_linxia_warm_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg13_r3_react_warm_wav_v1.mp3')],
|
||||||
|
state: { ZHOUNAN_SHARE: true },
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg13_r3_linxia_cold_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg13_r3_react_cold_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg13_r3_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg13_r3_react_curious_wav_v1.mp3')],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
linxia: lx('v2_msg13_linxia_cold_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg13_react_cold_wav_v1.mp3')],
|
||||||
|
state: { ZHOUNAN_DEPTH_ADD: 1 },
|
||||||
|
profile: { nostalgia: 1 },
|
||||||
|
},
|
||||||
|
up: {
|
||||||
|
linxia: lx('v2_msg13_linxia_curious_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg13_react_curious_wav_v1.mp3')],
|
||||||
|
state: { ZHOUNAN_DEPTH_ADD: 1, ZHOUNAN_SHARE: true },
|
||||||
|
profile: { nostalgia: 2, engagement: 1 },
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
isSilence: true,
|
||||||
|
npcReact: [rx('v2_msg13_react_silent_wav_v1.mp3')],
|
||||||
|
profile: { avoidance: 1 },
|
||||||
|
},
|
||||||
|
tap: {
|
||||||
|
linxia: lx('v2_msg13_linxia_tap_wav_v1.mp3'),
|
||||||
|
npcReact: [rx('v2_msg13_react_tap_wav_v1.mp3')],
|
||||||
|
state: { ZHOUNAN_DEPTH_ADD: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 14 · 妈妈 · 第二次电话
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 14, type: 'call',
|
||||||
|
sender: 'mom', senderName: '妈妈',
|
||||||
|
ringtone: `${MUS}/lv11_ring_mom_v1.mp3`,
|
||||||
|
audio: [npc('lv11_0015_mom_call_01_wav_v1.mp3')],
|
||||||
|
autoReply: lx('v2_msg14_linxia_auto_wav_v1.mp3'),
|
||||||
|
autoReact: {
|
||||||
|
condition: 'MOM_LINK',
|
||||||
|
true: rx('v2_msg14_react_linked_wav_v1.mp3'),
|
||||||
|
false: rx('v2_msg14_react_unlinked_wav_v1.mp3'),
|
||||||
|
},
|
||||||
|
autoReactPreamble: rx('v2_msg14_mom_ask_wav_v1.mp3'),
|
||||||
|
stateOnAnswer: { MOM_LINK: true },
|
||||||
|
profileOnMiss: { avoidance: 2 },
|
||||||
|
profileOnAnswer: { engagement: 2 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 15 · 时光胶囊 · 系统通知
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 15, type: 'system_notification',
|
||||||
|
sender: 'self', senderName: '时光胶囊',
|
||||||
|
systemAudio: npc('lv11_sys_memo_01_wav_v1.mp3'),
|
||||||
|
audioByProfile: {
|
||||||
|
avoidance: npc('lv11_0030_self3y_voice_02_wav_v1.mp3'),
|
||||||
|
engagement: npc('lv11_0030_self3y_voice_01_wav_v1.mp3'),
|
||||||
|
nostalgia: npc('lv11_0030_self3y_voice_03_wav_v1.mp3'),
|
||||||
|
},
|
||||||
|
stateOnPlay: { SELF_RECORD: true },
|
||||||
|
profileOnPlay: { nostalgia: 2 },
|
||||||
|
profileOnSkip: { avoidance: 2 },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// 16 · 妈妈 · 长语音 ×8(结局前奏)
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
{
|
||||||
|
id: 16, type: 'wechat_voice',
|
||||||
|
sender: 'mom', senderName: '妈妈',
|
||||||
|
audio: [
|
||||||
|
npc('lv11_0200_mom_voice_01_seg1_wav_v1.mp3'),
|
||||||
|
npc('lv11_0200_mom_voice_01_seg2_wav_v1.mp3'),
|
||||||
|
npc('lv11_0200_mom_voice_01_seg3_wav_v1.mp3'),
|
||||||
|
npc('lv11_0200_mom_voice_01_seg4_wav_v1.mp3'),
|
||||||
|
npc('lv11_0200_mom_voice_01_seg5_wav_v1.mp3'),
|
||||||
|
npc('lv11_0200_mom_voice_01_seg6_wav_v1.mp3'),
|
||||||
|
npc('lv11_0200_mom_voice_01_seg7_wav_v1.mp3'),
|
||||||
|
npc('lv11_0200_mom_voice_01_seg8_wav_v1.mp3'),
|
||||||
|
],
|
||||||
|
bgm: `${MUS}/lv11_bgm_m4_ending_v1.mp3`,
|
||||||
|
isEnding: true,
|
||||||
|
requiresMomLink: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 角色信息
|
||||||
|
const SENDERS = {
|
||||||
|
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`;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// utils.js — 工具函数
|
||||||
|
|
||||||
|
const DEV = new URLSearchParams(location.search).has('dev');
|
||||||
|
|
||||||
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
function dbg(msg) {
|
||||||
|
const el = document.getElementById('dbg');
|
||||||
|
if (el && el.style.display !== 'none') el.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TTS — 仅用于朗读文字消息内容,不用于任何 UI 反馈
|
||||||
|
const TTS = {
|
||||||
|
_voices: [],
|
||||||
|
init() {
|
||||||
|
if (!window.speechSynthesis) return;
|
||||||
|
const load = () => { this._voices = speechSynthesis.getVoices(); };
|
||||||
|
load();
|
||||||
|
speechSynthesis.onvoiceschanged = load;
|
||||||
|
},
|
||||||
|
_pick() {
|
||||||
|
return this._voices.find(v => v.lang.startsWith('zh') || v.name.includes('Chinese'))
|
||||||
|
|| this._voices[0] || null;
|
||||||
|
},
|
||||||
|
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.volume = volume;
|
||||||
|
const v = this._pick();
|
||||||
|
if (v) u.voice = v;
|
||||||
|
u.onend = resolve; u.onerror = resolve;
|
||||||
|
speechSynthesis.speak(u);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
stop() { if (window.speechSynthesis) speechSynthesis.cancel(); },
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "林夏",
|
||||||
|
"short_name": "林夏",
|
||||||
|
"description": "一款全黑屏的情感叙事游戏。今晚16条消息,你接谁不接谁,决定明天的自己。",
|
||||||
|
"start_url": "/game/",
|
||||||
|
"display": "fullscreen",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "icon.png", "sizes": "192x192", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user