save code

This commit is contained in:
xsl
2026-05-24 12:35:35 +08:00
parent af3a23520d
commit b3384499e3
6 changed files with 121 additions and 167 deletions
+24 -7
View File
@@ -52,10 +52,10 @@ class GameEngine {
if (!el) return; if (!el) return;
const H = { const H = {
idle: '', idle: '',
notification: '单击 播放 · 双击 跳过', notification: '单击 播放 · 双击/左滑 跳过',
ringing: '单击 接听 · 左滑 拒接', ringing: '单击/右滑 接听 · 左滑/双击 拒接',
playing: '双击 跳过 · 长按 重播', playing: '双击 跳过 · 长按 重播',
choosing: '→温暖 ←冷淡 ↑追问 ↓沉默', choosing: '单击回应 · →温暖 ←冷淡 ↑追问 ↓沉默 · 双击跳过',
responding: '双击 跳过', responding: '双击 跳过',
reacting: '双击 跳过', reacting: '双击 跳过',
paused: '单击 继续 · 三指点击 退出', paused: '单击 继续 · 三指点击 退出',
@@ -286,9 +286,13 @@ class GameEngine {
this._skipRequested = false; this._skipRequested = false;
// 文字消息(TTS 朗读) // 文字消息(TTS 朗读,不可用时播放提示音
if (msg.type === 'wechat_text' && msg.text) { if (msg.type === 'wechat_text' && msg.text) {
await TTS.speak(msg.text); const ok = await TTS.speak(msg.text);
if (!ok) {
SFX_UI.notify();
await sleep(600);
}
} }
// 语音消息 // 语音消息
else if (msg.audio?.length > 0) { else if (msg.audio?.length > 0) {
@@ -546,6 +550,9 @@ class GameEngine {
this._playMessage(this.currentMsg); this._playMessage(this.currentMsg);
} else if (s === 'choosing') { } else if (s === 'choosing') {
this._handleChoice('tap'); this._handleChoice('tap');
} else if (s === 'idle') {
SFX_UI.error();
Haptic.error();
} }
} }
@@ -598,6 +605,11 @@ class GameEngine {
this._skipChoice(); this._skipChoice();
return; return;
} }
if (s === 'idle') {
SFX_UI.error();
Haptic.error();
}
} }
_onSwipe(dir) { _onSwipe(dir) {
@@ -626,6 +638,11 @@ class GameEngine {
this._handleChoice(dir); this._handleChoice(dir);
return; return;
} }
if (s === 'idle') {
SFX_UI.error();
Haptic.error();
}
} }
// ── 长按:重播 / 状态广播 ── // ── 长按:重播 / 状态广播 ──
@@ -642,13 +659,13 @@ class GameEngine {
if (this.currentMsg.audio?.length > 0) { if (this.currentMsg.audio?.length > 0) {
this._playVoiceAsync(this.currentMsg.audio); this._playVoiceAsync(this.currentMsg.audio);
} else if (this.currentMsg.type === 'wechat_text' && this.currentMsg.text) { } else if (this.currentMsg.type === 'wechat_text' && this.currentMsg.text) {
TTS.speak(this.currentMsg.text); TTS.speak(this.currentMsg.text).then(ok => { if (!ok) SFX_UI.notify(); });
} }
return; return;
} }
// idle/notification → 广播未读数 // idle/notification → 广播未读数
TTS.speak(`当前未读${this.vars.UNREAD}条消息`); TTS.speak(`当前未读${this.vars.UNREAD}条消息`).then(ok => { if (!ok) SFX_UI.error(); });
} }
// ── 暂停 ── // ── 暂停 ──
-3
View File
@@ -19,9 +19,6 @@ const Haptic = {
// 拒绝 / 挂断 // 拒绝 / 挂断
reject() { this._v([100, 50, 100, 50, 100]); }, reject() { this._v([100, 50, 100, 50, 100]); },
// 进入细回复模式
detailMode() { this._v([30, 30, 60]); },
// 选项选中 // 选项选中
select() { this._v(80); }, select() { this._v(80); },
+15 -12
View File
@@ -31,23 +31,26 @@ async function main() {
} }
} }
// 防止双重启动
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();
}
// 触摸启动(手机) // 触摸启动(手机)
document.getElementById('start').addEventListener('touchend', async function onStart(e) { document.getElementById('start').addEventListener('touchend', function(e) {
e.preventDefault(); e.preventDefault();
this.removeEventListener('touchend', onStart); startGame(this);
this.style.transition = 'opacity 0.8s';
this.style.opacity = '0';
setTimeout(() => { this.style.display = 'none'; }, 800);
await main();
}, { once: true }); }, { once: true });
// 鼠标点击(电脑调试) // 鼠标点击(电脑调试)
document.getElementById('start').addEventListener('click', async function onClick() { document.getElementById('start').addEventListener('click', function() {
this.removeEventListener('click', onClick); startGame(this);
this.style.transition = 'opacity 0.8s';
this.style.opacity = '0';
setTimeout(() => { this.style.display = 'none'; }, 800);
await main();
}, { once: true }); }, { once: true });
// 防止页面滚动/缩放 // 防止页面滚动/缩放
+2 -19
View File
@@ -12,28 +12,11 @@ class ProfileTracker {
} }
} }
// 漏接电话
onMissedCall() { this.add({ avoidance: 2 }); }
// 单点回"嗯"
onSimpleReply() { this.add({ avoidance: 1 }); }
// 双指上滑已读不回
onReadNoReply() { this.add({ avoidance: 2 }); }
// 进入细回复
onDetailReply() { this.add({ engagement: 2 }); }
// 细回复选"主动追问"engagement 方向)
onEngage() { this.add({ engagement: 2 }); }
// 细回复选"分享自己"
onShare() { this.add({ engagement: 1, nostalgia: 1 }); }
// 反复重听同一条
onReplay() { this.add({ nostalgia: 2 }); }
// 听完长语音未回应
onListenNoReact(){ this.add({ avoidance: 1, nostalgia: 1 }); }
// 获取最终画像标签 // 获取最终画像标签
get label() { get label() {
const s = this.scores; const s = this.scores;
if (s.engagement >= s.avoidance && s.engagement >= s.nostalgia) return 'engagement'; if (s.engagement > s.avoidance && s.engagement >= s.nostalgia) return 'engagement';
if (s.nostalgia >= s.avoidance) return 'nostalgia'; if (s.nostalgia > s.avoidance) return 'nostalgia';
return 'avoidance'; return 'avoidance';
} }
} }
+4 -2
View File
@@ -22,15 +22,17 @@ const TTS = {
return this._voices.find(v => v.lang.startsWith('zh') || v.name.includes('Chinese')) return this._voices.find(v => v.lang.startsWith('zh') || v.name.includes('Chinese'))
|| this._voices[0] || null; || this._voices[0] || null;
}, },
get available() { return !!window.speechSynthesis; },
speak(text, { rate = 0.85, volume = 0.8 } = {}) { speak(text, { rate = 0.85, volume = 0.8 } = {}) {
return new Promise(resolve => { return new Promise(resolve => {
if (!window.speechSynthesis) { resolve(); return; } if (!window.speechSynthesis) { resolve(false); return; }
speechSynthesis.cancel(); speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text); const u = new SpeechSynthesisUtterance(text);
u.lang = 'zh-CN'; u.rate = rate; u.volume = volume; u.lang = 'zh-CN'; u.rate = rate; u.volume = volume;
const v = this._pick(); const v = this._pick();
if (v) u.voice = v; if (v) u.voice = v;
u.onend = resolve; u.onerror = resolve; u.onend = () => resolve(true);
u.onerror = () => resolve(false);
speechSynthesis.speak(u); speechSynthesis.speak(u);
}); });
}, },
+76 -124
View File
@@ -19,7 +19,7 @@
2. 打开手机浏览器,输入地址: 2. 打开手机浏览器,输入地址:
``` ```
http://172.28.107.97:8765/game/ http://<服务器IP>:8765/game/
``` ```
3. **关灯,戴上耳机** 3. **关灯,戴上耳机**
@@ -30,167 +30,119 @@
--- ---
## 三、消息类型与接收方式 ## 三、操作总览
游戏中有三种消息形态: ### 来电时
### 来电
- 触发:手机长震 + 角色专属铃声
- 响 **2030 秒**后自动漏接
| 操作 | 效果 | | 操作 | 效果 |
|------|------| |------|------|
| **单** | 接听 | | **单击 / 右滑** | 接听 |
| **左滑** | 拒接 | | **左滑 / 双击** | 拒接 |
| **不动** | 漏接(对方挂断) | | **不动** | 铃声持续(不会自动挂断) |
### 微信语音 / 微信文字 ### 收到消息时
- 触发:两短震 + 系统提示音(TTS 报出发件人)
| 操作 | 效果 | | 操作 | 效果 |
|------|------| |------|------|
| **单** | 播放语音 / 念出文字 | | **单** | 播放消息内容 |
| **左滑** | 忽略(保持未读) | | **双击 / 左滑** | 跳过这条消息 |
| **不动** | 保持未读,晚些再听 |
--- ### 播放中
## 四、回复消息 | 操作 | 效果 |
|------|------|
| **双击** | 跳过当前播放 |
| **长按** | 重播当前消息 |
**听完语音 / 文字念完后**,进入等待回复状态: ### 选择回复(听完消息后)
| 操作 | 发送内容 | 系统会播放提示音,进入回复选择状态:
| 操作 | 情感方向 |
|------|----------| |------|----------|
| **单** | 发"嗯。"(最简单的回执) | | **单** | 简单回应 |
| **双点** | 发标准回复(针对该消息的一句话) | | **右滑** | 温暖 / 接受 |
| **长按 1 秒** | 进入**细回复**模式 | | **左滑** | 冷淡 / 拒绝 |
| **滑** | 已读不回 | | **滑** | 好奇 / 追问 |
| **不动** | 维持"已读未回"状态 | | **下滑** | 沉默 / 退缩 |
| **双击** | 不回复(跳过) |
> 部分消息的回复会触发**多轮对话**——NPC 回应后,再次进入选择状态。
### 随时可用
| 操作 | 效果 |
|------|------|
| **两指长按** | 暂停游戏 |
| **单击**(暂停中) | 继续游戏 |
| **三指点击**(暂停中) | 退出游戏 |
| **长按**(空闲/通知时) | 语音播报未读消息数 |
--- ---
## 五、细回复(核心机制) ## 四、消息类型
长按 1 秒后,震动确认进入细回复。系统会依次念出 **4 个候选回复** 游戏中有四种消息形态
``` ### 来电(call
一…… "好,明天寄。" 手机长震 + 角色专属铃声。单击/右滑接听,左滑/双击拒接。
二…… "你怎么不自己来拿。"
三…… "你那边……都好吗。"
四…… (什么都不说)
```
**在听到目标选项时,朝对应方向滑动:** ### 微信语音(wechat_voice
两短震 + 系统提示音。单击播放语音内容。
| 滑动方向 | 对应选项 | ### 微信文字(wechat_text
|----------|----------| 两短震 + 系统提示音。单击后由系统朗读文字内容。
| **左滑** | 一 |
| **右滑** | 二 |
| **上滑** | 三 |
| **下滑** | 四 |
> **没听清?** 单点屏幕可重听所有选项,或长按 1 秒重新播放。 ### 系统通知(system_notification
> 选错了也没关系——每条消息只影响行为画像,不会"game over" 特殊消息,播放系统提示后自动播放内容
--- ---
## 六、查看当前状态 ## 五、行为画像
**随时下滑**,系统会用语音告诉你当前在哪个页面,以及可以做什么操作。
例如:
- 主屏幕时下滑 → "主屏幕,3条未读。上滑查看收件箱。"
- 来电时下滑 → "妈妈来电中。单点接听,左滑拒接。"
- 听完消息后下滑 → "阿哲的消息已播完。单点回嗯,双点标准回复,长按细回复,左滑已读不回。"
> 不确定自己在哪?下滑就对了。
---
## 七、查看未读列表(收件箱)
在主屏幕**上滑**,进入收件箱模式。
系统会依次念出所有未读消息:
```
"收件箱:4条未读。妈妈,来电。阿哲,语音。周南,语音。……"
```
- **单点** → 跳转至列表第一条
- **左滑** → 返回主屏幕
- **等待 8 秒** → 自动关闭收件箱,返回游戏
---
## 八、游戏内时间
游戏将现实的 21:0002:00(5 小时)压缩为 **60 分钟**真实时间。
| 游戏时间 | 真实等待 |
|----------|----------|
| 21:03 | 约 36 秒 |
| 21:30 | 约 6 分钟 |
| 22:30 | 约 18 分钟 |
| 23:45 | 约 33 分钟 |
| 02:00 | 约 60 分钟 |
消息到来时间固定,**漏接 / 不听是合法选择**,但漏掉的内容不会重发。
---
## 九、结局说明(不含剧透)
共 **5 种结局**,由以下 5 个变量决定:
| 变量 | 触发条件 |
|------|----------|
| 与妈妈的连接 | 是否接了妈妈的电话 / 回了文字 |
| 阿哲是否回来 | 23:15 那通电话的选择 |
| 群里是否发言 | 寝室群消息的回应 |
| 与周南的深度 | 回应周南的次数与方式 |
| 是否听自己 | 00:30 时光胶囊是否播放 |
另有一个**隐藏结局 E**,需要与某位角色深度互动才能触发。
---
## 十、行为画像
游戏全程追踪三个维度(玩家不可见): 游戏全程追踪三个维度(玩家不可见):
| 维度 | 含义 | 触发行为 | | 维度 | 含义 | 触发行为 |
|------|------|----------| |------|------|----------|
| **avoidance**(回避) | 林夏今晚想一个人待着 | 漏接、不回、已读不回 | | **avoidance**(回避) | 林夏今晚想一个人待着 | 跳过消息、不回复、沉默 |
| **engagement**(投入) | 林夏在认真面对每一个人 | 细回复、主动追问 | | **engagement**(投入) | 林夏在认真面对每一个人 | 温暖回应、好奇追问 |
| **nostalgia**(怀旧) | 林夏在这一夜反复回望 | 重听同一条消息、分享自己 | | **nostalgia**(怀旧) | 林夏在这一夜反复回望 | 与老同学深聊、分享自己 |
画像决定 **00:30 时光胶囊**播放哪个版本(明朗 / 迷茫 / 温柔) 画像影响**时光胶囊**(消息15)播放哪个版本
--- ---
## 十一、彩蛋 ## 六、结局说明(不含剧透)
- **夜间启动**(现实时间 22:00–04:00 之间打开游戏):开场多一句话 **5 种结局**,由以下变量决定:
- **消息09**:小美醉语音结束后会有一个撤回事件
- **消息04**:如果一直不回阿哲,会听到一个小细节 | 变量 | 触发条件 |
|------|----------|
| 与妈妈的连接 | 是否接了妈妈的电话 / 回了消息 |
| 阿哲是否回来 | 23:15 那通电话的选择 |
| 与周南的深度 | 回应周南的次数与方式 |
| 是否听自己 | 00:30 时光胶囊是否播放 |
| 行为画像 | 整体回应风格 |
另有一个**隐藏结局 E**,需要与某位角色深度互动才能触发。
--- ---
## 十二、开发者 / 测试模式 ## 、开发者 / 测试模式
在 URL 末尾加 `?dev` 可开启**10倍速**模式: 在 URL 末尾加 `?dev` 可开启开发模式:
``` ```
http://172.28.107.97:8765/game/?dev http://<服务器IP>:8765/game/?dev
``` ```
- 所有消息的触发时间缩短为原来的 1/10 - 消息之间的等待间隔从 2 秒缩短为 0.2 秒
- 全程约 **6 分钟**可打通 - 屏幕底部显示调试信息(当前状态)
- 适合测试结局分支 - 适合快速测试分支和结局
--- ---
## 十三、启动服务器 ## 、启动服务器
关机重启后需要重新启动 HTTP 服务器: 关机重启后需要重新启动 HTTP 服务器:
@@ -207,7 +159,7 @@ hostname -I | awk '{print $1}'
--- ---
## 十四、文件结构 ## 、文件结构
``` ```
/home/xsl/blind/ /home/xsl/blind/
@@ -218,18 +170,18 @@ hostname -I | awk '{print $1}'
│ └── js/ │ └── js/
│ ├── utils.js 工具函数 + 浏览器 TTS │ ├── utils.js 工具函数 + 浏览器 TTS
│ ├── haptics.js 震动反馈 │ ├── haptics.js 震动反馈
│ ├── audio.js 音频管理器 │ ├── audio.js 音频管理器 + 合成 UI 音效
│ ├── gestures.js 单指手势检测 │ ├── gestures.js 单指手势检测
│ ├── profile.js 行为画像评分 │ ├── profile.js 行为画像评分
│ ├── story.js 16条消息数据 │ ├── story.js 16条消息数据
│ ├── engine.js 游戏状态机 │ ├── engine.js 游戏状态机
│ └── main.js 入口 │ └── main.js 入口
└── audio/ └── audio/
└── 00_raw/ └── mp3/
├── tts/ 角色语音(68条) ├── tts/ 角色语音(169条)
├── music/ BGM + 铃声 ├── music/ BGM + 铃声8条)
├── sfx/ 音效 ├── sfx/ 音效4条)
└── ambience/ 环境底噪 └── ambience/ 环境底噪3条)
``` ```
--- ---