183 lines
5.5 KiB
Lua
183 lines
5.5 KiB
Lua
local Sounds = {}
|
|
|
|
local sources = {}
|
|
local SAMPLE_RATE = 44100
|
|
|
|
local function makeSoundData(duration, generator)
|
|
local samples = math.floor(SAMPLE_RATE * duration)
|
|
local sd = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1)
|
|
for i = 0, samples - 1 do
|
|
local t = i / SAMPLE_RATE
|
|
local p = i / samples
|
|
sd:setSample(i, math.max(-1, math.min(1, generator(t, p))))
|
|
end
|
|
return sd
|
|
end
|
|
|
|
local function makeSource(sd)
|
|
return love.audio.newSource(sd, "static")
|
|
end
|
|
|
|
local function genFire(t, p)
|
|
local freq = 800 - p * 600
|
|
local env = (1 - p) ^ 2
|
|
return math.sin(2 * math.pi * freq * t) * env * 0.4
|
|
end
|
|
|
|
local function genEnemyFire(t, p)
|
|
local freq = 400 - p * 250
|
|
local env = (1 - p) ^ 2
|
|
return math.sin(2 * math.pi * freq * t) * env * 0.35
|
|
end
|
|
|
|
local function genHitEnemy(t, p)
|
|
local env = (1 - p) ^ 1.5
|
|
local sine = math.sin(2 * math.pi * (80 + 40*(1-p)) * t) * 0.5
|
|
local noise = (math.random() * 2 - 1) * 0.4
|
|
return (sine + noise) * env * 0.5
|
|
end
|
|
|
|
local function genHitPlayer(t, p)
|
|
local env = (1 - p) ^ 1.0
|
|
local sine = math.sin(2 * math.pi * (50 + 30*(1-p)) * t) * 0.5
|
|
local noise = (math.random() * 2 - 1) * 0.5
|
|
local pulse = 0.7 + math.sin(2 * math.pi * 4 * t) * 0.3
|
|
return (sine + noise) * env * pulse * 0.5
|
|
end
|
|
|
|
local function genEngine(t, p)
|
|
local noise = (math.random() * 2 - 1) * 0.2
|
|
local low = math.sin(2 * math.pi * 35 * t) * 0.15
|
|
return (noise + low) * 0.3
|
|
end
|
|
|
|
local function genEnemyAppear(t, p)
|
|
local freq = 200 + p * 300
|
|
local env = 0.6
|
|
if p < 0.1 then env = p / 0.1 * 0.6 end
|
|
if p > 0.8 then env = (1 - p) / 0.2 * 0.6 end
|
|
return math.sin(2 * math.pi * freq * t) * env * 0.3
|
|
end
|
|
|
|
local function genExtraLife(t, p)
|
|
local freq
|
|
if p < 0.33 then freq = 400
|
|
elseif p < 0.66 then freq = 600
|
|
else freq = 800 end
|
|
local env = 0.7
|
|
if p < 0.05 then env = p / 0.05 * 0.7 end
|
|
if p > 0.9 then env = (1-p) / 0.1 * 0.7 end
|
|
return math.sin(2 * math.pi * freq * t) * env * 0.35
|
|
end
|
|
|
|
local function genMissileLaunch(t, p)
|
|
-- Rising whistle: frequency ramps up
|
|
local freq = 300 + p * 900
|
|
local env
|
|
if p < 0.1 then env = p / 0.1
|
|
elseif p > 0.8 then env = (1 - p) / 0.2
|
|
else env = 1 end
|
|
local tone = math.sin(2 * math.pi * freq * t)
|
|
local noise = (math.random() * 2 - 1) * 0.1
|
|
return (tone + noise) * env * 0.25
|
|
end
|
|
|
|
local function genScreenCrack(t, p)
|
|
-- Glass break: noise burst + downward pitch sweep
|
|
local env = (1 - p) ^ 1.5
|
|
local noise = (math.random() * 2 - 1)
|
|
local tone = math.sin(2 * math.pi * (1400 - p * 1200) * t)
|
|
return (noise * 0.6 + tone * 0.2) * env * 0.45
|
|
end
|
|
|
|
local function genMotionBlocked(t, p)
|
|
local env = (1 - p) ^ 2
|
|
return math.sin(2 * math.pi * 160 * t) * env * 0.25
|
|
end
|
|
|
|
local function genSaucerHum(t, _)
|
|
-- Long looping wavering tone — distinctive UFO cue
|
|
local base = 220
|
|
local vibrato = math.sin(2 * math.pi * 5 * t) * 12
|
|
return math.sin(2 * math.pi * (base + vibrato) * t) * 0.22
|
|
end
|
|
|
|
function Sounds.init()
|
|
sources = {}
|
|
local defs = {
|
|
fire = {0.08, genFire},
|
|
enemy_fire = {0.1, genEnemyFire},
|
|
hit_enemy = {0.4, genHitEnemy},
|
|
hit_player = {0.8, genHitPlayer},
|
|
engine = {0.3, genEngine},
|
|
enemy_appear = {0.3, genEnemyAppear},
|
|
extra_life = {0.3, genExtraLife},
|
|
missile_launch = {0.6, genMissileLaunch},
|
|
screen_crack = {0.7, genScreenCrack},
|
|
motion_blocked = {0.12, genMotionBlocked},
|
|
}
|
|
for name, def in pairs(defs) do
|
|
sources[name] = makeSource(makeSoundData(def[1], def[2]))
|
|
end
|
|
|
|
-- Saucer hum is a looping source (not cloned per-play) so it can be panned live
|
|
local humData = makeSoundData(1.0, genSaucerHum)
|
|
sources.saucer_hum = love.audio.newSource(humData, "static")
|
|
sources.saucer_hum:setLooping(true)
|
|
sources.saucer_hum:setRelative(false)
|
|
sources.saucer_hum:setVolume(0.6)
|
|
-- playMusic() silently no-ops if the music asset isn't bundled
|
|
end
|
|
|
|
function Sounds.play(name)
|
|
local src = sources[name]
|
|
if not src then return end
|
|
src:clone():play()
|
|
end
|
|
|
|
function Sounds.startSaucerHum()
|
|
local src = sources.saucer_hum
|
|
if not src then return end
|
|
if not src:isPlaying() then
|
|
src:setPosition(0, 0, 0)
|
|
src:play()
|
|
end
|
|
end
|
|
|
|
function Sounds.stopSaucerHum()
|
|
local src = sources.saucer_hum
|
|
if src and src:isPlaying() then src:stop() end
|
|
end
|
|
|
|
function Sounds.updateSaucerHum(bearingRadians, distance)
|
|
-- bearingRadians: atan2(side, forward) in player frame; 0 = straight ahead
|
|
-- Map bearing sine to x pan, distance to attenuation via z distance
|
|
local src = sources.saucer_hum
|
|
if not src or not src:isPlaying() then return end
|
|
local x = math.sin(bearingRadians) * 5
|
|
local z = math.max(1, distance / 200)
|
|
src:setPosition(x, 0, z)
|
|
end
|
|
|
|
function Sounds.playMusic(name)
|
|
local path = "assets/music/" .. name .. ".ogg"
|
|
if love.filesystem.getInfo and love.filesystem.getInfo(path) then
|
|
local ok, src = pcall(love.audio.newSource, path, "stream")
|
|
if ok and src then
|
|
src:setLooping(false)
|
|
src:play()
|
|
sources["music_" .. name] = src
|
|
end
|
|
end
|
|
-- Silently no-op if the asset isn't bundled
|
|
end
|
|
|
|
function Sounds.stopMusic()
|
|
for k, src in pairs(sources) do
|
|
if k:sub(1, 6) == "music_" and src.isPlaying and src:isPlaying() then
|
|
src:stop()
|
|
end
|
|
end
|
|
end
|
|
|
|
return Sounds
|