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) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-04-13 14:42:13 +01:00
commit 790ca87bfb
13 changed files with 1424 additions and 0 deletions

84
audio/sounds.lua Normal file
View file

@ -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

11
conf.lua Normal file
View file

@ -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

68
game/camera.lua Normal file
View file

@ -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

198
game/highscores.lua Normal file
View file

@ -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

59
game/hud.lua Normal file
View file

@ -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

211
game/lander.lua Normal file
View file

@ -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

95
game/particles.lua Normal file
View file

@ -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

29
game/stars.lua Normal file
View file

@ -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

166
game/terrain.lua Normal file
View file

@ -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

40
game/world.lua Normal file
View file

@ -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

336
main.lua Normal file
View file

@ -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

64
rendering/fonts.lua Normal file
View file

@ -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

63
rendering/palette.lua Normal file
View file

@ -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