304 lines
9.2 KiB
JavaScript
304 lines
9.2 KiB
JavaScript
/**
|
|
* 糖分突袭 · 游戏音效(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
|
|
}
|
|
}
|