oma-roids/main.lua
28allday bc88613e07 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>
2026-04-13 12:58:03 +01:00

469 lines
14 KiB
Lua

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