local World = require("game.world") local Palette = require("rendering.palette") local Projection = require("rendering.projection") local Horizon = {} -- Mountain profile: angular peaks as straight line segments -- Each entry is {angle_degrees, height} — connected by straight lines local mountainPoints = {} local smokeParticles = {} local VOLCANO_ANGLE = 230 -- Eruption phase: alternates between "quiet" and "active" periods so smoke reads as an eruption cycle local eruption = { active = false, timer = 0, nextCycle = 8 } -- Deterministic small PRNG for the mountain profile so the horizon stays the -- same across a single session without touching math.random's global state. local function lcg(seed) local state = seed return function() state = (state * 1103515245 + 12345) % 2147483648 return state / 2147483648 end end function Horizon.init() mountainPoints = {} local rnd = lcg(12345) local angle = 0 while angle < 360 do local h = rnd() < 0.45 and (25 + rnd() * 45) -- peak or (2 + rnd() * 10) -- valley / flat table.insert(mountainPoints, {angle = angle, height = h}) -- Variable spacing — bigger jumps for angular look angle = angle + 4 + rnd() * 8 end -- Close the loop table.insert(mountainPoints, {angle = 360, height = mountainPoints[1].height}) -- Tall narrow volcano local volcanoPts = { {VOLCANO_ANGLE - 15, 8}, {VOLCANO_ANGLE - 8, 25}, {VOLCANO_ANGLE - 3, 55}, {VOLCANO_ANGLE, 75}, {VOLCANO_ANGLE + 3, 55}, {VOLCANO_ANGLE + 8, 25}, {VOLCANO_ANGLE + 15, 8}, } for _, vp in ipairs(volcanoPts) do table.insert(mountainPoints, {angle = vp[1], height = vp[2]}) end table.sort(mountainPoints, function(a, b) return a.angle < b.angle end) smokeParticles = {} eruption.active = false eruption.timer = 0 eruption.nextCycle = 8 + math.random() * 6 end -- Get mountain height at a specific angle by interpolating between nearest points local function getMountainHeight(deg) deg = deg % 360 -- Find the two points bracketing this angle local prev = mountainPoints[#mountainPoints - 1] local nxt = mountainPoints[1] for i = 1, #mountainPoints - 1 do if mountainPoints[i].angle <= deg and mountainPoints[i + 1].angle > deg then prev = mountainPoints[i] nxt = mountainPoints[i + 1] break end end -- Linear interpolation between the two points local range = nxt.angle - prev.angle if range <= 0 then return prev.height end local t = (deg - prev.angle) / range return prev.height + (nxt.height - prev.height) * t end function Horizon.update(dt) -- Eruption cycle: quiet stretches then 3-5s active bursts eruption.timer = eruption.timer + dt if eruption.timer >= eruption.nextCycle then eruption.timer = 0 eruption.active = not eruption.active eruption.nextCycle = eruption.active and (3 + math.random() * 2) -- active burst duration or (10 + math.random() * 10) -- quiet interval between eruptions end if eruption.active and math.random() < 0.5 then table.insert(smokeParticles, { angle = VOLCANO_ANGLE + (math.random() - 0.5) * 6, height = 78 + math.random() * 5, vy = 18 + math.random() * 14, vangle = (math.random() - 0.5) * 1.5, life = 2.0 + math.random(), maxLife = 3.0, size = 1 + math.random() * 1.5, }) end for i = #smokeParticles, 1, -1 do local sp = smokeParticles[i] sp.height = sp.height + sp.vy * dt sp.angle = sp.angle + sp.vangle * dt sp.life = sp.life - dt if sp.life <= 0 then table.remove(smokeParticles, i) end end end function Horizon.draw(cameraAngle) local p = Palette.get() local vp = Projection.getViewport() -- Procedural horizon "distance": smaller divisor → mountains appear closer/bigger. -- 700 gives mountains roughly double the apparent size of our previous 1500 value -- so the horizon reads as a near-ish backdrop rather than vanishingly far away. local FAR_SCALE = (vp.w / 2 * math.sqrt(3)) / 700 -- === STRONG HORIZON LINE === love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.8) love.graphics.setLineWidth(2) love.graphics.line(vp.x, vp.horizonY, vp.x + vp.w, vp.horizonY) -- === MOUNTAINS: angular connected peaks === love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.7) love.graphics.setLineWidth(1.5) -- Walk across screen columns in larger steps for angular look local pts = {} local step = 3 for sx = vp.x, vp.x + vp.w, step do local relX = (sx - vp.x - vp.w / 2) / (vp.w / 2) local worldAngle = cameraAngle + math.atan(relX) local deg = math.deg(worldAngle) % 360 local height = getMountainHeight(deg) local sy = vp.horizonY - height * FAR_SCALE table.insert(pts, sx) table.insert(pts, sy) end if #pts >= 4 then love.graphics.line(pts) end -- === MOON === local moonWorldAngle = math.rad(10) local moonRelAngle = moonWorldAngle - cameraAngle while moonRelAngle > math.pi do moonRelAngle = moonRelAngle - math.pi * 2 end while moonRelAngle < -math.pi do moonRelAngle = moonRelAngle + math.pi * 2 end if math.abs(moonRelAngle) < math.pi / 4 then local moonSx = vp.x + vp.w/2 + math.tan(moonRelAngle) * (vp.w/2) local moonSy = vp.horizonY - vp.h * 0.28 local moonR = vp.h * 0.035 love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.5) love.graphics.setLineWidth(1.5) love.graphics.circle("line", moonSx, moonSy, moonR, 16) -- Crescent shadow love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.2) love.graphics.arc("line", "open", moonSx + moonR*0.3, moonSy, moonR*0.7, -math.pi/2, math.pi/2, 10) end -- === VOLCANO SMOKE === for _, sp in ipairs(smokeParticles) do local spRel = math.rad(sp.angle) - cameraAngle while spRel > math.pi do spRel = spRel - math.pi * 2 end while spRel < -math.pi do spRel = spRel + math.pi * 2 end if math.abs(spRel) < math.pi / 4 then local spSx = vp.x + vp.w/2 + math.tan(spRel) * (vp.w/2) local spSy = vp.horizonY - sp.height * FAR_SCALE local alpha = 0.35 * (sp.life / sp.maxLife) love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], alpha) love.graphics.circle("fill", spSx, spSy, sp.size) end end end return Horizon