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:
commit
bc88613e07
14 changed files with 1776 additions and 0 deletions
123
audio/sounds.lua
Normal file
123
audio/sounds.lua
Normal 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
11
conf.lua
Normal 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
13
data/waves.lua
Normal 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
166
game/asteroids.lua
Normal 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
72
game/bullets.lua
Normal 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
198
game/highscores.lua
Normal 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
45
game/hud.lua
Normal 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
121
game/particles.lua
Normal 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
155
game/saucers.lua
Normal 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
184
game/ship.lua
Normal 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
85
game/world.lua
Normal 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
469
main.lua
Normal 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
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
|
||||
70
rendering/palette.lua
Normal file
70
rendering/palette.lua
Normal 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
|
||||
Loading…
Add table
Reference in a new issue