v2收尾:手势升级 + 暂停退出 + 结局语音化 + 分支补全 + 12条新音频
gestures.js: 新增长按(1s)、两指长按(暂停)、三指点击(退出),支持多指检测 engine.js: REPLY_COUNT变量、长按重播/状态广播、暂停恢复退出机制、 选择提示音、结局台词预录音频替换TTS、内容预警+心理热线 story.js: MSG-10/11 follow-up补全down(沉默)选项 index.html: 操作指南添加长按/安全手势、内容预警文字 batch_tts_voxcpm.py: 新增12条台词(结局旁白+系统提示+分支NPC反应) audio/mp3/tts: 12个VoxCPM2生成的林夏声线MP3 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -421,6 +421,30 @@ LINES = [
|
||||
("v2_linxia_generic_cold_fine", "linxia", "行吧。"),
|
||||
("v2_linxia_generic_curious", "linxia", "嗯?"),
|
||||
("v2_linxia_generic_then", "linxia", "然后呢?"),
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# v2 收尾:结局旁白 + 系统提示 + 分支补全
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
# ── 结局旁白(林夏轻声自语) ──
|
||||
("v2_ending_a_linxia", "linxia", "明天再说。"),
|
||||
("v2_ending_b_linxia", "linxia", "寄出去了。"),
|
||||
("v2_ending_c_linxia", "linxia", "今晚就这样。"),
|
||||
("v2_ending_d_linxia", "linxia", "早上好。"),
|
||||
("v2_ending_e_linxia", "linxia", "好。"),
|
||||
|
||||
# ── 系统语音提示(林夏声线) ──
|
||||
("v2_sys_wait_reply", "linxia", "等待回复。"),
|
||||
("v2_sys_paused", "linxia", "游戏已暂停。单击继续,三指点击退出。"),
|
||||
("v2_sys_content_warning", "linxia",
|
||||
"本游戏涉及孤独、家庭关系等情感话题,请在舒适的状态下体验。"),
|
||||
("v2_sys_hotline", "linxia",
|
||||
"如需倾诉,全国心理援助热线,400-161-9995。"),
|
||||
|
||||
# ── 分支补全:MSG-10/11 沉默路径 NPC 反应 ──
|
||||
("v2_msg10_rw_react_silent", "mom", "那早点睡啊。"),
|
||||
("v2_msg10_call_react_down", "mom", "没事就好。早点睡。"),
|
||||
("v2_msg11_r3_react_silent", "azhe", "行吧,那我不来了。"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# 将新生成的 WAV 文件转为 MP3 并复制到游戏 tts 目录
|
||||
set -e
|
||||
|
||||
RAW_DIR="/home/xsl/blind/audio/00_raw/tts"
|
||||
OUT_DIR="/home/xsl/blind/audio/mp3/tts"
|
||||
|
||||
NEW_FILES=(
|
||||
"v2_ending_a_linxia_wav_v1"
|
||||
"v2_ending_b_linxia_wav_v1"
|
||||
"v2_ending_c_linxia_wav_v1"
|
||||
"v2_ending_d_linxia_wav_v1"
|
||||
"v2_ending_e_linxia_wav_v1"
|
||||
"v2_sys_wait_reply_wav_v1"
|
||||
"v2_sys_paused_wav_v1"
|
||||
"v2_sys_content_warning_wav_v1"
|
||||
"v2_sys_hotline_wav_v1"
|
||||
"v2_msg10_rw_react_silent_wav_v1"
|
||||
"v2_msg10_call_react_down_wav_v1"
|
||||
"v2_msg11_r3_react_silent_wav_v1"
|
||||
)
|
||||
|
||||
ok=0
|
||||
fail=0
|
||||
|
||||
for name in "${NEW_FILES[@]}"; do
|
||||
src="${RAW_DIR}/${name}.wav"
|
||||
dst="${OUT_DIR}/${name}.mp3"
|
||||
|
||||
if [ ! -f "$src" ]; then
|
||||
echo "[SKIP] $name.wav not found"
|
||||
((fail++))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -f "$dst" ]; then
|
||||
echo "[EXISTS] $name.mp3"
|
||||
((ok++))
|
||||
continue
|
||||
fi
|
||||
|
||||
echo -n "[CONVERT] $name ... "
|
||||
ffmpeg -y -i "$src" -codec:a libmp3lame -b:a 128k -ar 24000 -ac 1 "$dst" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "ok ($(du -h "$dst" | cut -f1))"
|
||||
((ok++))
|
||||
else
|
||||
echo "FAILED"
|
||||
((fail++))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done: $ok ok, $fail failed"
|
||||
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.
@@ -122,6 +122,14 @@
|
||||
<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>
|
||||
|
||||
@@ -130,6 +138,10 @@
|
||||
建议关灯 · 戴上耳机<br>
|
||||
按你的节奏来
|
||||
</div>
|
||||
<div class="tip" style="margin-top:20px;color:#333;">
|
||||
本游戏涉及孤独、家庭关系等情感话题<br>
|
||||
请在舒适的状态下体验
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="caption"></div>
|
||||
|
||||
+127
-16
@@ -6,6 +6,7 @@
|
||||
// 新增状态:
|
||||
// responding — 林夏说话中(打字音效 + 语音播放)
|
||||
// reacting — NPC反应播放中
|
||||
// paused — 游戏暂停(两指长按触发)
|
||||
|
||||
class GameEngine {
|
||||
constructor() {
|
||||
@@ -20,6 +21,7 @@ class GameEngine {
|
||||
ZHOUNAN_SHARE: false,
|
||||
SELF_RECORD: false,
|
||||
UNREAD: 0,
|
||||
REPLY_COUNT: 0,
|
||||
};
|
||||
|
||||
this.profile = new ProfileTracker();
|
||||
@@ -35,6 +37,9 @@ class GameEngine {
|
||||
this._pendingResolve = null; // 用于中断语音播放的 resolve
|
||||
this._skipRequested = false; // 跳过标志
|
||||
|
||||
// 暂停系统
|
||||
this._pausedState = null; // 暂停前的状态
|
||||
|
||||
this.gest = null;
|
||||
}
|
||||
|
||||
@@ -49,10 +54,11 @@ class GameEngine {
|
||||
idle: '',
|
||||
notification: '单击 播放 · 双击 跳过',
|
||||
ringing: '单击 接听 · 左滑 拒接',
|
||||
playing: '双击 跳过',
|
||||
playing: '双击 跳过 · 长按 重播',
|
||||
choosing: '→温暖 ←冷淡 ↑追问 ↓沉默',
|
||||
responding: '双击 跳过',
|
||||
reacting: '双击 跳过',
|
||||
paused: '单击 继续 · 三指点击 退出',
|
||||
ending: '',
|
||||
};
|
||||
el.textContent = H[s] || '';
|
||||
@@ -65,17 +71,24 @@ class GameEngine {
|
||||
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('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 });
|
||||
@@ -334,6 +347,7 @@ class GameEngine {
|
||||
this.state = 'choosing';
|
||||
SFX_UI.choiceReady();
|
||||
Haptic.confirm();
|
||||
Snd.playVoice([`${T}/v2_sys_wait_reply_wav_v1.mp3`]);
|
||||
this._startReminder();
|
||||
}
|
||||
|
||||
@@ -369,6 +383,7 @@ class GameEngine {
|
||||
// 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);
|
||||
@@ -516,6 +531,13 @@ class GameEngine {
|
||||
|
||||
_onTap() {
|
||||
const s = this.state;
|
||||
|
||||
// 暂停中 → 恢复
|
||||
if (s === 'paused') {
|
||||
this._onResume();
|
||||
return;
|
||||
}
|
||||
|
||||
if (s === 'ringing') {
|
||||
this._answerCall(this.currentMsg);
|
||||
} else if (s === 'notification') {
|
||||
@@ -530,6 +552,8 @@ class GameEngine {
|
||||
_onDoubleTap() {
|
||||
const s = this.state;
|
||||
|
||||
if (s === 'paused') return;
|
||||
|
||||
if (s === 'ringing') {
|
||||
this._rejectCall(this.currentMsg);
|
||||
return;
|
||||
@@ -579,6 +603,8 @@ class GameEngine {
|
||||
_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);
|
||||
@@ -602,6 +628,83 @@ class GameEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 长按:重播 / 状态广播 ──
|
||||
_onLongPress() {
|
||||
const s = this.state;
|
||||
|
||||
if (s === 'paused' || s === 'ending') return;
|
||||
|
||||
Haptic.confirm();
|
||||
|
||||
// playing/responding/reacting/choosing → 重播当前消息音频
|
||||
if ((s === 'playing' || s === 'responding' || s === 'reacting' || s === 'choosing') && this.currentMsg) {
|
||||
this._interruptVoice();
|
||||
if (this.currentMsg.audio?.length > 0) {
|
||||
this._playVoiceAsync(this.currentMsg.audio);
|
||||
} else if (this.currentMsg.type === 'wechat_text' && this.currentMsg.text) {
|
||||
TTS.speak(this.currentMsg.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// idle/notification → 广播未读数
|
||||
TTS.speak(`当前未读${this.vars.UNREAD}条消息`);
|
||||
}
|
||||
|
||||
// ── 暂停 ──
|
||||
_onPause() {
|
||||
const s = this.state;
|
||||
if (s === 'ending' || s === 'paused') return;
|
||||
|
||||
this._pausedState = s;
|
||||
this._interruptVoice();
|
||||
this._clearReminder();
|
||||
Haptic.stop();
|
||||
this.state = 'paused';
|
||||
Snd.playVoice([`${T}/v2_sys_paused_wav_v1.mp3`]);
|
||||
}
|
||||
|
||||
// ── 恢复 ──
|
||||
_onResume() {
|
||||
Snd.stopVoice();
|
||||
const prev = this._pausedState || 'idle';
|
||||
this._pausedState = null;
|
||||
|
||||
// 语音无法断点续播,根据暂停前状态决定恢复行为
|
||||
if (prev === 'choosing' && this._currentChoices) {
|
||||
this.state = 'choosing';
|
||||
SFX_UI.choiceReady();
|
||||
Haptic.confirm();
|
||||
this._startReminder();
|
||||
} else if (prev === 'notification' || prev === 'ringing') {
|
||||
this.state = prev;
|
||||
this._startReminder();
|
||||
} else {
|
||||
// playing/responding/reacting → 无法续播,推进到下一条或回到选择
|
||||
if (this._currentChoices) {
|
||||
this._enterChoosing(this._currentChoices);
|
||||
} else {
|
||||
this._proceedToNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 退出 ──
|
||||
_onExit() {
|
||||
if (this.state !== 'paused') return;
|
||||
|
||||
this._endingTriggered = true;
|
||||
this._interruptVoice();
|
||||
// 退出不需要特殊音频,直接返回开始页
|
||||
setTimeout(() => {
|
||||
const startEl = document.getElementById('start');
|
||||
if (startEl) {
|
||||
startEl.style.opacity = '1';
|
||||
startEl.style.display = 'flex';
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// 结局系统
|
||||
// ══════════════════════════════════════════
|
||||
@@ -611,30 +714,29 @@ class GameEngine {
|
||||
|
||||
const v = this.vars;
|
||||
|
||||
// 结局E(隐藏)
|
||||
// 结局E(隐藏)— 优先级 1
|
||||
if (v.ZHOUNAN_DEPTH >= 2 && v.ZHOUNAN_SHARE &&
|
||||
(this.profile.label === 'engagement' || this.profile.label === 'nostalgia')) {
|
||||
return this._triggerEnding('E');
|
||||
}
|
||||
|
||||
// 结局A:妈妈连接 + 自己录音
|
||||
// 结局A:妈妈连接 + 自己录音 — 优先级 2
|
||||
if (v.MOM_LINK && v.SELF_RECORD) {
|
||||
return this._triggerEnding('A');
|
||||
}
|
||||
|
||||
// 结局B:拒了阿哲 + 回了周南
|
||||
// 结局B:拒了阿哲 + 回了周南 — 优先级 3
|
||||
if (v.HE_BACK === false && v.ZHOUNAN_DEPTH >= 1) {
|
||||
return this._triggerEnding('B');
|
||||
}
|
||||
|
||||
// 结局C:全部已读但极少回复
|
||||
const replied = Object.values(this.msgState).filter(s => s === 'replied').length;
|
||||
const read = Object.values(this.msgState).filter(s => s !== 'unread').length;
|
||||
if (read >= MESSAGES.length - 2 && replied <= 2) {
|
||||
// 结局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(兜底)
|
||||
// 结局D(兜底)— 优先级 5
|
||||
this._triggerEnding('D');
|
||||
}
|
||||
|
||||
@@ -659,6 +761,10 @@ class GameEngine {
|
||||
E: () => this._endingE(),
|
||||
}[type];
|
||||
if (fn) await fn();
|
||||
|
||||
// 结局后播报心理支持热线
|
||||
await sleep(3000);
|
||||
await this._playVoiceAsync([`${T}/v2_sys_hotline_wav_v1.mp3`]);
|
||||
}
|
||||
|
||||
async _endingA() {
|
||||
@@ -668,13 +774,15 @@ class GameEngine {
|
||||
await sleep(4000);
|
||||
SFX_UI.ending();
|
||||
this._showCaption('明天再说。');
|
||||
await this._playVoiceAsync([`${T}/v2_ending_a_linxia_wav_v1.mp3`]);
|
||||
}
|
||||
|
||||
async _endingB() {
|
||||
await sleep(2000);
|
||||
SFX_UI.ending();
|
||||
await sleep(1000);
|
||||
this._showCaption('寄出。');
|
||||
this._showCaption('寄出去了。');
|
||||
await this._playVoiceAsync([`${T}/v2_ending_b_linxia_wav_v1.mp3`]);
|
||||
}
|
||||
|
||||
async _endingC() {
|
||||
@@ -682,6 +790,7 @@ class GameEngine {
|
||||
SFX_UI.ending();
|
||||
await sleep(1000);
|
||||
this._showCaption('今晚就这样。');
|
||||
await this._playVoiceAsync([`${T}/v2_ending_c_linxia_wav_v1.mp3`]);
|
||||
}
|
||||
|
||||
async _endingD() {
|
||||
@@ -692,6 +801,7 @@ class GameEngine {
|
||||
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() {
|
||||
@@ -704,6 +814,7 @@ class GameEngine {
|
||||
SFX_UI.ending();
|
||||
await sleep(1000);
|
||||
this._showCaption('好。');
|
||||
await this._playVoiceAsync([`${T}/v2_ending_e_linxia_wav_v1.mp3`]);
|
||||
}
|
||||
|
||||
_showCaption(text) {
|
||||
|
||||
+150
-9
@@ -1,7 +1,8 @@
|
||||
// gestures.js — 触控手势检测器(简化版)
|
||||
// gestures.js — 触控手势检测器
|
||||
//
|
||||
// 仅支持 6 种手势:单击 · 双击 · 左滑 · 右滑 · 上滑 · 下滑
|
||||
// 无长按,无多指操作
|
||||
// 支持 9 种手势:
|
||||
// 单击 · 双击 · 长按(1s) · 左滑 · 右滑 · 上滑 · 下滑
|
||||
// 两指长按(1s) · 三指点击
|
||||
|
||||
class GestureDetector {
|
||||
constructor(el) {
|
||||
@@ -14,6 +15,15 @@ class GestureDetector {
|
||||
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();
|
||||
@@ -30,21 +40,105 @@ class GestureDetector {
|
||||
// ── 触摸事件 ──
|
||||
_bindTouch() {
|
||||
const el = this._el;
|
||||
|
||||
el.addEventListener('touchstart', e => {
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
this._startX = t.clientX;
|
||||
this._startY = t.clientY;
|
||||
this._startTime = Date.now();
|
||||
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._reset(), { passive: false });
|
||||
el.addEventListener('touchcancel', () => {
|
||||
this._cancelLongPress();
|
||||
this._cancelMultiTouchLP();
|
||||
this._resetMultiTouch();
|
||||
this._reset();
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// ── 鼠标事件(桌面调试)──
|
||||
@@ -53,10 +147,31 @@ class GestureDetector {
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 键盘事件(桌面调试)──
|
||||
@@ -69,11 +184,14 @@ class GestureDetector {
|
||||
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;
|
||||
@@ -114,8 +232,31 @@ class GestureDetector {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具方法 ──
|
||||
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -388,6 +388,11 @@ const MESSAGES = [
|
||||
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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -415,7 +420,8 @@ const MESSAGES = [
|
||||
},
|
||||
down: {
|
||||
isSilence: true,
|
||||
npcReact: [rx('v2_msg10_call_react_silent_wav_v1.mp3')],
|
||||
npcReact: [rx('v2_msg10_call_react_down_wav_v1.mp3')],
|
||||
profile: { avoidance: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -469,6 +475,12 @@ const MESSAGES = [
|
||||
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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
# 林夏 v2 收尾任务文档
|
||||
|
||||
## 一、游戏开发任务
|
||||
|
||||
### G1: 手势系统升级 — gestures.js
|
||||
|
||||
**现状**: 仅支持 6 种手势(单击/双击/四方向滑动),无长按、无多指手势
|
||||
**目标**: 新增长按(1s)、两指长按(暂停)、三指点击(退出)
|
||||
|
||||
**交付物**:
|
||||
- 长按检测:touchstart 后 1000ms 无移动 → emit `longpress`
|
||||
- 两指长按检测:两指同时触屏 1000ms → emit `twofinger_longpress`
|
||||
- 三指点击检测:三指同时触屏后抬起 → emit `threefinger_tap`
|
||||
- touchmove 监听:移动超阈值取消长按
|
||||
- 键盘映射:L=长按, P=暂停, Q=退出
|
||||
|
||||
**测试case**:
|
||||
1. 单指按住 1.5s 不动 → 触发 longpress,不触发 singletap
|
||||
2. 单指按住 0.3s 抬起 → 触发 singletap,不触发 longpress
|
||||
3. 单指按住后滑动 → 触发 swipe,不触发 longpress
|
||||
4. 双指按住 1.5s → 触发 twofinger_longpress
|
||||
5. 双指快速点击(<1s)→ 不触发任何事件(忽略)
|
||||
6. 三指同时触屏后抬起 → 触发 threefinger_tap
|
||||
7. 键盘 L/P/Q 分别触发对应事件
|
||||
|
||||
---
|
||||
|
||||
### G2: 变量系统补全 — engine.js
|
||||
|
||||
**现状**: 使用 `UNREAD` 变量,缺少 `REPLY_COUNT`
|
||||
**目标**: 添加 `REPLY_COUNT`,在玩家做出非沉默选择时递增
|
||||
|
||||
**交付物**:
|
||||
- `vars` 对象新增 `REPLY_COUNT: 0`
|
||||
- `_handleChoice()` 中非沉默选择时 `REPLY_COUNT++`
|
||||
- 结局 C 判定改用 `REPLY_COUNT <= 2`
|
||||
|
||||
**测试case**:
|
||||
1. 选择右滑(温暖) → REPLY_COUNT 递增 1
|
||||
2. 选择下滑(沉默, isSilence=true) → REPLY_COUNT 不变
|
||||
3. 双击跳过选择 → REPLY_COUNT 不变
|
||||
4. 全程极少回复(REPLY_COUNT<=2) + 全部已读 → 触发结局 C
|
||||
|
||||
---
|
||||
|
||||
### G3: 长按手势处理 — engine.js
|
||||
|
||||
**现状**: 无长按处理
|
||||
**目标**: playing/choosing 状态重播当前消息音频;其他状态广播未读数
|
||||
|
||||
**交付物**:
|
||||
- `_onLongPress()` 方法
|
||||
- playing/responding/reacting → 中断当前播放,重播 currentMsg.audio
|
||||
- choosing → 重播 currentMsg.audio(不退出选择状态)
|
||||
- idle/notification → TTS 播报 "当前未读 N 条消息"
|
||||
|
||||
**测试case**:
|
||||
1. playing 状态长按 → 当前语音重头播放
|
||||
2. choosing 状态长按 → 重播 NPC 问题,仍可继续选择
|
||||
3. idle 状态长按 → TTS 播报未读数
|
||||
4. ending 状态长按 → 无反应
|
||||
|
||||
---
|
||||
|
||||
### G4: 暂停/退出机制 — engine.js
|
||||
|
||||
**现状**: 无暂停/退出功能
|
||||
**目标**: 两指长按暂停,单击恢复,三指点击退出
|
||||
|
||||
**交付物**:
|
||||
- 新状态 `paused`
|
||||
- `_onPause()`: 保存当前状态、停止所有音频/震动、TTS 播报暂停提示
|
||||
- `_onResume()`: 恢复到 choosing/idle(语音无法断点续播)
|
||||
- `_onExit()`: 仅 paused 状态可用,TTS 播报感谢后返回开始页
|
||||
- hint 栏显示暂停状态提示
|
||||
|
||||
**测试case**:
|
||||
1. playing 状态两指长按 → 音频停止,进入 paused,TTS 播报暂停
|
||||
2. paused 状态单击 → 恢复游戏
|
||||
3. paused 状态三指点击 → 退出到开始页
|
||||
4. 非 paused 状态三指点击 → 无反应
|
||||
5. ending 状态两指长按 → 无反应(结局不可暂停)
|
||||
|
||||
---
|
||||
|
||||
### G5: 选择状态语音提示 — engine.js
|
||||
|
||||
**现状**: 进入 choosing 仅播放 choiceReady 音效 + confirm 震动
|
||||
**目标**: 追加 TTS 播报 "等待回复"
|
||||
|
||||
**交付物**:
|
||||
- `_enterChoosing()` 中 SFX_UI.choiceReady() 后追加 TTS.speak('等待回复')
|
||||
|
||||
**测试case**:
|
||||
1. 播放完 NPC 语音进入选择 → 听到 "叮" + TTS "等待回复"
|
||||
2. 快速双击跳过 → TTS 被中断,不卡住
|
||||
|
||||
---
|
||||
|
||||
### G6: 结局语音化 — engine.js
|
||||
|
||||
**现状**: 结局使用 `_showCaption(text)` 显示视觉文字(与零视觉设计矛盾)
|
||||
**目标**: 结局台词通过 TTS 朗读(后续替换为预录音频)
|
||||
|
||||
**交付物**:
|
||||
- `_endingA/B/C/D/E` 中 `_showCaption(text)` → `TTS.speak(text)`(保留 caption 作为补充)
|
||||
- 结局 E 周南语音播完后 TTS 说 "好"
|
||||
|
||||
**测试case**:
|
||||
1. 触发结局 A → 听到水壶声 + TTS "明天再说"
|
||||
2. 触发结局 D → 听到鸟鸣声 + TTS "早上好"
|
||||
3. 触发结局 E → 周南语音 + TTS "好"
|
||||
4. TTS 不可用时 → caption 仍然显示(降级方案)
|
||||
|
||||
---
|
||||
|
||||
### G7: 内容安全 — engine.js + index.html
|
||||
|
||||
**现状**: 无内容预警和求助信息
|
||||
**目标**: 游戏开始前播报内容预警,结局后显示心理支持热线
|
||||
|
||||
**交付物**:
|
||||
- `start()` 开头:TTS 播报 "本游戏涉及孤独、家庭关系等情感话题,请在舒适的状态下体验"
|
||||
- 结局播完后:TTS 播报 "如需倾诉,全国心理援助热线 400-161-9995"
|
||||
- index.html 开始页添加内容预警文字
|
||||
|
||||
**测试case**:
|
||||
1. 游戏启动 → 先听到内容预警,再开始环境音
|
||||
2. 任意结局完成后 → 听到热线播报
|
||||
3. 开始页可见内容预警文字
|
||||
|
||||
---
|
||||
|
||||
### G8: 分支补全 — story.js
|
||||
|
||||
**现状**: MSG-10/11 follow-up 缺少沉默(down)选项
|
||||
**目标**: 补全所有未闭合分支
|
||||
|
||||
**交付物**:
|
||||
- MSG-10 right follow-up: 添加 down(沉默) 选项
|
||||
- MSG-10 up follow-up: 添加 down(沉默) 选项
|
||||
- MSG-11 up follow-up: 添加 down(沉默) 选项
|
||||
|
||||
**测试case**:
|
||||
1. MSG-10 温暖回复后 → 可选右滑/上滑/下滑(沉默)
|
||||
2. MSG-10 追问后通话 → 可选右滑/上滑/下滑(沉默)
|
||||
3. MSG-11 追问后 → 可选右滑/左滑/下滑(沉默)
|
||||
|
||||
---
|
||||
|
||||
### G9: 操作指南更新 — index.html
|
||||
|
||||
**现状**: 开始页操作说明缺少长按和安全手势
|
||||
**目标**: 补充完整操作说明
|
||||
|
||||
**交付物**:
|
||||
- 添加 "播放中" 长按重播说明
|
||||
- 添加 "随时可用" 安全手势分组(两指长按暂停、三指点击退出)
|
||||
|
||||
**测试case**:
|
||||
1. 开始页显示完整操作说明,含长按和安全手势
|
||||
|
||||
---
|
||||
|
||||
## 二、音频制作任务(需 VoxCPM2 服务)
|
||||
|
||||
### A1: 结局旁白音频
|
||||
|
||||
**现状**: 结局台词无预录音频,依赖 TTS
|
||||
**目标**: 用林夏声线生成 5 条结局旁白
|
||||
|
||||
**交付物** (5 个 MP3):
|
||||
| 文件名 | 台词 |
|
||||
|---------|------|
|
||||
| `v2_ending_a_linxia_wav_v1.mp3` | "明天再说。" |
|
||||
| `v2_ending_b_linxia_wav_v1.mp3` | "寄出去了。" |
|
||||
| `v2_ending_c_linxia_wav_v1.mp3` | "今晚就这样。" |
|
||||
| `v2_ending_d_linxia_wav_v1.mp3` | "早上好。" |
|
||||
| `v2_ending_e_linxia_wav_v1.mp3` | "好。" |
|
||||
|
||||
**测试case**:
|
||||
1. 每个文件 < 3s,采样率 24kHz+,音色一致
|
||||
2. 语气匹配情境(A 疲惫温柔、D 平静、E 轻快)
|
||||
|
||||
---
|
||||
|
||||
### A2: 系统提示音频
|
||||
|
||||
**现状**: 无系统提示预录音频
|
||||
**目标**: 生成游戏流程中的系统语音提示
|
||||
|
||||
**交付物** (4 个 MP3):
|
||||
| 文件名 | 台词 |
|
||||
|---------|------|
|
||||
| `v2_sys_wait_reply_wav_v1.mp3` | "等待回复" |
|
||||
| `v2_sys_paused_wav_v1.mp3` | "游戏已暂停。单击继续,三指点击退出" |
|
||||
| `v2_sys_content_warning_wav_v1.mp3` | "本游戏涉及孤独、家庭关系等情感话题,请在舒适的状态下体验" |
|
||||
| `v2_sys_hotline_wav_v1.mp3` | "如需倾诉,全国心理援助热线 400-161-9995" |
|
||||
|
||||
**测试case**:
|
||||
1. 音色使用林夏声线,语速适中
|
||||
2. 暂停提示 < 5s,内容预警 < 8s
|
||||
|
||||
---
|
||||
|
||||
### A3: 新增分支台词音频
|
||||
|
||||
**现状**: MSG-10/11 补全的 down 分支缺音频
|
||||
**目标**: 生成沉默后 NPC 反应音频
|
||||
|
||||
**交付物** (3 个 MP3):
|
||||
| 文件名 | 角色 | 台词 |
|
||||
|---------|------|------|
|
||||
| `v2_msg10_rw_react_silent_wav_v1.mp3` | 妈妈 | "那早点睡啊。" |
|
||||
| `v2_msg10_call_react_down_wav_v1.mp3` | 妈妈 | "……没事就好。早点睡。" |
|
||||
| `v2_msg11_r3_react_silent_wav_v1.mp3` | 阿哲 | "行吧,那我不来了。" |
|
||||
|
||||
**测试case**:
|
||||
1. 音色与对应角色现有音频一致
|
||||
2. 语气自然,符合上下文
|
||||
|
||||
---
|
||||
|
||||
### A4: MSG-16 后期处理
|
||||
|
||||
**现状**: seg5-6 是奶奶无声电话的描述段,需要电话底噪叠加
|
||||
**目标**: 与 MSG-08 未知号码呼吸声使用匹配的底噪
|
||||
|
||||
**交付物**:
|
||||
- `lv11_0200_mom_voice_01_seg5_wav_v1.mp3` 叠加电话底噪
|
||||
- `lv11_0200_mom_voice_01_seg6_wav_v1.mp3` 叠加电话底噪
|
||||
|
||||
**测试case**:
|
||||
1. seg5-6 可听到轻微电话底噪(与 MSG-08 sfx_breathing_unknown_v1.mp3 风格匹配)
|
||||
2. 底噪不盖过妈妈说话声(底噪 -18dB 以下)
|
||||
|
||||
---
|
||||
|
||||
## 三、执行顺序
|
||||
|
||||
```
|
||||
阶段一(代码 — 立即执行):
|
||||
G1 → G2 → G3/G4/G5 → G6 → G7 → G8 → G9
|
||||
|
||||
阶段二(音频 — 需 VoxCPM2):
|
||||
A1 → A2 → A3 → A4
|
||||
|
||||
阶段三(集成):
|
||||
结局音频替换 TTS → 系统提示音替换 TTS → 回归测试
|
||||
```
|
||||
+126
-260
@@ -1,7 +1,9 @@
|
||||
# 《林夏》音频素材本地制作执行文档
|
||||
|
||||
> **前置文档**:`林夏.md`(游戏设计)、`SOP_AI音频生产.md`(生产规范)
|
||||
> **执行环境**:本机 WSL2 · RTX 5090 32GB · CosyVoice2-0.5B · FFmpeg 7.0
|
||||
> **执行环境**:本机 WSL2 · RTX 5090 32GB · FFmpeg 7.0
|
||||
> **TTS 主链路**:**VoxCPM2**(见 `林夏.md` §7.2),批量脚本 `audio/batch_tts_voxcpm.py`
|
||||
> **TTS 备用**:CosyVoice2-0.5B(见附录 B)
|
||||
> **目标**:按 SOP 规范在本地生产全部音频素材
|
||||
|
||||
---
|
||||
@@ -10,12 +12,14 @@
|
||||
|
||||
| 资源 | 路径 / 状态 | 用途 |
|
||||
|---|---|---|
|
||||
| CosyVoice 2.0 (0.5B) | `/home/xsl/tools/CosyVoice` · conda env: `cosyvoice` | TTS 主力(替代 SOP 中 MiniMax/豆包方案) |
|
||||
| **VoxCPM2** | `bash /home/xsl/AutoVideo/start-voxcpm.sh` → `http://127.0.0.1:8000` | **TTS 主链路**(声音克隆 + 合成) |
|
||||
| CosyVoice 2.0 (0.5B) | `/home/xsl/tools/CosyVoice` · conda env: `cosyvoice` | TTS 备用(见附录 B) |
|
||||
| FFmpeg 7.0 | `/usr/local/bin/ffmpeg` | 音频格式转换、响度归一化、听筒效果 |
|
||||
| GPU | RTX 5090 32GB | 跑 CosyVoice 推理 |
|
||||
| GPU | RTX 5090 32GB | 跑 VoxCPM2 / CosyVoice 推理 |
|
||||
| MuseTalk | `/home/xsl/work/MuseTalk` | **注意:这是唇形同步工具,不是 TTS** |
|
||||
|
||||
> **SOP 中的 MiniMax / 豆包 API 全部替换为本地 CosyVoice**。音乐(Suno)、音效(ElevenLabs SFX)、环境采样(Freesound)仍走云端。
|
||||
> **SOP 中的 MiniMax / 豆包 API 全部替换为本地 VoxCPM2**。音乐(Suno)、音效(ElevenLabs SFX)、环境采样(Freesound)仍走云端。
|
||||
> CosyVoice2 保留作为备用方案(见附录 B),仅在 VoxCPM2 表现不佳时切换。
|
||||
|
||||
---
|
||||
|
||||
@@ -26,51 +30,27 @@
|
||||
```bash
|
||||
cd /home/xsl/blind
|
||||
mkdir -p audio/{00_raw/{tts,music,sfx,ambience,ringtone},01_processed/{tts,music,sfx,ambience,ringtone},02_final/{tts,music,sfx,ambience,ringtone},03_qa_reports,99_legal}
|
||||
mkdir -p audio/00_raw/tts/prompt_ref # 存放角色参考音频
|
||||
mkdir -p voice/{linxia,mom,azhe,xiaomei,zhounan,hr,anan,dorm_a,dorm_b,dorm_c,delivery,system} # 各角色参考音频
|
||||
```
|
||||
|
||||
### 1.2 验证 CosyVoice 环境
|
||||
### 1.2 启动 VoxCPM2 服务
|
||||
|
||||
```bash
|
||||
# 激活 conda 环境
|
||||
conda activate cosyvoice
|
||||
|
||||
# 验证依赖
|
||||
cd /home/xsl/tools/CosyVoice
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.append('third_party/Matcha-TTS')
|
||||
from cosyvoice.cli.cosyvoice import CosyVoice2
|
||||
model = CosyVoice2('pretrained_models/CosyVoice2-0.5B')
|
||||
print('可用预训练音色:', model.list_available_spks())
|
||||
print('采样率:', model.sample_rate)
|
||||
print('CosyVoice2 加载成功')
|
||||
"
|
||||
# 启动 VoxCPM2 TTS 服务
|
||||
bash /home/xsl/AutoVideo/start-voxcpm.sh
|
||||
# uvicorn 服务: http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
如果报错 `ModuleNotFoundError`,先装依赖:
|
||||
验证服务可用:
|
||||
```bash
|
||||
conda activate cosyvoice
|
||||
cd /home/xsl/tools/CosyVoice
|
||||
pip install -r requirements.txt
|
||||
curl -s http://127.0.0.1:8000/docs | head -5
|
||||
# 应返回 API 文档页面
|
||||
```
|
||||
|
||||
### 1.3 启动 CosyVoice WebUI
|
||||
|
||||
```bash
|
||||
conda activate cosyvoice
|
||||
cd /home/xsl/tools/CosyVoice
|
||||
python webui.py --port 8000
|
||||
```
|
||||
|
||||
浏览器访问 `http://localhost:8000`。WebUI 提供 4 种推理模式:
|
||||
|
||||
| 模式 | 说明 | 本项目用途 |
|
||||
|---|---|---|
|
||||
| **预训练音色** | 从内置音色列表选择 | 快速试听、系统 TTS |
|
||||
| **3s极速复刻** | 提供 3-30s 参考音频 + 对应文字 | **主力模式**:为每个角色准备参考音频 |
|
||||
| **跨语种复刻** | 跨语言克隆 | 暂不需要 |
|
||||
| **自然语言控制** | 用文字描述控制语气风格 | CosyVoice2 不支持此模式,改用 instruct2 API |
|
||||
**VoxCPM2 API 接口**(详见 `林夏.md` §7.2):
|
||||
- `POST /v1/voices` — 注册参考音频(wav_base64)→ 返回 voice_id
|
||||
- `POST /v1/speech` — 合成语音(text + voice_id)→ 返回 WAV
|
||||
- 输出格式:48kHz 16-bit PCM WAV
|
||||
|
||||
### 1.4 验证 FFmpeg
|
||||
|
||||
@@ -87,7 +67,7 @@ echo "FFmpeg loudnorm 可用"
|
||||
|
||||
## 2. 角色参考音频准备(关键步骤)
|
||||
|
||||
CosyVoice2 的"3s极速复刻"需要**每个角色一段 5-20 秒的参考音频**。参考音频的音色决定了最终 TTS 的音色。
|
||||
VoxCPM2 的声音克隆需要**每个角色一段参考音频**(WAV 格式)。参考音频的音色决定了最终 TTS 的音色。参考音频存放在 `voice/{角色}/` 目录下(详见 `林夏.md` §7.3 角色音色配置)。
|
||||
|
||||
### 2.1 参考音频来源方案
|
||||
|
||||
@@ -101,7 +81,7 @@ CosyVoice2 的"3s极速复刻"需要**每个角色一段 5-20 秒的参考音频
|
||||
| `anan` 安安 | 23 岁,可爱 | 年轻女性活泼语调截取 |
|
||||
| `dorm-a/b/c` | 三个不同年轻女声 | 需要 3 段风格不同的女声参考 |
|
||||
| `delivery` 外卖小哥 | 30 岁,北方腔 | 短视频/影视截取 |
|
||||
| `system` 系统 | 中性 AI 女声 | 用 CosyVoice 预训练音色即可 |
|
||||
| `system` 系统 | 中性 AI 女声 | VoxCPM2 注册一个中性女声参考音频 |
|
||||
| `self-3y` 3年前自己 | 同 xiaomei 基底 | 复用 xiaomei 参考音频,调速度和音调 |
|
||||
|
||||
### 2.2 参考音频制作规格
|
||||
@@ -111,229 +91,80 @@ CosyVoice2 的"3s极速复刻"需要**每个角色一段 5-20 秒的参考音频
|
||||
- **内容**:完整的中文句子(需同时记录对应文字)
|
||||
- **质量**:干声为主,避免底噪、混响、BGM
|
||||
|
||||
存放路径:`audio/00_raw/tts/prompt_ref/{角色代号}_ref.wav`
|
||||
存放路径:`voice/{角色代号}/`(与 `林夏.md` §7.3 保持一致)
|
||||
|
||||
```bash
|
||||
# 如果参考音频不是 wav 或采样率不对,用 FFmpeg 转换
|
||||
ffmpeg -i input.mp3 -ar 16000 -ac 1 audio/00_raw/tts/prompt_ref/mom_ref.wav
|
||||
ffmpeg -i input.mp3 -ar 16000 -ac 1 voice/mom/ref.wav
|
||||
```
|
||||
|
||||
### 2.3 创建参考音频记录表
|
||||
|
||||
在 `audio/00_raw/tts/prompt_ref/` 下创建 `ref_manifest.txt`,记录每条参考音频对应的文字:
|
||||
在 `voice/` 下创建 `ref_manifest.txt`,记录每条参考音频对应的文字:
|
||||
|
||||
```
|
||||
mom_ref.wav | 今天天气不错,妈妈给你做了红烧排骨,等你回来吃饭。
|
||||
azhe_ref.wav | 好的,我知道了,那件事明天再说吧。
|
||||
xiaomei_ref.wav | 哎你猜怎么着,今天路上遇到一个超级搞笑的事!
|
||||
zhounan_ref.wav | 嗯,其实我也不太确定,就是觉得应该跟你说一声。
|
||||
hr_ref.wav | 好的,那我们下周三前把流程走完,有什么问题随时联系我。
|
||||
anan_ref.wav | 天呐天呐你快来!这边超级好玩的!
|
||||
dorm_a_ref.wav | 嘿你们看看我,今天升职了!请大家吃饭!
|
||||
dorm_b_ref.wav | 恭喜恭喜啊,太厉害了,我就知道你行的。
|
||||
dorm_c_ref.wav | 嗯,不错,对了夏夏怎么不说话啊。
|
||||
delivery_ref.wav | 你好你的外卖到了,放门口了啊,麻辣烫注意别洒了。
|
||||
mom/ref.wav | 今天天气不错,妈妈给你做了红烧排骨,等你回来吃饭。
|
||||
azhe/ref.wav | 好的,我知道了,那件事明天再说吧。
|
||||
xiaomei/ref.wav | 哎你猜怎么着,今天路上遇到一个超级搞笑的事!
|
||||
zhounan/ref.wav | 嗯,其实我也不太确定,就是觉得应该跟你说一声。
|
||||
hr/ref.wav | 好的,那我们下周三前把流程走完,有什么问题随时联系我。
|
||||
anan/ref.wav | 天呐天呐你快来!这边超级好玩的!
|
||||
dorm_a/ref.wav | 嘿你们看看我,今天升职了!请大家吃饭!
|
||||
dorm_b/ref.wav | 恭喜恭喜啊,太厉害了,我就知道你行的。
|
||||
dorm_c/ref.wav | 嗯,不错,对了夏夏怎么不说话啊。
|
||||
delivery/ref.wav | 你好你的外卖到了,放门口了啊,麻辣烫注意别洒了。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. TTS 生产流程
|
||||
|
||||
### 3.1 用 WebUI 逐条生产(推荐新手)
|
||||
### 3.1 VoxCPM2 声音克隆流程
|
||||
|
||||
1. 浏览器打开 `http://localhost:8000`
|
||||
2. 选择模式:**3s极速复刻**
|
||||
3. 上传对应角色的参考音频
|
||||
4. 在"prompt文本"中输入参考音频对应的文字
|
||||
5. 在"合成文本"中输入要合成的台词
|
||||
6. 调整速度(对应 SOP §4.2 各角色 Speed 值)
|
||||
7. 点击"生成音频"
|
||||
8. 试听满意后,右键保存音频
|
||||
**前置**:确认 VoxCPM2 服务已启动(§1.2)。
|
||||
|
||||
### 3.2 用 Python 脚本批量生产(推荐熟练后)
|
||||
**单条合成步骤**:
|
||||
1. 将角色参考音频注册为 voice_id:`POST /v1/voices`(wav_base64)
|
||||
2. 调用合成接口:`POST /v1/speech`(text + voice_id)
|
||||
3. 获得 48kHz 16-bit PCM WAV
|
||||
4. 用 FFmpeg atempo 调速(按 `林夏.md` §7.3 各角色语速配置)
|
||||
5. 保存到 `audio/00_raw/tts/`
|
||||
|
||||
创建批量生产脚本 `audio/batch_tts.py`:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
《林夏》TTS 批量生产脚本
|
||||
用法: conda activate cosyvoice && python audio/batch_tts.py
|
||||
"""
|
||||
import os, sys, json, torch, torchaudio
|
||||
|
||||
# 设置路径
|
||||
COSYVOICE_DIR = "/home/xsl/tools/CosyVoice"
|
||||
PROJECT_DIR = "/home/xsl/blind"
|
||||
sys.path.insert(0, COSYVOICE_DIR)
|
||||
sys.path.insert(0, f"{COSYVOICE_DIR}/third_party/Matcha-TTS")
|
||||
|
||||
from cosyvoice.cli.cosyvoice import CosyVoice2
|
||||
from cosyvoice.utils.file_utils import load_wav
|
||||
|
||||
# 加载模型
|
||||
print("正在加载 CosyVoice2 模型...")
|
||||
model = CosyVoice2(f"{COSYVOICE_DIR}/pretrained_models/CosyVoice2-0.5B")
|
||||
print(f"模型加载完成,采样率: {model.sample_rate}")
|
||||
|
||||
# ============================================================
|
||||
# 角色配置 — 按 SOP §4.2 配置卡填写
|
||||
# speed: 对应 SOP 中的 Speed 值
|
||||
# ref_wav: 参考音频路径
|
||||
# ref_text: 参考音频对应文字
|
||||
# ============================================================
|
||||
ROLES = {
|
||||
"mom": {
|
||||
"speed": 0.92,
|
||||
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/mom_ref.wav",
|
||||
"ref_text": "今天天气不错,妈妈给你做了红烧排骨,等你回来吃饭。",
|
||||
},
|
||||
"azhe": {
|
||||
"speed": 1.05,
|
||||
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/azhe_ref.wav",
|
||||
"ref_text": "好的,我知道了,那件事明天再说吧。",
|
||||
},
|
||||
"xiaomei": {
|
||||
"speed": 1.10,
|
||||
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/xiaomei_ref.wav",
|
||||
"ref_text": "哎你猜怎么着,今天路上遇到一个超级搞笑的事!",
|
||||
},
|
||||
"zhounan": {
|
||||
"speed": 0.95,
|
||||
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/zhounan_ref.wav",
|
||||
"ref_text": "嗯,其实我也不太确定,就是觉得应该跟你说一声。",
|
||||
},
|
||||
"hr": {
|
||||
"speed": 1.00,
|
||||
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/hr_ref.wav",
|
||||
"ref_text": "好的,那我们下周三前把流程走完,有什么问题随时联系我。",
|
||||
},
|
||||
"anan": {
|
||||
"speed": 1.15,
|
||||
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/anan_ref.wav",
|
||||
"ref_text": "天呐天呐你快来!这边超级好玩的!",
|
||||
},
|
||||
"delivery": {
|
||||
"speed": 1.20,
|
||||
"ref_wav": f"{PROJECT_DIR}/audio/00_raw/tts/prompt_ref/delivery_ref.wav",
|
||||
"ref_text": "你好你的外卖到了,放门口了啊,麻辣烫注意别洒了。",
|
||||
},
|
||||
# system 用预训练音色,不走 zero-shot
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 台词表 — 从林夏.md §3 / §5 抄写
|
||||
# 每条格式: (文件名前缀, 角色, 台词, speed_override=None)
|
||||
# ============================================================
|
||||
LINES = [
|
||||
# === 第 01 条 · 21:03 妈妈电话 ===
|
||||
("lv11_2103_mom_call_01", "mom",
|
||||
"今晚煮了你爱吃的排骨,给你拍张照啊。下周五回来一趟,你高中物理老师的儿子,挺好的。"),
|
||||
|
||||
# === 第 02 条 · 21:15 小美三连 ===
|
||||
("lv11_2115_xiaomei_voice_01", "xiaomei", "在吗"),
|
||||
("lv11_2115_xiaomei_voice_02", "xiaomei", "你被甩了我都听说了"),
|
||||
("lv11_2115_xiaomei_voice_03", "xiaomei", "明天晚上喝酒,海底捞旁边那家,不许放鸽子"),
|
||||
|
||||
# === 第 03 条 · 21:20 外卖小哥 ===
|
||||
("lv11_2120_delivery_call_01", "delivery",
|
||||
"你好你的麻辣烫到了,放门口可以吗?"),
|
||||
|
||||
# === 第 04 条 · 21:30 阿哲语音 ===
|
||||
("lv11_2130_azhe_voice_01", "azhe",
|
||||
"嗯……夏夏。我那个AirPods充电盒,好像落你抽屉里了。你方便的时候帮我寄一下吗,工位还要用。地址我等下发你。"),
|
||||
|
||||
# === 第 05 条 · 21:45 HR 王姐 ===
|
||||
("lv11_2145_hr_voice_01", "hr",
|
||||
"夏夏啊,离职流程你下周三之前办完哈,公积金那边记得跟进。辛苦啦,外面有合适的工作我帮你留意。"),
|
||||
|
||||
# === 第 06 条 · 22:00 安安 ===
|
||||
("lv11_2200_anan_voice_01", "anan",
|
||||
"夏夏!我在helens!要不要过来!我请客!"),
|
||||
|
||||
# === 第 10 条 · 23:00 妈妈文字 ===
|
||||
("lv11_2300_mom_text_01", "mom", "睡了吗?"),
|
||||
|
||||
# === 第 11 条 · 23:15 阿哲电话 ===
|
||||
("lv11_2315_azhe_call_01", "azhe",
|
||||
"我能过去拿吗?就5分钟"),
|
||||
|
||||
# === 第 12 条 · 23:30 周南 ===
|
||||
("lv11_2330_zhounan_voice_01", "zhounan",
|
||||
"林夏,是我,周南。你应该不记得我了吧,初三坐你后桌那个"),
|
||||
|
||||
# === 第 14 条 · 00:15 妈妈电话 ===
|
||||
("lv11_0015_mom_call_01", "mom",
|
||||
"夏夏,你不接电话我有点担心。妈妈知道你工作忙,你早点睡。"),
|
||||
|
||||
# ... 更多台词按需添加
|
||||
# 长戏(妈妈 4'17"、小美醉语音、周南 4 段)需分段处理,见 §3.3
|
||||
]
|
||||
|
||||
|
||||
def synthesize_line(filename, role, text, speed_override=None):
|
||||
"""合成单条台词"""
|
||||
cfg = ROLES[role]
|
||||
speed = speed_override or cfg["speed"]
|
||||
ref_wav = cfg["ref_wav"]
|
||||
ref_text = cfg["ref_text"]
|
||||
|
||||
if not os.path.exists(ref_wav):
|
||||
print(f" [跳过] 参考音频不存在: {ref_wav}")
|
||||
return
|
||||
|
||||
# 加载参考音频
|
||||
prompt_speech = load_wav(ref_wav, 16000)
|
||||
# 裁剪到合理长度
|
||||
if prompt_speech.shape[1] > 16000 * 30:
|
||||
prompt_speech = prompt_speech[:, :16000 * 30]
|
||||
|
||||
output_path = f"{PROJECT_DIR}/audio/00_raw/tts/{filename}_v1.wav"
|
||||
print(f" 合成中: {filename} [{role}] speed={speed}")
|
||||
print(f" 台词: {text[:50]}...")
|
||||
|
||||
speeches = []
|
||||
for chunk in model.inference_zero_shot(
|
||||
tts_text=text,
|
||||
prompt_text=ref_text,
|
||||
prompt_speech_16k=prompt_speech,
|
||||
stream=False,
|
||||
speed=speed,
|
||||
):
|
||||
speeches.append(chunk["tts_speech"])
|
||||
|
||||
if speeches:
|
||||
full_speech = torch.concat(speeches, dim=1)
|
||||
torchaudio.save(output_path, full_speech, model.sample_rate)
|
||||
duration = full_speech.shape[1] / model.sample_rate
|
||||
print(f" 已保存: {output_path} ({duration:.1f}s)")
|
||||
else:
|
||||
print(f" [失败] 未生成音频: {filename}")
|
||||
|
||||
|
||||
def main():
|
||||
print(f"\n{'='*60}")
|
||||
print(f" 《林夏》TTS 批量生产")
|
||||
print(f" 共 {len(LINES)} 条台词")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
for i, line in enumerate(LINES):
|
||||
filename = line[0]
|
||||
role = line[1]
|
||||
text = line[2]
|
||||
speed_override = line[3] if len(line) > 3 else None
|
||||
|
||||
print(f"\n[{i+1}/{len(LINES)}] {filename}")
|
||||
synthesize_line(filename, role, text, speed_override)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(" 批量合成完成!请到 audio/00_raw/tts/ 检查结果")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
**角色音色目录**(与 `林夏.md` §7.3 一致):
|
||||
```
|
||||
voice/
|
||||
├── linxia/ # 林夏(speed 1.00)
|
||||
├── mom/ # 妈妈(speed 0.92)
|
||||
├── azhe/ # 阿哲(speed 1.05)
|
||||
├── xiaomei/ # 小美(speed 1.10,醉态 0.95)
|
||||
├── zhounan/ # 周南(speed 0.95)
|
||||
├── hr/ # HR 王姐(speed 1.00)
|
||||
├── anan/ # 安安(speed 1.15)
|
||||
├── dorm_a/ # 室友 A(speed 1.10)
|
||||
├── dorm_b/ # 室友 B(speed 1.00)
|
||||
├── dorm_c/ # 室友 C(speed 0.95)
|
||||
└── delivery/ # 外卖小哥(speed 1.20)
|
||||
```
|
||||
|
||||
### 3.2 用批量脚本生产(主力方式)
|
||||
|
||||
使用项目自带的 VoxCPM2 批量合成脚本:
|
||||
|
||||
```bash
|
||||
# 确保 VoxCPM2 服务已启动
|
||||
bash /home/xsl/AutoVideo/start-voxcpm.sh
|
||||
|
||||
# 批量合成所有台词
|
||||
python audio/batch_tts_voxcpm.py --source voice
|
||||
|
||||
# 只合成特定角色
|
||||
python audio/batch_tts_voxcpm.py --source voice --role linxia
|
||||
|
||||
# 干跑查看任务列表
|
||||
python audio/batch_tts_voxcpm.py --source voice --dry-run
|
||||
```
|
||||
|
||||
脚本读取 `voice/` 目录下的参考音频,按 `林夏.md` §7.3 角色配置自动注册 voice_id 并批量合成。输出到 `audio/00_raw/tts/`。
|
||||
|
||||
### 3.3 长戏分段处理
|
||||
|
||||
@@ -360,11 +191,11 @@ SOP §5.3 规定:**超过 30 秒的台词必须分段录制**。
|
||||
|
||||
### 3.4 系统 TTS
|
||||
|
||||
系统提示音(播报未读消息、念选项等)用 CosyVoice **预训练音色**模式:
|
||||
系统提示音(播报状态、念选项等)用 VoxCPM2 注册一个中性女声参考音频:
|
||||
|
||||
1. WebUI 选"预训练音色"
|
||||
2. 从下拉菜单选一个清晰的中性女声
|
||||
3. 合成所有系统语句
|
||||
1. 准备一段清晰的中性女声参考音频(如新闻播报片段),存入 `voice/system/`
|
||||
2. 通过 `POST /v1/voices` 注册为 system voice_id
|
||||
3. 用 `POST /v1/speech` 合成所有系统语句
|
||||
|
||||
系统语句示例:
|
||||
- "阿哲,发来1条语音。8秒。"
|
||||
@@ -663,13 +494,13 @@ done
|
||||
## 9. 执行顺序总结
|
||||
|
||||
```
|
||||
第 1 步 · 部署环境 (§1)
|
||||
第 1 步 · 启动 VoxCPM2 + 部署环境 (§1)
|
||||
↓ 约 30 分钟
|
||||
第 2 步 · 准备参考音频 (§2) ← 这一步最关键,音色质量取决于此
|
||||
第 2 步 · 准备参考音频到 voice/ (§2) ← 这一步最关键,音色质量取决于此
|
||||
↓ 约 1-3 天(需要找人录音或搜集素材)
|
||||
第 3 步 · 系统 TTS 先行 (§3.4) ← 最简单的先跑通流程
|
||||
第 3 步 · 系统 TTS 先行 (§3.4) ← 最简单的先跑通 VoxCPM2 流程
|
||||
↓ 约 1 小时
|
||||
第 4 步 · 短台词批量生产 (§3.1/3.2)
|
||||
第 4 步 · 短台词批量生产 (§3.2 batch_tts_voxcpm.py)
|
||||
↓ 约 3-4 小时
|
||||
第 5 步 · 长戏分段录制 (§3.3)
|
||||
↓ 约 2-3 小时
|
||||
@@ -692,22 +523,57 @@ done
|
||||
|
||||
## 附录 A · 常见问题
|
||||
|
||||
**Q: CosyVoice 生成的语音有电流声/杂音?**
|
||||
**Q: VoxCPM2 生成的语音有电流声/杂音?**
|
||||
A: 检查参考音频质量。参考音频必须干净无噪,采样率 ≥ 16kHz。
|
||||
|
||||
**Q: 语速不自然?**
|
||||
A: speed 参数范围 0.5-2.0。如果调速后音质下降,改为在合成文本中加标点控制节奏(句号=长停顿,逗号=短停顿)。
|
||||
A: 用 FFmpeg atempo 调速(范围 0.5-2.0)。如果调速后音质下降,改为在合成文本中加标点控制节奏(句号=长停顿,逗号=短停顿)。
|
||||
|
||||
**Q: 怎样让同一个音色听起来"醉了"?**
|
||||
A: CosyVoice 没有情绪控制参数。替代方案:
|
||||
A: TTS 通常没有情绪控制参数。替代方案:
|
||||
1. 在文本中插入省略号、重复字来模拟口齿不清
|
||||
2. 后期用 FFmpeg 轻微变速+加混响:
|
||||
```bash
|
||||
ffmpeg -i drunk.wav -af "atempo=0.95,aecho=0.8:0.7:40:0.3" -y drunk_fx.wav
|
||||
```
|
||||
|
||||
**Q: WebUI 启动报 CUDA 错误?**
|
||||
A: 确认 conda env 正确:`conda activate cosyvoice`。确认 GPU 可用:`nvidia-smi`。
|
||||
**Q: VoxCPM2 服务无法启动?**
|
||||
A: 确认 `bash /home/xsl/AutoVideo/start-voxcpm.sh` 正常。确认 GPU 可用:`nvidia-smi`。
|
||||
|
||||
**Q: 生成很慢?**
|
||||
A: RTX 5090 应该很快(远超实时)。如果慢,检查是否误用了 CPU。在 Python 中加 `print(torch.cuda.is_available())` 确认。
|
||||
A: RTX 5090 应该很快(远超实时)。如果慢,检查是否误用了 CPU。
|
||||
|
||||
---
|
||||
|
||||
## 附录 B · CosyVoice2 备用方案
|
||||
|
||||
> **仅在 VoxCPM2 表现不佳或不可用时使用。** 正常生产流程请走 §1-§3 的 VoxCPM2 主链路。
|
||||
|
||||
### B.1 环境验证
|
||||
|
||||
```bash
|
||||
conda activate cosyvoice
|
||||
cd /home/xsl/tools/CosyVoice
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.append('third_party/Matcha-TTS')
|
||||
from cosyvoice.cli.cosyvoice import CosyVoice2
|
||||
model = CosyVoice2('pretrained_models/CosyVoice2-0.5B')
|
||||
print('CosyVoice2 加载成功,采样率:', model.sample_rate)
|
||||
"
|
||||
```
|
||||
|
||||
### B.2 WebUI 启动
|
||||
|
||||
```bash
|
||||
conda activate cosyvoice
|
||||
cd /home/xsl/tools/CosyVoice
|
||||
python webui.py --port 8001 # 注意:用 8001 避免与 VoxCPM2 的 8000 端口冲突
|
||||
```
|
||||
|
||||
模式选择:**3s极速复刻**(上传参考音频 + 对应文字 → 合成)。
|
||||
|
||||
### B.3 批量脚本
|
||||
|
||||
CosyVoice2 版批量脚本为 `audio/batch_tts.py`(区别于主力脚本 `audio/batch_tts_voxcpm.py`)。
|
||||
用法:`conda activate cosyvoice && python audio/batch_tts.py`
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## 0. 一句话概述
|
||||
|
||||
24 岁的林夏,被分手、被裁员、被妈妈催婚,全发生在同一个周五。她把手机调成静音、躲进合租屋,关上灯。屏幕坏了——只剩耳机能听、手指能摸。今晚 16 个人会发来电话和语音。她接谁、不接谁、怎么回——每一个回应都会被对方听到,每一段对话都会走向不同的结局。
|
||||
24 岁的林夏,被分手、被裁员、被妈妈催婚,全发生在同一个周五。她把手机调成静音、躲进合租屋,关上灯。屏幕坏了——只剩耳机能听、手指能摸。今晚会有 16 条电话和语音陆续到达。她接谁、不接谁、怎么回——每一个回应都会被对方听到,每一段对话都会走向不同的结局。
|
||||
|
||||
---
|
||||
|
||||
@@ -39,18 +39,21 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|
||||
|
||||
## 2. 核心玩法
|
||||
|
||||
### 2.1 六种手势——零视觉依赖
|
||||
### 2.1 七种手势——零视觉依赖
|
||||
|
||||
| 手势 | 全局含义 | 情感色彩 | 触觉反馈 | 音效 |
|
||||
|------|---------|---------|---------|------|
|
||||
| **单击** | 接受 / 回应 / 继续 | 中性应答 | 轻触感 | 清脆短音 880Hz |
|
||||
| **双击** | 跳过 / 忽略 | 不耐烦 / 赶时间 | 短促双震 | 下行滑音 587→392Hz |
|
||||
| **长按(1s)** | 根据状态:重听或播报 | 确认 | 缓慢长震 | 低沉确认音 330→440Hz |
|
||||
| **右滑** | 温暖 / 靠近 | 接纳、关心 | 流畅长震 | 上行音 440→659Hz |
|
||||
| **左滑** | 冷淡 / 推开 | 疏离、敷衍 | 干脆短震 | 下行音 523→349Hz |
|
||||
| **上滑** | 好奇 / 追问 | 想了解更多 | 快速上弹震 | 快速上行 523→784Hz |
|
||||
| **下滑** | 沉默 / 退缩 | 说不出口 | 极轻慢震 | 极轻低音 330Hz |
|
||||
|
||||
> **不需要记位置**。每个方向有固定的情感含义,贯穿全游戏。右滑永远是温暖,左滑永远是冷淡。
|
||||
>
|
||||
> **长按随时可用**:任何状态下长按 1 秒,系统根据当前状态做出响应——在 `playing` 或 `choosing` 状态下重播上一句 NPC 语音,其他状态下语音播报当前位置(例如"小美的消息,等待回复")。这是玩家在纯音频环境下的"安全网"。
|
||||
|
||||
### 2.2 对话流程(多轮交互)
|
||||
|
||||
@@ -67,8 +70,11 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|
||||
└──────────┬──────────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [三音上行提示音 · 等待选择] │
|
||||
│ ★ 进入选择态 │
|
||||
│ [三音上行提示音 + 明确震动模式(长-短-长)] │
|
||||
│ 系统语音提示:"等待回复" │
|
||||
│ 玩家操作:右滑 / 左滑 / 上滑 / 下滑 / 单击 │
|
||||
│ 长按 → 重听 NPC 刚才说的话 │
|
||||
└──────────┬──────────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
@@ -132,7 +138,7 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|
||||
| 07 | 寝室群 | 语音 ×5 | 室友 A 升职,@林夏 | 5 方向 + 温暖 Round 3 |
|
||||
| 08 | 未知号码 | 电话 | 呼吸声 6 秒后挂断 | 无选择(悬疑钩子) |
|
||||
| 09 | 小美(醉)| 语音 ×4 | "阿哲大三追过我" | 5 方向 + 追问 Round 3 |
|
||||
| 10 | 妈妈 | 文字 | "睡了吗?" | 5 方向 + 温暖/追问各有 Round 3 |
|
||||
| 10 | 妈妈 | 文字(系统语音朗读) | "睡了吗?" | 5 方向 + 温暖/追问各有 Round 3 |
|
||||
| 11 | 阿哲 | 电话 | "我能过去拿吗?就 5 分钟" | 5 方向(关键分支锚点) |
|
||||
| 12 | 周南 | 语音 | "初三坐你后桌那个" | 5 方向 |
|
||||
| 13 | 周南 | 语音 ×4 | "今天我生日" | 5 方向 + 温暖 Round 3 |
|
||||
@@ -269,6 +275,8 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|
||||
**无选择**——悬疑钩子。
|
||||
**不接** → 未接来电。
|
||||
|
||||
**回收**:MSG-16 妈妈长语音第 5 段中提到"我外婆每天晚上都给我打一个电话,她不说什么"——暗示未知号码就是妈妈从另一个号码拨出的"不说什么的电话",与妈妈线形成跨代呼应。不做明确揭示,留给玩家自己领悟。如果玩家接了 MSG-08 + 听完 MSG-16,两段呼吸/沉默会在记忆中形成对位。
|
||||
|
||||
---
|
||||
|
||||
### MSG-09 小美(醉)· 语音 ×4
|
||||
@@ -298,15 +306,15 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|
||||
|
||||
### MSG-10 妈妈 · 文字消息
|
||||
|
||||
**Round 1**:消息到达提示音。TTS 朗读三个字——"睡了吗?"
|
||||
**Round 1**:消息到达提示音。系统语音朗读三个字——"睡了吗?"
|
||||
|
||||
| 方向 | 林夏说 | 妈妈反应 | 效果 |
|
||||
|------|--------|---------|------|
|
||||
| 右滑 | "没有,怎么了?" | 妈妈发语音:"没事,就是想起来问一下。今天怎么样?" | MOM_LINK, engagement+2 |
|
||||
| 左滑 | "嗯,要睡了。" | 妈妈发文字:"好,早点睡。" | MOM_LINK, avoidance+1 |
|
||||
| 左滑 | "嗯,要睡了。" | 系统语音朗读妈妈的文字:"好,早点睡。" | MOM_LINK, avoidance+1 |
|
||||
| 上滑 | "妈……你能打电话给我吗?" | [电话铃响] 妈妈打来了!接听后:"怎么了夏夏?" | MOM_LINK, engagement+3 |
|
||||
| 下滑 | [不回复] | [30 秒后] 妈妈发文字:"看到了回一下。" | avoidance+2 |
|
||||
| 单击 | "嗯。" | 妈妈发文字:"嗯就是睡了?晚安。" | MOM_LINK |
|
||||
| 下滑 | [不回复] | [30 秒后] 系统语音朗读妈妈的文字:"看到了回一下。" | avoidance+2 |
|
||||
| 单击 | "嗯。" | 系统语音朗读妈妈的文字:"嗯就是睡了?晚安。" | MOM_LINK |
|
||||
|
||||
**上滑路径**(妈妈打来电话后,说"怎么了夏夏?你声音不对。"):
|
||||
- 右滑 → 林夏:"没什么……就是有点累。" → 妈妈:"累了就回家歇几天。妈煮了排骨。"
|
||||
@@ -315,7 +323,9 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|
||||
|
||||
**右滑路径**(妈妈问"今天怎么样"后):
|
||||
- 右滑 → 林夏:"还好。" → 妈妈:"那就好。别太晚了。"
|
||||
- 上滑 → 林夏:"不太好。" → 妈妈:"怎么了?" → [可继续展开]
|
||||
- 上滑 → 林夏:"不太好。" → 妈妈:"怎么了?"
|
||||
- 右滑 → 林夏:"工作没了。" → 妈妈:"……回来歇几天吧。妈不催你。"
|
||||
- 下滑 → [沉默] → 妈妈:"不想说就不说。妈在这呢。" → 对话结束
|
||||
|
||||
---
|
||||
|
||||
@@ -328,10 +338,15 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|
||||
|------|--------|---------|------|
|
||||
| 右滑 | "可以。但只有 5 分钟。" | "好,我马上过来。谢谢。" → [挂断] | HE_BACK=true |
|
||||
| 左滑 | "不方便。我明天寄给你。" | "好吧……那谢了。" → [挂断] | HE_BACK=false |
|
||||
| 上滑 | "为什么非要今天来?" | "我……明天要出差。" [停顿] "就 5 分钟。" | 继续选择 |
|
||||
| 上滑 | "为什么非要今天来?" | "我……明天要出差。" [停顿] "就 5 分钟。" | → 追问后再选 |
|
||||
| 下滑 | [沉默 → 挂断音] | [电话断了] | HE_BACK=false |
|
||||
| 单击 | "不了。" | "好。" → [挂断] | HE_BACK=false |
|
||||
|
||||
**上滑 Round 3**(阿哲说"就 5 分钟"后,再次进入选择):
|
||||
- 右滑 → 林夏:"……来吧。" → 阿哲:"谢谢。我马上到。" → [挂断] → HE_BACK=true
|
||||
- 左滑 → 林夏:"还是算了。" → 阿哲:"好吧。" → [挂断] → HE_BACK=false
|
||||
- 下滑 → [沉默] → 阿哲:"那……你考虑一下,我等你消息。" → [挂断] → HE_BACK=false
|
||||
|
||||
**如果 HE_BACK=true** → 消息 12 之前插入门铃声 + 敲门声场景。
|
||||
|
||||
---
|
||||
@@ -382,8 +397,12 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|
||||
|
||||
接听后 → 林夏自动说"嗯,我没事。"
|
||||
妈妈:"真的没事?"
|
||||
- 如果 MOM_LINK=true:"好。那早点睡。妈爱你。"
|
||||
- 如果 MOM_LINK=false:"你今天怎么都不理妈妈?出什么事了吗?" → 可选择回应
|
||||
- 如果 MOM_LINK=true:"好。那早点睡。妈爱你。" → 对话结束
|
||||
- 如果 MOM_LINK=false:"你今天怎么都不理妈妈?出什么事了吗?" → 进入选择:
|
||||
- 右滑 → 林夏:"没事妈,就是手机坏了。" → 妈妈:"那你赶紧修啊。好了早点睡。" → MOM_LINK=true
|
||||
- 左滑 → 林夏:"嗯,没事。" → 妈妈:"……好吧。晚安。" → MOM_LINK=true
|
||||
- 下滑 → [沉默] → 妈妈:"你不说话我更担心。算了,不逼你了。晚安。" → MOM_LINK=true
|
||||
**不接** → MOM_LINK 保持当前值(可能仍为 false)
|
||||
|
||||
**状态**:接听 → MOM_LINK=true
|
||||
|
||||
@@ -409,12 +428,16 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|
||||
3. "那时候妈妈在广州,给一个香港老板做秘书,1995 年。那年我也被分手过。也被开除过。"
|
||||
4. "那年我哭得特别难看。后来呢?后来就没什么后来了。我回了老家,认识了你爸,生了你。"
|
||||
5. "我想跟你说,那年夏天我没死,是因为我外婆每天晚上都给我打一个电话。"
|
||||
- **音频设计**:第 5 段开头,叠入与 MSG-08 相同的电话底噪质感(轻微线路嘶声),持续 2-3 秒后淡出。如果玩家之前接了 MSG-08 的未知号码,这一刻会在听觉上产生似曾相识的感觉——同样的电话底噪、同样的呼吸停顿,但这次有了妈妈的声音。
|
||||
6. "她不说什么,就问我今天吃饭了没。"
|
||||
- **音频设计**:"不说什么"之后留 3 秒静默(仅电话底噪),呼应 MSG-08 的 6 秒呼吸声,形成跨消息的听觉对位。
|
||||
7. "我现在打给你,你不接也没事。妈妈给你发个语音,你什么时候想听就听。你不用回我。"
|
||||
8. "你今天接了好多电话吧。妈妈不打扰你了。晚安。"
|
||||
|
||||
*无选择交互。让玩家安静听完。播放结束 → 触发结局判定。*
|
||||
|
||||
**如果 MOM_LINK=false**:MSG-16 不播放。MSG-15 结束后,静默 5 秒(环境底噪),然后直接进入结局判定。此时结局 A 的条件不可能满足(缺少 MOM_LINK),流程会落入结局 B/C/D。
|
||||
|
||||
---
|
||||
|
||||
## 5. 分支判定变量
|
||||
@@ -424,10 +447,35 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|
||||
| **MOM_LINK** | 是否接妈妈两次电话中的至少一次 / 是否回复"睡了吗?" | 决定 MSG-16 是否播放 |
|
||||
| **HE_BACK** | 是否答应阿哲过来拿耳机(MSG-11 右滑) | 决定 MSG-12 前是否插入门铃场景 |
|
||||
| **GROUP_REPLY** | 是否在寝室群里回复过(MSG-07 非沉默) | 决定彩蛋消息 |
|
||||
| **ZHOUNAN_DEPTH** | 给周南的回应深度(累计) | 决定结局字幕 + self-3y 版本 |
|
||||
| **ZHOUNAN_DEPTH** | 给周南的回应深度(累计) | 决定结局独白 + self-3y 版本 |
|
||||
| **ZHOUNAN_SHARE** | 是否在周南对话中选择"分享自己"(MSG-13 上滑/右滑 R3 右滑) | 决定隐藏结局 |
|
||||
| **SELF_RECORD** | 是否听完 3 年前备忘录(MSG-15) | 决定结局类型 |
|
||||
| **PROFILE** | 全场行为聚合画像:avoidance / engagement / nostalgia | 决定 self-3y 版本 + 环境音 |
|
||||
| **UNREAD_COUNT** | 未播放/未接听的消息数(初始 16,每播放或接听 −1) | 决定结局 C/D |
|
||||
| **REPLY_COUNT** | 林夏实际发出回应的消息条数 | 决定结局 C |
|
||||
|
||||
**REPLY_COUNT 计数规则**:
|
||||
- 选择右滑/左滑/上滑/单击(发出语音回应)→ +1
|
||||
- 下滑(沉默)→ 不计
|
||||
- 电话接听后自动回应(MSG-01/03/14)→ +1
|
||||
- 双击跳过整条消息 → 不计
|
||||
- 发表情(MSG-07 单击)→ +1
|
||||
|
||||
### 变量影响层级
|
||||
|
||||
并非所有选择都对结局产生等量影响。设计上区分**核心变量**与**氛围变量**:
|
||||
|
||||
| 层级 | 变量 | 影响 | 相关消息 |
|
||||
|------|------|------|---------|
|
||||
| **核心** | MOM_LINK | 决定 MSG-16 是否播放(妈妈长独白),直接影响结局 A | MSG-01, 10, 14 |
|
||||
| **核心** | HE_BACK | 决定门铃场景是否触发,区分结局 A/B | MSG-11 |
|
||||
| **核心** | ZHOUNAN_DEPTH + ZHOUNAN_SHARE | 决定隐藏结局 E | MSG-12, 13 |
|
||||
| **辅助** | SELF_RECORD | 决定结局 A 是否可达 | MSG-15 |
|
||||
| **氛围** | GROUP_REPLY | 影响彩蛋消息,但不改变结局走向 | MSG-07 |
|
||||
| **氛围** | PROFILE(avoidance/engagement/nostalgia) | 影响环境音色调 + self-3y 版本,不直接决定结局 | 全局累计 |
|
||||
| **统计** | UNREAD_COUNT / REPLY_COUNT | 决定结局 C/D 的触发条件 | 全局自动累计 |
|
||||
|
||||
> **设计原则**:HR、安安、寝室群、小美醉酒等消息的选择主要影响 PROFILE 氛围和当下的对话反馈,让玩家"听得出这一句不一样"。真正改变结局走向的是**妈妈线、阿哲线、周南线**三条核心关系。玩家在这三条线上的选择会产生明确的、跨消息的后果。
|
||||
|
||||
### PROFILE 计分细则
|
||||
|
||||
@@ -450,31 +498,48 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|
||||
|
||||
### 结局 A · "明天就明天吧"
|
||||
**触发**:MOM_LINK=true + SELF_RECORD=true + 至少回了周南或群里 1 条
|
||||
**优先级**:2
|
||||
- 妈妈语音播完。林夏起身,环境声:走到厨房、烧水、坐在小板凳上。
|
||||
- 字幕:"明天再说。"
|
||||
- 林夏轻声自语:"明天再说。"
|
||||
- 烧水壶滴的一声 → 游戏结束。
|
||||
|
||||
### 结局 B · "AirPods 我寄给你"
|
||||
**触发**:HE_BACK=false + 回过周南至少 1 条
|
||||
- 林夏给阿哲发了文字"我寄给你,地址发我"。
|
||||
- 字幕:"寄出。"
|
||||
**优先级**:3
|
||||
- 林夏给阿哲发语音"我寄给你,地址发我"(打字音效 → 发送音效 → 林夏语音播放)。
|
||||
- 林夏轻声自语:"寄出去了。"
|
||||
- 一个"清醒地走"的结局。
|
||||
|
||||
### 结局 C · "全部已读"
|
||||
**触发**:未读数=0 且回复≤2 条
|
||||
- TTS:"今晚共 16 条新消息,已读 16,未回复 14。"
|
||||
- 长沉默。字幕:"今晚就这样。"
|
||||
**触发**:UNREAD_COUNT=0 且 REPLY_COUNT≤2
|
||||
**优先级**:4
|
||||
- 系统语音播报实际数值:"今晚共 16 条新消息,已读 {16−UNREAD_COUNT},未回复 {16−REPLY_COUNT}。"
|
||||
- 长沉默。林夏轻声自语:"今晚就这样。"
|
||||
|
||||
### 结局 D · "明天再开机"
|
||||
**触发**:未读数≥8
|
||||
- 系统提示电量 1%。触感震动 + 白噪 → 彻底黑。
|
||||
- 第二天清晨鸟叫。字幕:"早上好。"
|
||||
**触发**:UNREAD_COUNT≥8,**或**结局 E/A/B/C 均不满足(真正兜底)
|
||||
**优先级**:5(兜底结局)
|
||||
- 系统语音提示电量 1%。触感震动 + 白噪 → 彻底黑。
|
||||
- 第二天清晨鸟叫。林夏轻声自语:"早上好。"
|
||||
|
||||
### 结局 E · 隐藏 · "周南,我也记得你"
|
||||
**触发**:ZHOUNAN_DEPTH≥2 + ZHOUNAN_SHARE + PROFILE=engagement 或 nostalgia
|
||||
**优先级**:1(最高优先级)
|
||||
- 周南秒回:"那我下次出差来北京,请你吃饭好不好。"
|
||||
- 系统播放林夏的轻笑——这是全游戏唯一一次林夏主动笑出声。
|
||||
- 字幕:"好。"
|
||||
- 林夏轻声说:"好。"
|
||||
|
||||
### 结局判定规则
|
||||
|
||||
当玩家同时满足多个结局条件时,按**优先级数字从小到大**判定,取第一个满足的结局:
|
||||
|
||||
```
|
||||
E(隐藏,优先级 1)→ A(优先级 2)→ B(优先级 3)→ C(优先级 4)→ D(兜底,优先级 5)
|
||||
```
|
||||
|
||||
- 结局 E 最稀有、最难触发,一旦满足则覆盖其他结局。
|
||||
- 结局 D 是**真正的兜底结局**——无论玩家怎么玩,只要 E/A/B/C 都不满足就一定进入 D。UNREAD_COUNT≥8 是 D 的常见触发场景,但不是唯一入口。
|
||||
- 结局 A 和 B 可能同时满足(例如 MOM_LINK=true + SELF_RECORD=true + HE_BACK=false + 回过周南),此时优先触发 A,因为 A 是更完整的情感闭合。
|
||||
|
||||
---
|
||||
|
||||
@@ -493,7 +558,9 @@ NPC 说话 → 玩家选择方向 → 林夏说出回应 → NPC 对林夏的回
|
||||
| **环境音** | 卧室夜噪、酒吧、街声 | 4-5 段 | Freesound (CC0/CC-BY) |
|
||||
| **角色铃声** | 来电铃声 | 3-4 段 | 简单合成或 Suno 短旋律 |
|
||||
|
||||
### 7.2 TTS 方案:VoxCPM2 本地声音克隆
|
||||
### 7.2 TTS 方案:VoxCPM2 本地声音克隆(唯一主链路)
|
||||
|
||||
> **注意**:本项目 TTS 统一使用 **VoxCPM2**。`执行文档_音频素材制作.md` 中早期记录的 CosyVoice2 方案作为备用,不作为主生产链路。批量脚本、音色目录、QA 标准均以 VoxCPM2 为准。
|
||||
|
||||
所有角色语音使用 **VoxCPM2**(本地部署,无需外部 API),通过声音克隆实现一致的角色音色。
|
||||
|
||||
@@ -569,6 +636,7 @@ python audio/batch_tts_voxcpm.py --source voice --dry-run
|
||||
| `choiceSilent` | 下滑沉默 | 330Hz 极轻 |
|
||||
| `typing` | 打字中 | 6 次随机频率短脉冲 |
|
||||
| `send` | 发送 | 400→900Hz 上扫 |
|
||||
| `longPress` | 长按确认 | 330→440Hz 缓升 |
|
||||
| `ending` | 结局 | 三音渐弱 |
|
||||
|
||||
### 7.6 BGM 与环境音
|
||||
@@ -592,9 +660,9 @@ python audio/batch_tts_voxcpm.py --source voice --dry-run
|
||||
### 8.1 技术栈
|
||||
|
||||
纯前端 Web 应用(单 HTML + JS),无需后端服务器:
|
||||
- **手势识别**:`gestures.js` — 6 种手势(单击、双击、四方向滑动)
|
||||
- **手势识别**:`gestures.js` — 7 种手势(单击、双击、长按、四方向滑动)
|
||||
- **音频管理**:`audio.js` — Web Audio API 合成 + HTML5 Audio 播放
|
||||
- **触觉反馈**:`haptics.js` — Vibration API
|
||||
- **触觉反馈**:`haptics.js` — Vibration API(增强反馈,非必需;见下方降级说明)
|
||||
- **剧情数据**:`story.js` — 16 条消息的完整对话树
|
||||
- **游戏引擎**:`engine.js` — 状态机 + 异步对话流程
|
||||
- **行为画像**:`profile.js` — PROFILE 计分 + 状态变量
|
||||
@@ -605,20 +673,43 @@ python audio/batch_tts_voxcpm.py --source voice --dry-run
|
||||
idle → notification/ringing → playing → choosing → responding → reacting → [choosing | idle] → ending
|
||||
```
|
||||
|
||||
| 状态 | 说明 | 可用手势 |
|
||||
|------|------|---------|
|
||||
| idle | 等待消息 | — |
|
||||
| notification | 消息到达提示中 | 单击(播放)、双击(跳过) |
|
||||
| ringing | 来电铃声中 | 单击(接听)、左滑(拒接) |
|
||||
| playing | NPC 语音播放中 | 双击(跳过语音) |
|
||||
| choosing | 等待玩家选择 | 右滑、左滑、上滑、下滑、单击 |
|
||||
| responding | 林夏语音播放中 | 双击(跳过) |
|
||||
| reacting | NPC 反应播放中 | 双击(跳过) |
|
||||
| ending | 结局播放中 | — |
|
||||
| 状态 | 说明 | 可用手势 | 进入音效 |
|
||||
|------|------|---------|---------|
|
||||
| idle | 等待消息 | 长按(播报状态) | — |
|
||||
| notification | 消息到达提示中 | 单击(播放)、双击(跳过)、长按(播报状态) | 提示音 + 震动 |
|
||||
| ringing | 来电铃声中 | 单击(接听)、左滑(拒接)、长按(播报来电人) | 铃声 + 长震 |
|
||||
| playing | NPC 语音播放中 | 双击(跳过语音)、长按(重听本句) | — |
|
||||
| choosing | 等待玩家选择 | 右滑、左滑、上滑、下滑、单击、长按(重听 NPC 上一句) | **三音上行 + 长-短-长震动 + "等待回复"语音** |
|
||||
| responding | 林夏语音播放中 | 双击(跳过) | 打字音效 |
|
||||
| reacting | NPC 反应播放中 | 双击(跳过) | — |
|
||||
| ending | 结局播放中 | 三指点击(退出) | 结局专属音效 |
|
||||
|
||||
> **关键**:`playing → choosing` 的状态切换必须有**明确的听觉分界**(三音上行 + 震动模式变化 + "等待回复"语音提示),确保玩家在纯音频环境下不会误以为自己还在听消息。单击在 `notification` 状态是"播放",在 `choosing` 状态是"嗯"——两者之间有明确的进入选择态提示,不会混淆。
|
||||
|
||||
### 8.3 异步对话流程
|
||||
|
||||
```javascript
|
||||
// 进入选择态时的明确确认
|
||||
function enterChoosing(choices) {
|
||||
state = 'choosing';
|
||||
SFX_UI.choiceReady(); // 三音上行
|
||||
haptics.pattern([200, 100, 80, 100, 200]); // 长-短-长 震动
|
||||
speak('等待回复'); // 系统语音确认
|
||||
currentChoices = choices;
|
||||
}
|
||||
|
||||
// 长按处理:任何状态下可用
|
||||
async _handleLongPress() {
|
||||
SFX_UI.longPress();
|
||||
if (state === 'choosing' || state === 'playing') {
|
||||
// 重播上一句 NPC 语音
|
||||
await playVoiceAsync([lastNpcAudio]);
|
||||
} else {
|
||||
// 播报当前状态
|
||||
speak(getStatusText()); // 如:"小美的消息,等待回复"
|
||||
}
|
||||
}
|
||||
|
||||
async _handleChoice(dir) {
|
||||
const choice = currentMsg.choices[dir];
|
||||
|
||||
@@ -667,7 +758,29 @@ async _handleChoice(dir) {
|
||||
- 中断当前语音播放
|
||||
- 异步函数在每个步骤检查 skip 标志,快速跳到下一阶段
|
||||
|
||||
### 8.5 隐私承诺
|
||||
### 8.5 全局安全手势
|
||||
|
||||
除 §2.1 的 7 种剧情手势外,有两种**全局安全手势**,它们的优先级高于任何剧情手势,在所有状态下均可触发:
|
||||
|
||||
| 手势 | 功能 | 优先级 |
|
||||
|------|------|--------|
|
||||
| **两指长按(1s)** | 暂停游戏 | 最高——立即中断当前音频播放,进入暂停状态 |
|
||||
| **三指点击** | 安全退出 | 最高——系统语音确认后跳转到支持资源页 |
|
||||
|
||||
**实现要求**:
|
||||
- `gestures.js` 在手势识别时先判断触摸点数量:≥3 → 安全退出,=2 且持续 ≥1s → 暂停,=1 → 进入剧情手势判断。
|
||||
- 暂停状态下仅响应:单击(继续)、三指点击(退出)。
|
||||
- 安全手势不影响任何游戏变量(PROFILE、REPLY_COUNT 等不变)。
|
||||
|
||||
### 8.6 触觉降级策略
|
||||
|
||||
触觉反馈(Vibration API)是增强体验,**不是必需通道**。iPhone Safari 不支持 Vibration API,部分 Android 浏览器也可能受限。设计原则:
|
||||
|
||||
- **所有状态切换必须有独立的音效/语音确认**,不能仅靠震动区分。
|
||||
- `haptics.js` 在初始化时检测 `navigator.vibrate` 是否可用,不可用时静默降级。
|
||||
- 测试时必须在关闭震动的情况下完整通关一遍,确认纯音频也能正常游玩。
|
||||
|
||||
### 8.6 隐私承诺
|
||||
|
||||
本作**不申请任何敏感权限**:
|
||||
- ❌ 不要麦克风、摄像头、位置、通讯录
|
||||
@@ -707,7 +820,7 @@ async _handleChoice(dir) {
|
||||
- 林夏 ~55 条,~100 秒
|
||||
- NPC ~90 条,~835 秒
|
||||
- **总语音约 145 条,约 15.5 分钟**
|
||||
- UI 音效:12 种,全部 Web Audio 合成
|
||||
- UI 音效:13 种,全部 Web Audio 合成
|
||||
- BGM:4 段
|
||||
- 环境音:4-5 段
|
||||
|
||||
@@ -721,7 +834,7 @@ blind/
|
||||
│ ├── index.html # 入口
|
||||
│ └── js/
|
||||
│ ├── main.js # 启动 + 事件绑定
|
||||
│ ├── gestures.js # 6 种手势识别
|
||||
│ ├── gestures.js # 7 种手势识别(含长按)
|
||||
│ ├── haptics.js # 触觉反馈
|
||||
│ ├── audio.js # 音频管理 + UI 合成音效
|
||||
│ ├── story.js # 16 条消息剧情数据
|
||||
@@ -763,7 +876,27 @@ blind/
|
||||
| 手势误判导致选错 | 滑动阈值 30px + 40°范围;双击间隔 250ms 内 |
|
||||
| 玩家不理解手势含义 | MSG-02(小美教学关)强引导,每步正向反馈 |
|
||||
| 女性玩家代入感 | 编剧组需有 24 岁左右女性参与 |
|
||||
| 题材敏感 | 开场加心理预警 + 提供试玩版 |
|
||||
| 题材敏感 | 见下方"情绪安全设计"专节 |
|
||||
|
||||
### 情绪安全设计
|
||||
|
||||
本作涉及分手、裁员、独居、深夜来电、家庭创伤回忆等高情绪浓度内容,必须认真对待玩家的心理安全。
|
||||
|
||||
**开场内容提示**(游戏启动后、第一条消息前):
|
||||
- 系统语音播报:"本游戏涉及情感压力、职场失落、家庭关系等主题。如果你当前情绪状态不佳,建议择日再玩。游戏过程中,两指长按可暂停,三指点击可直接退出。"
|
||||
|
||||
**游戏中安全机制**:
|
||||
- **随时暂停**:任何状态下两指同时长按 1 秒,游戏暂停,系统语音:"游戏已暂停。单击继续,三指点击退出。"(两指长按与单指双击完全不同,避免误触跳过)
|
||||
- **安全退出**:三指同时点击,系统语音确认后直接跳转到结尾支持资源页。
|
||||
- **长按状态播报**:让玩家随时确认自己在哪,减少焦虑感。
|
||||
|
||||
**结尾支持资源**(任何结局播放完毕后):
|
||||
- 系统语音:"感谢你陪林夏度过这一夜。如果你现在需要和人说说话:全国 24 小时心理援助热线 400-161-9995;北京心理危机研究与干预中心 010-8295-1332。"
|
||||
- 音频播放完毕后保持在屏幕上(即使是纯音频游戏,此处允许显示文字——用户此时可以看手机了,屏幕"修好了"是一个隐喻)。
|
||||
|
||||
**叙事安全原则**:
|
||||
- 林夏的自救不完全寄托在妈妈或男性角色身上——妈妈提供的是"被记住"的感觉,周南提供的是"被看见"的感觉,但最终让林夏起身烧水、给阿哲寄耳机的,是她自己的选择。
|
||||
- MSG-16 妈妈语音中"那年夏天我没死"这句表述需谨慎处理:妈妈的语气是平静追忆而非戏剧化,TTS 合成时 emotion 设为 `gentle, peaceful`,避免制造突然的情绪冲击。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user