From 790ca87bfb49967c3956554b9f3cbaa8758763a6 Mon Sep 17 00:00:00 2001 From: 28allday Date: Mon, 13 Apr 2026 14:42:13 +0100 Subject: [PATCH] Lunar Lander: complete Love2D game with Omarchy integration Faithful recreation of Atari Lunar Lander (1979) with vector wireframe aesthetic. Auto-detects Omarchy system theme and font on launch. Features: - Dynamic zoom camera (zooms in as you approach the surface) - Procedural jagged terrain with flat landing pads (2X, 3X, 5X multipliers) - Apollo-style wireframe lander with thrust flame - Gravity, thrust, rotation physics - Landing evaluation: good (speed/angle/pad check), hard, crash - Fuel management (750 starting, +50 for good landings) - Crash debris particles, thrust exhaust particles - Star field background - HUD: altitude, horizontal/vertical speed, fuel bar, score, time - Persistent high scores with 3-letter initial entry - Procedural sound effects (thrust, landing chimes, crash, fuel warning) - Full-screen scaling, system font detection Controls: Arrows/WASD rotate+thrust, Space abort, Enter start Co-Authored-By: Claude Opus 4.6 (1M context) --- audio/sounds.lua | 84 +++++++++++ conf.lua | 11 ++ game/camera.lua | 68 +++++++++ game/highscores.lua | 198 +++++++++++++++++++++++++ game/hud.lua | 59 ++++++++ game/lander.lua | 211 ++++++++++++++++++++++++++ game/particles.lua | 95 ++++++++++++ game/stars.lua | 29 ++++ game/terrain.lua | 166 +++++++++++++++++++++ game/world.lua | 40 +++++ main.lua | 336 ++++++++++++++++++++++++++++++++++++++++++ rendering/fonts.lua | 64 ++++++++ rendering/palette.lua | 63 ++++++++ 13 files changed, 1424 insertions(+) create mode 100644 audio/sounds.lua create mode 100644 conf.lua create mode 100644 game/camera.lua create mode 100644 game/highscores.lua create mode 100644 game/hud.lua create mode 100644 game/lander.lua create mode 100644 game/particles.lua create mode 100644 game/stars.lua create mode 100644 game/terrain.lua create mode 100644 game/world.lua create mode 100644 main.lua create mode 100644 rendering/fonts.lua create mode 100644 rendering/palette.lua diff --git a/audio/sounds.lua b/audio/sounds.lua new file mode 100644 index 0000000..0ab6e98 --- /dev/null +++ b/audio/sounds.lua @@ -0,0 +1,84 @@ +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 genThrust(t, p) + local noise = (math.random() * 2 - 1) * 0.25 + local low = math.sin(2 * math.pi * 45 * t) * 0.2 + local rumble = math.sin(2 * math.pi * 25 * t) * 0.15 + return (noise + low + rumble) * 0.4 +end + +local function genLandGood(t, p) + local freq + if p < 0.33 then freq = 500 + elseif p < 0.66 then freq = 700 + else freq = 900 end + local env = 0.7 + 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 genLandHard(t, p) + local env = (1 - p) ^ 2 + return math.sin(2 * math.pi * 250 * t) * env * 0.3 +end + +local function genCrash(t, p) + local env = (1 - p) ^ 1.2 + local sine = math.sin(2 * math.pi * (60 + 30*(1-p)) * t) * 0.5 + local noise = (math.random() * 2 - 1) * 0.5 + return (sine + noise) * env * 0.5 +end + +local function genFuelWarn(t, p) + local freq = 800 + local env = 0.6 + if p > 0.5 then env = 0 end + return math.sin(2 * math.pi * freq * t) * env * 0.3 +end + +local function genAbort(t, p) + local freq = 600 - p * 300 + local env = (1 - p) ^ 1.5 + local noise = (math.random() * 2 - 1) * 0.3 + return (math.sin(2 * math.pi * freq * t) * 0.4 + noise) * env * 0.4 +end + +function Sounds.init() + sources = {} + local defs = { + thrust = {0.25, genThrust}, + land_good = {0.4, genLandGood}, + land_hard = {0.3, genLandHard}, + crash = {0.6, genCrash}, + fuel_warn = {0.15, genFuelWarn}, + abort = {0.3, genAbort}, + } + for name, def in pairs(defs) do + sources[name] = makeSource(makeSoundData(def[1], def[2])) + end +end + +function Sounds.play(name) + local src = sources[name] + if not src then return end + src:clone():play() +end + +return Sounds diff --git a/conf.lua b/conf.lua new file mode 100644 index 0000000..f25e230 --- /dev/null +++ b/conf.lua @@ -0,0 +1,11 @@ +function love.conf(t) + t.window.title = "LUNAR LANDER" + t.window.width = 1024 + t.window.height = 768 + t.window.resizable = true + t.window.vsync = 1 + t.window.fullscreen = false + t.window.fullscreentype = "desktop" + t.window.minwidth = 512 + t.window.minheight = 384 +end diff --git a/game/camera.lua b/game/camera.lua new file mode 100644 index 0000000..a135888 --- /dev/null +++ b/game/camera.lua @@ -0,0 +1,68 @@ +local World = require("game.world") + +local Camera = { + x = 2000, + y = 400, + zoom = 0.5, +} + +local BASE_ALT = 1200 +local MAX_ZOOM = 4.0 +local MIN_ZOOM = 0.35 + +function Camera.update(lander, terrain, dt) + -- Track lander position + Camera.x = lander.x + Camera.y = lander.y + + -- Compute altitude + local groundY = terrain.getHeightAt(lander.x) + local altitude = groundY - lander.y + altitude = math.max(altitude, 10) + + -- Zoom based on altitude + local targetZoom = BASE_ALT / altitude + targetZoom = math.max(MIN_ZOOM, math.min(MAX_ZOOM, targetZoom)) + + -- Smooth lerp + Camera.zoom = Camera.zoom + (targetZoom - Camera.zoom) * math.min(1, dt * 2.5) +end + +function Camera.getAltitude(landerY, terrain, landerX) + local groundY = terrain.getHeightAt(landerX) + return math.max(0, groundY - landerY) +end + +function Camera.applyTransform() + local sw, sh = World.screenW, World.screenH + local scale = World.baseScale * Camera.zoom + + love.graphics.push() + love.graphics.translate(sw / 2, sh / 2) + love.graphics.scale(scale) + love.graphics.translate(-Camera.x, -Camera.y) +end + +function Camera.popTransform() + love.graphics.pop() +end + +function Camera.getVisibleRect() + local sw, sh = World.screenW, World.screenH + local scale = World.baseScale * Camera.zoom + local hw = (sw / 2) / scale + local hh = (sh / 2) / scale + return Camera.x - hw, Camera.y - hh, Camera.x + hw, Camera.y + hh +end + +function Camera.getZoom() + return Camera.zoom +end + +function Camera.reset() + Camera.x = 2000 + Camera.y = 400 + Camera.zoom = MIN_ZOOM +end + +return Camera diff --git a/game/highscores.lua b/game/highscores.lua new file mode 100644 index 0000000..8b6f6f1 --- /dev/null +++ b/game/highscores.lua @@ -0,0 +1,198 @@ +local HighScores = {} + +local MAX_SCORES = 10 +local SAVE_FILE = "lunarlander_scores.dat" +local scores = {} + +local entry = { + active = false, + score = 0, + letters = {"A", "A", "A"}, + position = 1, + blink = 0, +} + +function HighScores.init() + HighScores.load() +end + +function HighScores.load() + scores = {} + if love.filesystem.getInfo(SAVE_FILE) then + local data = love.filesystem.read(SAVE_FILE) + if data then + for line in data:gmatch("[^\n]+") do + local initials, score = line:match("^(%a%a%a)%s+(%d+)$") + if initials and score then + table.insert(scores, {initials = initials:upper(), score = tonumber(score)}) + end + end + end + end + table.sort(scores, function(a, b) return a.score > b.score end) + while #scores > MAX_SCORES do table.remove(scores) end +end + +function HighScores.save() + local lines = {} + for _, e in ipairs(scores) do + table.insert(lines, string.format("%s %d", e.initials, e.score)) + end + love.filesystem.write(SAVE_FILE, table.concat(lines, "\n") .. "\n") +end + +function HighScores.isHighScore(score) + if score <= 0 then return false end + if #scores < MAX_SCORES then return true end + return score > scores[#scores].score +end + +function HighScores.addScore(initials, score) + table.insert(scores, {initials = initials:upper(), score = score}) + table.sort(scores, function(a, b) return a.score > b.score end) + while #scores > MAX_SCORES do table.remove(scores) end + HighScores.save() +end + +function HighScores.getScores() + local result = {} + for _, s in ipairs(scores) do + table.insert(result, {initials = s.initials, score = s.score}) + end + return result +end + +function HighScores.getHighest() + if #scores > 0 then return scores[1].score end + return 0 +end + +function HighScores.startEntry(score) + entry.active = true + entry.score = score + entry.letters = {"A", "A", "A"} + entry.position = 1 + entry.blink = 0 +end + +function HighScores.isEntryActive() return entry.active end + +function HighScores.updateEntry(dt) + entry.blink = entry.blink + dt +end + +function HighScores.keypressedEntry(key) + if not entry.active then return nil end + if key == "left" then + entry.position = math.max(1, entry.position - 1) + elseif key == "right" then + entry.position = math.min(3, entry.position + 1) + elseif key == "up" then + local b = entry.letters[entry.position]:byte() + b = b + 1; if b > 90 then b = 65 end + entry.letters[entry.position] = string.char(b) + elseif key == "down" then + local b = entry.letters[entry.position]:byte() + b = b - 1; if b < 65 then b = 90 end + entry.letters[entry.position] = string.char(b) + elseif key == "return" or key == "kpenter" then + local initials = table.concat(entry.letters) + local score = entry.score + entry.active = false + HighScores.addScore(initials, score) + return "done", {initials = initials, score = score} + elseif key:match("^%a$") and #key == 1 then + entry.letters[entry.position] = key:upper() + if entry.position < 3 then entry.position = entry.position + 1 end + end + return nil +end + +function HighScores.drawEntry(screenW, screenH, palette, fonts) + local midX = screenW / 2 + local midY = screenH * 0.25 + local t = love.timer.getTime() + + love.graphics.setFont(fonts.large) + local pulse = 0.7 + math.sin(t * 4) * 0.3 + love.graphics.setColor(palette.bright[1], palette.bright[2], palette.bright[3], pulse) + love.graphics.printf("NEW HIGH SCORE", 0, midY, screenW, "center") + + midY = midY + fonts.large:getHeight() + 16 + love.graphics.setFont(fonts.medium) + love.graphics.setColor(palette.fg) + love.graphics.printf(string.format("%d", entry.score), 0, midY, screenW, "center") + + midY = midY + fonts.medium:getHeight() + 32 + local boxW = math.floor(screenW * 0.06) + local boxH = math.floor(boxW * 1.3) + local gap = math.floor(boxW * 0.4) + local totalW = boxW * 3 + gap * 2 + local startX = midX - totalW / 2 + + love.graphics.setFont(fonts.large) + local letterH = fonts.large:getHeight() + + for i = 1, 3 do + local bx = startX + (i - 1) * (boxW + gap) + local by = midY + local isSelected = (i == entry.position) + + if isSelected then + local blinkOn = math.floor(entry.blink * 3) % 2 == 0 + love.graphics.setColor(blinkOn and palette.bright or palette.dim) + love.graphics.setLineWidth(3) + else + love.graphics.setColor(palette.dim) + love.graphics.setLineWidth(2) + end + love.graphics.rectangle("line", bx, by, boxW, boxH) + + if isSelected then + love.graphics.setColor(palette.bright[1], palette.bright[2], palette.bright[3], 0.5) + local arrowX = bx + boxW / 2 + love.graphics.polygon("fill", arrowX-5, by-4, arrowX+5, by-4, arrowX, by-12) + love.graphics.polygon("fill", arrowX-5, by+boxH+4, arrowX+5, by+boxH+4, arrowX, by+boxH+12) + end + + love.graphics.setColor(palette.fg) + local lw = fonts.large:getWidth(entry.letters[i]) + love.graphics.print(entry.letters[i], bx + (boxW - lw)/2, by + (boxH - letterH)/2) + end + + love.graphics.setFont(fonts.small) + love.graphics.setColor(palette.dim) + love.graphics.printf("TYPE LETTERS / ARROWS / ENTER TO CONFIRM", 0, midY + boxH + 32, screenW, "center") +end + +function HighScores.drawTable(screenW, screenH, palette, fonts) + local topY = screenH * 0.68 + local lineH = fonts.medium:getHeight() + 4 + + love.graphics.setFont(fonts.medium) + love.graphics.setColor(palette.bright) + love.graphics.printf("HIGH SCORES", 0, topY, screenW, "center") + + topY = topY + lineH + 8 + love.graphics.setFont(fonts.small) + local entryH = fonts.small:getHeight() + 3 + + if #scores == 0 then return end + + local colW = math.floor(screenW * 0.4) + local startX = (screenW - colW) / 2 + + for i, s in ipairs(scores) do + local y = topY + (i - 1) * entryH + if i == 1 then + love.graphics.setColor(palette.bright) + else + love.graphics.setColor(palette.fg[1], palette.fg[2], palette.fg[3], 0.8) + end + love.graphics.print(string.format("%2d. %s", i, s.initials), startX, y) + local sw = fonts.small:getWidth(string.format("%d", s.score)) + love.graphics.print(string.format("%d", s.score), startX + colW - sw, y) + end +end + +return HighScores diff --git a/game/hud.lua b/game/hud.lua new file mode 100644 index 0000000..63475f1 --- /dev/null +++ b/game/hud.lua @@ -0,0 +1,59 @@ +local World = require("game.world") +local Palette = require("rendering.palette") +local Fonts = require("rendering.fonts") + +local HUD = {} + +function HUD.draw(altitude, hspeed, vspeed) + local p = Palette.get() + local sw, sh = World.screenW, World.screenH + local pad = 12 + + love.graphics.setFont(Fonts.small) + + -- Left column: SCORE, TIME, FUEL + love.graphics.setColor(p.hud) + love.graphics.print("SCORE " .. string.format("%d", World.score), pad, pad) + love.graphics.print("TIME " .. string.format("%.0f", World.time), pad, pad + Fonts.small:getHeight() + 4) + + -- Fuel bar + local fuelY = pad + (Fonts.small:getHeight() + 4) * 2 + local fuelMax = 750 + local fuelFrac = math.max(0, World.fuel / fuelMax) + local barW = sw * 0.15 + local barH = Fonts.small:getHeight() * 0.7 + + love.graphics.print("FUEL", pad, fuelY) + local barX = pad + Fonts.small:getWidth("FUEL") + 8 + + love.graphics.setColor(p.dim) + love.graphics.rectangle("line", barX, fuelY + 2, barW, barH) + + if World.fuel < 150 and math.floor(love.timer.getTime() * 4) % 2 == 0 then + love.graphics.setColor(p.warning) + else + love.graphics.setColor(p.pad) + end + love.graphics.rectangle("fill", barX + 1, fuelY + 3, (barW - 2) * fuelFrac, barH - 2) + + love.graphics.setColor(p.hud) + love.graphics.print(string.format("%.0f", World.fuel), barX + barW + 8, fuelY) + + -- Right column: ALTITUDE, HORIZONTAL SPEED, VERTICAL SPEED + local rx = sw - pad + local function rightPrint(text, y) + local w = Fonts.small:getWidth(text) + love.graphics.print(text, rx - w, y) + end + + love.graphics.setColor(p.hud) + rightPrint(string.format("ALTITUDE %.0f", altitude), pad) + + local hdir = hspeed > 0.5 and " >" or (hspeed < -0.5 and "< " or " ") + rightPrint(string.format("HORIZ SPEED %s%.1f", hdir, math.abs(hspeed)), pad + Fonts.small:getHeight() + 4) + + local vdir = vspeed > 0.5 and " v" or (vspeed < -0.5 and " ^" or " ") + rightPrint(string.format("VERT SPEED %s%.1f", vdir, math.abs(vspeed)), pad + (Fonts.small:getHeight() + 4) * 2) +end + +return HUD diff --git a/game/lander.lua b/game/lander.lua new file mode 100644 index 0000000..ead0f11 --- /dev/null +++ b/game/lander.lua @@ -0,0 +1,211 @@ +local World = require("game.world") +local Palette = require("rendering.palette") + +local Lander = {} + +local GRAVITY = 25 +local THRUST_ACCEL = 60 +local ROT_SPEED = 2.1 -- ~120 deg/sec in radians +local FUEL_RATE = 12 -- fuel per second at full thrust + +-- Lander shape (local coords, 0 = pointing up, Y+ down) +local BODY = { + {-8, -8}, {-4, -12}, {4, -12}, {8, -8}, + {10, 0}, {8, 6}, {-8, 6}, {-10, 0}, +} + +local LEGS = { + {{-8, 6}, {-14, 16}}, + {{-6, 6}, {-14, 16}}, + {{8, 6}, {14, 16}}, + {{6, 6}, {14, 16}}, +} + +local FEET = { + {{-17, 16}, {-11, 16}}, + {{11, 16}, {17, 16}}, +} + +local WINDOW = { + {{-3, -6}, {3, -6}}, + {{3, -6}, {3, -2}}, + {{3, -2}, {-3, -2}}, + {{-3, -2}, {-3, -6}}, +} + +local ANTENNA = { + {{0, -12}, {0, -18}}, + {{-3, -18}, {3, -18}}, +} + +local lander = {} + +function Lander.init() + lander.x = World.WORLD_W / 2 + (math.random() - 0.5) * 1000 + lander.y = 200 + lander.vx = 20 + math.random() * 20 + lander.vy = 5 + math.random() * 10 + lander.angle = 0 -- 0 = pointing up + lander.alive = true + lander.landed = false + lander.thrusting = false +end + +function Lander.get() + return lander +end + +local function transformPoint(px, py, angle, cx, cy) + local cos_a = math.cos(angle) + local sin_a = math.sin(angle) + return cx + px * cos_a - py * sin_a, + cy + px * sin_a + py * cos_a +end + +function Lander.update(dt) + if not lander.alive or lander.landed then return end + + -- Gravity + lander.vy = lander.vy + GRAVITY * dt + + -- Rotation + if love.keyboard.isDown("left", "a") then + lander.angle = lander.angle - ROT_SPEED * dt + end + if love.keyboard.isDown("right", "d") then + lander.angle = lander.angle + ROT_SPEED * dt + end + + -- Thrust + lander.thrusting = love.keyboard.isDown("up", "w") and World.fuel > 0 + if lander.thrusting then + -- Thrust in the direction the lander is pointing (up from lander's perspective) + lander.vx = lander.vx - math.sin(lander.angle) * THRUST_ACCEL * dt + lander.vy = lander.vy - math.cos(lander.angle) * THRUST_ACCEL * dt + World.fuel = math.max(0, World.fuel - FUEL_RATE * dt) + end + + -- Move + lander.x = lander.x + lander.vx * dt + lander.y = lander.y + lander.vy * dt + + -- Clamp X + lander.x = math.max(20, math.min(World.WORLD_W - 20, lander.x)) +end + +function Lander.abort() + if not lander.alive or lander.landed then return end + if World.fuel < 10 then return end + + -- Auto-level: snap angle toward 0 + lander.angle = lander.angle * 0.3 + -- Full thrust burst + lander.vy = lander.vy - THRUST_ACCEL * 0.5 + -- Costs a chunk of fuel + World.fuel = math.max(0, World.fuel - 50) +end + +function Lander.getCollisionPoints() + -- Return transformed foot positions for terrain collision + local pts = {} + -- Foot endpoints + local footPts = {{-17, 16}, {-11, 16}, {11, 16}, {17, 16}} + for _, fp in ipairs(footPts) do + local wx, wy = transformPoint(fp[1], fp[2], lander.angle, lander.x, lander.y) + table.insert(pts, {x = wx, y = wy}) + end + -- Body bottom + local bodyBottom = {{-8, 6}, {8, 6}} + for _, bp in ipairs(bodyBottom) do + local wx, wy = transformPoint(bp[1], bp[2], lander.angle, lander.x, lander.y) + table.insert(pts, {x = wx, y = wy}) + end + return pts +end + +function Lander.die() + lander.alive = false +end + +function Lander.land() + lander.landed = true + lander.vx = 0 + lander.vy = 0 +end + +function Lander.draw() + if not lander.alive then return end + + local p = Palette.get() + local a = lander.angle + local cx, cy = lander.x, lander.y + + -- Body outline + love.graphics.setColor(p.lander) + love.graphics.setLineWidth(2) + local bodyPts = {} + for _, v in ipairs(BODY) do + local wx, wy = transformPoint(v[1], v[2], a, cx, cy) + table.insert(bodyPts, wx) + table.insert(bodyPts, wy) + end + table.insert(bodyPts, bodyPts[1]) + table.insert(bodyPts, bodyPts[2]) + love.graphics.line(bodyPts) + + -- Legs + love.graphics.setLineWidth(1.5) + for _, leg in ipairs(LEGS) do + local x1, y1 = transformPoint(leg[1][1], leg[1][2], a, cx, cy) + local x2, y2 = transformPoint(leg[2][1], leg[2][2], a, cx, cy) + love.graphics.line(x1, y1, x2, y2) + end + + -- Feet + love.graphics.setLineWidth(2) + for _, foot in ipairs(FEET) do + local x1, y1 = transformPoint(foot[1][1], foot[1][2], a, cx, cy) + local x2, y2 = transformPoint(foot[2][1], foot[2][2], a, cx, cy) + love.graphics.line(x1, y1, x2, y2) + end + + -- Window + love.graphics.setColor(p.lander[1], p.lander[2], p.lander[3], 0.5) + love.graphics.setLineWidth(1) + for _, seg in ipairs(WINDOW) do + local x1, y1 = transformPoint(seg[1][1], seg[1][2], a, cx, cy) + local x2, y2 = transformPoint(seg[2][1], seg[2][2], a, cx, cy) + love.graphics.line(x1, y1, x2, y2) + end + + -- Antenna + love.graphics.setColor(p.lander) + for _, seg in ipairs(ANTENNA) do + local x1, y1 = transformPoint(seg[1][1], seg[1][2], a, cx, cy) + local x2, y2 = transformPoint(seg[2][1], seg[2][2], a, cx, cy) + love.graphics.line(x1, y1, x2, y2) + end + + -- Thrust flame + if lander.thrusting then + love.graphics.setColor(p.thrust) + love.graphics.setLineWidth(2) + local flameLen = 10 + math.random() * 15 + local flameSpread = 3 + math.random() * 3 + + local fx1, fy1 = transformPoint(-flameSpread, 6, a, cx, cy) + local fx2, fy2 = transformPoint(flameSpread, 6, a, cx, cy) + local ftx, fty = transformPoint((math.random()-0.5)*3, 6 + flameLen, a, cx, cy) + + love.graphics.line(fx1, fy1, ftx, fty) + love.graphics.line(fx2, fy2, ftx, fty) + + -- Inner flame + local flameLen2 = 5 + math.random() * 8 + local ft2x, ft2y = transformPoint((math.random()-0.5)*2, 6 + flameLen2, a, cx, cy) + love.graphics.setColor(p.bright) + love.graphics.line(cx, cy + 3, ft2x, ft2y) + end +end + +return Lander diff --git a/game/particles.lua b/game/particles.lua new file mode 100644 index 0000000..f096599 --- /dev/null +++ b/game/particles.lua @@ -0,0 +1,95 @@ +local Palette = require("rendering.palette") + +local Particles = {} + +local particles = {} + +function Particles.spawnThrust(x, y, angle) + local spread = 0.5 + local speed = 30 + math.random() * 40 + local dir = angle + math.pi + (math.random() - 0.5) * spread + table.insert(particles, { + x = x, y = y, + vx = math.sin(dir) * speed, + vy = math.cos(dir) * speed, + life = 0.2 + math.random() * 0.2, + maxLife = 0.4, + ptype = "dot", + }) +end + +function Particles.spawnCrash(x, y, vx, vy) + for i = 1, 20 do + local angle = math.random() * math.pi * 2 + local speed = 40 + math.random() * 100 + table.insert(particles, { + x = x, y = y, + vx = vx * 0.2 + math.cos(angle) * speed, + vy = vy * 0.2 + math.sin(angle) * speed, + life = 0.5 + math.random() * 1.0, + maxLife = 1.5, + ptype = "dot", + }) + end + -- Line debris + for i = 1, 6 do + local angle = math.random() * math.pi * 2 + local speed = 30 + math.random() * 60 + local len = 5 + math.random() * 10 + local da = math.random() * math.pi * 2 + table.insert(particles, { + x = x + (math.random()-0.5)*10, y = y + (math.random()-0.5)*10, + vx = math.cos(angle) * speed, + vy = math.sin(angle) * speed - 20, + lx1 = math.cos(da) * len, ly1 = math.sin(da) * len, + lx2 = -math.cos(da) * len, ly2 = -math.sin(da) * len, + rot = 0, rotSpeed = (math.random()-0.5) * 5, + life = 1.0 + math.random() * 1.0, + maxLife = 2.0, + ptype = "line", + }) + end +end + +function Particles.update(dt) + for i = #particles, 1, -1 do + local p = particles[i] + p.x = p.x + p.vx * dt + p.y = p.y + p.vy * dt + p.vy = p.vy + 15 * dt -- light gravity on debris + p.life = p.life - dt + if p.rot then p.rot = p.rot + (p.rotSpeed or 0) * dt end + if p.life <= 0 then table.remove(particles, i) end + end +end + +function Particles.clear() + particles = {} +end + +function Particles.anyActive() + return #particles > 0 +end + +function Particles.draw() + local pal = Palette.get() + for _, p in ipairs(particles) do + local alpha = math.max(0, p.life / p.maxLife) + if p.ptype == "dot" then + love.graphics.setColor(pal.explosion[1], pal.explosion[2], pal.explosion[3], alpha) + love.graphics.circle("fill", p.x, p.y, 1.5) + elseif p.ptype == "line" then + love.graphics.setColor(pal.lander[1], pal.lander[2], pal.lander[3], alpha) + love.graphics.setLineWidth(1.5) + local cos_r = math.cos(p.rot) + local sin_r = math.sin(p.rot) + local x1 = p.x + p.lx1 * cos_r - p.ly1 * sin_r + local y1 = p.y + p.lx1 * sin_r + p.ly1 * cos_r + local x2 = p.x + p.lx2 * cos_r - p.ly2 * sin_r + local y2 = p.y + p.lx2 * sin_r + p.ly2 * cos_r + love.graphics.line(x1, y1, x2, y2) + end + end +end + +return Particles diff --git a/game/stars.lua b/game/stars.lua new file mode 100644 index 0000000..facb770 --- /dev/null +++ b/game/stars.lua @@ -0,0 +1,29 @@ +local Palette = require("rendering.palette") + +local Stars = {} + +local starList = {} + +function Stars.init() + starList = {} + math.randomseed(99999) + for i = 1, 150 do + table.insert(starList, { + x = math.random(), + y = math.random() * 0.7, -- top 70% of screen + brightness = 0.2 + math.random() * 0.6, + size = 0.5 + math.random() * 1.0, + }) + end + math.randomseed(os.time()) +end + +function Stars.draw(screenW, screenH) + local p = Palette.get() + for _, s in ipairs(starList) do + love.graphics.setColor(p.stars[1], p.stars[2], p.stars[3], s.brightness) + love.graphics.circle("fill", s.x * screenW, s.y * screenH, s.size) + end +end + +return Stars diff --git a/game/terrain.lua b/game/terrain.lua new file mode 100644 index 0000000..0e290de --- /dev/null +++ b/game/terrain.lua @@ -0,0 +1,166 @@ +local World = require("game.world") +local Palette = require("rendering.palette") + +local Terrain = {} + +local points = {} +local pads = {} + +function Terrain.generate() + points = {} + pads = {} + + local W = World.WORLD_W + local baseline = 1600 + local step = 30 + + -- Place landing pads first + local padDefs = { + {width = 120, mult = 2, label = "2X"}, + {width = 80, mult = 3, label = "3X"}, + {width = 50, mult = 5, label = "5X"}, + } + + -- Distribute pads across the terrain + local padPositions = {} + local usedZones = {} + for _, def in ipairs(padDefs) do + local attempts = 0 + local px + repeat + px = 400 + math.random() * (W - 800) + attempts = attempts + 1 + local ok = true + for _, used in ipairs(usedZones) do + if math.abs(px - used) < 400 then ok = false; break end + end + if ok then break end + until attempts > 50 + + local py = baseline + (math.random() - 0.5) * 200 + table.insert(padPositions, {x = px, y = py, width = def.width, mult = def.mult, label = def.label}) + table.insert(usedZones, px) + table.insert(pads, { + x1 = px - def.width / 2, + x2 = px + def.width / 2, + y = py, + mult = def.mult, + label = def.label, + }) + end + + -- Sort pads by X for terrain generation + table.sort(pads, function(a, b) return a.x1 < b.x1 end) + + -- Generate terrain points left to right + local x = 0 + local y = baseline + (math.random() - 0.5) * 100 + table.insert(points, {x = x, y = y}) + + while x < W do + -- Check if we're approaching a pad + local onPad = false + local currentPad = nil + for _, pad in ipairs(pads) do + if x >= pad.x1 - step and x <= pad.x2 + step then + onPad = true + currentPad = pad + break + end + end + + if onPad and currentPad then + -- Transition to pad level + if x < currentPad.x1 then + -- Approach: slope down/up to pad + table.insert(points, {x = currentPad.x1 - 5, y = y}) + table.insert(points, {x = currentPad.x1, y = currentPad.y}) + x = currentPad.x1 + end + -- Flat pad + table.insert(points, {x = currentPad.x2, y = currentPad.y}) + x = currentPad.x2 + y = currentPad.y + -- Resume jagged after pad + x = x + step * 0.5 + y = y + (math.random() - 0.5) * 60 + table.insert(points, {x = x, y = y}) + else + x = x + step + math.random() * step + -- Jagged variation + y = y + (math.random() - 0.5) * 120 + y = math.max(baseline - 250, math.min(baseline + 250, y)) + table.insert(points, {x = x, y = y}) + end + end + + -- Ensure last point reaches edge + table.insert(points, {x = W, y = points[#points].y}) +end + +function Terrain.getPoints() + return points +end + +function Terrain.getPads() + return pads +end + +function Terrain.getHeightAt(wx) + -- Find terrain height at world X by interpolating between points + if #points < 2 then return 1600 end + if wx <= points[1].x then return points[1].y end + if wx >= points[#points].x then return points[#points].y end + + for i = 1, #points - 1 do + if points[i].x <= wx and points[i+1].x > wx then + local t = (wx - points[i].x) / (points[i+1].x - points[i].x) + return points[i].y + t * (points[i+1].y - points[i].y) + end + end + return 1600 +end + +function Terrain.getPadAt(wx) + for _, pad in ipairs(pads) do + if wx >= pad.x1 and wx <= pad.x2 then + return pad + end + end + return nil +end + +function Terrain.draw(visMinX, visMaxX) + local p = Palette.get() + + -- Draw terrain surface + love.graphics.setColor(p.terrain) + love.graphics.setLineWidth(2) + + local pts = {} + for _, pt in ipairs(points) do + if pt.x >= visMinX - 100 and pt.x <= visMaxX + 100 then + table.insert(pts, pt.x) + table.insert(pts, pt.y) + end + end + if #pts >= 4 then + love.graphics.line(pts) + end + + -- Draw landing pads (brighter, with labels) + love.graphics.setColor(p.pad) + love.graphics.setLineWidth(3) + for _, pad in ipairs(pads) do + if pad.x2 >= visMinX and pad.x1 <= visMaxX then + love.graphics.line(pad.x1, pad.y, pad.x2, pad.y) + + -- Label below pad + love.graphics.setColor(p.pad[1], p.pad[2], p.pad[3], 0.7) + local labelX = (pad.x1 + pad.x2) / 2 + love.graphics.print(pad.label, labelX - 8, pad.y + 5) + end + end +end + +return Terrain diff --git a/game/world.lua b/game/world.lua new file mode 100644 index 0000000..8db2750 --- /dev/null +++ b/game/world.lua @@ -0,0 +1,40 @@ +local World = { + WORLD_W = 4000, + WORLD_H = 2000, + screenW = 0, + screenH = 0, + baseScale = 1, + state = "title", + score = 0, + highScore = 0, + fuel = 750, + time = 0, + stateTimer = 0, + landingResult = "", + landingPoints = 0, +} + +function World.resize(w, h) + if not w or not h then w, h = love.graphics.getDimensions() end + World.screenW = w + World.screenH = h + World.baseScale = w / World.WORLD_W +end + +function World.ensureScale() + local w, h = love.graphics.getDimensions() + if w ~= World.screenW or h ~= World.screenH then + World.resize(w, h) + local Fonts = require("rendering.fonts") + Fonts.init(1) + end +end + +function World.addScore(points) + World.score = World.score + points + if World.score > World.highScore then + World.highScore = World.score + end +end + +return World diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..00edd59 --- /dev/null +++ b/main.lua @@ -0,0 +1,336 @@ +local World = require("game.world") +local Lander = require("game.lander") +local Terrain = require("game.terrain") +local Camera = require("game.camera") +local Particles = require("game.particles") +local Stars = require("game.stars") +local HUD = require("game.hud") +local HighScores = require("game.highscores") +local Palette = require("rendering.palette") +local Fonts = require("rendering.fonts") +local Sounds = require("audio.sounds") + +local thrustSoundTimer = 0 +local fuelWarnTimer = 0 +local STATE_PAUSE = 3.0 + +local function startGame() + World.state = "playing" + World.score = 0 + World.fuel = 750 + World.time = 0 + World.stateTimer = 0 + + Terrain.generate() + Lander.init() + Camera.reset() + Particles.clear() +end + +local function nextAttempt() + World.stateTimer = 0 + Terrain.generate() + Lander.init() + Camera.reset() + Particles.clear() + World.state = "playing" +end + +local function checkLanding() + local l = Lander.get() + local colPts = Lander.getCollisionPoints() + + for _, pt in ipairs(colPts) do + local terrainY = Terrain.getHeightAt(pt.x) + if pt.y >= terrainY then + -- Contact! Evaluate landing + local pad = Terrain.getPadAt(l.x) + local vspeed = math.abs(l.vy) + local hspeed = math.abs(l.vx) + local tilt = math.abs(l.angle) + + if pad and vspeed < 15 and hspeed < 30 and tilt < math.rad(15) then + -- Good landing + local pts = 50 * pad.mult + World.addScore(pts) + World.fuel = World.fuel + 50 + World.landingResult = "GOOD LANDING! " .. pad.label .. " = " .. pts .. " PTS" + World.landingPoints = pts + World.state = "landed" + Lander.land() + Sounds.play("land_good") + elseif pad and vspeed < 25 and hspeed < 45 and tilt < math.rad(25) then + -- Hard landing + local pts = 15 * pad.mult + World.addScore(pts) + World.landingResult = "HARD LANDING " .. pts .. " PTS" + World.landingPoints = pts + World.state = "landed" + Lander.land() + Sounds.play("land_hard") + else + -- Crash + World.addScore(5) + World.landingResult = "CRASH! 5 PTS" + World.landingPoints = 5 + World.state = "crashed" + Particles.spawnCrash(l.x, l.y, l.vx, l.vy) + Lander.die() + Sounds.play("crash") + end + + World.stateTimer = 0 + return + end + end +end + +local function drawTitleScreen() + local p = Palette.get() + local sw, sh = World.screenW, World.screenH + local t = love.timer.getTime() + + -- Background + love.graphics.setColor(p.bg) + love.graphics.rectangle("fill", 0, 0, sw, sh) + Stars.draw(sw, sh) + + -- Title + local titleY = sh * 0.15 + love.graphics.setFont(Fonts.large) + love.graphics.setColor(p.bright) + love.graphics.printf("LUNAR LANDER", 0, titleY, sw, "center") + + -- Controls + love.graphics.setFont(Fonts.small) + love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.4) + love.graphics.printf("ARROWS / WASD: ROTATE & THRUST SPACE: ABORT", 0, sh * 0.38, sw, "center") + love.graphics.printf("LAND GENTLY ON MARKED PADS FOR POINTS", 0, sh * 0.42, sw, "center") + + -- Press Enter + local pulse = 0.3 + math.sin(t * 3) * 0.3 + love.graphics.setFont(Fonts.medium) + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse + 0.2) + love.graphics.printf("PRESS ENTER TO START", 0, sh * 0.52, sw, "center") + + -- High scores + local allScores = HighScores.getScores() + if #allScores > 0 then + HighScores.drawTable(sw, sh, p, Fonts) + end +end + +function love.load() + love.mouse.setVisible(false) + love.graphics.setBackgroundColor(0, 0, 0) + love.graphics.setLineStyle("smooth") + Palette.loadFromSystem() + World.resize(love.graphics.getDimensions()) + Fonts.init(1) + HighScores.init() + Sounds.init() + Stars.init() + World.highScore = HighScores.getHighest() + World.state = "title" +end + +function love.resize(w, h) + World.resize(w, h) + Fonts.init(1) +end + +function love.update(dt) + World.ensureScale() + + if World.state == "title" then + return + end + + if World.state == "playing" then + World.time = World.time + dt + Lander.update(dt) + Camera.update(Lander.get(), Terrain, dt) + Particles.update(dt) + + -- Thrust sound + local l = Lander.get() + if l.thrusting then + thrustSoundTimer = thrustSoundTimer + dt + if thrustSoundTimer > 0.2 then + thrustSoundTimer = 0 + Sounds.play("thrust") + end + -- Thrust particles + local fx, fy = l.x, l.y + 8 + Particles.spawnThrust(fx, fy, l.angle) + else + thrustSoundTimer = 0.15 + end + + -- Fuel warning + if World.fuel < 150 and World.fuel > 0 then + fuelWarnTimer = fuelWarnTimer + dt + if fuelWarnTimer > 0.5 then + fuelWarnTimer = 0 + Sounds.play("fuel_warn") + end + end + + -- Check terrain collision + checkLanding() + + -- Game over if fuel depleted and landed/still + if World.fuel <= 0 and not l.thrusting then + -- Let them coast until they crash or land + end + + elseif World.state == "landed" or World.state == "crashed" then + World.stateTimer = World.stateTimer + dt + Particles.update(dt) + Camera.update(Lander.get(), Terrain, dt) + + if World.stateTimer >= STATE_PAUSE then + if World.fuel <= 0 then + World.state = "game_over" + World.stateTimer = 0 + else + nextAttempt() + end + end + + elseif World.state == "game_over" then + World.stateTimer = World.stateTimer + dt + + elseif World.state == "high_score_entry" then + HighScores.updateEntry(dt) + end +end + +function love.draw() + love.graphics.clear(0, 0, 0) + local p = Palette.get() + local sw, sh = World.screenW, World.screenH + + if World.state == "title" then + drawTitleScreen() + return + end + + if World.state == "high_score_entry" then + love.graphics.setColor(p.bg) + love.graphics.rectangle("fill", 0, 0, sw, sh) + Stars.draw(sw, sh) + HighScores.drawEntry(sw, sh, p, Fonts) + return + end + + -- Draw game world + love.graphics.setColor(p.bg) + love.graphics.rectangle("fill", 0, 0, sw, sh) + + -- Stars (screen space, behind everything) + Stars.draw(sw, sh) + + -- World space + Camera.applyTransform() + + local minX, minY, maxX, maxY = Camera.getVisibleRect() + Terrain.draw(minX, maxX) + Lander.draw() + Particles.draw() + + Camera.popTransform() + + -- HUD (screen space) + local l = Lander.get() + local altitude = Camera.getAltitude(l.y, Terrain, l.x) + HUD.draw(altitude, l.vx, l.vy) + + -- Landing/crash result text + if World.state == "landed" or World.state == "crashed" then + love.graphics.setFont(Fonts.medium) + local flash = World.state == "crashed" and + (math.floor(World.stateTimer * 4) % 2 == 0) or true + + if flash then + if World.state == "crashed" then + love.graphics.setColor(p.warning) + else + love.graphics.setColor(p.bright) + end + love.graphics.printf(World.landingResult, 0, sh * 0.45, sw, "center") + end + end + + -- Game over + if World.state == "game_over" then + local t = love.timer.getTime() + + love.graphics.setColor(0, 0, 0, 0.5) + love.graphics.rectangle("fill", 0, 0, sw, sh) + + if World.stateTimer > 0.5 then + love.graphics.setFont(Fonts.large) + local pulse = 0.7 + math.sin(t * 3) * 0.3 + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse) + love.graphics.printf("OUT OF FUEL", 0, sh * 0.3, sw, "center") + end + + if World.stateTimer > 1.5 then + love.graphics.setFont(Fonts.medium) + love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.7) + love.graphics.printf("FINAL SCORE: " .. string.format("%d", World.score), 0, sh * 0.3 + Fonts.large:getHeight() + 8, sw, "center") + end + + if World.stateTimer > 2.5 then + local pulse = 0.3 + math.sin(t * 3) * 0.3 + love.graphics.setFont(Fonts.small) + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse + 0.2) + love.graphics.printf("PRESS ENTER", 0, sh * 0.3 + Fonts.large:getHeight() + Fonts.medium:getHeight() + 24, sw, "center") + end + end +end + +function love.keypressed(key) + if World.state == "title" then + if key == "return" then startGame() end + if key == "escape" then love.event.quit() end + return + end + + if World.state == "high_score_entry" then + local result = HighScores.keypressedEntry(key) + if result == "done" then + World.highScore = HighScores.getHighest() + World.state = "title" + end + return + end + + if World.state == "playing" then + if key == "space" then + Lander.abort() + Sounds.play("abort") + end + end + + if World.state == "game_over" then + if key == "return" and World.stateTimer > 2.0 then + if HighScores.isHighScore(World.score) then + World.state = "high_score_entry" + HighScores.startEntry(World.score) + else + World.state = "title" + end + return + end + if key == "escape" then + World.state = "title" + return + end + end + + if key == "escape" then + love.event.quit() + end +end diff --git a/rendering/fonts.lua b/rendering/fonts.lua new file mode 100644 index 0000000..4f8558d --- /dev/null +++ b/rendering/fonts.lua @@ -0,0 +1,64 @@ +local Fonts = {} + +Fonts.small = nil +Fonts.medium = nil +Fonts.large = nil +Fonts.currentH = 0 +Fonts.fontPath = nil +Fonts.fontData = nil + +function Fonts.detectSystemFont() + local home = os.getenv("HOME") + local f = io.open(home .. "/.config/waybar/style.css", "r") + if not f then return nil end + local content = f:read("*a") + f:close() + local fontName = content:match("font%-family:%s*[\"']?([^;\"']+)") + if not fontName then return nil end + fontName = fontName:match("^%s*(.-)%s*$") + local handle = io.popen('fc-match "' .. fontName .. '" --format="%{file}"') + if not handle then return nil end + local path = handle:read("*a") + handle:close() + if path and path ~= "" then + local test = io.open(path, "r") + if test then test:close(); return path end + end + return nil +end + +function Fonts.init(scale) + local h = love.graphics.getHeight() + if h == Fonts.currentH then return end + Fonts.currentH = h + + if not Fonts.fontPath then + Fonts.fontPath = Fonts.detectSystemFont() or false + end + if Fonts.fontPath and not Fonts.fontData then + local f = io.open(Fonts.fontPath, "rb") + if f then + local data = f:read("*a") + f:close() + Fonts.fontData = love.filesystem.newFileData(data, "systemfont.ttf") + else + Fonts.fontPath = false + end + end + + local sS = math.max(10, math.floor(h * 0.018)) + local sM = math.max(12, math.floor(h * 0.025)) + local sL = math.max(16, math.floor(h * 0.045)) + + if Fonts.fontData then + Fonts.small = love.graphics.newFont(Fonts.fontData, sS) + Fonts.medium = love.graphics.newFont(Fonts.fontData, sM) + Fonts.large = love.graphics.newFont(Fonts.fontData, sL) + else + Fonts.small = love.graphics.newFont(sS) + Fonts.medium = love.graphics.newFont(sM) + Fonts.large = love.graphics.newFont(sL) + end +end + +return Fonts diff --git a/rendering/palette.lua b/rendering/palette.lua new file mode 100644 index 0000000..f76a60a --- /dev/null +++ b/rendering/palette.lua @@ -0,0 +1,63 @@ +local Palette = {} + +local theme = {} + +local function hexToRGB(hex) + hex = hex:gsub("#", "") + return { + tonumber(hex:sub(1,2), 16) / 255, + tonumber(hex:sub(3,4), 16) / 255, + tonumber(hex:sub(5,6), 16) / 255, + } +end + +function Palette.loadFromSystem() + local path = os.getenv("HOME") .. "/.config/omarchy/current/theme/ghostty.conf" + local f = io.open(path, "r") + if not f then + theme.bg = {0, 0, 0} + theme.fg = {1, 1, 1} + theme.accent = {1, 1, 1} + for i = 0, 15 do theme["color" .. i] = {i/15, i/15, i/15} end + theme.dim = theme.color8 + return + end + for line in f:lines() do + local key, val = line:match("^(%S+)%s*=%s*(#%x+)") + if key and val then + if key == "background" then theme.bg = hexToRGB(val) + elseif key == "foreground" then theme.fg = hexToRGB(val) + elseif key == "cursor-color" then theme.accent = hexToRGB(val) + end + end + local idx, hex = line:match("^palette%s*=%s*(%d+)=(#%x+)") + if idx and hex then theme["color" .. idx] = hexToRGB(hex) end + end + f:close() + theme.dim = theme.color8 or theme.color0 or {0.2, 0.2, 0.2} + for i = 0, 15 do + if not theme["color" .. i] then theme["color" .. i] = theme.fg or {1, 1, 1} end + end + if not theme.bg then theme.bg = {0, 0, 0} end + if not theme.fg then theme.fg = {1, 1, 1} end + if not theme.accent then theme.accent = theme.color12 or theme.fg end +end + +function Palette.get() + return { + bg = theme.bg, + fg = theme.fg, + lander = theme.fg, + terrain = theme.color4, + pad = theme.color2 or theme.color3, + thrust = theme.accent, + explosion = theme.accent, + hud = theme.fg, + bright = theme.accent, + dim = theme.dim, + stars = theme.dim, + warning = theme.accent, + } +end + +return Palette