OMA-COMMAND — Missile Command arcade clone in Love2D with Omarchy theme integration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
567 lines
18 KiB
Lua
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
|