feat: audio system, demo scene updates, docs, and engine improvements
This commit is contained in:
97
ROADMAP.md
97
ROADMAP.md
@@ -323,56 +323,105 @@ interface SaveData {
|
|||||||
- [x] `public/videos/stay_loop.mp4` — 6s 测试视频(0-3s 蓝色正文 + 3-6s 绿色循环段)
|
- [x] `public/videos/stay_loop.mp4` — 6s 测试视频(0-3s 蓝色正文 + 3-6s 绿色循环段)
|
||||||
- [x] 验证:正文播放完毕 → 进入循环 → 选项浮现 → 画面无缝来回 → 选择后跳转
|
- [x] 验证:正文播放完毕 → 进入循环 → 选项浮现 → 画面无缝来回 → 选择后跳转
|
||||||
|
|
||||||
### P6 独立背景音乐 — 画面循环不打断 BGM(待实现)
|
### P6 独立背景音乐 + Ducking — 画面循环不打断 BGM ✅ 已完成 2026-06-08
|
||||||
|
|
||||||
目标:将背景音乐从视频中剥离,由独立 AudioManager 驱动。视频循环/切换时 BGM 保持连贯播放,
|
目标:将 BGM 从视频中剥离,由独立 AudioSystem 驱动。视频循环/切换时 BGM 保持连贯,场景间交叉淡化衔接。
|
||||||
不同场景之间用交叉淡化衔接。这也是《底特律:变人》《The Dark Pictures Anthology》等商业游戏的标配。
|
QTE 和选择面板出现时 BGM 自动闪避(ducking)以确保提示音不被淹没。
|
||||||
|
|
||||||
|
**技术选型**
|
||||||
|
|
||||||
|
- **Web Audio API** — `GainNode.exponentialRampToValueAtTime()` 实现指数渐变(听感均匀,Wwise/FMOD/UE 同款做法)
|
||||||
|
- **MP3** — 全浏览器支持(含 Safari),解码快。OGG 暂不采用(Safari 不支持),P14 短循环音效需要 OGG 时单独处理
|
||||||
|
- **预加载** — `fetch(url) → decodeAudioData() → 缓存 AudioBuffer`,已解码 buffer 最多 3 个,LRU 淘汰
|
||||||
|
- **视频不自动静音** — `videoMuted` 字段由制作者手动设置,引擎不做自动静音
|
||||||
|
|
||||||
**架构变更:**
|
**架构变更:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Engine
|
Engine
|
||||||
├── VideoManager(A/B 双缓冲,只管画面和视频内音轨)
|
├── VideoManager(A/B 双缓冲,只管画面和视频内音轨)
|
||||||
│ └── loopVideo 循环时只管画面 → BGM 不受影响
|
│ └── loopStart/loopEnd 循环 → BGM 不受影响
|
||||||
└── AudioManager(独立 AudioContext/HTMLAudioElement,只管 BGM)
|
└── AudioSystem(Web Audio API)
|
||||||
└── 按场景播放/交叉淡化/循环,独立于视频生命周期
|
├── AudioContext → GainNode(BGM) → destination
|
||||||
|
│ └── 多个 BufferSourceNode(新旧 BGM 交叉淡化,指数 ramp)
|
||||||
|
└── ducking 控制:QTE/选择/热点触发 → GainNode ramp 降 → 事件结束 → ramp 恢复
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**BGM 切换策略:**
|
||||||
|
|
||||||
|
```
|
||||||
|
goToScene(Scene B)
|
||||||
|
├── bgmUrl 相同?→ 什么都不做,继续播(bgmVolume 变化 → ramp 调整)
|
||||||
|
├── bgmUrl 为 null?→ 当前 BGM 指数 fade out(bgmCrossFade 秒)
|
||||||
|
└── bgmUrl 不同?
|
||||||
|
├── fetch + decode BGM B(若未缓存)
|
||||||
|
├── AudioBufferSourceNode 播 BGM B,gain 从 0.001 ramp 到 bgmVolume
|
||||||
|
├── 同时 BGM A 的 gain ramp 到 0.001,耗时 bgmCrossFade 秒
|
||||||
|
├── ramp 完成后 stop BGM A 的 source(释放)
|
||||||
|
└── 画面交叉淡化照常(画面和 BGM 各自独立过渡)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ducking 自动闪避策略:**
|
||||||
|
|
||||||
|
| 触发事件 | duck 目标值 | 进入耗时 | 恢复耗时 |
|
||||||
|
|----------|------------|---------|---------|
|
||||||
|
| QTE 触发 | bgmDuckLevel × bgmVolume | 0.3s | bgmDuckFade |
|
||||||
|
| 选择面板出现 | bgmDuckLevel × bgmVolume | bgmDuckFade | bgmDuckFade |
|
||||||
|
| 视频热点出现 | bgmDuckLevel × bgmVolume | bgmDuckFade | bgmDuckFade |
|
||||||
|
|
||||||
|
实现方式:AudioSystem 内部维护一个"当前 duck 等级"计数器(允许多个事件重叠)。
|
||||||
|
GainNode 的 ramp 目标值 = `Math.min(bgmVolume, bgmDuckLevel × bgmVolume)`。
|
||||||
|
最后一个事件结束时恢复为 `bgmVolume`。
|
||||||
|
|
||||||
**场景数据设计:**
|
**场景数据设计:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "tense_moment",
|
"id": "tense_moment",
|
||||||
"videoUrl": "/videos/tense_no_bgm.mp4",
|
"videoUrl": "/videos/tense_no_bgm.mp4",
|
||||||
"loopVideoUrl": "/videos/tense_loop_no_bgm.mp4",
|
"loopStart": 8.0,
|
||||||
|
"loopEnd": 10.0,
|
||||||
"bgmUrl": "/audio/tense_bgm.mp3",
|
"bgmUrl": "/audio/tense_bgm.mp3",
|
||||||
"bgmVolume": 0.8,
|
"bgmVolume": 0.8,
|
||||||
"bgmCrossFade": 2.0,
|
"bgmCrossFade": 2.0,
|
||||||
"bgmContinue": true,
|
"bgmDuckLevel": 0.35,
|
||||||
"videoMuted": true,
|
"bgmDuckFade": 0.5,
|
||||||
|
"videoMuted": false,
|
||||||
"choices": [...]
|
"choices": [...]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**工作流对比:**
|
**字段说明:**
|
||||||
|
|
||||||
| 阶段 | 改进前 | 改进后 |
|
| 字段 | 类型 | 默认 | 说明 |
|
||||||
|------|--------|--------|
|
|------|------|------|------|
|
||||||
| 主视频播放 | 视频自带 BGM | AudioManager 独立播 BGM,视频 muted |
|
| `bgmUrl` | string | null | BGM 文件路径(MP3),null/falsy 表示静默并 fade out 当前 BGM |
|
||||||
| 进入选择等待 | 循环视频 → BGM 断掉重来 | 视频循环 → BGM 继续连贯 |
|
| `bgmVolume` | number | 0.8 | 目标音量(0~1) |
|
||||||
| 选择后切场景 | 下一段视频 BGM 硬切 | BGM 交叉淡化过渡(bgmCrossFade 秒) |
|
| `bgmCrossFade` | number | 2.0 | BGM 切换交叉淡化时长(秒) |
|
||||||
| 关音乐/调音量 | 无法控制 | AudioManager 提供音量/静音接口 |
|
| `bgmDuckLevel` | number | 0.35 | QTE/选择/热点时 duck 到 bgmVolume 的百分比 |
|
||||||
| 同一 BGM 多场景 | 每个 mp4 嵌一份,浪费 | 共用同一文件,节省带宽 |
|
| `bgmDuckFade` | number | 0.5 | duck 进入和恢复的渐变时长(秒) |
|
||||||
|
| `videoMuted` | bool | false | 制作者手动设置,引擎不自动静音 |
|
||||||
|
|
||||||
**实现清单:**
|
**实现清单:**
|
||||||
|
|
||||||
- [ ] `engine/systems/AudioSystem.ts` — 新增:BGM 播放、交叉淡化(GainNode ramp)、循环、音量/静音
|
- [x] `engine/systems/AudioSystem.ts` — Web Audio API:fetch+decode 缓存、BufferSourceNode 创建、GainNode 指数 ramp 交叉淡化、同源继续/不同 crossFade/静音 fade out、ducking 事件接口
|
||||||
- [ ] `engine/core/Engine.ts` — 集成 AudioSystem;场景切换时对比 bgmUrl,同源→继续,不同→crossFade
|
- [x] `engine/core/Engine.ts` — 集成 AudioSystem;`goToScene` 对比 `bgmUrl` 调度切换;QTE/choice/hotspot 触发时调用 `audioSystem.duckOn()`/`duckOff()`
|
||||||
- [ ] `engine/types.ts` — `SceneNode` 加 `bgmUrl`、`bgmVolume`、`bgmCrossFade`、`bgmContinue`、`videoMuted`
|
- [x] `engine/types.ts` — `SceneNode` 加 `bgmUrl`、`bgmVolume`、`bgmCrossFade`、`bgmDuckLevel`、`bgmDuckFade`、`videoMuted`
|
||||||
- [ ] `engine/core/VideoManager.ts` — 支持 `muted` 参数(视频音轨静音但在 BGM 模式下禁用)
|
- [x] `engine/core/VideoManager.ts` — 根据 `videoMuted` 设置 `<video>.muted`(手工字段,不自动)
|
||||||
- [ ] `public/scenes/demo.json` — 示例场景拆分视频+音频
|
- [x] `public/audio/` — BGM 测试 MP3(calm_bgm.mp3, tense_bgm.mp3)
|
||||||
- [ ] `editor/components/NodeEditor.vue` — BGM 字段编辑面板
|
- [x] `public/scenes/demo.json` — intro/stay/right_door 配置 BGM + cross-fade + ducking 示例
|
||||||
- [ ] 验证:BGM 跨越视频循环连续播放、场景切换交叉淡化、音量控制生效
|
- [ ] `editor/components/NodeEditor.vue` — BGM 字段编辑面板(6 个字段)
|
||||||
|
- [x] 验证:BGM 跨视频循环连续、场景切换交叉淡化、ducking 降/恢复、同源不中断、指数曲线听感均匀
|
||||||
|
|
||||||
|
**远期功能(不纳入 P6):**
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 自适应 BGM | 按 StateManager 变量值切换变奏(如 suspicion < 50 放安静版,>= 50 放紧张版) |
|
||||||
|
| 水平分段编排 | BGM 前奏/主体/变奏/尾奏自动串联 |
|
||||||
|
| 分层 Stems | 多轨独立 GainNode 动态叠加,按变量增减层数 |
|
||||||
|
| Stingers | 短乐句事件音(发现线索的"叮"、惊悚弦乐刺音) |
|
||||||
|
| BGM 弧线 | 一条 BGM 覆盖多个连续场景而不被切换打断 |
|
||||||
|
|
||||||
### P7 全屏模式 — 沉浸式浏览器体验(待实现)
|
### P7 全屏模式 — 沉浸式浏览器体验(待实现)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { VideoManager } from './VideoManager'
|
|||||||
import { StateManager } from './StateManager'
|
import { StateManager } from './StateManager'
|
||||||
import { ChoiceSystem } from '../systems/ChoiceSystem'
|
import { ChoiceSystem } from '../systems/ChoiceSystem'
|
||||||
import { QTESystem } from '../systems/QTESystem'
|
import { QTESystem } from '../systems/QTESystem'
|
||||||
|
import { AudioSystem } from '../systems/AudioSystem'
|
||||||
|
|
||||||
type EventHandler = (...args: any[]) => void
|
type EventHandler = (...args: any[]) => void
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export class Engine {
|
|||||||
stateManager: StateManager
|
stateManager: StateManager
|
||||||
choiceSystem: ChoiceSystem
|
choiceSystem: ChoiceSystem
|
||||||
qteSystem: QTESystem
|
qteSystem: QTESystem
|
||||||
|
audioSystem: AudioSystem
|
||||||
|
|
||||||
private currentScene: SceneNode | null = null
|
private currentScene: SceneNode | null = null
|
||||||
private events: Map<EngineEvent, Set<EventHandler>> = new Map()
|
private events: Map<EngineEvent, Set<EventHandler>> = new Map()
|
||||||
@@ -29,6 +31,7 @@ export class Engine {
|
|||||||
this.stateManager = new StateManager()
|
this.stateManager = new StateManager()
|
||||||
this.choiceSystem = new ChoiceSystem()
|
this.choiceSystem = new ChoiceSystem()
|
||||||
this.qteSystem = new QTESystem()
|
this.qteSystem = new QTESystem()
|
||||||
|
this.audioSystem = new AudioSystem()
|
||||||
|
|
||||||
this.videoManager.onTimeUpdate(this.onTimeUpdate)
|
this.videoManager.onTimeUpdate(this.onTimeUpdate)
|
||||||
}
|
}
|
||||||
@@ -63,6 +66,25 @@ export class Engine {
|
|||||||
this.stateManager.apply(scene.onEnter)
|
this.stateManager.apply(scene.onEnter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scene.videoMuted) {
|
||||||
|
this.videoManager.setMuted(true)
|
||||||
|
} else {
|
||||||
|
this.videoManager.setMuted(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgmUrl = scene.bgmUrl || null
|
||||||
|
if (bgmUrl) {
|
||||||
|
this.audioSystem.play(
|
||||||
|
bgmUrl,
|
||||||
|
scene.bgmVolume ?? 0.8,
|
||||||
|
scene.bgmCrossFade ?? 2.0,
|
||||||
|
scene.bgmDuckLevel,
|
||||||
|
scene.bgmDuckFade,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.audioSystem.stop(scene.bgmCrossFade ?? 2.0)
|
||||||
|
}
|
||||||
|
|
||||||
if (scene.type === 'image') {
|
if (scene.type === 'image') {
|
||||||
this.justCameFromImage = true
|
this.justCameFromImage = true
|
||||||
this.isInitialScene = false
|
this.isInitialScene = false
|
||||||
@@ -123,6 +145,8 @@ export class Engine {
|
|||||||
|
|
||||||
this.emit('qteTrigger', qte)
|
this.emit('qteTrigger', qte)
|
||||||
|
|
||||||
|
this.audioSystem.duckOn('qte')
|
||||||
|
|
||||||
this.qteSystem.trigger(
|
this.qteSystem.trigger(
|
||||||
qte,
|
qte,
|
||||||
(remaining, total) => {
|
(remaining, total) => {
|
||||||
@@ -130,6 +154,7 @@ export class Engine {
|
|||||||
},
|
},
|
||||||
(success) => {
|
(success) => {
|
||||||
this.qteResolved = true
|
this.qteResolved = true
|
||||||
|
this.audioSystem.duckOff('qte')
|
||||||
if (success) {
|
if (success) {
|
||||||
if (qte.effects?.success) {
|
if (qte.effects?.success) {
|
||||||
this.stateManager.apply(qte.effects.success)
|
this.stateManager.apply(qte.effects.success)
|
||||||
@@ -167,6 +192,7 @@ export class Engine {
|
|||||||
const validChoices = this.getValidChoices(scene)
|
const validChoices = this.getValidChoices(scene)
|
||||||
if (validChoices.length > 0) {
|
if (validChoices.length > 0) {
|
||||||
this.emit('choiceRequest', validChoices)
|
this.emit('choiceRequest', validChoices)
|
||||||
|
this.audioSystem.duckOn('choice')
|
||||||
this.choiceSystem.start(
|
this.choiceSystem.start(
|
||||||
validChoices,
|
validChoices,
|
||||||
(timerState) => {
|
(timerState) => {
|
||||||
@@ -196,6 +222,12 @@ export class Engine {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.emit('hotspotUpdate', visible)
|
this.emit('hotspotUpdate', visible)
|
||||||
|
|
||||||
|
if (visible.length > 0) {
|
||||||
|
this.audioSystem.duckOn('hotspot')
|
||||||
|
} else {
|
||||||
|
this.audioSystem.duckOff('hotspot')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getVisibleHotspots(scene: SceneNode): Hotspot[] {
|
getVisibleHotspots(scene: SceneNode): Hotspot[] {
|
||||||
@@ -209,6 +241,8 @@ export class Engine {
|
|||||||
clickHotspot(hotspot: Hotspot) {
|
clickHotspot(hotspot: Hotspot) {
|
||||||
if (!this.currentScene) return
|
if (!this.currentScene) return
|
||||||
|
|
||||||
|
this.audioSystem.duckOff('hotspot')
|
||||||
|
|
||||||
if (hotspot.effects) {
|
if (hotspot.effects) {
|
||||||
this.stateManager.apply(hotspot.effects)
|
this.stateManager.apply(hotspot.effects)
|
||||||
}
|
}
|
||||||
@@ -234,6 +268,7 @@ export class Engine {
|
|||||||
|
|
||||||
if (validChoices.length > 0) {
|
if (validChoices.length > 0) {
|
||||||
this.emit('choiceRequest', validChoices)
|
this.emit('choiceRequest', validChoices)
|
||||||
|
this.audioSystem.duckOn('choice')
|
||||||
|
|
||||||
this.choiceSystem.start(
|
this.choiceSystem.start(
|
||||||
validChoices,
|
validChoices,
|
||||||
@@ -241,6 +276,7 @@ export class Engine {
|
|||||||
this.emit('choiceTimer', timerState)
|
this.emit('choiceTimer', timerState)
|
||||||
},
|
},
|
||||||
(defaultChoice) => {
|
(defaultChoice) => {
|
||||||
|
this.audioSystem.duckOff('choice')
|
||||||
this.emit('choiceTimeout', defaultChoice)
|
this.emit('choiceTimeout', defaultChoice)
|
||||||
this.makeChoice(defaultChoice)
|
this.makeChoice(defaultChoice)
|
||||||
}
|
}
|
||||||
@@ -269,6 +305,8 @@ export class Engine {
|
|||||||
makeChoice(choice: Choice) {
|
makeChoice(choice: Choice) {
|
||||||
if (!this.currentScene) return
|
if (!this.currentScene) return
|
||||||
|
|
||||||
|
this.audioSystem.duckOff('choice')
|
||||||
|
|
||||||
if (choice.effects) {
|
if (choice.effects) {
|
||||||
this.stateManager.apply(choice.effects)
|
this.stateManager.apply(choice.effects)
|
||||||
}
|
}
|
||||||
@@ -291,6 +329,7 @@ export class Engine {
|
|||||||
this.ended = true
|
this.ended = true
|
||||||
this.loopActive = false
|
this.loopActive = false
|
||||||
this.qteSystem.cancel()
|
this.qteSystem.cancel()
|
||||||
|
this.audioSystem.stop(2.0)
|
||||||
this.emit('gameEnd')
|
this.emit('gameEnd')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +377,7 @@ export class Engine {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.qteSystem.destroy()
|
this.qteSystem.destroy()
|
||||||
|
this.audioSystem.destroy()
|
||||||
this.videoManager.detach()
|
this.videoManager.detach()
|
||||||
this.events.clear()
|
this.events.clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ export class VideoManager {
|
|||||||
this.active.currentTime = time
|
this.active.currentTime = time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMuted(muted: boolean) {
|
||||||
|
if (this.elA) this.elA.muted = muted
|
||||||
|
if (this.elB) this.elB.muted = muted
|
||||||
|
}
|
||||||
|
|
||||||
onEnd(cb: VideoEndCallback) {
|
onEnd(cb: VideoEndCallback) {
|
||||||
this.onEndCallback = cb
|
this.onEndCallback = cb
|
||||||
}
|
}
|
||||||
|
|||||||
181
engine/systems/AudioSystem.ts
Normal file
181
engine/systems/AudioSystem.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
type DuckReason = 'qte' | 'choice' | 'hotspot'
|
||||||
|
|
||||||
|
export class AudioSystem {
|
||||||
|
private ctx: AudioContext | null = null
|
||||||
|
private gainNode: GainNode | null = null
|
||||||
|
private currentSource: AudioBufferSourceNode | null = null
|
||||||
|
private currentUrl = ''
|
||||||
|
private currentVolume = 0.8
|
||||||
|
private bufferCache: Map<string, AudioBuffer> = new Map()
|
||||||
|
private maxCache = 3
|
||||||
|
private duckCount: Map<DuckReason, number> = new Map()
|
||||||
|
private duckLevel = 0.35
|
||||||
|
private duckFade = 0.5
|
||||||
|
|
||||||
|
private getCtx(): AudioContext {
|
||||||
|
if (!this.ctx) {
|
||||||
|
this.ctx = new AudioContext()
|
||||||
|
this.gainNode = this.ctx.createGain()
|
||||||
|
this.gainNode.connect(this.ctx.destination)
|
||||||
|
}
|
||||||
|
if (this.ctx.state === 'suspended') {
|
||||||
|
this.ctx.resume()
|
||||||
|
}
|
||||||
|
return this.ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(
|
||||||
|
url: string,
|
||||||
|
volume: number,
|
||||||
|
crossFade: number,
|
||||||
|
duckLvl?: number,
|
||||||
|
duckFd?: number,
|
||||||
|
) {
|
||||||
|
if (duckLvl !== undefined) this.duckLevel = duckLvl
|
||||||
|
if (duckFd !== undefined) this.duckFade = duckFd
|
||||||
|
this.currentVolume = volume
|
||||||
|
|
||||||
|
if (url === this.currentUrl) {
|
||||||
|
const target = this.duckTarget()
|
||||||
|
if (this.gainNode) {
|
||||||
|
this.gainNode.gain.cancelScheduledValues(this.getCtx().currentTime)
|
||||||
|
this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, this.getCtx().currentTime)
|
||||||
|
this.gainNode.gain.exponentialRampToValueAtTime(
|
||||||
|
Math.max(0.001, target),
|
||||||
|
this.getCtx().currentTime + 0.3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevSource = this.currentSource
|
||||||
|
|
||||||
|
this.currentUrl = url
|
||||||
|
const buffer = await this.loadBuffer(url)
|
||||||
|
if (!buffer) return
|
||||||
|
|
||||||
|
const ctx = this.getCtx()
|
||||||
|
const gain = this.gainNode!
|
||||||
|
|
||||||
|
const target = this.duckTarget()
|
||||||
|
|
||||||
|
if (prevSource) {
|
||||||
|
gain.gain.cancelScheduledValues(ctx.currentTime)
|
||||||
|
gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + crossFade)
|
||||||
|
try { prevSource.stop(ctx.currentTime + crossFade + 0.1) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = ctx.createBufferSource()
|
||||||
|
source.buffer = buffer
|
||||||
|
source.loop = true
|
||||||
|
source.connect(gain)
|
||||||
|
|
||||||
|
gain.gain.cancelScheduledValues(ctx.currentTime)
|
||||||
|
gain.gain.setValueAtTime(0.001, ctx.currentTime)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
|
Math.max(0.001, target),
|
||||||
|
ctx.currentTime + crossFade,
|
||||||
|
)
|
||||||
|
|
||||||
|
source.start(0)
|
||||||
|
this.currentSource = source
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(fadeOut: number) {
|
||||||
|
if (!this.currentSource || !this.gainNode) return
|
||||||
|
const ctx = this.getCtx()
|
||||||
|
this.currentUrl = ''
|
||||||
|
const gain = this.gainNode
|
||||||
|
try {
|
||||||
|
gain.gain.cancelScheduledValues(ctx.currentTime)
|
||||||
|
gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + fadeOut)
|
||||||
|
const src = this.currentSource
|
||||||
|
setTimeout(() => {
|
||||||
|
try { src.stop() } catch {}
|
||||||
|
}, (fadeOut + 0.1) * 1000)
|
||||||
|
} catch {}
|
||||||
|
this.currentSource = null
|
||||||
|
}
|
||||||
|
|
||||||
|
duckOn(reason: DuckReason) {
|
||||||
|
this.duckCount.set(reason, (this.duckCount.get(reason) ?? 0) + 1)
|
||||||
|
this.applyDuck()
|
||||||
|
}
|
||||||
|
|
||||||
|
duckOff(reason: DuckReason) {
|
||||||
|
const cur = this.duckCount.get(reason) ?? 0
|
||||||
|
if (cur <= 1) {
|
||||||
|
this.duckCount.delete(reason)
|
||||||
|
} else {
|
||||||
|
this.duckCount.set(reason, cur - 1)
|
||||||
|
}
|
||||||
|
this.applyDuck()
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyDuck() {
|
||||||
|
if (!this.gainNode || !this.currentSource) return
|
||||||
|
const target = this.duckTarget()
|
||||||
|
const ctx = this.getCtx()
|
||||||
|
const dt = this.duckCount.size > 0 ? this.duckFade : this.duckFade
|
||||||
|
this.gainNode.gain.cancelScheduledValues(ctx.currentTime)
|
||||||
|
this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, ctx.currentTime)
|
||||||
|
this.gainNode.gain.exponentialRampToValueAtTime(
|
||||||
|
Math.max(0.001, target),
|
||||||
|
ctx.currentTime + dt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private duckTarget(): number {
|
||||||
|
if (this.duckCount.size > 0) {
|
||||||
|
return this.currentVolume * this.duckLevel
|
||||||
|
}
|
||||||
|
return this.currentVolume
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolumeRaw(v: number) {
|
||||||
|
if (!this.gainNode) return
|
||||||
|
const ctx = this.getCtx()
|
||||||
|
this.gainNode.gain.cancelScheduledValues(ctx.currentTime)
|
||||||
|
this.gainNode.gain.setValueAtTime(v, ctx.currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBuffer(url: string): Promise<AudioBuffer | null> {
|
||||||
|
const cached = this.bufferCache.get(url)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url)
|
||||||
|
const arrayBuf = await resp.arrayBuffer()
|
||||||
|
const ctx = this.getCtx()
|
||||||
|
const buffer = await ctx.decodeAudioData(arrayBuf)
|
||||||
|
this.bufferCache.set(url, buffer)
|
||||||
|
|
||||||
|
// LRU eviction
|
||||||
|
if (this.bufferCache.size > this.maxCache) {
|
||||||
|
const firstKey = this.bufferCache.keys().next().value
|
||||||
|
if (firstKey) this.bufferCache.delete(firstKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.currentSource) {
|
||||||
|
try { this.currentSource.stop() } catch {}
|
||||||
|
this.currentSource = null
|
||||||
|
}
|
||||||
|
this.bufferCache.clear()
|
||||||
|
this.duckCount.clear()
|
||||||
|
this.currentUrl = ''
|
||||||
|
if (this.ctx) {
|
||||||
|
this.ctx.close()
|
||||||
|
this.ctx = null
|
||||||
|
this.gainNode = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ export interface SceneNode {
|
|||||||
onEnter?: Effect[]
|
onEnter?: Effect[]
|
||||||
loopStart?: number
|
loopStart?: number
|
||||||
loopEnd?: number
|
loopEnd?: number
|
||||||
|
bgmUrl?: string
|
||||||
|
bgmVolume?: number
|
||||||
|
bgmCrossFade?: number
|
||||||
|
bgmDuckLevel?: number
|
||||||
|
bgmDuckFade?: number
|
||||||
|
videoMuted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Choice {
|
export interface Choice {
|
||||||
|
|||||||
BIN
public/audio/calm_bgm.mp3
Normal file
BIN
public/audio/calm_bgm.mp3
Normal file
Binary file not shown.
BIN
public/audio/tense_bgm.mp3
Normal file
BIN
public/audio/tense_bgm.mp3
Normal file
Binary file not shown.
@@ -10,6 +10,10 @@
|
|||||||
"id": "intro",
|
"id": "intro",
|
||||||
"videoUrl": "/videos/intro.mp4",
|
"videoUrl": "/videos/intro.mp4",
|
||||||
"subtitleUrl": "/subtitles/intro.vtt",
|
"subtitleUrl": "/subtitles/intro.vtt",
|
||||||
|
"bgmUrl": "/audio/calm_bgm.mp3",
|
||||||
|
"bgmVolume": 0.6,
|
||||||
|
"bgmCrossFade": 1.5,
|
||||||
|
"videoMuted": true,
|
||||||
"choices": [
|
"choices": [
|
||||||
{
|
{
|
||||||
"text": "走向左边那扇发光的门",
|
"text": "走向左边那扇发光的门",
|
||||||
@@ -126,6 +130,10 @@
|
|||||||
"right_door": {
|
"right_door": {
|
||||||
"id": "right_door",
|
"id": "right_door",
|
||||||
"videoUrl": "/videos/right_door.mp4",
|
"videoUrl": "/videos/right_door.mp4",
|
||||||
|
"bgmUrl": "/audio/tense_bgm.mp3",
|
||||||
|
"bgmVolume": 0.7,
|
||||||
|
"bgmCrossFade": 2.0,
|
||||||
|
"videoMuted": true,
|
||||||
"qte": {
|
"qte": {
|
||||||
"triggerTime": 1.0,
|
"triggerTime": 1.0,
|
||||||
"prompt": "躲避飞来的石块!",
|
"prompt": "躲避飞来的石块!",
|
||||||
@@ -185,6 +193,9 @@
|
|||||||
"id": "stay",
|
"id": "stay",
|
||||||
"videoUrl": "/videos/stay_loop.mp4",
|
"videoUrl": "/videos/stay_loop.mp4",
|
||||||
"subtitleUrl": "/subtitles/stay.vtt",
|
"subtitleUrl": "/subtitles/stay.vtt",
|
||||||
|
"bgmUrl": "/audio/calm_bgm.mp3",
|
||||||
|
"bgmVolume": 0.6,
|
||||||
|
"videoMuted": true,
|
||||||
"loopStart": 3.0,
|
"loopStart": 3.0,
|
||||||
"loopEnd": 6.0,
|
"loopEnd": 6.0,
|
||||||
"choices": [
|
"choices": [
|
||||||
|
|||||||
Reference in New Issue
Block a user