Asteroids: complete Love2D game with Omarchy integration

Faithful recreation of Atari Asteroids (1979) with vector wireframe aesthetic.
Auto-detects Omarchy system theme and font on launch.

Features:
- Inertia physics (zero friction), rotate/thrust/fire/hyperspace controls
- 3 asteroid sizes that split on destroy (large→medium→small)
- Large and small UFO saucers with AI (random vs aimed shooting)
- Screen wrapping for ship/asteroids/saucers, bullets expire at edges
- Ship death fragments, explosion particles
- Iconic heartbeat that speeds up as wave clears
- Wave progression (4→11 asteroids, speed ramps)
- 3 lives, extra life every 10k points
- Persistent high scores with 3-letter initial entry
- Procedural sound effects (beat, thrust, fire, explosions, saucer drone)
- Full-screen scaling, system font detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-04-13 12:58:03 +01:00
commit bc88613e07
14 changed files with 1776 additions and 0 deletions

123
audio/sounds.lua Normal file
View file

@ -0,0 +1,123 @@
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
local val = generator(t, p)
sd:setSample(i, math.max(-1, math.min(1, val)))
end
return sd
end
local function makeSource(sd)
return love.audio.newSource(sd, "static")
end
-- Sound generators
local function genBeat1(t, p)
local env = (1 - p) ^ 4
return math.sin(2 * math.pi * 80 * t) * env * 0.5
end
local function genBeat2(t, p)
local env = (1 - p) ^ 4
return math.sin(2 * math.pi * 100 * t) * env * 0.5
end
local function genThrust(t, p)
local noise = (math.random() * 2 - 1) * 0.3
local low = math.sin(2 * math.pi * 40 * t) * 0.2
return (noise + low) * 0.4
end
local function genFire(t, p)
local freq = 1500 - p * 1100
local env = (1 - p) ^ 2
return math.sin(2 * math.pi * freq * t) * env * 0.35
end
local function genExplodeLarge(t, p)
local env = (1 - p) ^ 1.5
local sine = math.sin(2 * math.pi * (50 + 30 * (1-p)) * t) * 0.5
local noise = (math.random() * 2 - 1) * 0.5
return (sine + noise) * env * 0.5
end
local function genExplodeMedium(t, p)
local env = (1 - p) ^ 2
local sine = math.sin(2 * math.pi * (80 + 40 * (1-p)) * t) * 0.4
local noise = (math.random() * 2 - 1) * 0.4
return (sine + noise) * env * 0.45
end
local function genExplodeSmall(t, p)
local env = (1 - p) ^ 3
local sine = math.sin(2 * math.pi * 150 * t) * 0.3
local noise = (math.random() * 2 - 1) * 0.5
return (sine + noise) * env * 0.4
end
local function genSaucerLarge(t, p)
local freq = 200 + math.sin(2 * math.pi * 5 * t) * 50
return math.sin(2 * math.pi * freq * t) * 0.25
end
local function genSaucerSmall(t, p)
local freq = 350 + math.sin(2 * math.pi * 8 * t) * 80
return math.sin(2 * math.pi * freq * t) * 0.25
end
local function genExtraLife(t, p)
local freq
if p < 0.33 then freq = 400
elseif p < 0.66 then freq = 600
else freq = 800 end
local env = 0.7
if p < 0.05 then env = p / 0.05 * 0.7 end
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 genShipExplode(t, p)
local freq = 500 * (1 - p * 0.7)
local env = (1 - p) ^ 0.8
local sine = math.sin(2 * math.pi * freq * t) * 0.4
local noise = (math.random() * 2 - 1) * 0.3 * p
local pulse = 0.7 + math.sin(2 * math.pi * 4 * t) * 0.3
return (sine + noise) * env * pulse * 0.5
end
function Sounds.init()
sources = {}
local defs = {
beat1 = {0.08, genBeat1},
beat2 = {0.08, genBeat2},
thrust = {0.3, genThrust},
fire = {0.05, genFire},
explode_large = {0.5, genExplodeLarge},
explode_medium = {0.3, genExplodeMedium},
explode_small = {0.15, genExplodeSmall},
saucer_large = {0.4, genSaucerLarge},
saucer_small = {0.4, genSaucerSmall},
extra_life = {0.3, genExtraLife},
ship_explode = {1.0, genShipExplode},
}
for name, def in pairs(defs) do
sources[name] = makeSource(makeSoundData(def[1], def[2]))
end
end
function Sounds.play(name)
local src = sources[name]
if not src then return end
src:clone():play()
end
return Sounds

11
conf.lua Normal file
View file

@ -0,0 +1,11 @@
function love.conf(t)
t.window.title = "ASTEROIDS"
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

13
data/waves.lua Normal file
View file

