从github迁移过来
This commit is contained in:
+139
@@ -0,0 +1,139 @@
|
||||
<!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}}
|
||||
|
||||
/* ── 调试条(开发用) ── */
|
||||
#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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="hint">· 轻触屏幕开始 ·</div>
|
||||
<div class="tip">
|
||||
建议关灯 · 戴上耳机<br>
|
||||
全程约 60 分钟
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="caption"></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,211 @@
|
||||
// audio.js — 音频管理器(HTML Audio + Web Audio API 混用)
|
||||
|
||||
class AudioManager {
|
||||
constructor() {
|
||||
this._ctx = null;
|
||||
this._master = null;
|
||||
this._bgmNode = null;
|
||||
this._ambNode = null;
|
||||
this._voiceQueue = [];
|
||||
this._voiceEl = null;
|
||||
this._ringEl = null;
|
||||
this._ringTimer = null;
|
||||
this._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音)──
|
||||
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(_){} } };
|
||||
}
|
||||
|
||||
// ── 铃声(循环直到 stopRingtone)──
|
||||
async startRingtone(url) {
|
||||
this.stopRingtone();
|
||||
// 使用 HTMLAudio 以支持更大文件
|
||||
this._ringEl = new Audio(url);
|
||||
this._ringEl.loop = true;
|
||||
this._ringEl.volume = 1.0;
|
||||
try { await this._ringEl.play(); } catch(e) { console.warn('[Audio] ring play error', e); }
|
||||
}
|
||||
|
||||
stopRingtone() {
|
||||
if (this._ringEl) {
|
||||
this._ringEl.pause();
|
||||
this._ringEl.src = '';
|
||||
this._ringEl = null;
|
||||
}
|
||||
if (this._ringTimer) { clearTimeout(this._ringTimer); this._ringTimer = null; }
|
||||
}
|
||||
|
||||
// ── 语音消息播放(支持多段顺序播放)──
|
||||
playVoice(urls, { vol = 1.0, onEnd = null, onSegEnd = null } = {}) {
|
||||
this.stopVoice();
|
||||
this._voiceQueue = [...urls];
|
||||
this._playNextSegment(vol, onEnd, onSegEnd);
|
||||
}
|
||||
|
||||
_playNextSegment(vol, onEnd, onSegEnd) {
|
||||
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 = () => {
|
||||
if (onSegEnd) onSegEnd();
|
||||
this._playNextSegment(vol, onEnd, onSegEnd);
|
||||
};
|
||||
el.onerror = () => {
|
||||
console.warn('[Audio] voice error:', url);
|
||||
this._playNextSegment(vol, onEnd, onSegEnd);
|
||||
};
|
||||
el.play().catch(e => console.warn('[Audio] voice play error', e));
|
||||
}
|
||||
|
||||
stopVoice() {
|
||||
this._voiceQueue = [];
|
||||
if (this._voiceEl) {
|
||||
this._voiceEl.pause();
|
||||
this._voiceEl.onended = null;
|
||||
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));
|
||||
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);
|
||||
i++;
|
||||
if (i >= steps) { clearInterval(t); resolve(); }
|
||||
}, dt);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 键盘声(空间音频:左耳)──
|
||||
startTyping() {
|
||||
if (this._typingNode) return;
|
||||
const buf = this._preloaded['sfx_keyboard_slow'];
|
||||
if (!buf) return;
|
||||
const src = this._ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.loop = true;
|
||||
const gain = this._ctx.createGain();
|
||||
gain.gain.value = 0.15;
|
||||
// 偏左声道
|
||||
const pan = this._ctx.createStereoPanner();
|
||||
pan.pan.value = -0.8;
|
||||
src.connect(gain);
|
||||
gain.connect(pan);
|
||||
pan.connect(this._master);
|
||||
src.start();
|
||||
this._typingNode = { src, gain, pan };
|
||||
}
|
||||
|
||||
stopTyping() {
|
||||
if (this._typingNode) {
|
||||
try { this._typingNode.src.stop(); } catch(_) {}
|
||||
this._typingNode = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Snd = new AudioManager();
|
||||
@@ -0,0 +1,774 @@
|
||||
// engine.js — 游戏状态机
|
||||
|
||||
class GameEngine {
|
||||
constructor() {
|
||||
// 游戏状态
|
||||
this.state = 'idle'; // idle→playing→ringing→in_call→msg_incoming→msg_playing→await_reply→detail_reply→inbox→ending
|
||||
|
||||
// 剧情变量
|
||||
this.vars = {
|
||||
MOM_LINK: false,
|
||||
HE_BACK: null, // null=未定, true=来了, false=拒了
|
||||
GROUP_REPLY: false,
|
||||
ZHOUNAN_DEPTH: 0, // 0-3
|
||||
ZHOUNAN_SHARE: false,
|
||||
SELF_RECORD: false,
|
||||
UNREAD: 0, // 未读消息计数
|
||||
};
|
||||
|
||||
this.profile = new ProfileTracker();
|
||||
|
||||
// 消息状态
|
||||
this.msgState = {}; // msgId → 'unread'|'read'|'replied'|'missed'
|
||||
this.replayCount = {};// msgId → 重听次数
|
||||
|
||||
// 当前处理的消息
|
||||
this.currentMsg = null;
|
||||
this.currentChoices = null; // 细回复选项列表
|
||||
this.awaitingChoice = false;
|
||||
|
||||
// 定时器
|
||||
this._timers = [];
|
||||
this._ringTimer = null;
|
||||
this._startTime = null;
|
||||
|
||||
// 手势引用
|
||||
this.gest = null;
|
||||
|
||||
// 特殊事件追踪
|
||||
this._azheFollowUpDone = false;
|
||||
this._xiaomeiRecallDone = false;
|
||||
this._batteryWarned = false;
|
||||
this._doorbell = false;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 初始化
|
||||
// ══════════════════════════════════════════
|
||||
init(gestureDetector) {
|
||||
this.gest = gestureDetector;
|
||||
this._bindGestures();
|
||||
}
|
||||
|
||||
_bindGestures() {
|
||||
const G = this.gest;
|
||||
G.on('singletap', () => this._onSingleTap());
|
||||
G.on('doubletap', () => this._onDoubleTap());
|
||||
G.on('longpress1s', () => this._onLongPress1s());
|
||||
G.on('longpress5s', () => this._onLongPress5s());
|
||||
G.on('swipe_left', () => this._onSwipe('left'));
|
||||
G.on('swipe_right', () => this._onSwipe('right'));
|
||||
G.on('swipe_up', () => this._onSwipe('up'));
|
||||
G.on('swipe_down', () => this._onSwipe('down'));
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 游戏开始
|
||||
// ══════════════════════════════════════════
|
||||
async start() {
|
||||
this._startTime = Date.now();
|
||||
this.state = 'playing';
|
||||
|
||||
// 启动环境底噪
|
||||
await Snd.startAmbience(AMBIENCE_DEFAULT, { vol: 0.2 });
|
||||
|
||||
// 调度所有消息
|
||||
for (const msg of MESSAGES) {
|
||||
this._schedule(msg.triggerAt * 1000, () => this._triggerMessage(msg));
|
||||
}
|
||||
|
||||
// 调度特殊事件
|
||||
this._scheduleSpecialEvents();
|
||||
|
||||
// 02:05 兜底:确保结局被触发
|
||||
const endingDelay = (gt(2, 5)) * 1000;
|
||||
this._schedule(endingDelay, () => {
|
||||
if (!this._endingTriggered) this.triggerFinalEnding();
|
||||
});
|
||||
|
||||
// 夜间启动彩蛋
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 22 || hour < 4) {
|
||||
await sleep(2000);
|
||||
await TTS.speak('你也还醒着啊。', { rate: 0.8, volume: 0.6 });
|
||||
}
|
||||
|
||||
// 开始主BGM
|
||||
await sleep(3000);
|
||||
Snd.startBGM(`${MUS}/lv11_bgm_m1_main_loop_v1.wav`, { vol: 0.3 });
|
||||
}
|
||||
|
||||
_scheduleSpecialEvents() {
|
||||
// 阿哲撤回(消息4未回复时,10游戏分钟后)
|
||||
this._schedule(gt(21, 40) * 1000, () => {
|
||||
if (!this._azheFollowUpDone && this.msgState[4] !== 'replied') {
|
||||
this._azheFollowUpDone = true;
|
||||
Snd.startTyping();
|
||||
sleep(8000).then(() => {
|
||||
Snd.stopTyping();
|
||||
TTS.speak('阿哲撤回了一条消息。', { rate: 0.85, volume: 0.7 });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// HE_BACK 为 true 时,门铃音效(23:30左右)
|
||||
this._schedule(gt(23, 28) * 1000, () => {
|
||||
if (this.vars.HE_BACK === true && !this._doorbell) {
|
||||
this._doorbell = true;
|
||||
Snd.playFX ? null : null;
|
||||
Snd.playVoice([`${SFX}/sfx_door_knock_gentle_v1.wav`]);
|
||||
Haptic.ring();
|
||||
}
|
||||
});
|
||||
|
||||
// 电量1%警告(结局D触发条件之一)
|
||||
this._schedule(gt(1, 0) * 1000, () => {
|
||||
if (!this._batteryWarned) {
|
||||
this._batteryWarned = true;
|
||||
const unread = Object.values(this.msgState).filter(s => s === 'unread').length
|
||||
+ (Object.keys(this.msgState).length === 0 ? MESSAGES.length : 0);
|
||||
if (this.vars.UNREAD >= 8) {
|
||||
Snd.playVoice([`${TDIR}/lv11_sys_battery_01_wav_v1.wav`]);
|
||||
Haptic.system();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_schedule(ms, fn) {
|
||||
const t = setTimeout(fn, ms);
|
||||
this._timers.push(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 消息触发
|
||||
// ══════════════════════════════════════════
|
||||
async _triggerMessage(msg) {
|
||||
if (this.state === 'ringing' || this.state === 'in_call') {
|
||||
// 已在通话中,延迟处理(放入队列)
|
||||
this._schedule(5000, () => this._triggerMessage(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
// 消息16(妈妈长语音):MOM_LINK=false时跳过,直接触发结局
|
||||
if (msg.requiresMomLink && !this.vars.MOM_LINK) {
|
||||
this.triggerFinalEnding();
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录为未读
|
||||
this.msgState[msg.id] = 'unread';
|
||||
this.vars.UNREAD++;
|
||||
this.currentMsg = msg;
|
||||
|
||||
if (msg.type === 'call') {
|
||||
await this._startRinging(msg);
|
||||
} else if (msg.type === 'wechat_voice' || msg.type === 'wechat_text') {
|
||||
await this._notifyMessage(msg);
|
||||
} else if (msg.type === 'system_notification') {
|
||||
await this._notifySystem(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 来电流程 ──
|
||||
async _startRinging(msg) {
|
||||
this.state = 'ringing';
|
||||
this.currentMsg = msg;
|
||||
|
||||
Haptic.ring();
|
||||
await Snd.startRingtone(msg.ringtone);
|
||||
|
||||
// 超时自动漏接
|
||||
this._ringTimer = setTimeout(() => {
|
||||
this._missCall(msg);
|
||||
}, msg.ringDuration * 1000);
|
||||
|
||||
await TTS.speak(`${msg.senderName},来电话了。`, { rate: 0.9, volume: 0.7 });
|
||||
}
|
||||
|
||||
_missCall(msg) {
|
||||
Snd.stopRingtone();
|
||||
Haptic.stop();
|
||||
this.state = 'playing';
|
||||
this.msgState[msg.id] = 'missed';
|
||||
this.profile.add(msg.profileOnMiss);
|
||||
if (msg.id === 1 || msg.id === 14) {
|
||||
// 妈妈漏接不影响 MOM_LINK(需主动接才算)
|
||||
}
|
||||
}
|
||||
|
||||
_answerCall(msg) {
|
||||
clearTimeout(this._ringTimer);
|
||||
Snd.stopRingtone();
|
||||
Haptic.confirm();
|
||||
this.state = 'in_call';
|
||||
this.msgState[msg.id] = 'read';
|
||||
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
|
||||
this.profile.add(msg.profileOnAnswer);
|
||||
|
||||
if (msg.stateOnAnswer) this._applyState(msg.stateOnAnswer);
|
||||
|
||||
// 播放通话内容
|
||||
Snd.playVoice(msg.audio, {
|
||||
onEnd: () => {
|
||||
if (msg.inCallChoices) {
|
||||
this._startInCallChoice(msg);
|
||||
} else if (msg.autoHangup) {
|
||||
setTimeout(() => this._endCall(msg), msg.autoHangup * 1000);
|
||||
} else {
|
||||
this._endCall(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _startInCallChoice(msg) {
|
||||
this.state = 'detail_reply';
|
||||
this.currentChoices = msg.inCallChoices;
|
||||
this.awaitingChoice = true;
|
||||
await this._readChoices(msg.inCallChoices);
|
||||
}
|
||||
|
||||
_endCall(msg) {
|
||||
this.state = 'playing';
|
||||
this.currentMsg = null;
|
||||
if (msg.sfxAfter) Snd.playVoice([msg.sfxAfter]);
|
||||
}
|
||||
|
||||
_rejectCall(msg) {
|
||||
clearTimeout(this._ringTimer);
|
||||
Snd.stopRingtone();
|
||||
Haptic.reject();
|
||||
this.state = 'playing';
|
||||
this.msgState[msg.id] = 'missed';
|
||||
this.profile.add(msg.profileOnMiss);
|
||||
}
|
||||
|
||||
// ── 微信消息流程 ──
|
||||
async _notifyMessage(msg) {
|
||||
Haptic.message();
|
||||
const type = msg.type === 'wechat_text' ? '文字消息' : '语音';
|
||||
await TTS.speak(`${msg.senderName},发来${type}。`, { rate: 0.9, volume: 0.7 });
|
||||
this.state = 'msg_incoming';
|
||||
// 等待玩家交互(单点播放 / 不动保持未读)
|
||||
}
|
||||
|
||||
async _playMessage(msg) {
|
||||
this.state = 'msg_playing';
|
||||
this.msgState[msg.id] = 'read';
|
||||
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
|
||||
|
||||
// 开始BGM(如果消息指定)
|
||||
if (msg.bgm) Snd.startBGM(msg.bgm, { vol: 0.25 });
|
||||
|
||||
if (msg.type === 'wechat_text') {
|
||||
// 文字消息:TTS 朗读
|
||||
await TTS.speak(msg.text, { rate: 0.85 });
|
||||
this._afterMessagePlayed(msg);
|
||||
} else {
|
||||
// 语音消息:播放音频
|
||||
Snd.playVoice(msg.audio, {
|
||||
onEnd: () => {
|
||||
// 如果是小美醉语音,5秒后撤回
|
||||
if (msg.followUp?.action === 'xiaomei_recall') {
|
||||
setTimeout(() => {
|
||||
TTS.speak('小美撤回了刚才的语音。但你已经听过了。', { rate: 0.85, volume: 0.7 });
|
||||
}, msg.followUp.delaySec * 1000);
|
||||
}
|
||||
this._afterMessagePlayed(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_afterMessagePlayed(msg) {
|
||||
this.state = 'await_reply';
|
||||
this.currentMsg = msg;
|
||||
// 等待玩家选择回应方式
|
||||
// 单点=嗯 / 双点=标准回复 / 长按=细回复 / 双指上滑=已读不回 / 不动=本已读
|
||||
}
|
||||
|
||||
// ── 系统通知(时光胶囊)──
|
||||
async _notifySystem(msg) {
|
||||
Haptic.system();
|
||||
Snd.playVoice([msg.systemAudio]);
|
||||
await sleep(3000);
|
||||
await TTS.speak(msg.text, { rate: 0.85, volume: 0.8 });
|
||||
this.state = 'msg_incoming';
|
||||
this.currentMsg = msg;
|
||||
// 单点=播放, 双指上滑=跳过
|
||||
}
|
||||
|
||||
async _playSystemMessage(msg) {
|
||||
const profileLabel = this.profile.label;
|
||||
const audioUrl = msg.audioByProfile[profileLabel];
|
||||
this.msgState[msg.id] = 'read';
|
||||
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
|
||||
this._applyState(msg.stateOnPlay || {});
|
||||
this.profile.add(msg.profileOnPlay);
|
||||
|
||||
this.state = 'msg_playing';
|
||||
Snd.playVoice([audioUrl], {
|
||||
onEnd: () => {
|
||||
this.state = 'playing';
|
||||
this._checkEndingConditions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 细回复流程
|
||||
// ══════════════════════════════════════════
|
||||
async _enterDetailReply(msg) {
|
||||
if (!msg.detailReplies) return;
|
||||
this.state = 'detail_reply';
|
||||
this.currentChoices = msg.detailReplies;
|
||||
this.awaitingChoice = true;
|
||||
Haptic.detailMode();
|
||||
await this._readChoices(msg.detailReplies);
|
||||
}
|
||||
|
||||
async _readChoices(choices) {
|
||||
const nums = ['一', '二', '三', '四'];
|
||||
for (let i = 0; i < choices.length; i++) {
|
||||
if (!this.awaitingChoice) return;
|
||||
await TTS.speak(`${nums[i]}……`, { rate: 0.75, volume: 0.65 });
|
||||
await sleep(200);
|
||||
await TTS.speak(choices[i].label, { rate: 0.85, volume: 0.85 });
|
||||
await sleep(400);
|
||||
}
|
||||
// 读完后震动提示"可以选了"
|
||||
if (this.awaitingChoice) Haptic.confirm();
|
||||
}
|
||||
|
||||
_selectDetailChoice(dir) {
|
||||
if (!this.awaitingChoice || !this.currentChoices) return;
|
||||
const choice = this.currentChoices.find(c => c.dir === dir);
|
||||
if (!choice) { Haptic.error(); return; }
|
||||
|
||||
this.awaitingChoice = false;
|
||||
TTS.stop();
|
||||
Haptic.select();
|
||||
|
||||
const msg = this.currentMsg;
|
||||
|
||||
if (choice.isSilence) {
|
||||
// 玩家选择不说话
|
||||
TTS.speak('(沉默)', { rate: 0.8, volume: 0.5 });
|
||||
} else {
|
||||
TTS.speak(`发送:${choice.label}`, { rate: 0.85, volume: 0.7 });
|
||||
this.msgState[msg.id] = 'replied';
|
||||
this.profile.onDetailReply();
|
||||
}
|
||||
|
||||
if (choice.state) this._applyState(choice.state);
|
||||
if (choice.profile) this.profile.add(choice.profile);
|
||||
|
||||
// 周南相关深度追踪
|
||||
if (choice.state?.ZHOUNAN_DEPTH_ADD) {
|
||||
this.vars.ZHOUNAN_DEPTH += 1;
|
||||
}
|
||||
if (choice.state?.ZHOUNAN_SHARE) {
|
||||
this.vars.ZHOUNAN_SHARE = true;
|
||||
}
|
||||
|
||||
this.state = 'playing';
|
||||
this.currentChoices = null;
|
||||
this._checkEndingConditions();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 手势处理器
|
||||
// ══════════════════════════════════════════
|
||||
_onSingleTap() {
|
||||
const s = this.state;
|
||||
const msg = this.currentMsg;
|
||||
|
||||
if (s === 'ringing') {
|
||||
this._answerCall(msg);
|
||||
}
|
||||
else if (s === 'msg_incoming') {
|
||||
if (msg.type === 'system_notification') {
|
||||
this._playSystemMessage(msg);
|
||||
} else {
|
||||
this._playMessage(msg);
|
||||
}
|
||||
}
|
||||
else if (s === 'await_reply') {
|
||||
// 单点 = 发"嗯"
|
||||
if (msg?.type !== 'call') {
|
||||
TTS.speak('发送:嗯。', { rate: 0.9, volume: 0.7 });
|
||||
this.msgState[msg.id] = 'replied';
|
||||
this.profile.onSimpleReply();
|
||||
if (msg.stateOnStdReply) this._applyState(msg.stateOnStdReply);
|
||||
this.state = 'playing';
|
||||
this._checkEndingConditions();
|
||||
}
|
||||
}
|
||||
else if (s === 'detail_reply' && this.awaitingChoice) {
|
||||
// 在细回复中单点 = 重听选项
|
||||
this._readChoices(this.currentChoices);
|
||||
}
|
||||
else if (s === 'inbox') {
|
||||
this._inboxSelect();
|
||||
}
|
||||
else if (s === 'playing') {
|
||||
const unread = this.vars.UNREAD;
|
||||
if (unread > 0) {
|
||||
TTS.speak(`${unread}条未读消息。上滑打开收件箱。`, { rate: 0.9, volume: 0.6 });
|
||||
} else {
|
||||
TTS.speak('暂无新消息。', { rate: 0.9, volume: 0.6 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onDoubleTap() {
|
||||
const s = this.state;
|
||||
const msg = this.currentMsg;
|
||||
|
||||
if (s === 'await_reply' && msg) {
|
||||
// 双点 = 发标准回复
|
||||
const reply = msg.standardReply || '嗯,好的。';
|
||||
TTS.speak(`发送:${reply}`, { rate: 0.9, volume: 0.7 });
|
||||
this.msgState[msg.id] = 'replied';
|
||||
this.profile.onDetailReply();
|
||||
if (msg.stateOnStdReply) this._applyState(msg.stateOnStdReply);
|
||||
this.state = 'playing';
|
||||
this._checkEndingConditions();
|
||||
}
|
||||
else if (s === 'msg_incoming') {
|
||||
// 双点 = 直接回"嗯"不听
|
||||
if (msg?.type === 'wechat_text') {
|
||||
TTS.speak('发送:嗯。', { rate: 0.9, volume: 0.7 });
|
||||
this.msgState[msg.id] = 'replied';
|
||||
this.profile.onSimpleReply();
|
||||
this.state = 'playing';
|
||||
}
|
||||
}
|
||||
else if (s === 'ringing') {
|
||||
// 双点来电 = 同样接听
|
||||
this._answerCall(msg);
|
||||
}
|
||||
}
|
||||
|
||||
_onLongPress1s() {
|
||||
const s = this.state;
|
||||
const msg = this.currentMsg;
|
||||
|
||||
if (s === 'await_reply' && msg?.detailReplies) {
|
||||
this._enterDetailReply(msg);
|
||||
}
|
||||
else if (s === 'in_call' && msg?.inCallChoices) {
|
||||
this._startInCallChoice(msg);
|
||||
}
|
||||
else if (s === 'detail_reply' && this.awaitingChoice) {
|
||||
// 重听选项
|
||||
TTS.stop();
|
||||
this._readChoices(this.currentChoices);
|
||||
}
|
||||
}
|
||||
|
||||
_onLongPress5s() {
|
||||
// 强制挂断
|
||||
if (this.state === 'in_call' || this.state === 'ringing') {
|
||||
clearTimeout(this._ringTimer);
|
||||
Snd.stopRingtone();
|
||||
Snd.stopVoice();
|
||||
Haptic.reject();
|
||||
TTS.speak('已挂断。', { rate: 0.9, volume: 0.7 });
|
||||
this.state = 'playing';
|
||||
}
|
||||
}
|
||||
|
||||
_onSwipe(dir) {
|
||||
const s = this.state;
|
||||
const msg = this.currentMsg;
|
||||
|
||||
// 细回复 / 通话选项:四个方向用于选择
|
||||
if ((s === 'detail_reply' || (s === 'in_call' && this.awaitingChoice)) && this.currentChoices) {
|
||||
this._selectDetailChoice(dir);
|
||||
return;
|
||||
}
|
||||
|
||||
// 下滑 = 查询当前状态(任意场景可用)
|
||||
if (dir === 'down') {
|
||||
this._announceStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
// 上滑 = 打开收件箱(主屏幕时)
|
||||
if (dir === 'up') {
|
||||
if (s === 'playing') this._onInboxOpen();
|
||||
return;
|
||||
}
|
||||
|
||||
// 右滑 = 接听(来电时)
|
||||
if (dir === 'right') {
|
||||
if (s === 'ringing') this._answerCall(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// 左滑 = 拒接 / 挂断 / 忽略 / 已读不回 / 返回
|
||||
if (dir === 'left') {
|
||||
if (s === 'ringing') {
|
||||
this._rejectCall(msg);
|
||||
}
|
||||
else if (s === 'in_call') {
|
||||
Snd.stopVoice();
|
||||
Haptic.reject();
|
||||
TTS.speak('已挂断。', { rate: 0.9, volume: 0.7 });
|
||||
this.state = 'playing';
|
||||
this.currentMsg = null;
|
||||
this.awaitingChoice = false;
|
||||
}
|
||||
else if (s === 'msg_incoming') {
|
||||
if (msg?.type === 'system_notification') {
|
||||
TTS.speak('已跳过。', { rate: 0.9, volume: 0.6 });
|
||||
this.msgState[msg.id] = 'read';
|
||||
this.profile.add(msg.profileOnSkip);
|
||||
} else {
|
||||
TTS.speak('(消息保持未读)', { rate: 0.85, volume: 0.5 });
|
||||
}
|
||||
this.state = 'playing';
|
||||
this.currentMsg = null;
|
||||
}
|
||||
else if (s === 'await_reply' && msg) {
|
||||
TTS.speak('(已读不回)', { rate: 0.85, volume: 0.6 });
|
||||
this.msgState[msg.id] = 'read';
|
||||
this.profile.onReadNoReply();
|
||||
this.state = 'playing';
|
||||
}
|
||||
else if (s === 'inbox') {
|
||||
this.state = 'playing';
|
||||
this._inboxMessages = null;
|
||||
TTS.speak('返回主屏幕。', { rate: 0.9, volume: 0.6 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 下滑:播报当前状态和可用操作 ──
|
||||
_announceStatus() {
|
||||
const s = this.state;
|
||||
const msg = this.currentMsg;
|
||||
|
||||
if (s === 'playing') {
|
||||
const unread = this.vars.UNREAD;
|
||||
if (unread > 0) {
|
||||
TTS.speak(`主屏幕,${unread}条未读。上滑查看收件箱。`, { rate: 0.9, volume: 0.7 });
|
||||
} else {
|
||||
TTS.speak('主屏幕,暂无新消息。', { rate: 0.9, volume: 0.7 });
|
||||
}
|
||||
}
|
||||
else if (s === 'ringing') {
|
||||
TTS.speak(`${msg?.senderName}来电中。单点接听,左滑拒接。`, { rate: 0.9, volume: 0.7 });
|
||||
}
|
||||
else if (s === 'in_call') {
|
||||
TTS.speak('通话中。左滑挂断。', { rate: 0.9, volume: 0.7 });
|
||||
}
|
||||
else if (s === 'msg_incoming') {
|
||||
TTS.speak(`${msg?.senderName}的新消息。单点播放,左滑忽略。`, { rate: 0.9, volume: 0.7 });
|
||||
}
|
||||
else if (s === 'msg_playing') {
|
||||
TTS.speak('消息播放中。', { rate: 0.9, volume: 0.7 });
|
||||
}
|
||||
else if (s === 'await_reply') {
|
||||
TTS.speak(`${msg?.senderName}的消息已播完。单点回嗯,双点标准回复,长按细回复,左滑已读不回。`, { rate: 0.9, volume: 0.7 });
|
||||
}
|
||||
else if (s === 'detail_reply') {
|
||||
TTS.speak('细回复选项中。滑动选择,单点重听。', { rate: 0.9, volume: 0.7 });
|
||||
}
|
||||
else if (s === 'inbox') {
|
||||
TTS.speak('收件箱。单点进入最新消息,左滑返回。', { rate: 0.9, volume: 0.7 });
|
||||
}
|
||||
}
|
||||
|
||||
async _onInboxOpen() {
|
||||
if (this.state === 'in_call') return;
|
||||
|
||||
const prevState = this.state;
|
||||
this.state = 'inbox';
|
||||
Haptic.system();
|
||||
|
||||
// 统计未读/未回
|
||||
const unreadMsgs = MESSAGES.filter(m =>
|
||||
!this.msgState[m.id] || this.msgState[m.id] === 'unread'
|
||||
);
|
||||
|
||||
if (unreadMsgs.length === 0) {
|
||||
await TTS.speak('收件箱已空。', { rate: 0.9, volume: 0.7 });
|
||||
this.state = prevState;
|
||||
return;
|
||||
}
|
||||
|
||||
await TTS.speak(`收件箱:${unreadMsgs.length}条未读。`, { rate: 0.9, volume: 0.7 });
|
||||
|
||||
for (const m of unreadMsgs.slice(0, 5)) {
|
||||
const sender = SENDERS[m.sender];
|
||||
const type = m.type === 'call' ? '未接来电' : m.type === 'wechat_text' ? '文字' : '语音';
|
||||
await TTS.speak(`${sender?.name || m.senderName},${type}。`, { rate: 0.85, volume: 0.7 });
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
await TTS.speak('单点进入最新一条,左滑返回。', { rate: 0.85, volume: 0.6 });
|
||||
|
||||
this._inboxMessages = unreadMsgs;
|
||||
// 5秒后自动退出收件箱
|
||||
setTimeout(() => {
|
||||
if (this.state === 'inbox') {
|
||||
this.state = prevState;
|
||||
this._inboxMessages = null;
|
||||
}
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
_inboxSelect() {
|
||||
if (!this._inboxMessages || this._inboxMessages.length === 0) {
|
||||
this.state = 'playing';
|
||||
return;
|
||||
}
|
||||
const msg = this._inboxMessages[0];
|
||||
this._inboxMessages = null;
|
||||
this.currentMsg = msg;
|
||||
this.state = 'playing';
|
||||
// 触发该消息
|
||||
this._triggerMessage(msg);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 状态变量应用
|
||||
// ══════════════════════════════════════════
|
||||
_applyState(changes) {
|
||||
for (const [k, v] of Object.entries(changes)) {
|
||||
if (k === 'ZHOUNAN_DEPTH_ADD') {
|
||||
this.vars.ZHOUNAN_DEPTH += (v || 1);
|
||||
} else if (v !== null) {
|
||||
this.vars[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 结局判定
|
||||
// ══════════════════════════════════════════
|
||||
_checkEndingConditions() {
|
||||
// 只在消息16触发后真正判定结局
|
||||
// 这里只做中途检测
|
||||
const unread = Object.values(this.msgState).filter(s => s === 'unread').length;
|
||||
|
||||
// 结局D:未读≥8时在01:00前触发
|
||||
if (unread >= 8 && Date.now() - this._startTime >= gt(1, 0) * 1000) {
|
||||
if (!this._endingTriggered) this._triggerEnding('D');
|
||||
}
|
||||
}
|
||||
|
||||
async triggerFinalEnding() {
|
||||
if (this._endingTriggered) return;
|
||||
|
||||
const v = this.vars;
|
||||
const unread = Object.values(this.msgState).filter(s => s === 'unread' || !this.msgState[s]).length;
|
||||
|
||||
// 结局 E(隐藏)
|
||||
if (v.ZHOUNAN_DEPTH >= 2 && v.ZHOUNAN_SHARE &&
|
||||
(this.profile.label === 'engagement' || this.profile.label === 'nostalgia')) {
|
||||
return this._triggerEnding('E');
|
||||
}
|
||||
|
||||
// 结局 A:妈妈连接+自己录音+有回复
|
||||
if (v.MOM_LINK && v.SELF_RECORD) {
|
||||
return this._triggerEnding('A');
|
||||
}
|
||||
|
||||
// 结局 B:拒了阿哲+回了周南
|
||||
if (v.HE_BACK === false && v.ZHOUNAN_DEPTH >= 1) {
|
||||
return this._triggerEnding('B');
|
||||
}
|
||||
|
||||
// 结局 C:全部已读但回复≤2
|
||||
const repliedCount = Object.values(this.msgState).filter(s => s === 'replied').length;
|
||||
const readCount = Object.values(this.msgState).filter(s => s !== 'unread').length;
|
||||
if (readCount >= MESSAGES.length - 2 && repliedCount <= 2) {
|
||||
return this._triggerEnding('C');
|
||||
}
|
||||
|
||||
// 结局 D(兜底)
|
||||
this._triggerEnding('D');
|
||||
}
|
||||
|
||||
async _triggerEnding(type) {
|
||||
if (this._endingTriggered) return;
|
||||
this._endingTriggered = true;
|
||||
this.state = 'ending';
|
||||
|
||||
Snd.stopBGM(3000);
|
||||
await sleep(2000);
|
||||
|
||||
const ENDINGS = {
|
||||
A: this._endingA.bind(this),
|
||||
B: this._endingB.bind(this),
|
||||
C: this._endingC.bind(this),
|
||||
D: this._endingD.bind(this),
|
||||
E: this._endingE.bind(this),
|
||||
};
|
||||
|
||||
if (ENDINGS[type]) await ENDINGS[type]();
|
||||
}
|
||||
|
||||
async _endingA() {
|
||||
// 妈妈语音播完→厨房→烧水
|
||||
Snd.startAmbience(`${AMB}/amb_kitchen_morning_v1.wav`, { vol: 0.3 });
|
||||
await sleep(2000);
|
||||
Snd.playVoice([`${SFX}/sfx_kettle_whistle_v1.wav`]);
|
||||
await sleep(4000);
|
||||
this._showCaption('明天再说。');
|
||||
await sleep(3000);
|
||||
Snd.playVoice([`${SFX}/sfx_ending_chime_v1.wav`]);
|
||||
}
|
||||
|
||||
async _endingB() {
|
||||
await TTS.speak('寄出。', { rate: 0.8, volume: 0.8 });
|
||||
await sleep(4000);
|
||||
this._showCaption('寄出。');
|
||||
}
|
||||
|
||||
async _endingC() {
|
||||
await TTS.speak(
|
||||
`今晚共${MESSAGES.length}条新消息,已读${Object.values(this.msgState).filter(s => s !== 'unread').length},未回复${MESSAGES.length - Object.values(this.msgState).filter(s => s === 'replied').length}。`,
|
||||
{ rate: 0.85, volume: 0.8 }
|
||||
);
|
||||
await sleep(6000);
|
||||
this._showCaption('今晚就这样。');
|
||||
}
|
||||
|
||||
async _endingD() {
|
||||
Snd.playVoice([`${TDIR}/lv11_sys_battery_01_wav_v1.wav`]);
|
||||
await sleep(2000);
|
||||
Haptic.ring();
|
||||
await sleep(1000);
|
||||
// 屏幕"彻底黑了"→清晨鸟叫
|
||||
await sleep(3000);
|
||||
Snd.startAmbience(`${AMB}/amb_dawn_birds_v1.wav`, { vol: 0.4 });
|
||||
await sleep(4000);
|
||||
this._showCaption('早上好。');
|
||||
}
|
||||
|
||||
async _endingE() {
|
||||
// 周南秒回
|
||||
await sleep(2000);
|
||||
await TTS.speak('周南发来语音。', { rate: 0.9, volume: 0.7 });
|
||||
await TTS.speak('那我下次出差来北京,请你吃饭好不好。', { rate: 0.9 });
|
||||
await sleep(2000);
|
||||
// 林夏的轻笑(用self3y音色)
|
||||
Snd.playVoice([`${TDIR}/lv11_sys_goodnight_wav_v1.wav`]);
|
||||
await sleep(3000);
|
||||
this._showCaption('好。');
|
||||
await sleep(2000);
|
||||
Snd.playVoice([`${SFX}/sfx_ending_chime_v1.wav`]);
|
||||
}
|
||||
|
||||
_showCaption(text) {
|
||||
const el = document.getElementById('caption');
|
||||
el.textContent = text;
|
||||
el.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// gestures.js — 触控手势检测器(单指操作)
|
||||
//
|
||||
// 支持:单点 · 双点 · 长按1s · 长按5s
|
||||
// 左滑 · 右滑 · 上滑 · 下滑
|
||||
|
||||
class GestureDetector {
|
||||
constructor(el) {
|
||||
this._el = el;
|
||||
this._handlers = {};
|
||||
|
||||
// 状态
|
||||
this._startX = 0;
|
||||
this._startY = 0;
|
||||
this._startTime = 0;
|
||||
|
||||
this._tapTimer = null;
|
||||
this._longTimer1 = null;
|
||||
this._longTimer5 = null;
|
||||
this._longFired = false;
|
||||
this._tapCount = 0;
|
||||
|
||||
this._bind();
|
||||
}
|
||||
|
||||
on(evt, fn) { this._handlers[evt] = fn; return this; }
|
||||
|
||||
_emit(evt, data) {
|
||||
dbg(evt);
|
||||
const fn = this._handlers[evt];
|
||||
if (fn) fn(data);
|
||||
}
|
||||
|
||||
_bind() {
|
||||
const el = this._el;
|
||||
el.addEventListener('touchstart', e => this._onStart(e), { passive: false });
|
||||
el.addEventListener('touchend', e => this._onEnd(e), { passive: false });
|
||||
el.addEventListener('touchcancel', e => this._onCancel(e), { passive: false });
|
||||
}
|
||||
|
||||
_onStart(e) {
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
this._startX = t.clientX;
|
||||
this._startY = t.clientY;
|
||||
this._startTime = Date.now();
|
||||
this._longFired = false;
|
||||
|
||||
this._clearTimers();
|
||||
|
||||
// 1s长按
|
||||
this._longTimer1 = setTimeout(() => {
|
||||
this._longFired = true;
|
||||
this._emit('longpress1s');
|
||||
Haptic.detailMode();
|
||||
}, 900);
|
||||
|
||||
// 5s长按(安全机制)
|
||||
this._longTimer5 = setTimeout(() => {
|
||||
this._longFired = true;
|
||||
this._emit('longpress5s');
|
||||
Haptic.reject();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
_onEnd(e) {
|
||||
e.preventDefault();
|
||||
this._clearTimers();
|
||||
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - this._startX;
|
||||
const dy = t.clientY - this._startY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const dt = Date.now() - this._startTime;
|
||||
|
||||
if (this._longFired) return;
|
||||
|
||||
// ── 滑动判断 ──
|
||||
const SWIPE_MIN = 35;
|
||||
const SWIPE_ANG = 45;
|
||||
|
||||
if (dist > SWIPE_MIN) {
|
||||
const angle = Math.abs(Math.atan2(dy, dx) * 180 / Math.PI);
|
||||
let dir = null;
|
||||
|
||||
if (angle < SWIPE_ANG) dir = 'right';
|
||||
else if (angle > 180 - SWIPE_ANG) dir = 'left';
|
||||
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._emit(`swipe_${dir}`);
|
||||
Haptic.confirm();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 点击判断 ──
|
||||
if (dist < 20 && dt < 500) {
|
||||
this._tapCount++;
|
||||
if (this._tapCount === 1) {
|
||||
this._tapTimer = setTimeout(() => {
|
||||
this._tapCount = 0;
|
||||
this._emit('singletap');
|
||||
Haptic.confirm();
|
||||
}, 250);
|
||||
} else if (this._tapCount === 2) {
|
||||
clearTimeout(this._tapTimer);
|
||||
this._tapCount = 0;
|
||||
this._emit('doubletap');
|
||||
Haptic.confirm();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onCancel(e) {
|
||||
this._clearTimers();
|
||||
this._longFired = false;
|
||||
}
|
||||
|
||||
_clearTimers() {
|
||||
clearTimeout(this._tapTimer);
|
||||
clearTimeout(this._longTimer1);
|
||||
clearTimeout(this._longTimer5);
|
||||
this._tapTimer = null;
|
||||
this._longTimer1 = null;
|
||||
this._longTimer5 = null;
|
||||
}
|
||||
}
|
||||
@@ -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,73 @@
|
||||
// main.js — 游戏入口
|
||||
|
||||
// 开发模式(URL加?dev):时间线缩短为1/10
|
||||
const DEV_MODE = new URLSearchParams(location.search).has('dev');
|
||||
if (DEV_MODE) {
|
||||
// 直接缩短所有消息的触发时间
|
||||
MESSAGES.forEach(m => { m.triggerAt = Math.floor(m.triggerAt / 10); });
|
||||
document.title = '林夏 [DEV x10]';
|
||||
}
|
||||
|
||||
let engine = null;
|
||||
let gestures = null;
|
||||
|
||||
async function main() {
|
||||
TTS.init();
|
||||
|
||||
// 初始化音频(必须在用户手势后)
|
||||
await Snd.init();
|
||||
|
||||
// 预加载关键短音效
|
||||
await Promise.allSettled([
|
||||
Snd.preload('sfx_keyboard', `${SFX}/sfx_keyboard_slow_v1.wav`),
|
||||
Snd.preload('sfx_chime', `${SFX}/sfx_ending_chime_v1.wav`),
|
||||
]);
|
||||
|
||||
// 请求屏幕常亮(Android Chrome 支持)
|
||||
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_MODE) {
|
||||
document.getElementById('dbg').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 触摸启动(手机)
|
||||
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());
|
||||
|
||||
// 页面隐藏时停止打字音效
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && Snd) Snd.stopTyping();
|
||||
});
|
||||
@@ -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,358 @@
|
||||
// story.js — 《林夏》全部16条消息数据
|
||||
|
||||
const A = '../audio/00_raw';
|
||||
const TDIR = `${A}/tts`;
|
||||
const MUS = `${A}/music`;
|
||||
const SFX = `${A}/sfx`;
|
||||
const AMB = `${A}/ambience`;
|
||||
|
||||
// 游戏时间压缩比:5小时 → 60分钟(实际秒)
|
||||
// 1 游戏分钟 = 12 实际秒
|
||||
// triggerAt = (游戏时间 - 21:00) 的分钟数 × 12
|
||||
function gt(hh, mm) {
|
||||
const gameMin = (hh < 21 ? hh + 24 : hh) * 60 + mm - 21 * 60;
|
||||
return gameMin * 12; // 实际秒
|
||||
}
|
||||
|
||||
const MESSAGES = [
|
||||
|
||||
// ── 01 · 21:03 · 妈妈 · 电话 ──────────────────────────────────
|
||||
{
|
||||
id: 1, gameTime: '21:03', triggerAt: gt(21, 3),
|
||||
type: 'call',
|
||||
sender: 'mom', senderName: '妈妈',
|
||||
ringtone: `${MUS}/lv11_ring_mom_v1.wav`,
|
||||
ringDuration: 28,
|
||||
audio: [`${TDIR}/lv11_2103_mom_call_01_wav_v1.wav`],
|
||||
note: '今晚煮了你爱吃的排骨',
|
||||
stateOnAnswer: { MOM_LINK: true },
|
||||
profileOnMiss: { avoidance: 2 },
|
||||
profileOnAnswer:{ engagement: 1 },
|
||||
},
|
||||
|
||||
// ── 02 · 21:15 · 小美 · 微信语音×3(教学关)──────────────────
|
||||
{
|
||||
id: 2, gameTime: '21:15', triggerAt: gt(21, 15),
|
||||
type: 'wechat_voice',
|
||||
sender: 'xiaomei', senderName: '小美',
|
||||
audio: [
|
||||
`${TDIR}/lv11_2115_xiaomei_voice_01_wav_v1.wav`,
|
||||
`${TDIR}/lv11_2115_xiaomei_voice_02_wav_v1.wav`,
|
||||
`${TDIR}/lv11_2115_xiaomei_voice_03_wav_v1.wav`,
|
||||
],
|
||||
note: '你被甩了我都听说了,明天喝酒',
|
||||
isTutorial: true,
|
||||
detailReplies: [
|
||||
{ label: '好啊,明天不见不散。', dir: 'left', profile: { engagement: 1 } },
|
||||
{ label: '我不太想出门……', dir: 'right', profile: { nostalgia: 1 } },
|
||||
{ label: '你先去,我再说。', dir: 'up', profile: { avoidance: 1 } },
|
||||
{ label: '(沉默)', dir: 'down', isSilence: true, profile: { avoidance: 2 } },
|
||||
],
|
||||
standardReply: '好啊。',
|
||||
profileOnRead: { engagement: 1 },
|
||||
profileOnAvoid: { avoidance: 1 },
|
||||
},
|
||||
|
||||
// ── 03 · 21:20 · 外卖小哥 · 电话 ─────────────────────────────
|
||||
{
|
||||
id: 3, gameTime: '21:20', triggerAt: gt(21, 20),
|
||||
type: 'call',
|
||||
sender: 'delivery', senderName: '外卖小哥',
|
||||
ringtone: `${MUS}/lv11_ring_delivery_v1.wav`,
|
||||
ringDuration: 20,
|
||||
audio: [`${TDIR}/lv11_2120_delivery_call_01_wav_v1.wav`],
|
||||
note: '你好,你的麻辣烫到了',
|
||||
profileOnMiss: { avoidance: 1 },
|
||||
profileOnAnswer:{ engagement: 1 },
|
||||
},
|
||||
|
||||
// ── 04 · 21:30 · 阿哲 · 微信语音(第一刀)────────────────────
|
||||
{
|
||||
id: 4, gameTime: '21:30', triggerAt: gt(21, 30),
|
||||
type: 'wechat_voice',
|
||||
sender: 'azhe', senderName: '阿哲',
|
||||
audio: [`${TDIR}/lv11_2130_azhe_voice_01_wav_v1.wav`],
|
||||
note: 'AirPods落你那了,方便寄一下吗',
|
||||
detailReplies: [
|
||||
{ label: '好,明天寄。', dir: 'left', state: { HE_BACK: false }, profile: { engagement: 1 } },
|
||||
{ label: '你怎么不自己来拿。', dir: 'right', state: {}, profile: { engagement: 2 } },
|
||||
{ label: '你那边……都好吗。', dir: 'up', state: {}, profile: { engagement: 2 } },
|
||||
{ label: '(什么都不说)', dir: 'down', isSilence: true, state: { HE_BACK: null }, profile: { avoidance: 2 } },
|
||||
],
|
||||
standardReply: '好,明天寄给你。',
|
||||
stateOnStdReply: { HE_BACK: false },
|
||||
// 10分钟后阿哲撤回消息(如果玩家未回复)
|
||||
followUp: { delayMin: 10, action: 'azhe_recall' },
|
||||
profileOnRead: { engagement: 0 },
|
||||
profileOnAvoid: { avoidance: 1 },
|
||||
},
|
||||
|
||||
// ── 05 · 21:45 · HR王姐 · 微信语音 ───────────────────────────
|
||||
{
|
||||
id: 5, gameTime: '21:45', triggerAt: gt(21, 45),
|
||||
type: 'wechat_voice',
|
||||
sender: 'hr', senderName: 'HR王姐',
|
||||
audio: [`${TDIR}/lv11_2145_hr_voice_01_wav_v1.wav`],
|
||||
note: '离职流程周三前办完哈',
|
||||
detailReplies: [
|
||||
{ label: '好的,我知道了。谢谢王姐。', dir: 'left', profile: { engagement: 1 } },
|
||||
{ label: '王姐,有什么建议吗?', dir: 'right', profile: { engagement: 2 } },
|
||||
{ label: '……谢谢你。', dir: 'up', profile: { nostalgia: 1 } },
|
||||
{ label: '(不回复)', dir: 'down', isSilence: true, profile: { avoidance: 1 } },
|
||||
],
|
||||
standardReply: '好的,谢谢王姐。',
|
||||
profileOnRead: { engagement: 1 },
|
||||
profileOnAvoid: { avoidance: 1 },
|
||||
},
|
||||
|
||||
// ── 06 · 22:00 · 安安 · 微信语音(酒吧背景)─────────────────
|
||||
{
|
||||
id: 6, gameTime: '22:00', triggerAt: gt(22, 0),
|
||||
type: 'wechat_voice',
|
||||
sender: 'anan', senderName: '安安',
|
||||
audio: [`${TDIR}/lv11_2200_anan_voice_01_wav_v1.wav`],
|
||||
note: '我在helens!要不要过来!',
|
||||
bgUnder: `${AMB}/amb_bar_loud_01_v1.wav`, // 酒吧底噪混入
|
||||
detailReplies: [
|
||||
{ label: '我今晚不方便,你们玩好啊。', dir: 'left', profile: { avoidance: 1 } },
|
||||
{ label: '在哪儿啊?我……看看吧。', dir: 'right', profile: { engagement: 1 } },
|
||||
{ label: '你喝了多少了哈哈。', dir: 'up', profile: { engagement: 1 } },
|
||||
{ label: '(不回复)', dir: 'down', isSilence: true, profile: { avoidance: 2 } },
|
||||
],
|
||||
standardReply: '不了,你们玩好~',
|
||||
profileOnRead: { engagement: 0 },
|
||||
profileOnAvoid: { avoidance: 1 },
|
||||
},
|
||||
|
||||
// ── 07 · 22:15 · 大学寝室群 · 5条语音 ────────────────────────
|
||||
{
|
||||
id: 7, gameTime: '22:15', triggerAt: gt(22, 15),
|
||||
type: 'wechat_voice',
|
||||
sender: 'dorm', senderName: '我们寝室的(4人群)',
|
||||
audio: [
|
||||
`${TDIR}/lv11_2215_dorm_a_voice_01_wav_v1.wav`,
|
||||
`${TDIR}/lv11_2215_dorm_a_voice_02_wav_v1.wav`,
|
||||
`${TDIR}/lv11_2215_dorm_b_voice_01_wav_v1.wav`,
|
||||
`${TDIR}/lv11_2215_dorm_b_voice_02_wav_v1.wav`,
|
||||
`${TDIR}/lv11_2215_dorm_c_voice_01_wav_v1.wav`,
|
||||
],
|
||||
note: '室友A升职了,最后她@你',
|
||||
isGroup: true,
|
||||
detailReplies: [
|
||||
{ label: '恭喜恭喜!', dir: 'left', state: { GROUP_REPLY: true }, profile: { engagement: 1 } },
|
||||
{ label: '哇,快讲讲!', dir: 'right', state: { GROUP_REPLY: true }, profile: { engagement: 2 } },
|
||||
{ label: '最近有点忙,你们聊~', dir: 'up', profile: { avoidance: 1 } },
|
||||
{ label: '(沉默)', dir: 'down', isSilence: true, profile: { avoidance: 2 } },
|
||||
],
|
||||
standardReply: '恭喜!',
|
||||
stateOnStdReply: { GROUP_REPLY: true },
|
||||
profileOnRepeat: { nostalgia: 2 },
|
||||
profileOnRead: { engagement: 0 },
|
||||
profileOnAvoid: { avoidance: 1 },
|
||||
},
|
||||
|
||||
// ── 08 · 22:30 · 未知号码 · 电话(悬疑钩子)─────────────────
|
||||
{
|
||||
id: 8, gameTime: '22:30', triggerAt: gt(22, 30),
|
||||
type: 'call',
|
||||
sender: 'unknown', senderName: '未知号码',
|
||||
ringtone: `${MUS}/lv11_ring_unknown_v1.wav`,
|
||||
ringDuration: 20,
|
||||
audio: [`${SFX}/sfx_breathing_unknown_v1.wav`],
|
||||
note: '接通后只有呼吸声,6秒挂断',
|
||||
autoHangup: 6, // 接通后自动挂断秒数
|
||||
sfxAfter: `${SFX}/sfx_heartbeat_fast_v1.wav`,
|
||||
profileOnMiss: { avoidance: 1 },
|
||||
profileOnAnswer:{ nostalgia: 1 },
|
||||
},
|
||||
|
||||
// ── 09 · 22:45 · 小美醉语音 · 4段(秘密揭晓)───────────────
|
||||
{
|
||||
id: 9, gameTime: '22:45', triggerAt: gt(22, 45),
|
||||
type: 'wechat_voice',
|
||||
sender: 'xiaomei', senderName: '小美',
|
||||
audio: [
|
||||
`${TDIR}/lv11_2245_xiaomei_voice_01_seg1_wav_v1.wav`,
|
||||
`${TDIR}/lv11_2245_xiaomei_voice_01_seg2_wav_v1.wav`,
|
||||
`${TDIR}/lv11_2245_xiaomei_voice_01_seg3_wav_v1.wav`,
|
||||
`${TDIR}/lv11_2245_xiaomei_voice_01_seg4_wav_v1.wav`,
|
||||
],
|
||||
note: '阿哲大三追过我,我没答应他',
|
||||
isDrunk: true,
|
||||
bgm: `${MUS}/lv11_bgm_m2_suspense_v1.wav`, // 悬疑BGM
|
||||
detailReplies: [
|
||||
{ label: '我知道了。', dir: 'left', profile: { avoidance: 1 } },
|
||||
{ label: '你……为什么现在才说。', dir: 'right', profile: { engagement: 2 } },
|
||||
{ label: '小美,你还好吗。', dir: 'up', profile: { engagement: 2 } },
|
||||
{ label: '(什么都不说)', dir: 'down', isSilence: true, profile: { avoidance: 2, nostalgia: 1 } },
|
||||
],
|
||||
standardReply: '我知道了。',
|
||||
// 5秒后消息被撤回
|
||||
followUp: { delaySec: 5, action: 'xiaomei_recall' },
|
||||
profileOnRepeat: { nostalgia: 2 },
|
||||
profileOnAvoid: { avoidance: 1, nostalgia: 1 },
|
||||
},
|
||||
|
||||
// ── 10 · 23:00 · 妈妈 · 微信文字 ─────────────────────────────
|
||||
{
|
||||
id: 10, gameTime: '23:00', triggerAt: gt(23, 0),
|
||||
type: 'wechat_text',
|
||||
sender: 'mom', senderName: '妈妈',
|
||||
text: '睡了吗?',
|
||||
note: '三个字,最痛',
|
||||
detailReplies: [
|
||||
{ label: '嗯,要睡了。', dir: 'left', state: { MOM_LINK: true }, profile: { avoidance: 1 } },
|
||||
{ label: '没有,怎么了。', dir: 'right', state: { MOM_LINK: true }, profile: { engagement: 1 } },
|
||||
{ label: '妈,你在吗,我想打电话。', dir: 'up', state: { MOM_LINK: true }, profile: { engagement: 2 } },
|
||||
{ label: '(不回复)', dir: 'down', isSilence: true, profile: { avoidance: 2 } },
|
||||
],
|
||||
standardReply: '嗯。',
|
||||
stateOnStdReply: { MOM_LINK: true },
|
||||
profileOnRead: { nostalgia: 1 },
|
||||
profileOnAvoid: { avoidance: 1 },
|
||||
},
|
||||
|
||||
// ── 11 · 23:15 · 阿哲 · 电话(关键分支)─────────────────────
|
||||
{
|
||||
id: 11, gameTime: '23:15', triggerAt: gt(23, 15),
|
||||
type: 'call',
|
||||
sender: 'azhe', senderName: '阿哲',
|
||||
ringtone: `${MUS}/lv11_ring_azhe_v1.wav`,
|
||||
ringDuration: 30,
|
||||
audio: [`${TDIR}/lv11_2315_azhe_call_01_wav_v1.wav`],
|
||||
note: '我能过去拿吗?就5分钟',
|
||||
// 通话中的选择(接通后听完再做)
|
||||
inCallChoices: [
|
||||
{ label: '不方便,明天吧。', dir: 'left', state: { HE_BACK: false } },
|
||||
{ label: '可以,但只有5分钟。', dir: 'right', state: { HE_BACK: true } },
|
||||
{ label: '(不说话,挂断)', dir: 'down', isSilence: true, state: { HE_BACK: false } },
|
||||
],
|
||||
profileOnMiss: { avoidance: 2 },
|
||||
profileOnAnswer:{ engagement: 1 },
|
||||
},
|
||||
|
||||
// ── 12 · 23:30 · 周南 · 微信语音(悬疑揭晓)────────────────
|
||||
{
|
||||
id: 12, gameTime: '23:30', triggerAt: gt(23, 30),
|
||||
type: 'wechat_voice',
|
||||
sender: 'zhounan', senderName: '周南',
|
||||
audio: [`${TDIR}/lv11_2330_zhounan_voice_01_wav_v1.wav`],
|
||||
note: '林夏,是我,周南。初三坐你后桌那个',
|
||||
detailReplies: [
|
||||
{ label: '嗯……你好。', dir: 'left', state: { ZHOUNAN_DEPTH: 1 }, profile: { nostalgia: 1 } },
|
||||
{ label: '周南!你还记得我啊!', dir: 'right', state: { ZHOUNAN_DEPTH: 1 }, profile: { engagement: 2, nostalgia: 1 } },
|
||||
{ label: '你是……初三坐我后桌那个……?', dir: 'up', state: { ZHOUNAN_DEPTH: 1 }, profile: { nostalgia: 2 } },
|
||||
{ label: '(不回复)', dir: 'down', isSilence: true },
|
||||
],
|
||||
standardReply: '嗯……好久不见。',
|
||||
stateOnStdReply: { ZHOUNAN_DEPTH: 1 },
|
||||
profileOnRead: { nostalgia: 1 },
|
||||
profileOnAvoid: { avoidance: 1 },
|
||||
},
|
||||
|
||||
// ── 13 · 23:45 · 周南 · 4条语音(十年故事)─────────────────
|
||||
{
|
||||
id: 13, gameTime: '23:45', triggerAt: gt(23, 45),
|
||||
type: 'wechat_voice',
|
||||
sender: 'zhounan', senderName: '周南',
|
||||
audio: [
|
||||
`${TDIR}/lv11_2345_zhounan_voice_01_wav_v1.wav`,
|
||||
`${TDIR}/lv11_2345_zhounan_voice_02_wav_v1.wav`,
|
||||
`${TDIR}/lv11_2345_zhounan_voice_03_wav_v1.wav`,
|
||||
`${TDIR}/lv11_2345_zhounan_voice_04_wav_v1.wav`,
|
||||
],
|
||||
note: '高考、复读、二本、回老家……今天我生日',
|
||||
bgm: `${MUS}/lv11_bgm_m3_zhounan_v1.wav`,
|
||||
detailReplies: [
|
||||
{ label: '你讲了好多。我都听了。', dir: 'left', state: { ZHOUNAN_DEPTH_ADD: 1 }, profile: { engagement: 2, nostalgia: 1 } },
|
||||
{ label: '你一个人在北京,还好吗?', dir: 'right', state: { ZHOUNAN_DEPTH_ADD: 1 }, profile: { engagement: 2 } },
|
||||
{ label: '……生日快乐。', dir: 'up', state: { ZHOUNAN_DEPTH_ADD: 1, ZHOUNAN_SHARE: true }, profile: { nostalgia: 2, engagement: 1 } },
|
||||
{ label: '(沉默)', dir: 'down', isSilence: true, profile: { nostalgia: 1, avoidance: 1 } },
|
||||
],
|
||||
standardReply: '……谢谢你告诉我。',
|
||||
stateOnStdReply: { ZHOUNAN_DEPTH_ADD: 1 },
|
||||
profileOnRepeat: { nostalgia: 2 },
|
||||
profileOnAvoid: { nostalgia: 1, avoidance: 1 },
|
||||
},
|
||||
|
||||
// ── 14 · 00:15 · 妈妈 · 电话(第二次)─────────────────────
|
||||
{
|
||||
id: 14, gameTime: '00:15', triggerAt: gt(0, 15),
|
||||
type: 'call',
|
||||
sender: 'mom', senderName: '妈妈',
|
||||
ringtone: `${MUS}/lv11_ring_mom_v1.wav`,
|
||||
ringDuration: 30,
|
||||
audio: [`${TDIR}/lv11_0015_mom_call_01_wav_v1.wav`],
|
||||
note: '夏夏,你不接电话我有点担心',
|
||||
stateOnAnswer: { MOM_LINK: true },
|
||||
profileOnMiss: { avoidance: 2 },
|
||||
profileOnAnswer:{ engagement: 2 },
|
||||
},
|
||||
|
||||
// ── 15 · 00:30 · 自己 · 时光胶囊(系统通知)────────────────
|
||||
{
|
||||
id: 15, gameTime: '00:30', triggerAt: gt(0, 30),
|
||||
type: 'system_notification',
|
||||
sender: 'self', senderName: '时光胶囊',
|
||||
text: '3年前的今晚,你给自己录过一段备忘录。是否播放?',
|
||||
systemAudio: `${TDIR}/lv11_sys_memo_01_wav_v1.wav`,
|
||||
audioByProfile: {
|
||||
avoidance: `${TDIR}/lv11_0030_self3y_voice_02_wav_v1.wav`,
|
||||
engagement: `${TDIR}/lv11_0030_self3y_voice_01_wav_v1.wav`,
|
||||
nostalgia: `${TDIR}/lv11_0030_self3y_voice_03_wav_v1.wav`,
|
||||
},
|
||||
note: 'self-3y,由PROFILE决定版本',
|
||||
stateOnPlay: { SELF_RECORD: true },
|
||||
profileOnPlay: { nostalgia: 2 },
|
||||
profileOnSkip: { avoidance: 2 },
|
||||
},
|
||||
|
||||
// ── 16 · 02:00 · 妈妈 · 长语音4分17秒(结局)──────────────
|
||||
{
|
||||
id: 16, gameTime: '02:00', triggerAt: gt(2, 0),
|
||||
type: 'wechat_voice',
|
||||
sender: 'mom', senderName: '妈妈',
|
||||
audio: [
|
||||
`${TDIR}/lv11_0200_mom_voice_01_seg1_wav_v1.wav`,
|
||||
`${TDIR}/lv11_0200_mom_voice_01_seg2_wav_v1.wav`,
|
||||
`${TDIR}/lv11_0200_mom_voice_01_seg3_wav_v1.wav`,
|
||||
`${TDIR}/lv11_0200_mom_voice_01_seg4_wav_v1.wav`,
|
||||
`${TDIR}/lv11_0200_mom_voice_01_seg5_wav_v1.wav`,
|
||||
`${TDIR}/lv11_0200_mom_voice_01_seg6_wav_v1.wav`,
|
||||
`${TDIR}/lv11_0200_mom_voice_01_seg7_wav_v1.wav`,
|
||||
`${TDIR}/lv11_0200_mom_voice_01_seg8_wav_v1.wav`,
|
||||
],
|
||||
note: '妈妈讲她24岁那年,结局起点',
|
||||
bgm: `${MUS}/lv11_bgm_m4_ending_v1.wav`,
|
||||
sfxAfter: [
|
||||
`${SFX}/sfx_night_bus_v1.wav`,
|
||||
`${SFX}/sfx_cat_yowl_v1.wav`,
|
||||
],
|
||||
isEnding: true,
|
||||
requiresMomLink: true, // 必须 MOM_LINK=true 才自动播放
|
||||
},
|
||||
];
|
||||
|
||||
// 角色→铃声映射(用于收件箱读出)
|
||||
const SENDERS = {
|
||||
mom: { name: '妈妈', ringtone: `${MUS}/lv11_ring_mom_v1.wav` },
|
||||
azhe: { name: '阿哲', ringtone: `${MUS}/lv11_ring_azhe_v1.wav` },
|
||||
xiaomei: { name: '小美', ringtone: `${MUS}/lv11_ring_xiaomei_v1.wav` },
|
||||
delivery: { name: '外卖小哥', ringtone: `${MUS}/lv11_ring_delivery_v1.wav` },
|
||||
hr: { name: 'HR王姐', ringtone: `${MUS}/lv11_ring_hr_v1.wav` },
|
||||
anan: { name: '安安', ringtone: `${MUS}/lv11_ring_anan_v1.wav` },
|
||||
dorm: { name: '寝室群', ringtone: `${MUS}/lv11_ring_dorm_group_v1.wav` },
|
||||
unknown: { name: '未知号码', ringtone: `${MUS}/lv11_ring_unknown_v1.wav` },
|
||||
zhounan: { name: '周南', ringtone: `${MUS}/lv11_ring_zhounan_v1.wav` },
|
||||
self: { name: '时光胶囊', ringtone: null },
|
||||
};
|
||||
|
||||
const SFX_UI = {
|
||||
wechat_msg: `${SFX}/sfx_deep_breath_v1.wav`, // 微信消息提示(用sfx模拟)
|
||||
keyboard: `${SFX}/sfx_keyboard_slow_v1.wav`,
|
||||
chime: `${SFX}/sfx_ending_chime_v1.wav`,
|
||||
transition: `${SFX}/sfx_transition_chapter_v1.wav`,
|
||||
};
|
||||
|
||||
const AMBIENCE_DEFAULT = `${AMB}/amb_apartment_night_01_v1.wav`;
|
||||
const AMBIENCE_RAIN = `${AMB}/amb_rain_window_01_v1.wav`;
|
||||
@@ -0,0 +1,53 @@
|
||||
// utils.js — 轻量工具函数
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 语音合成(选项朗读用)
|
||||
const TTS = {
|
||||
_voices: [],
|
||||
_ready: false,
|
||||
|
||||
init() {
|
||||
if (!window.speechSynthesis) return;
|
||||
const load = () => {
|
||||
this._voices = speechSynthesis.getVoices();
|
||||
this._ready = true;
|
||||
};
|
||||
load();
|
||||
speechSynthesis.onvoiceschanged = load;
|
||||
},
|
||||
|
||||
_pick() {
|
||||
// 优先选中文语音
|
||||
const zh = this._voices.find(v =>
|
||||
v.lang.startsWith('zh') || v.name.includes('Chinese') || v.name.includes('中')
|
||||
);
|
||||
return zh || this._voices[0] || null;
|
||||
},
|
||||
|
||||
speak(text, { rate = 0.85, pitch = 1, volume = 0.85 } = {}) {
|
||||
return new Promise(resolve => {
|
||||
if (!window.speechSynthesis) { resolve(); return; }
|
||||
speechSynthesis.cancel();
|
||||
const u = new SpeechSynthesisUtterance(text);
|
||||
u.lang = 'zh-CN';
|
||||
u.rate = rate;
|
||||
u.pitch = pitch;
|
||||
u.volume = volume;
|
||||
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" }
|
||||
]
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
# 《林夏》操作说明
|
||||
|
||||
---
|
||||
|
||||
## 一、游戏简介
|
||||
|
||||
全黑屏叙事游戏。玩家扮演 24 岁的林夏,用一部碎屏手机度过今晚 21:00–02:00 的 5 个小时。
|
||||
|
||||
- **没有视觉界面**,只有声音和触感
|
||||
- **全程约 60 分钟**(5 小时游戏时间压缩)
|
||||
- **16 条消息**,5 种结局
|
||||
- **不需要说话**,不需要麦克风
|
||||
|
||||
---
|
||||
|
||||
## 二、开始前准备
|
||||
|
||||
1. 手机和电脑连接**同一 Wi-Fi**
|
||||
2. 打开手机浏览器,输入地址:
|
||||
|
||||
```
|
||||
http://172.28.107.97:8765/game/
|
||||
```
|
||||
|
||||
3. **关灯,戴上耳机**
|
||||
4. 将手机音量调到适中(建议 60–80%)
|
||||
5. 轻触屏幕任意位置开始游戏
|
||||
|
||||
> **iOS 用户注意**:Safari 不支持震动反馈,操作正常但无震感。建议使用 Android Chrome。
|
||||
|
||||
---
|
||||
|
||||
## 三、消息类型与接收方式
|
||||
|
||||
游戏中有三种消息形态:
|
||||
|
||||
### 来电
|
||||
- 触发:手机长震 + 角色专属铃声
|
||||
- 响 **20–30 秒**后自动漏接
|
||||
|
||||
| 操作 | 效果 |
|
||||
|------|------|
|
||||
| **单点** | 接听 |
|
||||
| **左滑** | 拒接 |
|
||||
| **不动** | 漏接(对方挂断) |
|
||||
|
||||
### 微信语音 / 微信文字
|
||||
- 触发:两短震 + 系统提示音(TTS 报出发件人)
|
||||
|
||||
| 操作 | 效果 |
|
||||
|------|------|
|
||||
| **单点** | 播放语音 / 念出文字 |
|
||||
| **左滑** | 忽略(保持未读) |
|
||||
| **不动** | 保持未读,晚些再听 |
|
||||
|
||||
---
|
||||
|
||||
## 四、回复消息
|
||||
|
||||
**听完语音 / 文字念完后**,进入等待回复状态:
|
||||
|
||||
| 操作 | 发送内容 |
|
||||
|------|----------|
|
||||
| **单点** | 发"嗯。"(最简单的回执) |
|
||||
| **双点** | 发标准回复(针对该消息的一句话) |
|
||||
| **长按 1 秒** | 进入**细回复**模式 |
|
||||
| **左滑** | 已读不回 |
|
||||
| **不动** | 维持"已读未回"状态 |
|
||||
|
||||
---
|
||||
|
||||
## 五、细回复(核心机制)
|
||||
|
||||
长按 1 秒后,震动确认进入细回复。系统会依次念出 **4 个候选回复**:
|
||||
|
||||
```
|
||||
一…… "好,明天寄。"
|
||||
二…… "你怎么不自己来拿。"
|
||||
三…… "你那边……都好吗。"
|
||||
四…… (什么都不说)
|
||||
```
|
||||
|
||||
**在听到目标选项时,朝对应方向滑动:**
|
||||
|
||||
| 滑动方向 | 对应选项 |
|
||||
|----------|----------|
|
||||
| **左滑** | 一 |
|
||||
| **右滑** | 二 |
|
||||
| **上滑** | 三 |
|
||||
| **下滑** | 四 |
|
||||
|
||||
> **没听清?** 单点屏幕可重听所有选项,或长按 1 秒重新播放。
|
||||
> 选错了也没关系——每条消息只影响行为画像,不会"game over"。
|
||||
|
||||
---
|
||||
|
||||
## 六、查看当前状态
|
||||
|
||||
**随时下滑**,系统会用语音告诉你当前在哪个页面,以及可以做什么操作。
|
||||
|
||||
例如:
|
||||
- 主屏幕时下滑 → "主屏幕,3条未读。上滑查看收件箱。"
|
||||
- 来电时下滑 → "妈妈来电中。单点接听,左滑拒接。"
|
||||
- 听完消息后下滑 → "阿哲的消息已播完。单点回嗯,双点标准回复,长按细回复,左滑已读不回。"
|
||||
|
||||
> 不确定自己在哪?下滑就对了。
|
||||
|
||||
---
|
||||
|
||||
## 七、查看未读列表(收件箱)
|
||||
|
||||
在主屏幕**上滑**,进入收件箱模式。
|
||||
|
||||
系统会依次念出所有未读消息:
|
||||
```
|
||||
"收件箱:4条未读。妈妈,来电。阿哲,语音。周南,语音。……"
|
||||
```
|
||||
|
||||
- **单点** → 跳转至列表第一条
|
||||
- **左滑** → 返回主屏幕
|
||||
- **等待 8 秒** → 自动关闭收件箱,返回游戏
|
||||
|
||||
---
|
||||
|
||||
## 八、游戏内时间
|
||||
|
||||
游戏将现实的 21:00–02:00(5 小时)压缩为 **60 分钟**真实时间。
|
||||
|
||||
| 游戏时间 | 真实等待 |
|
||||
|----------|----------|
|
||||
| 21:03 | 约 36 秒 |
|
||||
| 21:30 | 约 6 分钟 |
|
||||
| 22:30 | 约 18 分钟 |
|
||||
| 23:45 | 约 33 分钟 |
|
||||
| 02:00 | 约 60 分钟 |
|
||||
|
||||
消息到来时间固定,**漏接 / 不听是合法选择**,但漏掉的内容不会重发。
|
||||
|
||||
---
|
||||
|
||||
## 九、结局说明(不含剧透)
|
||||
|
||||
共 **5 种结局**,由以下 5 个变量决定:
|
||||
|
||||
| 变量 | 触发条件 |
|
||||
|------|----------|
|
||||
| 与妈妈的连接 | 是否接了妈妈的电话 / 回了文字 |
|
||||
| 阿哲是否回来 | 23:15 那通电话的选择 |
|
||||
| 群里是否发言 | 寝室群消息的回应 |
|
||||
| 与周南的深度 | 回应周南的次数与方式 |
|
||||
| 是否听自己 | 00:30 时光胶囊是否播放 |
|
||||
|
||||
另有一个**隐藏结局 E**,需要与某位角色深度互动才能触发。
|
||||
|
||||
---
|
||||
|
||||
## 十、行为画像
|
||||
|
||||
游戏全程追踪三个维度(玩家不可见):
|
||||
|
||||
| 维度 | 含义 | 触发行为 |
|
||||
|------|------|----------|
|
||||
| **avoidance**(回避) | 林夏今晚想一个人待着 | 漏接、不回、已读不回 |
|
||||
| **engagement**(投入) | 林夏在认真面对每一个人 | 细回复、主动追问 |
|
||||
| **nostalgia**(怀旧) | 林夏在这一夜反复回望 | 重听同一条消息、分享自己 |
|
||||
|
||||
画像决定 **00:30 时光胶囊**播放哪个版本(明朗 / 迷茫 / 温柔)。
|
||||
|
||||
---
|
||||
|
||||
## 十一、彩蛋
|
||||
|
||||
- **夜间启动**(现实时间 22:00–04:00 之间打开游戏):开场多一句话
|
||||
- **消息09**:小美醉语音结束后会有一个撤回事件
|
||||
- **消息04**:如果一直不回阿哲,会听到一个小细节
|
||||
|
||||
---
|
||||
|
||||
## 十二、开发者 / 测试模式
|
||||
|
||||
在 URL 末尾加 `?dev` 可开启**10倍速**模式:
|
||||
|
||||
```
|
||||
http://172.28.107.97:8765/game/?dev
|
||||
```
|
||||
|
||||
- 所有消息的触发时间缩短为原来的 1/10
|
||||
- 全程约 **6 分钟**可打通
|
||||
- 适合测试结局分支
|
||||
|
||||
---
|
||||
|
||||
## 十三、启动服务器
|
||||
|
||||
关机重启后需要重新启动 HTTP 服务器:
|
||||
|
||||
```bash
|
||||
cd /home/xsl/blind
|
||||
python3 -m http.server 8765 --bind 0.0.0.0 &
|
||||
```
|
||||
|
||||
查看当前 IP:
|
||||
|
||||
```bash
|
||||
hostname -I | awk '{print $1}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十四、文件结构
|
||||
|
||||
```
|
||||
/home/xsl/blind/
|
||||
├── game/
|
||||
│ ├── index.html 主页面(黑屏)
|
||||
│ ├── manifest.json PWA 配置
|
||||
│ ├── 操作说明.md 本文件
|
||||
│ └── js/
|
||||
│ ├── utils.js 工具函数 + 浏览器 TTS
|
||||
│ ├── haptics.js 震动反馈
|
||||
│ ├── audio.js 音频管理器
|
||||
│ ├── gestures.js 单指手势检测
|
||||
│ ├── profile.js 行为画像评分
|
||||
│ ├── story.js 16条消息数据
|
||||
│ ├── engine.js 游戏状态机
|
||||
│ └── main.js 入口
|
||||
└── audio/
|
||||
└── 00_raw/
|
||||
├── tts/ 角色语音(68条)
|
||||
├── music/ BGM + 铃声
|
||||
├── sfx/ 音效
|
||||
└── ambience/ 环境底噪
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*"屏幕坏了,但她的一夜还没结束。"*
|
||||
Reference in New Issue
Block a user