项目整理:重写主文档、归档过时文档、清理死代码、新增音频制作手册

文档整理:
- 重写 game/操作说明.md 为项目主文档(6游戏/6手势/旁白/模型生成音频/TTS两步流程)
- 归档过时文档至 other_docs/(林夏原始设计、旧执行文档)
- 新增 docs/音频制作执行手册.md(从零制作游戏音频的完整流程)
- docs/ 下保留旁白音色提示词、音频脚本

代码清理:
- 删除旧架构死代码: story.js / gestures.js / profile.js
- 删除耦合旧架构的过时测试目录 game/tests/

脚本配置修正:
- check_tts_env.sh: 启动脚本路径 AutoVideo → tts-server(实际位置)
- gen_narrator_samples.py: 硬编码地址改为环境变量,默认对齐 tts-server 端口 8000
This commit is contained in:
xsl
2026-06-19 22:29:04 +08:00
parent b3384499e3
commit 866f363591
46 changed files with 5061 additions and 2374 deletions
+28 -136
View File
@@ -6,155 +6,47 @@
<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>
<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;
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;
}
/* ── 调试条 ── */
#g{position:fixed;inset:0;background:#000;}
#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;
display:none;position:fixed;bottom:0;left:0;right:0;
background:rgba(0,0,0,.92);color:#0f0;font:10px monospace;
padding:4px 8px;max-height:72px;overflow-y:auto;z-index:99;
white-space:pre-wrap;
}
</style>
</head>
<body>
<div id="game"></div>
<div id="g"></div>
<pre id="dbg"></pre>
<div id="start">
<h1>林夏</h1>
<div class="sub">
今晚 16 条消息<br>
你接谁,不接谁<br>
决定明天的自己
</div>
<div id="controls">
<div class="ctrl-title">操 作 说 明</div>
<!-- 基础工具 -->
<script src="js/utils.js"></script>
<script src="js/haptics.js"></script>
<script src="js/audio.js"></script>
<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>
<!-- 新核心 -->
<script src="js/narrator.js"></script>
<script src="js/input.js"></script>
<script src="js/engine.js"></script>
<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>
<!-- 六个游戏 -->
<script src="js/games/linxia.js"></script>
<script src="js/games/yinian.js"></script>
<script src="js/games/shouye.js"></script>
<script src="js/games/houzhen.js"></script>
<script src="js/games/yisheng.js"></script>
<script src="js/games/fu.js"></script>
<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>
<!-- 选择器 + 入口 -->
<script src="js/selector.js"></script>
<script src="js/main.js"></script>
</body>
</html>
+5
View File
@@ -76,6 +76,7 @@ class AudioManager {
// ── 铃声(循环)──
async startRingtone(url) {
this.stopRingtone();
if (typeof TEST !== 'undefined' && TEST) return;
this._ringEl = new Audio(url);
this._ringEl.loop = true;
this._ringEl.volume = 1.0;
@@ -93,6 +94,10 @@ class AudioManager {
// ── 语音播放(多段顺序)──
playVoice(urls, { vol = 1.0, onEnd = null } = {}) {
this.stopVoice();
if (typeof TEST !== 'undefined' && TEST) {
if (onEnd) onEnd();
return;
}
this._voiceQueue = [...urls];
this._playNext(vol, onEnd);
}
+136 -798
View File
@@ -1,842 +1,180 @@
// engine.js — 游戏引擎 v2(多轮对话 + 林夏语音 + NPC反应)
// engine.js — 场景引擎
//
// 状态流:idle → notification/ringing → playing → choosing
// → responding → reacting → [choosing | idle] → …… → ending
// 场景数据结构:
// narrate : string | fn(state)→string 旁白文本(TTS 兜底)
// narrateAudio : string | fn(state)→string 旁白预录 MP3 路径(优先于 TTS)
// audio : string[] 角色语音(播放完后才显示选项)
// bgm : string | null | undefined undefined=不变 null=停止 string=切换
// delay : number 自动跳转前的等待毫秒
// tap : { label, narrate, narrateAudio, audio, state, next } 单击操作
// doubleTap : { label, narrate, narrateAudio, audio, state, next } 双击操作
// next : string | fn(state) | null 无选项时自动跳转(null=结局)
//
// 新增状态:
// responding — 林夏说话中(打字音效 + 语音播放)
// reacting — NPC反应播放中
// paused — 游戏暂停(两指长按触发)
// state/next 可以是函数 fn(currentState)=>值
class GameEngine {
class SceneEngine {
constructor() {
this._state = 'idle';
// 剧情变量
this.vars = {
MOM_LINK: false,
HE_BACK: null,
GROUP_REPLY: false,
ZHOUNAN_DEPTH: 0,
ZHOUNAN_SHARE: false,
SELF_RECORD: false,
UNREAD: 0,
REPLY_COUNT: 0,
};
this.profile = new ProfileTracker();
this.msgState = {};
this.currentMsg = null;
this._queue = [];
this._endingTriggered = false;
this._reminderTimer = null;
// v2: 对话系统
this._currentChoices = null; // 当前可用的选项集
this._pendingResolve = null; // 用于中断语音播放的 resolve
this._skipRequested = false; // 跳过标志
// 暂停系统
this._pausedState = null; // 暂停前的状态
this.gest = null;
this._game = null;
this._state = {};
this._scene = null;
this._waiting = false;
this._input = null;
this._onEnd = null;
}
// ── 状态 getter/setter ──
get state() { return this._state; }
set state(s) {
this._state = s;
dbg(`state: ${s}`);
const el = document.getElementById('hint');
if (!el) return;
const H = {
idle: '',
notification: '单击 播放 · 双击/左滑 跳过',
ringing: '单击/右滑 接听 · 左滑/双击 拒接',
playing: '双击 跳过 · 长按 重播',
choosing: '单击回应 · →温暖 ←冷淡 ↑追问 ↓沉默 · 双击跳过',
responding: '双击 跳过',
reacting: '双击 跳过',
paused: '单击 继续 · 三指点击 退出',
ending: '',
};
el.textContent = H[s] || '';
}
// ── 运行游戏 ──
async run(game, input, onEnd) {
this._game = game;
this._state = JSON.parse(JSON.stringify(game.initialState || {}));
this._scene = null;
this._waiting = false;
this._input = input;
this._onEnd = onEnd;
// ══════════════════════════════════════════
// 初始化 & 启动
// ══════════════════════════════════════════
// 接管输入
input.onTap = () => this._tap();
input.onDoubleTap = () => this._dt();
init(gestureDetector) {
this.gest = gestureDetector;
this.gest
.on('singletap', () => this._onTap())
.on('doubletap', () => this._onDoubleTap())
.on('swipe_left', () => this._onSwipe('left'))
.on('swipe_right', () => this._onSwipe('right'))
.on('swipe_up', () => this._onSwipe('up'))
.on('swipe_down', () => this._onSwipe('down'))
.on('longpress', () => this._onLongPress())
.on('twofinger_longpress', () => this._onPause())
.on('threefinger_tap', () => this._onExit());
}
async start() {
this._queue = [...MESSAGES];
// 内容预警
await this._playVoiceAsync([`${T}/v2_sys_content_warning_wav_v1.mp3`]);
await sleep(1000);
await Snd.startAmbience(AMBIENCE_DEFAULT, { vol: 0.15 });
await sleep(1500);
Snd.startBGM(`${MUS}/lv11_bgm_m1_main_loop_v1.mp3`, { vol: 0.1 });
await sleep(1500);
this._next();
}
// ══════════════════════════════════════════
// 语音播放(异步 + 可中断)
// ══════════════════════════════════════════
_playVoiceAsync(urls) {
return new Promise(resolve => {
this._pendingResolve = resolve;
Snd.playVoice(urls, {
onEnd: () => {
this._pendingResolve = null;
resolve();
},
});
});
}
_interruptVoice() {
// 清场
Narrator.stop();
Snd.stopVoice();
TTS.stop();
if (this._pendingResolve) {
this._pendingResolve();
this._pendingResolve = null;
}
Snd.stopBGM(0);
Snd.stopAmbience();
dbg(`[engine] start ${game.id}`);
if (game.bgm) Snd.startBGM(game.bgm, { vol: 0.18 });
if (game.ambience) Snd.startAmbience(game.ambience, { vol: 0.15 });
await Narrator.sayAndWait(game.intro);
await this._goto(game.first);
}
// 打字 + 发送音效
async _playTypingSend() {
SFX_UI.typing();
await sleep(800);
if (this._skipRequested) return;
SFX_UI.send();
await sleep(300);
}
// ══════════════════════════════════════════
// 消息队列推进
// ══════════════════════════════════════════
async _next() {
if (this._endingTriggered) return;
const msg = this._queue.shift();
if (!msg) {
this.triggerFinalEnding();
// ── 跳转到场景 ──
async _goto(id) {
if (id === null || id === undefined) {
this._ending();
return;
}
if (msg.requiresMomLink && !this.vars.MOM_LINK) {
this.triggerFinalEnding();
const scene = this._game.scenes[id];
if (!scene) { console.error('[engine] missing scene:', id); return; }
this._scene = scene;
this._waiting = false;
Narrator.clearOptions();
dbg(`[scene] ${id}`);
// BGM 切换
if (scene.bgm !== undefined) {
if (scene.bgm) Snd.startBGM(scene.bgm, { vol: 0.18 });
else Snd.stopBGM(1500);
}
// 播放音频(等完)
const sceneAudio = this._r(scene.audio);
if (sceneAudio && sceneAudio.length > 0) {
await this._playAudio(sceneAudio);
}
// 旁白描述(等完)— 优先播放预录音频,否则 TTS
if (scene.narrateAudio || scene.narrate) {
await this._sayNarrate(scene);
}
// 有选项 → 等待输入
if (scene.tap || scene.doubleTap) {
this._waiting = true;
Narrator.options(
scene.tap ? scene.tap.label : null,
scene.doubleTap ? scene.doubleTap.label : null,
);
return;
}
this.currentMsg = msg;
this._currentChoices = msg.choices || null;
this.msgState[msg.id] = 'unread';
this.vars.UNREAD++;
if (msg.type === 'call') {
this._startRinging(msg);
} else {
this._notifyMessage(msg);
}
// 无选项 → 自动跳转
if (scene.delay) await new Promise(r => setTimeout(r, scene.delay));
await this._goto(this._r(scene.next));
}
_notifyMessage(msg) {
this.state = 'notification';
Haptic.message();
SFX_UI.notify();
this._startReminder();
}
// ── 执行一个操作 ──
async _exec(action) {
this._waiting = false;
Narrator.clearOptions();
Narrator.stop(); // 打断正在播报的选项提示
async _startRinging(msg) {
this.state = 'ringing';
Haptic.ring();
await Snd.startRingtone(msg.ringtone);
this._startReminder();
}
// ══════════════════════════════════════════
// 通话流程(异步)
// ══════════════════════════════════════════
async _answerCall(msg) {
Snd.stopRingtone();
Haptic.stop();
Haptic.confirm();
SFX_UI.tap();
this._clearReminder();
this.state = 'playing';
this.msgState[msg.id] = 'read';
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
if (msg.stateOnAnswer) this._applyState(msg.stateOnAnswer);
if (msg.profileOnAnswer) this.profile.add(msg.profileOnAnswer);
this._skipRequested = false;
// 播放通话内容
await this._playVoiceAsync(msg.audio);
// _onDoubleTap 在 'playing' 状态下会直接处理状态转换
// 所以如果状态已不是 'playing',说明跳过已被处理,直接返回
if (this.state !== 'playing') return;
// 自动挂断(MSG-08 未知号码)
if (msg.autoHangup) {
if (msg.sfxAfter) {
const sfx = Array.isArray(msg.sfxAfter) ? msg.sfxAfter[0] : msg.sfxAfter;
await this._playVoiceAsync([sfx]);
if (this.state !== 'playing') return;
}
this._proceedToNext();
return;
}
// 自动回复(MSG-01/03/14 妈妈电话、外卖、妈妈第二次电话)
if (msg.autoReply) {
await this._playTypingSend();
if (this.state !== 'playing') return;
this.state = 'responding';
await this._playVoiceAsync([msg.autoReply]);
// 条件反应(MSG-14:先播前导语"真的没事?",再根据 MOM_LINK 播放不同反应)
if (!this._skipRequested && msg.autoReact) {
this.state = 'reacting';
// 前导语(如"真的没事?")
if (msg.autoReactPreamble) {
await sleep(500);
if (!this._skipRequested) {
await this._playVoiceAsync([msg.autoReactPreamble]);
}
}
// 条件分支反应
if (!this._skipRequested) {
const val = this.vars[msg.autoReact.condition];
const reactUrl = val ? msg.autoReact['true'] : msg.autoReact['false'];
if (reactUrl) {
await sleep(400);
if (!this._skipRequested) {
await this._playVoiceAsync([reactUrl]);
}
}
}
}
this._proceedToNext();
return;
}
// 通话后有选择(MSG-11 阿哲)
if (msg.choices) {
this._enterChoosing(msg.choices);
return;
}
this._proceedToNext();
}
_rejectCall(msg) {
Snd.stopRingtone();
Haptic.reject();
SFX_UI.skip();
this._clearReminder();
this.msgState[msg.id] = 'missed';
if (msg.profileOnMiss) this.profile.add(msg.profileOnMiss);
this._proceedToNext();
}
// ══════════════════════════════════════════
// 消息播放(异步)
// ══════════════════════════════════════════
async _playMessage(msg) {
this._clearReminder();
this.state = 'playing';
this.msgState[msg.id] = 'read';
this.vars.UNREAD = Math.max(0, this.vars.UNREAD - 1);
if (msg.bgm) Snd.startBGM(msg.bgm, { vol: 0.1 });
// 系统通知(时光胶囊)
if (msg.type === 'system_notification') {
this._playSystemNotification(msg);
return;
}
this._skipRequested = false;
// 文字消息(TTS 朗读,不可用时播放提示音)
if (msg.type === 'wechat_text' && msg.text) {
const ok = await TTS.speak(msg.text);
if (!ok) {
SFX_UI.notify();
await sleep(600);
}
}
// 语音消息
else if (msg.audio?.length > 0) {
await this._playVoiceAsync(msg.audio);
}
if (this._skipRequested) return;
if (this.state !== 'playing') return;
this._afterPlayed(msg);
}
_playSystemNotification(msg) {
Snd.playVoice([msg.systemAudio], {
onEnd: () => {
if (this.state !== 'playing') return;
const audio = msg.audioByProfile?.[this.profile.label];
if (audio) {
if (msg.stateOnPlay) this._applyState(msg.stateOnPlay);
if (msg.profileOnPlay) this.profile.add(msg.profileOnPlay);
setTimeout(() => {
if (this.state !== 'playing') return;
Snd.playVoice([audio], {
onEnd: () => this._proceedToNext(),
});
}, 800);
} else {
this._proceedToNext();
}
},
});
}
_afterPlayed(msg) {
if (this.state !== 'playing') return;
if (msg.isEnding) {
this.triggerFinalEnding();
return;
}
if (msg.choices) {
this._enterChoosing(msg.choices);
return;
}
this._proceedToNext();
}
// ══════════════════════════════════════════
// 选择系统(v2 多轮对话核心)
// ══════════════════════════════════════════
_enterChoosing(choices) {
this._currentChoices = choices;
this.state = 'choosing';
SFX_UI.choiceReady();
Haptic.confirm();
Snd.playVoice([`${T}/v2_sys_wait_reply_wav_v1.mp3`]);
this._startReminder();
}
async _handleChoice(dir) {
const choices = this._currentChoices;
if (!choices) {
SFX_UI.error();
Haptic.error();
return;
}
const choice = choices[dir];
if (!choice) {
SFX_UI.error();
Haptic.error();
return;
}
this._clearReminder();
this._skipRequested = false;
// 1. 播放方向对应的情感音效
const sfxMap = {
tap: 'tap',
right: 'choiceWarm',
left: 'choiceCold',
up: 'choiceCurious',
down: 'choiceSilent',
};
if (sfxMap[dir]) SFX_UI[sfxMap[dir]]();
Haptic.select();
// 2. 应用状态和画像
if (!choice.isSilence) {
this.msgState[this.currentMsg.id] = 'replied';
this.vars.REPLY_COUNT++;
}
if (choice.state) this._applyState(choice.state);
if (choice.profile) this.profile.add(choice.profile);
// 3. 林夏回应
this.state = 'responding';
if (choice.isSilence) {
// 沉默:短暂停顿
await sleep(1500);
} else if (choice.linxia) {
// 打字 → 发送 → 林夏语音
await this._playTypingSend();
if (!this._skipRequested) {
await this._playVoiceAsync([choice.linxia]);
}
// 更新状态
if (action.state) {
const updates = this._r(action.state);
Object.assign(this._state, updates);
}
if (this._skipRequested) {
// 跳过了林夏回应 → 如有 followUp 进入选择,否则跳到下一条
if (choice.followUp?.choices) {
this._enterChoosing(choice.followUp.choices);
return;
}
this._proceedToNext();
return;
// 反应音频(支持函数型)
const actionAudio = this._r(action.audio);
if (actionAudio && actionAudio.length > 0) {
await this._playAudio(actionAudio);
}
// 4. NPC 反应
if (choice.npcReact?.length > 0) {
this.state = 'reacting';
await sleep(400);
if (!this._skipRequested) {
await this._playVoiceAsync(choice.npcReact);
}
// 反应旁白
if (action.narrateAudio || action.narrate) {
await this._sayNarrate(action);
}
if (this._skipRequested) {
if (choice.followUp?.choices) {
this._enterChoosing(choice.followUp.choices);
return;
}
this._proceedToNext();
return;
}
// 5. 如有后续对话 → 再次进入选择
if (choice.followUp?.choices) {
await sleep(300);
this._enterChoosing(choice.followUp.choices);
return;
}
// 6. 推进到下一条
this._proceedToNext();
await this._goto(this._r(action.next));
}
_skipChoice() {
this._clearReminder();
SFX_UI.skip();
Haptic.confirm();
this.profile.add({ avoidance: 1 });
this._proceedToNext();
_tap() {
if (!this._waiting || !this._scene?.tap) return;
this._exec(this._scene.tap);
}
// ══════════════════════════════════════════
// 推进到下一条
// ══════════════════════════════════════════
async _proceedToNext() {
const prev = this.currentMsg;
this.currentMsg = null;
this._currentChoices = null;
this.state = 'idle';
this._clearReminder();
if (this._endingTriggered) return;
// 特殊事件:阿哲来了(门铃)
if (prev?.id === 11 && this.vars.HE_BACK === true) {
await sleep(1500);
Snd.playVoice([`${SFX}/sfx_door_knock_gentle_v1.mp3`]);
Haptic.ring();
await sleep(3000);
}
// 检查结局D:未读过多
const triggered = MESSAGES.length - this._queue.length;
if (this.vars.UNREAD >= 8 && triggered >= 14) {
this._triggerEnding('D');
return;
}
// 短暂停顿后推进
SFX_UI.next();
await sleep(DEV ? 200 : 2000);
this._next();
_dt() {
if (!this._waiting || !this._scene?.doubleTap) return;
this._exec(this._scene.doubleTap);
}
// ══════════════════════════════════════════
// 提醒定时器
// ══════════════════════════════════════════
_startReminder() {
this._clearReminder();
this._reminderTimer = setInterval(() => {
if (this.state === 'notification') {
SFX_UI.notify();
Haptic.message();
} else if (this.state === 'choosing') {
SFX_UI.choiceReady();
Haptic.confirm();
} else if (this.state === 'ringing') {
Haptic.ring();
} else {
this._clearReminder();
}
}, 8000);
}
_clearReminder() {
if (this._reminderTimer) {
clearInterval(this._reminderTimer);
this._reminderTimer = null;
}
}
// ══════════════════════════════════════════
// 状态变量
// ══════════════════════════════════════════
_applyState(changes) {
for (const [k, v] of Object.entries(changes)) {
if (k === 'ZHOUNAN_DEPTH_ADD') {
this.vars.ZHOUNAN_DEPTH += (v || 1);
} else if (v !== null) {
this.vars[k] = v;
}
}
}
// ══════════════════════════════════════════
// 手势处理
// ══════════════════════════════════════════
_onTap() {
const s = this.state;
// 暂停中 → 恢复
if (s === 'paused') {
this._onResume();
return;
}
if (s === 'ringing') {
this._answerCall(this.currentMsg);
} else if (s === 'notification') {
SFX_UI.tap();
Haptic.confirm();
this._playMessage(this.currentMsg);
} else if (s === 'choosing') {
this._handleChoice('tap');
} else if (s === 'idle') {
SFX_UI.error();
Haptic.error();
}
}
_onDoubleTap() {
const s = this.state;
if (s === 'paused') return;
if (s === 'ringing') {
this._rejectCall(this.currentMsg);
return;
}
if (s === 'notification') {
this._clearReminder();
SFX_UI.skip();
Haptic.confirm();
const msg = this.currentMsg;
if (msg?.profileOnSkip) this.profile.add(msg.profileOnSkip);
else this.profile.add({ avoidance: 1 });
this._proceedToNext();
return;
}
if (s === 'playing') {
this._skipRequested = true;
this._interruptVoice();
SFX_UI.skip();
Haptic.confirm();
const msg = this.currentMsg;
if (msg?.isEnding) {
this.triggerFinalEnding();
} else if (msg?.choices) {
this._enterChoosing(msg.choices);
} else {
this._proceedToNext();
}
return;
}
if (s === 'responding' || s === 'reacting') {
this._skipRequested = true;
this._interruptVoice();
SFX_UI.skip();
Haptic.confirm();
return;
}
if (s === 'choosing') {
this._skipChoice();
return;
}
if (s === 'idle') {
SFX_UI.error();
Haptic.error();
}
}
_onSwipe(dir) {
const s = this.state;
if (s === 'paused') return;
if (s === 'ringing') {
if (dir === 'left') this._rejectCall(this.currentMsg);
else if (dir === 'right') this._answerCall(this.currentMsg);
return;
}
if (s === 'notification') {
if (dir === 'left') {
this._clearReminder();
SFX_UI.skip();
Haptic.confirm();
this.profile.add({ avoidance: 1 });
this._proceedToNext();
}
return;
}
if (s === 'choosing') {
this._handleChoice(dir);
return;
}
if (s === 'idle') {
SFX_UI.error();
Haptic.error();
}
}
// ── 长按:重播 / 状态广播 ──
_onLongPress() {
const s = this.state;
if (s === 'paused' || s === 'ending') return;
Haptic.confirm();
// playing/responding/reacting/choosing → 重播当前消息音频
if ((s === 'playing' || s === 'responding' || s === 'reacting' || s === 'choosing') && this.currentMsg) {
this._interruptVoice();
if (this.currentMsg.audio?.length > 0) {
this._playVoiceAsync(this.currentMsg.audio);
} else if (this.currentMsg.type === 'wechat_text' && this.currentMsg.text) {
TTS.speak(this.currentMsg.text).then(ok => { if (!ok) SFX_UI.notify(); });
}
return;
}
// idle/notification → 广播未读数
TTS.speak(`当前未读${this.vars.UNREAD}条消息`).then(ok => { if (!ok) SFX_UI.error(); });
}
// ── 暂停 ──
_onPause() {
const s = this.state;
if (s === 'ending' || s === 'paused') return;
this._pausedState = s;
this._interruptVoice();
this._clearReminder();
Haptic.stop();
this.state = 'paused';
Snd.playVoice([`${T}/v2_sys_paused_wav_v1.mp3`]);
}
// ── 恢复 ──
_onResume() {
Snd.stopVoice();
const prev = this._pausedState || 'idle';
this._pausedState = null;
// 语音无法断点续播,根据暂停前状态决定恢复行为
if (prev === 'choosing' && this._currentChoices) {
this.state = 'choosing';
SFX_UI.choiceReady();
Haptic.confirm();
this._startReminder();
} else if (prev === 'notification' || prev === 'ringing') {
this.state = prev;
this._startReminder();
} else {
// playing/responding/reacting → 无法续播,推进到下一条或回到选择
if (this._currentChoices) {
this._enterChoosing(this._currentChoices);
} else {
this._proceedToNext();
}
}
}
// ── 退出 ──
_onExit() {
if (this.state !== 'paused') return;
this._endingTriggered = true;
this._interruptVoice();
// 退出不需要特殊音频,直接返回开始页
setTimeout(() => {
const startEl = document.getElementById('start');
if (startEl) {
startEl.style.opacity = '1';
startEl.style.display = 'flex';
}
}, 2000);
}
// ══════════════════════════════════════════
// 结局系统
// ══════════════════════════════════════════
async triggerFinalEnding() {
if (this._endingTriggered) return;
const v = this.vars;
// 结局E(隐藏)— 优先级 1
if (v.ZHOUNAN_DEPTH >= 2 && v.ZHOUNAN_SHARE &&
(this.profile.label === 'engagement' || this.profile.label === 'nostalgia')) {
return this._triggerEnding('E');
}
// 结局A:妈妈连接 + 自己录音 — 优先级 2
if (v.MOM_LINK && v.SELF_RECORD) {
return this._triggerEnding('A');
}
// 结局B:拒了阿哲 + 回了周南 — 优先级 3
if (v.HE_BACK === false && v.ZHOUNAN_DEPTH >= 1) {
return this._triggerEnding('B');
}
// 结局C:全部已读但极少回复 — 优先级 4
const read = Object.values(this.msgState).filter(s => s !== 'unread').length;
if (read >= MESSAGES.length - 2 && v.REPLY_COUNT <= 2) {
return this._triggerEnding('C');
}
// 结局D(兜底)— 优先级 5
this._triggerEnding('D');
}
async _triggerEnding(type) {
if (this._endingTriggered) return;
this._endingTriggered = true;
this.state = 'ending';
this._clearReminder();
this._interruptVoice();
Snd.stopRingtone();
// ── 结局 ──
async _ending() {
Snd.stopBGM(3000);
await sleep(2000);
dbg(`ending: ${type}`);
const fn = {
A: () => this._endingA(),
B: () => this._endingB(),
C: () => this._endingC(),
D: () => this._endingD(),
E: () => this._endingE(),
}[type];
if (fn) await fn();
// 结局后播报心理支持热线
await sleep(3000);
await this._playVoiceAsync([`${T}/v2_sys_hotline_wav_v1.mp3`]);
}
async _endingA() {
Snd.startAmbience(`${AMB}/amb_kitchen_morning_v1.mp3`, { vol: 0.3 });
await sleep(2000);
Snd.playVoice([`${SFX}/sfx_kettle_whistle_v1.mp3`]);
await sleep(4000);
Snd.stopAmbience();
const id = this._game.getEnding(this._state);
const e = this._game.endings?.[id];
dbg(`[ending] ${id}`);
SFX_UI.ending();
this._showCaption('明天再说。');
await this._playVoiceAsync([`${T}/v2_ending_a_linxia_wav_v1.mp3`]);
if (e) {
await this._sayNarrate(e);
} else {
await Narrator.sayAndWait('游戏结束。');
}
await new Promise(r => setTimeout(r, 2000));
await Narrator.sayAndWait('感谢体验。');
await new Promise(r => setTimeout(r, 3000));
if (this._onEnd) this._onEnd();
}
async _endingB() {
await sleep(2000);
SFX_UI.ending();
await sleep(1000);
this._showCaption('寄出去了。');
await this._playVoiceAsync([`${T}/v2_ending_b_linxia_wav_v1.mp3`]);
// ── 旁白:优先预录 MP3,兜底 TTS ──
_sayNarrate(node) {
const audioPath = this._r(node.narrateAudio);
if (audioPath) {
return this._playAudio([audioPath]);
}
const text = this._r(node.narrate);
if (text) return Narrator.sayAndWait(text);
return Promise.resolve();
}
async _endingC() {
await sleep(3000);
SFX_UI.ending();
await sleep(1000);
this._showCaption('今晚就这样。');
await this._playVoiceAsync([`${T}/v2_ending_c_linxia_wav_v1.mp3`]);
// ── 播放多段音频(顺序,等完)──
_playAudio(urls) {
return new Promise(resolve => {
Snd.playVoice(urls, { onEnd: resolve });
});
}
async _endingD() {
Haptic.system();
await sleep(2000);
Haptic.ring();
await sleep(4000);
Snd.startAmbience(`${AMB}/amb_dawn_birds_v1.mp3`, { vol: 0.4 });
await sleep(4000);
this._showCaption('早上好。');
await this._playVoiceAsync([`${T}/v2_ending_d_linxia_wav_v1.mp3`]);
}
async _endingE() {
Haptic.message();
SFX_UI.notify();
await sleep(1500);
// 周南秒回:"那我下次出差来北京,请你吃饭好不好。"
await this._playVoiceAsync([`${T}/v2_ending_e_zhounan_wav_v1.mp3`]);
await sleep(2000);
SFX_UI.ending();
await sleep(1000);
this._showCaption('好。');
await this._playVoiceAsync([`${T}/v2_ending_e_linxia_wav_v1.mp3`]);
}
_showCaption(text) {
const el = document.getElementById('caption');
el.textContent = text;
el.style.display = 'flex';
// ── 解析 string | fn(state) ──
_r(val) {
return typeof val === 'function' ? val(this._state) : val;
}
}
+125
View File
@@ -0,0 +1,125 @@
// games/fu.js — 《浮》
// 感官漂流。十二个声音漂过来,抓住还是放走。
// 结局:旁白读出你带走了什么。
const GAME_FU = {
id: 'fu',
intro: '你漂浮在某处。不知道是水,还是梦,还是别的什么。声音会漂过来。单击,把手伸过去。双击,看着它走。',
first: 'f01',
bgm: null,
ambience: '../audio/mp3/ambience/amb_apartment_night_01_v1.mp3',
initialState: { kept: [], released: [] },
scenes: {
f01: {
delay: 2000,
narrate: '第一个。像夏天傍晚的风,带着草的气味,你说不清是哪年夏天的。它停在你身边了。',
tap: { label: '把它留住', narrate: '它停在你手心里,有点暖。',
state: s => ({ kept: [...s.kept, '夏天的风'], released: s.released }), next: 'f02' },
doubleTap:{ label: '让它走', narrate: '你看着它慢慢飘走了。',
state: s => ({ kept: s.kept, released: [...s.released, '夏天的风'] }), next: 'f02' },
},
f02: {
delay: 1500,
narrate: '第二个。某人的笑声。你认识这个笑,但你说不清是谁的。',
tap: { label: '留住它', narrate: '笑声停在你耳边,还在回响。',
state: s => ({ kept: [...s.kept, '笑声'], released: s.released }), next: 'f03' },
doubleTap:{ label: '让它走', narrate: '笑声远了。',
state: s => ({ kept: s.kept, released: [...s.released, '笑声'] }), next: 'f03' },
},
f03: {
delay: 1500,
narrate: '第三个。一种说不清楚的遗憾。它没有形状,但是很重。',
tap: { label: '接住它', narrate: '你把遗憾抱在怀里了。它很重,但你没有放开。',
state: s => ({ kept: [...s.kept, '遗憾'], released: s.released }), next: 'f04' },
doubleTap:{ label: '让它过去', narrate: '遗憾漂过去了。你呼了口气。',
state: s => ({ kept: s.kept, released: [...s.released, '遗憾'] }), next: 'f04' },
},
f04: {
delay: 1500,
narrate: '第四个。很久没有过的安全感。轻轻的,像一床旧被子的气味。',
tap: { label: '留住它', narrate: '你把它裹在身上了。',
state: s => ({ kept: [...s.kept, '安全感'], released: s.released }), next: 'f05' },
doubleTap:{ label: '放走', narrate: '安全感飘走了。你看着它走。',
state: s => ({ kept: s.kept, released: [...s.released, '安全感'] }), next: 'f05' },
},
f05: {
delay: 1500,
narrate: '第五个。想哭但没哭出来的那种感觉。像喉咙里卡着什么。',
tap: { label: '承认它', narrate: '你承认了。那个东西松动了一点点。',
state: s => ({ kept: [...s.kept, '没哭出来的眼泪'], released: s.released }), next: 'f06' },
doubleTap:{ label: '让它走', narrate: '它漂走了。喉咙还是有点紧。',
state: s => ({ kept: s.kept, released: [...s.released, '没哭出来的眼泪'] }), next: 'f06' },
},
f06: {
delay: 1500,
narrate: '第六个。一首旋律,只有开头几个音,后面的你想不起来了。',
tap: { label: '哼几下', narrate: '你在心里哼了那几个音。后面的还是想不起来,但这几个音够了。',
state: s => ({ kept: [...s.kept, '那段旋律'], released: s.released }), next: 'f07' },
doubleTap:{ label: '算了', narrate: '旋律消失了。',
state: s => ({ kept: s.kept, released: [...s.released, '那段旋律'] }), next: 'f07' },
},
f07: {
delay: 1500,
narrate: '第七个。被需要的感觉。有人曾对你说过你很重要,你还记得那句话。',
tap: { label: '带着它', narrate: '你把那句话放进口袋里了。',
state: s => ({ kept: [...s.kept, '被需要的感觉'], released: s.released }), next: 'f08' },
doubleTap:{ label: '放开', narrate: '那句话漂走了。',
state: s => ({ kept: s.kept, released: [...s.released, '被需要的感觉'] }), next: 'f08' },
},
f08: {
delay: 1500,
narrate: '第八个。一个人的时候的自在。没人看着,没人要你做什么,只是你自己。',
tap: { label: '收下', narrate: '你把这份自在收进来了。',
state: s => ({ kept: [...s.kept, '独处的自在'], released: s.released }), next: 'f09' },
doubleTap:{ label: '不需要', narrate: '它漂走了。也许你现在不需要。',
state: s => ({ kept: s.kept, released: [...s.released, '独处的自在'] }), next: 'f09' },
},
f09: {
delay: 1500,
narrate: '第九个。害怕。不知道怕什么,就是有点怕。',
tap: { label: '承认害怕', narrate: '你承认了:我有点害怕。说出来之后,它小了一点。',
state: s => ({ kept: [...s.kept, '害怕'], released: s.released }), next: 'f10' },
doubleTap:{ label: '不理它', narrate: '你没有看它,害怕漂走了。也许。',
state: s => ({ kept: s.kept, released: [...s.released, '害怕'] }), next: 'f10' },
},
f10: {
delay: 1500,
narrate: '第十个。好奇心。对某件事、某个人、某个地方,你还有好奇。',
tap: { label: '带上它', narrate: '好奇心跳进你手里了,很活跃。',
state: s => ({ kept: [...s.kept, '好奇心'], released: s.released }), next: 'f11' },
doubleTap:{ label: '放走', narrate: '好奇心漂走了,也许它去找别人了。',
state: s => ({ kept: s.kept, released: [...s.released, '好奇心'] }), next: 'f11' },
},
f11: {
delay: 1500,
narrate: '第十一个。疲惫。真实的、身体里的疲惫,它已经跟了你很久了。',
tap: { label: '承认疲惫', narrate: '你说:我累了。疲惫点了点头,说我知道。',
state: s => ({ kept: [...s.kept, '疲惫'], released: s.released }), next: 'f12' },
doubleTap:{ label: '让它走', narrate: '疲惫漂走了。你感觉稍微轻了一点点。',
state: s => ({ kept: s.kept, released: [...s.released, '疲惫'] }), next: 'f12' },
},
f12: {
delay: 2000,
narrate: '最后一个。它没有名字。是你漂浮在这里时特有的某种感觉,说不清,但它在。',
tap: { label: '带走它', narrate: '你把它揣在怀里了,连同它的没有名字。',
state: s => ({ kept: [...s.kept, '无名之物'], released: s.released }), next: null },
doubleTap:{ label: '留在这里', narrate: '你看着它留在了这片水里,这片梦里。',
state: s => ({ kept: s.kept, released: [...s.released, '无名之物'] }), next: null },
},
},
getEnding: () => 'only',
endings: {
only: {
narrate: s => {
let t = '你漂回来了。';
if (s.kept.length > 0) t += `你带走了:${s.kept.join('、')}`;
if (s.released.length > 0) t += `你放走了:${s.released.join('、')}`;
t += '这就是今天的你。';
return t;
},
},
},
};
+206
View File
@@ -0,0 +1,206 @@
// games/houzhen.js — 《候诊》
// 等待检查结果的两小时。
// 单击=把注意力放在外面,双击=退回到自己里面。
// 没有好坏结局,只有你等待的方式。
const GAME_HOUZHEN = {
id: 'houzhen',
intro: '下午一点半,你坐在医院候诊室,等一个检查结果。叫号机已经叫到了八十四号。你是九十四号。单击,把注意力放到外面。双击,退回到自己里面。',
first: 'h01',
bgm: null,
ambience: null,
initialState: { out: 0, inside: 0, moments: [] },
scenes: {
// 1. 建立等待感(无选择)
h01: {
delay: 3000,
narrate: '叫号机叫到了八十五号。你的号是九十四。等待的时间被切成了一小块一小块的,每一块都很结实。',
next: 'h02',
},
// 2. 左边的老人
h02: {
delay: 1000,
narrate: '你左边坐着一个老人。他在看窗外,窗外是停车场,没有什么特别的。但他看得很专注。',
tap: {
label: '看看他',
narrate: '你看了他一会儿。他感觉到了,朝你点了点头。你也点了点头。没有说话,但有什么东西发生了。',
state: s => ({ out: s.out + 1, inside: s.inside, moments: [...s.moments, '和老人点头'] }),
next: 'h03_wait',
},
doubleTap: {
label: '低头看手机',
narrate: '你低下头,看手机。屏幕亮着,什么都有,但你没有真的看进去什么。',
state: s => ({ out: s.out, inside: s.inside + 1, moments: s.moments }),
next: 'h03_wait',
},
},
h03_wait: {
delay: 5000,
narrate: '叫号机叫到了八十八号。',
next: 'h03',
},
// 3. 一条旧消息
h03: {
delay: 1000,
narrate: '你手机上有一条两天前的消息,你当时看到了,但没有回。那个人说:最近怎么样。',
tap: {
label: '打开来回复',
narrate: '你打开来,打了几个字。发出去了。不知道他会不会在意这么晚才回。',
state: s => ({ out: s.out + 1, inside: s.inside, moments: [...s.moments, '回了两天前的消息'] }),
next: 'h04_wait',
},
doubleTap: {
label: '先不管它',
narrate: '你把手机翻过去,屏幕朝下放在大腿上。等等再说。',
state: s => ({ out: s.out, inside: s.inside + 1, moments: s.moments }),
next: 'h04_wait',
},
},
h04_wait: {
delay: 4000,
narrate: '护士从走廊那头走过来,叫了一个名字,不是你。',
next: 'h04',
},
// 4. 一个孩子在哭
h04: {
delay: 1000,
narrate: '对面有个小孩在哭,大约三四岁,蹲在椅子旁边哭。旁边的大人在轻声说什么,但他还是哭。',
tap: {
label: '看过去',
narrate: '你看着那个孩子。他哭了一会儿就停了,大概是哭够了。你们没有交流,但你陪着他哭完了。',
state: s => ({ out: s.out + 1, inside: s.inside, moments: [...s.moments, '陪孩子哭完了'] }),
next: 'h05_wait',
},
doubleTap: {
label: '看别处',
narrate: '你把视线移开了。每个人都有自己的事,你也有你的。',
state: s => ({ out: s.out, inside: s.inside + 1, moments: s.moments }),
next: 'h05_wait',
},
},
h05_wait: {
delay: 6000,
narrate: '叫号机叫到了九十号。还有四个人。',
next: 'h05',
},
// 5. 窗外的光线
h05: {
delay: 1000,
narrate: '窗外的光线变了。下午的阳光斜进来,在地板上画了一块长方形的亮。你注意到了。',
tap: {
label: '看着那块光',
narrate: '你盯着那块光看了一会儿。它在慢慢移动,非常慢,但是在动。时间在过。',
state: s => ({ out: s.out + 1, inside: s.inside, moments: [...s.moments, '看着地板上的光'] }),
next: 'h06_wait',
},
doubleTap: {
label: '没注意',
narrate: '你闭上眼睛,想了想别的事。',
state: s => ({ out: s.out, inside: s.inside + 1, moments: s.moments }),
next: 'h06_wait',
},
},
h06_wait: {
delay: 4000,
narrate: '叫号机叫到了九十一号。',
next: 'h06',
},
// 6. 电话响了
h06: {
delay: 1000,
narrate: '你的手机响了。是一个你不常打电话的人——是你家里的人。也许只是普通的问候。',
tap: {
label: '接了',
narrate: '你接了。对方说:你在哪啊,吃了吗。你说在外面,没事。就这样。挂掉之后候诊室里还是那么安静。',
state: s => ({ out: s.out + 1, inside: s.inside, moments: [...s.moments, '接了家人的电话'] }),
next: 'h07_wait',
},
doubleTap: {
label: '没接',
narrate: '你看着屏幕亮了又暗。现在不是时候。等会儿再说。',
state: s => ({ out: s.out, inside: s.inside + 1, moments: s.moments }),
next: 'h07_wait',
},
},
h07_wait: {
delay: 5000,
narrate: '叫号机叫到了九十二号。',
next: 'h07',
},
// 7. 右边的陌生人
h07: {
delay: 1000,
narrate: '你右边的人一直在看同一页的杂志,翻来翻去都是那页。你不确定她在看,还是只是拿着。',
tap: {
label: '看过去',
narrate: '你朝她看了一眼。你们的眼神碰了一下,然后都移开了。没有什么,就是两个陌生人在等待。',
state: s => ({ out: s.out + 1, inside: s.inside, moments: [...s.moments, '和陌生人的眼神'] }),
next: 'h08',
},
doubleTap: {
label: '不看',
narrate: '你没有看。你在想等会儿检查结果会说什么。',
state: s => ({ out: s.out, inside: s.inside + 1, moments: s.moments }),
next: 'h08',
},
},
// 8. 叫号推进(自动)
h08: {
delay: 4000,
narrate: '九十三号。就差一个了。',
next: 'h09',
},
// 9. 叫到你了
h09: {
delay: 2000,
narrate: '九十四号。是你。你站起来,整理了一下衣服,走向那扇门。',
tap: {
label: '走进去',
narrate: '你推开门走进去了。门关上了。等待结束了。',
state: s => s,
next: null,
},
doubleTap: {
label: '再等一下',
narrate: '你停了一秒,深呼吸,然后走进去了。门关上了。',
state: s => s,
next: null,
},
},
},
getEnding: () => 'summary',
endings: {
summary: {
narrate: s => {
const total = s.out + s.inside;
let t = `你等了很久。`;
if (s.out > s.inside)
t += `在这段时间里,你向外走了${s.out}次——看向别人,接了电话,留意窗外。`;
else if (s.inside > s.out)
t += `在这段时间里,你向内走了${s.inside}次——退回到自己里,先不管别的。`;
else
t += `你向外走了${s.out}次,也向内走了${s.inside}次,差不多一半一半。`;
if (s.moments.length > 0)
t += `你记住了:${s.moments.join('')}`;
t += '这就是你今天等待的方式。';
return t;
},
},
},
};
+556
View File
@@ -0,0 +1,556 @@
// games/linxia.js — 《林夏》(重构版)
// 一部碎屏手机,一个北漂的夜晚,16条消息。
// 操作:单击=接听/打开/回复,双击=拒接/忽略/不回。
// 旁白全程解释状态,玩家不看屏幕也能明白自己在哪里。
const _T = '../audio/mp3/tts';
const _MUS = '../audio/mp3/music';
const _AMB = '../audio/mp3/ambience';
const _SFX = '../audio/mp3/sfx';
const GAME_LINXIA = {
id: 'linxia',
intro: '今晚九点。你是林夏,二十四岁,在北京独居。手机屏幕碎了,但还能用。今晚有十六条消息,你来决定接谁,不接谁。单击接听或回复,双击拒接或忽略。',
first: 'msg_01',
bgm: `${_MUS}/lv11_bgm_m1_main_loop_v1.mp3`,
ambience: `${_AMB}/amb_apartment_night_01_v1.mp3`,
initialState: {
mom_link: 0, // 与妈妈的连接深度 0-3
he_back: null, // 阿哲是否回来 null/true/false
zhounan: 0, // 与周南互动深度 0-2
self_heard: false, // 是否听了时光胶囊
engage: 0, // 主动回应次数
avoid: 0, // 回避次数
},
scenes: {
// ══════════════════
// 01 · 妈妈来电
// ══════════════════
msg_01: {
narrate: '晚上九点零三分。手机震动,铃声响起。屏幕显示:妈妈。',
tap: {
label: '接听',
audio: [`${_T}/lv11_2103_mom_call_01_wav_v1.mp3`],
narrate: '你接了。妈妈的声音从电话里传来,带着一点日常的关心,问你今天怎么样,吃了没有。你说还好。挂掉之后,房间里又安静了。',
state: s => ({ mom_link: s.mom_link + 1, engage: s.engage + 1 }),
next: 'msg_02_notify',
},
doubleTap: {
label: '不接',
narrate: '铃声响了又停。妈妈没再打来。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_02_notify',
},
},
// ══════════════════
// 02 · 小美语音×3(教学关)
// ══════════════════
msg_02_notify: {
delay: 1500,
narrate: '九点一刻。微信震了两下。小美给你发来三条语音消息。',
tap: {
label: '打开来听',
state: s => s,
next: 'msg_02_play',
},
doubleTap: {
label: '先不看',
narrate: '你把手机屏幕朝下放着。消息还在那里,但你不想听。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_03',
},
},
msg_02_play: {
audio: [
`${_T}/lv11_2115_xiaomei_voice_01_wav_v1.mp3`,
`${_T}/lv11_2115_xiaomei_voice_02_wav_v1.mp3`,
`${_T}/lv11_2115_xiaomei_voice_03_wav_v1.mp3`,
],
narrate: '小美说了很多,大意是关于上次那件事,她一直想跟你说清楚,她说她只是太担心你了。最后她问:你最近还好吗?',
tap: {
label: '回复她',
narrate: '你回了消息。小美很快回说:谢谢你愿意回我。',
state: s => ({ engage: s.engage + 1 }),
next: 'msg_03',
},
doubleTap: {
label: '不回了',
narrate: '你听完了,但没有回。消息就这样放着。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_03',
},
},
// ══════════════════
// 03 · 外卖小哥来电
// ══════════════════
msg_03: {
delay: 2000,
narrate: '九点二十分。来电话了,是一个陌生号码——是外卖小哥,说你的外卖到了,问你在哪层。',
tap: {
label: '接听',
audio: [`${_T}/lv11_2120_delivery_call_01_wav_v1.mp3`],
narrate: '你接了,告诉他楼层,外卖放门口就好。过了一会儿,手机震动,门铃响了,外卖到了。',
state: s => ({ engage: s.engage + 1 }),
next: 'msg_04_notify',
},
doubleTap: {
label: '不接',
narrate: '你没接。不知道外卖最后怎么样了。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_04_notify',
},
},
// ══════════════════
// 04 · 阿哲语音
// ══════════════════
msg_04_notify: {
delay: 2500,
narrate: '九点半。微信震了一下。阿哲——你的前男友——给你发来一条语音。',
tap: {
label: '听',
state: s => s,
next: 'msg_04_play',
},
doubleTap: {
label: '不看',
narrate: '你没有打开。阿哲的消息放在那里。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_05_notify',
},
},
msg_04_play: {
audio: [`${_T}/lv11_2130_azhe_voice_01_wav_v1.mp3`],
narrate: '阿哲说,他有一件东西落在你这里了,问你方便不方便他过来拿一下。语气很平,不像是有别的意思。',
tap: {
label: '回他',
narrate: '你回了条消息说可以。阿哲说谢谢,说到时候再联系。你放下手机,有点说不清的感觉。',
state: s => ({ engage: s.engage + 1 }),
next: 'msg_05_notify',
},
doubleTap: {
label: '不回',
narrate: '你没有回。消息就这样。',
state: s => ({ avoid: s.avoid + 1, he_back: s.he_back === null ? false : s.he_back }),
next: 'msg_05_notify',
},
},
// ══════════════════
// 05 · HR王姐语音
// ══════════════════
msg_05_notify: {
delay: 2000,
narrate: '九点四十五分。HR王姐发来语音,是关于下个月调岗的事,问你有没有空聊一聊。',
tap: {
label: '听',
state: s => s,
next: 'msg_05_play',
},
doubleTap: {
label: '明天再说',
narrate: '你把消息标记了已读,回头再说。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_06_notify',
},
},
msg_05_play: {
audio: [`${_T}/lv11_2145_hr_voice_01_wav_v1.mp3`],
narrate: '王姐说有个岗位的变动,你可能会受到影响,想提前跟你说一声,让你有个准备。语气是友善的,像是帮你。',
tap: {
label: '回她',
narrate: '你回了消息说谢谢告知,改天约时间聊。王姐说好的。',
state: s => ({ engage: s.engage + 1 }),
next: 'msg_06_notify',
},
doubleTap: {
label: '不回',
narrate: '你没有回。这件事先放一放。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_06_notify',
},
},
// ══════════════════
// 06 · 安安·酒吧邀约
// ══════════════════
msg_06_notify: {
delay: 2000,
narrate: '晚上十点。安安发来语音,她说她们今晚在酒吧,问你要不要过来,说来了就是了,不用提前说。',
tap: {
label: '听完再说',
state: s => s,
next: 'msg_06_play',
},
doubleTap: {
label: '不去',
narrate: '你没有打开消息。今晚不想动。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_07_notify',
},
},
msg_06_play: {
audio: [`${_T}/lv11_2200_anan_voice_01_wav_v1.mp3`],
narrate: '安安的声音很热闹,背景里有音乐。她说来嘛来嘛,好久没见了,就几个人,很轻松的那种。',
tap: {
label: '答应去',
narrate: '你回了条消息说行,等等到。安安说耶!你站起来换了件外套,但后来不知道为什么又坐下了。',
state: s => ({ engage: s.engage + 1 }),
next: 'msg_07_notify',
},
doubleTap: {
label: '不去了',
narrate: '你回了消息说今晚状态不太好,下次吧。安安说没事,下次一定。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_07_notify',
},
},
// ══════════════════
// 07 · 寝室群×5
// ══════════════════
msg_07_notify: {
delay: 2000,
narrate: '晚上十点一刻。寝室群活了,连着震了好几下,是大学室友们在聊。你们已经很久没联系了。',
tap: {
label: '看看聊什么',
state: s => s,
next: 'msg_07_play',
},
doubleTap: {
label: '静音',
narrate: '你把群消息静音了。不是今晚。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_08',
},
},
msg_07_play: {
audio: [
`${_T}/lv11_2215_dorm_a_voice_01_wav_v1.mp3`,
`${_T}/lv11_2215_dorm_b_voice_01_wav_v1.mp3`,
`${_T}/lv11_2215_dorm_c_voice_01_wav_v1.mp3`,
`${_T}/lv11_2215_dorm_a_voice_02_wav_v1.mp3`,
`${_T}/lv11_2215_dorm_b_voice_02_wav_v1.mp3`,
],
narrate: '她们在聊上周聚会的事,有人说想你了,有人问你最近怎么样,有人说快来啊。那种熟悉的语气,你有点想念。',
tap: {
label: '回一条',
narrate: '你发了一条消息,说最近还好,想你们了。群里一下子热闹起来,大家说你终于出现了。你看着那些消息,嘴角动了一下。',
state: s => ({ engage: s.engage + 1 }),
next: 'msg_08',
},
doubleTap: {
label: '看着但不回',
narrate: '你看完了,但没有回复。你把手机放下。那种热闹是她们的,你在这边。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_08',
},
},
// ══════════════════
// 08 · 未知号码
// ══════════════════
msg_08: {
delay: 3000,
narrate: '晚上十点半。手机响了,来电显示:未知号码。铃声有点不一样,低沉的那种。',
tap: {
label: '接听',
audio: [`${_SFX}/sfx_breathing_unknown_v1.mp3`],
narrate: '你接了。里面没有声音,只有轻微的呼吸声,然后断了。你不知道是谁,心跳加快了一下。',
state: s => ({ engage: s.engage + 1 }),
next: 'msg_09_notify',
},
doubleTap: {
label: '不接',
narrate: '未知号码,不接。铃声停了。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_09_notify',
},
},
// ══════════════════
// 09 · 小美醉了·深夜语音×4
// ══════════════════
msg_09_notify: {
delay: 3000,
bgm: `${_MUS}/lv11_bgm_m2_suspense_v1.mp3`,
narrate: '十点四十五分。小美又发来消息,连续四条。语音很长,而且发送时间是一条接一条。',
tap: {
label: '听',
state: s => s,
next: 'msg_09_play',
},
doubleTap: {
label: '先不听',
narrate: '消息放那里,你没有打开。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_10_notify',
},
},
msg_09_play: {
audio: [
`${_T}/lv11_2245_xiaomei_voice_01_seg1_wav_v1.mp3`,
`${_T}/lv11_2245_xiaomei_voice_01_seg2_wav_v1.mp3`,
`${_T}/lv11_2245_xiaomei_voice_01_seg3_wav_v1.mp3`,
`${_T}/lv11_2245_xiaomei_voice_01_seg4_wav_v1.mp3`,
],
narrate: '小美喝多了,说话有点不清楚,但每一句都很认真。她说她一直有件事想跟你说:那年她告诉了别人,是她不对,她知道,她一直很愧疚,她说她怕你恨她。',
tap: {
label: '回她',
narrate: '你打了几个字发过去。小美隔了很久才回,说谢谢你,然后说她要先睡了,明天聊。你把手机放下。',
state: s => ({ engage: s.engage + 1 }),
next: 'msg_10_notify',
},
doubleTap: {
label: '今晚不回',
narrate: '你没有回。这件事太重了,不是今晚能说清楚的。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_10_notify',
},
},
// ══════════════════
// 10 · 妈妈文字消息
// ══════════════════
msg_10_notify: {
delay: 2500,
narrate: '深夜十一点刚过。妈妈发来一条文字消息,就五个字:睡了吗,夏夏。',
tap: {
label: '回妈妈',
narrate: '你回了:还没呢,妈你睡吧。妈妈说好,早点睡,然后发了个晚安的表情。你盯着那个表情看了一会儿。',
state: s => ({ mom_link: s.mom_link + 1, engage: s.engage + 1 }),
next: 'msg_11',
},
doubleTap: {
label: '不回',
narrate: '你没有回妈妈的消息。她那边大概以为你已经睡了。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_11',
},
},
// ══════════════════
// 11 · 阿哲来电(关键分支)
// ══════════════════
msg_11: {
delay: 2000,
narrate: '十一点一刻。手机响了,专属铃声——是阿哲打来的。',
tap: {
label: '接听',
audio: [`${_T}/lv11_2315_azhe_call_01_wav_v1.mp3`],
narrate: '阿哲说他就在附近,问你方不方便他上来取一下那件东西。他的声音平静,但停顿了一下。',
state: s => ({ engage: s.engage + 1 }),
next: 'msg_11_choice',
},
doubleTap: {
label: '拒接',
narrate: '铃声停了。阿哲没再打来。',
state: s => ({ avoid: s.avoid + 1, he_back: false }),
next: 'msg_12_notify',
},
},
msg_11_choice: {
narrate: '他在等你说行不行。',
tap: {
label: '让他来',
narrate: '你说好,上来吧。阿哲说谢谢,说马上到。你放下手机,在房间里站了一会儿。',
state: s => ({ he_back: true }),
next: 'msg_12_notify',
},
doubleTap: {
label: '不方便',
narrate: '你说今晚不太方便。阿哲沉默了一秒,说行,那之后再说。挂掉了。',
state: s => ({ he_back: false }),
next: 'msg_12_notify',
},
},
// ══════════════════
// 12 · 周南·第一条语音
// ══════════════════
msg_12_notify: {
delay: 3000,
narrate: '十一点半。一个名字出现在屏幕上:周南。你有三年没联系了,他突然发来一条语音。',
tap: {
label: '听',
state: s => s,
next: 'msg_12_play',
},
doubleTap: {
label: '不打开',
narrate: '你没有打开周南的消息。三年了,不急这一会儿。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_13_notify',
},
},
msg_12_play: {
audio: [`${_T}/lv11_2330_zhounan_voice_01_wav_v1.mp3`],
narrate: '周南说:林夏你好,是我周南,不知道你还记不记得,就是突然想起你,也不知道你还在不在这个号,就发一条。他的声音还是老样子,只是低沉了一些。',
tap: {
label: '回他',
narrate: '你回了:记得,你好啊。消息发出去了,你盯着那两个字,觉得说多说少都不对,就这样。',
state: s => ({ zhounan: s.zhounan + 1, engage: s.engage + 1 }),
next: 'msg_13_notify',
},
doubleTap: {
label: '不回',
narrate: '你听完了,没有回。也许三年的空白不是一条消息能填上的。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_13_notify',
},
},
// ══════════════════
// 13 · 周南·长语音×4
// ══════════════════
msg_13_notify: {
delay: 2500,
bgm: `${_MUS}/lv11_bgm_m3_zhounan_v1.mp3`,
narrate: '深夜十一点四十五分。周南又发来四条,比刚才长很多。',
tap: {
label: '听',
state: s => s,
next: 'msg_13_play',
},
doubleTap: {
label: '不听了',
narrate: '你把手机放到一边了。今晚已经够多了。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_14',
},
},
msg_13_play: {
audio: [
`${_T}/lv11_2345_zhounan_voice_01_wav_v1.mp3`,
`${_T}/lv11_2345_zhounan_voice_02_wav_v1.mp3`,
`${_T}/lv11_2345_zhounan_voice_03_wav_v1.mp3`,
`${_T}/lv11_2345_zhounan_voice_04_wav_v1.mp3`,
],
narrate: '周南说了他这十年:换了几个城市,做了几份工作,有一段感情,然后结束了。他说他今晚喝了一点酒,就想到你,想到你们高中的那些事,说他一直不知道当年为什么就断了联系。然后他问:你在北京过得好吗?',
tap: {
label: '认真回他',
narrate: '你想了很久,然后把这段时间的事告诉了周南。说不上好不好,就是在过。他说谢谢你告诉我这些。你们聊了很久。',
state: s => ({ zhounan: s.zhounan + 1, engage: s.engage + 1 }),
next: 'msg_14',
},
doubleTap: {
label: '说还好',
narrate: '你回了说还好,然后问了他现在在哪里。你们就这样聊着,没有太深入,但很平静。',
state: s => ({ engage: s.engage + 1 }),
next: 'msg_14',
},
},
// ══════════════════
// 14 · 妈妈·第二次来电
// ══════════════════
msg_14: {
delay: 3000,
narrate: '凌晨零点一刻。妈妈又打来电话了,这么晚——她还没睡。',
tap: {
label: '接听',
audio: [`${_T}/lv11_0015_mom_call_01_wav_v1.mp3`],
narrate: s => s.mom_link >= 1
? '你接了。妈妈说:我就是想再听到你的声音。你们聊了一会儿,说了一些很日常的话,但你都记住了。'
: '你接了。妈妈说:没事,就是问问你睡了没。你说没呢,妈你怎么还不睡。她说想听你的声音。',
state: s => ({ mom_link: s.mom_link + 1, engage: s.engage + 1 }),
next: 'msg_15',
},
doubleTap: {
label: '拒接',
narrate: '太晚了,挂掉了。妈妈没有再打来。',
state: s => ({ avoid: s.avoid + 1 }),
next: 'msg_15',
},
},
// ══════════════════
// 15 · 时光胶囊
// ══════════════════
msg_15: {
delay: 2000,
narrate: '凌晨零点半。手机弹出一个系统通知:三年前的今天,你给三年后的自己录了一条语音,今天到了。',
tap: {
label: '听这条语音',
audio: [
`${_T}/lv11_sys_memo_01_wav_v1.mp3`,
`${_T}/lv11_0030_self3y_voice_01_wav_v1.mp3`,
],
narrate: '你听到了三年前的自己的声音。她说:如果你在听这条消息,说明你撑过来了。不管现在怎么样,你撑过来了。然后她停顿了很久,说:辛苦了。',
state: s => ({ self_heard: true, engage: s.engage + 1 }),
next: 'msg_16',
},
doubleTap: {
label: '不听了',
narrate: '你把那个通知划掉了。也许不是现在。',
state: s => ({ self_heard: false, avoid: s.avoid + 1 }),
next: 'msg_16',
},
},
// ══════════════════
// 16 · 结局触发点
// ══════════════════
msg_16: {
delay: 3000,
narrate: s => s.mom_link >= 2
? '凌晨两点。妈妈发来一段很长的语音,她说她一直有些话想说给你听。'
: '凌晨两点。房间很安静,窗外偶尔有车声。今晚就这样了。',
tap: {
label: '听妈妈说',
audio: s => s.mom_link >= 2 ? [
`${_T}/lv11_0200_mom_voice_01_seg1_wav_v1.mp3`,
`${_T}/lv11_0200_mom_voice_01_seg2_wav_v1.mp3`,
`${_T}/lv11_0200_mom_voice_01_seg3_wav_v1.mp3`,
`${_T}/lv11_0200_mom_voice_01_seg4_wav_v1.mp3`,
`${_T}/lv11_0200_mom_voice_01_seg5_wav_v1.mp3`,
`${_T}/lv11_0200_mom_voice_01_seg6_wav_v1.mp3`,
`${_T}/lv11_0200_mom_voice_01_seg7_wav_v1.mp3`,
`${_T}/lv11_0200_mom_voice_01_seg8_wav_v1.mp3`,
] : [],
narrate: s => s.mom_link >= 2
? '妈妈说了很多——关于你小时候,关于她一直担心的事,关于她说不出口但一直想说的话。你把手机贴在耳边,听完了。'
: '你就这样坐着,今晚的声音慢慢变少了。',
state: s => s,
next: null,
},
doubleTap: {
label: '就这样了',
narrate: '你把手机放到床头,闭上眼睛。今晚就这样了。',
state: s => s,
next: null,
},
},
},
getEnding: s => {
if (s.he_back === true) return 'return'; // 优先级1
if (s.mom_link >= 2 && s.engage >= 4) return 'link'; // 优先级2
if (s.zhounan >= 2) return 'past'; // 优先级3
if (s.self_heard) return 'self'; // 优先级4
return 'island'; // 兜底
},
endings: {
return: {
narrate: '《回归》。阿哲来了,或者你们说好了见面。不管那个门开不开,今晚你做了一个决定。',
},
link: {
narrate: '《连接》。今晚你和妈妈说了话,不止一次。那种联系很细,但是在。也许明天你会想打电话回去。',
},
past: {
narrate: '《旧路》。周南在深夜找到了你,你们聊了很久。那条从高中就断掉的线,今晚接上了一点点。',
},
self: {
narrate: '《自省》。今晚你听了三年前自己的声音。她说辛苦了,你接受了这句话。这不算小事。',
},
island: {
narrate: '《孤岛》。今晚大部分的消息,你都没有回应。你一个人度过了这个夜晚。这也是一种选择,你心里清楚。',
},
},
};
+175
View File
@@ -0,0 +1,175 @@
// games/shouye.js — 《守夜》
// 夜班保安,监控室,五个小时。
// 单击=去查那个声音,双击=按兵不动。
// 结局由你调查了什么决定。
const GAME_SHOUYE = {
id: 'shouye',
intro: '凌晨零点。你是这栋楼的夜班保安,独自坐在一楼监控室。你不能离开座位,但你有一部对讲机和一部电话。今晚五点交班。单击,去查那个声音。双击,按兵不动。',
first: 's01',
bgm: null,
ambience: '../audio/mp3/ambience/amb_apartment_night_01_v1.mp3',
initialState: {
investigated: [],
ignored: [],
family_called: false,
saw_dawn: false,
missed_fight: true, // 默认"没管四楼争吵"
},
scenes: {
// ── 00:10 地下室水声 ──
s01: {
delay: 2000,
narrate: '凌晨零点十分。地下室传来水声,不大,但是持续的,一直在响。',
tap: {
label: '打电话下去查',
narrate: '你拨了地下室的内线。没人接。你下去看了一眼,是一根管子在漏水,用旧布堵住了。回到监控室,零点二十三分。',
state: s => ({ investigated: [...s.investigated, '地下室漏水'], ignored: s.ignored }),
next: 's02',
},
doubleTap: {
label: '先不管',
narrate: '你听了一会儿。应该不碍事。你把注意力放回到监控屏幕上。',
state: s => ({ investigated: s.investigated, ignored: [...s.ignored, '地下室水声'] }),
next: 's02',
},
},
// ── 00:47 四楼争吵 ──
s02: {
delay: 3000,
narrate: '零点四十七分。四楼传来争吵声,一男一女,断断续续,能听出来语气很激动,但听不清说什么。',
tap: {
label: '打电话上去',
narrate: '你拨了四楼的号码。男的接了,说没事,就是聊天,让你别担心。声音还是有点紧,但他们关掉了。你记下来了:四楼,零点五十二分,双方均在场。',
state: s => ({ investigated: [...s.investigated, '四楼争吵'], ignored: s.ignored, missed_fight: false }),
next: 's03',
},
doubleTap: {
label: '不管,私事',
narrate: '家务事,你管不着。你转过身去继续盯着屏幕。争吵后来停了,你不知道是什么时候。',
state: s => ({ investigated: s.investigated, ignored: [...s.ignored, '四楼争吵'], missed_fight: true }),
next: 's03',
},
},
// ── 01:17 三楼脚步(老陈)──
s03: {
delay: 3000,
narrate: '凌晨一点十七分。三楼有脚步声,很轻,走走停停。监控记录:三楼只有七零二亮着灯,住户是老陈,八十岁,独居。',
tap: {
label: '打电话问问',
narrate: '电话通了,老陈说他在找眼镜,眼镜掉床底下了。你说没事我就是例行确认一下。他说谢谢你小伙子,然后你们聊了几句,关于他年轻时候也做过工程。你挂掉电话,一点二十八分。',
state: s => ({ investigated: [...s.investigated, '三楼老陈'], ignored: s.ignored }),
next: 's04',
},
doubleTap: {
label: '应该没事',
narrate: '老人家睡不好,很正常。你记了一笔,继续监控。脚步声后来停了。',
state: s => ({ investigated: s.investigated, ignored: [...s.ignored, '三楼脚步'] }),
next: 's04',
},
},
// ── 02:03 门铃无应答 ──
s04: {
delay: 4000,
narrate: '凌晨两点零三分。大门门铃响了,不是访客系统,是有人在外面按了手动铃,按了两下,然后停了。没有人应答。监控画面:门口没有人了。',
tap: {
label: '出去看看',
narrate: '你走到门口,什么都没有,只有一个快递包裹靠在墙边,大概是外卖或者什么。也许是送错了。你把包裹推到角落,拍了张照,两点一刻回到座位。',
state: s => ({ investigated: [...s.investigated, '门铃'], ignored: s.ignored }),
next: 's05',
},
doubleTap: {
label: '不是你管的事',
narrate: '无应答,无入侵,记录在案,按流程忽略。你回到屏幕前。',
state: s => ({ investigated: s.investigated, ignored: [...s.ignored, '门铃'] }),
next: 's05',
},
},
// ── 02:55 你打盹了(自动)──
s05: {
delay: 4000,
narrate: s => {
const base = '凌晨两点五十五分。你不知道从什么时候开始,眼皮变得很重……你睡着了一会儿。';
if (s.missed_fight)
return base + '醒来的时候,你看到监控里,四楼门口放着一个箱子,不知道是谁的,不知道什么时候放的。';
return base + '醒来的时候,监控里一切正常。你记录:三点十二分,短暂失守。';
},
next: 's06',
},
// ── 03:30 家人来电 ──
s06: {
delay: 3000,
narrate: '凌晨三点半。你的私人手机响了。是家里的人打来的。这么晚打来——可能是什么事,也可能只是弄错时间了。',
tap: {
label: '接了',
narrate: '是你妈,她说睡不着,看见你今天发的朋友圈,就想打个电话。你们聊了十分钟,说了些不重要的事。挂掉之后,监控室比之前暖了一点点。',
state: s => ({ ...s, family_called: true }),
next: 's07',
},
doubleTap: {
label: '没接',
narrate: '这么晚不方便。你把手机调成静音,准备待会儿发条消息。你没有发。',
state: s => ({ ...s, family_called: false }),
next: 's07',
},
},
// ── 04:12 屋顶声音 ──
s07: {
delay: 4000,
narrate: '凌晨四点十二分。屋顶传来动静,断断续续,不像是机械设备,更像是有什么东西在走动。',
tap: {
label: '上去看',
narrate: '你拿着手电上了顶层,推开屋顶的铁门,风吹过来,你看见了天边刚刚开始发白的颜色——还不是黎明,但是快了。然后你看见了一只流浪猫,坐在水箱上看着你。你们看了对方一会儿,然后你下来了。你是今晚唯一上了屋顶的人。',
state: s => ({ ...s, investigated: [...s.investigated, '屋顶'], saw_dawn: true }),
next: 's08',
},
doubleTap: {
label: '可能是猫',
narrate: '你记了一笔,继续监控。动静后来停了。',
state: s => ({ ...s, ignored: [...s.ignored, '屋顶声音'], saw_dawn: false }),
next: 's08',
},
},
// ── 05:00 交班(自动→结局)──
s08: {
delay: 5000,
narrate: '五点整。接班的人进来了,你们交接了几句,递了记录本,你背上包走出去。外面已经有一点点亮了。',
next: null,
},
},
getEnding: s => {
if (s.saw_dawn) return 'dawn';
const n = s.investigated.length;
if (n >= 4 && s.family_called) return 'solid';
if (n <= 1 && !s.family_called) return 'detached';
if (s.missed_fight) return 'oversight';
return 'ordinary';
},
endings: {
dawn: {
narrate: '《你看到了什么》。今晚你一个人上了屋顶,你看到了那只猫,你看到了天边开始变色。只有你看到了。这算不上什么,但只有你知道。',
},
solid: {
narrate: '《平凡守夜》。你今晚查了应该查的,接了应该接的电话。没有大事,没有遗漏。这栋楼平安了一夜,有一部分是因为你在。',
},
detached: {
narrate: '《这不是我的事》。你今晚保持了距离。声音来了,你记录,你不动。你守住了一夜,但不知道楼里发生了什么。这也是一种守夜的方式。',
},
oversight: {
narrate: '《疏忽》。你今晚漏掉了四楼的事。那个女人在凌晨两点多拎着箱子离开了,你没有注意到。也许和你没有关系。也许有。你不知道。',
},
ordinary: {
narrate: '《普通的夜班》。你做了你能做的,漏掉了一些,照顾到了一些。和所有普通的夜班一样。',
},
},
};
+185
View File
@@ -0,0 +1,185 @@
// games/yinian.js — 《一念》
// 内心法庭。七个声音,一个决定。
// 单击=接纳这个声音,双击=让它走。
// 结局由接纳了哪些声音决定。
const GAME_YINIAN = {
id: 'yinian',
intro: '你坐在黑暗里,独自一人。今晚你必须做一个决定——你自己知道是什么。七个声音在等着和你说话。单击,让它说完。双击,让它走。',
first: 'v01',
bgm: null,
ambience: '../audio/mp3/ambience/amb_apartment_night_01_v1.mp3',
initialState: {
accepted: [], // 接纳的声音
rejected: [], // 放走的声音
// 关键标记
kept_fear: false,
kept_longing: false,
kept_apology: false,
kept_past: false,
},
scenes: {
// ── 第一个声音:三年前的自己 ──
v01: {
delay: 1000,
narrate: '第一个声音来了。是三年前的你。那时候你刚做了一个选择,你觉得那是对的。那时候的你说:我们当时想要的,你还记得吗?',
tap: {
label: '听它说完',
narrate: '你让三年前的自己说完了。她说,你当时很想要那件事。你知道那是真的。',
state: s => ({ accepted: [...s.accepted, '三年前的自己'], rejected: s.rejected, kept_past: true }),
next: 'v02',
},
doubleTap: {
label: '让它走',
narrate: '三年前的声音淡去了。你选择了不看它。',
state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '三年前的自己'], kept_past: false }),
next: 'v02',
},
},
// ── 第二个声音:一个老朋友的留言 ──
v02: {
delay: 1000,
narrate: '第二个声音。是一个你很久没联系的朋友,她留了条语音。她说:你最近怎么样,我有点担心你。很久了,你一直没回。',
tap: {
label: '听下去',
narrate: '你把这份担心收进来了。她还在关心你,这件事是真实的。',
state: s => ({ accepted: [...s.accepted, '朋友的担心'], rejected: s.rejected }),
next: 'v03',
},
doubleTap: {
label: '让它走',
narrate: '留言消失了。你没有听完。',
state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '朋友的担心'] }),
next: 'v03',
},
},
// ── 第三个声音:关于钱的恐惧 ──
v03: {
delay: 1000,
narrate: '第三个声音。是关于钱的恐惧。它说:你知道你现在能撑多久吗?如果这件事不成,你怎么办?它问得很具体,很冷静。',
tap: {
label: '正视它',
narrate: '你承认了:这份恐惧是真实的。你带着它,而不是假装它不存在。',
state: s => ({ accepted: [...s.accepted, '对钱的恐惧'], rejected: s.rejected, kept_fear: true }),
next: 'v04',
},
doubleTap: {
label: '先不管它',
narrate: '恐惧的声音淡下去了。你知道它还在,但今晚先不看。',
state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '对钱的恐惧'], kept_fear: false }),
next: 'v04',
},
},
// ── 第四个声音:关于孤独的渴望 ──
v04: {
delay: 1000,
narrate: '第四个声音。是一种渴望——想要被人理解,想要有人在身边,想要不那么孤独。它来得轻,但是很深。',
tap: {
label: '承认它',
narrate: '你承认了这份渴望。它是真实的,不是软弱。',
state: s => ({ accepted: [...s.accepted, '对连接的渴望'], rejected: s.rejected, kept_longing: true }),
next: 'v05',
},
doubleTap: {
label: '让它走',
narrate: '渴望漂走了。你告诉自己,一个人也没什么。',
state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '对连接的渴望'], kept_longing: false }),
next: 'v05',
},
},
// ── 第五个声音:一个未说出口的道歉 ──
v05: {
delay: 1000,
narrate: '第五个声音。是一个你一直没有说出口的道歉。你知道是对谁的,你知道说的是什么。这个声音只是在等你。',
tap: {
label: '说出来',
narrate: '你在黑暗里,说了那句对不起。没有人听见,但你说了。有什么东西松开了。',
state: s => ({ accepted: [...s.accepted, '那声道歉'], rejected: s.rejected, kept_apology: true }),
next: 'v06',
},
doubleTap: {
label: '还没准备好',
narrate: '道歉的声音还在那里。你没有说。它等着。',
state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '那声道歉'], kept_apology: false }),
next: 'v06',
},
},
// ── 第六个声音:关于那个决定 ──
v06: {
delay: 1000,
narrate: '第六个声音。是今晚那个决定本身。它把两种可能都摆在你面前,不说话,只是等着你看清楚。',
tap: {
label: '看清楚它',
narrate: '你看了。两条路都有代价,你知道了。这不让选择变得更容易,但让它变得更真实。',
state: s => ({ accepted: [...s.accepted, '那个决定本身'], rejected: s.rejected }),
next: 'v07',
},
doubleTap: {
label: '还不想看',
narrate: '你把眼睛闭上了。还没准备好看那两条路。',
state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '那个决定本身'] }),
next: 'v07',
},
},
// ── 第七个声音:现在的自己 ──
v07: {
delay: 2000,
narrate: s => {
const n = s.accepted.length;
if (n >= 5)
return '第七个声音,是现在的你。她说:你今晚接住了很多,你没有回避它们。这需要力气。';
if (n <= 1)
return '第七个声音,是现在的你。她沉默了很久,然后说:你把它们都推走了。也许你需要的不是答案,是喘息。';
if (s.kept_fear && !s.kept_longing)
return '第七个声音,是现在的你。她说:你把恐惧留下了,但把渴望推走了。你知道这意味着什么吗?';
return '第七个声音,是现在的你。她说:你接住了一些,放走了一些。这就是你今晚的状态。';
},
tap: {
label: '听下去',
narrate: '你听完了现在的自己说话。她说:不管你今晚做什么决定,你都已经在这里待过了。这算数。',
state: s => ({ accepted: [...s.accepted, '现在的自己'], rejected: s.rejected }),
next: null,
},
doubleTap: {
label: '就这样',
narrate: '你没有听完。你觉得你已经知道了你需要知道的。',
state: s => ({ accepted: s.accepted, rejected: [...s.rejected, '现在的自己'] }),
next: null,
},
},
},
getEnding: s => {
if (s.kept_apology) return 'reconcile';
const n = s.accepted.length;
if (n >= 5) return 'accept';
if (n <= 1) return 'release';
if (s.kept_fear && !s.kept_longing) return 'stuck';
return 'between';
},
endings: {
accept: {
narrate: '《接纳》。你今晚接住了很多,你没有选择只是轻装。也许你的决定,就是带着这些往前走。',
},
release: {
narrate: '《出发》。你今晚把大部分都放走了。你想要的也许是轻。轻装,也是一种勇气。',
},
stuck: {
narrate: '《困住》。你接受了恐惧,但推走了渴望。你知道风险,但忘了你想要的是什么。这个决定,也许需要再等一等。',
},
reconcile: {
narrate: '《和解》。你说出了那声道歉。不管今晚的决定是什么,你和自己和解了一点点。这很重要。',
},
between: {
narrate: '《悬在中间》。你接住了一些,放走了一些。你还悬在两个答案之间。也许这就是你今晚真实的状态。',
},
},
};
+308
View File
@@ -0,0 +1,308 @@
// games/yisheng.js — 《遗声》
// 陌生人的手机,十五条录音。
// 单击=听这条,双击=跳过。
// 跳过就永远不知道这条说了什么。
// 结局由你听了多少、听了哪些决定。
const GAME_YISHENG = {
id: 'yisheng',
intro: '你收到了一部手机。手机的主人去世了一个月前。他留下了很多录音,按时间排列。你不认识他。或者你以为你不认识他。单击,听这条录音。双击,跳过,永远不知道这条说了什么。',
first: 'r01',
bgm: null,
ambience: null,
initialState: {
heard: [],
skipped: [],
heard_critical: 0, // 听了几条关键录音(r10-r14)
unlocked_last: false,
},
scenes: {
// ── 第1条:三月五日 ──
r01: {
narrate: '第一条录音。录制时间:三月五日,早上八点。时长:二十二秒。文件名:买橙子。',
tap: {
label: '听',
narrate: '他说:今天买了一袋橙子,是脐橙,比上次的甜很多。建议大家有机会可以试试。就这些。录音结束。',
state: s => ({ heard: [...s.heard, '买橙子'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r02',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第一条。你永远不会知道这条说了什么了。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第一条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r02',
},
},
// ── 第2条:工资日 ──
r02: {
narrate: '第二条录音。三月十二日,下午。时长:三十八秒。文件名:发工资了。',
tap: {
label: '听',
narrate: '他说:今天发工资了。然后列了他这个月的支出清单,很详细——房租、电费、手机月供、还有给他妈买了一件外套。他说最后剩了不多,但够。录音结束。',
state: s => ({ heard: [...s.heard, '发工资了'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r03',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第二条。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第二条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r03',
},
},
// ── 第3条:工作烦心事 ──
r03: {
narrate: '第三条录音。三月十八日,晚上。时长:一分零七秒。文件名:说说工作的事。',
tap: {
label: '听',
narrate: '他说他们组里来了个新同事,总是在会议上抢着发言,说了很多他本来准备说的话。他说他当时没说话,他不确定那是不是对的选择。他在录音里问自己:我是懦弱了,还是留有余地?他没有答案。录音结束。',
state: s => ({ heard: [...s.heard, '工作的烦恼'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r04',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第三条。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第三条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r04',
},
},
// ── 第4条:和同事吵架 ──
r04: {
narrate: '第四条录音。四月二日。时长:四十九秒。文件名:吵了一架。',
tap: {
label: '听',
narrate: '他说他今天和那个新同事正面冲突了,是在走廊,两个人都没控制住。他说他说了一句话,事后觉得说得太重了。他在录音里把那句话又说了一遍,想听听是不是真的太重。录音里他沉默了一会儿,然后说:是的,是太重了。录音结束。',
state: s => ({ heard: [...s.heard, '吵了一架'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r05',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第四条。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第四条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r05',
},
},
// ── 第5条:电影 ──
r05: {
narrate: '第五条录音。四月十五日,深夜。时长:二十六秒。文件名:电影。',
tap: {
label: '听',
narrate: '他说他去看了一部很老的电影,黑白的,在一个小影院,观众只有六个人。他说他中途哭了,但他不确定是因为电影,还是因为电影院里那种空荡荡的感觉。录音结束。',
state: s => ({ heard: [...s.heard, '老电影'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r06',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第五条。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第五条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r06',
},
},
// ── 第6条:小林 ──
r06: {
narrate: '第六条录音。五月三日。时长:一分二十秒。文件名:小林的事。',
tap: {
label: '听',
narrate: '他开始讲一个叫小林的朋友。他说小林是他认识最久的人,从小一起长大的,后来各自去了不同的城市,联系越来越少。他说他最近一直在想,那种联系的稀薄,是谁的责任?还是说这就是长大的代价?录音结束。',
state: s => ({ heard: [...s.heard, '小林的事'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r07',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第六条。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第六条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r07',
},
},
// ── 第7条:小林的一句话 ──
r07: {
narrate: '第七条录音。五月二十日。时长:三十三秒。文件名:小林说的。',
tap: {
label: '听',
narrate: '他说小林打电话来了。小林在电话里说了一句话,让他很难受。小林说:感觉你现在不太一样了,变得……不知道,挺难接近的。他说他当时说了没什么,你想多了。他说他那时候应该说:我只是最近状态不好。但他没说。录音结束。',
state: s => ({ heard: [...s.heard, '小林说的话'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r08',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第七条。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第七条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r08',
},
},
// ── 第8条:感情结束了 ──
r08: {
narrate: '第八条录音。六月八日,凌晨。时长:两分十四秒。文件名:结束了。',
tap: {
label: '听',
narrate: '他说一段感情结束了,他没有细说是谁,说了有多久,只说:结束了,是我提的,我觉得这是对的,但今晚我睡不着。他说他在厨房坐着,泡了杯茶,但茶凉了也没喝。他说:我只是想把今晚的感觉记下来,不想让它消失。录音结束。',
state: s => ({ heard: [...s.heard, '感情结束了'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r09',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第八条。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第八条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r09',
},
},
// ── 第9条:是不是我的错 ──
r09: {
narrate: '第九条录音。六月二十日。时长:一分五十秒。文件名:在反思。',
tap: {
label: '听',
narrate: '他说他一直在想那段感情里他做错了什么。他列了很多条,每一条都很具体。然后他说:但是我说不清,这些错是因为我是这样的人,还是因为那时候我在经历别的事情,所以才这样。他说:这个问题很重要,但我没有答案。录音结束。',
state: s => ({ heard: [...s.heard, '反思'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r10',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第九条。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第九条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r10',
},
},
// ── 第10条(关键):有人问起你 ──
r10: {
narrate: '第十条录音。七月四日。时长:四十二秒。文件名:有人问起你。',
tap: {
label: '听',
narrate: '他说:今天有人跟我提到了你的名字。我以为我早就忘了,但我发现我还记得很多细节。他没有解释"你"是谁。他说:我觉得我应该把这件事录下来,因为我发现我不想忘记。录音结束。',
state: s => ({ heard: [...s.heard, '有人问起你'], skipped: s.skipped, heard_critical: s.heard_critical + 1, unlocked_last: s.unlocked_last }),
next: 'r11',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第十条。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第十条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r11',
},
},
// ── 第11条(关键):他查了你 ──
r11: {
narrate: '第十一条录音。七月十一日。时长:一分零八秒。文件名:我查了一下。',
tap: {
label: '听',
narrate: '他说:我查了你现在在哪里,在做什么。不是恶意,就是想知道。他说他找到了一些信息,知道了你现在的大致状态。他说:你好像过得还不错,我很高兴,也有一点……他停了,然后说:没什么。录音结束。',
state: s => ({ heard: [...s.heard, '他查了你'], skipped: s.skipped, heard_critical: s.heard_critical + 1, unlocked_last: s.unlocked_last }),
next: 'r12',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第十一条。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第十一条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r12',
},
},
// ── 第12条(关键):如果你听到 ──
r12: {
narrate: '第十二条录音。八月二日。时长:五十七秒。文件名:给不认识我的人(草稿)。',
tap: {
label: '听',
narrate: '他说:如果你听到这条,你肯定不认识我。我在想,把这些录音留给一个陌生人,有什么意义。然后他说:也许是因为我希望有人知道我活过,而不是认识我的人,而是真正陌生的人——你不需要对我有感情,你只是听了。录音结束。',
state: s => ({ heard: [...s.heard, '给陌生人的草稿'], skipped: s.skipped, heard_critical: s.heard_critical + 1, unlocked_last: s.unlocked_last }),
next: 'r13',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第十二条。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第十二条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r13',
},
},
// ── 第13条(关键):一件事 ──
r13: {
narrate: '第十三条录音。九月一日。时长:两分三十八秒。文件名:我有一件事想说。',
tap: {
label: '听',
narrate: '他深吸一口气,说:有一件事我从没对任何人说过。他停了很久,然后说:我有时候觉得活着的意义我不太找得到,不是说我要做什么,只是我不知道我在这里有什么用。他说:我知道这句话很沉,但我必须把它说出来,因为放在心里太久了。然后他说:我今天状态还可以,就是今天有这个力气把它说出来。录音结束。',
state: s => ({ heard: [...s.heard, '那件事'], skipped: s.skipped, heard_critical: s.heard_critical + 1, unlocked_last: s.unlocked_last }),
next: 'r14',
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第十三条。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第十三条'], heard_critical: s.heard_critical, unlocked_last: s.unlocked_last }),
next: 'r14',
},
},
// ── 第14条(关键):最后一条 ──
r14: {
narrate: '第十四条录音。十月十五日,录制时间是他去世前三天。时长:三分二十秒。文件名:最后一条,我想讲完整。',
tap: {
label: '听',
narrate: '他说:我知道这是最后一条了。我不是悲观,我只是知道。他说:我想对听这些录音的人说,我不后悔我活过的大多数时候。橙子是甜的,老电影是值得哭的,小林的声音我还记得,那个人,不管怎样,我不后悔爱过。他说:如果你听了很多条,谢谢你。你是陌生人,但你陪着我说完了。录音结束。',
state: s => ({
heard: [...s.heard, '最后一条'],
skipped: s.skipped,
heard_critical: s.heard_critical + 1,
unlocked_last: s.heard.length >= 9,
}),
next: s => s.heard.length >= 9 ? 'r15' : null,
},
doubleTap: {
label: '跳过',
narrate: '你跳过了第十四条,也就是最后一条。手机里没有更多录音了。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '第十四条'], heard_critical: s.heard_critical, unlocked_last: false }),
next: null,
},
},
// ── 第15条(隐藏):给不认识我的人 ──
r15: {
delay: 2000,
narrate: '还有一条录音,没有标日期。文件名:给不认识我的人(正式版)。这条需要你听了很多条才能解锁。你解锁了。',
tap: {
label: '听',
narrate: '他说:你好。你不认识我,我也不认识你。但你坐在那里,听完了我这些年说的话。我不知道你是谁,我不知道你们那边现在是白天还是夜晚。我只是想说:谢谢你愿意听一个陌生人说话。这很重要,比你知道的还要重要。录音结束。这是最后一条了。',
state: s => ({ heard: [...s.heard, '给不认识我的人'], skipped: s.skipped, heard_critical: s.heard_critical, unlocked_last: true }),
next: null,
},
doubleTap: {
label: '跳过',
narrate: '你连最后这条也跳过了。',
state: s => ({ heard: s.heard, skipped: [...s.skipped, '最后一条'], heard_critical: s.heard_critical, unlocked_last: false }),
next: null,
},
},
},
getEnding: s => {
if (s.unlocked_last) return 'known';
if (s.heard_critical === 0) return 'stranger';
if (s.skipped.length >= 7) return 'passing';
if (s.heard.length >= 9) return 'outline';
return 'brief';
},
endings: {
known: {
narrate: '《我认识你》。你听完了他大部分的录音,你听到了那条隐藏的最后一条。你不认识他,但你陪他把该说的都说完了。这已经足够了。',
},
stranger: {
narrate: '《陌生人》。你跳过了所有关于他和你之间联系的录音。他对你来说,还是一个完全的陌生人。也许这样也好。',
},
passing: {
narrate: '《路过》。你路过了他的大部分录音,只停留了几条。你知道他存在过,但你不知道他是谁。',
},
outline: {
narrate: '《轮廓》。你听了他生活的碎片,知道了一个大致的形状。你不知道他所有的事,但你知道了一个人活着的样子。',
},
brief: {
narrate: '《简短的相遇》。你听了几条,然后停下了。也许你听到了你需要听的,也许你还没准备好听更多。这也是一种结果。',
},
},
};
-262
View File
@@ -1,262 +0,0 @@
// 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();
}
}
+74
View File
@@ -0,0 +1,74 @@
// input.js — 输入检测
// 只保留两种操作:单击 / 双击
// 键盘:空格=单击,回车=双击(桌面调试)
class InputDetector {
constructor(el) {
this.el = el;
this.onTap = null;
this.onDoubleTap = null;
this.enabled = false;
this._last = 0;
this._timer = null;
this._WIN = 300; // ms双击判定窗口
this._bindTouch();
this._bindMouse();
this._bindKeyboard();
}
enable() { this.enabled = true; }
disable() { this.enabled = false; }
_fire(e) {
e.preventDefault();
if (!this.enabled) return;
const now = Date.now();
if (now - this._last < this._WIN) {
// 双击
clearTimeout(this._timer);
this._last = 0;
Haptic.confirm();
SFX_UI.skip();
dbg('[input] double-tap');
if (this.onDoubleTap) this.onDoubleTap();
} else {
this._last = now;
this._timer = setTimeout(() => {
this._last = 0;
Haptic.confirm();
SFX_UI.tap();
dbg('[input] tap');
if (this.onTap) this.onTap();
}, this._WIN);
}
}
_bindTouch() {
this.el.addEventListener('touchend', e => this._fire(e), { passive: false });
}
_bindMouse() {
this.el.addEventListener('click', e => this._fire(e));
}
_bindKeyboard() {
document.addEventListener('keydown', e => {
if (!this.enabled) return;
if (e.code === 'Space') {
e.preventDefault();
Haptic.confirm();
SFX_UI.tap();
dbg('[input] tap (kbd)');
if (this.onTap) this.onTap();
} else if (e.code === 'Enter') {
e.preventDefault();
Haptic.confirm();
SFX_UI.skip();
dbg('[input] double-tap (kbd)');
if (this.onDoubleTap) this.onDoubleTap();
}
});
}
}
+35 -42
View File
@@ -1,58 +1,51 @@
// main.js — 游戏入口
// main.js — 入口
let engine = null;
let gestures = null;
const DEV = location.search.includes('dev');
const TEST = location.search.includes('test');
async function main() {
TTS.init();
let _booted = false;
// 初始化音频(必须在用户手势后)
await Snd.init();
async function boot(e) {
if (_booted) return;
_booted = true;
e.preventDefault();
// 请求屏幕常亮
// 屏幕常亮
try {
if ('wakeLock' in navigator) {
window._wakeLock = await navigator.wakeLock.request('screen');
}
if ('wakeLock' in navigator) navigator.wakeLock.request('screen').catch(() => {});
} catch (_) {}
// 绑定手势
const gameEl = document.getElementById('game');
gestures = new GestureDetector(gameEl);
// 初始化子系统
await Narrator.init();
await Snd.init();
// 创建并启动引擎
engine = new GameEngine();
engine.init(gestures);
await engine.start();
// 创建输入检测器
const touchEl = document.getElementById('g');
const input = new InputDetector(touchEl);
// 创建引擎
const engine = new SceneEngine();
// 先启用输入
input.enable();
// 启动选择器
const selector = new GameSelector(engine, input);
await selector.start();
if (DEV) {
document.getElementById('dbg').style.display = 'block';
document.title = '林夏 [DEV]';
document.title = '盲游 [DEV]';
window.__engine = engine;
window.__input = input;
window.__selector = selector;
}
}
// 防止双重启动
let _started = false;
function startGame(el) {
if (_started) return;
_started = true;
el.style.transition = 'opacity 0.8s';
el.style.opacity = '0';
setTimeout(() => { el.style.display = 'none'; }, 800);
main();
}
const _el = document.getElementById('g');
_el.addEventListener('touchend', boot, { once: true, passive: false });
_el.addEventListener('click', boot, { once: true });
// 触摸启动(手机)
document.getElementById('start').addEventListener('touchend', function(e) {
e.preventDefault();
startGame(this);
}, { once: true });
// 鼠标点击(电脑调试)
document.getElementById('start').addEventListener('click', function() {
startGame(this);
}, { once: true });
// 防止页面滚动/缩放
document.addEventListener('touchmove', e => e.preventDefault(), { passive: false });
// 防止页面缩放 / 滚动
document.addEventListener('touchmove', e => e.preventDefault(), { passive: false });
document.addEventListener('gesturestart', e => e.preventDefault());
+109
View File
@@ -0,0 +1,109 @@
// narrator.js — 旁白系统
// 全程语音播报:场景描述、操作提示、状态反馈。
// 这是无障碍核心:玩家闭眼也能完全掌握游戏状态。
const Narrator = (() => {
let _voice = null;
let _busy = false;
let _queue = []; // [{ text, resolve }]
let _repTimer = null;
let _repFn = null;
// ── 初始化:找最佳中文语音 ──
function init() {
return new Promise(resolve => {
if (!window.speechSynthesis) { resolve(); return; }
const load = () => {
const vs = speechSynthesis.getVoices();
_voice = vs.find(v => v.lang === 'zh-CN')
|| vs.find(v => v.lang.startsWith('zh'))
|| vs[0] || null;
resolve();
};
if (speechSynthesis.getVoices().length > 0) load();
else speechSynthesis.addEventListener('voiceschanged', load, { once: true });
});
}
// ── 底层单句播放 ──
function _speak(text) {
return new Promise(resolve => {
if (!window.speechSynthesis) { resolve(); return; }
const u = new SpeechSynthesisUtterance(text);
u.lang = 'zh-CN';
u.rate = 0.88;
u.pitch = 1.0;
u.volume = 1.0;
if (_voice) u.voice = _voice;
u.onend = resolve;
u.onerror = resolve;
speechSynthesis.speak(u);
});
}
// ── 推进队列 ──
async function _next() {
if (_busy || _queue.length === 0) return;
_busy = true;
const item = _queue.shift();
dbg(`[N] ${item.text.slice(0, 40)}`);
await _speak(item.text);
_busy = false;
if (item.resolve) item.resolve();
_next();
}
// ── 排队说(fire-and-forget)──
function say(text, onDone) {
_queue.push({ text, resolve: onDone || null });
_next();
}
// ── 说完再继续(await 用)──
function sayAndWait(text) {
return new Promise(resolve => {
_queue.push({ text, resolve });
_next();
});
}
// ── 立刻打断当前语音,说新内容 ──
function interrupt(text, onDone) {
if (window.speechSynthesis) speechSynthesis.cancel();
_queue = [];
_busy = false;
say(text, onDone);
}
// ── 宣告操作选项 + 每12秒自动重复 ──
function options(tapLabel, dtLabel) {
clearTimeout(_repTimer);
let tip = '';
if (tapLabel && dtLabel) tip = `单击${tapLabel},双击${dtLabel}`;
else if (tapLabel) tip = `单击${tapLabel}`;
else if (dtLabel) tip = `双击${dtLabel}`;
_repFn = () => {
interrupt(`——还在等你。${tip}`);
_repTimer = setTimeout(_repFn, 14000);
};
say(tip);
_repTimer = setTimeout(_repFn, 14000);
}
// ── 清除重复定时器(选择后调用)──
function clearOptions() {
clearTimeout(_repTimer);
_repFn = null;
}
// ── 停止一切 ──
function stop() {
if (window.speechSynthesis) speechSynthesis.cancel();
_queue = [];
_busy = false;
clearOptions();
}
return { init, say, sayAndWait, interrupt, options, clearOptions, stop };
})();
-22
View File
@@ -1,22 +0,0 @@
// 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];
}
}
// 获取最终画像标签
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';
}
}
+84
View File
@@ -0,0 +1,84 @@
// selector.js — 游戏选择器
// 单击:切换到下一个游戏
// 双击:开始当前选中的游戏
const GAME_CATALOG = [
{
title: '林夏',
desc: '一部碎屏手机,一个北漂的夜晚。十六条消息,决定明天的自己。',
get: () => GAME_LINXIA,
},
{
title: '一念',
desc: '内心法庭。七个声音在等你,今晚必须做一个决定。',
get: () => GAME_YINIAN,
},
{
title: '守夜',
desc: '你是夜班保安,独自坐在监控室。楼里传来声音,去查,还是不动。',
get: () => GAME_SHOUYE,
},
{
title: '候诊',
desc: '等待检查结果的两个小时。等待本身就是一种体验。',
get: () => GAME_HOUZHEN,
},
{
title: '遗声',
desc: '一个陌生人的手机,二十二条录音。听,还是跳过——跳过就永远不知道了。',
get: () => GAME_YISHENG,
},
{
title: '浮',
desc: '感官漂流。声音漂过来,抓住,还是让它走。结束时你会听到你带走了什么。',
get: () => GAME_FU,
},
];
class GameSelector {
constructor(engine, input) {
this._engine = engine;
this._input = input;
this._idx = 0;
this._active = false;
}
async start() {
this._active = true;
this._idx = 0;
this._input.onTap = () => this._next();
this._input.onDoubleTap = () => this._pick();
await Narrator.sayAndWait(
'欢迎来到盲游。共六个游戏。单击听下一个介绍,双击开始当前游戏。'
);
this._announce();
}
_announce() {
const g = GAME_CATALOG[this._idx];
const n = this._idx + 1;
Narrator.interrupt(
`${n}个:《${g.title}》。${g.desc}单击换一个,双击开始。`
);
}
_next() {
if (!this._active) return;
this._idx = (this._idx + 1) % GAME_CATALOG.length;
this._announce();
}
async _pick() {
if (!this._active) return;
this._active = false;
const g = GAME_CATALOG[this._idx];
Narrator.interrupt(`开始《${g.title}》。`);
await new Promise(r => setTimeout(r, 1500));
this._engine.run(g.get(), this._input, () => {
// 游戏结束后回到选择器
setTimeout(() => this.start(), 2000);
});
}
}
-674
View File
@@ -1,674 +0,0 @@
// story.js — 消息数据 v2VoxCPM2 语音 + 多轮对话)
//
// 选择方向的情感含义(全局一致):
// 单击(tap) → 简单回应
// 双击 → 不回复 / 跳过
// 右滑(right) → 温暖 / 接受 / 开放
// 左滑(left) → 冷淡 / 拒绝 / 疏远
// 上滑(up) → 好奇 / 主动 / 追问
// 下滑(down) → 沉默 / 退缩
//
// 每个 choice 结构:
// linxia: 林夏回应的音频路径(null/缺省 = 沉默)
// npcReact: NPC反应音频数组(缺省 = 无反应)
// isSilence: 沉默选项标记
// state: 剧情状态变更
// profile: 画像分数变更
// followUp: 可选的下一轮对话 { choices: { ... } }
const A = '../audio/mp3';
const T = `${A}/tts`; // VoxCPM2 生成的所有语音
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`;
+1
View File
@@ -1,6 +1,7 @@
// utils.js — 工具函数
const DEV = new URLSearchParams(location.search).has('dev');
const TEST = new URLSearchParams(location.search).has('test');
const sleep = ms => new Promise(r => setTimeout(r, ms));
+175 -135
View File
@@ -1,189 +1,229 @@
# 《林夏》操作说明
# 盲游 · 项目说明
> 全黑屏纯音频交互游戏合集。没有视觉界面,只有声音和触感。
> 玩家闭眼也能完整体验——旁白全程解释状态,操作只有六种手势。
---
## 一、游戏简介
## 一、项目简介
全黑屏叙事游戏。玩家扮演 24 岁的林夏,用一部碎屏手机度过今晚 21:00–02:00 的 5 个小时。
本项目是一个**盲人可玩、明眼人闭眼也好玩**的纯音频叙事游戏合集,共收录 **6 个独立游戏**
- **没有视觉界面**,只有声音和触感
- **全程约 60 分钟**(5 小时游戏时间压缩)
- **16 条消息**5 种结局
- **不需要说话**,不需要麦克风
| # | 游戏 | 一句话 | 旁白定位 |
|---|------|--------|---------|
| 1 | 《林夏》 | 一部碎屏手机,一个北漂的夜晚,16 条消息决定明天的自己 | 纪录片叙述者 |
| 2 | 《一念》 | 内心法庭,七个声音在等你,今晚必须做一个决定 | 你的内心独白 |
| 3 | 《守夜》 | 你是夜班保安,独自坐在监控室,楼里传来声音,查还是不查 | 老同事的对讲机留言 |
| 4 | 《候诊》 | 等待检查结果的两小时,等待本身就是一种体验 | 时间本身的声音 |
| 5 | 《遗声》 | 一个陌生人的手机,十五条录音,听还是跳过——跳过就永远不知道 | 手机系统语音 |
| 6 | 《浮》 | 感官漂流,声音漂过来,抓住还是让它走 | 梦里的声音 |
每个游戏有独立的旁白音色、独立的场景脚本和多种结局。游戏之间互不影响,可任意选择体验。
---
## 二、开始前准备
## 二、操作方式(六种手势)
1. 手机和电脑连接**同一 Wi-Fi**
2. 打开手机浏览器,输入地址:
全程**不看屏幕**,只靠六种手势操作。每种手势有固定的语义,贯穿所有游戏:
```
http://<服务器IP>:8765/game/
```
| 手势 | 通用含义 | 触觉反馈 | 确认音效 |
|------|---------|---------|---------|
| **单击** | 接受 / 打开 / 简单回应 | 轻触 | 清脆短音 |
| **双击** | 拒绝 / 跳过 / 忽略 | 短促双震 | 下行滑音 |
| **右滑** | 温暖 / 接受 / 靠近 | 流畅长震 | 上行音 |
| **左滑** | 冷淡 / 拒绝 / 推开 | 干脆短震 | 下行音 |
| **上滑** | 好奇 / 追问 / 主动 | 快速上弹 | 快速上行 |
| **下滑** | 沉默 / 退缩 / 不回应 | 极轻慢震 | 极轻低音 |
3. **关灯,戴上耳机**
4. 将手机音量调到适中(建议 60–80%)
5. 轻触屏幕任意位置开始游戏
### 操作规则
> **iOS 用户注意**:Safari 不支持震动反馈,操作正常但无震感。建议使用 Android Chrome
- **每个场景只启用六种手势中的一部分**。旁白会播报当前可用的操作及其含义,玩家无需记忆
- 若玩家做出了当前场景不支持的手势,会有"无效操作"音效提示,不会产生误操作。
- 没有时间限制,消息不会自动推进,完全由玩家控制节奏。
- 旁白会在空闲一段时间后自动重复播报当前可用的操作选项,避免玩家迷失。
### 游戏选择界面
进入后旁白播报:"欢迎来到盲游,共六个游戏。单击听下一个介绍,双击开始当前游戏。"单击切换游戏,双击开始。
### 桌面调试键位
| 键 | 手势 |
|----|------|
| 空格 | 单击 |
| 回车 | 双击 |
| ↑ | 上滑 |
| ↓ | 下滑 |
| ← | 左滑 |
| → | 右滑 |
> iOS Safari 不支持震动反馈,操作正常但无震感,建议使用 Android Chrome。
---
## 三、操作总览
## 三、旁白系统
### 来电时
**旁白是无障碍的核心**。每个游戏都有一位独立的旁白声音,全程为玩家描述:
| 操作 | 效果 |
|------|------|
| **单击 / 右滑** | 接听 |
| **左滑 / 双击** | 拒接 |
| **不动** | 铃声持续(不会自动挂断) |
- 场景发生了什么("晚上九点零三分。手机震动,铃声响起。屏幕显示:妈妈。")
- 当前可用的操作选项("单击接听,双击拒接。")
- 操作后的结果("你接了。妈妈的声音从电话里传来……")
- 结局陈述
### 收到消息时
### 旁白的声音各不相同
| 操作 | 效果 |
|------|------|
| **单击** | 播放消息内容 |
| **双击 / 左滑** | 跳过这条消息 |
六个游戏的旁白音色、年龄感、情感基调均不同,详见 `docs/旁白音色提示词.md`。例如《林夏》是克制的纪录片女声,《守夜》是疲惫的中年男声,《浮》是飘渺的气声。
### 播放中
### 旁白的实现方式
| 操作 | 效果 |
|------|------|
| **双击** | 跳过当前播放 |
| **长按** | 重播当前消息 |
### 选择回复(听完消息后)
系统会播放提示音,进入回复选择状态:
| 操作 | 情感方向 |
|------|----------|
| **单击** | 简单回应 |
| **右滑** | 温暖 / 接受 |
| **左滑** | 冷淡 / 拒绝 |
| **上滑** | 好奇 / 追问 |
| **下滑** | 沉默 / 退缩 |
| **双击** | 不回复(跳过) |
> 部分消息的回复会触发**多轮对话**——NPC 回应后,再次进入选择状态。
### 随时可用
| 操作 | 效果 |
|------|------|
| **两指长按** | 暂停游戏 |
| **单击**(暂停中) | 继续游戏 |
| **三指点击**(暂停中) | 退出游戏 |
| **长按**(空闲/通知时) | 语音播报未读消息数 |
旁白台词**优先使用预录的 TTS 配音**(由模型生成 MP3,见第四节);若预录文件缺失,则回退到浏览器内置 TTS 朗读同一段文本作为兜底。这样即使部分旁白尚未生成配音,游戏也可完整运行。
---
## 四、消息类型
## 四、音频制作(全部由模型生成)
游戏中有四种消息形态:
> **核心原则**:所有音乐、音效、配音均由 AI 模型本地生成,不使用任何真人录音或网络素材。
### 来电(call
手机长震 + 角色专属铃声。单击/右滑接听,左滑/双击拒接。
### 4.1 音频分类与生成方式
### 微信语音(wechat_voice
两短震 + 系统提示音。单击播放语音内容。
| 类别 | 内容 | 生成模型 | 数量 |
|------|------|---------|------|
| **角色配音** | 各游戏角色的台词语音 | TTS(VoxCPM2 | 林夏游戏 169 条 |
| **旁白配音** | 六个游戏的旁白台词 | TTSVoxCPM2 | 约 178 条 |
| **BGM** | 背景音乐 | ACE-Step 1.5 | 4 段 |
| **铃声** | 来电铃声 | ACE-Step 1.5 | 4 条 |
| **环境音** | 场景底噪 | ACE-Step 1.5 | 3 段 |
| **叙事音效** | 呼吸、心跳、敲门等 | ACE-Step 1.5 | 4 条 |
| **UI 音效** | 操作反馈音 | Web Audio 浏览器合成 | 13 种(无文件) |
### 微信文字(wechat_text
两短震 + 系统提示音。单击后由系统朗读文字内容。
### 4.2 TTS 配音流程(关键
### 系统通知(system_notification
特殊消息,播放系统提示后自动播放内容。
---
## 五、行为画像
游戏全程追踪三个维度(玩家不可见):
| 维度 | 含义 | 触发行为 |
|------|------|----------|
| **avoidance**(回避) | 林夏今晚想一个人待着 | 跳过消息、不回复、沉默 |
| **engagement**(投入) | 林夏在认真面对每一个人 | 温暖回应、好奇追问 |
| **nostalgia**(怀旧) | 林夏在这一夜反复回望 | 与老同学深聊、分享自己 |
画像影响**时光胶囊**(消息15)播放哪个版本。
---
## 六、结局说明(不含剧透)
共 **5 种结局**,由以下变量决定:
| 变量 | 触发条件 |
|------|----------|
| 与妈妈的连接 | 是否接了妈妈的电话 / 回了消息 |
| 阿哲是否回来 | 23:15 那通电话的选择 |
| 与周南的深度 | 回应周南的次数与方式 |
| 是否听自己 | 00:30 时光胶囊是否播放 |
| 行为画像 | 整体回应风格 |
另有一个**隐藏结局 E**,需要与某位角色深度互动才能触发。
---
## 七、开发者 / 测试模式
在 URL 末尾加 `?dev` 可开启开发模式:
**所有 TTS 配音都遵循"先定音色,再生成配音"的两步流程**
```
http://<服务器IP>:8765/game/?dev
第一步:用提示词生成音色
├─ 根据角色/旁白定位,撰写音色描述提示词(见 docs/旁白音色提示词.md)
├─ 用 TTS 模型的 Voice Design 能力,按提示词生成多个音色样本
└─ 人工试听筛选,选定一个参考音频作为该角色的"音色锚点"
第二步:用 TTS 生成配音
├─ 将选定音色注册为参考(voice cloning
├─ 输入台词文本 + 表演修饰(语速、情感方向)
├─ 模型克隆该音色,逐句合成配音
└─ 输出 MP3,按命名规范部署到 audio/mp3/
```
- 消息之间的等待间隔从 2 秒缩短为 0.2 秒
- 屏幕底部显示调试信息(当前状态)
- 适合快速测试分支和结局
- **角色音色提示词**:见 `林夏.md`(归档于 `other_docs/`)§7.3 角色音色配置表。
- **旁白音色提示词**:见 `docs/旁白音色提示词.md`,为六个游戏的旁白分别定义了声音特征、情感基调、中英文 TTS 提示词及朗读标注。
- **旁白台词脚本**:见 `docs/音频脚本.md`,定义了全部旁白台词、文件命名规范与生成优先级。
### 4.3 音频文件命名规范
```
audio/mp3/narrator/{game_id}/{game_id}_{scene_id}_{type}.mp3
```
- `{game_id}`linxia / yinian / shouye / houzhen / yisheng / fu
- `{scene_id}`:如 msg01、s02、v03、h04、r10、f05
- `{type}`intro(开场)/ narrate(描述)/ tap(单击旁白)/ dt(双击旁白)/ ending_{id}(结局)等
### 4.4 技术规格
| 项目 | 规格 |
|------|------|
| 格式 | MP3128kbps |
| 采样率 | 44100 Hz |
| 声道 | 单声道 |
| 响度 | -18 LUFS |
| 首尾静音 | 开头 100ms,结尾 200ms |
---
## 八、启动服务器
## 五、项目结构
关机重启后需要重新启动 HTTP 服务器:
```
/home/xsl/blind/
├── game/ # 游戏前端
│ ├── index.html # 入口页面(黑屏)
│ ├── manifest.json # PWA 配置
│ ├── 操作说明.md # 本文件
│ └── js/
│ ├── utils.js # 工具函数 + 浏览器 TTS
│ ├── haptics.js # 震动反馈
│ ├── audio.js # 音频管理器 + 合成 UI 音效
│ ├── narrator.js # 旁白系统(TTS 播报 + 队列)
│ ├── input.js # 手势输入检测
│ ├── engine.js # 场景引擎(状态机)
│ ├── selector.js # 游戏选择器
│ ├── main.js # 入口
│ └── games/ # 六个游戏数据
│ ├── linxia.js # 《林夏》
│ ├── yinian.js # 《一念》
│ ├── shouye.js # 《守夜》
│ ├── houzhen.js # 《候诊》
│ ├── yisheng.js # 《遗声》
│ └── fu.js # 《浮》
├── audio/ # 音频资源 + 生成脚本
│ ├── mp3/ # 游戏运行时音频(184 文件)
│ │ ├── tts/ # 角色配音(169 条)
│ │ ├── music/ # BGM + 铃声(8 条)
│ │ ├── sfx/ # 叙事音效(4 条)
│ │ └── ambience/ # 环境音(3 条)
│ ├── narrator_samples/ # 旁白音色样本(WAV,注册音色用)
│ ├── 00_raw/ # 原始素材
│ ├── batch_tts_voxcpm.py # TTS 批量生成脚本
│ ├── gen_music_acestep.py # BGM 生成脚本
│ ├── gen_sfx_acestep.py # 音效生成脚本
│ ├── gen_narrator_samples.py # 旁白音色样本生成
│ ├── batch_narrator_tts.py # 旁白配音批量生成
│ └── ... # 其他音频处理脚本
├── voice_mp3/narrator/ # 旁白预录 MP3(按游戏分目录)
├── docs/ # 设计文档
│ ├── 旁白音色提示词.md # 六游戏旁白音色定义 + TTS 提示词
│ └── 音频脚本.md # 六游戏旁白台词脚本 + 命名规范
└── other_docs/ # 历史参考文档(归档)
├── 林夏_原始设计.md # 《林夏》原始游戏设计文档
├── 执行文档_音频素材制作_归档.md # 旧版音频制作执行文档
├── gdd/ # 其他游戏的概念设计草稿
└── case_doc/ # 参考案例
```
---
## 六、启动与运行
### 启动 HTTP 服务器
```bash
cd /home/xsl/blind
python3 -m http.server 8765 --bind 0.0.0.0 &
```
查看当前 IP
### 手机访问
```bash
hostname -I | awk '{print $1}'
```
1. 手机和电脑连接**同一 Wi-Fi**
2. 查看电脑 IP`hostname -I | awk '{print $1}'`
3. 手机浏览器输入:`http://<服务器IP>:8765/game/`
4. **关灯,戴上耳机**,音量调到适中(建议 6080%
5. 轻触屏幕任意位置开始
---
## 九、文件结构
## 七、开发 / 测试模式
在 URL 末尾加参数可开启调试模式:
```
/home/xsl/blind/
├── game/
│ ├── index.html 主页面(黑屏)
│ ├── manifest.json PWA 配置
│ ├── 操作说明.md 本文件
│ └── js/
│ ├── utils.js 工具函数 + 浏览器 TTS
│ ├── haptics.js 震动反馈
│ ├── audio.js 音频管理器 + 合成 UI 音效
│ ├── gestures.js 单指手势检测
│ ├── profile.js 行为画像评分
│ ├── story.js 16条消息数据
│ ├── engine.js 游戏状态机
│ └── main.js 入口
└── audio/
└── mp3/
├── tts/ 角色语音(169条)
├── music/ BGM + 铃声(8条)
├── sfx/ 音效(4条)
└── ambience/ 环境底噪(3条)
http://<服务器IP>:8765/game/?dev
```
- `?dev`:屏幕底部显示调试信息(当前场景状态),缩短消息间隔
- `?test`:跳过音频播放等待(快速自动化测试用)
---
*"屏幕坏了,但她的一夜还没结束。"*