@ -0,0 +1,13 @@
local Waves = {}
function Waves.get(wave)
local counts = {4, 6, 8, 10, 11}
local count = counts[math.min(wave, #counts)]
local speedMult = 1.0 + (math.min(wave, 11) - 1) * 0.08
return {
asteroidCount = count,
speedMultiplier = speedMult,
}
end
return Waves

166
game/asteroids.lua Normal file
View file

@ -0,0 +1,166 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Asteroids = {}
local active = {}
local shapes = { large = {}, medium = {}, small = {} }
local SIZES = {
large = { radius = 45, minSpeed = 40, maxSpeed = 80, points = 20 },
medium = { radius = 22, minSpeed = 60, maxSpeed = 120, points = 50 },
small = { radius = 11, minSpeed = 80, maxSpeed = 160, points = 100 },
}
local NUM_VARIANTS = 5
local NUM_VERTICES = 12
local function generateShape(baseRadius)
local verts = {}
for i = 1, NUM_VERTICES do
local angle = (i - 1) / NUM_VERTICES * math.pi * 2
angle = angle + (math.random() - 0.5) * 0.3
local r = baseRadius * (0.7 + math.random() * 0.6)
table.insert(verts, {math.cos(angle) * r, math.sin(angle) * r})
end
return verts
end
function Asteroids.init()
for size, info in pairs(SIZES) do
shapes[size] = {}
for i = 1, NUM_VARIANTS do
shapes[size][i] = generateShape(info.radius)
end
end
active = {}
end
function Asteroids.spawnWave(wave)
local Waves = require("data.waves")
local config = Waves.get(wave)
local count = config.asteroidCount
local speedMult = config.speedMultiplier
for i = 1, count do
-- Spawn at edges, not near centre
local x, y
repeat
x = math.random(0, World.GAME_W)
y = math.random(0, World.GAME_H)
until math.abs(x - World.GAME_W/2) > 150 or math.abs(y - World.GAME_H/2) > 150
local angle = math.random() * math.pi * 2
local speed = (SIZES.large.minSpeed + math.random() * (SIZES.large.maxSpeed - SIZES.large.minSpeed)) * speedMult
table.insert(active, {
x = x, y = y,
vx = math.cos(angle) * speed,
vy = math.sin(angle) * speed,
rot = 0,
rotSpeed = (math.random() - 0.5) * 3,
size = "large",
shapeIdx = math.random(1, NUM_VARIANTS),
radius = SIZES.large.radius,
})
end
end
function Asteroids.update(dt)
for _, a in ipairs(active) do
a.x = World.wrapX(a.x + a.vx * dt)
a.y = World.wrapY(a.y + a.vy * dt)
a.rot = a.rot + a.rotSpeed * dt
end
end
function Asteroids.destroy(idx)
local a = active[idx]
if not a then return nil end
local result = {
x = a.x, y = a.y,
size = a.size,
points = SIZES[a.size].points,
}
-- Spawn children
local childSize = nil
if a.size == "large" then childSize = "medium"
elseif a.size == "medium" then childSize = "small"
end
if childSize then
local info = SIZES[childSize]
for c = 1, 2 do
local angle = math.random() * math.pi * 2
local speed = info.minSpeed + math.random() * (info.maxSpeed - info.minSpeed)
table.insert(active, {
x = a.x, y = a.y,
vx = math.cos(angle) * speed,
vy = math.sin(angle) * speed,
rot = 0,
rotSpeed = (math.random() - 0.5) * 3,
size = childSize,
shapeIdx = math.random(1, NUM_VARIANTS),
radius = info.radius,
})
end
end
table.remove(active, idx)
return result
end
function Asteroids.count()
return #active
end
function Asteroids.getAll()
return active
end
function Asteroids.clear()
active = {}
end
local function drawAsteroidAt(a, ox, oy)
local shape = shapes[a.size][a.shapeIdx]
if not shape then return end
local pts = {}
local cos_r = math.cos(a.rot)
local sin_r = math.sin(a.rot)
for _, v in ipairs(shape) do
local rx = v[1] * cos_r - v[2] * sin_r + ox
local ry = v[1] * sin_r + v[2] * cos_r + oy
table.insert(pts, rx)
table.insert(pts, ry)
end
-- Close the loop
table.insert(pts, pts[1])
table.insert(pts, pts[2])
love.graphics.line(pts)
end
function Asteroids.draw()
local p = Palette.get()
local lw = 1 / World.scale
love.graphics.setColor(p.asteroid)
love.graphics.setLineWidth(lw * 1.5)
local W, H = World.GAME_W, World.GAME_H
for _, a in ipairs(active) do
drawAsteroidAt(a, a.x, a.y)
-- Wrap ghosts
if a.x < a.radius then drawAsteroidAt(a, a.x + W, a.y) end
if a.x > W - a.radius then drawAsteroidAt(a, a.x - W, a.y) end
if a.y < a.radius then drawAsteroidAt(a, a.x, a.y + H) end
if a.y > H - a.radius then drawAsteroidAt(a, a.x, a.y - H) end
end
end
return Asteroids

72
game/bullets.lua Normal file
View file

@ -0,0 +1,72 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Bullets = {}
local active = {}
local BULLET_SPEED = 600
local BULLET_LIFETIME = 0.667
local MAX_PLAYER_BULLETS = 4
function Bullets.fire(x, y, angle, shipVx, shipVy, owner)
if owner == "player" then
local count = 0
for _, b in ipairs(active) do
if b.owner == "player" then count = count + 1 end
end
if count >= MAX_PLAYER_BULLETS then return false end
end
table.insert(active, {
x = x, y = y,
vx = shipVx + math.cos(angle) * BULLET_SPEED,
vy = shipVy + math.sin(angle) * BULLET_SPEED,
timer = BULLET_LIFETIME,
owner = owner or "player",
})
return true
end
function Bullets.update(dt)
for i = #active, 1, -1 do
local b = active[i]
b.x = b.x + b.vx * dt
b.y = b.y + b.vy * dt
b.timer = b.timer - dt
-- Bullets don't wrap — remove when out of bounds or expired
if b.timer <= 0 or b.x < -5 or b.x > World.GAME_W + 5 or
b.y < -5 or b.y > World.GAME_H + 5 then
table.remove(active, i)
end
end
end
function Bullets.getAll()
return active
end
function Bullets.remove(idx)
table.remove(active, idx)
end
function Bullets.clear()
active = {}
end
function Bullets.draw()
local p = Palette.get()
local lw = 1 / World.scale
for _, b in ipairs(active) do
if b.owner == "player" then
love.graphics.setColor(p.bullet)
else
love.graphics.setColor(p.saucer)
end
love.graphics.setLineWidth(lw * 2)
love.graphics.circle("fill", b.x, b.y, 1.5)
end
end
return Bullets

198
game/highscores.lua Normal file
View file

@ -0,0 +1,198 @@
local HighScores = {}
local MAX_SCORES = 10
local SAVE_FILE = "asteroids_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

45
game/hud.lua Normal file
View file

@ -0,0 +1,45 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Fonts = require("rendering.fonts")
local Ship = require("game.ship")
local HUD = {}
function HUD.draw()
local p = Palette.get()
-- Pop out of game transform for crisp screen-space text
love.graphics.pop()
local font = Fonts.medium or love.graphics.getFont()
love.graphics.setFont(font)
local pad = 8
-- Score (top left)
love.graphics.setColor(p.hud)
love.graphics.print(string.format("%06d", World.score), pad, pad)
-- High score (top centre)
if World.highScore > 0 then
love.graphics.setFont(Fonts.small)
love.graphics.setColor(p.dim)
local hsText = string.format("%06d", World.highScore)
local tw = Fonts.small:getWidth(hsText)
love.graphics.print(hsText, (World.screenW - tw) / 2, pad)
end
-- Lives (small ship icons below score)
local iconY = pad + font:getHeight() + 6
local iconScale = 0.5
local iconSpacing = 20
for i = 1, math.min(World.lives, 10) do
Ship.drawIcon(pad + 10 + (i-1) * iconSpacing, iconY + 8, iconScale)
end
-- Restore game transform
love.graphics.push()
love.graphics.translate(World.offsetX, World.offsetY)
love.graphics.scale(World.scale)
end
return HUD

121
game/particles.lua Normal file
View file

@ -0,0 +1,121 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Particles = {}
local particles = {}
function Particles.spawnExplosion(x, y, count, speed, maxTime)
count = count or 8
speed = speed or 100
maxTime = maxTime or 1.0
for i = 1, count do
local angle = math.random() * math.pi * 2
local spd = speed * (0.3 + math.random() * 0.7)
table.insert(particles, {
x = x, y = y,
vx = math.cos(angle) * spd,
vy = math.sin(angle) * spd,
timer = maxTime * (0.5 + math.random() * 0.5),
maxTime = maxTime,
ptype = "dot",
})
end
end
function Particles.spawnShipDeath(x, y, angle, vx, vy)
-- Break ship into 3 line segments that drift apart
local cos_a = math.cos(angle)
local sin_a = math.sin(angle)
local function transform(px, py)
return px * cos_a - py * sin_a,
px * sin_a + py * cos_a
end
-- Ship vertices
local nose = {20, 0}
local lwing = {-14, -12}
local notch = {-8, 0}
local rwing = {-14, 12}
local segments = {
{nose, lwing},
{lwing, notch},
{notch, rwing},
{rwing, nose},
}
for _, seg in ipairs(segments) do
local x1r, y1r = transform(seg[1][1], seg[1][2])
local x2r, y2r = transform(seg[2][1], seg[2][2])
local midX = (x1r + x2r) / 2
local midY = (y1r + y2r) / 2
local spread = 40 + math.random() * 40
local sAngle = math.atan2(midY, midX) + (math.random() - 0.5) * 1.5
table.insert(particles, {
x = x + midX, y = y + midY,
vx = vx * 0.3 + math.cos(sAngle) * spread,
vy = vy * 0.3 + math.sin(sAngle) * spread,
-- Line offsets from centre
lx1 = x1r - midX, ly1 = y1r - midY,
lx2 = x2r - midX, ly2 = y2r - midY,
rotSpeed = (math.random() - 0.5) * 4,
rot = 0,
timer = 2.0,
maxTime = 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.timer = p.timer - dt
if p.rot then p.rot = p.rot + (p.rotSpeed or 0) * dt end
if p.timer <= 0 then
table.remove(particles, i)
end
end
end
function Particles.anyActive()
return #particles > 0
end
function Particles.clear()
particles = {}
end
function Particles.draw()
local pal = Palette.get()
local lw = 1 / World.scale
for _, p in ipairs(particles) do
local alpha = math.max(0, p.timer / p.maxTime)
if p.ptype == "dot" then
love.graphics.setColor(pal.explosion[1], pal.explosion[2], pal.explosion[3], alpha)
love.graphics.setLineWidth(lw)
love.graphics.circle("fill", p.x, p.y, 1.2)
elseif p.ptype == "line" then
love.graphics.setColor(pal.ship[1], pal.ship[2], pal.ship[3], alpha)
love.graphics.setLineWidth(lw * 2)
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

155
game/saucers.lua Normal file
View file

@ -0,0 +1,155 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Bullets = require("game.bullets")
local Saucers = {}
local saucer = nil
local spawnTimer = 0
local SPAWN_INTERVAL = 15
local DEFS = {
large = { speed = 150, fireRate = 1.5, radius = 20, points = 200 },
small = { speed = 200, fireRate = 1.0, radius = 12, points = 1000 },
}
-- Saucer shape: classic flying saucer profile
local SHAPE_LARGE = {
{-20, 0}, {-10, -8}, {10, -8}, {20, 0},
{10, 6}, {-10, 6}, {-20, 0},
}
local SHAPE_LARGE_TOP = {
{-10, -8}, {-6, -14}, {6, -14}, {10, -8},
}
local SHAPE_SMALL = {
{-12, 0}, {-6, -5}, {6, -5}, {12, 0},
{6, 4}, {-6, 4}, {-12, 0},
}
local SHAPE_SMALL_TOP = {
{-6, -5}, {-3, -9}, {3, -9}, {6, -5},
}
function Saucers.update(dt, shipX, shipY)
if not saucer then
spawnTimer = spawnTimer + dt
if spawnTimer >= SPAWN_INTERVAL then
spawnTimer = 0
-- Determine size based on score
local size
if World.score >= 40000 then
size = "small"
elseif World.score >= 10000 then
size = math.random() < 0.6 and "small" or "large"
else
size = "large"
end
local fromLeft = math.random() < 0.5
local def = DEFS[size]
saucer = {
x = fromLeft and -20 or (World.GAME_W + 20),
y = math.random(80, World.GAME_H - 80),
vx = (fromLeft and 1 or -1) * def.speed,
vy = 0,
size = size,
radius = def.radius,
points = def.points,
fireTimer = def.fireRate * 0.5,
fireRate = def.fireRate,
altTimer = 1 + math.random() * 2,
alive = true,
}
end
return
end
local s = saucer
-- Move
s.x = s.x + s.vx * dt
s.y = s.y + s.vy * dt
s.y = World.wrapY(s.y)
-- Altitude changes
s.altTimer = s.altTimer - dt
if s.altTimer <= 0 then
s.altTimer = 1 + math.random() * 2
s.vy = (math.random() - 0.5) * 200
end
-- Remove if off screen
if (s.vx > 0 and s.x > World.GAME_W + 30) or (s.vx < 0 and s.x < -30) then
saucer = nil
return
end
-- Firing
s.fireTimer = s.fireTimer - dt
if s.fireTimer <= 0 then
s.fireTimer = s.fireRate
local angle
if s.size == "large" then
angle = math.random() * math.pi * 2
else
-- Aim at player with some error
local dx, dy = World.wrappedDist(s.x, s.y, shipX, shipY)
angle = math.atan2(dy, dx) + (math.random() - 0.5) * 0.35
end
Bullets.fire(s.x, s.y, angle, 0, 0, "saucer")
end
end
function Saucers.get()
return saucer
end
function Saucers.destroy()
local points = saucer and saucer.points or 0
local x, y = saucer.x, saucer.y
saucer = nil
return points, x, y
end
function Saucers.clear()
saucer = nil
spawnTimer = 0
end
function Saucers.isActive()
return saucer ~= nil
end
local function drawShape(shape, ox, oy)
local pts = {}
for _, v in ipairs(shape) do
table.insert(pts, v[1] + ox)
table.insert(pts, v[2] + oy)
end
love.graphics.line(pts)
end
function Saucers.draw()
if not saucer then return end
local p = Palette.get()
local lw = 1 / World.scale
local t = love.timer.getTime()
local pulse = 0.7 + math.sin(t * 10) * 0.3
love.graphics.setColor(p.saucer[1], p.saucer[2], p.saucer[3], pulse)
love.graphics.setLineWidth(lw * 1.5)
if saucer.size == "large" then
drawShape(SHAPE_LARGE, saucer.x, saucer.y)
drawShape(SHAPE_LARGE_TOP, saucer.x, saucer.y)
-- Centre line
love.graphics.line(saucer.x - 10, saucer.y - 8, saucer.x + 10, saucer.y - 8)
else
drawShape(SHAPE_SMALL, saucer.x, saucer.y)
drawShape(SHAPE_SMALL_TOP, saucer.x, saucer.y)
love.graphics.line(saucer.x - 6, saucer.y - 5, saucer.x + 6, saucer.y - 5)
end
end
return Saucers

184
game/ship.lua Normal file
View file

@ -0,0 +1,184 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Ship = {}
local ROTATION_SPEED = 4.712 -- ~270 deg/sec
local THRUST_ACCEL = 300
local MAX_SPEED = 400
local INVULN_TIME = 3.0
local HYPER_COOLDOWN = 0.5
local COLLISION_RADIUS = 14
-- Ship shape vertices (pointing right at angle=0)
local SHAPE = {
{20, 0}, -- nose
{-14, -12}, -- left wing
{-8, 0}, -- rear notch
{-14, 12}, -- right wing
}
local ship = {}
function Ship.init()
ship.x = World.GAME_W / 2
ship.y = World.GAME_H / 2
ship.vx = 0
ship.vy = 0
ship.angle = -math.pi / 2 -- pointing up
ship.alive = true
ship.invulnerable = true
ship.invulnTimer = INVULN_TIME
ship.thrustOn = false
ship.hyperCooldown = 0
end
function Ship.get()
return ship
end
function Ship.update(dt)
if not ship.alive then return end
ship.invulnTimer = math.max(0, ship.invulnTimer - dt)
if ship.invulnTimer <= 0 then ship.invulnerable = false end
ship.hyperCooldown = math.max(0, ship.hyperCooldown - dt)
-- Rotation
if love.keyboard.isDown("left", "a") then
ship.angle = ship.angle - ROTATION_SPEED * dt
end
if love.keyboard.isDown("right", "d") then
ship.angle = ship.angle + ROTATION_SPEED * dt
end
-- Thrust
ship.thrustOn = love.keyboard.isDown("up", "w")
if ship.thrustOn then
ship.vx = ship.vx + math.cos(ship.angle) * THRUST_ACCEL * dt
ship.vy = ship.vy + math.sin(ship.angle) * THRUST_ACCEL * dt
-- Cap speed
local speed = math.sqrt(ship.vx * ship.vx + ship.vy * ship.vy)
if speed > MAX_SPEED then
ship.vx = ship.vx / speed * MAX_SPEED
ship.vy = ship.vy / speed * MAX_SPEED
end
end
-- Move and wrap
ship.x = World.wrapX(ship.x + ship.vx * dt)
ship.y = World.wrapY(ship.y + ship.vy * dt)
end
function Ship.die()
ship.alive = false
end
function Ship.respawn()
ship.x = World.GAME_W / 2
ship.y = World.GAME_H / 2
ship.vx = 0
ship.vy = 0
ship.angle = -math.pi / 2
ship.alive = true
ship.invulnerable = true
ship.invulnTimer = INVULN_TIME
end
function Ship.hyperspace()
if ship.hyperCooldown > 0 or not ship.alive then return false end
ship.hyperCooldown = HYPER_COOLDOWN
-- 25% chance of death
if math.random() < 0.25 then
return true -- signal death
end
ship.x = math.random(50, World.GAME_W - 50)
ship.y = math.random(50, World.GAME_H - 50)
ship.vx = 0
ship.vy = 0
return false
end
function Ship.getCollider()
return ship.x, ship.y, COLLISION_RADIUS
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 Ship.getVertices()
local verts = {}
for _, v in ipairs(SHAPE) do
local wx, wy = transformPoint(v[1], v[2], ship.angle, ship.x, ship.y)
table.insert(verts, {wx, wy})
end
return verts
end
function Ship.draw()
if not ship.alive then return end
-- Blink when invulnerable
if ship.invulnerable and math.floor(ship.invulnTimer * 8) % 2 == 0 then
return
end
local p = Palette.get()
local lw = 1 / World.scale
local verts = Ship.getVertices()
-- Draw ship outline
love.graphics.setColor(p.ship)
love.graphics.setLineWidth(lw * 2)
local pts = {}
for _, v in ipairs(verts) do
table.insert(pts, v[1])
table.insert(pts, v[2])
end
table.insert(pts, verts[1][1])
table.insert(pts, verts[1][2])
love.graphics.line(pts)
-- Thrust flame
if ship.thrustOn then
local flicker = 0.6 + math.random() * 0.4
love.graphics.setColor(p.thrust[1], p.thrust[2], p.thrust[3], flicker)
love.graphics.setLineWidth(lw * 1.5)
local flameLen = 8 + math.random() * 8
local fx, fy = transformPoint(-8, 0, ship.angle, ship.x, ship.y)
local fl, fl2 = transformPoint(-8, -4, ship.angle, ship.x, ship.y)
local fr, fr2 = transformPoint(-8, 4, ship.angle, ship.x, ship.y)
local ft, ft2 = transformPoint(-8 - flameLen, 0, ship.angle, ship.x, ship.y)
love.graphics.line(fl, fl2, ft, ft2, fr, fr2)
end
end
-- Draw a small ship icon for lives display
function Ship.drawIcon(cx, cy, scale)
local p = Palette.get()
local lw = 1
love.graphics.setColor(p.ship)
love.graphics.setLineWidth(lw)
local pts = {}
for _, v in ipairs(SHAPE) do
local rx = cx + v[1] * scale
local ry = cy + v[2] * scale
table.insert(pts, rx)
table.insert(pts, ry)
end
table.insert(pts, pts[1])
table.insert(pts, pts[2])
love.graphics.line(pts)
end
return Ship

85
game/world.lua Normal file
View file

@ -0,0 +1,85 @@
local World = {
GAME_W = 1024,
GAME_H = 768,
visibleH = 768,
scale = 1,
offsetX = 0,
offsetY = 0,
state = "title",
wave = 1,
score = 0,
highScore = 0,
lives = 3,
nextExtraLife = 10000,
gameOverTimer = 0,
screenW = 0,
screenH = 0,
respawnTimer = 0,
waveTimer = 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.scale = w / World.GAME_W
World.visibleH = h / World.scale
World.offsetX = 0
World.offsetY = h - (World.GAME_H * World.scale)
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(World.scale)
end
end
function World.toGame(sx, sy)
local gx = (sx - World.offsetX) / World.scale
local gy = (sy - World.offsetY) / World.scale
return gx, gy
end
function World.toScreen(gx, gy)
return gx * World.scale + World.offsetX, gy * World.scale + World.offsetY
end
function World.visibleTop()
return World.GAME_H - World.visibleH
end
function World.wrapX(x)
return (x % World.GAME_W + World.GAME_W) % World.GAME_W
end
function World.wrapY(y)
return (y % World.GAME_H + World.GAME_H) % World.GAME_H
end
function World.wrappedDist(x1, y1, x2, y2)
local dx = x2 - x1
local dy = y2 - y1
if math.abs(dx) > World.GAME_W / 2 then
dx = dx - World.GAME_W * (dx > 0 and 1 or -1)
end
if math.abs(dy) > World.GAME_H / 2 then
dy = dy - World.GAME_H * (dy > 0 and 1 or -1)
end
return dx, dy, math.sqrt(dx*dx + dy*dy)
end
function World.addScore(points)
World.score = World.score + points
while World.score >= World.nextExtraLife do
World.lives = World.lives + 1
World.nextExtraLife = World.nextExtraLife + 10000
end
if World.score > World.highScore then
World.highScore = World.score
end
end
return World

469
main.lua Normal file
View file

@ -0,0 +1,469 @@
local World = require("game.world")
local Ship = require("game.ship")
local AsteroidsMod = require("game.asteroids")
local Bullets = require("game.bullets")
local Saucers = require("game.saucers")
local Particles = require("game.particles")
local HUD = require("game.hud")
local HighScores = require("game.highscores")
local Palette = require("rendering.palette")
local Fonts = require("rendering.fonts")
local Waves = require("data.waves")
local Sounds = require("audio.sounds")
-- Beat system
local beat = {
timer = 0,
which = 1, -- alternates 1 and 2
waveStartCount = 4,
}
-- Thrust sound state
local thrustPlaying = false
local thrustTimer = 0
-- Wave transition
local waveDelay = 0
local WAVE_DELAY_TIME = 2.0
-- Respawn
local respawnDelay = 0
local RESPAWN_DELAY_TIME = 2.0
-- Title screen asteroids (decorative)
local titleAsteroidsInited = false
local function startGame()
World.state = "playing"
World.wave = 1
World.score = 0
World.lives = 3
World.nextExtraLife = 10000
World.gameOverTimer = 0
Ship.init()
AsteroidsMod.clear()
Bullets.clear()
Saucers.clear()
Particles.clear()
AsteroidsMod.spawnWave(1)
beat.waveStartCount = AsteroidsMod.count()
beat.timer = 0
beat.which = 1
waveDelay = 0
respawnDelay = 0
Sounds.play("beat1")
end
local function getBeatInterval()
local total = math.max(beat.waveStartCount, 1)
local remaining = math.max(AsteroidsMod.count(), 0)
local ratio = remaining / total
return 0.1 + 0.7 * ratio
end
local function drawGameWorld()
love.graphics.push()
love.graphics.translate(World.offsetX, World.offsetY)
love.graphics.scale(World.scale)
local p = Palette.get()
local top = World.visibleTop()
-- Background
love.graphics.setColor(p.bg)
love.graphics.rectangle("fill", 0, top, World.GAME_W, World.visibleH)
AsteroidsMod.draw()
Saucers.draw()
Bullets.draw()
Ship.draw()
Particles.draw()
HUD.draw()
love.graphics.pop()
end
local function drawTitleScreen()
local p = Palette.get()
local sw, sh = World.screenW, World.screenH
local t = love.timer.getTime()
-- Background with drifting asteroids
love.graphics.push()
love.graphics.translate(World.offsetX, World.offsetY)
love.graphics.scale(World.scale)
local top = World.visibleTop()
love.graphics.setColor(p.bg)
love.graphics.rectangle("fill", 0, top, World.GAME_W, World.visibleH)
AsteroidsMod.draw()
love.graphics.pop()
-- Title text
local centerY = sh * 0.18
love.graphics.setFont(Fonts.large)
love.graphics.setColor(p.bright)
love.graphics.printf("ASTEROIDS", 0, centerY, sw, "center")
-- Decorative wireframe lines
love.graphics.setColor(p.asteroid[1], p.asteroid[2], p.asteroid[3], 0.2)
love.graphics.setLineWidth(1)
local bx = sw * 0.2
local by1 = centerY - 10
local by2 = centerY + Fonts.large:getHeight() + 10
love.graphics.line(bx, by1, sw - bx, by1)
love.graphics.line(bx, by2, sw - bx, by2)
-- Controls
love.graphics.setFont(Fonts.small)
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.4)
local ctrlY = sh * 0.42
love.graphics.printf("ARROWS / WASD: MOVE SPACE: FIRE SHIFT: HYPERSPACE", 0, ctrlY, 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.54, sw, "center")
-- High score table
local allScores = HighScores.getScores()
if #allScores > 0 then
HighScores.drawTable(sw, sh, p, Fonts)
end
end
-- Collision helpers
local function circleCollision(x1, y1, r1, x2, y2, r2)
local _, _, dist = World.wrappedDist(x1, y1, x2, y2)
return dist < r1 + r2
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(World.scale)
HighScores.init()
Sounds.init()
AsteroidsMod.init()
World.highScore = HighScores.getHighest()
World.state = "title"
-- Spawn some decorative asteroids for title screen
AsteroidsMod.spawnWave(1)
end
function love.resize(w, h)
World.resize(w, h)
Fonts.init(World.scale)
end
function love.update(dt)
World.ensureScale()
if World.state == "title" then
AsteroidsMod.update(dt)
return
end
if World.state == "playing" then
local s = Ship.get()
-- Ship update
if s.alive then
Ship.update(dt)
-- Thrust sound
if s.thrustOn then
thrustTimer = thrustTimer + dt
if thrustTimer > 0.25 then
thrustTimer = 0
Sounds.play("thrust")
end
else
thrustTimer = 0.2 -- play immediately on next thrust
end
end
AsteroidsMod.update(dt)
Bullets.update(dt)
Saucers.update(dt, s.x, s.y)
Particles.update(dt)
-- Beat timer
beat.timer = beat.timer + dt
local interval = getBeatInterval()
if beat.timer >= interval then
beat.timer = 0
if beat.which == 1 then
Sounds.play("beat1")
beat.which = 2
else
Sounds.play("beat2")
beat.which = 1
end
end
-- === COLLISION DETECTION ===
local asteroids = AsteroidsMod.getAll()
local bullets = Bullets.getAll()
-- Bullets vs asteroids
for bi = #bullets, 1, -1 do
local b = bullets[bi]
for ai = #asteroids, 1, -1 do
local a = asteroids[ai]
if circleCollision(b.x, b.y, 2, a.x, a.y, a.radius) then
local result = AsteroidsMod.destroy(ai)
if result then
World.addScore(result.points)
local expSize = result.size == "large" and 12 or (result.size == "medium" and 8 or 5)
Particles.spawnExplosion(result.x, result.y, expSize, 80)
Sounds.play("explode_" .. result.size)
end
Bullets.remove(bi)
asteroids = AsteroidsMod.getAll() -- refresh after destroy
break
end
end
end
-- Bullets vs saucer
local saucer = Saucers.get()
if saucer then
for bi = #bullets, 1, -1 do
local b = bullets[bi]
if b.owner == "player" and circleCollision(b.x, b.y, 2, saucer.x, saucer.y, saucer.radius) then
local points, sx, sy = Saucers.destroy()
World.addScore(points)
Particles.spawnExplosion(sx, sy, 15, 120)
Sounds.play("explode_large")
Bullets.remove(bi)
break
end
end
end
-- Ship vs asteroids
if s.alive and not s.invulnerable then
local sx, sy, sr = Ship.getCollider()
for ai = #asteroids, 1, -1 do
local a = asteroids[ai]
if circleCollision(sx, sy, sr, a.x, a.y, a.radius) then
Ship.die()
Particles.spawnShipDeath(s.x, s.y, s.angle, s.vx, s.vy)
Sounds.play("ship_explode")
World.lives = World.lives - 1
respawnDelay = RESPAWN_DELAY_TIME
break
end
end
end
-- Ship vs saucer
saucer = Saucers.get()
if s.alive and not s.invulnerable and saucer then
local sx, sy, sr = Ship.getCollider()
if circleCollision(sx, sy, sr, saucer.x, saucer.y, saucer.radius) then
Ship.die()
Particles.spawnShipDeath(s.x, s.y, s.angle, s.vx, s.vy)
Sounds.play("ship_explode")
World.lives = World.lives - 1
local points, sx2, sy2 = Saucers.destroy()
World.addScore(points)
Particles.spawnExplosion(sx2, sy2, 15, 120)
respawnDelay = RESPAWN_DELAY_TIME
end
end
-- Saucer bullets vs ship
if s.alive and not s.invulnerable then
local sx, sy, sr = Ship.getCollider()
for bi = #bullets, 1, -1 do
local b = bullets[bi]
if b.owner == "saucer" and circleCollision(b.x, b.y, 2, sx, sy, sr) then
Ship.die()
Particles.spawnShipDeath(s.x, s.y, s.angle, s.vx, s.vy)
Sounds.play("ship_explode")
World.lives = World.lives - 1
Bullets.remove(bi)
respawnDelay = RESPAWN_DELAY_TIME
break
end
end
end
-- Respawn logic
if not s.alive then
respawnDelay = respawnDelay - dt
if respawnDelay <= 0 then
if World.lives > 0 then
-- Check centre is safe
local safe = true
for _, a in ipairs(AsteroidsMod.getAll()) do
local _, _, dist = World.wrappedDist(World.GAME_W/2, World.GAME_H/2, a.x, a.y)
if dist < a.radius + 80 then safe = false; break end
end
if safe then
Ship.respawn()
end
else
-- Game over
World.state = "game_over"
World.gameOverTimer = 0
Sounds.play("ship_explode")
end
end
end
-- Wave clear check
if AsteroidsMod.count() == 0 and not Saucers.isActive() then
waveDelay = waveDelay + dt
if waveDelay >= WAVE_DELAY_TIME then
waveDelay = 0
World.wave = World.wave + 1
AsteroidsMod.spawnWave(World.wave)
beat.waveStartCount = AsteroidsMod.count()
end
else
waveDelay = 0
end
elseif World.state == "game_over" then
World.gameOverTimer = World.gameOverTimer + dt
Particles.update(dt)
AsteroidsMod.update(dt)
elseif World.state == "high_score_entry" then
HighScores.updateEntry(dt)
AsteroidsMod.update(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.push()
love.graphics.translate(World.offsetX, World.offsetY)
love.graphics.scale(World.scale)
local top = World.visibleTop()
love.graphics.setColor(p.bg)
love.graphics.rectangle("fill", 0, top, World.GAME_W, World.visibleH)
AsteroidsMod.draw()
love.graphics.pop()
HighScores.drawEntry(sw, sh, p, Fonts)
return
end
drawGameWorld()
if World.state == "game_over" then
local t = love.timer.getTime()
local midY = sh * 0.35
if World.gameOverTimer > 0.5 then
love.graphics.setFont(Fonts.large)
local textPulse = 0.7 + math.sin(t * 3) * 0.3
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], textPulse)
love.graphics.printf("GAME OVER", 0, midY, sw, "center")
end
if World.gameOverTimer > 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("SCORE: " .. string.format("%06d", World.score), 0, midY + Fonts.large:getHeight() + 8, sw, "center")
end
if World.gameOverTimer > 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, midY + 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"
AsteroidsMod.clear()
AsteroidsMod.spawnWave(1) -- decorative title asteroids
end
return
end
if World.state == "playing" then
local s = Ship.get()
if key == "space" and s.alive then
if Bullets.fire(
s.x + math.cos(s.angle) * 16,
s.y + math.sin(s.angle) * 16,
s.angle, s.vx, s.vy, "player"
) then
Sounds.play("fire")
end
end
if (key == "lshift" or key == "rshift") and s.alive then
local died = Ship.hyperspace()
if died then
Ship.die()
Particles.spawnShipDeath(s.x, s.y, s.angle, s.vx, s.vy)
Sounds.play("ship_explode")
World.lives = World.lives - 1
respawnDelay = RESPAWN_DELAY_TIME
end
end
end
if World.state == "game_over" then
if key == "return" and World.gameOverTimer > 2.0 then
if HighScores.isHighScore(World.score) then
World.state = "high_score_entry"
HighScores.startEntry(World.score)
else
World.state = "title"
AsteroidsMod.clear()
AsteroidsMod.spawnWave(1)
end
return
end
if key == "escape" then
World.state = "title"
AsteroidsMod.clear()
AsteroidsMod.spawnWave(1)
return
end
end
if key == "escape" then
love.event.quit()
end
end

64
rendering/fonts.lua Normal file
View file

@ -0,0 +1,64 @@
local Fonts = {}
Fonts.small = nil
Fonts.medium = nil
Fonts.large = nil
Fonts.currentH = 0
Fonts.fontPath = nil
Fonts.fontData = nil
function Fonts.detectSystemFont()
local home = os.getenv("HOME")
local f = io.open(home .. "/.config/waybar/style.css", "r")
if not f then return nil end
local content = f:read("*a")
f:close()
local fontName = content:match("font%-family:%s*[\"']?([^;\"']+)")
if not fontName then return nil end
fontName = fontName:match("^%s*(.-)%s*$")
local handle = io.popen('fc-match "' .. fontName .. '" --format="%{file}"')
if not handle then return nil end
local path = handle:read("*a")
handle:close()
if path and path ~= "" then
local test = io.open(path, "r")
if test then test:close(); return path end
end
return nil
end
function Fonts.init(scale)
local h = love.graphics.getHeight()
if h == Fonts.currentH then return end
Fonts.currentH = h
if not Fonts.fontPath then
Fonts.fontPath = Fonts.detectSystemFont() or false
end
if Fonts.fontPath and not Fonts.fontData then
local f = io.open(Fonts.fontPath, "rb")
if f then
local data = f:read("*a")
f:close()
Fonts.fontData = love.filesystem.newFileData(data, "systemfont.ttf")
else
Fonts.fontPath = false
end
end
local sS = math.max(10, math.floor(h * 0.018))
local sM = math.max(12, math.floor(h * 0.025))
local sL = math.max(16, math.floor(h * 0.045))
if Fonts.fontData then
Fonts.small = love.graphics.newFont(Fonts.fontData, sS)
Fonts.medium = love.graphics.newFont(Fonts.fontData, sM)
Fonts.large = love.graphics.newFont(Fonts.fontData, sL)
else
Fonts.small = love.graphics.newFont(sS)
Fonts.medium = love.graphics.newFont(sM)
Fonts.large = love.graphics.newFont(sL)
end
end
return Fonts

70
rendering/palette.lua Normal file
View file

@ -0,0 +1,70 @@
-- Palette auto-detected from current Omarchy system theme
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,
ship = theme.fg,
asteroid = theme.color4,
bullet = theme.fg,
saucer = theme.color2 or theme.color3,
thrust = theme.accent,
explosion = theme.accent,
dim = theme.dim,
bright = theme.accent,
hud = theme.fg,
grid = theme.color0,
}
end
function Palette.raw()
return theme
end
return Palette