Initial public release

This commit is contained in:
28allday 2026-04-18 23:20:40 +01:00
commit 08cf8b815c
17 changed files with 1883 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
CLAUDE.md
.claude/

84
README.md Normal file
View file

@ -0,0 +1,84 @@
# OMA-LANDER
A faithful Lunar Lander arcade recreation for Omarchy Linux, inspired by the 1979 Atari vector cabinet. Built with Love2D — pure Lua, vector line graphics, procedural terrain, procedurally generated audio.
The lander itself is drawn as an Apollo Lunar Module silhouette: wide octagonal descent stage, angular ascent-stage cabin, splayed A-frame legs with saucer foot pads and surface probes, descent engine bell, docking tunnel, rendezvous radar, and triangular cockpit windows.
## Install
```bash
curl -sL https://git.no-signal.uk/nosignal/oma-lander/raw/branch/master/install.sh | bash
```
## Uninstall
```bash
oma-lander-uninstall
```
## Controls
| Input | Action |
|-------|--------|
| **Up / W** | Thrust (main engine) |
| **Left / A** | Rotate counter-clockwise |
| **Right / D** | Rotate clockwise |
| **Space** | Abort burn (auto-level, kills horizontal speed, heavy fuel cost) |
| **Enter** | Confirm / advance menus |
| **Escape** | Back / quit |
## Gameplay
Select a mission difficulty, then pilot the LM down through procedurally-generated lunar terrain to a marked landing pad. The camera tracks you and zooms in as you descend — altitude, horizontal and vertical speed, fuel, score and time are on the HUD.
### Mission difficulties
| Mission | Gravity | Fuel | Notes |
|---|---|---|---|
| **CADET** | 8 | 1000 | Training — low gravity, extra fuel |
| **PILOT** | 12 | 750 | Standard mission |
| **COMMANDER** | 16 | 600 | Heavier pull, less fuel |
| **ASTRONAUT** | 22 | 500 | Elite — extreme gravity |
### Landing pads
Three pads are placed on each generated map, each with a score multiplier. Narrower pads pay more:
| Label | Width | Multiplier |
|---|---|---|
| 2X | widest | × 2 |
| 3X | medium | × 3 |
| 5X | narrowest | × 5 |
### Landing results
Your touchdown is graded by vertical speed, horizontal speed and tilt at contact:
| Result | Required | Score | Fuel bonus |
|---|---|---|---|
| **A PERFECT LANDING** | on pad, v<8, h<15, tilt<5° | 50 × mult | +50 |
| **GOOD LANDING** | on pad, v<15, h<30, tilt<15° | 25 × mult | +25 |
| **ROUGH LANDING** | on pad, v<25, h<45, tilt<25° | 10 × mult | |
| **YOU MISSED THE LANDING AREA** | off pad, survivable | 5 | — |
| **CRAFT DESTROYED** | else | 0 | — |
Run out of fuel and the mission ends. Top-10 high scores are saved locally; enter three initials on a new high score.
## Omarchy Integration
- **Theme colours** auto-detected from your active Omarchy Ghostty theme
- **System font** detected from your Waybar config
- **Full-screen** via SUPER+F (Hyprland compositor)
## Requirements
- Love2D (`sudo pacman -S love`)
- Omarchy Linux (or any Arch-based distro)
## Acknowledgements
Inspired by the 1979 Atari Lunar Lander cabinet. No arcade assets, logos, or trademarked names are reproduced. The LM silhouette is drawn from public reference photographs of the Apollo Lunar Module.
## License
MIT

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 = "OMA-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

75
game/camera.lua Normal file
View file

