local birdsprites={}
local cfg={width=1920,height=1080,softMarginX=120,leaderSpeedX=420,turnEase=0.65,centerY=540,leaderAmpY=90,leaderOmegaY=1.75,leaderPhaseY=math.pi/3,count=8,trailLag=0.08,waveAmp=155,wavelength=520,basePhase=0.0,jitterMax=0*math.pi,wobbleAmp=0.35,wobbleFreqMin=0.35,wobbleFreqMax=0.75,bobRadius=16,trail=true,trailAlpha=0.08,showDebug=false,bg={0.06,0.06,0.08},fg={0.95,0.92,0.85},spriteScale=1.0,spacingMode="spatial",spacingPx=108.0}
local tLocal=0.0
local useExternalTime=false
local jitterPhase={}
local wobbleFreq={}
local wobbleOffset={}
local waveAmpScale={}
local sprites={}
local prevX={}
local facingRight={}
local function clamp(v,a,b)if v<a then return a elseif v>b then return b else return v end end
local function rndf(a,b)return a+(b-a)*math.random() end
local function triangle01(t)local m=t%2.0 if m<=1.0 then return m else return 2.0-m end end
local function smoothstep01(x)x=clamp(x,0.0,1.0)return x*x*(3.0-2.0*x) end
local function eased_u(u,easeAlpha)easeAlpha=clamp(easeAlpha or 0.0,0.0,1.0)return(1-easeAlpha)*u+easeAlpha*smoothstep01(u) end
local function x_limits()local xMin=cfg.bobRadius local xMax=cfg.width-cfg.bobRadius if xMax<xMin then xMax,xMin=xMin,xMax end return xMin,xMax end
local function interior_limits()local xMinHard,xMaxHard=x_limits()local xMin=xMinHard+cfg.softMarginX local xMax=xMaxHard-cfg.softMarginX if xMax<xMin then xMax,xMin=xMin,xMax end return xMin,xMax,xMinHard,xMaxHard end
local function leader_pos(tt)local xMin,xMax=interior_limits()local span=math.max(1,xMax-xMin)local p=(cfg.leaderSpeedX*tt)/span local u_lin=triangle01(p)local u=eased_u(u_lin,cfg.turnEase)local x=xMin+u*span local y=cfg.centerY+cfg.leaderAmpY*math.sin(cfg.leaderOmegaY*tt+cfg.leaderPhaseY)return x,y end
local function bird_pos_lag(i,t)local tt=t-i*cfg.trailLag local x,y=leader_pos(tt)local base=(2*math.pi)*(x/cfg.wavelength)+cfg.basePhase local jitter=jitterPhase[i+1] or 0.0 local wf=wobbleFreq[i+1] or 0.5 local woff=wobbleOffset[i+1] or 0.0 local wobble=cfg.wobbleAmp*math.sin(wf*t+woff)local ampScale=waveAmpScale[i+1] or 1.0 y=y+(cfg.waveAmp*ampScale)*math.sin(base+jitter+wobble)local topClamp=cfg.bobRadius+4 local bottomClamp=cfg.height-(cfg.bobRadius+4)if y<topClamp then y=topClamp end if y>bottomClamp then y=bottomClamp end return x,y end
local function bird_pos_spatial(i,t)local xMin,xMax=interior_limits()local span=math.max(1,xMax-xMin)local s=cfg.leaderSpeedX*t local s_i=s-(i*cfg.spacingPx)local u_lin=triangle01(s_i/span)local u=eased_u(u_lin,cfg.turnEase)local x=xMin+u*span local y=cfg.centerY+cfg.leaderAmpY*math.sin(cfg.leaderOmegaY*t+cfg.leaderPhaseY)local base=(2*math.pi)*(x/cfg.wavelength)+cfg.basePhase local jitter=jitterPhase[i+1] or 0.0 local wf=wobbleFreq[i+1] or 0.5 local woff=wobbleOffset[i+1] or 0.0 local wobble=cfg.wobbleAmp*math.sin(wf*t+woff)local ampScale=waveAmpScale[i+1] or 1.0 y=y+(cfg.waveAmp*ampScale)*math.sin(base+jitter+wobble)local topClamp=cfg.bobRadius+4 local bottomClamp=cfg.height-(cfg.bobRadius+4)if y<topClamp then y=topClamp end if y>bottomClamp then y=bottomClamp end return x,y end
local function bird_pos(i,t)if cfg.spacingMode=="lag" then return bird_pos_lag(i,t) else return bird_pos_spatial(i,t) end end
function birdsprites.load(options)math.randomseed(os.time()%2^31)if type(options)=="table" then for k,v in pairs(options)do if cfg[k]~=nil then cfg[k]=v end end end sprites={}
sprites[1]=love.graphics.newImage("birdsprites-01.png")sprites[1]:setFilter("nearest","nearest")
sprites[2]=love.graphics.newImage("birdsprites-02.png")sprites[2]:setFilter("nearest","nearest")
sprites[3]=love.graphics.newImage("birdsprites-03.png")sprites[3]:setFilter("nearest","nearest")
sprites[4]=love.graphics.newImage("birdsprites-04.png")sprites[4]:setFilter("nearest","nearest")
sprites[5]=love.graphics.newImage("birdsprites-05.png")sprites[5]:setFilter("nearest","nearest")
sprites[6]=love.graphics.newImage("birdsprites-06.png")sprites[6]:setFilter("nearest","nearest")
sprites[7]=love.graphics.newImage("birdsprites-07.png")sprites[7]:setFilter("nearest","nearest")
sprites[8]=love.graphics.newImage("birdsprites-08.png")sprites[8]:setFilter("nearest","nearest")
jitterPhase,wobbleFreq,wobbleOffset,waveAmpScale={},{},{},{} prevX,facingRight={},{ } for i=1,cfg.count do jitterPhase[i]=rndf(-cfg.jitterMax,cfg.jitterMax)wobbleFreq[i]=rndf(cfg.wobbleFreqMin,cfg.wobbleFreqMax)wobbleOffset[i]=rndf(0,2*math.pi)waveAmpScale[i]=rndf(0.92,1.08)prevX[i]=nil facingRight[i]=false end if love and love.graphics then love.graphics.setBackgroundColor(cfg.bg) end end
function birdsprites.start(startTime)tLocal=tonumber(startTime) or 0.0 end
function birdsprites.update(dt,externalTime)if externalTime~=nil then useExternalTime=true tLocal=externalTime else useExternalTime=false tLocal=tLocal+(dt or 0) end end
function birdsprites.draw()if cfg.trail then love.graphics.setColor(cfg.bg[1],cfg.bg[2],cfg.bg[3],cfg.trailAlpha)love.graphics.rectangle("fill",0,0,cfg.width,cfg.height) end for i=0,cfg.count-1 do local x,y=bird_pos(i,tLocal)local idx=(i%8)+1 local img=sprites[idx]if img then local k=i+1 local px=prevX[k]local dx=(px and(x-px)) or 0 if px==nil then prevX[k]=x else if math.abs(dx)>0.01 then facingRight[k]=(dx>0) end prevX[k]=x end local s=cfg.spriteScale local sx=facingRight[k] and -s or s local sy=s love.graphics.setColor(1,1,1,1)local w,h=img:getWidth(),img:getHeight()love.graphics.draw(img,x,y,0,sx,sy,w*0.5,h*0.5) end end if cfg.showDebug then love.graphics.setColor(cfg.fg[1],cfg.fg[2],cfg.fg[3],0.3)local xMin,xMax,xMinHard,xMaxHard=interior_limits()love.graphics.line(xMinHard,8,xMinHard,cfg.height-8)love.graphics.line(xMaxHard,8,xMaxHard,cfg.height-8)love.graphics.line(xMin,8,xMin,cfg.height-8)love.graphics.line(xMax,8,xMax,cfg.height-8)love.graphics.line(0,cfg.centerY,cfg.width,cfg.centerY)love.graphics.setColor(cfg.fg[1],cfg.fg[2],cfg.fg[3],0.9)local timeStr=string.format("t=%.3f %s",tLocal,useExternalTime and"(external)" or"(local)")love.graphics.print(("birdsprites: edge contact, eased turns (turnEase=%.2f, softMarginX=%d)"):format(cfg.turnEase,cfg.softMarginX),12,10)love.graphics.print(("%s spacing  |  spacingPx=%.1f  |  leaderSpeedX=%.1f px/s  |  trailLag=%.3fs"):format((cfg.spacingMode=="lag" and"lag" or"spatial"),cfg.spacingPx,cfg.leaderSpeedX,cfg.trailLag),12,28)love.graphics.print(("waveAmp=%d   λ=%d   wobbleAmp=%.2f   count=%d   spriteScale=%.2f"):format(cfg.waveAmp,cfg.wavelength,cfg.wobbleAmp,cfg.count,cfg.spriteScale),12,46)love.graphics.print(timeStr,12,64) end end
function birdsprites.setLeaderSpeedX(v)cfg.leaderSpeedX=v end
function birdsprites.setTurnEase(a)cfg.turnEase=clamp(a or cfg.turnEase,0.0,1.0) end
function birdsprites.setSoftMarginX(px)cfg.softMarginX=math.max(0,math.floor(px or cfg.softMarginX)) end
function birdsprites.setLeaderBob(ampY,omegaY,phaseY)if ampY then cfg.leaderAmpY=ampY end if omegaY then cfg.leaderOmegaY=omegaY end if phaseY then cfg.leaderPhaseY=phaseY end end
function birdsprites.setTrailLag(lag)cfg.trailLag=lag end
function birdsprites.setWave(amp,wavelength,basePhase)if amp then cfg.waveAmp=amp end if wavelength then cfg.wavelength=wavelength end if basePhase then cfg.basePhase=basePhase end end
function birdsprites.setRandomization(jitterMax,wobbleAmp,wobbleMin,wobbleMax)if jitterMax then cfg.jitterMax=jitterMax end if wobbleAmp then cfg.wobbleAmp=wobbleAmp end if wobbleMin then cfg.wobbleFreqMin=wobbleMin end if wobbleMax then cfg.wobbleFreqMax=wobbleMax end end
function birdsprites.setCount(n)n=math.max(1,math.floor(n or cfg.count))cfg.count=n birdsprites.load(cfg) end
function birdsprites.setSpriteScale(s)cfg.spriteScale=s or cfg.spriteScale end
function birdsprites.setSpacingPx(px)if px then cfg.spacingPx=math.max(0,px) end end
function birdsprites.setSpacingMode(mode)if mode=="spatial" or mode=="lag" then cfg.spacingMode=mode end end
return birdsprites
