Initial public release
OMA-COMMAND — Missile Command arcade clone in Love2D with Omarchy theme integration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
64ce7f0fcb
21 changed files with 2979 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ABM-CLAUDE.md
|
||||
CLAUDE.md
|
||||
88
README.md
Normal file
88
README.md
Normal file
|
|
@ -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
|
||||
128
audio/sounds.lua
Normal file
128
audio/sounds.lua
Normal file
|
|
@ -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
|
||||
11
conf.lua
Normal file
11
conf.lua
Normal file
|
|
@ -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
|
||||
50
data/waves.lua
Normal file
50
data/waves.lua
Normal file
|
|
@ -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
|
||||
87
game/abm.lua
Normal file
87
game/abm.lua
Normal file
|
|
@ -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
|
||||
232
game/batteries.lua
Normal file
232
game/batteries.lua
Normal file
|
|
@ -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
|
||||
265
game/cities.lua
Normal file
265
game/cities.lua
Normal file
|
|
@ -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
|
||||
70
game/crosshair.lua
Normal file
70
game/crosshair.lua
Normal file
|
|
@ -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
|
||||
110
game/explosions.lua
Normal file
110
game/explosions.lua
Normal file
|
|
@ -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
|
||||
296
game/fliers.lua
Normal file
296
game/fliers.lua
Normal file
|
|
@ -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
|
||||
255
game/highscores.lua
Normal file
255
game/highscores.lua
Normal file
|
|
@ -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
|
||||
40
game/hud.lua
Normal file
40
game/hud.lua
Normal file
|
|
@ -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
|
||||
293
game/missiles.lua
Normal file
293
game/missiles.lua
Normal file
|
|
@ -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
|
||||
97
game/world.lua
Normal file
97
game/world.lua
Normal file
|
|
@ -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
|
||||
33
icon.svg
Normal file
33
icon.svg
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<!-- Ground baseline -->
|
||||
<line x1="40" y1="430" x2="472" y2="430" stroke="white" stroke-width="6"/>
|
||||
|
||||
<!-- City silhouettes -->
|
||||
<polygon points="80,430 80,400 95,400 95,385 115,385 115,400 130,400 130,430" fill="none" stroke="white" stroke-width="4" stroke-linejoin="round"/>
|
||||
<polygon points="400,430 400,395 418,395 418,380 435,380 435,395 452,395 452,430" fill="none" stroke="white" stroke-width="4" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Launcher / silo centre -->
|
||||
<polygon points="230,430 230,405 256,385 282,405 282,430" fill="none" stroke="white" stroke-width="5" stroke-linejoin="round"/>
|
||||
|
||||
<!-- ABM trail going up-right from silo -->
|
||||
<line x1="256" y1="395" x2="370" y2="200" stroke="white" stroke-width="5" stroke-linecap="round"/>
|
||||
|
||||
<!-- Explosion / fireball at trail tip -->
|
||||
<circle cx="370" cy="200" r="58" fill="none" stroke="white" stroke-width="6"/>
|
||||
<circle cx="370" cy="200" r="40" fill="none" stroke="white" stroke-width="4" opacity="0.6"/>
|
||||
<circle cx="370" cy="200" r="22" fill="none" stroke="white" stroke-width="3" opacity="0.4"/>
|
||||
|
||||
<!-- Incoming ICBM trail from top-left -->
|
||||
<line x1="80" y1="50" x2="220" y2="230" stroke="white" stroke-width="4" opacity="0.9"/>
|
||||
<circle cx="220" cy="230" r="6" fill="white"/>
|
||||
|
||||
<!-- MIRV split fork -->
|
||||
<line x1="220" y1="230" x2="180" y2="320" stroke="white" stroke-width="3" opacity="0.7"/>
|
||||
<line x1="220" y1="230" x2="260" y2="330" stroke="white" stroke-width="3" opacity="0.7"/>
|
||||
|
||||
<!-- Crosshair top-right -->
|
||||
<g stroke="white" stroke-width="4" fill="none">
|
||||
<line x1="440" y1="90" x2="440" y2="130"/>
|
||||
<line x1="420" y1="110" x2="460" y2="110"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
143
install.sh
Executable file
143
install.sh
Executable file
|
|
@ -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 ""
|
||||
567
main.lua
Normal file
567
main.lua
Normal file
|
|
@ -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
|
||||
75
rendering/fonts.lua
Normal file
75
rendering/fonts.lua
Normal file
|
|
@ -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
|
||||
92
rendering/palette.lua
Normal file
92
rendering/palette.lua
Normal file
|
|
@ -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
|
||||
45
uninstall.sh
Executable file
45
uninstall.sh
Executable file
|
|
@ -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 ==="
|
||||
Loading…
Add table
Reference in a new issue