@ -0,0 +1,75 @@
local World = require("game.world")
local Camera = {
x = 2000,
y = 900,
zoom = 0.35,
}
local MAX_ZOOM = 3.5
local MIN_ZOOM = 0.3
local ZOOM_SPEED = 2.0
function Camera.update(lander, terrain, dt)
-- Track lander X
Camera.x = lander.x
-- Get ground height below lander
local groundY = terrain.getHeightAt(lander.x)
local altitude = groundY - lander.y
-- Camera Y: keep terrain in the lower portion of the viewport even at high altitude
local targetY = lander.y + altitude * 0.45
Camera.y = Camera.y + (targetY - Camera.y) * math.min(1, dt * 3)
-- Zoom: fit the distance from lander to ground within the viewport
-- We want the full altitude span to fit in about 70% of screen height
local sw, sh = World.screenW, World.screenH
local effectiveScale = World.baseScale
local neededHeight = math.max(altitude + 100, 200) -- minimum view range
local viewportFraction = 0.7
local targetZoom = (sh * viewportFraction) / (neededHeight * effectiveScale)
targetZoom = math.max(MIN_ZOOM, math.min(MAX_ZOOM, targetZoom))
Camera.zoom = Camera.zoom + (targetZoom - Camera.zoom) * math.min(1, dt * ZOOM_SPEED)
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 = 900
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

318
game/lander.lua Normal file
View file

