/** * 糖分突袭 · 游戏音效(InnerAudioContext 池化,H5/小程序通用) * 每种操作独立音轨 + playbackRate 变调,避免“全是同一个声” */ import { ref, onUnmounted } from 'vue' const STORAGE_KEY = 'tongji_game_sfx_enabled' const COS = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file' /** 9 条基础素材(已上传 COS,小程序域名白名单内) */ const SRC = { tap: `${COS}/20260526/20260526105128557ef8669.mp3`, swoosh: `${COS}/20260527/202605271539371ebe13672.mp3`, pop: `${COS}/20260526/202605261051282a0a94508.mp3`, buzz: `${COS}/20260527/202605271539376ff628472.mp3`, chime: `${COS}/20260528/202605280937077bad76716.mp3`, fanfare: `${COS}/20260528/20260528093707b021f5794.mp3`, blast: `${COS}/20260528/20260528093707ebd4d5733.mp3`, sparkle: `${COS}/20260528/20260528093707016f07427.mp3`, stinger: `${COS}/20260528/20260528093709c669f4659.mp3` } /** * 音效表:src / volume / rate(playbackRate) / pool * rate 不同可在同一素材上听出明显差异 */ const SFX = { // —— 交互 —— select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 }, deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 }, hint: { src: SRC.tap, volume: 0.26, rate: 1.28, pool: 2 }, swap: { src: SRC.swoosh, volume: 0.38, rate: 1.0, pool: 3 }, swapFail: { src: SRC.buzz, volume: 0.36, rate: 1.18, pool: 2 }, drop: { src: SRC.pop, volume: 0.3, rate: 0.88, pool: 4 }, dropLight: { src: SRC.pop, volume: 0.22, rate: 1.22, pool: 2 }, // —— 消除(按连数)—— match3: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 }, match4: { src: SRC.fanfare, volume: 0.54, rate: 1.06, pool: 3 }, match5: { src: SRC.blast, volume: 0.62, rate: 1.0, pool: 3 }, // —— 消除(按 GI 主色)—— matchLowGi: { src: SRC.chime, volume: 0.42, rate: 0.86, pool: 2 }, matchMidGi: { src: SRC.chime, volume: 0.46, rate: 1.02, pool: 2 }, matchHighGi: { src: SRC.buzz, volume: 0.44, rate: 0.92, pool: 2 }, // —— 连消 —— combo2: { src: SRC.fanfare, volume: 0.56, rate: 1.12, pool: 2 }, combo3: { src: SRC.blast, volume: 0.64, rate: 1.08, pool: 2 }, comboMega: { src: SRC.blast, volume: 0.72, rate: 1.22, pool: 2 }, // —— 道具 —— insulin: { src: SRC.sparkle, volume: 0.5, rate: 1.15, pool: 2 }, fiber: { src: SRC.swoosh, volume: 0.48, rate: 1.32, pool: 2 }, meal: { src: SRC.stinger, volume: 0.42, rate: 1.35, pool: 1 }, reshuffle: { src: SRC.swoosh, volume: 0.46, rate: 0.72, pool: 2 }, // —— 反馈 —— score: { src: SRC.tap, volume: 0.28, rate: 1.38, pool: 2 }, goalTick: { src: SRC.chime, volume: 0.36, rate: 1.45, pool: 2 }, meterWarn: { src: SRC.buzz, volume: 0.5, rate: 1.0, pool: 2 }, meterDanger: { src: SRC.buzz, volume: 0.58, rate: 0.78, pool: 2 }, meterStable: { src: SRC.pop, volume: 0.24, rate: 1.05, pool: 1 }, win: { src: SRC.fanfare, volume: 0.66, rate: 1.0, pool: 1 }, winStinger: { src: SRC.stinger, volume: 0.38, rate: 1.5, pool: 1 }, lose: { src: SRC.buzz, volume: 0.52, rate: 0.68, pool: 1 }, // 兼容旧名 match: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 }, combo: { src: SRC.fanfare, volume: 0.56, rate: 1.1, pool: 3 }, heal: { src: SRC.pop, volume: 0.44, rate: 1.18, pool: 2 }, boost: { src: SRC.sparkle, volume: 0.52, rate: 1.0, pool: 2 }, invalid: { src: SRC.buzz, volume: 0.4, rate: 1.15, pool: 2 }, danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 } } class SfxPool { constructor(src, size, volume, rate = 1) { this.src = src this.size = size this.volume = volume this.rate = rate this.list = [] this.cursor = 0 this.warmedUp = false } create() { for (let i = 0; i < this.size; i++) { const ctx = uni.createInnerAudioContext() ctx.src = this.src ctx.obeyMuteSwitch = false ctx.autoplay = false ctx.volume = this.volume try { ctx.playbackRate = this.rate } catch (_) {} this.list.push(ctx) } } warmUp() { if (this.warmedUp) return if (!this.list.length) this.create() this.list.forEach((ctx) => { try { ctx.volume = 0 ctx.play() setTimeout(() => { try { ctx.stop() ctx.volume = this.volume } catch (_) {} }, 60) } catch (_) {} }) this.warmedUp = true } play(scale = 1, rateMul = 1) { if (!this.list.length) this.create() const ctx = this.list[this.cursor % this.list.length] this.cursor += 1 const vol = Math.min(1, this.volume * scale) const rate = Math.min(2, Math.max(0.5, this.rate * rateMul)) try { ctx.volume = vol try { ctx.playbackRate = rate } catch (_) {} ctx.stop() ctx.seek(0) ctx.play() } catch (_) { try { ctx.play() } catch (e) {} } } destroy() { this.list.forEach((ctx) => { try { ctx.stop() ctx.destroy() } catch (_) {} }) this.list = [] this.warmedUp = false } } function dominantGi(tiles = []) { const cnt = { low: 0, mid: 0, high: 0 } tiles.forEach((t) => { const g = t?.type?.gi if (g && cnt[g] !== undefined) cnt[g] += 1 }) let best = 'mid' let max = 0 Object.entries(cnt).forEach(([k, v]) => { if (v > max) { max = v best = k } }) return best } export function useGameSfx() { const enabled = ref(true) const pools = {} try { const stored = uni.getStorageSync(STORAGE_KEY) if (stored === false || stored === '0' || stored === 0) { enabled.value = false } } catch (_) {} function ensureAudioOption() { // #ifdef MP-WEIXIN try { uni.setInnerAudioOption({ obeyMuteSwitch: false, mixWithOther: true }) } catch (_) {} // #endif } function getPool(name) { const cfg = SFX[name] if (!cfg) return null if (!pools[name]) { pools[name] = new SfxPool(cfg.src, cfg.pool, cfg.volume, cfg.rate ?? 1) } return pools[name] } function warmUp() { if (!enabled.value) return ensureAudioOption() Object.keys(SFX).forEach((name) => { getPool(name)?.warmUp() }) } function play(name, scale = 1, rateMul = 1) { if (!enabled.value) return getPool(name)?.play(scale, rateMul) } function playLayer(primary, secondary, delayMs = 70, secScale = 0.6) { play(primary) if (secondary) { setTimeout(() => play(secondary, secScale), delayMs) } } function playMatch({ matchCount = 3, chain = 0, tiles = [] } = {}) { if (!enabled.value) return const giTrack = (() => { const gi = dominantGi(tiles) if (gi === 'low') return 'matchLowGi' if (gi === 'high') return 'matchHighGi' return 'matchMidGi' })() if (chain >= 3) { playLayer('comboMega', giTrack, 90, 0.55) return } if (chain === 2) { playLayer('combo3', giTrack, 80, 0.5) return } if (chain === 1) { playLayer('combo2', giTrack, 70, 0.45) return } if (matchCount >= 5) { playLayer('match5', 'matchHighGi', 85, 0.5) } else if (matchCount >= 4) { playLayer('match4', giTrack, 65, 0.48) } else { play(giTrack, 1, 1) setTimeout(() => play('match3', 0.85), 40) } } function playDrop(count = 1) { if (!enabled.value || count <= 0) return const name = count >= 4 ? 'drop' : 'dropLight' play(name, Math.min(1.2, 0.85 + count * 0.04), count >= 6 ? 0.9 : 1) } function playMeter(delta, level) { if (!enabled.value) return if (level > 70) play('meterDanger', 1 + (level - 70) * 0.02) else if (level < 30) play('meterWarn', 0.9, 0.95) else if (delta > 8) play('meterWarn', 0.75) else if (delta < -5) play('meterStable', 1.1) } function toggleEnabled() { enabled.value = !enabled.value try { uni.setStorageSync(STORAGE_KEY, enabled.value ? '1' : '0') } catch (_) {} if (enabled.value) { warmUp() play('select') } } function destroyAll() { Object.keys(pools).forEach((key) => { pools[key]?.destroy() delete pools[key] }) } onUnmounted(() => { destroyAll() }) return { enabled, play, playLayer, playMatch, playDrop, playMeter, warmUp, toggleEnabled, destroyAll } }