oma-command/main.lua
nosignal 64ce7f0fcb Initial public release
OMA-COMMAND — Missile Command arcade clone in Love2D with Omarchy theme integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:32:07 +01:00

567 lines
18 KiB
Lua

local World = require("game.world")
local Crosshair = require("game.crosshair")
local Cities = require("game.cities")
local Batteries = require("game.batteries")
local Explosions = require("game.explosions")
local ABM = require("game.abm")
local Missiles = require("game.missiles")
local Fliers = require("game.fliers")
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")
-- Wave transition / tally state
local tally = {
phase = "none", -- "missiles", "cities", "bonus", "done"
timer = 0,
tickTimer = 0,
missilesLeft = 0,
missilesCounted = 0,
citiesLeft = 0,
citiesCounted = 0,
multiplier = 1,
bonusCityEarned = false,
bonusCityDeployed = 0,
doneTimer = 0,
scoreAtStart = 0,
bonusCitiesBefore = 0,
}
local TICK_INTERVAL = 0.12
local DONE_PAUSE = 1.2
local function resetTally()
tally.phase = "none"
tally.timer = 0
tally.tickTimer = 0
tally.missilesLeft = 0
tally.missilesCounted = 0
tally.citiesLeft = 0
tally.citiesCounted = 0
tally.multiplier = 1
tally.bonusCityEarned = false
tally.bonusCityDeployed = 0
tally.doneTimer = 0
tally.scoreAtStart = 0
tally.bonusCitiesBefore = 0
end
local function startGame()
World.state = "playing"
World.wave = 1
World.score = 0
World.gameOverTimer = 0
World.bonusCities = 0
World.nextBonusAt = 10000
Cities.init()
Batteries.init()
Explosions.clear()
ABM.clear()
Fliers.init()
Cities.resetWaveCount()
Missiles.spawnWave(1)
Sounds.play("wave_start")
end
local function startNextWave()
World.wave = World.wave + 1
Batteries.rearm()
Explosions.clear()
ABM.clear()
Fliers.clear()
Cities.resetWaveCount()
Missiles.spawnWave(World.wave)
Sounds.play("wave_start")
end
local function beginTally()
resetTally()
local config = Waves.get(World.wave)
tally.multiplier = config.multiplier
tally.scoreAtStart = World.score
tally.bonusCitiesBefore = World.bonusCities
local totalAmmo = 0
for i = 1, 3 do
local b = Batteries.get(i)
if b then totalAmmo = totalAmmo + b.ammo end
end
tally.missilesLeft = totalAmmo
tally.citiesLeft = Cities.aliveCount()
tally.phase = "missiles"
tally.timer = 0
tally.tickTimer = 0
end
local function drawGrid()
local p = Palette.get(World.wave)
local lw = 1 / World.scale
love.graphics.setColor(p.ground[1], p.ground[2], p.ground[3], 0.8)
love.graphics.setLineWidth(lw * 2)
love.graphics.line(0, World.GROUND_Y, 256, World.GROUND_Y)
love.graphics.setColor(p.grid[1], p.grid[2], p.grid[3], 0.3)
love.graphics.setLineWidth(lw)
local numLines = 5
for i = 1, numLines do
local y = World.GROUND_Y + i * 3.5
local alpha = 0.3 * (1 - i / (numLines + 1))
love.graphics.setColor(p.grid[1], p.grid[2], p.grid[3], alpha)
love.graphics.line(0, y, 256, y)
end
local vanishX = 128
local vanishY = World.GROUND_Y
local bottomY = 231
local numVerts = 12
love.graphics.setColor(p.grid[1], p.grid[2], p.grid[3], 0.2)
for i = 0, numVerts do
local baseX = (i / numVerts) * 256
love.graphics.line(vanishX, vanishY, baseX, bottomY)
end
end
local function drawScanlines()
local top = World.visibleTop()
love.graphics.setColor(0, 0, 0, 0.06)
local lw = 0.5 / World.scale
love.graphics.setLineWidth(lw)
for y = top, World.GAME_H, 1.5 do
love.graphics.line(0, y, 256, y)
end
end
local function drawGameWorld()
love.graphics.push()
love.graphics.translate(World.offsetX, World.offsetY)
love.graphics.scale(World.scale)
local p = Palette.get(World.wave)
local top = World.visibleTop()
love.graphics.setColor(p.sky)
love.graphics.rectangle("fill", 0, top, World.GAME_W, World.visibleH)
drawGrid()
drawScanlines()
Missiles.draw()
Fliers.draw()
ABM.draw()
Explosions.draw()
Cities.draw()
Batteries.draw()
Crosshair.draw()
HUD.draw()
love.graphics.pop()
end
local function drawTitleScreen()
local p = Palette.get(1)
local sw, sh = World.screenW, World.screenH
local t = love.timer.getTime()
love.graphics.push()
love.graphics.translate(World.offsetX, World.offsetY)
love.graphics.scale(World.scale)
local top = World.visibleTop()
love.graphics.setColor(p.sky)
love.graphics.rectangle("fill", 0, top, World.GAME_W, World.visibleH)
drawGrid()
drawScanlines()
local lw = 1 / World.scale
local cx, cy = 128, World.GROUND_Y * 0.45
love.graphics.setLineWidth(lw)
for i = 1, 6 do
local r = 8 + i * 12 + math.sin(t * 0.5 + i * 0.8) * 3
local alpha = 0.15 - i * 0.02
if alpha > 0 then
love.graphics.setColor(p.ground[1], p.ground[2], p.ground[3], alpha)
love.graphics.line(cx, cy-r, cx+r, cy, cx, cy+r, cx-r, cy, cx, cy-r)
end
end
love.graphics.setLineWidth(lw)
for i = 1, 5 do
local mx = 30 + (i - 1) * 50 + math.sin(t * 0.3 + i) * 10
local my1 = top + 5
local my2 = top + 30 + math.sin(t * 0.7 + i * 2) * 15
love.graphics.setColor(p.missile[1], p.missile[2], p.missile[3], 0.15)
love.graphics.line(mx, my1, mx + (i % 2 == 0 and 5 or -5), my2)
end
love.graphics.pop()
local centerY = sh * 0.28
love.graphics.setFont(Fonts.large)
love.graphics.setColor(p.bright)
love.graphics.printf("OMA-COMMAND", 0, centerY, sw, "center")
love.graphics.setFont(Fonts.small)
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.6)
love.graphics.printf("MISSILE DEFENCE", 0, centerY + Fonts.large:getHeight() + 4, sw, "center")
-- High score from persistent table
local highest = HighScores.getHighest()
if highest > 0 then
love.graphics.setFont(Fonts.small)
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.5)
love.graphics.printf("HIGH SCORE: " .. string.format("%06d", highest), 0, centerY + Fonts.large:getHeight() + 4 + Fonts.small:getHeight() + 12, sw, "center")
end
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.62, sw, "center")
love.graphics.setColor(p.ground[1], p.ground[2], p.ground[3], 0.2)
love.graphics.setLineWidth(1)
local bx = sw * 0.15
local by1 = sh * 0.22
local by2 = sh * 0.72
love.graphics.line(bx, by1, sw - bx, by1)
love.graphics.line(bx, by2, sw - bx, by2)
local tickLen = sw * 0.03
love.graphics.line(bx, by1, bx, by1 + tickLen)
love.graphics.line(sw - bx, by1, sw - bx, by1 + tickLen)
love.graphics.line(bx, by2, bx, by2 - tickLen)
love.graphics.line(sw - bx, by2, sw - bx, by2 - tickLen)
-- Show high score table if scores exist
local allScores = HighScores.getScores()
if #allScores > 0 then
HighScores.drawTable(sw, sh, p, Fonts)
end
end
local function drawTallyScreen()
local p = Palette.get(World.wave)
local sw, sh = World.screenW, World.screenH
local t = love.timer.getTime()
local baseY = sh * 0.2
local lineH = Fonts.medium and Fonts.medium:getHeight() + 6 or 24
local smallH = Fonts.small and Fonts.small:getHeight() + 4 or 16
love.graphics.setFont(Fonts.medium)
love.graphics.setColor(p.bright)
love.graphics.printf("BONUS POINTS", 0, baseY, sw, "center")
love.graphics.setFont(Fonts.small)
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.7)
love.graphics.printf(tally.multiplier .. "x", 0, baseY + lineH, sw, "center")
local infoY = baseY + lineH * 2 + 8
love.graphics.setFont(Fonts.medium)
love.graphics.setColor(p.fg)
local missileTotal = tally.missilesCounted * 5 * tally.multiplier
love.graphics.printf(
"MISSILES: " .. tally.missilesCounted .. " x 5 = " .. missileTotal,
0, infoY, sw, "center"
)
if tally.phase == "cities" or tally.phase == "bonus" or tally.phase == "done" then
local cityTotal = tally.citiesCounted * 100 * tally.multiplier
love.graphics.printf(
"CITIES: " .. tally.citiesCounted .. " x 100 = " .. cityTotal,
0, infoY + lineH, sw, "center"
)
end
if tally.phase == "bonus" or tally.phase == "done" then
if tally.bonusCityEarned then
local pulse = 0.5 + math.sin(t * 6) * 0.5
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse)
love.graphics.setFont(Fonts.small)
love.graphics.printf("+ BONUS CITY", 0, infoY + lineH * 2 + 4, sw, "center")
end
if tally.bonusCityDeployed > 0 then
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.8)
love.graphics.setFont(Fonts.small)
local deployText = tally.bonusCityDeployed == 1
and "1 CITY REBUILT"
or (tally.bonusCityDeployed .. " CITIES REBUILT")
love.graphics.printf(deployText, 0, infoY + lineH * 2 + 4 + smallH, sw, "center")
end
end
love.graphics.setFont(Fonts.medium)
love.graphics.setColor(p.bright)
love.graphics.printf(string.format("SCORE: %06d", World.score), 0, sh * 0.72, sw, "center")
end
function love.load()
love.mouse.setVisible(false)
love.graphics.setBackgroundColor(0, 0, 0)
love.graphics.setLineStyle("smooth")
Palette.loadFromSystem()
World.resize(love.graphics.getDimensions())
Fonts.init(World.scale)
HighScores.init()
Sounds.init()
World.highScore = HighScores.getHighest()
World.state = "title"
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
return
end
if World.state == "playing" then
Crosshair.update()
Crosshair.updateTargets(dt)
ABM.update(dt)
Missiles.update(dt)
Fliers.update(dt)
Explosions.update(dt)
if Missiles.allDone() and Fliers.allDone() and ABM.count() == 0 and not Explosions.anyActive() then
if Cities.allDestroyed() and World.bonusCities == 0 then
World.state = "game_over"
World.gameOverTimer = 0
Sounds.play("game_over")
if World.score > World.highScore then
World.highScore = World.score
end
Explosions.clear()
ABM.clear()
Missiles.clear()
Fliers.clear()
return
end
World.state = "wave_end"
beginTally()
end
elseif World.state == "wave_end" then
tally.timer = tally.timer + dt
if tally.phase == "missiles" then
tally.tickTimer = tally.tickTimer + dt
if tally.tickTimer >= TICK_INTERVAL then
tally.tickTimer = tally.tickTimer - TICK_INTERVAL
if tally.missilesCounted < tally.missilesLeft then
tally.missilesCounted = tally.missilesCounted + 1
World.addScoreRaw(5 * tally.multiplier)
Sounds.play("bonus_tick")
else
tally.phase = "cities"
tally.tickTimer = 0
end
end
elseif tally.phase == "cities" then
tally.tickTimer = tally.tickTimer + dt
if tally.tickTimer >= TICK_INTERVAL * 2 then
tally.tickTimer = tally.tickTimer - TICK_INTERVAL * 2
if tally.citiesCounted < tally.citiesLeft then
tally.citiesCounted = tally.citiesCounted + 1
World.addScoreRaw(100 * tally.multiplier)
Sounds.play("bonus_tick")
else
tally.bonusCityEarned = (World.bonusCities > tally.bonusCitiesBefore)
tally.phase = "bonus"
tally.tickTimer = 0
if Cities.destroyedCount() > 0 and World.bonusCities > 0 then
tally.bonusCityDeployed = Cities.deployBonusCities()
end
end
end
elseif tally.phase == "bonus" then
tally.tickTimer = tally.tickTimer + dt
if tally.tickTimer >= 1.0 then
tally.phase = "done"
tally.doneTimer = 0
end
elseif tally.phase == "done" then
tally.doneTimer = tally.doneTimer + dt
if tally.doneTimer >= DONE_PAUSE then
World.state = "playing"
startNextWave()
end
end
elseif World.state == "game_over" then
World.gameOverTimer = World.gameOverTimer + dt
Explosions.update(dt)
elseif World.state == "high_score_entry" then
HighScores.updateEntry(dt)
end
end
function love.draw()
love.graphics.clear(0, 0, 0)
local p = Palette.get(World.wave)
local sw, sh = World.screenW, World.screenH
if World.state == "title" then
drawTitleScreen()
return
end
if World.state == "high_score_entry" then
-- Background
love.graphics.push()
love.graphics.translate(World.offsetX, World.offsetY)
love.graphics.scale(World.scale)
local top = World.visibleTop()
love.graphics.setColor(p.sky)
love.graphics.rectangle("fill", 0, top, World.GAME_W, World.visibleH)
drawGrid()
drawScanlines()
love.graphics.pop()
HighScores.drawEntry(sw, sh, p, Fonts)
return
end
drawGameWorld()
local midY = sh * 0.4
if World.state == "wave_end" then
love.graphics.setColor(0, 0, 0, 0.5)
love.graphics.rectangle("fill", 0, 0, sw, sh)
drawTallyScreen()
end
if World.state == "game_over" then
local t = love.timer.getTime()
love.graphics.push()
love.graphics.translate(World.offsetX, World.offsetY)
love.graphics.scale(World.scale)
local lw = 1 / World.scale
local cx, cy = 128, World.visibleTop() + World.visibleH * 0.4
local numRings = math.min(math.floor(World.gameOverTimer * 4) + 1, 8)
for i = 1, numRings do
local r = (World.gameOverTimer * 25 - i * 5)
if r > 0 and r < 100 then
local alpha = math.max(0, 1 - r / 100)
local pulse = 0.5 + math.sin(t * 10 + i) * 0.3
love.graphics.setColor(p.exp1[1], p.exp1[2], p.exp1[3], alpha * pulse)
love.graphics.setLineWidth(lw * 2)
love.graphics.circle("line", cx, cy, r, 24 + i * 4)
end
end
love.graphics.pop()
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("THE END", 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.mousepressed(x, y, button)
if button == 1 and World.state == "playing" then
local gx, gy = World.toGame(x, y)
local bat = Batteries.findNearest(gx, gy)
if bat and ABM.count() < 8 then
if Batteries.fire(bat.index) then
ABM.fire(bat.x, bat.y, gx, gy, bat.speed)
Sounds.play("launch")
end
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, data = HighScores.keypressedEntry(key)
if result == "done" then
World.highScore = HighScores.getHighest()
World.state = "title"
end
return
end
if World.state == "playing" then
local batIndex = nil
if key == "a" or key == "1" then batIndex = 1
elseif key == "s" or key == "2" then batIndex = 2
elseif key == "d" or key == "3" then batIndex = 3
end
if batIndex and ABM.count() < 8 then
local bat = Batteries.get(batIndex)
if bat and bat.alive and bat.ammo > 0 then
if Batteries.fire(batIndex) then
ABM.fire(bat.x, bat.y, Crosshair.x, Crosshair.y, bat.speed)
Sounds.play("launch")
end
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"
end
return
end
if key == "escape" then
World.state = "title"
return
end
end
if key == "escape" then
love.event.quit()
end
end