@ -0,0 +1,318 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Lander = {}
-- Physics tuned to match original Lunar Lander feel.
-- Gravity is set per difficulty via World.gravity.
local THRUST_ACCEL = 36 -- about 3x Pilot-difficulty gravity — can hover and climb
local ROT_SPEED = 1.4 -- ~80 deg/sec — deliberate, not twitchy
local FUEL_RATE = 6 -- fuel per second of thrust
local MAX_SPEED = 120 -- terminal velocity cap
-- Apollo LM-inspired silhouette.
-- Local coords: angle 0 = pointing up, +Y = down.
-- Wide, flat descent stage under a narrower, angular ascent stage;
-- A-frame legs splay out to saucer pads; engine bell protrudes below.
-- Descent stage: wide, flat, boxy base
local DESCENT = {
{-14, 2}, {14, 2}, {14, 8}, {-14, 8},
}
-- Panel lines on descent stage (horizontal divider + vertical bay separators)
local DESCENT_DETAIL = {
{{-14, 5}, {14, 5}},
{{-5, 2}, {-5, 8}},
{{5, 2}, {5, 8}},
}
-- Descent engine bell (hangs below the descent stage)
local ENGINE_BELL = {
{-3, 8}, {-5, 12}, {5, 12}, {3, 8},
}
-- Ascent stage: narrower, angular cabin
local ASCENT = {
{-8, 2}, {-8, -3}, {-7, -7}, {-4, -10}, {4, -10}, {7, -7}, {8, -3}, {8, 2},
}
-- Divider between crew cabin and equipment section
local ASCENT_DETAIL = {
{{-8, -3}, {8, -3}},
}
-- RCS thruster quads jutting out at the ascent-stage corners
local RCS_QUADS = {
{{-10, -5}, {-8, -5}, {-8, -3}, {-10, -3}},
{{10, -5}, {8, -5}, {8, -3}, {10, -3}},
}
-- Docking tunnel on top of ascent stage
local DOCKING_TUNNEL = {
{-3, -10}, {-3, -13}, {3, -13}, {3, -10},
}
-- Triangular forward cockpit windows
local WINDOWS = {
{{-5, -7}, {-2, -9}, {-2, -6}},
{{5, -7}, {2, -9}, {2, -6}},
}
-- Rendezvous radar / top antenna
local ANTENNA = {
{{0, -13}, {0, -17}},
{{-2, -17}, {2, -17}},
}
-- Four landing legs as two visible A-frames (main strut + diagonal brace)
-- Legs attach at the outer edges of the descent stage and splay out wide
local LEGS = {
{{-14, 2}, {-22, 16}}, -- left main strut (upper)
{{-14, 8}, {-22, 16}}, -- left secondary strut (lower, forms A-frame)
{{14, 2}, {22, 16}}, -- right main strut
{{14, 8}, {22, 16}}, -- right secondary strut
}
-- Saucer-shaped foot pads at the A-frame apex
local PAD_POSITIONS = {
{-22, 16}, {22, 16},
}
local PAD_RADIUS = 3
-- Surface-contact probes sticking down from the pads
local PROBES = {
{{-22, 16}, {-22, 20}},
{{22, 16}, {22, 20}},
}
local lander = {}
function Lander.init()
lander.x = World.WORLD_W / 2 + (math.random() - 0.5) * 800
lander.y = 200
lander.vx = 5 + math.random() * 8 -- gentle initial drift
lander.vy = 2 + math.random() * 3 -- slight downward
lander.angle = 0
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 (difficulty-driven, always pulls down)
lander.vy = lander.vy + World.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 (fires out the bottom of the lander, pushing opposite)
lander.thrusting = love.keyboard.isDown("up", "w") and World.fuel > 0
if lander.thrusting then
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
-- Cap speed so it doesn't get out of control
local speed = math.sqrt(lander.vx * lander.vx + lander.vy * lander.vy)
if speed > MAX_SPEED then
lander.vx = lander.vx / speed * MAX_SPEED
lander.vy = lander.vy / speed * MAX_SPEED
end
-- Move
lander.x = lander.x + lander.vx * dt
lander.y = lander.y + lander.vy * dt
-- Clamp X to world bounds and kill horizontal momentum against the wall
if lander.x < 20 then
lander.x = 20
if lander.vx < 0 then lander.vx = 0 end
elseif lander.x > World.WORLD_W - 20 then
lander.x = World.WORLD_W - 20
if lander.vx > 0 then lander.vx = 0 end
end
-- Don't let lander fly off the top
if lander.y < 0 then
lander.y = 0
lander.vy = math.max(0, lander.vy)
end
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 to upright
lander.angle = lander.angle * 0.1
-- Strong upward thrust burst — halve downward velocity and push up
if lander.vy > 0 then
lander.vy = lander.vy * 0.3
end
lander.vy = lander.vy - THRUST_ACCEL * 0.6
lander.vx = lander.vx * 0.3
World.fuel = math.max(0, World.fuel - 60)
end
function Lander.getCollisionPoints()
local pts = {}
local colPts = {
{-22, 16}, {22, 16}, -- foot pads
{-14, 8}, {0, 8}, {14, 8}, -- descent stage bottom (catches hard tilts)
}
for _, cp in ipairs(colPts) do
local wx, wy = transformPoint(cp[1], cp[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
local function drawClosedPolyline(verts, a, cx, cy)
local pts = {}
for _, v in ipairs(verts) do
local wx, wy = transformPoint(v[1], v[2], a, cx, cy)
table.insert(pts, wx)
table.insert(pts, wy)
end
table.insert(pts, pts[1])
table.insert(pts, pts[2])
love.graphics.line(pts)
end
local function drawSegment(seg, a, cx, cy)
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
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
love.graphics.setColor(p.lander)
-- Descent stage outline
love.graphics.setLineWidth(2)
drawClosedPolyline(DESCENT, a, cx, cy)
-- Descent stage panel detail (thinner)
love.graphics.setLineWidth(1)
for _, seg in ipairs(DESCENT_DETAIL) do
drawSegment(seg, a, cx, cy)
end
-- Descent engine bell
love.graphics.setLineWidth(1.5)
drawClosedPolyline(ENGINE_BELL, a, cx, cy)
-- Ascent stage outline
love.graphics.setLineWidth(2)
drawClosedPolyline(ASCENT, a, cx, cy)
-- Ascent stage cabin/equipment divider
love.graphics.setLineWidth(1)
for _, seg in ipairs(ASCENT_DETAIL) do
drawSegment(seg, a, cx, cy)
end
-- Docking tunnel on top
love.graphics.setLineWidth(1.5)
drawClosedPolyline(DOCKING_TUNNEL, a, cx, cy)
-- RCS thruster quads
for _, quad in ipairs(RCS_QUADS) do
drawClosedPolyline(quad, a, cx, cy)
end
-- Legs (A-frame struts)
love.graphics.setLineWidth(1.5)
for _, leg in ipairs(LEGS) do
drawSegment(leg, a, cx, cy)
end
-- Surface-contact probes
love.graphics.setLineWidth(1)
for _, probe in ipairs(PROBES) do
drawSegment(probe, a, cx, cy)
end
-- Foot pads (saucer circles)
love.graphics.setLineWidth(2)
for _, pad in ipairs(PAD_POSITIONS) do
local wx, wy = transformPoint(pad[1], pad[2], a, cx, cy)
love.graphics.circle("line", wx, wy, PAD_RADIUS)
end
-- Forward cockpit windows (dimmer)
love.graphics.setColor(p.lander[1], p.lander[2], p.lander[3], 0.6)
love.graphics.setLineWidth(1)
for _, win in ipairs(WINDOWS) do
drawClosedPolyline(win, a, cx, cy)
end
-- Rendezvous radar / antenna
love.graphics.setColor(p.lander)
love.graphics.setLineWidth(1)
for _, seg in ipairs(ANTENNA) do
drawSegment(seg, a, cx, cy)
end
-- Descent-engine thrust flame (exits the engine bell at y=12)
if lander.thrusting then
love.graphics.setColor(p.thrust)
love.graphics.setLineWidth(2)
local flameLen = 12 + math.random() * 18
local flameSpread = 2 + math.random() * 2
local fx1, fy1 = transformPoint(-flameSpread, 12, a, cx, cy)
local fx2, fy2 = transformPoint(flameSpread, 12, a, cx, cy)
local ftx, fty = transformPoint((math.random()-0.5)*3, 12 + flameLen, a, cx, cy)
love.graphics.line(fx1, fy1, ftx, fty)
love.graphics.line(fx2, fy2, ftx, fty)
-- Inner bright flame
love.graphics.setColor(p.bright)
local flameLen2 = 6 + math.random() * 10
local fi1x, fi1y = transformPoint(-1, 12, a, cx, cy)
local fi2x, fi2y = transformPoint(1, 12, a, cx, cy)
local ft2x, ft2y = transformPoint((math.random()-0.5)*1.5, 12 + flameLen2, a, cx, cy)
love.graphics.line(fi1x, fi1y, ft2x, ft2y)
love.graphics.line(fi2x, fi2y, ft2x, ft2y)
end
end
return Lander

96
game/particles.lua Normal file
View file

@ -0,0 +1,96 @@
local Palette = require("rendering.palette")
local Particles = {}
local particles = {}
function Particles.spawnThrust(x, y, angle)
-- Jet direction is local (0, 1) rotated by jetAngle (same convention as lander transformPoint)
local spread = 0.5
local speed = 30 + math.random() * 40
local jetAngle = angle + (math.random() - 0.5) * spread
table.insert(particles, {
x = x, y = y,
vx = -math.sin(jetAngle) * speed,
vy = math.cos(jetAngle) * 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

154
game/terrain.lua Normal file
View file

@ -0,0 +1,154 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Camera = require("game.camera")
local Terrain = {}
local points = {}
local pads = {}
function Terrain.generate()
points = {}
pads = {}
local W = World.WORLD_W
local baseline = 1600
-- Place 3 landing pads at fixed zones to avoid overlap issues
local padDefs = {
{cx = W * 0.2, width = 120, mult = 2, label = "2X"},
{cx = W * 0.55, width = 80, mult = 3, label = "3X"},
{cx = W * 0.8, width = 50, mult = 5, label = "5X"},
}
for _, def in ipairs(padDefs) do
local py = baseline + (math.random() - 0.5) * 200
table.insert(pads, {
x1 = def.cx - def.width / 2,
x2 = def.cx + def.width / 2,
y = py,
mult = def.mult,
label = def.label,
})
end
table.sort(pads, function(a, b) return a.x1 < b.x1 end)
-- Generate terrain left to right, inserting pads as flat segments
local x = 0
local y = baseline + (math.random() - 0.5) * 80
table.insert(points, {x = 0, y = y})
local padIdx = 1
while x < W do
-- Check if next pad is coming up
if padIdx <= #pads and x >= pads[padIdx].x1 - 40 then
local pad = pads[padIdx]
-- Slope to pad start
table.insert(points, {x = pad.x1 - 5, y = y})
table.insert(points, {x = pad.x1, y = pad.y})
-- Flat pad
table.insert(points, {x = pad.x2, y = pad.y})
x = pad.x2 + 1
y = pad.y
padIdx = padIdx + 1
-- Resume jagged
local jx = x + 20 + math.random() * 30
y = y + (math.random() - 0.5) * 80
y = math.max(baseline - 250, math.min(baseline + 250, y))
table.insert(points, {x = jx, y = y})
x = jx
else
-- Normal jagged terrain
local step = 25 + math.random() * 35
-- Don't overshoot the next pad's approach zone — would cause out-of-order points
if padIdx <= #pads then
local approach = pads[padIdx].x1 - 40
if x < approach and x + step > approach then
step = approach - x
end
end
x = x + step
y = y + (math.random() - 0.5) * 100
y = math.max(baseline - 250, math.min(baseline + 250, y))
table.insert(points, {x = x, y = y})
end
end
-- Close at right edge
if points[#points].x < W then
table.insert(points, {x = W, y = points[#points].y})
end
end
function Terrain.getPoints()
return points
end
function Terrain.getPads()
return pads
end
function Terrain.getHeightAt(wx)
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()
-- Terrain surface
love.graphics.setColor(p.terrain)
love.graphics.setLineWidth(2)
local pts = {}
for _, pt in ipairs(points) do
if pt.x >= visMinX - 200 and pt.x <= visMaxX + 200 then
table.insert(pts, pt.x)
table.insert(pts, pt.y)
end
end
if #pts >= 4 then
love.graphics.line(pts)
end
-- Landing pads (brighter)
for _, pad in ipairs(pads) do
if pad.x2 >= visMinX and pad.x1 <= visMaxX then
love.graphics.setColor(p.pad)
love.graphics.setLineWidth(3)
love.graphics.line(pad.x1, pad.y, pad.x2, pad.y)
-- Multiplier label — constant screen size regardless of zoom
love.graphics.setColor(p.pad[1], p.pad[2], p.pad[3], 0.8)
local cx = (pad.x1 + pad.x2) / 2
local invZoom = 1 / (World.baseScale * Camera.getZoom())
love.graphics.push()
love.graphics.translate(cx, pad.y + 15 * invZoom)
love.graphics.scale(invZoom * 0.8, invZoom * 0.8)
love.graphics.printf(pad.label, -50, 0, 100, "center")
love.graphics.pop()
end
end
end
return Terrain

43
game/world.lua Normal file
View file

@ -0,0 +1,43 @@
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,
gravity = 12,
startFuel = 750,
difficultyName = "PILOT",
}
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()
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

33
icon.svg Normal file
View file

@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" fill="#000"/>
<!-- Apollo LM silhouette -->
<g fill="none" stroke="#4ade80" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round">
<!-- Rendezvous antenna -->
<line x1="32" y1="10" x2="32" y2="14"/>
<line x1="29" y1="10" x2="35" y2="10"/>
<!-- Docking tunnel -->
<polyline points="30,17 30,14 34,14 34,17"/>
<!-- Ascent stage (pentagonal cabin) -->
<polyline points="26,26 26,21 28,17 36,17 38,21 38,26 26,26"/>
<!-- Cockpit windows -->
<polyline points="28,23 30,20 30,23 28,23"/>
<polyline points="36,23 34,20 34,23 36,23"/>
<!-- Descent stage (wide flat base) -->
<polyline points="20,26 44,26 44,36 20,36 20,26"/>
<!-- Panel detail -->
<line x1="20" y1="31" x2="44" y2="31"/>
<!-- Descent engine bell -->
<polyline points="29,36 30,42 34,42 35,36"/>
<!-- Landing legs (A-frames) -->
<line x1="20" y1="26" x2="10" y2="48"/>
<line x1="20" y1="36" x2="10" y2="48"/>
<line x1="44" y1="26" x2="54" y2="48"/>
<line x1="44" y1="36" x2="54" y2="48"/>
<!-- Foot pads -->
<circle cx="10" cy="48" r="2.5"/>
<circle cx="54" cy="48" r="2.5"/>
<!-- Surface probes -->
<line x1="10" y1="50.5" x2="10" y2="54"/>
<line x1="54" y1="50.5" x2="54" y2="54"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

142
install.sh Executable file
View file

@ -0,0 +1,142 @@
#!/bin/bash
set -euo pipefail
# OMA-LANDER Installer / Uninstaller
# Usage: ./install.sh — install the game
# ./install.sh uninstall — remove the game
GAME_NAME="oma-lander"
DISPLAY_NAME="OMA-LANDER"
COMMENT="Apollo LM lunar landing arcade with Omarchy theme integration"
REPO_URL="https://git.no-signal.uk/nosignal/oma-lander.git"
INSTALL_DIR="$HOME/.local/share/$GAME_NAME"
DESKTOP_FILE="$HOME/.local/share/applications/$GAME_NAME.desktop"
ICON_DIR="$HOME/.local/share/icons/hicolor"
UNINSTALL_BIN="$HOME/.local/bin/$GAME_NAME-uninstall"
# ── UNINSTALL ──
if [ "${1:-}" = "uninstall" ]; then
echo "=== Uninstalling $DISPLAY_NAME ==="
[ -f "$DESKTOP_FILE" ] && rm "$DESKTOP_FILE" && echo "Removed desktop entry"
for size in 16 32 48 64 128 256 512; do
icon_path="$ICON_DIR/${size}x${size}/apps/$GAME_NAME.png"
[ -f "$icon_path" ] && rm "$icon_path"
done
[ -f "$ICON_DIR/scalable/apps/$GAME_NAME.svg" ] && rm -f "$ICON_DIR/scalable/apps/$GAME_NAME.svg"
echo "Removed icons"
[ -d "$INSTALL_DIR" ] && rm -rf "$INSTALL_DIR" && echo "Removed game files"
[ -f "$UNINSTALL_BIN" ] && rm "$UNINSTALL_BIN" && echo "Removed uninstall command"
command -v gtk-update-icon-cache &>/dev/null && gtk-update-icon-cache -f -t "$ICON_DIR" 2>/dev/null || true
command -v omarchy-restart-walker &>/dev/null && omarchy-restart-walker 2>/dev/null || true
echo "=== $DISPLAY_NAME uninstalled ==="
exit 0
fi
# ── INSTALL ──
echo "=== Installing $DISPLAY_NAME ==="
# Install dependencies
DEPS=()
command -v love &>/dev/null || DEPS+=(love)
command -v git &>/dev/null || DEPS+=(git)
command -v rsvg-convert &>/dev/null || DEPS+=(librsvg)
if [ ${#DEPS[@]} -gt 0 ]; then
echo "Installing dependencies: ${DEPS[*]}"
if command -v pacman &>/dev/null; then
sudo pacman -S --noconfirm "${DEPS[@]}"
else
echo "Error: missing ${DEPS[*]} and pacman not found."
echo "Install them manually and re-run this script."
exit 1
fi
fi
# Clone or update the game
if [ -d "$INSTALL_DIR/.git" ]; then
echo "Updating existing installation..."
cd "$INSTALL_DIR"
git pull --ff-only
else
[ -d "$INSTALL_DIR" ] && rm -rf "$INSTALL_DIR"
echo "Cloning game repository..."
git clone "$REPO_URL" "$INSTALL_DIR"
fi
# Install icon
echo "Installing icon..."
ICON_SVG="$INSTALL_DIR/icon.svg"
if command -v rsvg-convert &>/dev/null; then
for size in 16 32 48 64 128 256 512; do
mkdir -p "$ICON_DIR/${size}x${size}/apps"
rsvg-convert -w "$size" -h "$size" "$ICON_SVG" -o "$ICON_DIR/${size}x${size}/apps/$GAME_NAME.png"
done
elif command -v magick &>/dev/null; then
for size in 16 32 48 64 128 256 512; do
mkdir -p "$ICON_DIR/${size}x${size}/apps"
magick "$ICON_SVG" -resize "${size}x${size}" "$ICON_DIR/${size}x${size}/apps/$GAME_NAME.png"
done
else
echo "No SVG converter found, using SVG icon directly"
mkdir -p "$ICON_DIR/scalable/apps"
cp "$ICON_SVG" "$ICON_DIR/scalable/apps/$GAME_NAME.svg"
fi
# Create .desktop file
echo "Creating desktop entry..."
mkdir -p "$(dirname "$DESKTOP_FILE")"
cat > "$DESKTOP_FILE" << EOF
[Desktop Entry]
Type=Application
Name=$DISPLAY_NAME
Comment=$COMMENT
Exec=uwsm app -- love $INSTALL_DIR
Icon=$GAME_NAME
Terminal=false
Categories=Game;ArcadeGame;
StartupNotify=true
TryExec=love
EOF
# Install uninstall command
mkdir -p "$HOME/.local/bin"
cat > "$UNINSTALL_BIN" << 'UNINSTALL'
#!/bin/bash
# Uninstall OMA-LANDER
SCRIPT_URL="https://git.no-signal.uk/nosignal/oma-lander/raw/branch/master/install.sh"
curl -sL "$SCRIPT_URL" | bash -s uninstall 2>/dev/null || bash "$HOME/.local/share/oma-lander/install.sh" uninstall 2>/dev/null || {
rm -f "$HOME/.local/share/applications/oma-lander.desktop"
rm -rf "$HOME/.local/share/oma-lander"
for s in 16 32 48 64 128 256 512; do
rm -f "$HOME/.local/share/icons/hicolor/${s}x${s}/apps/oma-lander.png"
done
rm -f "$HOME/.local/share/icons/hicolor/scalable/apps/oma-lander.svg"
rm -f "$HOME/.local/bin/oma-lander-uninstall"
command -v omarchy-restart-walker &>/dev/null && omarchy-restart-walker 2>/dev/null
echo "OMA-LANDER uninstalled"
}
UNINSTALL
chmod +x "$UNINSTALL_BIN"
# Update icon cache
command -v gtk-update-icon-cache &>/dev/null && gtk-update-icon-cache -f -t "$ICON_DIR" 2>/dev/null || true
# Restart walker
if command -v omarchy-restart-walker &>/dev/null; then
echo "Refreshing app launcher..."
omarchy-restart-walker 2>/dev/null || true
fi
echo ""
echo "=== $DISPLAY_NAME installed ==="
echo ""
echo " Launch: search '$DISPLAY_NAME' in app launcher or run: love $INSTALL_DIR"
echo " Uninstall: $GAME_NAME-uninstall"
echo ""

428
main.lua Normal file
View file

@ -0,0 +1,428 @@
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 DIFFICULTIES = {
{name = "CADET", gravity = 8, fuel = 1000, description = "TRAINING - LOW GRAVITY"},
{name = "PILOT", gravity = 12, fuel = 750, description = "STANDARD MISSION"},
{name = "COMMANDER", gravity = 16, fuel = 600, description = "EXPERIENCED - HEAVIER PULL"},
{name = "ASTRONAUT", gravity = 22, fuel = 500, description = "ELITE - EXTREME GRAVITY"},
}
local difficultyIndex = 2
local function applyDifficulty(d)
World.gravity = d.gravity
World.startFuel = d.fuel
World.difficultyName = d.name
end
local function startGame()
applyDifficulty(DIFFICULTIES[difficultyIndex])
World.state = "playing"
World.score = 0
World.fuel = World.startFuel
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 < 8 and hspeed < 15 and tilt < math.rad(5) then
-- A perfect landing — tightest tolerances
local pts = 50 * pad.mult
World.addScore(pts)
World.fuel = World.fuel + 50
World.landingResult = "CONGRATULATIONS — A PERFECT LANDING " .. pts .. " PTS"
World.landingPoints = pts
World.state = "landed"
Lander.land()
Sounds.play("land_good")
elseif pad and vspeed < 15 and hspeed < 30 and tilt < math.rad(15) then
-- Good landing
local pts = 25 * pad.mult
World.addScore(pts)
World.fuel = World.fuel + 25
World.landingResult = "GOOD LANDING " .. 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
-- Rough but survivable on-pad landing
local pts = 10 * pad.mult
World.addScore(pts)
World.landingResult = "ROUGH LANDING " .. pts .. " PTS"
World.landingPoints = pts
World.state = "landed"
Lander.land()
Sounds.play("land_hard")
elseif (not pad) and vspeed < 20 and hspeed < 40 and tilt < math.rad(20) then
-- Missed the pad but touched down intact
World.addScore(5)
World.landingResult = "YOU MISSED THE LANDING AREA 5 PTS"
World.landingPoints = 5
World.state = "landed"
Lander.land()
Sounds.play("land_hard")
else
-- Destroyed
World.landingResult = "CRAFT DESTROYED"
World.landingPoints = 0
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("OMA-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 SELECT MISSION", 0, sh * 0.52, sw, "center")
-- High scores
local allScores = HighScores.getScores()
if #allScores > 0 then
HighScores.drawTable(sw, sh, p, Fonts)
end
end
local function drawDifficultyScreen()
local p = Palette.get()
local sw, sh = World.screenW, World.screenH
local t = love.timer.getTime()
love.graphics.setColor(p.bg)
love.graphics.rectangle("fill", 0, 0, sw, sh)
Stars.draw(sw, sh)
love.graphics.setFont(Fonts.large)
love.graphics.setColor(p.bright)
love.graphics.printf("SELECT MISSION", 0, sh * 0.12, sw, "center")
love.graphics.setFont(Fonts.medium)
local lineH = Fonts.medium:getHeight() + 12
local menuTop = sh * 0.30
for i, d in ipairs(DIFFICULTIES) do
local y = menuTop + (i - 1) * lineH
local selected = (i == difficultyIndex)
if selected then
local pulse = 0.7 + math.sin(t * 4) * 0.3
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse)
love.graphics.printf("> " .. d.name .. " <", 0, y, sw, "center")
else
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.5)
love.graphics.printf(d.name, 0, y, sw, "center")
end
end
-- Description of selected mission
love.graphics.setFont(Fonts.small)
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.7)
local d = DIFFICULTIES[difficultyIndex]
local descY = menuTop + #DIFFICULTIES * lineH + 20
love.graphics.printf(d.description, 0, descY, sw, "center")
love.graphics.printf(string.format("GRAVITY %d FUEL %d", d.gravity, d.fuel),
0, descY + Fonts.small:getHeight() + 4, sw, "center")
-- Instructions
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.4)
love.graphics.printf("UP / DOWN: CHANGE MISSION ENTER: BEGIN ESC: BACK",
0, sh - Fonts.small:getHeight() - 12, sw, "center")
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()
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()
end
function love.update(dt)
World.ensureScale()
if World.state == "title" or World.state == "difficulty_select" 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 — spawn at engine-bell exit transformed by lander angle
local sa, ca = math.sin(l.angle), math.cos(l.angle)
local fx = l.x - sa * 12
local fy = l.y + ca * 12
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()
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 == "difficulty_select" then
drawDifficultyScreen()
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 == "landed" or
(math.floor(World.stateTimer * 4) % 2 == 0)
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 World.state = "difficulty_select" end
if key == "escape" then love.event.quit() end
return
end
if World.state == "difficulty_select" then
if key == "up" or key == "w" then
difficultyIndex = ((difficultyIndex - 2) % #DIFFICULTIES) + 1
elseif key == "down" or key == "s" then
difficultyIndex = (difficultyIndex % #DIFFICULTIES) + 1
elseif key == "return" then
startGame()
elseif key == "escape" then
World.state = "title"
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()
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