Lunar Lander: complete Love2D game with Omarchy integration
Faithful recreation of Atari Lunar Lander (1979) with vector wireframe aesthetic. Auto-detects Omarchy system theme and font on launch. Features: - Dynamic zoom camera (zooms in as you approach the surface) - Procedural jagged terrain with flat landing pads (2X, 3X, 5X multipliers) - Apollo-style wireframe lander with thrust flame - Gravity, thrust, rotation physics - Landing evaluation: good (speed/angle/pad check), hard, crash - Fuel management (750 starting, +50 for good landings) - Crash debris particles, thrust exhaust particles - Star field background - HUD: altitude, horizontal/vertical speed, fuel bar, score, time - Persistent high scores with 3-letter initial entry - Procedural sound effects (thrust, landing chimes, crash, fuel warning) - Full-screen scaling, system font detection Controls: Arrows/WASD rotate+thrust, Space abort, Enter start Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
790ca87bfb
13 changed files with 1424 additions and 0 deletions
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 = "LUNAR LANDER"
|
||||
t.window.width = 1024
|
||||
t.window.height = 768
|
||||
t.window.resizable = true
|
||||
t.window.vsync = 1
|
||||
t.window.fullscreen = false
|
||||
t.window.fullscreentype = "desktop"
|
||||
t.window.minwidth = 512
|
||||
t.window.minheight = 384
|
||||
end
|
||||
68
game/camera.lua
Normal file
68
game/camera.lua
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
local World = require("game.world")
|
||||
|
||||
local Camera = {
|
||||
x = 2000,
|
||||
y = 400,
|
||||
zoom = 0.5,
|
||||
}
|
||||
|
||||
local BASE_ALT = 1200
|
||||
local MAX_ZOOM = 4.0
|
||||
local MIN_ZOOM = 0.35
|
||||
|
||||
function Camera.update(lander, terrain, dt)
|
||||
-- Track lander position
|
||||
Camera.x = lander.x
|
||||
Camera.y = lander.y
|
||||
|
||||
-- Compute altitude
|
||||
local groundY = terrain.getHeightAt(lander.x)
|
||||
local altitude = groundY - lander.y
|
||||
altitude = math.max(altitude, 10)
|
||||
|
||||
-- Zoom based on altitude
|
||||
local targetZoom = BASE_ALT / altitude
|
||||
targetZoom = math.max(MIN_ZOOM, math.min(MAX_ZOOM, targetZoom))
|
||||
|
||||
-- Smooth lerp
|
||||
Camera.zoom = Camera.zoom + (targetZoom - Camera.zoom) * math.min(1, dt * 2.5)
|
||||
end
|
||||
|
||||
function Camera.getAltitude(landerY, terrain, landerX)
|
||||
local groundY = terrain.getHeightAt(landerX)
|
||||
return math.max(0, groundY - landerY)
|
||||
end
|
||||
|
||||
function Camera.applyTransform()
|
||||
local sw, sh = World.screenW, World.screenH
|
||||
local scale = World.baseScale * Camera.zoom
|
||||
|
||||
love.graphics.push()
|
||||
love.graphics.translate(sw / 2, sh / 2)
|
||||
love.graphics.scale(scale)
|
||||
love.graphics.translate(-Camera.x, -Camera.y)
|
||||
end
|
||||
|
||||
function Camera.popTransform()
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
function Camera.getVisibleRect()
|
||||
local sw, sh = World.screenW, World.screenH
|
||||
local scale = World.baseScale * Camera.zoom
|
||||
local hw = (sw / 2) / scale
|
||||
local hh = (sh / 2) / scale
|
||||
return Camera.x - hw, Camera.y - hh, Camera.x + hw, Camera.y + hh
|
||||
end
|
||||
|
||||
function Camera.getZoom()
|
||||
return Camera.zoom
|
||||
end
|
||||
|
||||
function Camera.reset()
|
||||
Camera.x = 2000
|
||||
Camera.y = 400
|
||||
Camera.zoom = MIN_ZOOM
|
||||
end
|
||||
|
||||
return Camera
|
||||
198
game/highscores.lua
Normal file
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
|
||||
211
game/lander.lua
Normal file
211
game/lander.lua
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
local World = require("game.world")
|
||||
local Palette = require("rendering.palette")
|
||||
|
||||
local Lander = {}
|
||||
|
||||
local GRAVITY = 25
|
||||
local THRUST_ACCEL = 60
|
||||
local ROT_SPEED = 2.1 -- ~120 deg/sec in radians
|
||||
local FUEL_RATE = 12 -- fuel per second at full thrust
|
||||
|
||||
-- Lander shape (local coords, 0 = pointing up, Y+ down)
|
||||
local BODY = {
|
||||
{-8, -8}, {-4, -12}, {4, -12}, {8, -8},
|
||||
{10, 0}, {8, 6}, {-8, 6}, {-10, 0},
|
||||
}
|
||||
|
||||
local LEGS = {
|
||||
{{-8, 6}, {-14, 16}},
|
||||
{{-6, 6}, {-14, 16}},
|
||||
{{8, 6}, {14, 16}},
|
||||
{{6, 6}, {14, 16}},
|
||||
}
|
||||
|
||||
local FEET = {
|
||||
{{-17, 16}, {-11, 16}},
|
||||
{{11, 16}, {17, 16}},
|
||||
}
|
||||
|
||||
local WINDOW = {
|
||||
{{-3, -6}, {3, -6}},
|
||||
{{3, -6}, {3, -2}},
|
||||
{{3, -2}, {-3, -2}},
|
||||
{{-3, -2}, {-3, -6}},
|
||||
}
|
||||
|
||||
local ANTENNA = {
|
||||
{{0, -12}, {0, -18}},
|
||||
{{-3, -18}, {3, -18}},
|
||||
}
|
||||
|
||||
local lander = {}
|
||||
|
||||
function Lander.init()
|
||||
lander.x = World.WORLD_W / 2 + (math.random() - 0.5) * 1000
|
||||
lander.y = 200
|
||||
lander.vx = 20 + math.random() * 20
|
||||
lander.vy = 5 + math.random() * 10
|
||||
lander.angle = 0 -- 0 = pointing up
|
||||
lander.alive = true
|
||||
lander.landed = false
|
||||
lander.thrusting = false
|
||||
end
|
||||
|
||||
function Lander.get()
|
||||
return lander
|
||||
end
|
||||
|
||||
local function transformPoint(px, py, angle, cx, cy)
|
||||
local cos_a = math.cos(angle)
|
||||
local sin_a = math.sin(angle)
|
||||
return cx + px * cos_a - py * sin_a,
|
||||
cy + px * sin_a + py * cos_a
|
||||
end
|
||||
|
||||
function Lander.update(dt)
|
||||
if not lander.alive or lander.landed then return end
|
||||
|
||||
-- Gravity
|
||||
lander.vy = lander.vy + GRAVITY * dt
|
||||
|
||||
-- Rotation
|
||||
if love.keyboard.isDown("left", "a") then
|
||||
lander.angle = lander.angle - ROT_SPEED * dt
|
||||
end
|
||||
if love.keyboard.isDown("right", "d") then
|
||||
lander.angle = lander.angle + ROT_SPEED * dt
|
||||
end
|
||||
|
||||
-- Thrust
|
||||
lander.thrusting = love.keyboard.isDown("up", "w") and World.fuel > 0
|
||||
if lander.thrusting then
|
||||
-- Thrust in the direction the lander is pointing (up from lander's perspective)
|
||||
lander.vx = lander.vx - math.sin(lander.angle) * THRUST_ACCEL * dt
|
||||
lander.vy = lander.vy - math.cos(lander.angle) * THRUST_ACCEL * dt
|
||||
World.fuel = math.max(0, World.fuel - FUEL_RATE * dt)
|
||||
end
|
||||
|
||||
-- Move
|
||||
lander.x = lander.x + lander.vx * dt
|
||||
lander.y = lander.y + lander.vy * dt
|
||||
|
||||
-- Clamp X
|
||||
lander.x = math.max(20, math.min(World.WORLD_W - 20, lander.x))
|
||||
end
|
||||
|
||||
function Lander.abort()
|
||||
if not lander.alive or lander.landed then return end
|
||||
if World.fuel < 10 then return end
|
||||
|
||||
-- Auto-level: snap angle toward 0
|
||||
lander.angle = lander.angle * 0.3
|
||||
-- Full thrust burst
|
||||
lander.vy = lander.vy - THRUST_ACCEL * 0.5
|
||||
-- Costs a chunk of fuel
|
||||
World.fuel = math.max(0, World.fuel - 50)
|
||||
end
|
||||
|
||||
function Lander.getCollisionPoints()
|
||||
-- Return transformed foot positions for terrain collision
|
||||
local pts = {}
|
||||
-- Foot endpoints
|
||||
local footPts = {{-17, 16}, {-11, 16}, {11, 16}, {17, 16}}
|
||||
for _, fp in ipairs(footPts) do
|
||||
local wx, wy = transformPoint(fp[1], fp[2], lander.angle, lander.x, lander.y)
|
||||
table.insert(pts, {x = wx, y = wy})
|
||||
end
|
||||
-- Body bottom
|
||||
local bodyBottom = {{-8, 6}, {8, 6}}
|
||||
for _, bp in ipairs(bodyBottom) do
|
||||
local wx, wy = transformPoint(bp[1], bp[2], lander.angle, lander.x, lander.y)
|
||||
table.insert(pts, {x = wx, y = wy})
|
||||
end
|
||||
return pts
|
||||
end
|
||||
|
||||
function Lander.die()
|
||||
lander.alive = false
|
||||
end
|
||||
|
||||
function Lander.land()
|
||||
lander.landed = true
|
||||
lander.vx = 0
|
||||
lander.vy = 0
|
||||
end
|
||||
|
||||
function Lander.draw()
|
||||
if not lander.alive then return end
|
||||
|
||||
local p = Palette.get()
|
||||
local a = lander.angle
|
||||
local cx, cy = lander.x, lander.y
|
||||
|
||||
-- Body outline
|
||||
love.graphics.setColor(p.lander)
|
||||
love.graphics.setLineWidth(2)
|
||||
local bodyPts = {}
|
||||
for _, v in ipairs(BODY) do
|
||||
local wx, wy = transformPoint(v[1], v[2], a, cx, cy)
|
||||
table.insert(bodyPts, wx)
|
||||
table.insert(bodyPts, wy)
|
||||
end
|
||||
table.insert(bodyPts, bodyPts[1])
|
||||
table.insert(bodyPts, bodyPts[2])
|
||||
love.graphics.line(bodyPts)
|
||||
|
||||
-- Legs
|
||||
love.graphics.setLineWidth(1.5)
|
||||
for _, leg in ipairs(LEGS) do
|
||||
local x1, y1 = transformPoint(leg[1][1], leg[1][2], a, cx, cy)
|
||||
local x2, y2 = transformPoint(leg[2][1], leg[2][2], a, cx, cy)
|
||||
love.graphics.line(x1, y1, x2, y2)
|
||||
end
|
||||
|
||||
-- Feet
|
||||
love.graphics.setLineWidth(2)
|
||||
for _, foot in ipairs(FEET) do
|
||||
local x1, y1 = transformPoint(foot[1][1], foot[1][2], a, cx, cy)
|
||||
local x2, y2 = transformPoint(foot[2][1], foot[2][2], a, cx, cy)
|
||||
love.graphics.line(x1, y1, x2, y2)
|
||||
end
|
||||
|
||||
-- Window
|
||||
love.graphics.setColor(p.lander[1], p.lander[2], p.lander[3], 0.5)
|
||||
love.graphics.setLineWidth(1)
|
||||
for _, seg in ipairs(WINDOW) do
|
||||
local x1, y1 = transformPoint(seg[1][1], seg[1][2], a, cx, cy)
|
||||
local x2, y2 = transformPoint(seg[2][1], seg[2][2], a, cx, cy)
|
||||
love.graphics.line(x1, y1, x2, y2)
|
||||
end
|
||||
|
||||
-- Antenna
|
||||
love.graphics.setColor(p.lander)
|
||||
for _, seg in ipairs(ANTENNA) do
|
||||
local x1, y1 = transformPoint(seg[1][1], seg[1][2], a, cx, cy)
|
||||
local x2, y2 = transformPoint(seg[2][1], seg[2][2], a, cx, cy)
|
||||
love.graphics.line(x1, y1, x2, y2)
|
||||
end
|
||||
|
||||
-- Thrust flame
|
||||
if lander.thrusting then
|
||||
love.graphics.setColor(p.thrust)
|
||||
love.graphics.setLineWidth(2)
|
||||
local flameLen = 10 + math.random() * 15
|
||||
local flameSpread = 3 + math.random() * 3
|
||||
|
||||
local fx1, fy1 = transformPoint(-flameSpread, 6, a, cx, cy)
|
||||
local fx2, fy2 = transformPoint(flameSpread, 6, a, cx, cy)
|
||||
local ftx, fty = transformPoint((math.random()-0.5)*3, 6 + flameLen, a, cx, cy)
|
||||
|
||||
love.graphics.line(fx1, fy1, ftx, fty)
|
||||
love.graphics.line(fx2, fy2, ftx, fty)
|
||||
|
||||
-- Inner flame
|
||||
local flameLen2 = 5 + math.random() * 8
|
||||
local ft2x, ft2y = transformPoint((math.random()-0.5)*2, 6 + flameLen2, a, cx, cy)
|
||||
love.graphics.setColor(p.bright)
|
||||
love.graphics.line(cx, cy + 3, ft2x, ft2y)
|
||||
end
|
||||
end
|
||||
|
||||
return Lander
|
||||
95
game/particles.lua
Normal file
95
game/particles.lua
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
local Palette = require("rendering.palette")
|
||||
|
||||
local Particles = {}
|
||||
|
||||
local particles = {}
|
||||
|
||||
function Particles.spawnThrust(x, y, angle)
|
||||
local spread = 0.5
|
||||
local speed = 30 + math.random() * 40
|
||||
local dir = angle + math.pi + (math.random() - 0.5) * spread
|
||||
table.insert(particles, {
|
||||
x = x, y = y,
|
||||
vx = math.sin(dir) * speed,
|
||||
vy = math.cos(dir) * speed,
|
||||
life = 0.2 + math.random() * 0.2,
|
||||
maxLife = 0.4,
|
||||
ptype = "dot",
|
||||
})
|
||||
end
|
||||
|
||||
function Particles.spawnCrash(x, y, vx, vy)
|
||||
for i = 1, 20 do
|
||||
local angle = math.random() * math.pi * 2
|
||||
local speed = 40 + math.random() * 100
|
||||
table.insert(particles, {
|
||||
x = x, y = y,
|
||||
vx = vx * 0.2 + math.cos(angle) * speed,
|
||||
vy = vy * 0.2 + math.sin(angle) * speed,
|
||||
life = 0.5 + math.random() * 1.0,
|
||||
maxLife = 1.5,
|
||||
ptype = "dot",
|
||||
})
|
||||
end
|
||||
-- Line debris
|
||||
for i = 1, 6 do
|
||||
local angle = math.random() * math.pi * 2
|
||||
local speed = 30 + math.random() * 60
|
||||
local len = 5 + math.random() * 10
|
||||
local da = math.random() * math.pi * 2
|
||||
table.insert(particles, {
|
||||
x = x + (math.random()-0.5)*10, y = y + (math.random()-0.5)*10,
|
||||
vx = math.cos(angle) * speed,
|
||||
vy = math.sin(angle) * speed - 20,
|
||||
lx1 = math.cos(da) * len, ly1 = math.sin(da) * len,
|
||||
lx2 = -math.cos(da) * len, ly2 = -math.sin(da) * len,
|
||||
rot = 0, rotSpeed = (math.random()-0.5) * 5,
|
||||
life = 1.0 + math.random() * 1.0,
|
||||
maxLife = 2.0,
|
||||
ptype = "line",
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function Particles.update(dt)
|
||||
for i = #particles, 1, -1 do
|
||||
local p = particles[i]
|
||||
p.x = p.x + p.vx * dt
|
||||
p.y = p.y + p.vy * dt
|
||||
p.vy = p.vy + 15 * dt -- light gravity on debris
|
||||
p.life = p.life - dt
|
||||
if p.rot then p.rot = p.rot + (p.rotSpeed or 0) * dt end
|
||||
if p.life <= 0 then table.remove(particles, i) end
|
||||
end
|
||||
end
|
||||
|
||||
function Particles.clear()
|
||||
particles = {}
|
||||
end
|
||||
|
||||
function Particles.anyActive()
|
||||
return #particles > 0
|
||||
end
|
||||
|
||||
function Particles.draw()
|
||||
local pal = Palette.get()
|
||||
for _, p in ipairs(particles) do
|
||||
local alpha = math.max(0, p.life / p.maxLife)
|
||||
if p.ptype == "dot" then
|
||||
love.graphics.setColor(pal.explosion[1], pal.explosion[2], pal.explosion[3], alpha)
|
||||
love.graphics.circle("fill", p.x, p.y, 1.5)
|
||||
elseif p.ptype == "line" then
|
||||
love.graphics.setColor(pal.lander[1], pal.lander[2], pal.lander[3], alpha)
|
||||
love.graphics.setLineWidth(1.5)
|
||||
local cos_r = math.cos(p.rot)
|
||||
local sin_r = math.sin(p.rot)
|
||||
local x1 = p.x + p.lx1 * cos_r - p.ly1 * sin_r
|
||||
local y1 = p.y + p.lx1 * sin_r + p.ly1 * cos_r
|
||||
local x2 = p.x + p.lx2 * cos_r - p.ly2 * sin_r
|
||||
local y2 = p.y + p.lx2 * sin_r + p.ly2 * cos_r
|
||||
love.graphics.line(x1, y1, x2, y2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return Particles
|
||||
29
game/stars.lua
Normal file
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
|
||||
166
game/terrain.lua
Normal file
166
game/terrain.lua
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
local World = require("game.world")
|
||||
local Palette = require("rendering.palette")
|
||||
|
||||
local Terrain = {}
|
||||
|
||||
local points = {}
|
||||
local pads = {}
|
||||
|
||||
function Terrain.generate()
|
||||
points = {}
|
||||
pads = {}
|
||||
|
||||
local W = World.WORLD_W
|
||||
local baseline = 1600
|
||||
local step = 30
|
||||
|
||||
-- Place landing pads first
|
||||
local padDefs = {
|
||||
{width = 120, mult = 2, label = "2X"},
|
||||
{width = 80, mult = 3, label = "3X"},
|
||||
{width = 50, mult = 5, label = "5X"},
|
||||
}
|
||||
|
||||
-- Distribute pads across the terrain
|
||||
local padPositions = {}
|
||||
local usedZones = {}
|
||||
for _, def in ipairs(padDefs) do
|
||||
local attempts = 0
|
||||
local px
|
||||
repeat
|
||||
px = 400 + math.random() * (W - 800)
|
||||
attempts = attempts + 1
|
||||
local ok = true
|
||||
for _, used in ipairs(usedZones) do
|
||||
if math.abs(px - used) < 400 then ok = false; break end
|
||||
end
|
||||
if ok then break end
|
||||
until attempts > 50
|
||||
|
||||
local py = baseline + (math.random() - 0.5) * 200
|
||||
table.insert(padPositions, {x = px, y = py, width = def.width, mult = def.mult, label = def.label})
|
||||
table.insert(usedZones, px)
|
||||
table.insert(pads, {
|
||||
x1 = px - def.width / 2,
|
||||
x2 = px + def.width / 2,
|
||||
y = py,
|
||||
mult = def.mult,
|
||||
label = def.label,
|
||||
})
|
||||
end
|
||||
|
||||
-- Sort pads by X for terrain generation
|
||||
table.sort(pads, function(a, b) return a.x1 < b.x1 end)
|
||||
|
||||
-- Generate terrain points left to right
|
||||
local x = 0
|
||||
local y = baseline + (math.random() - 0.5) * 100
|
||||
table.insert(points, {x = x, y = y})
|
||||
|
||||
while x < W do
|
||||
-- Check if we're approaching a pad
|
||||
local onPad = false
|
||||
local currentPad = nil
|
||||
for _, pad in ipairs(pads) do
|
||||
if x >= pad.x1 - step and x <= pad.x2 + step then
|
||||
onPad = true
|
||||
currentPad = pad
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if onPad and currentPad then
|
||||
-- Transition to pad level
|
||||
if x < currentPad.x1 then
|
||||
-- Approach: slope down/up to pad
|
||||
table.insert(points, {x = currentPad.x1 - 5, y = y})
|
||||
table.insert(points, {x = currentPad.x1, y = currentPad.y})
|
||||
x = currentPad.x1
|
||||
end
|
||||
-- Flat pad
|
||||
table.insert(points, {x = currentPad.x2, y = currentPad.y})
|
||||
x = currentPad.x2
|
||||
y = currentPad.y
|
||||
-- Resume jagged after pad
|
||||
x = x + step * 0.5
|
||||
y = y + (math.random() - 0.5) * 60
|
||||
table.insert(points, {x = x, y = y})
|
||||
else
|
||||
x = x + step + math.random() * step
|
||||
-- Jagged variation
|
||||
y = y + (math.random() - 0.5) * 120
|
||||
y = math.max(baseline - 250, math.min(baseline + 250, y))
|
||||
table.insert(points, {x = x, y = y})
|
||||
end
|
||||
end
|
||||
|
||||
-- Ensure last point reaches edge
|
||||
table.insert(points, {x = W, y = points[#points].y})
|
||||
end
|
||||
|
||||
function Terrain.getPoints()
|
||||
return points
|
||||
end
|
||||
|
||||
function Terrain.getPads()
|
||||
return pads
|
||||
end
|
||||
|
||||
function Terrain.getHeightAt(wx)
|
||||
-- Find terrain height at world X by interpolating between points
|
||||
if #points < 2 then return 1600 end
|
||||
if wx <= points[1].x then return points[1].y end
|
||||
if wx >= points[#points].x then return points[#points].y end
|
||||
|
||||
for i = 1, #points - 1 do
|
||||
if points[i].x <= wx and points[i+1].x > wx then
|
||||
local t = (wx - points[i].x) / (points[i+1].x - points[i].x)
|
||||
return points[i].y + t * (points[i+1].y - points[i].y)
|
||||
end
|
||||
end
|
||||
return 1600
|
||||
end
|
||||
|
||||
function Terrain.getPadAt(wx)
|
||||
for _, pad in ipairs(pads) do
|
||||
if wx >= pad.x1 and wx <= pad.x2 then
|
||||
return pad
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Terrain.draw(visMinX, visMaxX)
|
||||
local p = Palette.get()
|
||||
|
||||
-- Draw terrain surface
|
||||
love.graphics.setColor(p.terrain)
|
||||
love.graphics.setLineWidth(2)
|
||||
|
||||
local pts = {}
|
||||
for _, pt in ipairs(points) do
|
||||
if pt.x >= visMinX - 100 and pt.x <= visMaxX + 100 then
|
||||
table.insert(pts, pt.x)
|
||||
table.insert(pts, pt.y)
|
||||
end
|
||||
end
|
||||
if #pts >= 4 then
|
||||
love.graphics.line(pts)
|
||||
end
|
||||
|
||||
-- Draw landing pads (brighter, with labels)
|
||||
love.graphics.setColor(p.pad)
|
||||
love.graphics.setLineWidth(3)
|
||||
for _, pad in ipairs(pads) do
|
||||
if pad.x2 >= visMinX and pad.x1 <= visMaxX then
|
||||
love.graphics.line(pad.x1, pad.y, pad.x2, pad.y)
|
||||
|
||||
-- Label below pad
|
||||
love.graphics.setColor(p.pad[1], p.pad[2], p.pad[3], 0.7)
|
||||
local labelX = (pad.x1 + pad.x2) / 2
|
||||
love.graphics.print(pad.label, labelX - 8, pad.y + 5)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return Terrain
|
||||
40
game/world.lua
Normal file
40
game/world.lua
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
local World = {
|
||||
WORLD_W = 4000,
|
||||
WORLD_H = 2000,
|
||||
screenW = 0,
|
||||
screenH = 0,
|
||||
baseScale = 1,
|
||||
state = "title",
|
||||
score = 0,
|
||||
highScore = 0,
|
||||
fuel = 750,
|
||||
time = 0,
|
||||
stateTimer = 0,
|
||||
landingResult = "",
|
||||
landingPoints = 0,
|
||||
}
|
||||
|
||||
function World.resize(w, h)
|
||||
if not w or not h then w, h = love.graphics.getDimensions() end
|
||||
World.screenW = w
|
||||
World.screenH = h
|
||||
World.baseScale = w / World.WORLD_W
|
||||
end
|
||||
|
||||
function World.ensureScale()
|
||||
local w, h = love.graphics.getDimensions()
|
||||
if w ~= World.screenW or h ~= World.screenH then
|
||||
World.resize(w, h)
|
||||
local Fonts = require("rendering.fonts")
|
||||
Fonts.init(1)
|
||||
end
|
||||
end
|
||||
|
||||
function World.addScore(points)
|
||||
World.score = World.score + points
|
||||
if World.score > World.highScore then
|
||||
World.highScore = World.score
|
||||
end
|
||||
end
|
||||
|
||||
return World
|
||||
336
main.lua
Normal file
336
main.lua
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
local World = require("game.world")
|
||||
local Lander = require("game.lander")
|
||||
local Terrain = require("game.terrain")
|
||||
local Camera = require("game.camera")
|
||||
local Particles = require("game.particles")
|
||||
local Stars = require("game.stars")
|
||||
local HUD = require("game.hud")
|
||||
local HighScores = require("game.highscores")
|
||||
local Palette = require("rendering.palette")
|
||||
local Fonts = require("rendering.fonts")
|
||||
local Sounds = require("audio.sounds")
|
||||
|
||||
local thrustSoundTimer = 0
|
||||
local fuelWarnTimer = 0
|
||||
local STATE_PAUSE = 3.0
|
||||
|
||||
local function startGame()
|
||||
World.state = "playing"
|
||||
World.score = 0
|
||||
World.fuel = 750
|
||||
World.time = 0
|
||||
World.stateTimer = 0
|
||||
|
||||
Terrain.generate()
|
||||
Lander.init()
|
||||
Camera.reset()
|
||||
Particles.clear()
|
||||
end
|
||||
|
||||
local function nextAttempt()
|
||||
World.stateTimer = 0
|
||||
Terrain.generate()
|
||||
Lander.init()
|
||||
Camera.reset()
|
||||
Particles.clear()
|
||||
World.state = "playing"
|
||||
end
|
||||
|
||||
local function checkLanding()
|
||||
local l = Lander.get()
|
||||
local colPts = Lander.getCollisionPoints()
|
||||
|
||||
for _, pt in ipairs(colPts) do
|
||||
local terrainY = Terrain.getHeightAt(pt.x)
|
||||
if pt.y >= terrainY then
|
||||
-- Contact! Evaluate landing
|
||||
local pad = Terrain.getPadAt(l.x)
|
||||
local vspeed = math.abs(l.vy)
|
||||
local hspeed = math.abs(l.vx)
|
||||
local tilt = math.abs(l.angle)
|
||||
|
||||
if pad and vspeed < 15 and hspeed < 30 and tilt < math.rad(15) then
|
||||
-- Good landing
|
||||
local pts = 50 * pad.mult
|
||||
World.addScore(pts)
|
||||
World.fuel = World.fuel + 50
|
||||
World.landingResult = "GOOD LANDING! " .. pad.label .. " = " .. pts .. " PTS"
|
||||
World.landingPoints = pts
|
||||
World.state = "landed"
|
||||
Lander.land()
|
||||
Sounds.play("land_good")
|
||||
elseif pad and vspeed < 25 and hspeed < 45 and tilt < math.rad(25) then
|
||||
-- Hard landing
|
||||
local pts = 15 * pad.mult
|
||||
World.addScore(pts)
|
||||
World.landingResult = "HARD LANDING " .. pts .. " PTS"
|
||||
World.landingPoints = pts
|
||||
World.state = "landed"
|
||||
Lander.land()
|
||||
Sounds.play("land_hard")
|
||||
else
|
||||
-- Crash
|
||||
World.addScore(5)
|
||||
World.landingResult = "CRASH! 5 PTS"
|
||||
World.landingPoints = 5
|
||||
World.state = "crashed"
|
||||
Particles.spawnCrash(l.x, l.y, l.vx, l.vy)
|
||||
Lander.die()
|
||||
Sounds.play("crash")
|
||||
end
|
||||
|
||||
World.stateTimer = 0
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function drawTitleScreen()
|
||||
local p = Palette.get()
|
||||
local sw, sh = World.screenW, World.screenH
|
||||
local t = love.timer.getTime()
|
||||
|
||||
-- Background
|
||||
love.graphics.setColor(p.bg)
|
||||
love.graphics.rectangle("fill", 0, 0, sw, sh)
|
||||
Stars.draw(sw, sh)
|
||||
|
||||
-- Title
|
||||
local titleY = sh * 0.15
|
||||
love.graphics.setFont(Fonts.large)
|
||||
love.graphics.setColor(p.bright)
|
||||
love.graphics.printf("LUNAR LANDER", 0, titleY, sw, "center")
|
||||
|
||||
-- Controls
|
||||
love.graphics.setFont(Fonts.small)
|
||||
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.4)
|
||||
love.graphics.printf("ARROWS / WASD: ROTATE & THRUST SPACE: ABORT", 0, sh * 0.38, sw, "center")
|
||||
love.graphics.printf("LAND GENTLY ON MARKED PADS FOR POINTS", 0, sh * 0.42, sw, "center")
|
||||
|
||||
-- Press Enter
|
||||
local pulse = 0.3 + math.sin(t * 3) * 0.3
|
||||
love.graphics.setFont(Fonts.medium)
|
||||
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse + 0.2)
|
||||
love.graphics.printf("PRESS ENTER TO START", 0, sh * 0.52, sw, "center")
|
||||
|
||||
-- High scores
|
||||
local allScores = HighScores.getScores()
|
||||
if #allScores > 0 then
|
||||
HighScores.drawTable(sw, sh, p, Fonts)
|
||||
end
|
||||
end
|
||||
|
||||
function love.load()
|
||||
love.mouse.setVisible(false)
|
||||
love.graphics.setBackgroundColor(0, 0, 0)
|
||||
love.graphics.setLineStyle("smooth")
|
||||
Palette.loadFromSystem()
|
||||
World.resize(love.graphics.getDimensions())
|
||||
Fonts.init(1)
|
||||
HighScores.init()
|
||||
Sounds.init()
|
||||
Stars.init()
|
||||
World.highScore = HighScores.getHighest()
|
||||
World.state = "title"
|
||||
end
|
||||
|
||||
function love.resize(w, h)
|
||||
World.resize(w, h)
|
||||
Fonts.init(1)
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
World.ensureScale()
|
||||
|
||||
if World.state == "title" then
|
||||
return
|
||||
end
|
||||
|
||||
if World.state == "playing" then
|
||||
World.time = World.time + dt
|
||||
Lander.update(dt)
|
||||
Camera.update(Lander.get(), Terrain, dt)
|
||||
Particles.update(dt)
|
||||
|
||||
-- Thrust sound
|
||||
local l = Lander.get()
|
||||
if l.thrusting then
|
||||
thrustSoundTimer = thrustSoundTimer + dt
|
||||
if thrustSoundTimer > 0.2 then
|
||||
thrustSoundTimer = 0
|
||||
Sounds.play("thrust")
|
||||
end
|
||||
-- Thrust particles
|
||||
local fx, fy = l.x, l.y + 8
|
||||
Particles.spawnThrust(fx, fy, l.angle)
|
||||
else
|
||||
thrustSoundTimer = 0.15
|
||||
end
|
||||
|
||||
-- Fuel warning
|
||||
if World.fuel < 150 and World.fuel > 0 then
|
||||
fuelWarnTimer = fuelWarnTimer + dt
|
||||
if fuelWarnTimer > 0.5 then
|
||||
fuelWarnTimer = 0
|
||||
Sounds.play("fuel_warn")
|
||||
end
|
||||
end
|
||||
|
||||
-- Check terrain collision
|
||||
checkLanding()
|
||||
|
||||
-- Game over if fuel depleted and landed/still
|
||||
if World.fuel <= 0 and not l.thrusting then
|
||||
-- Let them coast until they crash or land
|
||||
end
|
||||
|
||||
elseif World.state == "landed" or World.state == "crashed" then
|
||||
World.stateTimer = World.stateTimer + dt
|
||||
Particles.update(dt)
|
||||
Camera.update(Lander.get(), Terrain, dt)
|
||||
|
||||
if World.stateTimer >= STATE_PAUSE then
|
||||
if World.fuel <= 0 then
|
||||
World.state = "game_over"
|
||||
World.stateTimer = 0
|
||||
else
|
||||
nextAttempt()
|
||||
end
|
||||
end
|
||||
|
||||
elseif World.state == "game_over" then
|
||||
World.stateTimer = World.stateTimer + dt
|
||||
|
||||
elseif World.state == "high_score_entry" then
|
||||
HighScores.updateEntry(dt)
|
||||
end
|
||||
end
|
||||
|
||||
function love.draw()
|
||||
love.graphics.clear(0, 0, 0)
|
||||
local p = Palette.get()
|
||||
local sw, sh = World.screenW, World.screenH
|
||||
|
||||
if World.state == "title" then
|
||||
drawTitleScreen()
|
||||
return
|
||||
end
|
||||
|
||||
if World.state == "high_score_entry" then
|
||||
love.graphics.setColor(p.bg)
|
||||
love.graphics.rectangle("fill", 0, 0, sw, sh)
|
||||
Stars.draw(sw, sh)
|
||||
HighScores.drawEntry(sw, sh, p, Fonts)
|
||||
return
|
||||
end
|
||||
|
||||
-- Draw game world
|
||||
love.graphics.setColor(p.bg)
|
||||
love.graphics.rectangle("fill", 0, 0, sw, sh)
|
||||
|
||||
-- Stars (screen space, behind everything)
|
||||
Stars.draw(sw, sh)
|
||||
|
||||
-- World space
|
||||
Camera.applyTransform()
|
||||
|
||||
local minX, minY, maxX, maxY = Camera.getVisibleRect()
|
||||
Terrain.draw(minX, maxX)
|
||||
Lander.draw()
|
||||
Particles.draw()
|
||||
|
||||
Camera.popTransform()
|
||||
|
||||
-- HUD (screen space)
|
||||
local l = Lander.get()
|
||||
local altitude = Camera.getAltitude(l.y, Terrain, l.x)
|
||||
HUD.draw(altitude, l.vx, l.vy)
|
||||
|
||||
-- Landing/crash result text
|
||||
if World.state == "landed" or World.state == "crashed" then
|
||||
love.graphics.setFont(Fonts.medium)
|
||||
local flash = World.state == "crashed" and
|
||||
(math.floor(World.stateTimer * 4) % 2 == 0) or true
|
||||
|
||||
if flash then
|
||||
if World.state == "crashed" then
|
||||
love.graphics.setColor(p.warning)
|
||||
else
|
||||
love.graphics.setColor(p.bright)
|
||||
end
|
||||
love.graphics.printf(World.landingResult, 0, sh * 0.45, sw, "center")
|
||||
end
|
||||
end
|
||||
|
||||
-- Game over
|
||||
if World.state == "game_over" then
|
||||
local t = love.timer.getTime()
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 0.5)
|
||||
love.graphics.rectangle("fill", 0, 0, sw, sh)
|
||||
|
||||
if World.stateTimer > 0.5 then
|
||||
love.graphics.setFont(Fonts.large)
|
||||
local pulse = 0.7 + math.sin(t * 3) * 0.3
|
||||
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse)
|
||||
love.graphics.printf("OUT OF FUEL", 0, sh * 0.3, sw, "center")
|
||||
end
|
||||
|
||||
if World.stateTimer > 1.5 then
|
||||
love.graphics.setFont(Fonts.medium)
|
||||
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.7)
|
||||
love.graphics.printf("FINAL SCORE: " .. string.format("%d", World.score), 0, sh * 0.3 + Fonts.large:getHeight() + 8, sw, "center")
|
||||
end
|
||||
|
||||
if World.stateTimer > 2.5 then
|
||||
local pulse = 0.3 + math.sin(t * 3) * 0.3
|
||||
love.graphics.setFont(Fonts.small)
|
||||
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse + 0.2)
|
||||
love.graphics.printf("PRESS ENTER", 0, sh * 0.3 + Fonts.large:getHeight() + Fonts.medium:getHeight() + 24, sw, "center")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function love.keypressed(key)
|
||||
if World.state == "title" then
|
||||
if key == "return" then startGame() end
|
||||
if key == "escape" then love.event.quit() end
|
||||
return
|
||||
end
|
||||
|
||||
if World.state == "high_score_entry" then
|
||||
local result = HighScores.keypressedEntry(key)
|
||||
if result == "done" then
|
||||
World.highScore = HighScores.getHighest()
|
||||
World.state = "title"
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if World.state == "playing" then
|
||||
if key == "space" then
|
||||
Lander.abort()
|
||||
Sounds.play("abort")
|
||||
end
|
||||
end
|
||||
|
||||
if World.state == "game_over" then
|
||||
if key == "return" and World.stateTimer > 2.0 then
|
||||
if HighScores.isHighScore(World.score) then
|
||||
World.state = "high_score_entry"
|
||||
HighScores.startEntry(World.score)
|
||||
else
|
||||
World.state = "title"
|
||||
end
|
||||
return
|
||||
end
|
||||
if key == "escape" then
|
||||
World.state = "title"
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if key == "escape" then
|
||||
love.event.quit()
|
||||
end
|
||||
end
|
||||
64
rendering/fonts.lua
Normal file
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(scale)
|
||||
local h = love.graphics.getHeight()
|
||||
if h == Fonts.currentH then return end
|
||||
Fonts.currentH = h
|
||||
|
||||
if not Fonts.fontPath then
|
||||
Fonts.fontPath = Fonts.detectSystemFont() or false
|
||||
end
|
||||
if Fonts.fontPath and not Fonts.fontData then
|
||||
local f = io.open(Fonts.fontPath, "rb")
|
||||
if f then
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
Fonts.fontData = love.filesystem.newFileData(data, "systemfont.ttf")
|
||||
else
|
||||
Fonts.fontPath = false
|
||||
end
|
||||
end
|
||||
|
||||
local sS = math.max(10, math.floor(h * 0.018))
|
||||
local sM = math.max(12, math.floor(h * 0.025))
|
||||
local sL = math.max(16, math.floor(h * 0.045))
|
||||
|
||||
if Fonts.fontData then
|
||||
Fonts.small = love.graphics.newFont(Fonts.fontData, sS)
|
||||
Fonts.medium = love.graphics.newFont(Fonts.fontData, sM)
|
||||
Fonts.large = love.graphics.newFont(Fonts.fontData, sL)
|
||||
else
|
||||
Fonts.small = love.graphics.newFont(sS)
|
||||
Fonts.medium = love.graphics.newFont(sM)
|
||||
Fonts.large = love.graphics.newFont(sL)
|
||||
end
|
||||
end
|
||||
|
||||
return Fonts
|
||||
63
rendering/palette.lua
Normal file
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