commit 64ce7f0fcbf69f4bd067e241afd507b39b5db599 Author: nosignal Date: Wed Apr 15 18:32:07 2026 +0100 Initial public release OMA-COMMAND — Missile Command arcade clone in Love2D with Omarchy theme integration. Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69a3206 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +ABM-CLAUDE.md +CLAUDE.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..39919f2 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# OMA-COMMAND + +A faithful Missile Command arcade clone built with Love2D for [Omarchy](https://omarchy.org/) Linux. + +Vector-rendered cities and trails with bloom, flashing explosion palettes, and Omarchy theme integration — the game adopts your desktop colours. + +## Install + +```bash +curl -sL https://git.no-signal.uk/nosignal/oma-command/raw/branch/master/install.sh | bash +``` + +This will: +- Install Love2D if not present +- Clone the game to `~/.local/share/oma-command/` +- Add an icon and launcher entry to your app menu +- Refresh the app launcher + +Search **OMA-COMMAND** in your app launcher to play. + +## Uninstall + +```bash +oma-command-uninstall +``` + +## Controls + +| Input | Action | +|-------|--------| +| **Mouse** | Move crosshair | +| **Left click** | Fire from nearest battery with ammo | +| **A** / **1** | Fire from Alpha (left) battery | +| **S** / **2** | Fire from Delta (centre, fast) battery | +| **D** / **3** | Fire from Omega (right) battery | +| **Enter** | Start game / dismiss screens | +| **Escape** | Pause / quit | +| **F1** | Toggle CRT effect | +| **F11** | Toggle fullscreen | + +## Gameplay + +- Three missile batteries defend six cities against incoming ICBMs +- Delta (centre) ABMs fly fast; Alpha and Omega are slower but still precise +- Missiles split into MIRVs mid-flight; smart bombs dodge your explosions +- Bombers appear from wave 2, satellites from wave 4, killer satellites from wave 8 +- Chain explosions by detonating ABMs near incoming warheads +- Maximum 3 cities can be lost per wave +- Bonus city every 10,000 points +- Persistent high scores with 3-letter initials + +## Scoring + +| Target | Base Points | +|--------|-------------| +| ICBM | 25 | +| Smart bomb | 125 | +| Bomber | 100 | +| Satellite | 100 | +| Killer satellite | 150 | +| Unused ABM (end of wave) | 5 × multiplier | +| Surviving city (end of wave) | 100 × multiplier | + +Wave score multiplier: ×1 (waves 1–2), ×2 (3–4), ×3 (5–6), ×4 (7–8), ×5 (9–10), ×6 (11+). + +## Omarchy Integration + +- **Theme colours** auto-detected from your active Omarchy theme +- **System font** detected from your Waybar config +- **Full-screen** via SUPER+F (Hyprland compositor) +- Switch themes with `omarchy-theme-set` and relaunch — the game adapts + +## Requirements + +- [Omarchy](https://omarchy.org/) Linux (or any Arch with Love2D) +- Love2D (`sudo pacman -S love`) + +## Run from source + +```bash +git clone https://git.no-signal.uk/nosignal/oma-command.git +cd oma-command +love . +``` + +## License + +MIT diff --git a/audio/sounds.lua b/audio/sounds.lua new file mode 100644 index 0000000..dd08e76 --- /dev/null +++ b/audio/sounds.lua @@ -0,0 +1,128 @@ +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 -- time in seconds + local p = i / samples -- progress 0..1 + local val = generator(t, p) + val = math.max(-1, math.min(1, val)) + sd:setSample(i, val) + end + return sd +end + +local function makeSource(soundData) + return love.audio.newSource(soundData, "static") +end + +-- ── Sound Generators ── + +local function genLaunch(t, p) + -- Ascending frequency sweep, sine wave + local freq = 300 + p * 1200 + local env = 1 - p -- fade out + return math.sin(2 * math.pi * freq * t) * env * 0.4 +end + +local function genExplosion(t, p) + -- Low sine + noise burst with decay + local env = (1 - p) ^ 2 + local sine = math.sin(2 * math.pi * (60 + 40 * (1 - p)) * t) * 0.5 + local noise = (math.random() * 2 - 1) * 0.5 + return (sine + noise) * env * 0.5 +end + +local function genImpact(t, p) + -- Sharp noise burst + local env = (1 - p) ^ 4 + local noise = (math.random() * 2 - 1) + return noise * env * 0.6 +end + +local function genCityDestroyed(t, p) + -- Low rumble: noise + low sine, longer decay + local env = (1 - p) ^ 1.5 + local sine = math.sin(2 * math.pi * (40 + 20 * (1 - p)) * t) * 0.6 + local noise = (math.random() * 2 - 1) * 0.4 + -- Add a sub-bass throb + local throb = math.sin(2 * math.pi * 25 * t) * 0.3 * env + return (sine + noise + throb) * env * 0.5 +end + +local function genMirvSplit(t, p) + -- Quick high chirp + local freq = 2000 - p * 800 + local env = (1 - p) ^ 2 + return math.sin(2 * math.pi * freq * t) * env * 0.35 +end + +local function genWaveStart(t, p) + -- Two-tone beep + local freq + if p < 0.5 then + freq = 800 + else + freq = 1100 + end + local env = 0.8 + -- Small fade at edges + if p < 0.05 then env = p / 0.05 * 0.8 end + if p > 0.9 then env = (1 - p) / 0.1 * 0.8 end + return math.sin(2 * math.pi * freq * t) * env * 0.3 +end + +local function genBonusTick(t, p) + -- Short click/pip + local freq = 1500 + local env = (1 - p) ^ 6 + return math.sin(2 * math.pi * freq * t) * env * 0.4 +end + +local function genGameOver(t, p) + -- Long descending sweep + noise + local freq = 600 * (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.2 * p -- noise increases + -- Add a pulsing effect + local pulse = 0.7 + math.sin(2 * math.pi * 3 * t) * 0.3 + return (sine + noise) * env * pulse * 0.5 +end + +-- ── Public API ── + +function Sounds.init() + sources = {} + + local defs = { + launch = {0.15, genLaunch}, + explosion = {0.4, genExplosion}, + impact = {0.1, genImpact}, + city_destroyed = {0.6, genCityDestroyed}, + mirv_split = {0.08, genMirvSplit}, + wave_start = {0.3, genWaveStart}, + bonus_tick = {0.05, genBonusTick}, + game_over = {1.5, genGameOver}, + } + + for name, def in pairs(defs) do + local duration, generator = def[1], def[2] + local sd = makeSoundData(duration, generator) + sources[name] = makeSource(sd) + end +end + +function Sounds.play(name) + local src = sources[name] + if not src then return end + -- Clone to allow overlapping playback + local clone = src:clone() + clone:play() +end + +return Sounds diff --git a/conf.lua b/conf.lua new file mode 100644 index 0000000..dc2d34b --- /dev/null +++ b/conf.lua @@ -0,0 +1,11 @@ +function love.conf(t) + t.window.title = "OMA-COMMAND" + t.window.width = 1024 + t.window.height = 924 + t.window.resizable = true + t.window.vsync = 1 + t.window.fullscreen = false + t.window.fullscreentype = "desktop" + t.window.minwidth = 512 + t.window.minheight = 462 +end diff --git a/data/waves.lua b/data/waves.lua new file mode 100644 index 0000000..df258d6 --- /dev/null +++ b/data/waves.lua @@ -0,0 +1,50 @@ +local Waves = {} + +-- Returns wave config for a given wave number +-- Tuned for a gentler ramp: wave 1-3 should feel learnable, +-- real challenge starts around wave 6-8, brutal by wave 15+ +function Waves.get(wave) + -- Missiles: gentler ramp — start low, add ~1/wave, cap at 26 + local missile_count = math.min(7 + wave, 26) + missile_count = math.floor(missile_count) + + -- Speed: very slow early, gentler ramp, caps at 50 + local missile_speed + if wave <= 4 then + missile_speed = 6 + wave * 1.5 -- 7.5, 9, 10.5, 12 + elseif wave <= 10 then + missile_speed = 12 + (wave - 4) * 2.5 -- 14.5..27 + elseif wave <= 18 then + missile_speed = 27 + (wave - 10) * 2.8 -- 29.8..49.4 + else + missile_speed = 50 + end + + -- Spawn interval: generous early, tighter later + local spawn_interval + if wave <= 4 then + spawn_interval = 2.2 - wave * 0.12 -- 2.08, 1.96, 1.84, 1.72 + elseif wave <= 10 then + spawn_interval = 1.7 - (wave - 4) * 0.12 -- 1.58..0.98 + else + spawn_interval = math.max(1.0 - (wave - 10) * 0.04, 0.5) + end + + local multiplier + if wave <= 2 then multiplier = 1 + elseif wave <= 4 then multiplier = 2 + elseif wave <= 6 then multiplier = 3 + elseif wave <= 8 then multiplier = 4 + elseif wave <= 10 then multiplier = 5 + else multiplier = 6 + end + + return { + missile_count = missile_count, + missile_speed = missile_speed, + spawn_interval = spawn_interval, + multiplier = multiplier, + } +end + +return Waves diff --git a/game/abm.lua b/game/abm.lua new file mode 100644 index 0000000..2393071 --- /dev/null +++ b/game/abm.lua @@ -0,0 +1,87 @@ +local World = require("game.world") +local Palette = require("rendering.palette") +local Explosions = require("game.explosions") +local Crosshair = require("game.crosshair") + +local ABM = {} + +local missiles = {} +local MAX_IN_FLIGHT = 8 + +function ABM.fire(startX, startY, targetX, targetY, speed) + if #missiles >= MAX_IN_FLIGHT then return false end + + local dx = targetX - startX + local dy = targetY - startY + local dist = math.sqrt(dx * dx + dy * dy) + if dist < 1 then return false end + + table.insert(missiles, { + startX = startX, + startY = startY, + targetX = targetX, + targetY = targetY, + x = startX, + y = startY, + speed = speed, + dirX = dx / dist, + dirY = dy / dist, + totalDist = dist, + birth = love.timer.getTime(), + }) + + Crosshair.addTarget(targetX, targetY) + return true +end + +function ABM.update(dt) + for i = #missiles, 1, -1 do + local m = missiles[i] + local step = m.speed * dt + m.x = m.x + m.dirX * step + m.y = m.y + m.dirY * step + + local dx = m.x - m.startX + local dy = m.y - m.startY + local traveled = math.sqrt(dx * dx + dy * dy) + + if traveled >= m.totalDist then + m.x = m.targetX + m.y = m.targetY + Explosions.add(m.targetX, m.targetY) + Crosshair.removeTarget(m.targetX, m.targetY) + table.remove(missiles, i) + end + end +end + +function ABM.count() + return #missiles +end + +function ABM.clear() + missiles = {} +end + +function ABM.draw() + local p = Palette.get(World.wave) + local lw = 1 / World.scale + + for _, m in ipairs(missiles) do + -- Glow trail + love.graphics.setColor(p.abm[1], p.abm[2], p.abm[3], 0.2) + love.graphics.setLineWidth(lw * 4) + love.graphics.line(m.startX, m.startY, m.x, m.y) + + -- Main vector trail + love.graphics.setColor(p.abm[1], p.abm[2], p.abm[3], 0.9) + love.graphics.setLineWidth(lw * 1.5) + love.graphics.line(m.startX, m.startY, m.x, m.y) + + -- Bright head dot + love.graphics.setColor(p.bright) + love.graphics.circle("line", m.x, m.y, 1, 8) + end +end + +return ABM diff --git a/game/batteries.lua b/game/batteries.lua new file mode 100644 index 0000000..192cd62 --- /dev/null +++ b/game/batteries.lua @@ -0,0 +1,232 @@ +local World = require("game.world") +local Palette = require("rendering.palette") + +local Batteries = {} + +local batteries = {} + +local DEFS = { + { name = "Alpha", x = 18, y = World.GROUND_Y + 6, speed = 180, keys = {"a", "1"} }, + { name = "Delta", x = 128, y = World.GROUND_Y + 6, speed = 420, keys = {"s", "2"} }, + { name = "Omega", x = 238, y = World.GROUND_Y + 6, speed = 180, keys = {"d", "3"} }, +} + +function Batteries.init() + batteries = {} + for i, def in ipairs(DEFS) do + batteries[i] = { + name = def.name, + x = def.x, + y = def.y, + speed = def.speed, + ammo = 10, + alive = true, + index = i, + } + end +end + +function Batteries.rearm() + for _, b in ipairs(batteries) do + b.ammo = 10 + b.alive = true + end +end + +function Batteries.get(index) + return batteries[index] +end + +function Batteries.getAll() + return batteries +end + +function Batteries.fire(index) + local b = batteries[index] + if b and b.alive and b.ammo > 0 then + b.ammo = b.ammo - 1 + return true + end + return false +end + +function Batteries.findNearest(gx, gy) + local delta = batteries[2] + if delta.alive and delta.ammo > 0 then + return delta + end + local best = nil + local bestDist = math.huge + for _, i in ipairs({1, 3}) do + local b = batteries[i] + if b.alive and b.ammo > 0 then + local dist = math.abs(b.x - gx) + if dist < bestDist then + bestDist = dist + best = b + end + end + end + return best +end + +function Batteries.destroy(index) + if batteries[index] then + batteries[index].alive = false + batteries[index].ammo = 0 + end +end + +function Batteries.getTargets() + local targets = {} + for _, b in ipairs(batteries) do + if b.alive then + table.insert(targets, {x = b.x, y = b.y, type = "battery", index = b.index}) + end + end + return targets +end + +function Batteries.draw() + local p = Palette.get(World.wave) + local lw = 1 / World.scale + local t = love.timer.getTime() + + for _, b in ipairs(batteries) do + local x, y = b.x, b.y + if b.alive then + -- Tilt launcher inward toward screen centre + local tilt + if b.index == 1 then tilt = 0.35 + elseif b.index == 3 then tilt = -0.35 + else tilt = 0 end + local ca, sa = math.cos(tilt), math.sin(tilt) + + -- Cab-facing direction: Alpha (left battery) faces right, Omega faces left, Delta faces right by default + local dir + if b.index == 1 then dir = 1 + elseif b.index == 3 then dir = -1 + else dir = 1 end + + -- === TRUCK CHASSIS === + love.graphics.setColor(p.ground[1], p.ground[2], p.ground[3], 0.9) + love.graphics.setLineWidth(lw * 1.5) + -- Flatbed: long rectangle + love.graphics.line(x-10, y-1.5, x+10, y-1.5) + love.graphics.line(x-10, y, x+10, y) + love.graphics.line(x-10, y-1.5, x-10, y) + love.graphics.line(x+10, y-1.5, x+10, y) + + -- Cab at front (in direction dir) + local cabX = x + dir * 7 + local cabFront = x + dir * 10 + local cabBack = x + dir * 4 + love.graphics.line(cabBack, y-1.5, cabBack, y-5) -- cab back wall + love.graphics.line(cabBack, y-5, cabBack + dir*2, y-5.5) -- roof slope back + love.graphics.line(cabBack + dir*2, y-5.5, cabFront - dir*0.5, y-5.5) -- roof + love.graphics.line(cabFront - dir*0.5, y-5.5, cabFront, y-3) -- windshield slope + love.graphics.line(cabFront, y-3, cabFront, y-1.5) -- cab front + + -- Window + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.5) + love.graphics.setLineWidth(lw) + love.graphics.line(cabFront - dir*0.3, y-3.2, cabFront - dir*1.6, y-5) + + -- Wheels (4, spaced along chassis) + love.graphics.setColor(p.ground[1], p.ground[2], p.ground[3], 0.9) + love.graphics.setLineWidth(lw) + love.graphics.circle("line", x-7, y+0.6, 1.3, 10) + love.graphics.circle("line", x-3, y+0.6, 1.3, 10) + love.graphics.circle("line", x+3, y+0.6, 1.3, 10) + love.graphics.circle("line", x+7, y+0.6, 1.3, 10) + + -- === TURRET mounted on flatbed (offset away from cab) === + local turretX = x - dir * 3 + love.graphics.setColor(p.ground[1], p.ground[2], p.ground[3], 0.75) + love.graphics.setLineWidth(lw * 1.3) + love.graphics.line(turretX-3, y-1.5, turretX-3, y-4, turretX+3, y-4, turretX+3, y-1.5) + + -- === LAUNCH RACK: 3 angled tubes with missiles loaded === + -- Tube length, pivot at (x, y-4), rotated by tilt + local pivotY = y - 4 + local tubeLen = 9 + local function rot(dx, dy) + return turretX + dx*ca - dy*sa, pivotY + dx*sa + dy*ca + end + + love.graphics.setLineWidth(lw * 1.3) + for i = -1, 1 do + local offX = i * 2.2 + -- Tube walls + love.graphics.setColor(p.ground[1], p.ground[2], p.ground[3], 0.9) + local wx1, wy1 = rot(offX - 0.8, 0) + local wx2, wy2 = rot(offX - 0.8, -tubeLen) + local wx3, wy3 = rot(offX + 0.8, 0) + local wx4, wy4 = rot(offX + 0.8, -tubeLen) + love.graphics.line(wx1, wy1, wx2, wy2) + love.graphics.line(wx3, wy3, wx4, wy4) + love.graphics.line(wx2, wy2, wx4, wy4) -- tube mouth + + -- Missile inside tube (only if ammo remains; stagger by index) + local loaded = b.ammo >= (i + 2) -- shows 1..3 missiles as ammo fills up + if loaded then + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.95) + love.graphics.setLineWidth(lw * 1.1) + -- Missile body + local bx1, by1 = rot(offX, -0.5) + local bx2, by2 = rot(offX, -tubeLen + 1) + love.graphics.line(bx1, by1, bx2, by2) + -- Nose cone + local nxL, nyL = rot(offX - 0.7, -tubeLen + 1) + local nxR, nyR = rot(offX + 0.7, -tubeLen + 1) + local ntx, nty = rot(offX, -tubeLen - 1.2) + love.graphics.line(nxL, nyL, ntx, nty) + love.graphics.line(nxR, nyR, ntx, nty) + love.graphics.line(nxL, nyL, nxR, nyR) + love.graphics.setLineWidth(lw * 1.3) + end + end + + -- === RESERVE AMMO: small warheads tucked under chassis === + local reserve = math.max(0, b.ammo - 3) + love.graphics.setLineWidth(lw) + for a = 1, reserve do + local ax = x - 9 + ((a - 1) % 7) * 2.6 + local ay = y - 0.8 + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.7) + love.graphics.line(ax, ay, ax, ay-1.6) + love.graphics.line(ax-0.4, ay-1.6, ax+0.4, ay-1.6) + love.graphics.line(ax-0.4, ay-1.6, ax, ay-2.2) + love.graphics.line(ax+0.4, ay-1.6, ax, ay-2.2) + end + + -- === STATUS LIGHT === + if math.sin(t * 4) > 0.3 then + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.7) + love.graphics.circle("fill", x + 8.5, y - 2.8, 0.5, 4) + end + + else + -- === DESTROYED SILO === + love.graphics.setColor(p.dim[1], p.dim[2], p.dim[3], 0.3) + love.graphics.setLineWidth(lw) + + -- Cracked outer hull + love.graphics.line(x-11, y, x-9, y-3, x-7, y-2, x-5, y-4) + love.graphics.line(x+5, y-3, x+7, y-1, x+9, y-3, x+11, y) + love.graphics.line(x-12, y, x+12, y) + + -- Rubble and collapsed structure + love.graphics.setColor(p.dim[1], p.dim[2], p.dim[3], 0.2) + love.graphics.line(x-4, y, x-3, y-3, x-1, y-1, x+1, y-4, x+3, y-2, x+4, y) + -- Bent launcher tube sticking out at angle + love.graphics.line(x-1, y-2, x+2, y-6, x+3, y-5.5) + love.graphics.line(x, y-2, x+3, y-7) + -- Scattered debris + love.graphics.line(x-6, y-1, x-5, y-2.5) + love.graphics.line(x+6, y-1, x+7, y-2) + end + end +end + +return Batteries diff --git a/game/cities.lua b/game/cities.lua new file mode 100644 index 0000000..2051b63 --- /dev/null +++ b/game/cities.lua @@ -0,0 +1,265 @@ +local World = require("game.world") +local Palette = require("rendering.palette") + +local Cities = {} + +Cities.destroyedThisWave = 0 + +-- City outlines: each city has a main profile polyline plus detail lines +-- {outline = {x,y,...}, details = {{x1,y1,x2,y2}, ...}} +-- All coords relative to city centre bottom + +local SHAPES = { + -- 1: Office tower cluster — tall centre, two flanking blocks + { + outline = {-7,0, -7,-6, -5,-6, -5,-8, -3,-8, -3,-4, -1,-4, -1,-14, 1,-14, 1,-4, 3,-4, 3,-7, 5,-7, 5,-10, 7,-10, 7,0}, + details = { + {-1,-14, 0,-16, 1,-14}, -- antenna spire + {-6,-2, -6,-5}, -- window column left + {-5.5,-2, -5.5,-5}, + {0,-5, 0,-12}, -- centre line + {6,-2, 6,-9}, -- window column right + {5.5,-2, 5.5,-9}, + }, + }, + -- 2: Twin towers with skybridge + { + outline = {-7,0, -7,-12, -4,-12, -4,-4, -2,-4, -2,-11, 2,-11, 2,-4, 4,-4, 4,-13, 7,-13, 7,0}, + details = { + {-4,-8, -2,-8}, -- skybridge + {-6,-3, -6,-11}, -- left window col + {-5,-3, -5,-11}, + {5,-3, 5,-12}, -- right window col + {6,-3, 6,-12}, + {4,-13, 5,-15, 6,-15, 7,-13}, -- right tower top detail + }, + }, + -- 3: Stepped pyramid / ziggurat + { + outline = {-8,0, -8,-4, -6,-4, -6,-7, -4,-7, -4,-10, -2,-10, -2,-13, 2,-13, 2,-10, 4,-10, 4,-7, 6,-7, 6,-4, 8,-4, 8,0}, + details = { + {-1,-13, 0,-15, 1,-13}, -- antenna + {-7,-1, -7,-3}, -- tier details + {-5,-5, -5,-6}, + {-3,-8, -3,-9}, + {3,-8, 3,-9}, + {5,-5, 5,-6}, + {7,-1, 7,-3}, + }, + }, + -- 4: Gothic spire with flying buttresses + { + outline = {-6,0, -6,-5, -4,-5, -4,-8, -2,-8, -2,-11, -1,-14, 0,-17, 1,-14, 2,-11, 2,-8, 4,-8, 4,-5, 6,-5, 6,0}, + details = { + {-6,-5, -4,-8}, -- left buttress + {6,-5, 4,-8}, -- right buttress + {-1,-8, -1,-11}, -- internal frame + {1,-8, 1,-11}, + {-0.5,-11, -0.5,-14}, -- upper spire frame + {0.5,-11, 0.5,-14}, + {-3,-3, -3,-7}, -- window columns + {3,-3, 3,-7}, + }, + }, + -- 5: Industrial complex — wide, boxy, with chimney + { + outline = {-8,0, -8,-6, -6,-6, -6,-8, -4,-8, -4,-6, -1,-6, -1,-10, 1,-10, 1,-6, 3,-6, 3,-5, 5,-5, 5,-8, 6,-8, 6,-12, 7,-12, 7,-5, 8,-5, 8,0}, + details = { + {6,-12, 6.5,-14, 7,-12}, -- chimney smoke wisp + {-7,-2, -7,-5}, -- windows + {-5,-2, -5,-5}, + {0,-7, 0,-9}, -- centre structure + {-3,-2, -3,-5}, + {4,-2, 4,-4}, + }, + }, + -- 6: Domed building with towers + { + outline = {-7,0, -7,-8, -6,-8, -6,-10, -5,-10, -5,-8, -3,-8, -3,-9, -2,-11, 0,-12, 2,-11, 3,-9, 3,-8, 5,-8, 5,-11, 6,-11, 6,-8, 7,-8, 7,0}, + details = { + {-2,-11, 0,-12, 2,-11}, -- dome highlight + {0,-12, 0,-14}, -- dome antenna + {-6,-8, -6,-10}, -- left tower internal + {5,-8, 5,-11}, -- right tower internal + {-5.5,-10, -5.5,-10.5, -6.5,-10.5, -6.5,-10}, -- left tower cap + {5,-11, 5,-11.5, 6,-11.5, 6,-11}, -- right tower cap + {-2,-3, -2,-7}, -- window columns + {2,-3, 2,-7}, + }, + }, +} + +-- Rubble: broken jagged debris wireframe +local RUBBLE = { + outline = {-6,0, -5,-2, -4,-1, -3,-3, -1,-1, 0,-2.5, 1,-1, 3,-3, 4,-1.5, 5,-2, 6,0}, + details = { + {-4,-1, -4.5,-3, -3,-2}, + {1,-1, 0.5,-3, 2,-2}, + {-2,-0.5, -1.5,-2}, + {3,-0.5, 3.5,-2.5}, + }, +} + +local cities = {} +local POSITIONS = { 40, 68, 96, 160, 188, 216 } + +function Cities.init() + cities = {} + Cities.destroyedThisWave = 0 + for i = 1, 6 do + cities[i] = { + x = POSITIONS[i], + y = World.GROUND_Y, + alive = true, + index = i, + } + end +end + +function Cities.resetWaveCount() + Cities.destroyedThisWave = 0 +end + +function Cities.get() + return cities +end + +function Cities.destroy(index) + if cities[index] and cities[index].alive then + if Cities.destroyedThisWave >= 3 then + -- Max 3 cities destroyed per wave - city survives + return false + end + cities[index].alive = false + Cities.destroyedThisWave = Cities.destroyedThisWave + 1 + return true + end + return false +end + +function Cities.allDestroyed() + for _, c in ipairs(cities) do + if c.alive then return false end + end + return true +end + +function Cities.aliveCount() + local count = 0 + for _, c in ipairs(cities) do + if c.alive then count = count + 1 end + end + return count +end + +function Cities.destroyedCount() + local count = 0 + for _, c in ipairs(cities) do + if not c.alive then count = count + 1 end + end + return count +end + +function Cities.deployBonusCities() + -- Deploy reserve bonus cities to destroyed city slots + local deployed = 0 + for _, c in ipairs(cities) do + if not c.alive and World.bonusCities > 0 then + c.alive = true + World.bonusCities = World.bonusCities - 1 + deployed = deployed + 1 + end + end + return deployed +end + +function Cities.getTargets() + local targets = {} + for _, c in ipairs(cities) do + if c.alive then + table.insert(targets, {x = c.x, y = c.y, type = "city", index = c.index}) + end + end + return targets +end + +function Cities.draw() + local p = Palette.get(World.wave) + local lw = 1 / World.scale + local t = love.timer.getTime() + + for i, c in ipairs(cities) do + if c.alive then + local shape = SHAPES[i] + + -- Main building outline + love.graphics.setColor(p.cities[1], p.cities[2], p.cities[3], 0.9) + love.graphics.setLineWidth(lw * 1.5) + local pts = {} + for j = 1, #shape.outline, 2 do + table.insert(pts, c.x + shape.outline[j]) + table.insert(pts, c.y + shape.outline[j+1]) + end + if #pts >= 4 then + love.graphics.line(pts) + end + + -- Architectural details (windows, antennae, internal structure) + love.graphics.setColor(p.cities[1], p.cities[2], p.cities[3], 0.35) + love.graphics.setLineWidth(lw) + for _, d in ipairs(shape.details) do + local dpts = {} + for j = 1, #d, 2 do + table.insert(dpts, c.x + d[j]) + table.insert(dpts, c.y + d[j+1]) + end + if #dpts >= 4 then + love.graphics.line(dpts) + end + end + + -- Faint glow at base + love.graphics.setColor(p.glow[1], p.glow[2], p.glow[3], 0.12) + love.graphics.setLineWidth(lw * 5) + love.graphics.line(c.x - 8, c.y, c.x + 8, c.y) + + -- Occasional window twinkle + local twinkle = math.sin(t * 1.5 + i * 2.1) + if twinkle > 0.85 then + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.4) + local wx = c.x + math.sin(i * 7.3) * 3 + local wy = c.y - 3 - math.abs(math.sin(i * 4.1)) * 6 + love.graphics.circle("fill", wx, wy, 0.4, 4) + end + else + -- Rubble — same for all destroyed cities + love.graphics.setColor(p.dim[1], p.dim[2], p.dim[3], 0.4) + love.graphics.setLineWidth(lw) + + -- Main rubble outline + local pts = {} + for j = 1, #RUBBLE.outline, 2 do + table.insert(pts, c.x + RUBBLE.outline[j]) + table.insert(pts, c.y + RUBBLE.outline[j+1]) + end + if #pts >= 4 then + love.graphics.line(pts) + end + + -- Rubble debris details + love.graphics.setColor(p.dim[1], p.dim[2], p.dim[3], 0.2) + for _, d in ipairs(RUBBLE.details) do + local dpts = {} + for j = 1, #d, 2 do + table.insert(dpts, c.x + d[j]) + table.insert(dpts, c.y + d[j+1]) + end + if #dpts >= 4 then + love.graphics.line(dpts) + end + end + end + end +end + +return Cities diff --git a/game/crosshair.lua b/game/crosshair.lua new file mode 100644 index 0000000..b20d30c --- /dev/null +++ b/game/crosshair.lua @@ -0,0 +1,70 @@ +local World = require("game.world") +local Palette = require("rendering.palette") + +local Crosshair = { + x = 128, + y = 116, + targets = {}, +} + +function Crosshair.update() + local mx, my = love.mouse.getPosition() + Crosshair.x, Crosshair.y = World.toGame(mx, my) +end + +function Crosshair.addTarget(x, y) + table.insert(Crosshair.targets, {x = x, y = y, time = 0}) +end + +function Crosshair.updateTargets(dt) + for i = #Crosshair.targets, 1, -1 do + Crosshair.targets[i].time = Crosshair.targets[i].time + dt + end +end + +function Crosshair.removeTarget(x, y) + for i = #Crosshair.targets, 1, -1 do + local t = Crosshair.targets[i] + if math.abs(t.x - x) < 1 and math.abs(t.y - y) < 1 then + table.remove(Crosshair.targets, i) + return + end + end +end + +function Crosshair.draw() + local p = Palette.get(World.wave) + local lw = 1 / World.scale + local t = love.timer.getTime() + + -- Target markers: pulsing wireframe squares + for _, tgt in ipairs(Crosshair.targets) do + local pulse = 0.3 + math.sin(tgt.time * 15) * 0.3 + love.graphics.setColor(p.crosshair[1], p.crosshair[2], p.crosshair[3], pulse) + love.graphics.setLineWidth(lw) + local s = 2.5 + love.graphics.rectangle("line", tgt.x - s, tgt.y - s, s*2, s*2) + end + + -- Crosshair: vector-style rotating brackets + local size = 5 + local gap = 1.5 + local rot = t * 0.5 -- slow rotation + + love.graphics.setColor(p.crosshair) + love.graphics.setLineWidth(lw * 2) + + -- Four corner brackets + -- Top + love.graphics.line(Crosshair.x - size, Crosshair.y - gap, Crosshair.x - size, Crosshair.y - size, Crosshair.x - gap, Crosshair.y - size) + love.graphics.line(Crosshair.x + gap, Crosshair.y - size, Crosshair.x + size, Crosshair.y - size, Crosshair.x + size, Crosshair.y - gap) + -- Bottom + love.graphics.line(Crosshair.x + size, Crosshair.y + gap, Crosshair.x + size, Crosshair.y + size, Crosshair.x + gap, Crosshair.y + size) + love.graphics.line(Crosshair.x - gap, Crosshair.y + size, Crosshair.x - size, Crosshair.y + size, Crosshair.x - size, Crosshair.y + gap) + + -- Centre dot + love.graphics.setColor(p.bright) + love.graphics.circle("fill", Crosshair.x, Crosshair.y, 0.5, 6) +end + +return Crosshair diff --git a/game/explosions.lua b/game/explosions.lua new file mode 100644 index 0000000..a6cbafb --- /dev/null +++ b/game/explosions.lua @@ -0,0 +1,110 @@ +local World = require("game.world") +local Palette = require("rendering.palette") +local Sounds = require("audio.sounds") + +local Explosions = {} + +local explosions = {} + +local EXPAND_TIME = 0.3 +local HOLD_TIME = 0.5 +local CONTRACT_TIME = 0.3 +local MAX_RADIUS = 12 +local NUM_RINGS = 4 +local MAX_EXPLOSIONS = 8 + +function Explosions.add(x, y) + if #explosions >= MAX_EXPLOSIONS then return end + Sounds.play("explosion") + table.insert(explosions, { + x = x, + y = y, + radius = 0, + maxRadius = MAX_RADIUS, + phase = "expanding", + timer = 0, + birth = love.timer.getTime(), + }) +end + +function Explosions.update(dt) + for i = #explosions, 1, -1 do + local e = explosions[i] + e.timer = e.timer + dt + + if e.phase == "expanding" then + e.radius = e.maxRadius * (e.timer / EXPAND_TIME) + if e.timer >= EXPAND_TIME then + e.phase = "holding" + e.timer = 0 + e.radius = e.maxRadius + end + elseif e.phase == "holding" then + e.radius = e.maxRadius + if e.timer >= HOLD_TIME then + e.phase = "contracting" + e.timer = 0 + end + elseif e.phase == "contracting" then + e.radius = e.maxRadius * (1 - e.timer / CONTRACT_TIME) + if e.timer >= CONTRACT_TIME then + table.remove(explosions, i) + end + end + end +end + +function Explosions.checkCollision(x, y) + for _, e in ipairs(explosions) do + local dx = e.x - x + local dy = e.y - y + if dx * dx + dy * dy <= e.radius * e.radius then + return true + end + end + return false +end + +function Explosions.anyActive() + return #explosions > 0 +end + +function Explosions.getActive() + return explosions +end + +function Explosions.clear() + explosions = {} +end + +function Explosions.draw() + local p = Palette.get(World.wave) + local t = love.timer.getTime() + local lw = 1 / World.scale + + for _, e in ipairs(explosions) do + if e.radius > 0.5 then + local age = t - e.birth + local flash = math.sin(age * 40) > 0 + + -- Outer glow halo + love.graphics.setColor(p.glow[1], p.glow[2], p.glow[3], 0.2) + love.graphics.circle("fill", e.x, e.y, e.radius * 1.4) + + -- Solid fireball — flashes between two theme colours + if flash then + love.graphics.setColor(p.exp1) + else + love.graphics.setColor(p.exp2) + end + love.graphics.circle("fill", e.x, e.y, e.radius) + + -- Bright wireframe edge ring for vector feel + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.6) + love.graphics.setLineWidth(lw * 1.5) + love.graphics.circle("line", e.x, e.y, e.radius, 32) + end + end +end + +return Explosions diff --git a/game/fliers.lua b/game/fliers.lua new file mode 100644 index 0000000..e2fa0ae --- /dev/null +++ b/game/fliers.lua @@ -0,0 +1,296 @@ +local World = require("game.world") +local Palette = require("rendering.palette") +local Explosions = require("game.explosions") +local Cities = require("game.cities") +local Batteries = require("game.batteries") +local Missiles = require("game.missiles") + +local Fliers = {} + +local active = {} +local cooldownTimer = 0 +local COOLDOWN_MIN = 5 +local COOLDOWN_MAX = 8 + +-- Flier types +local BOMBER = "bomber" +local SATELLITE = "satellite" +local KILLER_SAT = "killer_sat" + +local function canSpawnBomber(wave) return wave >= 2 end +local function canSpawnSatellite(wave) return wave >= 4 end +local function canSpawnKillerSat(wave) return wave >= 8 end + +local function pickCooldown() + return COOLDOWN_MIN + math.random() * (COOLDOWN_MAX - COOLDOWN_MIN) +end + +local function spawnFlier(wave) + local canBomber = canSpawnBomber(wave) + local canSat = canSpawnSatellite(wave) + local canKiller = canSpawnKillerSat(wave) + if not canBomber and not canSat then return end + + -- Pick type — weighted roll + local ftype + local r = math.random() + if canKiller and r < 0.2 then + ftype = KILLER_SAT + elseif canSat and canBomber then + ftype = r < 0.6 and BOMBER or SATELLITE + elseif canSat then + ftype = SATELLITE + else + ftype = BOMBER + end + + local fromLeft = math.random() < 0.5 + local speed, y + if ftype == BOMBER then + speed = 20 + y = World.GROUND_Y * 0.6 + elseif ftype == KILLER_SAT then + speed = 45 + y = World.GROUND_Y * 0.25 + else + speed = 30 + y = World.GROUND_Y * 0.3 + end + + local startX = fromLeft and -8 or (World.GAME_W + 8) + local endX = fromLeft and (World.GAME_W + 8) or -8 + local dirX = fromLeft and 1 or -1 + + table.insert(active, { + ftype = ftype, + x = startX, + y = y, + speed = speed, + dirX = dirX, + endX = endX, + alive = true, + birth = love.timer.getTime(), + launchTimer = 1.5 + math.random() * 1.0, + hasLaunched = false, + }) +end + +function Fliers.init() + active = {} + cooldownTimer = pickCooldown() +end + +function Fliers.update(dt) + local wave = World.wave + + -- Cooldown for spawning + if #active == 0 then + if canSpawnBomber(wave) or canSpawnSatellite(wave) then + cooldownTimer = cooldownTimer - dt + if cooldownTimer <= 0 then + spawnFlier(wave) + cooldownTimer = pickCooldown() + end + end + end + + for i = #active, 1, -1 do + local f = active[i] + if f.alive then + f.x = f.x + f.dirX * f.speed * dt + + -- Check explosion collision (use a small radius around the flier) + if Explosions.checkCollision(f.x, f.y) then + f.alive = false + World.addScore(f.ftype == KILLER_SAT and 150 or 100) + Explosions.add(f.x, f.y) + table.remove(active, i) + cooldownTimer = pickCooldown() + else + -- Check if off screen + if (f.dirX > 0 and f.x > World.GAME_W + 10) or + (f.dirX < 0 and f.x < -10) then + table.remove(active, i) + cooldownTimer = pickCooldown() + else + -- Periodically launch missiles while on screen + f.launchTimer = f.launchTimer - dt + if f.launchTimer <= 0 then + if f.ftype == KILLER_SAT then + f.launchTimer = 1.2 + math.random() * 0.6 + else + f.launchTimer = 2.0 + math.random() * 1.0 + end + if f.x > 0 and f.x < World.GAME_W then + Missiles.spawnFromFlier(f.x, f.y, wave) + end + end + end + end + end + end +end + +function Fliers.allDone() + return #active == 0 +end + +function Fliers.clear() + active = {} + cooldownTimer = pickCooldown() +end + +function Fliers.draw() + local p = Palette.get(World.wave) + local lw = 1 / World.scale + local t = love.timer.getTime() + + for _, f in ipairs(active) do + if f.alive then + local pulse = 0.7 + math.sin(t * 12 + f.birth) * 0.3 + local x, y = f.x, f.y + local d = f.dirX -- direction multiplier + + if f.ftype == BOMBER then + -- B-52 style strategic bomber + -- Fuselage + love.graphics.setColor(p.missile[1], p.missile[2], p.missile[3], pulse) + love.graphics.setLineWidth(lw * 1.5) + love.graphics.line( + x + 7*d, y, -- nose tip + x + 5*d, y - 0.8, -- upper nose + x + 2*d, y - 1, -- cockpit + x - 3*d, y - 1, -- fuselage top + x - 6*d, y - 0.5, -- tail section top + x - 8*d, y - 3, -- vertical stabiliser top + x - 8*d, y - 0.5, -- tail join + x - 6*d, y + 0.5, -- fuselage bottom + x + 2*d, y + 1, -- belly + x + 5*d, y + 0.5, -- lower nose + x + 7*d, y -- back to nose + ) + + -- Swept wings + love.graphics.setLineWidth(lw * 1.2) + -- Upper wing + love.graphics.line( + x + 2*d, y - 1, + x - 1*d, y - 5, + x - 3*d, y - 4.5, + x - 3*d, y - 1 + ) + -- Lower wing + love.graphics.line( + x + 2*d, y + 1, + x - 1*d, y + 5, + x - 3*d, y + 4.5, + x - 3*d, y + 1 + ) + + -- Engine pods under wings + love.graphics.setColor(p.missile[1], p.missile[2], p.missile[3], pulse * 0.5) + love.graphics.setLineWidth(lw) + love.graphics.line(x, y - 2.5, x - 1*d, y - 3, x - 1.5*d, y - 2.5) + love.graphics.line(x, y + 2.5, x - 1*d, y + 3, x - 1.5*d, y + 2.5) + + -- Tail horizontal stabiliser + love.graphics.line( + x - 6*d, y - 0.5, + x - 7*d, y - 2, + x - 8*d, y - 1.5 + ) + love.graphics.line( + x - 6*d, y + 0.5, + x - 7*d, y + 1.5, + x - 8*d, y + 1 + ) + + -- Cockpit window detail + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse * 0.4) + love.graphics.line(x + 4*d, y - 0.5, x + 3*d, y - 0.8, x + 2*d, y - 0.8) + + else + -- Satellite — detailed orbital platform + local rot = t * 0.8 + f.birth -- slow rotation for visual interest + + -- Central body (hexagonal) + love.graphics.setColor(p.missile[1], p.missile[2], p.missile[3], pulse) + love.graphics.setLineWidth(lw * 1.5) + local s = 2 + love.graphics.line( + x-s, y-s*0.5, + x, y-s, + x+s, y-s*0.5, + x+s, y+s*0.5, + x, y+s, + x-s, y+s*0.5, + x-s, y-s*0.5 + ) + + -- Solar panel arrays — two large rectangular panels + love.graphics.setLineWidth(lw * 1.2) + -- Left panel + local px = 3.5 + love.graphics.line(x-s, y, x-s-1.5, y) + love.graphics.line( + x-s-1.5, y-2.5, + x-s-px-1.5, y-2.5, + x-s-px-1.5, y+2.5, + x-s-1.5, y+2.5, + x-s-1.5, y-2.5 + ) + -- Panel grid lines + love.graphics.setColor(p.missile[1], p.missile[2], p.missile[3], pulse * 0.3) + love.graphics.setLineWidth(lw * 0.7) + love.graphics.line(x-s-3, y-2.5, x-s-3, y+2.5) + love.graphics.line(x-s-1.5, y, x-s-px-1.5, y) + + -- Right panel + love.graphics.setColor(p.missile[1], p.missile[2], p.missile[3], pulse) + love.graphics.setLineWidth(lw * 1.2) + love.graphics.line(x+s, y, x+s+1.5, y) + love.graphics.line( + x+s+1.5, y-2.5, + x+s+px+1.5, y-2.5, + x+s+px+1.5, y+2.5, + x+s+1.5, y+2.5, + x+s+1.5, y-2.5 + ) + love.graphics.setColor(p.missile[1], p.missile[2], p.missile[3], pulse * 0.3) + love.graphics.setLineWidth(lw * 0.7) + love.graphics.line(x+s+3, y-2.5, x+s+3, y+2.5) + love.graphics.line(x+s+1.5, y, x+s+px+1.5, y) + + -- Antenna dish on top + love.graphics.setColor(p.missile[1], p.missile[2], p.missile[3], pulse * 0.6) + love.graphics.setLineWidth(lw) + love.graphics.line(x, y-s, x+0.5, y-s-2) + love.graphics.line(x-1, y-s-2.5, x+1, y-s-1.5) + + -- Blinking status light + if math.sin(t * 8 + f.birth) > 0.5 then + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.8) + love.graphics.circle("fill", x, y, 0.5, 4) + end + + -- Killer satellite: add menacing spikes and pulsing red core + if f.ftype == KILLER_SAT then + local killerPulse = 0.6 + math.sin(t * 14 + f.birth) * 0.4 + love.graphics.setColor(1.0, 0.2, 0.2, killerPulse) + love.graphics.setLineWidth(lw * 1.3) + -- Spikes around hexagon + love.graphics.line(x, y-s, x, y-s-2) + love.graphics.line(x, y+s, x, y+s+2) + love.graphics.line(x-s, y-s*0.5, x-s-1.5, y-s-1) + love.graphics.line(x+s, y-s*0.5, x+s+1.5, y-s-1) + love.graphics.line(x-s, y+s*0.5, x-s-1.5, y+s+1) + love.graphics.line(x+s, y+s*0.5, x+s+1.5, y+s+1) + -- Angry red core + love.graphics.circle("fill", x, y, 0.9, 8) + end + end + end + end +end + +return Fliers diff --git a/game/highscores.lua b/game/highscores.lua new file mode 100644 index 0000000..81f39f3 --- /dev/null +++ b/game/highscores.lua @@ -0,0 +1,255 @@ +local HighScores = {} + +local MAX_SCORES = 10 +local SAVE_FILE = "high_scores.dat" + +local scores = {} + +-- Entry state +local entry = { + active = false, + score = 0, + letters = {"A", "A", "A"}, + position = 1, -- 1, 2, or 3 + blink = 0, +} + +-- ── Data Management ── + +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) + -- Trim to max + while #scores > MAX_SCORES do + table.remove(scores) + end +end + +function HighScores.save() + local lines = {} + for _, entry in ipairs(scores) do + table.insert(lines, string.format("%s %d", entry.initials, entry.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 + +-- ── Entry Screen ── + +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 -- wrap Z -> A + 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 -- wrap A -> Z + 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 + -- Direct letter input + 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 + + -- "NEW HIGH SCORE" header + love.graphics.setFont(fonts.large) + love.graphics.setColor(palette.bright) + local t = love.timer.getTime() + 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") + + -- Score display + 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") + + -- Letter entry boxes + 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 + + -- Box outline + local isSelected = (i == entry.position) + if isSelected then + local blinkOn = math.floor(entry.blink * 3) % 2 == 0 + if blinkOn then + love.graphics.setColor(palette.bright) + else + love.graphics.setColor(palette.dim) + end + love.graphics.setLineWidth(3) + else + love.graphics.setColor(palette.dim) + love.graphics.setLineWidth(2) + end + love.graphics.rectangle("line", bx, by, boxW, boxH) + + -- Up/down arrows for selected position + if isSelected then + love.graphics.setColor(palette.bright[1], palette.bright[2], palette.bright[3], 0.5) + local arrowX = bx + boxW / 2 + -- Up arrow + local arrowTop = by - 12 + love.graphics.polygon("fill", arrowX - 5, arrowTop + 8, arrowX + 5, arrowTop + 8, arrowX, arrowTop) + -- Down arrow + local arrowBot = by + boxH + 4 + love.graphics.polygon("fill", arrowX - 5, arrowBot, arrowX + 5, arrowBot, arrowX, arrowBot + 8) + end + + -- Letter + love.graphics.setColor(palette.fg) + local lw = fonts.large:getWidth(entry.letters[i]) + local lx = bx + (boxW - lw) / 2 + local ly = by + (boxH - letterH) / 2 + love.graphics.print(entry.letters[i], lx, ly) + end + + -- Instructions + local instrY = midY + boxH + 32 + love.graphics.setFont(fonts.small) + love.graphics.setColor(palette.dim) + love.graphics.printf("TYPE LETTERS / ARROWS TO SELECT / ENTER TO CONFIRM", 0, instrY, 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 + love.graphics.setColor(palette.dim) + love.graphics.printf("NO SCORES YET", 0, topY, screenW, "center") + 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 + local rankStr = string.format("%2d.", i) + local scoreStr = string.format("%d", s.score) + + -- Rank and initials + 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(rankStr .. " " .. s.initials, startX, y) + + -- Score right-aligned + local sw = fonts.small:getWidth(scoreStr) + love.graphics.print(scoreStr, startX + colW - sw, y) + + -- Subtle separator line + love.graphics.setColor(palette.dim[1], palette.dim[2], palette.dim[3], 0.2) + love.graphics.setLineWidth(1) + love.graphics.line(startX, y + entryH - 1, startX + colW, y + entryH - 1) + end +end + +return HighScores diff --git a/game/hud.lua b/game/hud.lua new file mode 100644 index 0000000..9313da9 --- /dev/null +++ b/game/hud.lua @@ -0,0 +1,40 @@ +local World = require("game.world") +local Palette = require("rendering.palette") +local Fonts = require("rendering.fonts") + +local HUD = {} + +function HUD.draw() + local p = Palette.get(World.wave) + + -- Pop out of game transform for crisp text + love.graphics.pop() + + local font = Fonts.medium or love.graphics.getFont() + love.graphics.setFont(font) + + local padding = 8 + + -- Score (top left) + love.graphics.setColor(p.bright) + love.graphics.print(string.format("%06d", World.score), padding, padding) + + -- Wave (top right) + love.graphics.setColor(p.fg) + local waveText = "WAVE " .. World.wave + local tw = font:getWidth(waveText) + love.graphics.print(waveText, World.screenW - tw - padding, padding) + + -- Thin separator line under HUD + local lineY = padding + font:getHeight() + 4 + love.graphics.setColor(p.dim) + love.graphics.setLineWidth(1) + love.graphics.line(0, lineY, World.screenW, lineY) + + -- Restore game transform + love.graphics.push() + love.graphics.translate(World.offsetX, World.offsetY) + love.graphics.scale(World.scale) +end + +return HUD diff --git a/game/missiles.lua b/game/missiles.lua new file mode 100644 index 0000000..94b1411 --- /dev/null +++ b/game/missiles.lua @@ -0,0 +1,293 @@ +local World = require("game.world") +local Palette = require("rendering.palette") +local Explosions = require("game.explosions") +local Cities = require("game.cities") +local Batteries = require("game.batteries") +local Waves = require("data.waves") +local Sounds = require("audio.sounds") + +local Missiles = {} + +local active = {} +local queue = {} +local spawnTimer = 0 + +-- Gather all alive targets (cities + batteries) +local function gatherTargets() + local targets = {} + local cityTargets = Cities.getTargets() + local batTargets = Batteries.getTargets() + for _, t in ipairs(cityTargets) do table.insert(targets, t) end + for _, t in ipairs(batTargets) do table.insert(targets, t) end + return targets +end + +-- Determine MIRV chance based on wave +local function mirvChance(wave) + if wave <= 2 then return 0 + elseif wave <= 4 then return 0.2 + else return math.min(0.3 + (wave - 5) * 0.02, 0.4) + end +end + +-- Determine smart bomb chance based on wave +local function smartBombChance(wave) + if wave <= 4 then return 0 + elseif wave <= 6 then return 0.15 + else return math.min(0.25, 0.25) + end +end + +-- Create a missile record from a definition and insert into active +local function activateMissile(def) + local dx = def.targetX - def.startX + local dy = def.targetY - def.startY + local dist = math.sqrt(dx * dx + dy * dy) + if dist < 1 then return end + + table.insert(active, { + startX = def.startX, + startY = def.startY, + targetX = def.targetX, + targetY = def.targetY, + targetType = def.targetType, + targetIndex = def.targetIndex, + x = def.startX, + y = def.startY, + speed = def.speed, + dirX = dx / dist, + dirY = dy / dist, + totalDist = dist, + alive = true, + birth = love.timer.getTime(), + isMIRV = def.isMIRV or false, + mirvSplit = false, + mirvSplitFrac = def.mirvSplitFrac or 0, + isSmartBomb = def.isSmartBomb or false, + }) +end + +function Missiles.spawnWave(wave) + active = {} + queue = {} + -- Prime timer so the first missile launches almost immediately + spawnTimer = (Waves.get(wave).spawn_interval or 2) - 0.3 + + local config = Waves.get(wave) + local targets = gatherTargets() + + if #targets == 0 then return end + + local mChance = mirvChance(wave) + local sChance = smartBombChance(wave) + + for i = 1, config.missile_count do + local target = targets[math.random(#targets)] + local startX = math.random(10, 246) + local startY = math.random(0, 5) + + local isMIRV = false + local isSmartBomb = false + + -- Determine type: MIRV and smart bomb are mutually exclusive + if math.random() < mChance then + isMIRV = true + elseif math.random() < sChance then + isSmartBomb = true + end + + table.insert(queue, { + startX = startX, + startY = startY, + targetX = target.x, + targetY = target.y, + targetType = target.type, + targetIndex = target.index, + speed = config.missile_speed, + isMIRV = isMIRV, + isSmartBomb = isSmartBomb, + mirvSplitFrac = isMIRV and (0.4 + math.random() * 0.2) or 0, + }) + end +end + +local function spawnOne() + if #queue == 0 then return end + local def = table.remove(queue, 1) + activateMissile(def) +end + +-- Spawn a missile from a flier position toward a random target +function Missiles.spawnFromFlier(startX, startY, wave) + local targets = gatherTargets() + if #targets == 0 then return end + + local target = targets[math.random(#targets)] + local config = Waves.get(wave) + + activateMissile({ + startX = startX, + startY = startY, + targetX = target.x, + targetY = target.y, + targetType = target.type, + targetIndex = target.index, + speed = config.missile_speed, + }) +end + +function Missiles.update(dt) + local config = Waves.get(World.wave) + + spawnTimer = spawnTimer + dt + if #queue > 0 and spawnTimer >= config.spawn_interval then + spawnTimer = 0 + spawnOne() + end + + -- Collect new MIRV children to add after iteration + local newMissiles = {} + + for i = #active, 1, -1 do + local m = active[i] + if m.alive then + -- Smart bomb steering + if m.isSmartBomb then + local exps = Explosions.getActive() + local steerX, steerY = 0, 0 + for _, e in ipairs(exps) do + local edx = m.x - e.x + local edy = m.y - e.y + local eDist = math.sqrt(edx * edx + edy * edy) + if eDist < 20 and eDist > 0.1 then + local strength = (20 - eDist) / 20 + steerX = steerX + (edx / eDist) * strength * 40 + steerY = steerY + (edy / eDist) * strength * 40 + end + end + -- Blend steering with original direction + local newDirX = m.dirX * m.speed + steerX + local newDirY = m.dirY * m.speed + steerY + local newLen = math.sqrt(newDirX * newDirX + newDirY * newDirY) + if newLen > 0.1 then + m.dirX = newDirX / newLen + m.dirY = newDirY / newLen + end + end + + local step = m.speed * dt + m.x = m.x + m.dirX * step + m.y = m.y + m.dirY * step + + -- MIRV split check + if m.isMIRV and not m.mirvSplit then + local dx = m.x - m.startX + local dy = m.y - m.startY + local traveled = math.sqrt(dx * dx + dy * dy) + if traveled >= m.totalDist * m.mirvSplitFrac then + m.mirvSplit = true + Sounds.play("mirv_split") + -- Spawn 2-3 new warheads from current position + local targets = gatherTargets() + if #targets > 0 then + local numChildren = math.random(2, 3) + for c = 1, numChildren do + local target = targets[math.random(#targets)] + table.insert(newMissiles, { + startX = m.x, + startY = m.y, + targetX = target.x, + targetY = target.y, + targetType = target.type, + targetIndex = target.index, + speed = m.speed, + }) + end + end + end + end + + if Explosions.checkCollision(m.x, m.y) then + m.alive = false + World.addScore(m.isSmartBomb and 125 or 25) + Explosions.add(m.x, m.y) + table.remove(active, i) + else + local dx = m.x - m.startX + local dy = m.y - m.startY + local traveled = math.sqrt(dx * dx + dy * dy) + + if traveled >= m.totalDist or m.y >= m.targetY then + Explosions.add(m.targetX, m.targetY) + if m.targetType == "city" then + if Cities.destroy(m.targetIndex) then + Sounds.play("city_destroyed") + else + Sounds.play("impact") + end + elseif m.targetType == "battery" then + Batteries.destroy(m.targetIndex) + Sounds.play("impact") + end + table.remove(active, i) + end + end + end + end + + -- Activate MIRV children + for _, def in ipairs(newMissiles) do + activateMissile(def) + end +end + +function Missiles.allDone() + return #active == 0 and #queue == 0 +end + +function Missiles.clear() + active = {} + queue = {} +end + +function Missiles.draw() + local p = Palette.get(World.wave) + local lw = 1 / World.scale + local t = love.timer.getTime() + + for _, m in ipairs(active) do + local age = t - m.birth + + -- Faint trail glow + love.graphics.setColor(p.missile[1], p.missile[2], p.missile[3], 0.2) + love.graphics.setLineWidth(lw * 4) + love.graphics.line(m.startX, m.startY, m.x, m.y) + + -- Main vector trail + love.graphics.setColor(p.missile[1], p.missile[2], p.missile[3], 0.8) + love.graphics.setLineWidth(lw * 1.5) + love.graphics.line(m.startX, m.startY, m.x, m.y) + + -- Warhead shape depends on type + local pulse = 0.6 + math.sin(age * 20) * 0.4 + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse) + love.graphics.setLineWidth(lw * 1.5) + + if m.isSmartBomb then + -- Small wireframe triangle for smart bombs + local s = 2 + love.graphics.line( + m.x, m.y - s, + m.x + s * 0.87, m.y + s * 0.5, + m.x - s * 0.87, m.y + s * 0.5, + m.x, m.y - s + ) + else + -- Standard pulsing wireframe diamond + local s = 1.5 + love.graphics.line(m.x, m.y-s, m.x+s, m.y, m.x, m.y+s, m.x-s, m.y, m.x, m.y-s) + end + end +end + +return Missiles diff --git a/game/world.lua b/game/world.lua new file mode 100644 index 0000000..95761e7 --- /dev/null +++ b/game/world.lua @@ -0,0 +1,97 @@ +local World = { + GAME_W = 256, + GAME_H = 231, + visibleH = 231, -- actual visible height in game units (adapts to screen) + scale = 1, + offsetX = 0, + offsetY = 0, + state = "title", + wave = 1, + score = 0, + highScore = 0, + gameOverTimer = 0, + waveEndTimer = 0, + GROUND_Y = 213, + screenW = 0, + screenH = 0, + bonusCities = 0, + nextBonusAt = 10000, +} + +function World.resize(w, h) + if not w or not h then + w, h = love.graphics.getDimensions() + end + World.screenW = w + World.screenH = h + + -- Scale to fill width, let height adapt + World.scale = w / World.GAME_W + World.visibleH = h / World.scale + World.offsetX = 0 + -- Anchor the bottom of the game (ground) to the bottom of the screen + -- Ground is at GROUND_Y, bottom of game area is GAME_H + 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 + gx = math.max(0, math.min(World.GAME_W, gx)) + gy = math.max(0, math.min(World.GAME_H, gy)) + return gx, gy +end + +function World.toScreen(gx, gy) + return gx * World.scale + World.offsetX, gy * World.scale + World.offsetY +end + +-- Top of the visible area in game coordinates (can be negative on widescreen) +function World.visibleTop() + return World.GAME_H - World.visibleH +end + +function World.addScore(points) + local Waves = require("data.waves") + local config = Waves.get(World.wave) + local earned = points * config.multiplier + local oldScore = World.score + World.score = World.score + earned + + -- Check for bonus city threshold crossing + while World.score >= World.nextBonusAt do + World.bonusCities = World.bonusCities + 1 + World.nextBonusAt = World.nextBonusAt + 10000 + end + + -- Track high score + if World.score > World.highScore then + World.highScore = World.score + end +end + +function World.addScoreRaw(points) + -- Add points without multiplier (used by tally screen) + local oldScore = World.score + World.score = World.score + points + + while World.score >= World.nextBonusAt do + World.bonusCities = World.bonusCities + 1 + World.nextBonusAt = World.nextBonusAt + 10000 + end + + if World.score > World.highScore then + World.highScore = World.score + end +end + +return World diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..8f9555b --- /dev/null +++ b/icon.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..72c6e67 --- /dev/null +++ b/install.sh @@ -0,0 +1,143 @@ +#!/bin/bash +set -euo pipefail + +# OMA-COMMAND Installer / Uninstaller +# Usage: ./install.sh — install the game +# ./install.sh uninstall — remove the game + +GAME_NAME="oma-command" +DISPLAY_NAME="OMA-COMMAND" +COMMENT="Missile Command clone with Omarchy theme integration" +REPO_URL="https://git.no-signal.uk/nosignal/oma-command.git" + +INSTALL_DIR="$HOME/.local/share/$GAME_NAME" +DESKTOP_FILE="$HOME/.local/share/applications/$GAME_NAME.desktop" +ICON_DIR="$HOME/.local/share/icons/hicolor" +UNINSTALL_BIN="$HOME/.local/bin/$GAME_NAME-uninstall" + +# ── UNINSTALL ── +if [ "${1:-}" = "uninstall" ]; then + echo "=== Uninstalling $DISPLAY_NAME ===" + + [ -f "$DESKTOP_FILE" ] && rm "$DESKTOP_FILE" && echo "Removed desktop entry" + + for size in 16 32 48 64 128 256 512; do + local_icon="$ICON_DIR/${size}x${size}/apps/$GAME_NAME.png" + [ -f "$local_icon" ] && rm "$local_icon" + done + [ -f "$ICON_DIR/scalable/apps/$GAME_NAME.svg" ] && rm -f "$ICON_DIR/scalable/apps/$GAME_NAME.svg" + echo "Removed icons" + + [ -d "$INSTALL_DIR" ] && rm -rf "$INSTALL_DIR" && echo "Removed game files" + [ -f "$UNINSTALL_BIN" ] && rm "$UNINSTALL_BIN" && echo "Removed uninstall command" + + command -v gtk-update-icon-cache &>/dev/null && gtk-update-icon-cache -f -t "$ICON_DIR" 2>/dev/null || true + command -v omarchy-restart-walker &>/dev/null && omarchy-restart-walker 2>/dev/null || true + + echo "=== $DISPLAY_NAME uninstalled ===" + exit 0 +fi + +# ── INSTALL ── +echo "=== Installing $DISPLAY_NAME ===" + +# Install dependencies +DEPS=() +command -v love &>/dev/null || DEPS+=(love) +command -v git &>/dev/null || DEPS+=(git) +command -v rsvg-convert &>/dev/null || DEPS+=(librsvg) + +if [ ${#DEPS[@]} -gt 0 ]; then + echo "Installing dependencies: ${DEPS[*]}" + if command -v pacman &>/dev/null; then + sudo pacman -S --noconfirm "${DEPS[@]}" + else + echo "Error: missing ${DEPS[*]} and pacman not found." + echo "Install them manually and re-run this script." + exit 1 + fi +fi + +# Clone or update the game +if [ -d "$INSTALL_DIR/.git" ]; then + echo "Updating existing installation..." + cd "$INSTALL_DIR" + git pull --ff-only +else + [ -d "$INSTALL_DIR" ] && rm -rf "$INSTALL_DIR" + echo "Cloning game repository..." + git clone "$REPO_URL" "$INSTALL_DIR" +fi + +# Install icon +echo "Installing icon..." +ICON_SVG="$INSTALL_DIR/icon.svg" + +if command -v rsvg-convert &>/dev/null; then + for size in 16 32 48 64 128 256 512; do + mkdir -p "$ICON_DIR/${size}x${size}/apps" + rsvg-convert -w "$size" -h "$size" "$ICON_SVG" -o "$ICON_DIR/${size}x${size}/apps/$GAME_NAME.png" + done +elif command -v magick &>/dev/null; then + for size in 16 32 48 64 128 256 512; do + mkdir -p "$ICON_DIR/${size}x${size}/apps" + magick "$ICON_SVG" -resize "${size}x${size}" "$ICON_DIR/${size}x${size}/apps/$GAME_NAME.png" + done +else + echo "No SVG converter found, using SVG icon directly" + mkdir -p "$ICON_DIR/scalable/apps" + cp "$ICON_SVG" "$ICON_DIR/scalable/apps/$GAME_NAME.svg" +fi + +# Create .desktop file +echo "Creating desktop entry..." +mkdir -p "$(dirname "$DESKTOP_FILE")" +cat > "$DESKTOP_FILE" << EOF +[Desktop Entry] +Type=Application +Name=$DISPLAY_NAME +Comment=$COMMENT +Exec=uwsm app -- love $INSTALL_DIR +Icon=$GAME_NAME +Terminal=false +Categories=Game;ArcadeGame; +StartupNotify=true +TryExec=love +EOF + +# Install uninstall command so user can remove it any time +mkdir -p "$HOME/.local/bin" +cat > "$UNINSTALL_BIN" << 'UNINSTALL' +#!/bin/bash +# Uninstall OMA-COMMAND +SCRIPT_URL="https://git.no-signal.uk/nosignal/oma-command/raw/branch/master/install.sh" +curl -sL "$SCRIPT_URL" | bash -s uninstall 2>/dev/null || bash "$HOME/.local/share/oma-command/install.sh" uninstall 2>/dev/null || { + # Fallback: inline uninstall + rm -f "$HOME/.local/share/applications/oma-command.desktop" + rm -rf "$HOME/.local/share/oma-command" + for s in 16 32 48 64 128 256 512; do + rm -f "$HOME/.local/share/icons/hicolor/${s}x${s}/apps/oma-command.png" + done + rm -f "$HOME/.local/share/icons/hicolor/scalable/apps/oma-command.svg" + rm -f "$HOME/.local/bin/oma-command-uninstall" + command -v omarchy-restart-walker &>/dev/null && omarchy-restart-walker 2>/dev/null + echo "OMA-COMMAND uninstalled" +} +UNINSTALL +chmod +x "$UNINSTALL_BIN" + +# Update icon cache +command -v gtk-update-icon-cache &>/dev/null && gtk-update-icon-cache -f -t "$ICON_DIR" 2>/dev/null || true + +# Restart walker +if command -v omarchy-restart-walker &>/dev/null; then + echo "Refreshing app launcher..." + omarchy-restart-walker 2>/dev/null || true +fi + +echo "" +echo "=== $DISPLAY_NAME installed ===" +echo "" +echo " Launch: search '$DISPLAY_NAME' in app launcher or run: love $INSTALL_DIR" +echo " Uninstall: $GAME_NAME-uninstall" +echo "" diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..733e61b --- /dev/null +++ b/main.lua @@ -0,0 +1,567 @@ +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 diff --git a/rendering/fonts.lua b/rendering/fonts.lua new file mode 100644 index 0000000..0c0ef1b --- /dev/null +++ b/rendering/fonts.lua @@ -0,0 +1,75 @@ +local Fonts = {} + +Fonts.small = nil +Fonts.medium = nil +Fonts.large = nil +Fonts.currentH = 0 +Fonts.fontPath = nil + +-- Detect the system font from Omarchy's waybar config (same method as omarchy-font-current) +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*$") -- trim whitespace + + -- Use fc-match to find the actual font file + 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 ~= "" and io.open(path, "r") then + io.open(path, "r"):close() + return path + end + + return nil +end + +function Fonts.init(scale) + local h = love.graphics.getHeight() + if h == Fonts.currentH then return end + Fonts.currentH = h + + -- Detect system font on first init + if not Fonts.fontPath then + Fonts.fontPath = Fonts.detectSystemFont() or false + end + + local sSmall = math.max(10, math.floor(h * 0.018)) + local sMedium = math.max(12, math.floor(h * 0.025)) + local sLarge = math.max(16, math.floor(h * 0.045)) + + if Fonts.fontPath and not Fonts.fontData then + -- Love2D sandboxes file access, so read the font via io and create FileData + 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 + + if Fonts.fontData then + Fonts.small = love.graphics.newFont(Fonts.fontData, sSmall) + Fonts.medium = love.graphics.newFont(Fonts.fontData, sMedium) + Fonts.large = love.graphics.newFont(Fonts.fontData, sLarge) + else + Fonts.small = love.graphics.newFont(sSmall) + Fonts.medium = love.graphics.newFont(sMedium) + Fonts.large = love.graphics.newFont(sLarge) + end +end + +return Fonts diff --git a/rendering/palette.lua b/rendering/palette.lua new file mode 100644 index 0000000..452ffc7 --- /dev/null +++ b/rendering/palette.lua @@ -0,0 +1,92 @@ +-- Palette auto-detected from the current Omarchy system theme +-- Reads ~/.config/omarchy/current/theme/ghostty.conf on every launch +-- This file is a symlink that always points to the active 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() + -- Omarchy always symlinks the active theme config here + local path = os.getenv("HOME") .. "/.config/omarchy/current/theme/ghostty.conf" + local f = io.open(path, "r") + if not f then + -- Absolute fallback: white-on-black so the game is always playable + 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() + + -- Derive dim from color8 (bright black / muted) which all themes provide + theme.dim = theme.color8 or theme.color0 or {0.2, 0.2, 0.2} + + -- Safety: ensure all 16 colours exist (some themes might be sparse) + 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 + +-- Vector-style palette mapping for the game +-- Maps the 16 ANSI theme colours into game roles +function Palette.get(wave) + local shift = ((math.ceil(wave / 2) - 1) % 5) + + local primary_colors = {theme.color4, theme.color5, theme.color6, theme.color9, theme.color10} + local enemy_colors = {theme.color1, theme.color2, theme.color3, theme.color9, theme.color1} + + return { + sky = theme.bg, + ground = primary_colors[shift + 1], + missile = enemy_colors[shift + 1], + abm = theme.color6, + cities = primary_colors[shift + 1], + exp1 = theme.accent, + exp2 = theme.color4, + crosshair = theme.accent, + dim = theme.dim, + bright = theme.accent, + fg = theme.fg, + grid = theme.color0, + glow = theme.color4, + } +end + +function Palette.raw() + return theme +end + +return Palette diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..f21e921 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +# OMA-COMMAND Uninstaller + +GAME_NAME="oma-command" +DISPLAY_NAME="OMA-COMMAND" + +INSTALL_DIR="$HOME/.local/share/$GAME_NAME" +DESKTOP_FILE="$HOME/.local/share/applications/$GAME_NAME.desktop" +ICON_DIR="$HOME/.local/share/icons/hicolor" + +echo "=== Uninstalling $DISPLAY_NAME ===" + +# Remove desktop entry +if [ -f "$DESKTOP_FILE" ]; then + rm "$DESKTOP_FILE" + echo "Removed desktop entry" +fi + +# Remove icons +for size in 16 32 48 64 128 256 512; do + local_icon="$ICON_DIR/${size}x${size}/apps/$GAME_NAME.png" + [ -f "$local_icon" ] && rm "$local_icon" +done +[ -f "$ICON_DIR/scalable/apps/$GAME_NAME.svg" ] && rm "$ICON_DIR/scalable/apps/$GAME_NAME.svg" +echo "Removed icons" + +# Remove game files +if [ -d "$INSTALL_DIR" ]; then + rm -rf "$INSTALL_DIR" + echo "Removed game files" +fi + +# Update icon cache +if command -v gtk-update-icon-cache &>/dev/null; then + gtk-update-icon-cache -f -t "$ICON_DIR" 2>/dev/null || true +fi + +# Restart walker +if command -v omarchy-restart-walker &>/dev/null; then + omarchy-restart-walker 2>/dev/null || true +fi + +echo "=== $DISPLAY_NAME uninstalled ==="