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

文档整理:
- 重写 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
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@ set -euo pipefail
HOST="${VOXCPM_HOST:-127.0.0.1}"
PORT="${VOXCPM_PORT:-8000}"
HEALTH_URL="http://${HOST}:${PORT}/health"
START_SCRIPT="${VOXCPM_START_SCRIPT:-/home/xsl/AutoVideo/start-voxcpm.sh}"
START_SCRIPT="${VOXCPM_START_SCRIPT:-/home/xsl/tts-server/start-voxcpm.sh}"
MODEL_DIR="${VOXCPM_MODEL_DIR:-/home/xsl/models/VoxCPM2}"
VENV_PYTHON="${VOXCPM_PYTHON:-/home/xsl/tts-server/.venv/bin/python}"
LOG_FILE="${VOXCPM_LOG_FILE:-/tmp/voxcpm-server.log}"
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
生成六个旁白音色的样本音频(Voice Design 模式,无需参考音频)
输出目录: audio/narrator_samples/{game_id}/sample_{n}.wav
每个游戏生成 3 条有代表性的台词,覆盖不同场景类型。
"""
import os
import time
import requests
import subprocess
HOST = os.environ.get("VOXCPM_HOST", "127.0.0.1")
PORT = os.environ.get("VOXCPM_PORT", "8000")
BASE_URL = f"http://{HOST}:{PORT}"
OUT_BASE = "/home/xsl/blind/audio/narrator_samples"
# ──────────────────────────────────────────────────────────────
# 六个旁白的 Voice Design 风格描述(中文,给 VoxCPM2)
# ──────────────────────────────────────────────────────────────
NARRATORS = {
"linxia": {
"style": "成熟女声,三十岁出头,克制平静,纪录片旁白语气,语速偏慢,普通话标准,胸腔共鸣,不带情感评判",
"speed": 0.85,
"samples": [
("s1_narrate", "晚上九点零三分。手机震动,铃声响起。屏幕显示:妈妈。"),
("s2_tap", "你接了。妈妈的声音从电话里传来,带着一点日常的关心,问你今天怎么样,吃了没有。你说还好。挂掉之后,房间里又安静了。"),
("s3_ending", "今晚大部分的消息,你都没有回应。你一个人度过了这个夜晚。这也是一种选择,你心里清楚。"),
],
},
"yinian": {
"style": "年轻女声,二十五岁左右,气声轻柔,内心独白风格,语速很慢,像说给自己听,声音贴近,偏头腔,有重量但不悲伤",
"speed": 0.80,
"samples": [
("s1_narrate", "你坐在黑暗里,独自一人。今晚你必须做一个决定——你自己知道是什么。"),
("s2_tap", "你在黑暗里,说了那句对不起。没有人听见,但你说了。有什么东西松开了。"),
("s3_ending", "你今晚接住了很多,你没有选择只是轻装。也许你的决定,就是带着这些往前走。"),
],
},
"shouye": {
"style": "中年男声,四十五岁左右,略带沙哑,低沉疲惫,实务语气,像在做交班记录,不慌张,稳定",
"speed": 0.90,
"samples": [
("s1_narrate", "凌晨零点十分。地下室传来水声,不大,但是持续的,一直在响。"),
("s2_tap", "你拨了地下室的内线。没人接。你下去看了一眼,是一根管子在漏水,用旧布堵住了。回到监控室,零点二十三分。"),
("s3_ending", "今晚你一个人上了屋顶,你看到了那只猫,你看到了天边开始变色。只有你看到了。这算不上什么,但只有你知道。"),
],
},
"houzhen": {
"style": "女声,完全中性,无情感,播音腔,字正腔圆,精确清晰,像医院叫号系统,句末无语调起伏",
"speed": 0.95,
"samples": [
("s1_narrate", "叫号机叫到了八十五号。你的号是九十四。等待的时间被切成了一小块一小块的,每一块都很结实。"),
("s2_tap", "你看了他一会儿。他感觉到了,朝你点了点头。你也点了点头。没有说话,但有什么东西发生了。"),
("s3_final", "九十四号。是你。你站起来,整理了一下衣服,走向那扇门。"),
],
},
"yisheng": {
"style": "年轻女声,二十五岁,清晰精确,略带机械感,手机系统语音风格,有礼但冷静,像精调过的语音助手",
"speed": 0.92,
"samples": [
("s1_intro", "你收到了一部手机。手机的主人去世了,一个月前。他留下了很多录音,按时间排列。你不认识他。或者你以为你不认识他。"),
("s2_meta", "第一条录音。录制时间:三月五日,早上八点。时长:二十二秒。文件名:买橙子。"),
("s3_skip", "你跳过了这条。你永远不会知道这条说了什么了。"),
],
},
"fu": {
"style": "女声,大量气声,极轻,飘渺,语速极慢,声音散而不集中,像梦里的声音,若即若离",
"speed": 0.75,
"samples": [
("s1_intro", "你漂浮在某处。不知道是水,还是梦,还是别的什么。声音会漂过来。"),
("s2_item", "第一个。像夏天傍晚的风,带着草的气味,你说不清是哪年夏天的。它停在你身边了。"),
("s3_tap", "它停在你手心里,有点暖。"),
],
},
}
def synthesize_styled(text: str, style: str, cfg: float = 3.0, steps: int = 20) -> bytes:
r = requests.post(
f"{BASE_URL}/v1/speech/styled",
json={
"text": text,
"style": style,
"voice_id": None,
"cfg_value": cfg,
"inference_timesteps": steps,
"denoise": True,
"retry_badcase": True,
"normalize": True,
},
timeout=180,
)
r.raise_for_status()
return r.content
def apply_speed(wav_in: str, speed: float, wav_out: str):
if abs(speed - 1.0) < 0.01:
import shutil; shutil.copy2(wav_in, wav_out)
return
subprocess.run([
"ffmpeg", "-y", "-i", wav_in,
"-filter:a", f"atempo={speed}",
"-acodec", "pcm_s16le", wav_out,
], check=True, capture_output=True)
def main():
print(f"\n{'='*60}")
print(f" 旁白样本生成 — Voice Design 模式(无需参考音频)")
print(f" 输出: {OUT_BASE}/")
print(f"{'='*60}\n")
total_ok = 0
total_fail = 0
for game_id, cfg in NARRATORS.items():
game_dir = os.path.join(OUT_BASE, game_id)
os.makedirs(game_dir, exist_ok=True)
print(f"── {game_id} [{cfg['style'][:30]}...]")
for tag, text in cfg["samples"]:
out_path = os.path.join(game_dir, f"{tag}.wav")
if os.path.exists(out_path):
print(f" [跳过] {tag}.wav 已存在")
total_ok += 1
continue
print(f" [{tag}] {text[:40]}...", end=" ", flush=True)
t0 = time.time()
try:
wav_bytes = synthesize_styled(text, cfg["style"])
raw_path = out_path + ".raw.wav"
with open(raw_path, "wb") as f:
f.write(wav_bytes)
apply_speed(raw_path, cfg["speed"], out_path)
if os.path.exists(raw_path) and raw_path != out_path:
os.remove(raw_path)
elapsed = time.time() - t0
size_kb = os.path.getsize(out_path) // 1024
print(f"{elapsed:.1f}s {size_kb}KB")
total_ok += 1
except Exception as e:
print(f"{e}")
total_fail += 1
print()
print(f"{'='*60}")
print(f" 完成 {total_ok} 条 失败 {total_fail}")
print(f" 输出目录: {OUT_BASE}/")
print(f"{'='*60}\n")
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""
为各游戏脚本注入 narrateAudio 字段
用法:
python audio/inject_narrateAudio.py --game shouye --dry-run
python audio/inject_narrateAudio.py --game shouye
说明:
扫描 audio/mp3/narrator/{game_id}/ 目录下已生成的 MP3
打印出对应游戏脚本中应添加的 narrateAudio 字段。
由于游戏脚本结构各异,本脚本只做"报告",不直接修改 JS 文件。
输出示例(复制粘贴到对应 scene 对象中):
s01: {
narrateAudio: '../audio/mp3/narrator/shouye/shouye_s01_narrate.mp3',
narrate: '...', // 保留原文作 TTS 兜底
...
}
"""
import os
import argparse
import glob
PROJECT_DIR = "/home/xsl/blind"
NARRATOR_DIR = os.path.join(PROJECT_DIR, "audio", "mp3", "narrator")
# 相对于 game/js/games/{game}.js 的音频路径
AUDIO_REL = "../../audio/mp3/narrator"
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--game", required=True,
help="游戏 ID (linxia/yinian/shouye/houzhen/yisheng/fu)")
parser.add_argument("--dry-run", action="store_true",
help="只列出可用文件,不输出注入建议")
args = parser.parse_args()
game_dir = os.path.join(NARRATOR_DIR, args.game)
if not os.path.isdir(game_dir):
print(f"[错误] 目录不存在: {game_dir}")
print(f"请先运行 batch_narrator_tts.py 生成音频。")
return
files = sorted(glob.glob(os.path.join(game_dir, "*.mp3")))
if not files:
print(f"[空] {game_dir}/ 中没有 MP3 文件。")
return
print(f"\n已生成的 {args.game} 旁白音频 ({len(files)} 条):\n")
if args.dry_run:
for f in files:
print(f" {os.path.basename(f)}")
return
print("// ── 将以下 narrateAudio 字段添加到对应 scene/action 对象中 ──")
print("// (保留 narrate 文字作 TTS 兜底;有了 narrateAudio 后引擎会优先播放文件)\n")
for f in files:
name = os.path.basename(f) # e.g. shouye_s01_narrate.mp3
stem = name[:-4] # e.g. shouye_s01_narrate
# 推断 scene/action key
parts = stem.split("_") # ['shouye', 's01', 'narrate']
rel_path = f"{AUDIO_REL}/{args.game}/{name}"
# 判断类型
if parts[-1] == "narrate" or parts[-1].startswith("narrate"):
field = "narrateAudio"
note = f"// → scene {parts[1]}"
elif parts[-1] == "tap" or parts[-1].startswith("tap"):
field = "narrateAudio"
note = f"// → scene {parts[1]} / tap"
elif parts[-1] == "dt":
field = "narrateAudio"
note = f"// → scene {parts[1]} / doubleTap"
elif parts[-1] == "intro":
field = "// game.introAudio"
note = "// → game 对象 intro 字段(engine.run 中用 narrateAudio 处理)"
elif "ending" in parts:
ending_id = "_".join(parts[parts.index("ending"):])
field = "narrateAudio"
note = f"// → endings.{ending_id.replace('ending_', '')} 对象"
else:
field = "narrateAudio"
note = ""
print(f" {field}: '{rel_path}', {note}")
print("\n// 提示:intro 的 narrateAudio 需在 engine.run() 里单独处理,")
print("// 或在 game 对象中加 introAudio 字段并在 engine 中检查。")
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+483
View File
@@ -0,0 +1,483 @@
# 盲游 · 旁白音色提示词文档
> 本文档为六个游戏旁白的音色定向文件。
> 用途:根据此文档寻找参考音频,或向 TTS 模型输入提示词,生成样本后人工筛选。
> 每个游戏的旁白声音独立,风格、质感、情绪基调均不同。
---
## 通用技术要求
生成的样本音频需满足:
- 格式:WAV,16kHz,单声道(作为参考音色注册用)
- 最终产出:MP344100Hz,单声道,128kbps-18 LUFS
- 首尾静音:开头 100ms,结尾 200ms
- 无背景音乐,无混响(干声)
---
## 一、《林夏》旁白 — 纪录片叙述者
### 角色定位
她是一个旁观者,不是林夏本人,也不是林夏的朋友。她只是在记录。她知道今晚发生了什么,但她不评价,不安慰,不引导。像一部安静的纪录片里的解说词。
### 声音特征
| 维度 | 描述 |
|------|------|
| 性别 | 女声 |
| 年龄感 | 30-38岁 |
| 音色底质 | 中低女声,有一点点气声,但不是耳语。清晰,不尖锐 |
| 共鸣位置 | 胸腔共鸣为主,不是头声 |
| 语速 | 慢,比正常对话语速慢约15%。每个短句之间有自然停顿 |
| 音调 | 标准普通话音调,不夸张,没有朗诵腔 |
| 气息 | 平稳,每句话起头不会有明显换气声 |
### 情感基调
**克制的悲悯**。她知道林夏今晚过得不容易,但她不会在语气上表现出来。那种情绪藏在语速和停顿里,不在音调变化里。
**不该有的感觉**:同情、叹息、戏剧性、新闻播报腔、心理咨询语气。
### TTS 提示词(英文,适用于支持风格描述的模型)
```
Voice: Adult Chinese female, 30-38 years old. Documentary narrator style.
Tone: Calm, observational, restrained compassion. Never dramatic.
Delivery: Slow pace (85% of normal speed), natural pauses between clauses.
Timbre: Mid-low register, slight chest resonance, not breathy, not sharp.
Emotion: Neutral on the surface, with a barely perceptible weight underneath.
Language: Standard Mandarin Chinese, no regional accent, no theatrical intonation.
Do NOT sound like: news anchor, audiobook reader, customer service, therapist.
Reference: Like a quiet documentary about ordinary people, late at night.
```
### TTS 提示词(中文)
```
声音风格:中国女性旁白,年龄感在三十岁出头。纪录片叙述者风格。
语气:平静、观察性的,有克制的悲悯,但绝不戏剧化。
语速:慢,约正常语速的85%,句与句之间有自然停顿。
音色:中低女声,以胸腔共鸣为主,略有气声但不是耳语,吐字清晰。
情感:表面中性,但语速和停顿里带着一点重量。
语言:标准普通话,无地方口音,无朗诵腔。
不要像:新闻主播、有声书朗读、客服提示音、心理咨询师。
参考感受:像一部关于普通人日常的安静纪录片,深夜播出。
```
### 样例句子及朗读标注
```
「晚上九点零三分。手机震动,铃声响起。屏幕显示:妈妈。」
朗读提示:
- 「晚上九点零三分。」— 陈述,轻,后面停顿约 0.5 秒
- 「手机震动,铃声响起。」— 音量略低,描述动作,不强调
- 「屏幕显示:」— 停顿,然后
- 「妈妈。」— 非常平,两个字,不带任何情感色彩
```
```
「铃声响了又停。妈妈没再打来。」
朗读提示:
- 整句语速偏慢,结尾「没再打来」后留出3秒的静默感
- 不要读出遗憾或心疼,只是陈述事实
```
---
## 二、《一念》旁白 — 内心独白
### 角色定位
她就是你自己。不是外部的声音,是你坐在黑暗里,自己在心里说话的声音。像半梦半醒之间,对自己说的那些不会说出口的话。
### 声音特征
| 维度 | 描述 |
|------|------|
| 性别 | 女声 |
| 年龄感 | 25-32岁 |
| 音色底质 | 略有气声,不是正常说话声而是「心里的声音」。比林夏更柔,更近 |
| 共鸣位置 | 偏向头腔和鼻腔,不是胸腔 |
| 语速 | 很慢,约正常语速的80%,每个词之间都有微小的停顿,像在慢慢想 |
| 音调 | 比标准说话略低半个音阶,平稳,很少起伏 |
| 气息 | 有轻微的气声底色,每句开头偶尔有一点呼气感,但不是叹气 |
| 距离感 | 非常近,像贴着耳朵说话,但不是低吼,是轻而有质感 |
### 情感基调
**亲密与重量**。这个声音不是在讲述,是在陪伴。她知道你在想什么,她不急着说完,每一句都给你留足了空间去感受。
**不该有的感觉**:治愈系播客腔、催眠音频的夸张柔和、冥想引导语气。
### TTS 提示词(英文)
```
Voice: Chinese female, 25-32 years old. Inner monologue, the voice inside your own head.
Tone: Intimate, slow, weighted. Like whispering to yourself in the dark.
Delivery: Very slow (80% speed), micro-pauses between phrases, thoughtful cadence.
Timbre: Slightly breathy, soft head resonance, close and present — not distant.
Emotion: Quiet tenderness, not sad but carrying weight. Like someone who knows.
Distance: Very close mic presence, like speaking directly into the listener's ear.
Do NOT sound like: meditation app, ASMR, therapy podcast, bedtime story reader.
Reference: The internal voice you hear when reading something that moved you deeply.
```
### TTS 提示词(中文)
```
声音风格:中国女性,年龄感25-32岁。内心独白,就是你自己脑海中的声音。
语气:亲密、缓慢、有重量。像在黑暗中对自己说话。
语速:很慢,约正常语速的80%,词与词之间有细小停顿,像在慢慢思考。
音色:略有气声,偏头腔共鸣,声音贴近,不是从远处传来的。
情感:安静的温柔,不悲伤但有分量,像一个懂你的人。
距离感:非常近,像直接说在耳边,但不是低吼。
不要像:冥想App引导词、ASMR、心理播客、睡眠故事。
参考感受:读一本让你动容的书时,脑海中浮现的那个声音。
```
### 样例句子及朗读标注
```
「你坐在黑暗里,独自一人。今晚你必须做一个决定——你自己知道是什么。」
朗读提示:
- 「你坐在黑暗里,」— 极轻,像呼气
- 「独自一人。」— 停顿感,留白约 0.8 秒
- 「今晚你必须做一个决定——」— 稍慢,破折号处停顿约 1 秒
- 「你自己知道是什么。」— 最轻,几乎是说给自己听的
```
```
「你在黑暗里,说了那句对不起。没有人听见,但你说了。有什么东西松开了。」
朗读提示:
- 「有什么东西松开了。」— 这是最重要的一句,但要说得最轻,
像呼出一口气,结尾略微延长「了」的尾音
```
---
## 三、《守夜》旁白 — 对讲机留言
### 角色定位
他是一个老保安,在你入职之前就在这栋楼里工作了很多年。他提前录好了这些提示音,放在对讲机系统里,帮助新来的保安在夜班时做判断。他见过很多事,不大惊小怪,但他认真负责。
### 声音特征
| 维度 | 描述 |
|------|------|
| 性别 | 男声 |
| 年龄感 | 45-55岁 |
| 音色底质 | 中低男声,略带沙哑,像抽了很多年烟但已经戒了。不是醇厚的磁性男声,是有点磨损的质感 |
| 共鸣位置 | 以胸腔为主,厚实但不夸张 |
| 语速 | 接近正常,约90%语速,但字与字之间咬字清楚,不含糊 |
| 音调 | 标准偏低,不高亢,像在做记录而不是在讲故事 |
| 录音质感 | 可以有轻微的对讲机感(略带中频突出),但要保持清晰 |
| 气息 | 平稳,带一点疲惫,但是那种习惯了疲惫的人,不是撑不住的那种 |
### 情感基调
**实务与温和**。他的语气像在交接班,说清楚情况,给你判断,但他不会给你施压。他见过太多了,什么情况他都经历过,所以他的语气里有一种稳定的疲惫,不是焦虑的那种疲惫。
**不该有的感觉**:权威、命令、惊慌、新闻播报腔、机械警报感。
### TTS 提示词(英文)
```
Voice: Middle-aged Chinese male, 45-55 years old. Pre-recorded walkie-talkie message.
Tone: Matter-of-fact, mildly tired, steady. Like a veteran security guard giving handover notes.
Delivery: Near-normal pace (90% speed), clear diction, slight deliberateness.
Timbre: Mid-low male voice, slightly gravelly/worn texture, chest resonance dominant.
Emotion: Calm fatigue. Not stressed, not urgent. Seen it all before.
Audio texture: Slight mid-frequency presence (like walkie-talkie), but still clear.
Do NOT sound like: military commander, news anchor, emergency alert, thriller narrator.
Reference: An experienced night-shift worker leaving a recorded message for the next shift.
```
### TTS 提示词(中文)
```
声音风格:中年中国男性,年龄感45-55岁。预录的对讲机提示音。
语气:实务、略带疲惫、稳定。像一个经验丰富的保安在交班时留下的提示。
语速:接近正常,约90%,吐字清楚,有一点郑重但不沉重。
音色:中低男声,略带沙哑/磨损感,以胸腔共鸣为主。
情感:平静的疲惫。不紧张,不急迫,什么都见过了。
音频质感:轻微的中频突出(对讲机感),但保持清晰可辨。
不要像:军事指挥官、新闻主播、紧急广播、惊悚片旁白。
参考感受:一个在这栋楼工作了二十年的老保安,录了一批提示音留给新来的年轻人。
```
### 样例句子及朗读标注
```
「凌晨零点十分。地下室传来水声,不大,但是持续的,一直在响。」
朗读提示:
- 「凌晨零点十分。」— 像在说时间记录,平,有点像报时
- 「地下室传来水声,」— 陈述,不强调
- 「不大,」— 略停,「但是」
- 「持续的,一直在响。」— 这三个字是关键信息,稍重,但不夸张
```
```
「五点整。接班的人进来了,你们交接了几句,递了记录本,你背上包走出去。外面已经有一点点亮了。」
朗读提示:
- 前半部分正常陈述
- 「外面已经有一点点亮了。」— 这里语气应该有一丝松动,
不是高兴,是那种熬完一夜之后的轻,但他说的时候还是很平
```
---
## 四、《候诊》旁白 — 时间本身
### 角色定位
这个声音没有性格,没有情绪,没有立场。它就是时间在说话。精确,中性,像一个永远不会犯错的计时器,或者一个医院里的叫号系统升级版。它不在意你的感受,它只在意事实。
### 声音特征
| 维度 | 描述 |
|------|------|
| 性别 | 女声或男声均可,推荐女声(更贴近中性)|
| 年龄感 | 难以判断年龄,像是AI生成的那种无年龄感 |
| 音色底质 | 非常干净,没有气声,没有磨损,没有个人质感。标准播音腔,但去掉情感成分 |
| 共鸣位置 | 中性共鸣,不特别强调胸腔或头腔 |
| 语速 | 接近正常,约95%,每个数字、每个名词都精确清晰 |
| 音调 | 几乎完全平,句末不做语气下沉或上扬 |
| 气息 | 完全平稳,机械般的平稳 |
### 情感基调
**零情感,精确**。不冷漠(冷漠也是一种情感),就是没有情感。像GPS语音导航,但更安静,更精准,没有任何「友好感」。
**不该有的感觉**:温暖、关怀、急迫、权威感、新闻感、AI助手的那种刻意亲切。
### TTS 提示词(英文)
```
Voice: Chinese female (preferred) or male, genderless age quality.
Tone: Completely neutral. Zero emotion. Not cold — simply emotionless.
Delivery: Near-normal pace (95%), extremely precise diction, especially for numbers and names.
Timbre: Clean, no breathiness, no texture, no warmth — pure phonation.
Emotion: None. This is time itself speaking. It does not care.
Intonation: Nearly flat. Minimal sentence-final variation.
Do NOT sound like: friendly AI assistant, GPS voice, news anchor, customer service bot.
Reference: A hospital queue number system, but more articulate and slower.
```
### TTS 提示词(中文)
```
声音风格:中国女声(推荐)或男声,无年龄感。
语气:完全中性。零情感。不是冷漠,而是没有情感这件事本身。
语速:接近正常,约95%,数字和名词发音极其精确清晰。
音色:干净,无气声,无质感,无温度——纯粹的发声。
情感:无。这是时间本身在说话,它不在乎任何人。
语调:近乎完全平,句末几乎没有语调变化。
不要像:友好的AI助手、GPS导航语音、新闻主播、客服机器人的刻意亲切。
参考感受:医院的叫号系统,但更清晰,更慢,句子更长。
```
### 样例句子及朗读标注
```
「叫号机叫到了八十五号。你的号是九十四。等待的时间被切成了一小块一小块的,每一块都很结实。」
朗读提示:
- 「叫号机叫到了八十五号。」— 像读数据
- 「你的号是九十四。」— 完全平,不带任何「所以你还要等」的暗示
- 后面的描述性句子:同样平,但语速可以微微放慢,因为句子更长
- 整句没有任何起伏,连「结实」也不强调
```
```
「九十四号。是你。你站起来,整理了一下衣服,走向那扇门。」
朗读提示:
- 「九十四号。」— 像报号,平
- 「是你。」— 两个字,无任何情感,不是「终于是你了」,只是确认
- 后面的动作描述:陈述,像摄像头记录下的画面
```
---
## 五、《遗声》旁白 — 手机系统语音
### 角色定位
她是这部手机的语音助手,被主人调教过,用来播报录音元数据。她有一点点温度,但本质上还是机器——她不知道这些录音是什么内容,她只负责读出文件信息。如果说《候诊》的旁白是零温度,她大约是温度计刚刚能感应到体温的那一格。
### 声音特征
| 维度 | 描述 |
|------|------|
| 性别 | 女声 |
| 年龄感 | 25-30岁的感觉,但带一点点机械感 |
| 音色底质 | 偏年轻的女声,咬字非常清晰,字字分明,但没有过度的感情投入 |
| 共鸣位置 | 偏头腔,亮而清晰 |
| 语速 | 约92%正常语速,读数字和文件名时会自然地慢下来一点 |
| 音调 | 略高于标准说话音调,但只高一点点 |
| 气息 | 完全清晰,无气声,像数字合成人声 |
| 节奏感 | 元数据的各个部分之间有固定的小停顿,像在读表格 |
### 情感基调
**冷静、有礼、略机械**。她不是真的无情,但她的情感是预设的,是「礼貌」这个程序的一部分,而不是真正感受到了什么。像一个很专业的前台,完全不会失礼,但你知道她下班之后不会想起你。
**不该有的感觉**:人类的共情感、悲伤(即使读到沉重的文件名也不会)、夸张的热情、恶意的冷漠。
### TTS 提示词(英文)
```
Voice: Young Chinese female, 25-30 years old. Phone voice assistant style.
Tone: Calm, polite, slightly mechanical — but with a tiny hint of warmth (not cold, not human).
Delivery: 92% speed, very precise diction, slight deliberate pauses between metadata fields.
Timbre: Bright head resonance, clean, no breathiness — digital synthesis quality.
Emotion: Programmed politeness. Not sad reading sad file names. Not happy reading happy ones.
Rhythm: Consistent, like reading a form — date, duration, filename as separate units.
Do NOT sound like: warm human narrator, empathetic counselor, cold robot, customer service.
Reference: Apple's Siri or a refined voice assistant, but quieter and slower.
```
### TTS 提示词(中文)
```
声音风格:年轻中国女性,年龄感25-30岁。手机语音助手风格。
语气:冷静、有礼、轻微的机械感——有一点点温度,但不是人类的温度。
语速:约92%,咬字极其清晰,元数据各字段之间有固定小停顿。
音色:偏头腔共鸣,明亮清晰,无气声——接近数字合成人声的质感。
情感:程序化的礼貌。读到沉重的文件名不会悲伤,读到轻松的也不会高兴。
节奏:稳定,像在读表格——日期、时长、文件名分别是独立的信息单元。
不要像:温暖的人类旁白、有共情的叙述者、冷酷的机器、客服话务员。
参考感受:Apple Siri或精调后的语音助手,但更安静、更慢。
```
### 样例句子及朗读标注
```
「第一条录音。录制时间:三月五日,早上八点。时长:二十二秒。文件名:买橙子。」
朗读提示:
- 「第一条录音。」— 轻,像索引号
- 「录制时间:」— 停顿,然后读出日期,日期之后停顿
- 「三月五日,早上八点。」— 数字精确,停顿
- 「时长:」— 同样格式
- 「二十二秒。」— 精确,停顿
- 「文件名:」— 停顿
- 「买橙子。」— 读文件名时没有任何情感色彩,就算是再奇怪的文件名也一样平
```
---
## 六、《浮》旁白 — 梦里的声音
### 角色定位
这个声音几乎不存在。它是梦里的,不知道从哪里来,没有身份,没有位置。它不是在"说话",是在"飘"。像你半睡半醒时听到的窗外风声,你以为自己听到了什么词,但你不确定。
### 声音特征
| 维度 | 描述 |
|------|------|
| 性别 | 女声 |
| 年龄感 | 难以判断,可能是任何年龄 |
| 音色底质 | 大量气声,几乎一半是气一半是声。不是正常的气声,是那种声音里大部分是呼吸的质感 |
| 共鸣位置 | 几乎没有共鸣,声音不集中,是「散」的 |
| 语速 | 很慢,约75%正常语速,某些词会自然地拖长 |
| 音调 | 比正常说话略低,非常平,起伏极小,像水面微微荡漾 |
| 气息 | 这个角色的声音本身就是气息。每句话开头都有轻微的吸气或呼气 |
| 空间感 | 有一点点远,像从很远的地方传来,或者像在一个大的空旷空间里 |
| 混响 | 可以有轻微的自然混响(不是KTV那种),像在一个大房间里或水边 |
### 情感基调
**飘渺与悬浮**。这个声音没有紧迫感,没有目的感,它只是在漂,说了什么它自己也不在意是否被听到。但每一句话都有它的重量,只是那个重量是用气息而不是用音量表现的。
**不该有的感觉**:ASMR(太刻意)、冥想引导(太有目的性)、鬼魂感(不是恐怖的)、梦境里的哲学大师。
### TTS 提示词(英文)
```
Voice: Chinese female, age indeterminate. The voice of a dream — barely present.
Tone: Floating, weightless, present but not insistent. It does not need to be heard.
Delivery: Very slow (75% speed), some words naturally elongated, long pauses between sentences.
Timbre: High breathy ratio — the voice is mostly breath with a thin thread of tone.
Resonance: Minimal, diffuse — not focused, like sound in an open space or on water.
Emotion: Serene vacancy. Aware but not attached. Like mist.
Space: Slightly distant, with subtle natural reverb (not studio dry, not echoey).
Do NOT sound like: ASMR content creator, meditation guide, horror movie ghost, poetic sage.
Reference: The voice you think you hear right before falling asleep, uncertain if it was real.
```
### TTS 提示词(中文)
```
声音风格:中国女性,无法判断年龄。梦里的声音,几乎不存在。
语气:漂浮、无重力、在场但不坚持。它不在意自己有没有被听到。
语速:很慢,约75%正常语速,某些词会自然地拖长,句与句之间停顿很长。
音色:气声比例极高——声音大部分是呼吸,带着一缕细细的音调。
共鸣:极少,声音是散的,不集中,像在开阔空间或水面上的声音。
情感:宁静的空洞。有意识但不执着,像雾。
空间感:略远,带轻微的自然混响(不是录音棚的干声,也不是大回声)。
不要像:ASMR创作者、冥想引导、恐怖片鬼声、诗意的哲学家。
参考感受:将睡未睡时,你以为听到了什么声音,但不确定是不是真的。
```
### 样例句子及朗读标注
```
「你漂浮在某处。不知道是水,还是梦,还是别的什么。声音会漂过来。」
朗读提示:
- 几乎每个逗号后都停顿 1 秒以上
- 「不知道是水,」— 语速拖长,「水」字尾音延长
- 「还是梦,」— 更轻,再延长
- 「还是别的什么。」— 最轻,像消失前的最后一丝声音
- 「声音会漂过来。」— 新的一句,但不重,像是在说梦话
```
```
「第一个。像夏天傍晚的风,带着草的气味,你说不清是哪年夏天的。它停在你身边了。」
朗读提示:
- 「第一个。」— 很轻,像梦里的报数
- 「像夏天傍晚的风,」— 语速极慢,「风」字略微拖长
- 「带着草的气味,」— 继续慢,像真的闻到了什么
- 「你说不清是哪年夏天的。」— 这句有一点感慨,但要控制,气声增加
- 「它停在你身边了。」— 最轻,最气声
```
---
## 附:音色对比速查表
| 游戏 | 关键词 | 最像 | 最不像 |
|------|--------|------|--------|
| 林夏 | 克制、观察、平静 | 深夜纪录片 | 心理咨询、有声书 |
| 一念 | 亲密、缓慢、有重量 | 你自己的内心声音 | 冥想App、ASMR |
| 守夜 | 实务、疲惫、稳定 | 老同事交班提示 | 军事指挥、新闻主播 |
| 候诊 | 精确、中性、零情感 | 医院叫号系统 | AI助手的亲切感 |
| 遗声 | 有礼、清晰、略机械 | 精调版Siri | 温暖人类旁白 |
| 浮 | 气声、飘渺、悬浮 | 将睡未睡时的幻听 | ASMR创作者 |
---
## 样本评估标准
收到生成样本后,按以下维度打分(每项 1-5 分):
1. **音色符合度** — 和提示词描述的质感是否匹配
2. **情感准确度** — 情感基调是否正确(不多也不少)
3. **语速节奏** — 是否有自然的停顿,不赶场
4. **清晰度** — 中文吐字是否清晰,特别是数字、专有名词
5. **辨识度** — 六个旁白之间是否有足够的声音差异
优先确保林夏(主游戏)和浮(风格最特殊)的样本质量。
+315
View File
@@ -0,0 +1,315 @@
# 盲游 · 音频制作执行手册
> 本手册是**从零制作一款游戏音频**的操作指南,覆盖配音、音乐、音效三类。
> 所有音频均由本机 AI 模型本地生成,不依赖任何真人录音或网络素材。
> 林夏项目将按本手册完全重头制作。
---
## 〇、环境速查(已就绪,无需再装)
| 类别 | 工具 | 路径 / 环境 | 用途 |
|------|------|------------|------|
| 基础 | FFmpeg 7.0 | `/usr/local/bin/ffmpeg` | 格式转换、响度归一化、听筒效果 |
| 基础 | Python 3.13 + conda | `/home/xsl/miniconda3` | 脚本运行 |
| GPU | RTX 5090 32GB | — | 模型推理 |
| **配音** | **VoxCPM2** | `/home/xsl/tts-server` · 端口 **8000** | TTS 主链路(Voice Design + 克隆) |
| 配音-备 | CosyVoice 2.0 | `/home/xsl/tools/CosyVoice` · conda `cosyvoice` | TTS 备用 |
| 配音-备 | GPT-SoVITS | `/home/xsl/tools/GPT-SoVITS` · conda `gptsovits` | TTS 备用 |
| 配音-备 | fish-speech | `/home/xsl/tools/fish-speech` · conda `fishspeech` | TTS 备用 |
| **音乐** | **ACE-Step 1.5** | `/home/xsl/tools/ACE-Step-1.5` · conda `acestep` | BGM + 铃声生成 |
| **音效** | ACE-Step 1.5 | 同上 | 氛围类音效(≥5s) |
| 音效-补 | Stable Audio Open | `/home/xsl/tools/stable-audio-tools` · conda `stableaudio` | 高品质音效补充 |
| UI 音效 | Web Audio API | 浏览器端 `audio.js` 合成 | 短促反馈音(无文件) |
**模型权重**VoxCPM2 @ `/home/xsl/models/VoxCPM2`
> 端口说明:VoxCPM2 服务固定使用 **8000**`/home/xsl/tts-server` 不动)。项目脚本通过 `VOXCPM_HOST`/`VOXCPM_PORT` 环境变量读取,默认 8000,已全部对齐。
---
## 一、总体流程(三类音频统一节奏)
```
准备内容(音色提示词 + 台词表 + BGM/音效 caption
├── 配音 ─→ 启动TTS ─→ 生成音色样本 ─→ 选定音色 ─→ 克隆生成台词 ─→ 归一化
├── 音乐 ─→ 启动ACE-Step ─→ 生成 ─→ 归一化
└── 音效 ─→ 启动ACE-Step ─→ 生成 ─→ 归一化
└── 统一归一化(process_audio.sh)──→ 部署到 audio/mp3/
```
**关键原则——TTS 配音两步走**:先用提示词生成音色样本 → 人工筛选 → 再用选定音色克隆生成正式配音。不要跳过选音色这一步。
---
## 二、配音制作(TTS / VoxCPM2
### 2.1 启动 TTS 服务
```bash
# 方式一:直接启动
bash /home/xsl/tts-server/start-voxcpm.sh
# 验证
curl http://127.0.0.1:8000/health
# 预期: {"status":"ok","model":"VoxCPM2","styled_speech":true}
# 方式二:环境检查 + 自动启动(含降噪模型回退)
bash /home/xsl/blind/audio/check_tts_env.sh
bash /home/xsl/blind/audio/check_tts_env.sh --full # 含全链路测试合成
```
> 若降噪模型下载失败,脚本会自动回退到 `VOXCPM_ENABLE_DENOISER=0` 重试(更稳,降噪关闭)。
### 2.2 第一步:生成音色样本(Voice Design
**输入**:角色的音色提示词(风格描述)。
**参考文档**`docs/旁白音色提示词.md`(旁白)、`other_docs/林夏_原始设计.md` §7.3(角色音色配置表)。
```bash
# 生成六个旁白的音色样本(无需参考音频,纯 Voice Design
cd /home/xsl/blind
python3 audio/gen_narrator_samples.py
# 输出: audio/narrator_samples/{game_id}/sample_*.wav
```
若要为新游戏/新角色生成样本,修改 `gen_narrator_samples.py` 中的 `NARRATORS` 字典:填入 `style`(音色提示词)、`speed`(语速)、`samples`(代表性台词)。
### 2.3 人工筛选音色
1. 试听 `audio/narrator_samples/{game_id}/` 下的 WAV
2.`docs/旁白音色提示词.md` 末尾的"样本评估标准"打分(音色符合度、情感准确度、语速节奏、清晰度、辨识度)
3. 选定一个样本,作为该角色/旁白的**音色锚点**
### 2.4 第二步:克隆生成正式配音
**将选定音色放入参考目录**
```
voice_mp3/narrator/{game_id}/ # 或 voice/narrator/{game_id}/WAV
└── 选定的音色样本文件
```
**批量生产旁白配音**
```bash
# 生产所有游戏旁白
python3 audio/batch_narrator_tts.py --source voice_mp3
# 只生产指定游戏
python3 audio/batch_narrator_tts.py --source voice_mp3 --game linxia
# 只生产 P0narrate/tap/dt/intro,核心体验)
python3 audio/batch_narrator_tts.py --source voice_mp3 --priority p0
# 干跑(先看任务列表,不实际合成)
python3 audio/batch_narrator_tts.py --source voice_mp3 --dry-run
```
输出:`audio/00_raw/narrator/{game_id}/{game_id}_{scene_id}_{type}.wav`
**批量生产角色配音(林夏角色台词)**
```bash
# 林夏角色台词(需在脚本 LINES 中维护台词表)
python3 audio/batch_tts_voxcpm.py --source voice_mp3 --dry-run # 先看任务
python3 audio/batch_tts_voxcpm.py --source voice_mp3 # 正式生产
python3 audio/batch_tts_voxcpm.py --source voice_mp3 --role mom # 只生产某角色
```
输出:`audio/00_raw/tts/{filename}_wav_v1.wav`
> 台词表在 `batch_tts_voxcpm.py` 的 `LINES` 变量中维护;参考音色在 `voice_mp3/{角色}/` 或 `voice/{角色}/` 下。重做林夏时需重新整理台词表。
### 2.5 VoxCPM2 API(手动单条合成时用)
| 接口 | 用途 |
|------|------|
| `POST /v1/voices` | 注册参考音频(wav_base64)→ 返回 voice_id |
| `POST /v1/speech` | 纯克隆合成(text + voice_id)→ WAV |
| `POST /v1/speech/styled` | Style Control(有 voice_id=克隆+表演;无=Voice Design |
参数:`cfg_value`(默认 2.0)、`inference_timesteps`(默认 10);输出 48kHz 16-bit PCM WAV。
---
## 三、音乐制作(BGM + 铃声 / ACE-Step 1.5
### 3.1 启动环境
```bash
conda activate acestep
```
### 3.2 生成
修改 `audio/gen_music_acestep.py` 中的 `BGM_TRACKS`(每段填 `filename``duration``caption``seed`),然后:
```bash
# 生成全部(BGM + 铃声)
python3 audio/gen_music_acestep.py
# 只生成 BGM
python3 audio/gen_music_acestep.py --category bgm
# 只生成铃声
python3 audio/gen_music_acestep.py --category ringtone
# 干跑
python3 audio/gen_music_acestep.py --dry-run
```
输出:`audio/00_raw/music/{filename}.wav`
> RTX 5090 已在脚本顶部做了 cuBLAS 修复:`torch.backends.cuda.preferred_blas_library("cublaslt")`,无需额外处理。
### 3.3 BGM caption 示例(参考林夏已有)
```python
{
"filename": "bgm_main_loop",
"duration": 90,
"caption": "lo-fi midnight bedroom, soft piano, light vinyl crackle, muted piano, subtle synth pad, occasional distant rain, melancholy but warm, ambient loop, no drums, 70 bpm",
"lyrics": "",
"steps": 20,
"seed": 2103,
}
```
caption 越具体,生成质量越稳定。建议包含:风格、乐器、情绪、节奏、是否带人声、bpm。
---
## 四、音效制作(ACE-Step 1.5
### 4.1 适用范围
- **ACE-Step 可生成**:≥5s 的氛围类音效(呼吸、心跳、转场 swell、环境底噪等)
- **需另行处理**:短促 UI 音效(叮、咚、点击)—— 由 `audio.js` 的 Web Audio 合成,无需文件
- **补充**:可用 Stable Audio Open 生成 ACE-Step 不擅长的音效
### 4.2 生成
修改 `audio/gen_sfx_acestep.py` 中的 `SFX_TRACKS`(填 `filename``duration``caption``seed`),然后:
```bash
conda activate acestep
python3 audio/gen_sfx_acestep.py # 正式生成
python3 audio/gen_sfx_acestep.py --dry-run # 干跑
```
输出:
- 音效 → `audio/00_raw/sfx/{filename}.wav`
- 环境音 → `audio/00_raw/ambience/{filename}.wav`
---
## 五、后期归一化(统一处理)
所有原始音频生成后,用 `process_audio.sh` 统一做响度归一化、听筒效果、格式转换:
```bash
cd /home/xsl/blind
bash audio/process_audio.sh tts # 配音(含电话/语音听筒效果)
bash audio/process_audio.sh music # BGM + 铃声
bash audio/process_audio.sh sfx # 音效
bash audio/process_audio.sh ambience # 环境音
bash audio/process_audio.sh all # 全部
```
### 各类响度标准
| 类别 | 响度 | 额外处理 |
|------|------|---------|
| TTS 配音 | -16 LUFS | 电话/语音类加听筒效果(highpass+lowpass+EQ);长语音分段拼接 |
| BGM | -18 LUFS | — |
| 音效 | -14 LUFS | — |
| 环境音 | -22 LUFS | highpass 60Hz + lowpass 12kHz |
处理流程:`audio/00_raw/{type}/``audio/01_processed/{type}/`
> 最终部署到 `audio/mp3/` 的文件应为 44100Hz、单声道、128kbps MP3。从 `01_processed` 拷贝/转码到 `mp3/` 时确认格式。
---
## 六、文件命名规范
### 配音(角色台词)
```
audio/mp3/tts/{role}_{scene}_{type}_wav_v1.mp3
```
林夏项目约定带 `_wav_v1` 后缀(历史命名,沿用)。
### 旁白
```
audio/mp3/narrator/{game_id}/{game_id}_{scene_id}_{type}.mp3
```
`{type}` 取值:`intro`(开场)/ `narrate`(描述)/ `tap`(单击旁白)/ `dt`(双击旁白)/ `ending_{id}`(结局)等。
### 音乐 / 音效 / 环境音
```
audio/mp3/music/{name}_v1.mp3
audio/mp3/sfx/sfx_{name}_v1.mp3
audio/mp3/ambience/amb_{name}_v1.mp3
```
---
## 七、生成优先级(生产顺序建议)
按以下顺序生产,优先保证核心体验可玩:
1. **P0(必须)**:每个游戏 `intro` + 所有 `narrate` + 所有 `tap`/`dt` 旁白
2. **P1(重要)**:所有 `ending` 结局配音
3. **P2(重要)**:角色主台词(NPC Round 1
4. **P3(次要)**:角色反应台词(Round 2/3)、BGM
5. **P4(可选)**:条件分支变体(`_a`/`_b`)、环境音、叙事音效
> 旁白和 intro 优先级最高——它们决定玩家能否理解游戏。即使角色台词还没生成,引擎会用浏览器 TTS 兜底旁白文本,游戏也能跑起来。
---
## 八、完整生产清单(林夏重做时按此打勾)
### 配音
- [ ] 整理角色台词表(写入 `batch_tts_voxcpm.py``LINES`
- [ ] 整理旁白脚本(对照 `docs/音频脚本.md`,写入 `batch_narrator_tts.py`
- [ ] 为每个角色/旁白写音色提示词(对照 `docs/旁白音色提示词.md`
- [ ] 启动 VoxCPM2`bash /home/xsl/tts-server/start-voxcpm.sh`
- [ ] 生成音色样本:`python3 audio/gen_narrator_samples.py`
- [ ] 试听 + 选定音色,放入 `voice_mp3/narrator/{game}/``voice_mp3/{角色}/`
- [ ] 生产旁白:`python3 audio/batch_narrator_tts.py --source voice_mp3 --priority p0`
- [ ] 生产角色:`python3 audio/batch_tts_voxcpm.py --source voice_mp3`
- [ ] 归一化:`bash audio/process_audio.sh tts`
### 音乐
- [ ] 写 BGM caption(写入 `gen_music_acestep.py``BGM_TRACKS`
- [ ] `conda activate acestep && python3 audio/gen_music_acestep.py --category bgm`
- [ ] 归一化:`bash audio/process_audio.sh music`
### 音效
- [ ] 写音效 caption(写入 `gen_sfx_acestep.py``SFX_TRACKS`
- [ ] `conda activate acestep && python3 audio/gen_sfx_acestep.py`
- [ ] 归一化:`bash audio/process_audio.sh sfx && bash audio/process_audio.sh ambience`
### 部署
- [ ] 确认 `audio/mp3/` 下文件齐全、格式正确(44100Hz/单声道/128kbps MP3
- [ ] 启动游戏验证:`python3 -m http.server 8765 --bind 0.0.0.0`,手机访问 `http://<IP>:8765/game/`
---
## 九、常见问题
| 问题 | 对策 |
|------|------|
| TTS 服务起不来 | 看 `/tmp/voxcpm-server.log`;若降噪模型下载失败,用 `VOXCPM_ENABLE_DENOISER=0 bash /home/xsl/tts-server/start-voxcpm.sh` |
| 端口 8000 被占 | `ss -lnt \| grep 8000` 查占用;如需换端口,`VOXCPM_PORT=xxxx bash start-voxcpm.sh`,并把 `gen_narrator_samples.py` 等脚本的 `VOXCPM_PORT` 一并设 |
| 某角色音色不满意 | 重新调 `style` 提示词,重跑 `gen_narrator_samples.py`,换样本 |
| 响度不统一 | 确认走了 `process_audio.sh`,别直接用 `00_raw` 的文件 |
| 长文本 TTS 质量下降 | 分段生成,`process_audio.sh` 会自动拼接(见脚本中分段处理逻辑) |
| ACE-Step 报 cuBLAS 错 | 脚本已内置修复;若仍报错,确认 `conda activate acestep` 后再跑 |
+555
View File
@@ -0,0 +1,555 @@
# 盲游 · 音频脚本总文档
> 本文档是 TTS 生成的唯一依据。所有旁白台词、情绪标注、文件命名均在此定义。
> 代码中的 `scene.narrate` 文本与本文档保持一致。
> 凡标注「动态,浏览器TTS」的行不需要预先生成文件。
---
## 一、技术规格
| 项目 | 规格 |
|------|------|
| 格式 | MP3128kbps |
| 采样率 | 44100 Hz |
| 声道 | 单声道 |
| 响度标准 | -18 LUFS(与角色语音一致) |
| 首尾静音 | 开头 100ms,结尾 200ms |
---
## 二、文件命名规范
```
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` 场景描述(无条件分支)
- `narrate_a/b` 场景描述(有条件分支,a=条件成立,b=不成立)
- `tap` 单击后旁白
- `tap_a/b` 单击后旁白(条件分支)
- `dt` 双击后旁白
- `ending_{id}` 结局旁白
---
## 三、各游戏旁白声音设定
| 游戏 | 旁白定位 | 语速 | 音调 | 情感基调 | TTS 音色建议 |
|------|---------|------|------|---------|------------|
| **林夏** | 纪录片旁白——克制、观察、不评判 | 0.85 | 标准 | 平静中带一丝悲悯 | 女声,成熟,普通话标准 |
| **一念** | 内心独白——像你自己的声音 | 0.80 | 略低 | 亲密、缓慢、有重量 | 女声,接近耳语但清晰 |
| **守夜** | 对讲机预录提示——老同事留的消息 | 0.90 | 标准偏低 | 实务、疲惫、温和 | 男声,中年感,略带沙哑 |
| **候诊** | 时间本身的声音——无情感 | 0.95 | 标准 | 完全中性,精确 | 女声或男声均可,播音腔 |
| **遗声** | 手机系统语音——像 Siri 但更温 | 0.92 | 略高 | 冷静,有礼,略机械 | 女声,偏年轻,咬字清晰 |
| **浮** | 梦里的声音——几乎不存在 | 0.75 | 略低,柔 | 极轻,近乎飘渺 | 女声,气声成分多,偏空灵 |
---
## 四、《林夏》旁白脚本
> 旁白定位:纪录片旁白。不替林夏说话,只描述发生了什么、房间里的状态。
### 开场
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_intro.mp3` | 今晚九点。你是林夏,二十四岁,在北京独居。手机屏幕碎了,但还能用。今晚有十六条消息,你来决定接谁,不接谁。单击接听或回复,双击拒接或忽略。 | 整个游戏唯一一次说明操作,语气平稳 |
### 消息01 · 妈妈来电
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg01_narrate.mp3` | 晚上九点零三分。手机震动,铃声响起。屏幕显示:妈妈。 | 平静陈述,稍有停顿感 |
| `linxia_msg01_tap.mp3` | 你接了。妈妈的声音从电话里传来,带着一点日常的关心,问你今天怎么样,吃了没有。你说还好。挂掉之后,房间里又安静了。 | 温和,轻描淡写 |
| `linxia_msg01_dt.mp3` | 铃声响了又停。妈妈没再打来。 | 简短,留白 |
### 消息02 · 小美语音×3
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg02_notify.mp3` | 九点一刻。微信震了两下。小美给你发来三条语音消息。 | — |
| `linxia_msg02_notify_dt.mp3` | 你把手机屏幕朝下放着。消息还在那里,但你不想听。 | — |
| `linxia_msg02_play_narrate.mp3` | 小美说了很多,大意是关于上次那件事,她一直想跟你说清楚,她说她只是太担心你了。最后她问:你最近还好吗? | 概括式,不朗读原话 |
| `linxia_msg02_play_tap.mp3` | 你回了消息。小美很快回说:谢谢你愿意回我。 | — |
| `linxia_msg02_play_dt.mp3` | 你听完了,但没有回。消息就这样放着。 | — |
### 消息03 · 外卖来电
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg03_narrate.mp3` | 九点二十分。来电话了,是一个陌生号码——是外卖小哥,说你的外卖到了,问你在哪层。 | — |
| `linxia_msg03_tap.mp3` | 你接了,告诉他楼层,外卖放门口就好。过了一会儿,门铃响了,外卖到了。 | — |
| `linxia_msg03_dt.mp3` | 你没接。不知道外卖最后怎么样了。 | — |
### 消息04 · 阿哲语音
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg04_notify.mp3` | 九点半。微信震了一下。阿哲——你的前男友——给你发来一条语音。 | "前男友"三字平静说出,不带评判 |
| `linxia_msg04_notify_dt.mp3` | 你没有打开。阿哲的消息放在那里。 | — |
| `linxia_msg04_play_narrate.mp3` | 阿哲说,他有一件东西落在你这里了,问你方便不方便他过来拿一下。语气很平,不像是有别的意思。 | — |
| `linxia_msg04_play_tap.mp3` | 你回了条消息说可以。阿哲说谢谢,说到时候再联系。你放下手机,有点说不清的感觉。 | — |
| `linxia_msg04_play_dt.mp3` | 你没有回。消息就这样。 | — |
### 消息05 · HR王姐
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg05_notify.mp3` | 九点四十五分。HR王姐发来语音,是关于下个月调岗的事,问你有没有空聊一聊。 | — |
| `linxia_msg05_notify_dt.mp3` | 你把消息标记了已读,回头再说。 | — |
| `linxia_msg05_play_narrate.mp3` | 王姐说有个岗位的变动,你可能会受到影响,想提前跟你说一声,让你有个准备。语气是友善的,像是帮你。 | — |
| `linxia_msg05_play_tap.mp3` | 你回了消息说谢谢告知,改天约时间聊。王姐说好的。 | — |
| `linxia_msg05_play_dt.mp3` | 你没有回。这件事先放一放。 | — |
### 消息06 · 安安邀约
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg06_notify.mp3` | 晚上十点。安安发来语音,她说她们今晚在酒吧,问你要不要过来,说来了就是了,不用提前说。 | — |
| `linxia_msg06_notify_dt.mp3` | 你没有打开消息。今晚不想动。 | — |
| `linxia_msg06_play_narrate.mp3` | 安安的声音很热闹,背景里有音乐。她说来嘛来嘛,好久没见了,就几个人,很轻松的那种。 | — |
| `linxia_msg06_play_tap.mp3` | 你回了条消息说行,等等到。安安说耶!你站起来换了件外套,但后来不知道为什么又坐下了。 | 后半句稍慢,有一点停顿 |
| `linxia_msg06_play_dt.mp3` | 你回了消息说今晚状态不太好,下次吧。安安说没事,下次一定。 | — |
### 消息07 · 寝室群×5
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg07_notify.mp3` | 晚上十点一刻。寝室群活了,连着震了好几下,是大学室友们在聊。你们已经很久没联系了。 | — |
| `linxia_msg07_notify_dt.mp3` | 你把群消息静音了。不是今晚。 | — |
| `linxia_msg07_play_narrate.mp3` | 她们在聊上周聚会的事,有人说想你了,有人问你最近怎么样,有人说快来啊。那种熟悉的语气,你有点想念。 | — |
| `linxia_msg07_play_tap.mp3` | 你发了一条消息,说最近还好,想你们了。群里一下子热闹起来,大家说你终于出现了。你看着那些消息,嘴角动了一下。 | 结尾"嘴角动了一下"轻描淡写 |
| `linxia_msg07_play_dt.mp3` | 你看完了,但没有回复。你把手机放下。那种热闹是她们的,你在这边。 | — |
### 消息08 · 未知号码
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg08_narrate.mp3` | 晚上十点半。手机响了,来电显示:未知号码。铃声有点不一样,低沉的那种。 | "低沉"略重 |
| `linxia_msg08_tap.mp3` | 你接了。里面没有声音,只有轻微的呼吸声,然后断了。你不知道是谁,心跳加快了一下。 | 节奏略慢,有悬疑感 |
| `linxia_msg08_dt.mp3` | 未知号码,不接。铃声停了。 | 简短 |
### 消息09 · 小美醉酒×4
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg09_notify.mp3` | 十点四十五分。小美又发来消息,连续四条。语音很长,而且发送时间是一条接一条。 | 语速可略快,传递焦虑感 |
| `linxia_msg09_notify_dt.mp3` | 消息放那里,你没有打开。 | — |
| `linxia_msg09_play_narrate.mp3` | 小美喝多了,说话有点不清楚,但每一句都很认真。她说她一直有件事想跟你说:那年她告诉了别人,是她不对,她知道,她一直很愧疚,她说她怕你恨她。 | 这段要有重量,不急 |
| `linxia_msg09_play_tap.mp3` | 你打了几个字发过去。小美隔了很久才回,说谢谢你,然后说她要先睡了,明天聊。你把手机放下。 | — |
| `linxia_msg09_play_dt.mp3` | 你没有回。这件事太重了,不是今晚能说清楚的。 | — |
### 消息10 · 妈妈文字
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg10_narrate.mp3` | 深夜十一点刚过。妈妈发来一条文字消息,就五个字:睡了吗,夏夏。 | "夏夏"两字稍软 |
| `linxia_msg10_tap.mp3` | 你回了:还没呢,妈你睡吧。妈妈说好,早点睡,然后发了个晚安的表情。你盯着那个表情看了一会儿。 | — |
| `linxia_msg10_dt.mp3` | 你没有回妈妈的消息。她那边大概以为你已经睡了。 | — |
### 消息11 · 阿哲关键来电
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg11_narrate.mp3` | 十一点一刻。手机响了,专属铃声——是阿哲打来的。 | — |
| `linxia_msg11_tap.mp3` | 阿哲说他就在附近,问你方不方便他上来取一下那件东西。他的声音平静,但停顿了一下。 | — |
| `linxia_msg11_dt.mp3` | 铃声停了。阿哲没再打来。 | — |
| `linxia_msg11_choice_narrate.mp3` | 他在等你说行不行。 | 非常短,有留白 |
| `linxia_msg11_choice_tap.mp3` | 你说好,上来吧。阿哲说谢谢,说马上到。你放下手机,在房间里站了一会儿。 | — |
| `linxia_msg11_choice_dt.mp3` | 你说今晚不太方便。阿哲沉默了一秒,说行,那之后再说。挂掉了。 | "沉默了一秒"前后要有停顿 |
### 消息12 · 周南初次语音
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg12_notify.mp3` | 十一点半。一个名字出现在屏幕上:周南。你有三年没联系了,他突然发来一条语音。 | — |
| `linxia_msg12_notify_dt.mp3` | 你没有打开周南的消息。三年了,不急这一会儿。 | — |
| `linxia_msg12_play_narrate.mp3` | 周南说:林夏你好,是我周南,不知道你还记不记得,就是突然想起你,也不知道你还在不在这个号,就发一条。他的声音还是老样子,只是低沉了一些。 | 转述口吻,温和 |
| `linxia_msg12_play_tap.mp3` | 你回了:记得,你好啊。消息发出去了,你盯着那两个字,觉得说多说少都不对,就这样。 | — |
| `linxia_msg12_play_dt.mp3` | 你听完了,没有回。也许三年的空白不是一条消息能填上的。 | — |
### 消息13 · 周南长语音×4
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg13_notify.mp3` | 深夜十一点四十五分。周南又发来四条,比刚才长很多。 | — |
| `linxia_msg13_notify_dt.mp3` | 你把手机放到一边了。今晚已经够多了。 | — |
| `linxia_msg13_play_narrate.mp3` | 周南说了他这十年:换了几个城市,做了几份工作,有一段感情,然后结束了。他说他今晚喝了一点酒,就想到你,想到你们高中的那些事,说他一直不知道当年为什么就断了联系。然后他问:你在北京过得好吗? | 这段较长,语速可略缓 |
| `linxia_msg13_play_tap.mp3` | 你想了很久,然后把这段时间的事告诉了周南。说不上好不好,就是在过。他说谢谢你告诉我这些。你们聊了很久。 | — |
| `linxia_msg13_play_dt.mp3` | 你回了说还好,然后问了他现在在哪里。你们就这样聊着,没有太深入,但很平静。 | — |
### 消息14 · 妈妈第二次来电
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg14_narrate.mp3` | 凌晨零点一刻。妈妈又打来电话了,这么晚——她还没睡。 | "这么晚"后有停顿 |
| `linxia_msg14_tap_a.mp3` | 你接了。妈妈说:我就是想再听到你的声音。你们聊了一会儿,说了一些很日常的话,但你都记住了。 | 条件:mom_link≥1 |
| `linxia_msg14_tap_b.mp3` | 你接了。妈妈说:没事,就是问问你睡了没。你说没呢,妈你睡吧。她说想听你的声音。 | 条件:mom_link=0 |
| `linxia_msg14_dt.mp3` | 太晚了,挂掉了。妈妈没有再打来。 | — |
### 消息15 · 时光胶囊
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg15_narrate.mp3` | 凌晨零点半。手机弹出一个系统通知:三年前的今天,你给三年后的自己录了一条语音,今天到了。 | — |
| `linxia_msg15_tap.mp3` | 你听到了三年前的自己的声音。她说:如果你在听这条消息,说明你撑过来了。不管现在怎么样,你撑过来了。然后她停顿了很久,说:辛苦了。 | 最后"辛苦了"三字要有分量,停顿后说 |
| `linxia_msg15_dt.mp3` | 你把那个通知划掉了。也许不是现在。 | — |
### 消息16 · 结局触发点
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_msg16_narrate_a.mp3` | 凌晨两点。妈妈发来一段很长的语音,她说她一直有些话想说给你听。 | 条件:mom_link≥2 |
| `linxia_msg16_narrate_b.mp3` | 凌晨两点。房间很安静,窗外偶尔有车声。今晚就这样了。 | 条件:mom_link<2 |
| `linxia_msg16_tap_a.mp3` | 妈妈说了很多——关于你小时候,关于她一直担心的事,关于她说不出口但一直想说的话。你把手机贴在耳边,听完了。 | 条件:mom_link≥2 |
| `linxia_msg16_tap_b.mp3` | 你就这样坐着,今晚的声音慢慢变少了。 | 条件:mom_link<2 |
| `linxia_msg16_dt.mp3` | 你把手机放到床头,闭上眼睛。今晚就这样了。 | — |
### 结局
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `linxia_ending_return.mp3` | 《回归》。阿哲来了,或者你们说好了见面。不管那个门开不开,今晚你做了一个决定。 | — |
| `linxia_ending_link.mp3` | 《连接》。今晚你和妈妈说了话,不止一次。那种联系很细,但是在。也许明天你会想打电话回去。 | — |
| `linxia_ending_past.mp3` | 《旧路》。周南在深夜找到了你,你们聊了很久。那条从高中就断掉的线,今晚接上了一点点。 | — |
| `linxia_ending_self.mp3` | 《自省》。今晚你听了三年前自己的声音。她说辛苦了,你接受了这句话。这不算小事。 | — |
| `linxia_ending_island.mp3` | 《孤岛》。今晚大部分的消息,你都没有回应。你一个人度过了这个夜晚。这也是一种选择,你心里清楚。 | — |
---
## 五、《一念》旁白脚本
> 旁白定位:你自己的内心独白。不是外部叙述者,是你在黑暗中自己的声音,和七个声音对话。
### 开场
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `yinian_intro.mp3` | 你坐在黑暗里,独自一人。今晚你必须做一个决定——你自己知道是什么。七个声音在等着和你说话。单击,让它说完。双击,让它走。 | 慢,像从睡梦中说话 |
### 7个声音
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `yinian_v01_narrate.mp3` | 第一个声音来了。是三年前的你。那时候你刚做了一个选择,你觉得那是对的。那时候的你说:我们当时想要的,你还记得吗? | — |
| `yinian_v01_tap.mp3` | 你让三年前的自己说完了。她说,你当时很想要那件事。你知道那是真的。 | — |
| `yinian_v01_dt.mp3` | 三年前的声音淡去了。你选择了不看它。 | — |
| `yinian_v02_narrate.mp3` | 第二个声音。是一个你很久没联系的朋友,她留了条语音。她说:你最近怎么样,我有点担心你。很久了,你一直没回。 | — |
| `yinian_v02_tap.mp3` | 你把这份担心收进来了。她还在关心你,这件事是真实的。 | — |
| `yinian_v02_dt.mp3` | 留言消失了。你没有听完。 | — |
| `yinian_v03_narrate.mp3` | 第三个声音。是关于钱的恐惧。它说:你知道你现在能撑多久吗?如果这件事不成,你怎么办?它问得很具体,很冷静。 | 这段可以稍冷、稍急 |
| `yinian_v03_tap.mp3` | 你承认了:这份恐惧是真实的。你带着它,而不是假装它不存在。 | — |
| `yinian_v03_dt.mp3` | 恐惧的声音淡下去了。你知道它还在,但今晚先不看。 | — |
| `yinian_v04_narrate.mp3` | 第四个声音。是一种渴望——想要被人理解,想要有人在身边,想要不那么孤独。它来得轻,但是很深。 | — |
| `yinian_v04_tap.mp3` | 你承认了这份渴望。它是真实的,不是软弱。 | — |
| `yinian_v04_dt.mp3` | 渴望漂走了。你告诉自己,一个人也没什么。 | — |
| `yinian_v05_narrate.mp3` | 第五个声音。是一个你一直没有说出口的道歉。你知道是对谁的,你知道说的是什么。这个声音只是在等你。 | 停顿感,等待感 |
| `yinian_v05_tap.mp3` | 你在黑暗里,说了那句对不起。没有人听见,但你说了。有什么东西松开了。 | "松开了"要有呼吸感 |
| `yinian_v05_dt.mp3` | 道歉的声音还在那里。你没有说。它等着。 | — |
| `yinian_v06_narrate.mp3` | 第六个声音。是今晚那个决定本身。它把两种可能都摆在你面前,不说话,只是等着你看清楚。 | — |
| `yinian_v06_tap.mp3` | 你看了。两条路都有代价,你知道了。这不让选择变得更容易,但让它变得更真实。 | — |
| `yinian_v06_dt.mp3` | 你把眼睛闭上了。还没准备好看那两条路。 | — |
| `yinian_v07_narrate_a.mp3` | 第七个声音,是现在的你。她说:你今晚接住了很多,你没有回避它们。这需要力气。 | 条件:accepted≥5 |
| `yinian_v07_narrate_b.mp3` | 第七个声音,是现在的你。她沉默了很久,然后说:你把它们都推走了。也许你需要的不是答案,是喘息。 | 条件:accepted≤1 |
| `yinian_v07_narrate_c.mp3` | 第七个声音,是现在的你。她说:你把恐惧留下了,但把渴望推走了。你知道这意味着什么吗? | 条件:kept_fear&&!kept_longing |
| `yinian_v07_narrate_d.mp3` | 第七个声音,是现在的你。她说:你接住了一些,放走了一些。这就是你今晚的状态。 | 默认情况 |
| `yinian_v07_tap.mp3` | 你听完了现在的自己说话。她说:不管你今晚做什么决定,你都已经在这里待过了。这算数。 | — |
| `yinian_v07_dt.mp3` | 你没有听完。你觉得你已经知道了你需要知道的。 | — |
### 结局
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `yinian_ending_accept.mp3` | 《接纳》。你今晚接住了很多,你没有选择只是轻装。也许你的决定,就是带着这些往前走。 | — |
| `yinian_ending_release.mp3` | 《出发》。你今晚把大部分都放走了。你想要的也许是轻。轻装,也是一种勇气。 | — |
| `yinian_ending_stuck.mp3` | 《困住》。你接受了恐惧,但推走了渴望。你知道风险,但忘了你想要的是什么。这个决定,也许需要再等一等。 | — |
| `yinian_ending_reconcile.mp3` | 《和解》。你说出了那声道歉。不管今晚的决定是什么,你和自己和解了一点点。这很重要。 | — |
| `yinian_ending_between.mp3` | 《悬在中间》。你接住了一些,放走了一些。你还悬在两个答案之间。也许这就是你今晚真实的状态。 | — |
---
## 六、《守夜》旁白脚本
> 旁白定位:老同事提前录好的对讲机提示。语气像一个见过很多夜班的人在告诉你怎么判断情况。实务、不煽情。
### 开场
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `shouye_intro.mp3` | 凌晨零点。你是这栋楼的夜班保安,独自坐在一楼监控室。你不能离开座位,但你有一部对讲机和一部电话。今晚五点交班。单击,去查那个声音。双击,按兵不动。 | — |
### 8个事件
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `shouye_s01_narrate.mp3` | 凌晨零点十分。地下室传来水声,不大,但是持续的,一直在响。 | — |
| `shouye_s01_tap.mp3` | 你拨了地下室的内线。没人接。你下去看了一眼,是一根管子在漏水,用旧布堵住了。回到监控室,零点二十三分。 | 实务口吻,记录感 |
| `shouye_s01_dt.mp3` | 你听了一会儿。应该不碍事。你把注意力放回到监控屏幕上。 | — |
| `shouye_s02_narrate.mp3` | 零点四十七分。四楼传来争吵声,一男一女,断断续续,能听出来语气很激动,但听不清说什么。 | — |
| `shouye_s02_tap.mp3` | 你拨了四楼的号码。男的接了,说没事,就是聊天,让你别担心。声音还是有点紧,但他们关掉了。你记下来了:四楼,零点五十二分,双方均在场。 | — |
| `shouye_s02_dt.mp3` | 家务事,你管不着。你转过身去继续盯着屏幕。争吵后来停了,你不知道是什么时候。 | — |
| `shouye_s03_narrate.mp3` | 凌晨一点十七分。三楼有脚步声,很轻,走走停停。监控记录:三楼只有七零二亮着灯,住户是老陈,八十岁,独居。 | — |
| `shouye_s03_tap.mp3` | 电话通了,老陈说他在找眼镜,眼镜掉床底下了。你说没事我就是例行确认一下。他说谢谢你小伙子,然后你们聊了几句,关于他年轻时候也做过工程。你挂掉电话,一点二十八分。 | 这段可以稍温暖 |
| `shouye_s03_dt.mp3` | 老人家睡不好,很正常。你记了一笔,继续监控。脚步声后来停了。 | — |
| `shouye_s04_narrate.mp3` | 凌晨两点零三分。大门门铃响了,不是访客系统,是有人在外面按了手动铃,按了两下,然后停了。没有人应答。监控画面:门口没有人了。 | — |
| `shouye_s04_tap.mp3` | 你走到门口,什么都没有,只有一个快递包裹靠在墙边,大概是外卖或者什么。也许是送错了。你把包裹推到角落,拍了张照,两点一刻回到座位。 | — |
| `shouye_s04_dt.mp3` | 无应答,无入侵,记录在案,按流程忽略。你回到屏幕前。 | — |
| `shouye_s05_narrate_a.mp3` | 凌晨两点五十五分。你不知道从什么时候开始,眼皮变得很重……你睡着了一会儿。醒来的时候,你看到监控里,四楼门口放着一个箱子,不知道是谁的,不知道什么时候放的。 | 条件:missed_fight=true |
| `shouye_s05_narrate_b.mp3` | 凌晨两点五十五分。你不知道从什么时候开始,眼皮变得很重……你睡着了一会儿。醒来的时候,监控里一切正常。你记录:三点十二分,短暂失守。 | 条件:missed_fight=false |
| `shouye_s06_narrate.mp3` | 凌晨三点半。你的私人手机响了。是家里的人打来的。这么晚打来——可能是什么事,也可能只是弄错时间了。 | — |
| `shouye_s06_tap.mp3` | 是你妈,她说睡不着,看见你今天发的朋友圈,就想打个电话。你们聊了十分钟,说了些不重要的事。挂掉之后,监控室比之前暖了一点点。 | 结尾"暖了一点点"轻说 |
| `shouye_s06_dt.mp3` | 这么晚不方便。你把手机调成静音,准备待会儿发条消息。你没有发。 | — |
| `shouye_s07_narrate.mp3` | 凌晨四点十二分。屋顶传来动静,断断续续,不像是机械设备,更像是有什么东西在走动。 | — |
| `shouye_s07_tap.mp3` | 你拿着手电上了顶层,推开屋顶的铁门,风吹过来,你看见了天边刚刚开始发白的颜色——还不是黎明,但是快了。然后你看见了一只流浪猫,坐在水箱上看着你。你们看了对方一会儿,然后你下来了。你是今晚唯一上了屋顶的人。 | 这段稍慢,有空间感 |
| `shouye_s07_dt.mp3` | 你记了一笔,继续监控。动静后来停了。 | — |
| `shouye_s08_narrate.mp3` | 五点整。接班的人进来了,你们交接了几句,递了记录本,你背上包走出去。外面已经有一点点亮了。 | — |
### 结局
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `shouye_ending_dawn.mp3` | 《你看到了什么》。今晚你一个人上了屋顶,你看到了那只猫,你看到了天边开始变色。只有你看到了。这算不上什么,但只有你知道。 | — |
| `shouye_ending_solid.mp3` | 《平凡守夜》。你今晚查了应该查的,接了应该接的电话。没有大事,没有遗漏。这栋楼平安了一夜,有一部分是因为你在。 | — |
| `shouye_ending_detached.mp3` | 《这不是我的事》。你今晚保持了距离。声音来了,你记录,你不动。你守住了一夜,但不知道楼里发生了什么。这也是一种守夜的方式。 | — |
| `shouye_ending_oversight.mp3` | 《疏忽》。你今晚漏掉了四楼的事。那个女人在凌晨两点多拎着箱子离开了,你没有注意到。也许和你没有关系。也许有。你不知道。 | — |
| `shouye_ending_ordinary.mp3` | 《普通的夜班》。你做了你能做的,漏掉了一些,照顾到了一些。和所有普通的夜班一样。 | — |
---
## 七、《候诊》旁白脚本
> 旁白定位:时间本身的声音。无任何情感,只陈述事实。播音腔,精确到分钟。
### 开场
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `houzhen_intro.mp3` | 下午一点半,你坐在医院候诊室,等一个检查结果。叫号机已经叫到了八十四号。你是九十四号。单击,把注意力放到外面。双击,退回到自己里面。 | — |
### 9个时间切片
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `houzhen_h01_narrate.mp3` | 叫号机叫到了八十五号。你的号是九十四。等待的时间被切成了一小块一小块的,每一块都很结实。 | — |
| `houzhen_h02_narrate.mp3` | 你左边坐着一个老人。他在看窗外,窗外是停车场,没有什么特别的。但他看得很专注。 | — |
| `houzhen_h02_tap.mp3` | 你看了他一会儿。他感觉到了,朝你点了点头。你也点了点头。没有说话,但有什么东西发生了。 | — |
| `houzhen_h02_dt.mp3` | 你低下头,看手机。屏幕亮着,什么都有,但你没有真的看进去什么。 | — |
| `houzhen_h03_wait.mp3` | 叫号机叫到了八十八号。 | 极短 |
| `houzhen_h03_narrate.mp3` | 你手机上有一条两天前的消息,你当时看到了,但没有回。那个人说:最近怎么样。 | — |
| `houzhen_h03_tap.mp3` | 你打开来,打了几个字。发出去了。不知道他会不会在意这么晚才回。 | — |
| `houzhen_h03_dt.mp3` | 你把手机翻过去,屏幕朝下放在大腿上。等等再说。 | — |
| `houzhen_h04_wait.mp3` | 护士从走廊那头走过来,叫了一个名字,不是你。 | — |
| `houzhen_h04_narrate.mp3` | 对面有个小孩在哭,大约三四岁,蹲在椅子旁边哭。旁边的大人在轻声说什么,但他还是哭。 | — |
| `houzhen_h04_tap.mp3` | 你看着那个孩子。他哭了一会儿就停了,大概是哭够了。你们没有交流,但你陪着他哭完了。 | — |
| `houzhen_h04_dt.mp3` | 你把视线移开了。每个人都有自己的事,你也有你的。 | — |
| `houzhen_h05_wait.mp3` | 叫号机叫到了九十号。还有四个人。 | — |
| `houzhen_h05_narrate.mp3` | 窗外的光线变了。下午的阳光斜进来,在地板上画了一块长方形的亮。你注意到了。 | — |
| `houzhen_h05_tap.mp3` | 你盯着那块光看了一会儿。它在慢慢移动,非常慢,但是在动。时间在过。 | — |
| `houzhen_h05_dt.mp3` | 你闭上眼睛,想了想别的事。 | — |
| `houzhen_h06_wait.mp3` | 叫号机叫到了九十一号。 | — |
| `houzhen_h06_narrate.mp3` | 你的手机响了。是一个你不常打电话的人——是你家里的人。也许只是普通的问候。 | — |
| `houzhen_h06_tap.mp3` | 你接了。对方说:你在哪啊,吃了吗。你说在外面,没事。就这样。挂掉之后候诊室里还是那么安静。 | — |
| `houzhen_h06_dt.mp3` | 你看着屏幕亮了又暗。现在不是时候。等会儿再说。 | — |
| `houzhen_h07_wait.mp3` | 叫号机叫到了九十二号。 | — |
| `houzhen_h07_narrate.mp3` | 你右边的人一直在看同一页的杂志,翻来翻去都是那页。你不确定她在看,还是只是拿着。 | — |
| `houzhen_h07_tap.mp3` | 你朝她看了一眼。你们的眼神碰了一下,然后都移开了。没有什么,就是两个陌生人在等待。 | — |
| `houzhen_h07_dt.mp3` | 你没有看。你在想等会儿检查结果会说什么。 | — |
| `houzhen_h08_narrate.mp3` | 九十三号。就差一个了。 | — |
| `houzhen_h09_narrate.mp3` | 九十四号。是你。你站起来,整理了一下衣服,走向那扇门。 | — |
| `houzhen_h09_tap.mp3` | 你推开门走进去了。门关上了。等待结束了。 | — |
| `houzhen_h09_dt.mp3` | 你停了一秒,深呼吸,然后走进去了。门关上了。 | — |
### 结局(动态,建议浏览器TTS)
> 候诊的结局根据统计数据动态生成,文本每次都不同,**建议保留浏览器TTS**,或预录所有组合(共约8种)。
若要预录所有组合,按以下模板生成:
```
你等了很久。
在这段时间里,你向外走了N次——看向别人,接了电话,留意窗外。 (N=1~5)
/ 你向内走了N次——退回到自己里,先不管别的。 (N=1~5)
/ 你向外走了X次,也向内走了Y次,差不多一半一半。 (X=Y)
你记住了:[事件列表]。
这就是你今天等待的方式。
```
---
## 八、《遗声》旁白脚本
> 旁白定位:手机系统语音,像 Siri。每条录音用元数据(日期、时长、文件名)介绍,然后玩家决定听不听。
### 开场
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `yisheng_intro.mp3` | 你收到了一部手机。手机的主人去世了,一个月前。他留下了很多录音,按时间排列。你不认识他。或者你以为你不认识他。单击,听这条录音。双击,跳过,永远不知道这条说了什么。 | — |
### 15条录音的元数据播报
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `yisheng_r01_narrate.mp3` | 第一条录音。录制时间:三月五日,早上八点。时长:二十二秒。文件名:买橙子。 | 系统感,读出元数据 |
| `yisheng_r02_narrate.mp3` | 第二条录音。三月十二日,下午。时长:三十八秒。文件名:发工资了。 | — |
| `yisheng_r03_narrate.mp3` | 第三条录音。三月十八日,晚上。时长:一分零七秒。文件名:说说工作的事。 | — |
| `yisheng_r04_narrate.mp3` | 第四条录音。四月二日。时长:四十九秒。文件名:吵了一架。 | — |
| `yisheng_r05_narrate.mp3` | 第五条录音。四月十五日,深夜。时长:二十六秒。文件名:电影。 | — |
| `yisheng_r06_narrate.mp3` | 第六条录音。五月三日。时长:一分二十秒。文件名:小林的事。 | — |
| `yisheng_r07_narrate.mp3` | 第七条录音。五月二十日。时长:三十三秒。文件名:小林说的。 | — |
| `yisheng_r08_narrate.mp3` | 第八条录音。六月八日,凌晨。时长:两分十四秒。文件名:结束了。 | — |
| `yisheng_r09_narrate.mp3` | 第九条录音。六月二十日。时长:一分五十秒。文件名:在反思。 | — |
| `yisheng_r10_narrate.mp3` | 第十条录音。七月四日。时长:四十二秒。文件名:有人问起你。 | — |
| `yisheng_r11_narrate.mp3` | 第十一条录音。七月十一日。时长:一分零八秒。文件名:我查了一下。 | — |
| `yisheng_r12_narrate.mp3` | 第十二条录音。八月二日。时长:五十七秒。文件名:给不认识我的人(草稿)。 | — |
| `yisheng_r13_narrate.mp3` | 第十三条录音。九月一日。时长:两分三十八秒。文件名:我有一件事想说。 | — |
| `yisheng_r14_narrate.mp3` | 第十四条录音。十月十五日,录制时间是他去世前三天。时长:三分二十秒。文件名:最后一条,我想讲完整。 | — |
| `yisheng_r15_narrate.mp3` | 还有一条录音,没有标日期。文件名:给不认识我的人(正式版)。这条需要你听了很多条才能解锁。你解锁了。 | — |
### 听完后的旁白(转述录音内容)
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `yisheng_r01_tap.mp3` | 他说:今天买了一袋橙子,是脐橙,比上次的甜很多。建议大家有机会可以试试。就这些。录音结束。 | — |
| `yisheng_r02_tap.mp3` | 他说:今天发工资了。然后列了他这个月的支出清单,很详细——房租、电费、手机月供、还有给他妈买了一件外套。他说最后剩了不多,但够。录音结束。 | — |
| `yisheng_r03_tap.mp3` | 他说他们组里来了个新同事,总是在会议上抢着发言,说了很多他本来准备说的话。他说他当时没说话,他不确定那是不是对的选择。他在录音里问自己:我是懦弱了,还是留有余地?他没有答案。录音结束。 | — |
| `yisheng_r04_tap.mp3` | 他说他今天和那个新同事正面冲突了,是在走廊,两个人都没控制住。他说他说了一句话,事后觉得说得太重了。他在录音里把那句话又说了一遍,想听听是不是真的太重。录音里他沉默了一会儿,然后说:是的,是太重了。录音结束。 | — |
| `yisheng_r05_tap.mp3` | 他说他去看了一部很老的电影,黑白的,在一个小影院,观众只有六个人。他说他中途哭了,但他不确定是因为电影,还是因为电影院里那种空荡荡的感觉。录音结束。 | — |
| `yisheng_r06_tap.mp3` | 他开始讲一个叫小林的朋友。他说小林是他认识最久的人,从小一起长大的,后来各自去了不同的城市,联系越来越少。他说他最近一直在想,那种联系的稀薄,是谁的责任?还是说这就是长大的代价?录音结束。 | — |
| `yisheng_r07_tap.mp3` | 他说小林打电话来了。小林在电话里说了一句话,让他很难受。小林说:感觉你现在不太一样了,变得……不知道,挺难接近的。他说他当时说了没什么,你想多了。他说他那时候应该说:我只是最近状态不好。但他没说。录音结束。 | — |
| `yisheng_r08_tap.mp3` | 他说一段感情结束了,他没有细说是谁,说了有多久,只说:结束了,是我提的,我觉得这是对的,但今晚我睡不着。他说他在厨房坐着,泡了杯茶,但茶凉了也没喝。他说:我只是想把今晚的感觉记下来,不想让它消失。录音结束。 | — |
| `yisheng_r09_tap.mp3` | 他说他一直在想那段感情里他做错了什么。他列了很多条,每一条都很具体。然后他说:但是我说不清,这些错是因为我是这样的人,还是因为那时候我在经历别的事情,所以才这样。他说:这个问题很重要,但我没有答案。录音结束。 | — |
| `yisheng_r10_tap.mp3` | 他说:今天有人跟我提到了你的名字。我以为我早就忘了,但我发现我还记得很多细节。他没有解释"你"是谁。他说:我觉得我应该把这件事录下来,因为我发现我不想忘记。录音结束。 | — |
| `yisheng_r11_tap.mp3` | 他说:我查了你现在在哪里,在做什么。不是恶意,就是想知道。他说他找到了一些信息,知道了你现在的大致状态。他说:你好像过得还不错,我很高兴,也有一点……他停了,然后说:没什么。录音结束。 | — |
| `yisheng_r12_tap.mp3` | 他说:如果你听到这条,你肯定不认识我。我在想,把这些录音留给一个陌生人,有什么意义。然后他说:也许是因为我希望有人知道我活过,而不是认识我的人,而是真正陌生的人——你不需要对我有感情,你只是听了。录音结束。 | — |
| `yisheng_r13_tap.mp3` | 他深吸一口气,说:有一件事我从没对任何人说过。他停了很久,然后说:我有时候觉得活着的意义我不太找得到,不是说我要做什么,只是我不知道我在这里有什么用。他说:我知道这句话很沉,但我必须把它说出来,因为放在心里太久了。然后他说:我今天状态还可以,就是今天有这个力气把它说出来。录音结束。 | 这段台词有情绪重量,需要留白 |
| `yisheng_r14_tap.mp3` | 他说:我知道这是最后一条了。我不是悲观,我只是知道。他说:我想对听这些录音的人说,我不后悔我活过的大多数时候。橙子是甜的,老电影是值得哭的,小林的声音我还记得,那个人,不管怎样,我不后悔爱过。他说:如果你听了很多条,谢谢你。你是陌生人,但你陪着我说完了。录音结束。 | — |
| `yisheng_r15_tap.mp3` | 他说:你好。你不认识我,我也不认识你。但你坐在那里,听完了我这些年说的话。我不知道你是谁,我不知道你们那边现在是白天还是夜晚。我只是想说:谢谢你愿意听一个陌生人说话。这很重要,比你知道的还要重要。录音结束。这是最后一条了。 | — |
### 跳过时的旁白
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `yisheng_skip_generic.mp3` | 你跳过了这条。你永远不会知道这条说了什么了。 | 第1条用,带说明 |
| `yisheng_skip_short.mp3` | 你跳过了。 | 第2-9条用,简短 |
| `yisheng_skip_last.mp3` | 你跳过了第十四条,也就是最后一条。手机里没有更多录音了。 | 第14条跳过专用 |
| `yisheng_skip_r15.mp3` | 你连最后这条也跳过了。 | 第15条跳过专用 |
### 结局
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `yisheng_ending_known.mp3` | 《我认识你》。你听完了他大部分的录音,你听到了那条隐藏的最后一条。你不认识他,但你陪他把该说的都说完了。这已经足够了。 | — |
| `yisheng_ending_stranger.mp3` | 《陌生人》。你跳过了所有关于他和你之间联系的录音。他对你来说,还是一个完全的陌生人。也许这样也好。 | — |
| `yisheng_ending_passing.mp3` | 《路过》。你路过了他的大部分录音,只停留了几条。你知道他存在过,但你不知道他是谁。 | — |
| `yisheng_ending_outline.mp3` | 《轮廓》。你听了他生活的碎片,知道了一个大致的形状。你不知道他所有的事,但你知道了一个人活着的样子。 | — |
| `yisheng_ending_brief.mp3` | 《简短的相遇》。你听了几条,然后停下了。也许你听到了你需要听的,也许你还没准备好听更多。这也是一种结果。 | — |
---
## 九、《浮》旁白脚本
> 旁白定位:梦里的声音。几乎不存在。每个句子都像是从很远的地方说来的,有点延迟。
### 开场
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `fu_intro.mp3` | 你漂浮在某处。不知道是水,还是梦,还是别的什么。声音会漂过来。单击,把手伸过去。双击,看着它走。 | 极慢,像说给将睡未睡的人听 |
### 12个漂流物
| 文件名 | 台词 | 备注 |
|--------|------|------|
| `fu_f01_narrate.mp3` | 第一个。像夏天傍晚的风,带着草的气味,你说不清是哪年夏天的。它停在你身边了。 | — |
| `fu_f01_tap.mp3` | 它停在你手心里,有点暖。 | 短,轻 |
| `fu_f01_dt.mp3` | 你看着它慢慢飘走了。 | — |
| `fu_f02_narrate.mp3` | 第二个。某人的笑声。你认识这个笑,但你说不清是谁的。 | — |
| `fu_f02_tap.mp3` | 笑声停在你耳边,还在回响。 | — |
| `fu_f02_dt.mp3` | 笑声远了。 | 极短 |
| `fu_f03_narrate.mp3` | 第三个。一种说不清楚的遗憾。它没有形状,但是很重。 | — |
| `fu_f03_tap.mp3` | 你把遗憾抱在怀里了。它很重,但你没有放开。 | — |
| `fu_f03_dt.mp3` | 遗憾漂过去了。你呼了口气。 | — |
| `fu_f04_narrate.mp3` | 第四个。很久没有过的安全感。轻轻的,像一床旧被子的气味。 | — |
| `fu_f04_tap.mp3` | 你把它裹在身上了。 | — |
| `fu_f04_dt.mp3` | 安全感飘走了。你看着它走。 | — |
| `fu_f05_narrate.mp3` | 第五个。想哭但没哭出来的那种感觉。像喉咙里卡着什么。 | — |
| `fu_f05_tap.mp3` | 你承认了。那个东西松动了一点点。 | — |
| `fu_f05_dt.mp3` | 它漂走了。喉咙还是有点紧。 | — |
| `fu_f06_narrate.mp3` | 第六个。一首旋律,只有开头几个音,后面的你想不起来了。 | — |
| `fu_f06_tap.mp3` | 你在心里哼了那几个音。后面的还是想不起来,但这几个音够了。 | — |
| `fu_f06_dt.mp3` | 旋律消失了。 | — |
| `fu_f07_narrate.mp3` | 第七个。被需要的感觉。有人曾对你说过你很重要,你还记得那句话。 | — |
| `fu_f07_tap.mp3` | 你把那句话放进口袋里了。 | — |
| `fu_f07_dt.mp3` | 那句话漂走了。 | — |
| `fu_f08_narrate.mp3` | 第八个。一个人的时候的自在。没人看着,没人要你做什么,只是你自己。 | — |
| `fu_f08_tap.mp3` | 你把这份自在收进来了。 | — |
| `fu_f08_dt.mp3` | 它漂走了。也许你现在不需要。 | — |
| `fu_f09_narrate.mp3` | 第九个。害怕。不知道怕什么,就是有点怕。 | — |
| `fu_f09_tap.mp3` | 你承认了:我有点害怕。说出来之后,它小了一点。 | — |
| `fu_f09_dt.mp3` | 你没有看它,害怕漂走了。也许。 | — |
| `fu_f10_narrate.mp3` | 第十个。好奇心。对某件事、某个人、某个地方,你还有好奇。 | — |
| `fu_f10_tap.mp3` | 好奇心跳进你手里了,很活跃。 | — |
| `fu_f10_dt.mp3` | 好奇心漂走了,也许它去找别人了。 | — |
| `fu_f11_narrate.mp3` | 第十一个。疲惫。真实的、身体里的疲惫,它已经跟了你很久了。 | — |
| `fu_f11_tap.mp3` | 你说:我累了。疲惫点了点头,说我知道。 | — |
| `fu_f11_dt.mp3` | 疲惫漂走了。你感觉稍微轻了一点点。 | — |
| `fu_f12_narrate.mp3` | 最后一个。它没有名字。是你漂浮在这里时特有的某种感觉,说不清,但它在。 | 比前面更慢 |
| `fu_f12_tap.mp3` | 你把它揣在怀里了,连同它的没有名字。 | — |
| `fu_f12_dt.mp3` | 你看着它留在了这片水里,这片梦里。 | — |
### 结局(动态,建议浏览器TTS或预录组合)
> 浮的结局文本完全动态(读出玩家保留/放走的清单),共约 2^12=4096 种组合,**建议保留浏览器TTS**。
>
> 若要预录,只需录以下固定句子,运行时拼接:
| 文件名 | 台词 |
|--------|------|
| `fu_ending_open.mp3` | 你漂回来了。 |
| `fu_ending_kept_prefix.mp3` | 你带走了: |
| `fu_ending_released_prefix.mp3` | 你放走了: |
| `fu_ending_close.mp3` | 这就是今天的你。 |
| `fu_item_wind.mp3` | 夏天的风 |
| `fu_item_laugh.mp3` | 笑声 |
| `fu_item_regret.mp3` | 遗憾 |
| `fu_item_safe.mp3` | 安全感 |
| `fu_item_tears.mp3` | 没哭出来的眼泪 |
| `fu_item_melody.mp3` | 那段旋律 |
| `fu_item_needed.mp3` | 被需要的感觉 |
| `fu_item_alone.mp3` | 独处的自在 |
| `fu_item_fear.mp3` | 害怕 |
| `fu_item_curious.mp3` | 好奇心 |
| `fu_item_tired.mp3` | 疲惫 |
| `fu_item_nameless.mp3` | 无名之物 |
| `fu_sep.mp3` | 、(顿号停顿,约200ms静音) |
---
## 十、统计
| 游戏 | 固定台词条数 | 条件分支条数 | 动态/TTS条数 | 合计 |
|------|------------|------------|------------|------|
| 林夏 | 38 | 5 | 0 | **43** |
| 一念 | 16 | 4 | 0 | **20** |
| 守夜 | 20 | 2 | 0 | **22** |
| 候诊 | 22 | 0 | 1(结局) | **22+动态** |
| 遗声 | 34 | 0 | 0 | **34** |
| 浮 | 37 | 0 | 1(结局) | **37+动态** |
| **合计** | **167** | **11** | **2** | **~178** |
---
## 十一、TTS 生成优先级
建议按以下顺序生成,优先保证核心体验:
1. **P0(必须)**intro、所有 narrate、所有 tap/dt 旁白
2. **P1(重要)**:所有 ending
3. **P2(可选)**:条件分支变体(_a/_b
4. **P3(可选)**:浮的动态结局分片
+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`:跳过音频播放等待(快速自动化测试用)
---
*"屏幕坏了,但她的一夜还没结束。"*
-304
View File
@@ -1,304 +0,0 @@
# 音频资源制作技术落地报告
> 调研日期:2026-05-20
> 最后更新:**2026-05-23**VoxCPM2 v5 克隆落地、游戏音频清理对齐)
> 硬件环境:NVIDIA RTX 5090 (32GB VRAM) / CUDA Driver 12.9 / WSL2 Linux
> **项目现行状态**:见 `audio_2/项目状态说明.md`
---
## 一、本机已有能力总览
| 能力域 | 工具 | 状态 | 环境 |
|--------|------|------|------|
| TTS 语音合成 | VoxCPM2 | **运行中** (port **8002**) | `/home/xsl/tts-server/` Python venv |
| TTS 语音合成 | CosyVoice2-0.5B | 已部署,需手动启动 | conda cosyvoice |
| 音乐/铃声生成 | ACE-Step 1.5 | 已部署,需手动启动 | conda acestep |
| 音效/氛围生成 | ACE-Step 1.5 | 同上 | conda acestep |
| 音效/氛围生成 | **Stable Audio Open 1.0** | **已部署** ✅ | conda stableaudio / torch 2.8.0+cu128 |
| 语音识别 (ASR) | SenseVoice Small | 模型已缓存 | HuggingFace cache |
| 语音识别 (ASR) | Whisper Tiny | 模型已缓存 | HuggingFace cache |
| 轻量 TTS | Kokoro-82M | 模型已缓存 | HuggingFace cache |
| 音频处理 | ffmpeg 7.0.2 | 可用 | 系统 PATH |
| 音频分析 | librosa / soundfile / pydub | 可用 | pip |
| 音视频同步 | MuseTalk / LatentSync | 已部署 | conda 环境 |
### 当前产出数据 (《林夏》项目,2026-05-23)
| 类型 | 数量 | 路径 | 生成工具 |
|------|------|------|----------|
| TTS 游戏对白 | **169** 条 | `audio/mp3/tts/*_wav_v1.mp3` | VoxCPM2 v5 克隆 |
| TTS 归档产物 | **177** 条 | `audio_2/tts/v5_cloned/*_wav_v5.mp3` | 同上(含台词表全量 + extra) |
| 参考音色 | **13** 角色 | `audio_2/tts/v4_voice_samples/` + `voice/*_ref.wav` | Voice Design 试听选定 |
| BGM | 4 轨 | `audio/mp3/music/` | ACE-Step 1.5 |
| 角色铃声 | 5 条(游戏引用 4 场景) | `audio/mp3/music/` | ACE-Step 1.5 |
| 叙事音效 | 4 条 | `audio/mp3/sfx/` | ACE-Step 1.5 |
| 环境氛围 | 3 条 | `audio/mp3/ambience/` | ACE-Step 1.5 |
| UI 短音效 | 13 种 | 浏览器内 | Web Audio API |
| **游戏 MP3 合计** | **184** | `audio/mp3/` | 已与 story.js 对齐 |
> 注:8000 端口被 head3d 占用,VoxCPM2 固定 **8002**。旧 `*_mp3_v1.mp3` 与 legacy 目录(`audio/mp3/linxia/``react/`)已清理。
---
## 二、各能力域详细分析
### 2.1 TTS 语音合成
#### 本机已有
| 工具 | 模型大小 | VRAM | 中文质量 | 声音克隆 | 延迟 | 特点 |
|------|---------|------|---------|---------|------|------|
| **VoxCPM2** | 4.7GB | ~6GB | A | 零样本 (5-30s参考音频) | ~1s/句 | FastAPI`/home/xsl/tts-server/`port **8002**;支持 Style Control + Voice Design |
| **CosyVoice2-0.5B** | 5.3GB | ~8GB | A+ | 零样本 | ~2s/句 | WebUI,多语言,语速可控,阿里开源 |
| **Kokoro-82M** | <200MB | <2GB (可CPU) | B (中文社区扩展) | 不支持 | <0.3s | 超轻量,英文为主,适合实时场景 |
| **Edge-TTS** | 0 (云端) | 0 | B+ | 不支持 | 网络延迟 | 微软在线服务,免费,多音色 |
**当前主链路**:《林夏》已落地 **Voice Design 试听 → 克隆锚定 → Style Control 表演**
```
03_gen_voice_samples.py (v4 试听,每角色 5 变体)
→ 人工选定参考 wav
→ 04_gen_tts_clone.py (v5 全量克隆)
→ audio/mp3/tts/*_wav_v1.mp3 (游戏加载)
```
API`POST /v1/speech/styled`(有 voice_id = 克隆+表演;无 voice_id = 纯 Voice Design)。
备用:CosyVoice2 → Edge-TTS 兜底。
#### 推荐部署到本地
| 工具 | Stars | 模型大小 | VRAM | 为什么值得部署 | 部署难度 |
|------|-------|---------|------|--------------|---------|
| **F5-TTS** | 14.5k | ~3GB | 8GB | Flow-matching 架构;零样本克隆质量最高;MIT 开源 | 低 (pip install) |
| **ChatTTS** | 39k | ~2GB | 4GB | 专为对话设计;自然笑声/停顿/语气词;适合互动叙事 | 低 (pip install) |
**部署优先级建议**
1. **ChatTTS** — 对话自然度最高,适合即兴/随机对白生成
#### 线上服务
| 服务 | 中文质量 | 声音克隆 | 延迟 | 价格 | 适用场景 |
|------|---------|---------|------|------|---------|
| **MiniMax Speech 2.8** | S | 支持 (30s参考) | <250ms | 按字符计费 | 实时语音交互、低延迟场景 |
| **豆包 TTS 2.0** (字节) | S | 支持 (Seed-ICL) | ~1s | 火山引擎计费 | 中文情感表达最强,指令控制情绪 |
| **通义 TTS** (阿里) | A+ | 支持 (CosyVoice) | 标准 | 免费试用额度 | 与本机 CosyVoice 生态互通 |
| **ElevenLabs** | A | 专业级 | ~100ms | $5-99/月 | 多语言、API 最成熟、配套 SFX |
| **百度语音合成** | A | 有限 | 标准 | Token 计费 | 最老牌、REST 接入最简单 |
---
### 2.2 音乐制作
#### 本机已有
| 工具 | 模型大小 | VRAM | 能力 | 当前产出 |
|------|---------|------|------|---------|
| **ACE-Step 1.5** | 9.4GB (60个checkpoint) | ~8GB | 文本→音乐/铃声/氛围;支持歌词+人声;20s 生成 4 分钟音频;seed 可复现 | 4轨 BGM + 9 条铃声 |
**当前配置**:已针对 RTX 5090 修复 cuBLAS 兼容性 (`cublaslt`)turbo 模式。
#### 推荐部署到本地
| 工具 | Stars | VRAM | 为什么值得部署 | 适用场景 |
|------|-------|------|--------------|---------|
| **DiffRhythm 2** | 新项目 | ~8GB | 最快:10 秒生成 4:45 歌曲;精确歌词对齐;Latent Diffusion 架构 | 快速迭代 BGM 候选 |
| **YuE (乐)** | Apache 2.0 | 8GB(量化)-80GB(全) | 双 LLaMA 架构;完整歌曲(人声+伴奏同步);最接近 Suno 的开源方案 | 需要完整歌曲(含唱) |
| **MusicGen (Meta)** | CC BY-NC | ~12GB fp16 | 研究级纯器乐生成;立体声输出;Loop 素材可控性强 | 纯器乐 BGM / 素材 |
| **Stable Audio Open 1.5** | 已 clone | ~12GB | 47s 立体声 44.1kHzSFX 和音乐兼顾;代码已在本机 | 音效与短音乐片段 |
**部署优先级建议**
1. **Stable Audio Open 1.5** — 代码已在本机 (`/home/xsl/tools/stable-audio-tools/`),只需下载模型权重 + `pip install`,马上可用
2. **DiffRhythm 2** — 速度碾压 ACE-Step,适合大量迭代试听
3. **YuE** — 32GB VRAM 可跑量化版,适合需要有歌词人声的完整歌曲
#### 线上服务
| 服务 | 人声 | 中文歌词 | 时长 | 价格 | 特点 |
|------|------|---------|------|------|------|
| **Suno v5** | 有 | 支持 | ~4min | 免费 300 首/月 | 品质最高;版权诉讼风险 |
| **Udio** | 有 | 支持 | ~4min | 免费额度 | 与 Suno 竞品;已暂停下载功能 |
| **网易天音** | 有 | 中文原生 | 可变 | 按需 | 国内平台,版权更清晰 |
---
### 2.3 音效生成
#### 本机已有
| 工具 | 能力 | 当前产出 |
|------|------|---------|
| **ACE-Step 1.5** | 氛围类音效 (>=5s):心跳、呼吸、雨声、敲门、猫叫、水壶、环境氛围等 | 18 条 SFX + 6 条氛围 |
| **ffmpeg** | 后期处理:混音叠加、底噪叠加、格式转换、音量调节 | 全流程 |
| **Web Audio API** (游戏内) | 13 种 UI 短音效:合成叮/咚/滴/滑动音,无需外部文件 | 实时合成 |
**限制**ACE-Step 擅长氛围/音乐类音效,不擅长短促精确的 UI 音效(<1s 的 click/beep)。
#### 推荐部署到本地
| 工具 | VRAM | 为什么值得部署 | 适用场景 |
|------|------|--------------|---------|
| **Stable Audio Open 1.5** | ~12GB | 音效生成品质最高的开源方案;44.1kHz 立体声;47s 时长 | 高品质 Foley、环境音 |
| **AudioLDM 2** | 8-16GB | 全能型(SFX + 音乐 + 语音);48kHz;多种模型尺寸 | 短音效、过渡音 |
**部署优先级建议**
1. **Stable Audio Open 1.5** — 同上,已有代码,补模型即可
2. **AudioLDM 2** — pip 安装,轻量推理,适合补充短促音效
#### 线上服务
| 服务 | 时长上限 | 价格 | 特点 |
|------|---------|------|------|
| **ElevenLabs SFX v2** | 30s | 免费额度 | 制作级品质,支持循环,版税免费 |
| **Stable Audio 2.5** (API) | 3min | 按量 | 音频修复/inpainting,商业安全 |
---
### 2.4 语音克隆 / 变声
#### 本机已有
VoxCPM2 和 CosyVoice2 均内置零样本声音克隆。《林夏》**13 角色**参考库(2026-05-23 选定):
```
audio_2/tts/v4_voice_samples/ # 每角色 1 条选定 wav/mp3
voice/
linxia/linxia_ref.wav linxia_v05
mom/mom_ref.wav mom_v01
azhe/azhe_ref.wav azhe_v03(男声)
xiaomei/xiaomei_ref.wav xiaomei_v05
xiaomei/xiaomei_drunk_ref.wav xiaomei_drunk_v05
zhounan/zhounan_ref.wav zhounan_v05(男声)
hr/ anan/ dorm_a/ dorm_b/ dorm_c/
delivery/delivery_ref.wav delivery_v04(男声)
linxia/self_3y_ref.wav self_3y_v03
```
批量脚本:`audio_2/scripts/04_gen_tts_clone.py` · 清单:`audio_2/tts/v5_cloned/clone_manifest.tsv`
#### 推荐部署到本地
| 工具 | Stars | 为什么值得 | 适用场景 |
|------|-------|----------|---------|
| **OpenVoice V2** | 36k | 单条音频秒级克隆;跨语言(克隆英文声 → 输出中文)| 快速原型、临时角色 |
| **RVC v2** | 36k | 实时变声;10 分钟数据可训练;社区模型库庞大 | 歌声转换、直播变声 |
| **Seed-VC** | 新 | 零样本相似度最高;解决音色泄漏问题;支持唱歌 | 高精度声音复刻 |
**部署优先级**Seed-VC > OpenVoice V2
---
### 2.5 语音识别 (ASR) — 辅助能力
| 工具 | 状态 | 用途 |
|------|------|------|
| **SenseVoice Small** | 模型已缓存 | 中文 ASR,可用于语音标注、字幕生成 |
| **Whisper Tiny** | 模型已缓存 | 多语言 ASR 兜底 |
---
## 三、推荐部署路线图
### ✅ 已完成部署(2026-05-20
| 序号 | 工具 | 路径 | 模型 | torch |
|------|------|------|------|-------|
| 1 | **Stable Audio Open 1.0** | `/home/xsl/tools/stable-audio-tools/` | `/home/xsl/models/stable-audio-open/` | 2.8.0+cu128 (stableaudio) |
**注:全部使用 CUDA 12.8 编译版 torch,支持 RTX 5090 (sm_120)**。
PyTorch wheel 来源:`https://mirrors.aliyun.com/pytorch-wheels/cu128/`
#### 启动命令
```bash
# Stable Audio Open — Gradio WebUI
conda activate stableaudio
cd /home/xsl/tools/stable-audio-tools
python run_gradio.py \
--model-config /home/xsl/models/stable-audio-open/model_config.json \
--ckpt-path /home/xsl/models/stable-audio-open/model.safetensors
```
### 下一优先级 — 待部署
| 序号 | 工具 | 操作 | 预计耗时 | VRAM |
|------|------|------|---------|------|
| 4 | **ChatTTS** | `pip install ChatTTS` → 下载模型 | 30min | 4GB |
### 按需部署
| 序号 | 工具 | 场景 |
|------|------|------|
| 5 | **DiffRhythm 2** | 需要快速迭代大量 BGM 候选时 |
| 6 | **AudioLDM 2** | 需要精确短音效生成时 |
| 7 | **YuE** | 需要完整中文歌曲(人声+伴奏)时 |
| 8 | **OpenVoice V2** | 需要快速跨语言声音克隆时 |
---
## 四、能力矩阵
```
本机已有 待部署 线上服务
┌───────────────┐ ┌────────────┐ ┌────────────┐
TTS │ VoxCPM2 │ │ ChatTTS │ │ MiniMax │
语音合成 │ CosyVoice2 │ │ F5-TTS │ │ 豆包 TTS │
│ Kokoro-82M │ │ │ │ ElevenLabs │
│ │ │ │ │ 通义 TTS │
└───────────────┘ └────────────┘ └────────────┘
┌───────────────┐ ┌────────────┐ ┌────────────┐
音乐生成 │ ACE-Step │ │ DiffRhythm │ │ Suno v5 │
│ │ │ YuE │ │ Udio │
│ │ │ MusicGen │ │ │
└───────────────┘ └────────────┘ └────────────┘
┌───────────────┐ ┌────────────┐ ┌────────────┐
音效生成 │ ACE-Step │ │ AudioLDM 2 │ │ ElevenLabs │
│ StableAudio ✅│ │ │ │ StableAudio│
│ Web Audio │ │ │ │ API │
│ ffmpeg │ │ │ │ │
└───────────────┘ └────────────┘ └────────────┘
┌───────────────┐ ┌────────────┐ ┌────────────┐
声音克隆 │ VoxCPM2 │ │ Seed-VC │ │ ElevenLabs │
│ CosyVoice2 │ │ OpenVoice │ │ MiniMax │
│ │ │ RVC v2 │ │ 豆包 │
└───────────────┘ └────────────┘ └────────────┘
┌───────────────┐
ASR │ SenseVoice │ (辅助能力,非生产主线)
语音识别 │ Whisper │
└───────────────┘
```
---
## 五、VRAM 并行策略
RTX 5090 (32GB) 可同时运行的组合:
| 组合 | 占用 VRAM | 场景 |
|------|----------|------|
| VoxCPM2 (6G) + ACE-Step (8G) | ~14GB | TTS + 音乐同时生产 |
| VoxCPM2 (6G) + Stable Audio (12G) | ~18GB | TTS + 高品质音效 |
| CosyVoice2 (8G) + ChatTTS (4G) | ~12GB | 双 TTS 引擎对比试听 |
| 单独 YuE 量化版 | ~8-24GB | 完整歌曲生成(独占) |
---
## 六、针对《林夏》项目的升级建议
| 现状 | 升级方案 | 收益 |
|------|---------|------|
| v5 克隆已部署,待 playtest | 实机听感迭代 → 微调 `voice_style_prompts.py` 或重选 v4 参考 | 各角色区分度与情感准确度 |
| ACE-Step 生成叙事音效 | → Stable Audio Open 1.5 补充精细音效 | 44.1kHz 立体声,电话底噪/UI 音效品质更高 |
| 结局仅语音 + 字幕 | → ACE-Step/DiffRhythm 生成结局专属 BGM | 5 个结局各有独立氛围音乐,沉浸感提升 |
---
## 七、文档索引(保持一致性)
| 文档 | 用途 |
|------|------|
| `林夏.md` §7 | 设计纲要:资产分类、角色配置、文件结构 |
| `执行文档_音频素材制作.md` | 操作步骤:环境、流程、后期、QA |
| `audio_2/项目状态说明.md` | **现行状态**:端口、目录、命令、选定音色表 |
| `audio_2/角色语音风格提示词.md` | Voice Design / 表演修饰参考 |
| `audio/batch_tts_voxcpm.py` | 台词表 `LINES` + `ROLES` 语速 |