- 将原单页竞赛流程拆分为独立路由页面(首页、封面、规则、抽题、答题、图解、组别、队伍) - 新增 Pinia store 管理竞赛状态、计时器和音频控制 - 添加 SCSS 变量文件定义主题色 - 更新路由配置和类型定义以支持新页面结构 - 重构 QuestionRenderer 组件优化样式 - 添加图标依赖并更新国际化配置 - 移动图片资源到用户目录并清理旧文件
122 lines
3.7 KiB
TypeScript
122 lines
3.7 KiB
TypeScript
export class AudioController {
|
|
private audioContext: AudioContext | null = null
|
|
private soundFiles: Record<string, string> = {}
|
|
private audioCache: Record<string, HTMLAudioElement> = {}
|
|
|
|
constructor(soundFiles: Record<string, string> = {}) {
|
|
this.soundFiles = soundFiles
|
|
}
|
|
|
|
private initAudioContext() {
|
|
if (!this.audioContext) {
|
|
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 播放指定类型的音效
|
|
* 优先使用预设的音频文件,如果没有配置或加载失败,则使用 Web Audio API 合成音效
|
|
*/
|
|
async play(type: 'start' | 'tick' | 'flip') {
|
|
// 尝试播放音频文件
|
|
if (this.soundFiles[type]) {
|
|
try {
|
|
await this.playFile(type)
|
|
return
|
|
}
|
|
catch (error) {
|
|
console.warn(`Failed to play audio file for ${type}, falling back to synth.`, error)
|
|
}
|
|
}
|
|
|
|
// 回退到合成音效
|
|
this.playSynth(type)
|
|
}
|
|
|
|
private playFile(type: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const url = this.soundFiles[type]
|
|
if (!url) {
|
|
reject(new Error('No file url'))
|
|
return
|
|
}
|
|
|
|
// 使用缓存的 Audio 对象
|
|
if (!this.audioCache[type]) {
|
|
this.audioCache[type] = new Audio(url)
|
|
}
|
|
|
|
const audio = this.audioCache[type]
|
|
audio.currentTime = 0
|
|
audio.play()
|
|
.then(() => resolve())
|
|
.catch(e => reject(e))
|
|
})
|
|
}
|
|
|
|
private playSynth(type: 'start' | 'tick' | 'flip') {
|
|
this.initAudioContext()
|
|
if (!this.audioContext)
|
|
return
|
|
|
|
const ctxTime = this.audioContext.currentTime
|
|
const gain = this.audioContext.createGain()
|
|
gain.connect(this.audioContext.destination)
|
|
|
|
if (type === 'start') {
|
|
const osc = this.audioContext.createOscillator()
|
|
osc.connect(gain)
|
|
// 开始音效:高音短促提示
|
|
osc.type = 'sine'
|
|
osc.frequency.setValueAtTime(880, ctxTime)
|
|
osc.frequency.exponentialRampToValueAtTime(440, ctxTime + 0.3)
|
|
gain.gain.setValueAtTime(0.5, ctxTime)
|
|
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.3)
|
|
osc.start(ctxTime)
|
|
osc.stop(ctxTime + 0.3)
|
|
}
|
|
else if (type === 'tick') {
|
|
const osc = this.audioContext.createOscillator()
|
|
osc.connect(gain)
|
|
// 倒计时音效:急促的滴答声
|
|
osc.type = 'triangle'
|
|
osc.frequency.setValueAtTime(600, ctxTime)
|
|
gain.gain.setValueAtTime(0.3, ctxTime)
|
|
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.1)
|
|
osc.start(ctxTime)
|
|
osc.stop(ctxTime + 0.1)
|
|
}
|
|
else if (type === 'flip') {
|
|
// 翻书音效:使用白噪声模拟纸张摩擦
|
|
const bufferSize = this.audioContext.sampleRate * 0.5 // 0.5秒缓冲
|
|
const buffer = this.audioContext.createBuffer(1, bufferSize, this.audioContext.sampleRate)
|
|
const data = buffer.getChannelData(0)
|
|
|
|
// 生成白噪声
|
|
for (let i = 0; i < bufferSize; i++) {
|
|
data[i] = Math.random() * 2 - 1
|
|
}
|
|
|
|
const noise = this.audioContext.createBufferSource()
|
|
noise.buffer = buffer
|
|
|
|
// 滤波器:低通滤波器,模拟纸张的闷声
|
|
const filter = this.audioContext.createBiquadFilter()
|
|
filter.type = 'lowpass'
|
|
filter.frequency.setValueAtTime(400, ctxTime)
|
|
filter.frequency.exponentialRampToValueAtTime(3000, ctxTime + 0.1) // 频率快速扫过,模拟快速翻动
|
|
|
|
noise.connect(filter)
|
|
filter.connect(gain)
|
|
|
|
// 音量包络:快速淡入淡出
|
|
gain.gain.setValueAtTime(0, ctxTime)
|
|
gain.gain.linearRampToValueAtTime(0.8, ctxTime + 0.05)
|
|
gain.gain.exponentialRampToValueAtTime(0.01, ctxTime + 0.3)
|
|
|
|
noise.start(ctxTime)
|
|
noise.stop(ctxTime + 0.3)
|
|
}
|
|
}
|
|
}
|