Initial public release
This commit is contained in:
commit
08cf8b815c
17 changed files with 1883 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
CLAUDE.md
|
||||||
|
.claude/
|
||||||
84
README.md
Normal file
84
README.md
Normal 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
84
audio/sounds.lua
Normal 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
11
conf.lua
Normal 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
75
game/camera.lua
Normal 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
198
game/highscores.lua
Normal 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
59
game/hud.lua
Normal 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
318
game/lander.lua
Normal 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
96
game/particles.lua
Normal 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
29
game/stars.lua
Normal 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
154
game/terrain.lua
Normal 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
43
game/world.lua
Normal 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
33
icon.svg
Normal 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
142
install.sh
Executable 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
428
main.lua
Normal 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
64
rendering/fonts.lua
Normal 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
63
rendering/palette.lua
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue