Initial public release

This commit is contained in:
28allday 2026-04-18 11:35:06 +01:00
commit 026e607b07
24 changed files with 3274 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
CLAUDE.md
.claude/

110
README.md Normal file
View file

@ -0,0 +1,110 @@
# OMA-TANK
A first-person wireframe tank combat game for Omarchy Linux, inspired by classic 1980 arcade vector tank games. Built with Love2D — pure Lua, software-rendered 3D line graphics, no polygons, no textures.
Vertex models are traced directly from the publicly-archived 1980 arcade ROM (vertex table at `$388e`, draw-command table at `$2472`, both documented by the 6502 disassembly preservation project), so silhouettes, proportions, and draw orders match the cabinet.
## Install
```bash
curl -sL https://git.no-signal.uk/nosignal/oma-tank/raw/branch/master/install.sh | bash
```
## Uninstall
```bash
oma-tank-uninstall
```
## Controls
Two control schemes, switchable on the title screen with `TAB`.
### Modern simplified (default)
| Input | Action |
|-------|--------|
| **Up / W** | Drive forward |
| **Down / S** | Reverse |
| **Left / A** | Rotate left |
| **Right / D** | Rotate right |
| **Space** | Fire |
### Classic dual-track
| Input | Action |
|-------|--------|
| **W / S** | Left track forward / back |
| **I / K** | Right track forward / back |
| **Space** | Fire |
Both tracks forward = drive straight. Opposing = pivot in place. A single track alone gives a 1.5× speed bonus.
### Gamepad
| Input | Action |
|-------|--------|
| **Left stick Y** | Left track |
| **Right stick Y** | Right track |
| **A / right trigger** | Fire |
| **Start** | Start game |
| **Y** (title) | Toggle control mode |
## Gameplay
- First-person view from inside a tank on an open plain, studded with indestructible cubes, pyramids, and slabs
- Only one hostile is on the field at a time — destroy it and another spawns after a short beat
- The saucer is a separate bonus target: doesn't attack, doesn't show on radar, flies at barrel height
- One hit kills you — the screen cracks and your tank explodes into tumbling debris
- You start with **3 tanks**. Bonus tank at **15,000**, another at **100,000**, every **100,000** thereafter
- One shot on the playfield at a time: fire again the moment your shell dies
### Enemies
| Target | Points |
|---|---|
| Slow tank | 1,000 |
| Guided missile | 2,000 |
| Super tank | 3,000 |
| Saucer (UFO) | 5,000 |
### Difficulty progression
- Below **10,000 pts** — slow tanks only
- **10,000+** — guided missiles enter the rotation (1-in-3 chance per spawn)
- After **6 missiles have launched** — super tanks replace slow tanks
- From **2,000 pts** — saucer starts drifting across the field at random 017 s intervals
### Evasion penalty
Avoid a tank for 4864 seconds without killing it and the game silently replaces it with a missile — you can't hide behind cover forever.
### Shooting over cover
Low slabs (`halfPrism`) can be shot over. Tall blocks and pyramids cannot. Use low slabs to close distance while blocked from enemy fire, then pop out to shoot.
## HUD
- **Top-left:** lives, and status text — `ENEMY IN RANGE`, `ENEMY TO LEFT / RIGHT / REAR`, or `MOTION BLOCKED BY OBJECT`
- **Top-centre:** circular radar with sweep and fading blips. Shows only the active tank/missile — not the saucer, not obstacles
- **Top-right:** score and high score (9-digit arcade format)
- **Screen centre:** two-bracket reticle. Curls flip from vertical to diagonal when an enemy is lined up; the whole reticle blinks while your shell is in flight (fire again the moment it stops)
## Omarchy Integration
- **Theme colours** auto-detected from your active Omarchy Ghostty theme
- **System font** detected from your Waybar config
- **Full-screen** via SUPER+F (Hyprland compositor)
## Requirements
- Love2D (`sudo pacman -S love`)
- Omarchy Linux (or any Arch-based distro)
## Acknowledgements
Inspired by the 1980 Atari arcade cabinet. Model geometry extracted from the publicly-archived ROM via the [6502 disassembly preservation project](https://6502disassembly.com/va-battlezone/). No arcade assets, logos, or trademarked names are reproduced.
## License
MIT

183
audio/sounds.lua Normal file
View file

@ -0,0 +1,183 @@
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
local p = i / samples
sd:setSample(i, math.max(-1, math.min(1, generator(t, p))))
end
return sd
end
local function makeSource(sd)
return love.audio.newSource(sd, "static")
end
local function genFire(t, p)
local freq = 800 - p * 600
local env = (1 - p) ^ 2
return math.sin(2 * math.pi * freq * t) * env * 0.4
end
local function genEnemyFire(t, p)
local freq = 400 - p * 250
local env = (1 - p) ^ 2
return math.sin(2 * math.pi * freq * t) * env * 0.35
end
local function genHitEnemy(t, p)
local env = (1 - p) ^ 1.5
local sine = math.sin(2 * math.pi * (80 + 40*(1-p)) * t) * 0.5
local noise = (math.random() * 2 - 1) * 0.4
return (sine + noise) * env * 0.5
end
local function genHitPlayer(t, p)
local env = (1 - p) ^ 1.0
local sine = math.sin(2 * math.pi * (50 + 30*(1-p)) * t) * 0.5
local noise = (math.random() * 2 - 1) * 0.5
local pulse = 0.7 + math.sin(2 * math.pi * 4 * t) * 0.3
return (sine + noise) * env * pulse * 0.5
end
local function genEngine(t, p)
local noise = (math.random() * 2 - 1) * 0.2
local low = math.sin(2 * math.pi * 35 * t) * 0.15
return (noise + low) * 0.3
end
local function genEnemyAppear(t, p)
local freq = 200 + p * 300
local env = 0.6
if p < 0.1 then env = p / 0.1 * 0.6 end
if p > 0.8 then env = (1 - p) / 0.2 * 0.6 end
return math.sin(2 * math.pi * freq * t) * env * 0.3
end
local function genExtraLife(t, p)
local freq
if p < 0.33 then freq = 400
elseif p < 0.66 then freq = 600
else freq = 800 end
local env = 0.7
if p < 0.05 then env = p / 0.05 * 0.7 end
if p > 0.9 then env = (1-p) / 0.1 * 0.7 end
return math.sin(2 * math.pi * freq * t) * env * 0.35
end
local function genMissileLaunch(t, p)
-- Rising whistle: frequency ramps up
local freq = 300 + p * 900
local env
if p < 0.1 then env = p / 0.1
elseif p > 0.8 then env = (1 - p) / 0.2
else env = 1 end
local tone = math.sin(2 * math.pi * freq * t)
local noise = (math.random() * 2 - 1) * 0.1
return (tone + noise) * env * 0.25
end
local function genScreenCrack(t, p)
-- Glass break: noise burst + downward pitch sweep
local env = (1 - p) ^ 1.5
local noise = (math.random() * 2 - 1)
local tone = math.sin(2 * math.pi * (1400 - p * 1200) * t)
return (noise * 0.6 + tone * 0.2) * env * 0.45
end
local function genMotionBlocked(t, p)
local env = (1 - p) ^ 2
return math.sin(2 * math.pi * 160 * t) * env * 0.25
end
local function genSaucerHum(t, _)
-- Long looping wavering tone — distinctive UFO cue
local base = 220
local vibrato = math.sin(2 * math.pi * 5 * t) * 12
return math.sin(2 * math.pi * (base + vibrato) * t) * 0.22
end
function Sounds.init()
sources = {}
local defs = {
fire = {0.08, genFire},
enemy_fire = {0.1, genEnemyFire},
hit_enemy = {0.4, genHitEnemy},
hit_player = {0.8, genHitPlayer},
engine = {0.3, genEngine},
enemy_appear = {0.3, genEnemyAppear},
extra_life = {0.3, genExtraLife},
missile_launch = {0.6, genMissileLaunch},
screen_crack = {0.7, genScreenCrack},
motion_blocked = {0.12, genMotionBlocked},
}
for name, def in pairs(defs) do
sources[name] = makeSource(makeSoundData(def[1], def[2]))
end
-- Saucer hum is a looping source (not cloned per-play) so it can be panned live
local humData = makeSoundData(1.0, genSaucerHum)
sources.saucer_hum = love.audio.newSource(humData, "static")
sources.saucer_hum:setLooping(true)
sources.saucer_hum:setRelative(false)
sources.saucer_hum:setVolume(0.6)
-- playMusic() silently no-ops if the music asset isn't bundled
end
function Sounds.play(name)
local src = sources[name]
if not src then return end
src:clone():play()
end
function Sounds.startSaucerHum()
local src = sources.saucer_hum
if not src then return end
if not src:isPlaying() then
src:setPosition(0, 0, 0)
src:play()
end
end
function Sounds.stopSaucerHum()
local src = sources.saucer_hum
if src and src:isPlaying() then src:stop() end
end
function Sounds.updateSaucerHum(bearingRadians, distance)
-- bearingRadians: atan2(side, forward) in player frame; 0 = straight ahead
-- Map bearing sine to x pan, distance to attenuation via z distance
local src = sources.saucer_hum
if not src or not src:isPlaying() then return end
local x = math.sin(bearingRadians) * 5
local z = math.max(1, distance / 200)
src:setPosition(x, 0, z)
end
function Sounds.playMusic(name)
local path = "assets/music/" .. name .. ".ogg"
if love.filesystem.getInfo and love.filesystem.getInfo(path) then
local ok, src = pcall(love.audio.newSource, path, "stream")
if ok and src then
src:setLooping(false)
src:play()
sources["music_" .. name] = src
end
end
-- Silently no-op if the asset isn't bundled
end
function Sounds.stopMusic()
for k, src in pairs(sources) do
if k:sub(1, 6) == "music_" and src.isPlaying and src:isPlaying() then
src:stop()
end
end
end
return Sounds

12
conf.lua Normal file
View file

@ -0,0 +1,12 @@
function love.conf(t)
t.identity = "oma-tank" -- save-data directory under love.filesystem
t.window.title = "OMA-TANK"
t.window.width = 1024
t.window.height = 768
t.window.resizable = true
t.window.vsync = 1
t.window.fullscreen = false
t.window.fullscreentype = "desktop"
t.window.minwidth = 512
t.window.minheight = 384
end

231
data/models.lua Normal file
View file

@ -0,0 +1,231 @@
-- Wireframe 3D models — vertex + edge data traced from the public 1980 arcade
-- AVG ROM (vertex table at $388e, draw-command table at $2472, both
-- documented by the 6502 disassembly preservation project).
--
-- Axis translation: the arcade uses +X forward, +Y right, +Z up. We use
-- +X right, +Y up, +Z forward. The arcade math box effectively halves X/Z
-- at draw time (visualizer's X and Z, which are arcade's right-axis and
-- forward-axis), so those are stored 2× and we halve here. Arcade's Z (up)
-- axis is stored at native scale.
--
-- All models pre-scaled ×0.025 to fit our world; bottoms shifted to Y=0
-- except the shell, which is centred so it can fly at arbitrary Y.
local Models = {}
local function buildModel(verts, lines, scale)
scale = scale or 1
local edges = {}
for _, pair in ipairs(lines) do
local a, b = verts[pair[1]], verts[pair[2]]
edges[#edges + 1] = {
a[1] * scale, a[2] * scale, a[3] * scale,
b[1] * scale, b[2] * scale, b[3] * scale,
}
end
return edges
end
-- Slow (enemy) tank — arcade ROM shape 2 — 24v, 38e.
-- Stepped hull, moderate height, forward-extending turret / antenna.
local tankVerts = {
{ 6.40, 0.00, -9.20}, { -6.40, 0.00, -9.20},
{ -6.40, 0.00, 12.10}, { 6.40, 0.00, 12.10},
{ 7.10, 2.80, -12.80}, { -7.10, 2.80, -12.80},
{ -7.10, 2.80, 15.60}, { 7.10, 2.80, 15.60},
{ 4.30, 5.00, -8.50}, { -4.30, 5.00, -8.50},
{ -4.30, 5.00, 8.50}, { 4.30, 5.00, 8.50},
{ 2.10, 9.20, -6.40}, { -2.10, 9.20, -6.40},
{ 0.50, 7.80, -1.60}, { -0.50, 7.80, -1.60},
{ -0.50, 6.80, 1.60}, { 0.50, 6.80, 1.60},
{ -0.50, 7.80, 14.00}, { -0.50, 6.80, 14.00},
{ 0.50, 7.80, 14.00}, { 0.50, 6.80, 14.00},
{ 0.00, 9.20, -6.40}, { 0.00, 10.00, -6.40},
}
local tankLines = {
{24,23},{13,14},{15,21},{21,19},{19,16},{16,15},{15,18},{18,17},
{17,20},{20,22},{22,18},{16,17},{20,19},{21,22},{4,1},{1,5},
{5,8},{8,7},{7,3},{3,4},{4,8},{8,12},{12,11},{11,7},
{7,6},{6,10},{10,11},{11,14},{14,10},{10,9},{9,12},{12,13},
{13,9},{9,5},{5,6},{6,2},{2,3},{2,1},
}
Models.tank = buildModel(tankVerts, tankLines)
-- Super tank — arcade ROM shape 33 — 25v, 34e.
-- Similar stepped profile but slightly taller and with rear antenna mount.
local supertankVerts = {
{ -4.60, 0.00, 18.20}, { -6.90, 0.00, -5.70},
{ 6.90, 0.00, -5.70}, { 4.60, 0.00, 18.20},
{ -5.70, 5.70, -5.70}, { 5.70, 5.70, -5.70},
{ 0.00, 1.10, 13.70}, { -3.40, 5.10, -3.40},
{ -3.40, 5.70, -5.70}, { 3.40, 5.70, -5.70},
{ 3.40, 5.10, -3.40}, { -2.30, 9.10, -3.40},
{ -2.30, 9.10, -5.70}, { 2.30, 9.10, -5.70},
{ 2.30, 9.10, -3.40}, { -1.10, 6.90, 16.00},
{ -1.10, 6.90, 1.10}, { 1.10, 6.90, 1.10},
{ 1.10, 6.90, 16.00}, { -1.10, 8.00, 16.00},
{ -1.10, 8.00, -1.10}, { 1.10, 8.00, -1.10},
{ 1.10, 8.00, 16.00}, { 0.00, 9.10, -5.70},
{ 0.00, 14.90, -5.70},
}
local supertankLines = {
{1,2},{2,5},{5,1},{1,4},{4,3},{3,6},{6,4},{3,2},
{5,6},{10,11},{11,7},{7,15},{15,14},{14,10},{10,9},{9,8},
{8,7},{7,12},{12,13},{13,9},{13,14},{15,12},{20,23},{23,22},
{22,21},{21,17},{17,16},{16,19},{19,18},{18,17},{16,20},{23,19},
{18,22},{24,25},
}
Models.supertank = buildModel(supertankVerts, supertankLines)
-- Guided missile — arcade ROM shape 22 (type $16) — 26v, 43e.
local missileVerts = {
{ -1.80, 4.20, -4.80}, { -0.90, 5.40, -4.80},
{ 0.90, 5.40, -4.80}, { 1.80, 4.20, -4.80},
{ 0.90, 3.00, -4.80}, { -0.90, 3.00, -4.80},
{ -3.60, 4.20, -1.20}, { -2.40, 6.60, -1.20},
{ 2.40, 6.60, -1.20}, { 3.60, 4.20, -1.20},
{ 2.40, 1.80, -1.20}, { -2.40, 1.80, -1.20},
{ 0.00, 4.20, 14.40}, { 0.00, 4.20, 17.40},
{ 1.80, 0.00, -1.80}, { -1.80, 0.00, -1.80},
{ -1.80, 0.00, 1.80}, { 1.80, 0.00, 1.80},
{ 0.60, 1.90, -0.60}, { -0.60, 1.90, -0.60},
{ -0.60, 2.10, 0.60}, { 0.60, 2.10, 0.60},
{ 0.00, 6.60, -1.20}, { -0.90, 5.40, 6.60},
{ 0.90, 5.40, 6.60}, { 0.00, 7.80, 0.60},
}
local missileLines = {
{14,13},{13,7},{7,1},{1,2},{2,8},{8,9},{9,10},{10,11},
{11,12},{12,7},{7,8},{8,13},{13,9},{9,3},{3,4},{4,10},
{10,13},{13,11},{11,5},{5,6},{6,12},{12,13},{25,24},{24,23},
{23,25},{25,26},{26,24},{26,23},{2,3},{4,5},{6,1},{19,20},
{20,21},{21,22},{22,19},{19,15},{15,16},{16,17},{17,18},{18,15},
{16,20},{21,17},{18,22},
}
Models.missile = buildModel(missileVerts, missileLines)
-- Saucer / UFO — arcade ROM shape 32 (type $20) — 17v, 32e.
local saucerVerts = {
{ 0.00, 0.00, -3.00}, { -2.00, 0.00, -2.00},
{ -3.00, 0.00, 0.00}, { -2.00, 0.00, 2.00},
{ 0.00, 0.00, 3.00}, { 2.00, 0.00, 2.00},
{ 3.00, 0.00, 0.00}, { 2.00, 0.00, -2.00},
{ 0.00, 3.00, -12.00}, { -8.50, 3.00, -8.50},
{ -12.00, 3.00, 0.00}, { -8.50, 3.00, 8.50},
{ 0.00, 3.00, 12.00}, { 8.50, 3.00, 8.50},
{ 12.00, 3.00, 0.00}, { 8.50, 3.00, -8.50},
{ 0.00, 8.00, 0.00},
}
local saucerLines = {
{17,9},{9,10},{10,17},{17,11},{11,12},{12,17},{17,13},{13,14},
{14,17},{17,15},{15,16},{16,17},{1,8},{8,16},{16,9},{9,1},
{1,2},{2,10},{10,11},{11,3},{3,4},{4,12},{12,13},{13,5},
{5,6},{6,14},{14,15},{15,7},{7,8},{7,6},{5,4},{3,2},
}
Models.ufo = buildModel(saucerVerts, saucerLines)
-- Low/wide pyramid obstacle — arcade ROM shape 12 — 5v, 8e.
local pyramidVerts = {
{ 10.00, 0.00, -10.00}, { -10.00, 0.00, -10.00},
{ -10.00, 0.00, 10.00}, { 10.00, 0.00, 10.00},
{ 0.00, 18.00, 0.00},
}
local pyramidLines = {
{1,5},{5,2},{2,1},{1,4},{4,5},{5,3},{3,4},{3,2},
}
Models.pyramid = buildModel(pyramidVerts, pyramidLines)
-- Tall / narrower pyramid obstacle — arcade ROM shape 0 — 5v, 8e.
local pyramidTallVerts = {
{ 6.40, 0.00, -6.40}, { -6.40, 0.00, -6.40},
{ -6.40, 0.00, 6.40}, { 6.40, 0.00, 6.40},
{ 0.00, 16.00, 0.00},
}
local pyramidTallLines = {
{1,5},{5,2},{2,1},{1,4},{4,5},{5,3},{3,4},{3,2},
}
Models.pyramidTall = buildModel(pyramidTallVerts, pyramidTallLines)
-- Tall block / cube — arcade ROM shape 1 — 8v, 12e.
-- Taller than wide — matches arcade's "tall box" obstacle.
local cubeVerts = {
{ 6.40, 0.00, -6.40}, { -6.40, 0.00, -6.40},
{ -6.40, 0.00, 6.40}, { 6.40, 0.00, 6.40},
{ 6.40, 16.00, -6.40}, { -6.40, 16.00, -6.40},
{ -6.40, 16.00, 6.40}, { 6.40, 16.00, 6.40},
}
local cubeLines = {
{1,2},{2,3},{3,4},{4,1},{1,5},{5,6},{6,7},{7,8},
{8,5},{6,2},{3,7},{8,4},
}
Models.cube = buildModel(cubeVerts, cubeLines)
-- Low block / slab — arcade ROM shape 15 — 8v, 12e.
local halfPrismVerts = {
{ 8.00, 0.00, -8.00}, { -8.00, 0.00, -8.00},
{ -8.00, 0.00, 8.00}, { 8.00, 0.00, 8.00},
{ 8.00, 7.00, -8.00}, { -8.00, 7.00, -8.00},
{ -8.00, 7.00, 8.00}, { 8.00, 7.00, 8.00},
}
local halfPrismLines = {
{1,2},{2,3},{3,4},{4,1},{1,5},{5,6},{6,7},{7,8},
{8,5},{6,2},{3,7},{8,4},
}
Models.halfPrism = buildModel(halfPrismVerts, halfPrismLines)
-- Tank shell — the arcade draws in-flight shells as a single bright point,
-- so there's no ROM model to trace. We use a small square-base pyramid with
-- the apex pointing forward (+Z) so the player can actually see their shot.
local shellVerts = {
{ 2.0, 1.5, 0.0}, { 2.0, -1.5, 0.0},
{ -2.0, -1.5, 0.0}, { -2.0, 1.5, 0.0},
{ 0.0, 0.0, 8.0},
}
local shellLines = {
{1,2},{2,3},{3,4},{4,1},
{1,5},{2,5},{3,5},{4,5},
}
Models.shell = buildModel(shellVerts, shellLines)
-- Debris — three shrapnel shapes used by the tank-destruction explosion.
local debris1Verts = {
{ 0, -3.9, 0}, { 0, 0, 3}, { 0, 3, -0.75}, { 0, 1.5, -2.1},
{ 3, 0, 0}, {-3, 0, -0.3},
}
local debris1Lines = {
{1,2},{2,3},{3,4},{4,1},
{1,5},{2,5},{3,5},{4,5},
{1,6},{2,6},{3,6},{4,6},
}
Models.debris1 = buildModel(debris1Verts, debris1Lines)
local debris2Verts = {
{ 0, -3, 9}, { 2.1, -3, 7.5}, { 1.5, -3, 0}, { 0.6, -3, -0.75},
{-1.2, -3, 6}, { 0, 0, 1.5}, { 0, 4.5, 0},
}
local debris2Lines = {
{1,2},{2,3},{3,4},{4,5},{5,1},
{1,6},{6,7},{7,4},{7,3},
}
Models.debris2 = buildModel(debris2Verts, debris2Lines)
local debris3Verts = {
{ 0, 3, 12}, { 0, 3, -3},
{ 1.5, 0, 11.1}, { 1.5, 0, 0}, { 0, 0, -3}, {-1.5, 0, 0}, {-1.5, 0, 10.5},
{ 0, -3, 9}, { 0, -3, 0},
{ 0, -6, 0}, { 1.5, -6, 0}, { 0, -6, -3}, {-1.5, -6, 0},
}
local debris3Lines = {
{1,2},
{3,4},{4,5},{5,6},{6,7},
{8,9},
{1,8},{1,3},{1,7},{3,8},{7,8},
{9,10},
{10,11},{11,12},{12,13},{13,10},
{11,4},{13,6},{12,5},
{4,9},{6,9},
{2,5},{2,4},{2,6},
}
Models.debris3 = buildModel(debris3Verts, debris3Lines)
Models.debrisPool = { Models.debris1, Models.debris2, Models.debris3 }
return Models

14
game/camera.lua Normal file
View file

@ -0,0 +1,14 @@
local World = require("game.world")
local Projection = require("rendering.projection")
local Camera = {}
function Camera.update(player)
Projection.setCamera(player.x, player.z, player.angle)
end
function Camera.initViewport()
Projection.init(0, World.viewY, World.screenW, World.viewH)
end
return Camera

69
game/debris.lua Normal file
View file

@ -0,0 +1,69 @@
-- Freefalling debris: 6 shrapnel pieces spawned when a tank is destroyed.
-- Random linear + angular velocity with gravity; auto-despawn when they fall below ground.
local Palette = require("rendering.palette")
local Projection = require("rendering.projection")
local Models = require("data.models")
local Debris = {}
local pieces = {}
local PIECES_PER_EXPLOSION = 6
local MAX_VELOCITY = 120 -- spread of initial shrapnel velocities
local MAX_ANG_VELOCITY = 10 -- rad/s on each axis
local GRAVITY = 90 -- world units/sec² downward
local function randomModel()
local pool = Models.debrisPool
return pool[math.random(1, #pool)]
end
function Debris.explode(x, y, z)
y = y or 0
for _ = 1, PIECES_PER_EXPLOSION do
table.insert(pieces, {
x = x, y = y, z = z,
vx = (math.random() * 2 - 1) * MAX_VELOCITY,
vy = math.random() * MAX_VELOCITY, -- up (positive in our world)
vz = (math.random() * 2 - 1) * MAX_VELOCITY,
rx = math.random() * math.pi * 2,
ry = math.random() * math.pi * 2,
rz = math.random() * math.pi * 2,
drx = (math.random() * 2 - 1) * MAX_ANG_VELOCITY,
dry = (math.random() * 2 - 1) * MAX_ANG_VELOCITY,
drz = (math.random() * 2 - 1) * MAX_ANG_VELOCITY,
model = randomModel(),
})
end
end
function Debris.update(dt)
for i = #pieces, 1, -1 do
local d = pieces[i]
d.x = d.x + d.vx * dt
d.y = d.y + d.vy * dt
d.z = d.z + d.vz * dt
d.rx = d.rx + d.drx * dt
d.ry = d.ry + d.dry * dt
d.rz = d.rz + d.drz * dt
d.vy = d.vy - GRAVITY * dt
if d.y < 0 then
table.remove(pieces, i)
end
end
end
function Debris.draw()
local p = Palette.get()
love.graphics.setColor(p.bright)
love.graphics.setLineWidth(1.5)
for _, d in ipairs(pieces) do
Projection.drawModelXYZ(d.model, d.x, d.y, d.z, d.rx, d.ry, d.rz)
end
end
function Debris.clear()
pieces = {}
end
return Debris

64
game/effects.lua Normal file
View file

@ -0,0 +1,64 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Effects = {}
local CRACK_DURATION = 2.0
local CRACK_MIN_LINES = 8
local CRACK_MAX_LINES = 12
local crack = nil
local function buildCrack(cx, cy)
local lines = {}
local count = CRACK_MIN_LINES + math.floor(math.random() * (CRACK_MAX_LINES - CRACK_MIN_LINES + 1))
local maxLen = math.max(World.screenW, World.screenH)
for i = 1, count do
local angle = (i / count) * math.pi * 2 + (math.random() - 0.5) * 0.4
local len = maxLen * (0.6 + math.random() * 0.6)
-- Jagged shards: a few jittered segments from the impact point outward
local segments = {cx, cy}
local steps = 3
for s = 1, steps do
local frac = s / steps
local jitter = (math.random() - 0.5) * len * 0.12
local perpAngle = angle + math.pi / 2
local px = cx + math.cos(angle) * len * frac + math.cos(perpAngle) * jitter
local py = cy + math.sin(angle) * len * frac + math.sin(perpAngle) * jitter
table.insert(segments, px)
table.insert(segments, py)
end
table.insert(lines, segments)
end
return { lines = lines, age = 0 }
end
function Effects.triggerCrack(cx, cy)
cx = cx or World.screenW / 2
cy = cy or (World.radarH + World.viewH / 2)
crack = buildCrack(cx, cy)
end
function Effects.update(dt)
if crack then
crack.age = crack.age + dt
if crack.age >= CRACK_DURATION then crack = nil end
end
end
function Effects.draw()
if not crack then return end
local p = Palette.get()
local fade = 1 - (crack.age / CRACK_DURATION)
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], fade)
love.graphics.setLineWidth(2)
for _, poly in ipairs(crack.lines) do
love.graphics.line(poly)
end
end
function Effects.clear()
crack = nil
end
return Effects

482
game/enemies.lua Normal file
View file

@ -0,0 +1,482 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Projection = require("rendering.projection")
local Models = require("data.models")
local Projectiles = require("game.projectiles")
local Sounds = require("audio.sounds")
local Obstacles = require("game.obstacles")
local Player = require("game.player")
local Enemies = {}
local enemy = nil
local ufo = nil
-- Enemy definitions — points: tank 1000, missile 2000, super tank 3000,
-- saucer 5000. Speeds calibrated for our world scale.
local TYPES = {
tank = { points = 1000, speed = 48, fireRate = 2.5, radius = 20, model = "tank" },
supertank = { points = 3000, speed = 60, fireRate = 1.5, radius = 22, model = "supertank" },
missile = { points = 2000, speed = 240, fireRate = 0, radius = 8, model = "missile" },
}
local ROT_SPEED = 0.4 -- rad/s — slightly slower than the player tank
-- Authentic arcade: missiles enter the rotation *only once* score reaches
-- MISSILE_THRESHOLD (default 10000 per DIP switch). Once eligible, each spawn
-- has a fixed 1-in-3 chance of being a missile.
local MISSILE_MIX_PROB = 0.33
-- Guided-missile mechanics
local MAP_RADIUS = 900 -- radius used for spawning and for waypoint spacing
local MISSILE_FALL_SPEED = 60 -- world units/sec of vertical descent during spawn
local MISSILE_NUM_WAYPOINTS = 4
local MISSILE_DIVERT = 60 -- ±lateral jitter per waypoint
local MISSILE_HOMING_RATE = 0.1 -- rad/s during tracking phase
local MISSILE_VERTICAL_TRAVERSE = 80 -- u/s rise/fall over obstacles
local MISSILE_HOP_HEIGHT = 35 -- approximately tallest obstacle (pyramid apex = 30)
local function pickEnemyType(forceMissile)
-- Arcade rules: missiles enter rotation once score >= MISSILE_THRESHOLD;
-- super tanks replace slow tanks once SUPERTANK_MISSILE_COUNT missiles have
-- launched this game (not a score threshold — the authentic trigger).
if forceMissile then return "missile" end
local missilesLive = (World.score >= World.MISSILE_THRESHOLD)
and (math.random() < MISSILE_MIX_PROB)
if missilesLive then return "missile" end
if World.missilesLaunched >= World.SUPERTANK_MISSILE_COUNT then
return "supertank"
end
return "tank"
end
local function buildEnemy(etype, playerX, playerZ, playerAngle)
playerAngle = playerAngle or 0
local def = TYPES[etype]
-- Authentic spawn distance: 50/50 choice between "near" and "far" (arcade uses
-- 3/8 and 3/4 of its internal coord scale). In our ×3 world that's ~300 / ~600.
local dist = (math.random() < 0.5) and 350 or 650
local relAngle = math.random() * math.pi * 2 - math.pi -- -π .. π
local absAngle = playerAngle + relAngle
local x = playerX + math.sin(absAngle) * dist
local z = playerZ + math.cos(absAngle) * dist
x, z = World.wrapField(x, z)
-- Cone-avoidance: if spawn lies inside the ±π/6 frontal cone, randomise the
-- enemy's own facing so the player isn't instantly targeted point-blank.
local facing
if math.abs(relAngle) < math.pi / 6 then
facing = math.random() * math.pi * 2
else
-- Bias to face toward player but with ±45° jitter (same convention as movement)
facing = math.atan2(playerX - x, playerZ - z) + (math.random() - 0.5) * math.pi / 2
end
return {
x = x, z = z,
angle = facing,
etype = etype,
speed = def.speed,
fireRate = def.fireRate,
-- Fire-suppression rule: enemy can't fire for the first FIRE_SUPPRESSION
-- seconds after spawning (arcade: 2s). Seed the fire timer accordingly.
fireTimer = math.max(def.fireRate, World.FIRE_SUPPRESSION or 2.0),
radius = def.radius,
points = def.points,
model = def.model,
alive = true,
hit = false,
state = "pursuing",
stateTimer = 0,
lifetime = 0,
}
end
local function buildMissile(playerX, playerZ, playerAngle)
local def = TYPES.missile
-- Spawn directly ahead of player at map radius, high in the sky, still falling.
local spawnX = playerX + math.sin(playerAngle) * MAP_RADIUS
local spawnZ = playerZ + math.cos(playerAngle) * MAP_RADIUS
spawnX, spawnZ = World.wrapField(spawnX, spawnZ)
return {
x = spawnX, z = spawnZ,
y = 80, -- high in the sky, descends at fallSpeed
angle = playerAngle + math.pi, -- initially facing back toward player
etype = "missile",
speed = def.speed,
fireRate = 0,
fireTimer = 0,
radius = def.radius,
points = def.points,
model = def.model,
alive = true,
hit = false,
phase = "falling", -- falling → traversing (waypoints) → tracking (homing)
waypoints = nil,
spawnAngle = playerAngle, -- cache for waypoint generation at landing
}
end
local function buildMissileWaypoints(missile, playerX, playerZ)
-- 4 waypoints distributed along the spawn→player line, with ±60 lateral jitter
-- that alternates sign so the path weaves from side to side.
local waypoints = {}
local spawnAngle = missile.spawnAngle or 0
local forwardX = math.sin(spawnAngle)
local forwardZ = math.cos(spawnAngle)
local rightX = math.cos(spawnAngle) -- perpendicular (lateral)
local rightZ = -math.sin(spawnAngle)
local previousOffset = 0
for i = 1, MISSILE_NUM_WAYPOINTS do
-- distance along the player→spawn axis, measured from player toward spawn
local dAlong = MAP_RADIUS * i / (MISSILE_NUM_WAYPOINTS + 1)
local offset = (math.random() * 2 - 1) * MISSILE_DIVERT
if i > 1 and ((offset < 0 and previousOffset < 0) or (offset > 0 and previousOffset > 0)) then
offset = -offset
end
previousOffset = offset
-- waypoint position = player + along·forward + offset·right
local wx = playerX + dAlong * forwardX + offset * rightX
local wz = playerZ + dAlong * forwardZ + offset * rightZ
-- Insert at front so traversal order goes from furthest (spawn) → closest (player)
table.insert(waypoints, 1, { x = wx, z = wz })
end
missile.waypoints = waypoints
end
local function spawnEnemy(playerX, playerZ, playerAngle, forceMissile)
local etype = pickEnemyType(forceMissile)
if etype == "missile" then
enemy = buildMissile(playerX, playerZ, playerAngle)
World.missilesLaunched = (World.missilesLaunched or 0) + 1
Sounds.play("missile_launch")
else
enemy = buildEnemy(etype, playerX, playerZ, playerAngle)
-- Evasion deadline: if this tank stays alive too long, it'll be swapped
-- for a missile (arcade anti-camping rule, 4864s random).
World.evadeDeadline = World.EVADE_TANK_TIME_MIN
+ math.random() * (World.EVADE_TANK_TIME_MAX - World.EVADE_TANK_TIME_MIN)
end
end
function Enemies.init()
enemy = nil
ufo = nil
World.saucerCooldown = World.SAUCER_MAX_DELAY
World.missilesLaunched = 0
World.evadeDeadline = 0
end
function Enemies.get()
return enemy
end
function Enemies.getUFO()
return ufo
end
function Enemies.update(dt, playerX, playerZ, playerAngle)
playerAngle = playerAngle or 0
-- Saucer / UFO — classic vector-arcade rules (public preservation notes):
-- • first eligible at SAUCER_THRESHOLD (2000) points
-- • random 017 s between appearances
-- • initial position and movement are RANDOM and do NOT depend on player position or facing
-- • remains on the battlefield until destroyed (not despawned by flying off-field)
-- • does not attack, does not appear on radar
-- • worth 5000 points, can co-exist with an enemy tank/missile
if World.score >= World.SAUCER_THRESHOLD and not ufo then
World.saucerCooldown = World.saucerCooldown - dt
if World.saucerCooldown <= 0 then
-- Random spawn point somewhere on the field; random direction; random height.
local angle = math.random() * math.pi * 2
ufo = {
x = math.random() * World.FIELD_SIZE,
z = math.random() * World.FIELD_SIZE,
-- Hover at roughly tank-barrel height so the player's horizontal
-- shell trajectory actually reaches it (arcade authentic).
y = 6 + math.random() * 8,
vx = math.cos(angle) * 150,
vz = math.sin(angle) * 150,
alive = true,
hit = false,
radius = 18,
points = 5000,
}
World.saucerCooldown = World.SAUCER_MIN_DELAY + math.random() * (World.SAUCER_MAX_DELAY - World.SAUCER_MIN_DELAY)
Sounds.startSaucerHum()
end
end
if ufo then
-- Torus-wrap keeps the saucer on the field indefinitely (until shot), matching
-- the arcade's "remains until destroyed" rule.
ufo.x = ufo.x + ufo.vx * dt
ufo.z = ufo.z + ufo.vz * dt
ufo.x, ufo.z = World.wrapField(ufo.x, ufo.z)
-- Spatialise hum by bearing + distance in player frame (use wrappedDist so the
-- audio position follows the nearest wrap copy of the saucer, not the absolute).
local dx, dz, dist = World.wrappedDist(playerX, playerZ, ufo.x, ufo.z)
local cosA = math.cos(playerAngle)
local sinA = math.sin(playerAngle)
local rx = dx * cosA - dz * sinA
local rz = dx * sinA + dz * cosA
Sounds.updateSaucerHum(math.atan2(rx, rz), dist)
end
-- Evasion anti-camping: if the current tank has been alive past its evade
-- deadline (4864s) and isn't already a missile, swap it for a missile
-- (arcade: unpunished evasion eventually triggers the missile).
if World.state == "playing" and enemy
and enemy.etype ~= "missile"
and (enemy.lifetime or 0) >= (World.evadeDeadline or 48) then
enemy = buildMissile(playerX, playerZ, playerAngle)
World.missilesLaunched = (World.missilesLaunched or 0) + 1
Sounds.play("missile_launch")
World.spawnTimer = 0
end
-- Spawn enemy if needed
if not enemy and World.state == "playing" then
World.spawnTimer = World.spawnTimer + dt
if World.spawnTimer >= World.RESPAWN_SPAWN_BEAT then
World.spawnTimer = 0
spawnEnemy(playerX, playerZ, playerAngle)
end
return
end
if not enemy then return end
-- Tick the enemy's age (used by the evade-deadline replacement rule above)
enemy.lifetime = (enemy.lifetime or 0) + dt
-- Check if hit
if enemy.hit then
enemy.alive = false
return
end
local e = enemy
local dx, dz, dist = World.wrappedDist(e.x, e.z, playerX, playerZ)
if e.etype == "missile" then
-- Phase 1: fall from sky at fallSpeed until touching ground, then compute waypoints.
if e.phase == "falling" then
e.y = e.y - MISSILE_FALL_SPEED * dt
if e.y <= 0 then
e.y = 0
e.phase = "traversing"
buildMissileWaypoints(e, playerX, playerZ)
if not e.waypoints or #e.waypoints == 0 then
e.phase = "tracking"
end
end
return
end
-- Phase 2: traverse 4 waypoints, steering directly at each.
if e.phase == "traversing" then
local wp = e.waypoints[1]
if not wp then
e.phase = "tracking"
else
local wdx = wp.x - e.x
local wdz = wp.z - e.z
local wdist = math.sqrt(wdx * wdx + wdz * wdz)
if wdist <= e.speed * dt then
table.remove(e.waypoints, 1)
if #e.waypoints == 0 then
e.phase = "tracking"
end
end
local curr = e.waypoints[1]
if curr then
e.angle = math.atan2(curr.x - e.x, curr.z - e.z)
end
end
end
-- Phase 3: slow homing at MISSILE_HOMING_RATE rad/s toward the player.
if e.phase == "tracking" then
local targetAngle = math.atan2(dx, dz)
local angleDiff = targetAngle - e.angle
while angleDiff > math.pi do angleDiff = angleDiff - math.pi * 2 end
while angleDiff < -math.pi do angleDiff = angleDiff + math.pi * 2 end
local maxTurn = MISSILE_HOMING_RATE * dt
if math.abs(angleDiff) <= maxTurn then
e.angle = targetAngle
else
e.angle = e.angle + (angleDiff > 0 and maxTurn or -maxTurn)
end
end
-- Horizontal advance + vertical hop over obstacles (shared by phases 2 and 3).
-- If the next XZ position collides with an obstacle, rise at verticalTraverseSpeed
-- until clear. If clear and airborne, sink back to ground.
local nx = e.x + math.sin(e.angle) * e.speed * dt
local nz = e.z + math.cos(e.angle) * e.speed * dt
nx, nz = World.wrapField(nx, nz)
local blocked = Obstacles.checkCollision(nx, nz, e.radius)
if blocked then
e.y = math.min(MISSILE_HOP_HEIGHT, (e.y or 0) + MISSILE_VERTICAL_TRAVERSE * dt)
-- Only advance horizontally once we're above the obstacle.
if e.y >= MISSILE_HOP_HEIGHT then
e.x, e.z = nx, nz
end
else
e.x, e.z = nx, nz
if (e.y or 0) > 0 then
e.y = math.max(0, e.y - MISSILE_VERTICAL_TRAVERSE * dt)
end
end
-- Missile acts as its own projectile — check collision with player (after landing)
if e.phase ~= "falling" and dist < e.radius + 12 then
e.hit = true
local p = Player.get()
if p.alive and (p.invulnTimer or 0) <= 0 then
p.wasHit = true
end
end
return
end
-- Tank / Supertank AI
e.stateTimer = e.stateTimer + dt
e.bumpAngle = e.bumpAngle or 0
e.bumpDist = e.bumpDist or 0
-- Maneuver-after-bump: on hitting an obstacle, first finish spinning out
-- π/8 away from the obstacle, then drive forward a short distance
-- perpendicular to the player's line before resuming pursuit.
if e.bumpAngle > 0 then
local turn = ROT_SPEED * dt
e.angle = e.angle + (e.bumpDir or 1) * turn
e.bumpAngle = e.bumpAngle - turn
if e.bumpAngle <= 0 then
e.bumpAngle = 0
e.bumpDist = e.radius * 3 -- short drive before re-engaging
end
return
end
if e.bumpDist > 0 then
local step = e.speed * dt
local nx = e.x + math.sin(e.angle) * step
local nz = e.z + math.cos(e.angle) * step
nx, nz = World.wrapField(nx, nz)
if not Obstacles.checkCollision(nx, nz, e.radius) then
e.x, e.z = nx, nz
end
e.bumpDist = e.bumpDist - step
return
end
-- Turn toward player (forward = (sin, cos))
local targetAngle = math.atan2(dx, dz)
local angleDiff = targetAngle - e.angle
while angleDiff > math.pi do angleDiff = angleDiff - math.pi * 2 end
while angleDiff < -math.pi do angleDiff = angleDiff + math.pi * 2 end
if math.abs(angleDiff) > 0.1 then
local turnDir = angleDiff > 0 and 1 or -1
e.angle = e.angle + turnDir * ROT_SPEED * dt
end
-- Move forward (hold range when close). Detect bumps against obstacles and trigger
-- the maneuver state so the enemy edges around instead of grinding into a cube.
if dist > 120 then
local nx = e.x + math.sin(e.angle) * e.speed * dt
local nz = e.z + math.cos(e.angle) * e.speed * dt
nx, nz = World.wrapField(nx, nz)
local hit, obstacle = Obstacles.checkCollision(nx, nz, e.radius)
if hit then
-- Spin π/8 in whichever direction turns away from the obstacle fastest.
local obsDx = World.wrappedDelta(e.x, obstacle.x, World.FIELD_SIZE)
local obsDz = World.wrappedDelta(e.z, obstacle.z, World.FIELD_SIZE)
local angleToObstacle = math.atan2(obsDx, obsDz)
local diff = angleToObstacle - e.angle
while diff > math.pi do diff = diff - math.pi * 2 end
while diff < -math.pi do diff = diff + math.pi * 2 end
e.bumpAngle = math.pi / 8
e.bumpDir = diff >= 0 and -1 or 1 -- turn AWAY from the obstacle
else
e.x, e.z = nx, nz
end
end
-- Fire at player with a distance-aware cone: atan(tankHalfWidth / dist)
if e.fireRate > 0 then
e.fireTimer = e.fireTimer - dt
local playerHalfWidth = 12
local acceptableArc = math.atan(playerHalfWidth / math.max(dist, 1))
if e.fireTimer <= 0 and dist < 700 and math.abs(angleDiff) < acceptableArc then
e.fireTimer = e.fireRate
local inaccuracy = (math.random() - 0.5) * 0.1
Projectiles.fire(e.x, e.z, e.angle + inaccuracy, "enemy")
Sounds.play("enemy_fire")
end
end
end
function Enemies.destroy()
if not enemy then return 0, 0, 0 end
local pts = enemy.points
local x, z = enemy.x, enemy.z
enemy = nil
World.enemiesDestroyed = World.enemiesDestroyed + 1
World.spawnTimer = 0
return pts, x, z
end
function Enemies.destroyUFO()
if not ufo then return 0, 0, 0 end
local pts = ufo.points
local x, z = ufo.x, ufo.z
ufo = nil
Sounds.stopSaucerHum()
return pts, x, z
end
function Enemies.clear()
enemy = nil
ufo = nil
World.spawnTimer = 0
Sounds.stopSaucerHum()
end
function Enemies.draw(playerX, playerZ)
local p = Palette.get()
if enemy and enemy.alive then
local dx, dz, dist = World.wrappedDist(playerX, playerZ, enemy.x, enemy.z)
if dist < 900 then
love.graphics.setColor(p.enemy)
love.graphics.setLineWidth(2)
local wx = playerX + dx
local wz = playerZ + dz
local model = Models[enemy.model]
if model then
-- drawModel rotates local +z (model front) by rotY; for enemy facing
-- direction (sin(a), cos(a)), the required rotation is rotY = -a.
-- Missile uses its own y (falls from sky); tanks stay on the ground plane.
local ey = enemy.y or 0
Projection.drawModel(model, wx, ey, wz, -enemy.angle)
end
end
end
if ufo and ufo.alive then
-- Use wrappedDist so the saucer renders in the correct wrapped "copy" near the player
local dx, dz, dist = World.wrappedDist(playerX, playerZ, ufo.x, ufo.z)
if dist < 900 then
love.graphics.setColor(p.bright)
love.graphics.setLineWidth(2)
Projection.drawModel(Models.ufo, playerX + dx, ufo.y, playerZ + dz)
end
end
end
return Enemies

202
game/highscores.lua Normal file
View file

@ -0,0 +1,202 @@
local Sounds = require("audio.sounds")
local HighScores = {}
local MAX_SCORES = 10
local SAVE_FILE = "oma-tank_scores.dat"
local scores = {}
local entry = {
active = false,
score = 0,
letters = {"A", "A", "A"},
position = 1,
blink = 0,
}
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)
while #scores > MAX_SCORES do table.remove(scores) end
end
function HighScores.save()
local lines = {}
for _, e in ipairs(scores) do
table.insert(lines, string.format("%s %d", e.initials, e.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
function HighScores.startEntry(score)
entry.active = true
entry.score = score
entry.letters = {"A", "A", "A"}
entry.position = 1
entry.blink = 0
Sounds.playMusic("overture_1812")
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
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
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
Sounds.stopMusic()
HighScores.addScore(initials, score)
return "done"
elseif key:match("^%a$") and #key == 1 then
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
local t = love.timer.getTime()
love.graphics.setFont(fonts.large)
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")
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")
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
local isSelected = (i == entry.position)
if isSelected then
local blinkOn = math.floor(entry.blink * 3) % 2 == 0
love.graphics.setColor(blinkOn and palette.bright or palette.dim)
love.graphics.setLineWidth(3)
else
love.graphics.setColor(palette.dim)
love.graphics.setLineWidth(2)
end
love.graphics.rectangle("line", bx, by, boxW, boxH)
if isSelected then
love.graphics.setColor(palette.bright[1], palette.bright[2], palette.bright[3], 0.5)
local arrowX = bx + boxW / 2
love.graphics.polygon("fill", arrowX-5, by-4, arrowX+5, by-4, arrowX, by-12)
love.graphics.polygon("fill", arrowX-5, by+boxH+4, arrowX+5, by+boxH+4, arrowX, by+boxH+12)
end
love.graphics.setColor(palette.fg)
local lw = fonts.large:getWidth(entry.letters[i])
love.graphics.print(entry.letters[i], bx + (boxW - lw)/2, by + (boxH - letterH)/2)
end
love.graphics.setFont(fonts.small)
love.graphics.setColor(palette.dim)
love.graphics.printf("TYPE LETTERS / ARROWS / ENTER TO CONFIRM", 0, midY + boxH + 32, 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 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
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(string.format("%2d. %s", i, s.initials), startX, y)
local sw = fonts.small:getWidth(string.format("%d", s.score))
love.graphics.print(string.format("%d", s.score), startX + colW - sw, y)
end
end
return HighScores

187
game/horizon.lua Normal file
View file

@ -0,0 +1,187 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Projection = require("rendering.projection")
local Horizon = {}
-- Mountain profile: angular peaks as straight line segments
-- Each entry is {angle_degrees, height} — connected by straight lines
local mountainPoints = {}
local smokeParticles = {}
local VOLCANO_ANGLE = 230
-- Eruption phase: alternates between "quiet" and "active" periods so smoke reads as an eruption cycle
local eruption = { active = false, timer = 0, nextCycle = 8 }
-- Deterministic small PRNG for the mountain profile so the horizon stays the
-- same across a single session without touching math.random's global state.
local function lcg(seed)
local state = seed
return function()
state = (state * 1103515245 + 12345) % 2147483648
return state / 2147483648
end
end
function Horizon.init()
mountainPoints = {}
local rnd = lcg(12345)
local angle = 0
while angle < 360 do
local h = rnd() < 0.45
and (25 + rnd() * 45) -- peak
or (2 + rnd() * 10) -- valley / flat
table.insert(mountainPoints, {angle = angle, height = h})
-- Variable spacing — bigger jumps for angular look
angle = angle + 4 + rnd() * 8
end
-- Close the loop
table.insert(mountainPoints, {angle = 360, height = mountainPoints[1].height})
-- Tall narrow volcano
local volcanoPts = {
{VOLCANO_ANGLE - 15, 8},
{VOLCANO_ANGLE - 8, 25},
{VOLCANO_ANGLE - 3, 55},
{VOLCANO_ANGLE, 75},
{VOLCANO_ANGLE + 3, 55},
{VOLCANO_ANGLE + 8, 25},
{VOLCANO_ANGLE + 15, 8},
}
for _, vp in ipairs(volcanoPts) do
table.insert(mountainPoints, {angle = vp[1], height = vp[2]})
end
table.sort(mountainPoints, function(a, b) return a.angle < b.angle end)
smokeParticles = {}
eruption.active = false
eruption.timer = 0
eruption.nextCycle = 8 + math.random() * 6
end
-- Get mountain height at a specific angle by interpolating between nearest points
local function getMountainHeight(deg)
deg = deg % 360
-- Find the two points bracketing this angle
local prev = mountainPoints[#mountainPoints - 1]
local nxt = mountainPoints[1]
for i = 1, #mountainPoints - 1 do
if mountainPoints[i].angle <= deg and mountainPoints[i + 1].angle > deg then
prev = mountainPoints[i]
nxt = mountainPoints[i + 1]
break
end
end
-- Linear interpolation between the two points
local range = nxt.angle - prev.angle
if range <= 0 then return prev.height end
local t = (deg - prev.angle) / range
return prev.height + (nxt.height - prev.height) * t
end
function Horizon.update(dt)
-- Eruption cycle: quiet stretches then 3-5s active bursts
eruption.timer = eruption.timer + dt
if eruption.timer >= eruption.nextCycle then
eruption.timer = 0
eruption.active = not eruption.active
eruption.nextCycle = eruption.active
and (3 + math.random() * 2) -- active burst duration
or (10 + math.random() * 10) -- quiet interval between eruptions
end
if eruption.active and math.random() < 0.5 then
table.insert(smokeParticles, {
angle = VOLCANO_ANGLE + (math.random() - 0.5) * 6,
height = 78 + math.random() * 5,
vy = 18 + math.random() * 14,
vangle = (math.random() - 0.5) * 1.5,
life = 2.0 + math.random(),
maxLife = 3.0,
size = 1 + math.random() * 1.5,
})
end
for i = #smokeParticles, 1, -1 do
local sp = smokeParticles[i]
sp.height = sp.height + sp.vy * dt
sp.angle = sp.angle + sp.vangle * dt
sp.life = sp.life - dt
if sp.life <= 0 then table.remove(smokeParticles, i) end
end
end
function Horizon.draw(cameraAngle)
local p = Palette.get()
local vp = Projection.getViewport()
-- Procedural horizon "distance": smaller divisor → mountains appear closer/bigger.
-- 700 gives mountains roughly double the apparent size of our previous 1500 value
-- so the horizon reads as a near-ish backdrop rather than vanishingly far away.
local FAR_SCALE = (vp.w / 2 * math.sqrt(3)) / 700
-- === STRONG HORIZON LINE ===
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.8)
love.graphics.setLineWidth(2)
love.graphics.line(vp.x, vp.horizonY, vp.x + vp.w, vp.horizonY)
-- === MOUNTAINS: angular connected peaks ===
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.7)
love.graphics.setLineWidth(1.5)
-- Walk across screen columns in larger steps for angular look
local pts = {}
local step = 3
for sx = vp.x, vp.x + vp.w, step do
local relX = (sx - vp.x - vp.w / 2) / (vp.w / 2)
local worldAngle = cameraAngle + math.atan(relX)
local deg = math.deg(worldAngle) % 360
local height = getMountainHeight(deg)
local sy = vp.horizonY - height * FAR_SCALE
table.insert(pts, sx)
table.insert(pts, sy)
end
if #pts >= 4 then
love.graphics.line(pts)
end
-- === MOON ===
local moonWorldAngle = math.rad(10)
local moonRelAngle = moonWorldAngle - cameraAngle
while moonRelAngle > math.pi do moonRelAngle = moonRelAngle - math.pi * 2 end
while moonRelAngle < -math.pi do moonRelAngle = moonRelAngle + math.pi * 2 end
if math.abs(moonRelAngle) < math.pi / 4 then
local moonSx = vp.x + vp.w/2 + math.tan(moonRelAngle) * (vp.w/2)
local moonSy = vp.horizonY - vp.h * 0.28
local moonR = vp.h * 0.035
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.5)
love.graphics.setLineWidth(1.5)
love.graphics.circle("line", moonSx, moonSy, moonR, 16)
-- Crescent shadow
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.2)
love.graphics.arc("line", "open", moonSx + moonR*0.3, moonSy, moonR*0.7, -math.pi/2, math.pi/2, 10)
end
-- === VOLCANO SMOKE ===
for _, sp in ipairs(smokeParticles) do
local spRel = math.rad(sp.angle) - cameraAngle
while spRel > math.pi do spRel = spRel - math.pi * 2 end
while spRel < -math.pi do spRel = spRel + math.pi * 2 end
if math.abs(spRel) < math.pi / 4 then
local spSx = vp.x + vp.w/2 + math.tan(spRel) * (vp.w/2)
local spSy = vp.horizonY - sp.height * FAR_SCALE
local alpha = 0.35 * (sp.life / sp.maxLife)
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], alpha)
love.graphics.circle("fill", spSx, spSy, sp.size)
end
end
end
return Horizon

101
game/hud.lua Normal file
View file

@ -0,0 +1,101 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Fonts = require("rendering.fonts")
local HUD = {}
-- Tiny top-down tank icon (like the original lives display)
local function drawTankIcon(cx, cy, s)
-- Hull outline
love.graphics.line(cx - s*1.2, cy - s*0.5, cx + s*0.6, cy - s*0.5)
love.graphics.line(cx + s*0.6, cy - s*0.5, cx + s*0.8, cy)
love.graphics.line(cx + s*0.8, cy, cx + s*0.6, cy + s*0.5)
love.graphics.line(cx + s*0.6, cy + s*0.5, cx - s*1.2, cy + s*0.5)
love.graphics.line(cx - s*1.2, cy + s*0.5, cx - s*1.2, cy - s*0.5)
-- Barrel
love.graphics.line(cx + s*0.4, cy - s*0.15, cx + s*1.8, cy - s*0.15)
love.graphics.line(cx + s*0.4, cy + s*0.15, cx + s*1.8, cy + s*0.15)
end
local function bearingLabel(player, enemy)
-- Returns an arcade-style directional message: "ENEMY IN RANGE",
-- "ENEMY TO LEFT", "ENEMY TO RIGHT", or "ENEMY TO REAR". Returns nil
-- when there is no targetable enemy (e.g. only a saucer present).
if not (enemy and enemy.alive) then return nil end
local dx, dz = World.wrappedDist(player.x, player.z, enemy.x, enemy.z)
local cosA = math.cos(player.angle)
local sinA = math.sin(player.angle)
local rx = dx * cosA - dz * sinA -- rightward
local rz = dx * sinA + dz * cosA -- forward
local dist = math.sqrt(rx * rx + rz * rz)
local bearing = math.atan2(rx, rz)
if rz > 0 and math.abs(bearing) < 0.25 and dist < 800 then
return "ENEMY IN RANGE"
end
if math.abs(bearing) > 3 * math.pi / 4 then return "ENEMY TO REAR" end
if bearing > 0 then return "ENEMY TO RIGHT" end
return "ENEMY TO LEFT"
end
function HUD.draw(player, enemy)
local p = Palette.get()
local sw = World.screenW
local sh = World.screenH
local rh = World.radarH
local sc = sw / 1024
-- === LIVES (top-left) as tank icons ===
love.graphics.setColor(p.bright)
love.graphics.setLineWidth(1.3 * sc)
local iconSize = 7 * sc
local iconSpacing = 26 * sc
local livesStartX = 30 * sc
local livesY = rh * 0.25
for i = 1, math.min(World.lives, 6) do
drawTankIcon(livesStartX + (i - 1) * iconSpacing, livesY, iconSize)
end
-- === STATUS MESSAGES (authentic arcade: top-left of display) ===
-- "MOTION BLOCKED BY OBJECT" takes priority while active, then falls back to
-- the directional enemy text ("ENEMY IN RANGE" / "ENEMY TO LEFT/RIGHT/REAR").
if player and player.alive then
love.graphics.setFont(Fonts.small)
if (player.blockedMessageTimer or 0) > 0 then
local alpha = math.min(1, player.blockedMessageTimer / 0.3)
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], alpha)
love.graphics.print("MOTION BLOCKED BY OBJECT", livesStartX, rh * 0.55)
else
local label = bearingLabel(player, enemy)
if label then
love.graphics.setColor(p.bright)
love.graphics.print(label, livesStartX, rh * 0.55)
end
end
end
-- === SCORE (top-right, right-aligned) ===
love.graphics.setFont(Fonts.medium)
love.graphics.setColor(p.bright)
local scoreStr = string.format("%09d", World.score)
local rightEdge = sw - 30 * sc
local valW = Fonts.medium:getWidth(scoreStr)
local scoreY = rh * 0.35
love.graphics.print(scoreStr, rightEdge - valW, scoreY)
-- === HIGH SCORE (below score) ===
love.graphics.setFont(Fonts.small)
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.7)
local hsY = scoreY + Fonts.medium:getHeight() + 2 * sc
local hsStr = string.format("%09d", World.highScore)
local hsW = Fonts.small:getWidth(hsStr)
love.graphics.print(hsStr, rightEdge - hsW, hsY)
-- === SEPARATOR LINE ===
love.graphics.setColor(p.dim[1], p.dim[2], p.dim[3], 0.5)
love.graphics.setLineWidth(1)
love.graphics.line(0, rh, sw, rh)
end
return HUD

136
game/input.lua Normal file
View file

@ -0,0 +1,136 @@
-- Input abstraction: normalises keyboard (classic dual-track / modern simplified)
-- and gamepad (always dual-stick) into a pair of track values in [-1, 1].
local Input = {}
local SETTINGS_FILE = "oma-tank_settings.dat"
local STICK_DEADZONE = 0.18
local mode = "modern"
local joystick = nil
local function clamp(v)
if v < -1 then return -1 end
if v > 1 then return 1 end
return v
end
local function keyPair(positive, negative)
local v = 0
if love.keyboard.isDown(positive) then v = v + 1 end
if love.keyboard.isDown(negative) then v = v - 1 end
return v
end
local function modernTracks()
-- Up/Down drive both tracks, Left/Right split them
local fwd = 0
if love.keyboard.isDown("up", "w") then fwd = fwd + 1 end
if love.keyboard.isDown("down", "s") then fwd = fwd - 1 end
local turn = 0
if love.keyboard.isDown("right", "d") then turn = turn + 1 end
if love.keyboard.isDown("left", "a") then turn = turn - 1 end
-- Reverse at 50% feel: matches the prior simplified control
if fwd < 0 then fwd = fwd * 0.5 end
local left = clamp(fwd + turn)
local right = clamp(fwd - turn)
return left, right
end
local function classicTracks()
-- W/S = left track, I/K = right track
local left = keyPair("w", "s")
local right = keyPair("i", "k")
return clamp(left), clamp(right)
end
local function applyDeadzone(v)
if math.abs(v) < STICK_DEADZONE then return 0 end
return v
end
local function gamepadTracks()
if not joystick then return 0, 0 end
-- Stick Y axis is positive down on most gamepads; flip so "forward" on the stick = +1
local left = -applyDeadzone(joystick:getGamepadAxis("lefty"))
local right = -applyDeadzone(joystick:getGamepadAxis("righty"))
return clamp(left), clamp(right)
end
function Input.tracks()
local kl, kr
if mode == "classic" then
kl, kr = classicTracks()
else
kl, kr = modernTracks()
end
local gl, gr = gamepadTracks()
-- If either source is pushing a stick, prefer gamepad values for that track
local left = (gl ~= 0) and gl or kl
local right = (gr ~= 0) and gr or kr
return left, right
end
function Input.setMode(newMode)
if newMode == "classic" or newMode == "modern" then
mode = newMode
Input.save()
end
end
function Input.getMode()
return mode
end
function Input.toggleMode()
mode = (mode == "classic") and "modern" or "classic"
Input.save()
end
function Input.label()
return (mode == "classic") and "CLASSIC DUAL-TRACK" or "MODERN SIMPLIFIED"
end
function Input.save()
pcall(function()
love.filesystem.write(SETTINGS_FILE, "mode=" .. mode .. "\n")
end)
end
function Input.load()
if love.filesystem.getInfo(SETTINGS_FILE) then
local ok, content = pcall(love.filesystem.read, SETTINGS_FILE)
if ok and content then
local m = content:match("mode=(%w+)")
if m == "classic" or m == "modern" then mode = m end
end
end
end
function Input.attachJoystick()
local joysticks = love.joystick.getJoysticks()
for _, js in ipairs(joysticks) do
if js:isGamepad() then
joystick = js
return
end
end
joystick = nil
end
function Input.joystickAdded(js)
if js:isGamepad() and not joystick then
joystick = js
end
end
function Input.joystickRemoved(js)
if joystick == js then
joystick = nil
Input.attachJoystick()
end
end
return Input

106
game/obstacles.lua Normal file
View file

@ -0,0 +1,106 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Projection = require("rendering.projection")
local Models = require("data.models")
local Player = require("game.player")
local Obstacles = {}
local obstacles = {}
-- Obstacle type mix — four arcade-authentic shapes.
local OBSTACLE_TYPES = {
"pyramid", "pyramid", "pyramid", -- low/wide pyramid (shape 12)
"pyramidTall", "pyramidTall", -- tall/narrow pyramid (shape 0)
"cube", "cube", "cube", -- tall block (shape 1)
"halfPrism", "halfPrism", -- low block/slab (shape 15)
}
local RADIUS_BY_TYPE = {
pyramid = 18, pyramidTall = 13, cube = 20, halfPrism = 14,
}
-- Effective obstacle heights for shot-blocking. Shells that are in flight above
-- the obstacle's top pass over cleanly; below, they hit.
local HEIGHT_BY_TYPE = {
pyramid = 18, pyramidTall = 16, cube = 16, halfPrism = 7,
}
-- The field is a 4000-unit torus; at 900-unit visible radius, ~50 obstacles gives
-- ~57 in view at any time — dense enough to read as an obstacle field, sparse
-- enough that navigation doesn't become a maze.
local OBSTACLE_COUNT = 50
local function tooCloseToExisting(x, z, newRadius, minClearance)
for _, o in ipairs(obstacles) do
local dx = x - o.x
local dz = z - o.z
local gap = math.sqrt(dx * dx + dz * dz) - (o.radius + newRadius)
if gap < minClearance then return true end
end
return false
end
function Obstacles.init()
obstacles = {}
local S = World.FIELD_SIZE
local centre = S / 2
local CLEARANCE = 40 -- minimum gap between any two obstacles' edges
local SPAWN_CLEAR = 150 -- no obstacles within this radius of player spawn
-- Obstacle layout is random per game; global RNG is seeded once in love.load.
local placed = 0
local attempts = 0
while placed < OBSTACLE_COUNT and attempts < 4000 do
attempts = attempts + 1
local x = math.random(50, S - 50)
local z = math.random(50, S - 50)
local dx = x - centre
local dz = z - centre
local otype = OBSTACLE_TYPES[math.random(1, #OBSTACLE_TYPES)]
local r = RADIUS_BY_TYPE[otype] or 20
if math.sqrt(dx*dx + dz*dz) > SPAWN_CLEAR
and not tooCloseToExisting(x, z, r, CLEARANCE) then
table.insert(obstacles, {
x = x, z = z,
otype = otype,
radius = r,
height = HEIGHT_BY_TYPE[otype] or 16,
})
placed = placed + 1
end
end
end
function Obstacles.getAll()
return obstacles
end
function Obstacles.checkCollision(x, z, radius)
for _, o in ipairs(obstacles) do
local dx = World.wrappedDelta(x, o.x, World.FIELD_SIZE)
local dz = World.wrappedDelta(z, o.z, World.FIELD_SIZE)
local dist = math.sqrt(dx*dx + dz*dz)
if dist < radius + o.radius then
return true, o
end
end
return false
end
function Obstacles.draw()
local p = Palette.get()
love.graphics.setColor(p.obstacle)
local pl = Player.get()
local px, pz = pl.x, pl.z
for _, o in ipairs(obstacles) do
local dx, dz, dist = World.wrappedDist(px, pz, o.x, o.z)
if dist < 900 then
local model = Models[o.otype] or Models.cube
Projection.drawModel(model, px + dx, 0, pz + dz)
end
end
end
return Obstacles

120
game/player.lua Normal file
View file

@ -0,0 +1,120 @@
local World = require("game.world")
local Player = {}
-- Tank-feel constants tuned for the authentic 1980 arcade rhythm: deliberate
-- movement, slow rotation, one-track drive is a partial speed boost.
local MAX_SPEED = 60
local ROT_SPEED = 0.5 -- ~28°/s — deliberate but playable
local ONE_TRACK_BONUS = 1.5 -- one-track-only drives at 1.5× the average
local COLLISION_RADIUS = 12
local MOVE_EPSILON = 0.02
local BLOCKED_MSG_DURATION = 0.8
local player = {
x = 0, z = 0,
angle = 0,
alive = true,
invulnTimer = 0,
deathTimer = 0,
blockedMessageTimer = 0,
blockedJustTriggered = false,
_wasBlocked = false,
}
function Player.init()
player.x = World.FIELD_SIZE / 2
player.z = World.FIELD_SIZE / 2
player.angle = 0
player.alive = true
player.invulnTimer = 0 -- no respawn-invuln; the scene resets around the player
player.deathTimer = 0
player.blockedMessageTimer = 0
player.blockedJustTriggered = false
player._wasBlocked = false
end
function Player.get()
return player
end
function Player.update(dt, obstacles, leftTrack, rightTrack)
player.blockedMessageTimer = math.max(0, player.blockedMessageTimer - dt)
player._moving = false
if not player.alive then
player.deathTimer = player.deathTimer + dt
return
end
player.invulnTimer = math.max(0, player.invulnTimer - dt)
leftTrack = leftTrack or 0
rightTrack = rightTrack or 0
-- Rotation (opposing tracks): left track forward + right track back => turn right
local turn = (leftTrack - rightTrack) * 0.5
player.angle = player.angle + turn * ROT_SPEED * dt
-- Forward/reverse (average of tracks), with one-track-only speed bonus
local drive = (leftTrack + rightTrack) * 0.5
local leftActive = math.abs(leftTrack) > MOVE_EPSILON
local rightActive = math.abs(rightTrack) > MOVE_EPSILON
if (leftActive and not rightActive) or (rightActive and not leftActive) then
drive = drive * ONE_TRACK_BONUS
end
local blocked = false
if math.abs(drive) > MOVE_EPSILON then
player._moving = true
local speed = drive * MAX_SPEED
-- Forward vector: (sin(angle), cos(angle)); angle=0 → +z
local nx = player.x + math.sin(player.angle) * speed * dt
local nz = player.z + math.cos(player.angle) * speed * dt
nx, nz = World.wrapField(nx, nz)
if obstacles then
for _, o in ipairs(obstacles) do
local dx = World.wrappedDelta(nx, o.x, World.FIELD_SIZE)
local dz = World.wrappedDelta(nz, o.z, World.FIELD_SIZE)
local dist = math.sqrt(dx*dx + dz*dz)
if dist < COLLISION_RADIUS + o.radius then
blocked = true
break
end
end
end
if not blocked then
player.x = nx
player.z = nz
end
end
-- Fire the "MOTION BLOCKED" message once per sustained block, not every frame
if blocked and not player._wasBlocked then
player.blockedMessageTimer = BLOCKED_MSG_DURATION
player.blockedJustTriggered = true
end
player._wasBlocked = blocked
end
function Player.die()
player.alive = false
player.deathTimer = 0
end
function Player.respawn()
player.x = World.FIELD_SIZE / 2
player.z = World.FIELD_SIZE / 2
player.alive = true
player.invulnTimer = 0
player.deathTimer = 0
end
function Player.isMoving()
return player._moving == true
end
return Player

129
game/projectiles.lua Normal file
View file

@ -0,0 +1,129 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Projection = require("rendering.projection")
local Models = require("data.models")
local Projectiles = {}
local active = {}
local SPEED = 540 -- world units/sec — shell travels fast
local LIFETIME = 2.0
function Projectiles.fire(x, z, angle, owner)
-- Forward = (sin(angle), cos(angle))
table.insert(active, {
x = x, z = z, y = 10,
angle = angle,
vx = math.sin(angle) * SPEED,
vz = math.cos(angle) * SPEED,
owner = owner or "player",
life = LIFETIME,
})
end
-- Closest distance from a point (px, pz) to a line segment ((ax, az)-(bx, bz)).
-- Used for swept-sphere hit tests so fast-moving shells can't tunnel through a target.
local function segmentDist(px, pz, ax, az, bx, bz)
local lx, lz = bx - ax, bz - az
local len2 = lx * lx + lz * lz
if len2 < 1e-6 then
local dx, dz = px - ax, pz - az
return math.sqrt(dx * dx + dz * dz)
end
local t = ((px - ax) * lx + (pz - az) * lz) / len2
if t < 0 then t = 0 elseif t > 1 then t = 1 end
local cx, cz = ax + t * lx, az + t * lz
local dx, dz = px - cx, pz - cz
return math.sqrt(dx * dx + dz * dz)
end
function Projectiles.update(dt, player, enemy, obstacles, ufo)
local obstacleList = obstacles and obstacles.getAll() or nil
for i = #active, 1, -1 do
local b = active[i]
local oldX, oldZ = b.x, b.z
b.x = b.x + b.vx * dt
b.z = b.z + b.vz * dt
b.x, b.z = World.wrapField(b.x, b.z)
b.life = b.life - dt
local remove = false
if b.life <= 0 then
remove = true
end
-- Obstacle hit — swept test against every obstacle (prevents tunnelling at 540 u/s).
-- Shells that are flying above the obstacle's top pass over cleanly so
-- the player can shoot over low slabs like the arcade.
if not remove and obstacleList then
for _, o in ipairs(obstacleList) do
if b.y <= (o.height or 999) then
local d = segmentDist(o.x, o.z, oldX, oldZ, b.x, b.z)
if d < (o.radius or 20) + 3 then
remove = true
break
end
end
end
end
-- Player bullet vs enemy — swept test: check the segment (oldX,oldZ)→(b.x,b.z)
if not remove and b.owner == "player" and enemy and enemy.alive then
local dist = segmentDist(enemy.x, enemy.z, oldX, oldZ, b.x, b.z)
if dist < enemy.radius then
remove = true
enemy.hit = true
end
end
-- Player bullet vs saucer/UFO — bonus target; uses its full radius
if not remove and b.owner == "player" and ufo and ufo.alive then
local dist = segmentDist(ufo.x, ufo.z, oldX, oldZ, b.x, b.z)
if dist < ufo.radius then
remove = true
ufo.hit = true
end
end
-- Enemy bullet vs player — same swept test
if not remove and b.owner == "enemy" and player and player.alive and player.invulnTimer <= 0 then
local dist = segmentDist(player.x, player.z, oldX, oldZ, b.x, b.z)
if dist < 15 then
remove = true
player.wasHit = true
end
end
if remove then
table.remove(active, i)
end
end
end
function Projectiles.clear()
active = {}
end
function Projectiles.hasPlayerShot()
for _, b in ipairs(active) do
if b.owner == "player" then return true end
end
return false
end
function Projectiles.draw(playerX, playerZ)
local p = Palette.get()
love.graphics.setColor(p.projectile)
love.graphics.setLineWidth(2)
for _, b in ipairs(active) do
local dx, dz = World.wrappedDist(playerX, playerZ, b.x, b.z)
local wx = playerX + dx
local wz = playerZ + dz
-- drawModel rotY = -angle so the shell's local +z nose aligns with its direction
Projection.drawModel(Models.shell, wx, b.y, wz, -b.angle)
end
end
return Projectiles

120
game/radar.lua Normal file
View file

@ -0,0 +1,120 @@
local World = require("game.world")
local Palette = require("rendering.palette")
local Radar = {}
-- Authentic-arcade circular radar: rotating sweep line, fading blips where the
-- sweep last crossed an enemy, forward-cone diagonals, 4 compass ticks.
local RADAR_RANGE = 1000 -- world units shown inside the circle
local SWEEP_SPEED = 4 -- rad/s
-- Blip fade: alpha drops ~0.51 per second after the sweep has passed an enemy
local BLIP_FADE_PER_SEC = 0.51
local FORWARD_CONE_HALF = math.rad(30)
local sweepAngle = 0
local blips = {} -- [id] = { rx, ry, alpha } in radar-local coords
function Radar.init()
sweepAngle = 0
blips = {}
end
function Radar.update(dt, player, enemy)
-- Sweep rotates counter-clockwise (angle decreases each tick)
sweepAngle = (sweepAngle - SWEEP_SPEED * dt) % (math.pi * 2)
-- Fade existing blips
for id, b in pairs(blips) do
b.alpha = b.alpha - BLIP_FADE_PER_SEC * dt
if b.alpha <= 0 then blips[id] = nil end
end
if not (enemy and enemy.alive and player) then return end
-- Enemy position in radar-local (player-facing) coords — forward = +y on the scope
local dx, dz, dist = World.wrappedDist(player.x, player.z, enemy.x, enemy.z)
if dist > RADAR_RANGE then return end
local cosA = math.cos(player.angle)
local sinA = math.sin(player.angle)
local rx = dx * cosA - dz * sinA -- right on radar
local ry = dx * sinA + dz * cosA -- forward on radar (drawn as -y on screen)
-- Register a blip if the sweep line passed over the enemy's bearing this frame.
--
-- The sweep rotates CCW, so across one frame the sweep angle moves from
-- `lastSweep` → `sweepAngle` (a decrease mod 2π). The enemy is "inside" that
-- arc when its bearing lies in [sweepAngle, lastSweep]. The arc wraps past 0
-- when sweepAngle > lastSweep (decrementing wrapped around), so there are
-- two cases:
-- • non-wrap: sweepAngle < lastSweep → enemyBearing in [sweepAngle, lastSweep]
-- • wrapped: sweepAngle > lastSweep → enemyBearing in [sweepAngle, 2π) [0, lastSweep]
local enemyBearing = math.atan2(rx, ry) % (math.pi * 2)
local lastSweep = (sweepAngle + SWEEP_SPEED * dt) % (math.pi * 2)
local swept
if sweepAngle <= lastSweep then
swept = enemyBearing >= sweepAngle and enemyBearing <= lastSweep
else
swept = enemyBearing >= sweepAngle or enemyBearing <= lastSweep
end
if swept then
blips.current = { rx = rx / RADAR_RANGE, ry = ry / RADAR_RANGE, alpha = 1.0 }
end
end
function Radar.draw(player, enemy, _obstacles)
local p = Palette.get()
local sw = World.screenW
local rh = World.radarH
local sc = sw / 1024
-- Update drift, but in draw so caller doesn't need to plumb a dedicated hook yet.
Radar.update(love.timer.getDelta(), player, enemy)
-- Radar strip background
love.graphics.setColor(p.radar_bg[1], p.radar_bg[2], p.radar_bg[3], 0.4)
love.graphics.rectangle("fill", 0, 0, sw, rh)
-- Radar circle position — top-centre of the HUD strip
local radius = math.min(rh * 0.42, sw * 0.08)
local cx = sw / 2
local cy = rh / 2
-- Outer ring
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.65)
love.graphics.setLineWidth(1.5 * sc)
love.graphics.circle("line", cx, cy, radius, 48)
-- Forward cone (two diagonals upward from centre, spanning ±30°)
local coneX = math.sin(FORWARD_CONE_HALF) * radius
local coneY = math.cos(FORWARD_CONE_HALF) * radius
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.35)
love.graphics.setLineWidth(1 * sc)
love.graphics.line(cx, cy, cx - coneX, cy - coneY)
love.graphics.line(cx, cy, cx + coneX, cy - coneY)
-- Compass ticks (N/E/S/W) on the rim
local tick = radius * 0.12
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.5)
love.graphics.line(cx, cy - radius, cx, cy - radius + tick)
love.graphics.line(cx, cy + radius, cx, cy + radius - tick)
love.graphics.line(cx - radius, cy, cx - radius + tick, cy)
love.graphics.line(cx + radius, cy, cx + radius - tick, cy)
-- Sweep line (bearing 0 = straight up)
local sx = cx + math.sin(sweepAngle) * radius
local sy = cy - math.cos(sweepAngle) * radius
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], 0.8)
love.graphics.setLineWidth(1.5 * sc)
love.graphics.line(cx, cy, sx, sy)
-- Fading blips
for _, b in pairs(blips) do
local bx = cx + b.rx * radius
local by = cy - b.ry * radius
love.graphics.setColor(p.enemy[1], p.enemy[2], p.enemy[3], math.min(1, b.alpha))
love.graphics.circle("fill", bx, by, 3 * sc)
end
end
return Radar

105
game/world.lua Normal file
View file

@ -0,0 +1,105 @@
local World = {
GAME_W = 1024,
GAME_H = 768,
FIELD_SIZE = 4000,
visibleH = 768,
scale = 1,
offsetX = 0,
offsetY = 0,
screenW = 0,
screenH = 0,
-- Viewport regions (screen pixels)
radarH = 0,
viewY = 0,
viewH = 0,
-- Classic vector-arcade thresholds (defaults matching the most common DIP
-- switch settings documented in arcade-preservation records).
MISSILE_THRESHOLD = 10000, -- binary: below score, no missiles; at/above, missiles mix in
SUPERTANK_MISSILE_COUNT = 6, -- super tanks replace slow tanks after this many missiles have launched
SAUCER_THRESHOLD = 2000, -- saucer becomes eligible
SAUCER_MIN_DELAY = 0,
SAUCER_MAX_DELAY = 17,
EVADE_TANK_TIME_MIN = 48, -- if current tank stays alive this long, swap it for a missile (arcade: 48-64s)
EVADE_TANK_TIME_MAX = 64,
RESPAWN_SPAWN_BEAT = 2.5, -- seconds between enemy death and next spawn
FIRE_SUPPRESSION = 2.0, -- seconds after enemy spawn during which it cannot fire (arcade rule)
-- State
state = "title",
score = 0,
highScore = 0,
lives = 3,
nextExtraLife = 15000,
enemiesDestroyed = 0,
gameOverTimer = 0,
spawnTimer = 0,
saucerCooldown = 0, -- seconds until next saucer eligible
missilesLaunched = 0, -- total missile spawns this game (gates super-tank transition)
evadeDeadline = 0, -- RNG'd 48..64s lifetime limit for the current tank
}
function World.resize(w, h)
if not w or not h then w, h = love.graphics.getDimensions() end
World.screenW = w
World.screenH = h
World.scale = w / World.GAME_W
World.visibleH = h / World.scale
World.offsetX = 0
World.offsetY = 0
-- Viewport layout
World.radarH = math.floor(h * 0.18)
World.viewY = World.radarH
World.viewH = h - World.radarH
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)
local Camera = require("game.camera")
Camera.initViewport()
end
end
function World.wrapField(x, z)
local S = World.FIELD_SIZE
return (x % S + S) % S, (z % S + S) % S
end
function World.wrappedDelta(a, b, size)
local d = b - a
if d > size / 2 then d = d - size end
if d < -size / 2 then d = d + size end
return d
end
function World.wrappedDist(x1, z1, x2, z2)
local S = World.FIELD_SIZE
local dx = World.wrappedDelta(x1, x2, S)
local dz = World.wrappedDelta(z1, z2, S)
return dx, dz, math.sqrt(dx*dx + dz*dz)
end
function World.addScore(points)
World.score = World.score + points
local granted = false
while World.score >= World.nextExtraLife do
World.lives = World.lives + 1
granted = true
if World.nextExtraLife < 100000 then
World.nextExtraLife = 100000
else
World.nextExtraLife = World.nextExtraLife + 100000
end
end
if granted then
local ok, Sounds = pcall(require, "audio.sounds")
if ok and Sounds.play then Sounds.play("extra_life") end
end
if World.score > World.highScore then
World.highScore = World.score
end
end
return World

18
icon.svg Normal file
View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" fill="#000"/>
<!-- Wireframe tank silhouette (side profile) -->
<g fill="none" stroke="#4ade80" stroke-width="2" stroke-linejoin="round">
<!-- Hull -->
<polyline points="10,40 14,32 46,32 50,40 10,40"/>
<!-- Turret -->
<polyline points="22,32 24,24 36,24 38,32"/>
<!-- Barrel -->
<line x1="36" y1="27" x2="56" y2="27"/>
<!-- Tracks -->
<rect x="10" y="40" width="40" height="8"/>
<line x1="16" y1="40" x2="16" y2="48"/>
<line x1="24" y1="40" x2="24" y2="48"/>
<line x1="32" y1="40" x2="32" y2="48"/>
<line x1="40" y1="40" x2="40" y2="48"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 721 B

142
install.sh Executable file
View file

@ -0,0 +1,142 @@
#!/bin/bash
set -euo pipefail
# OMA-TANK Installer / Uninstaller
# Usage: ./install.sh — install the game
# ./install.sh uninstall — remove the game
GAME_NAME="oma-tank"
DISPLAY_NAME="OMA-TANK"
COMMENT="First-person wireframe tank combat with Omarchy theme integration"
REPO_URL="https://git.no-signal.uk/nosignal/oma-tank.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
icon_path="$ICON_DIR/${size}x${size}/apps/$GAME_NAME.png"
[ -f "$icon_path" ] && rm "$icon_path"
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
mkdir -p "$HOME/.local/bin"
cat > "$UNINSTALL_BIN" << 'UNINSTALL'
#!/bin/bash
# Uninstall OMA-TANK
SCRIPT_URL="https://git.no-signal.uk/nosignal/oma-tank/raw/branch/master/install.sh"
curl -sL "$SCRIPT_URL" | bash -s uninstall 2>/dev/null || bash "$HOME/.local/share/oma-tank/install.sh" uninstall 2>/dev/null || {
rm -f "$HOME/.local/share/applications/oma-tank.desktop"
rm -rf "$HOME/.local/share/oma-tank"
for s in 16 32 48 64 128 256 512; do
rm -f "$HOME/.local/share/icons/hicolor/${s}x${s}/apps/oma-tank.png"
done
rm -f "$HOME/.local/share/icons/hicolor/scalable/apps/oma-tank.svg"
rm -f "$HOME/.local/bin/oma-tank-uninstall"
command -v omarchy-restart-walker &>/dev/null && omarchy-restart-walker 2>/dev/null
echo "OMA-TANK 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 ""

463
main.lua Normal file
View file

@ -0,0 +1,463 @@
local World = require("game.world")
local Camera = require("game.camera")
local Player = require("game.player")
local Enemies = require("game.enemies")
local Obstacles = require("game.obstacles")
local Projectiles = require("game.projectiles")
local Horizon = require("game.horizon")
local Radar = require("game.radar")
local HUD = require("game.hud")
local HighScores = require("game.highscores")
local Effects = require("game.effects")
local Debris = require("game.debris")
local Input = require("game.input")
local Palette = require("rendering.palette")
local Fonts = require("rendering.fonts")
local Projection = require("rendering.projection")
local Models = require("data.models")
local Sounds = require("audio.sounds")
local RESPAWN_DELAY = 2.5
local engineTimer = 0
local function tryFire(pl)
-- Authentic rule: "one shot on the playfield at a time." The reticle flashes
-- while your shell is in flight and you can fire again the moment it dies.
if pl.alive and not Projectiles.hasPlayerShot() then
-- Spawn shot at barrel tip: forward = (sin, cos), 20 units ahead, slight barrel height
Projectiles.fire(
pl.x + math.sin(pl.angle) * 20,
pl.z + math.cos(pl.angle) * 20,
pl.angle, "player"
)
Sounds.play("fire")
end
end
local function startGame()
World.state = "playing"
World.score = 0
World.lives = 3
World.nextExtraLife = 15000
World.enemiesDestroyed = 0
World.spawnTimer = 0
World.gameOverTimer = 0
Player.init()
Enemies.init()
Obstacles.init()
Projectiles.clear()
Effects.clear()
Debris.clear()
Radar.init()
Camera.initViewport()
Sounds.play("enemy_appear")
end
local FIRE_RETICLE_ARC = math.pi / 64 -- angular half-width for "enemy in sights" cue
local function enemyInSights(pl, enemy)
if not (enemy and enemy.alive) then return false end
local dx, dz = World.wrappedDist(pl.x, pl.z, enemy.x, enemy.z)
local cosA = math.cos(pl.angle)
local sinA = math.sin(pl.angle)
local rx = dx * cosA - dz * sinA
local rz = dx * sinA + dz * cosA
if rz <= 0 then return false end
return math.abs(math.atan2(rx, rz)) < FIRE_RETICLE_ARC
end
local function drawReticle(pl, enemy)
local p = Palette.get()
local sw, sh = World.screenW, World.screenH
local cx, cy = sw / 2, sh / 2
local size = sh * (100 / 900)
local spacing = size / 1.8
local inSights = enemyInSights(pl, enemy)
-- Reticle flashes at 0.15s intervals whenever the player's shell is in flight
-- (authentic arcade cue that you can't fire again yet).
if Projectiles.hasPlayerShot() then
local phase = math.floor(love.timer.getTime() / 0.15)
if phase % 2 == 0 then return end
end
local curlLen = inSights and (size / 5) or (size / 10)
local halfBar = size / 2
love.graphics.setColor(p.tank)
love.graphics.setLineWidth(math.max(1.4, sw / 1024 * 1.6))
-- Top bracket: horizontal bar + vertical tick upward from centre
love.graphics.line(cx - halfBar, cy - spacing, cx + halfBar, cy - spacing)
love.graphics.line(cx, cy - spacing, cx, cy - spacing - size)
-- Bottom bracket: mirror
love.graphics.line(cx - halfBar, cy + spacing, cx + halfBar, cy + spacing)
love.graphics.line(cx, cy + spacing, cx, cy + spacing + size)
-- Bracket-end "curls": hang inward from each end of the two horizontal bars.
-- Vertical when idle (short ticks), diagonal slanting inward when enemy is centred.
local function curl(x, y, dirX, dirY, diagonal)
if diagonal then
love.graphics.line(x, y, x + dirX * curlLen, y + dirY * curlLen)
else
love.graphics.line(x, y, x, y + dirY * curlLen)
end
end
-- Top bar ends: curls hang downward toward centre
curl(cx - halfBar, cy - spacing, 1, 1, inSights)
curl(cx + halfBar, cy - spacing, -1, 1, inSights)
-- Bottom bar ends: curls reach upward toward centre
curl(cx - halfBar, cy + spacing, 1, -1, inSights)
curl(cx + halfBar, cy + spacing, -1, -1, inSights)
end
local function drawGameWorld()
local pl = Player.get()
local p = Palette.get()
local sw, sh = World.screenW, World.screenH
-- Background
love.graphics.setColor(p.bg)
love.graphics.rectangle("fill", 0, World.radarH, sw, World.viewH)
-- Set scissor to 3D viewport
love.graphics.setScissor(0, World.radarH, sw, World.viewH)
Camera.update(pl)
-- Mountains, horizon line, moon, smoke
Horizon.draw(pl.angle)
-- Obstacles
love.graphics.setLineWidth(1.5)
Obstacles.draw()
-- Enemies
Enemies.draw(pl.x, pl.z)
-- Projectiles
Projectiles.draw(pl.x, pl.z)
-- Debris from tank destruction (3D tumbling wireframes)
Debris.draw()
love.graphics.setScissor()
-- Reticle (two stacked brackets, curls flip + blink when enemy is in sights)
if pl.alive then
drawReticle(pl, Enemies.get())
end
-- Radar + HUD
Radar.draw(pl, Enemies.get(), Obstacles.getAll())
HUD.draw(pl, Enemies.get())
Effects.draw()
end
local function drawTitleScreen()
local p = Palette.get()
local sw, sh = World.screenW, World.screenH
local t = love.timer.getTime()
-- Background
love.graphics.setColor(p.bg)
love.graphics.rectangle("fill", 0, 0, sw, sh)
-- Rotating tank model in centre
love.graphics.setScissor(0, sh * 0.15, sw, sh * 0.4)
Projection.init(0, sh * 0.15, sw, sh * 0.4)
Projection.setCamera(0, 0, 0)
love.graphics.setColor(p.tank)
love.graphics.setLineWidth(2)
Projection.drawModel(Models.tank, 0, 0, 120, t * 0.5)
love.graphics.setScissor()
-- Restore viewport for gameplay
Camera.initViewport()
-- Title
local titleY = sh * 0.05
love.graphics.setFont(Fonts.large)
love.graphics.setColor(p.bright)
love.graphics.printf("OMA-TANK", 0, titleY, sw, "center")
-- Controls / mode
love.graphics.setFont(Fonts.small)
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.5)
local controlHint
if Input.getMode() == "classic" then
controlHint = "W/S: LEFT TRACK I/K: RIGHT TRACK SPACE: FIRE"
else
controlHint = "ARROWS / WASD: DRIVE SPACE: FIRE"
end
love.graphics.printf(controlHint, 0, sh * 0.56, sw, "center")
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.35)
love.graphics.printf("MODE: " .. Input.label() .. " [TAB] TO SWITCH", 0, sh * 0.60, sw, "center")
-- Press Enter
local pulse = 0.3 + math.sin(t * 3) * 0.3
love.graphics.setFont(Fonts.medium)
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse + 0.2)
love.graphics.printf("PRESS ENTER TO START", 0, sh * 0.66, sw, "center")
-- High scores
local allScores = HighScores.getScores()
if #allScores > 0 then
HighScores.drawTable(sw, sh, p, Fonts)
end
end
function love.load()
math.randomseed(os.time())
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()
Horizon.init()
Obstacles.init()
Camera.initViewport()
Input.load()
Input.attachJoystick()
World.highScore = HighScores.getHighest()
World.state = "title"
end
function love.resize(w, h)
World.resize(w, h)
Fonts.init(World.scale)
Camera.initViewport()
end
function love.update(dt)
World.ensureScale()
if World.state == "title" then
return
end
if World.state == "playing" then
local pl = Player.get()
local leftT, rightT = Input.tracks()
Player.update(dt, Obstacles.getAll(), leftT, rightT)
-- Engine sound
if pl.alive and Player.isMoving() then
engineTimer = engineTimer + dt
if engineTimer > 0.25 then
engineTimer = 0
Sounds.play("engine")
end
end
-- (Anti-camping now handled in Enemies.update via each tank's lifetime
-- vs. World.evadeDeadline — no per-frame timer needed here.)
-- Motion-blocked sound cue (one-shot per sustained block)
if pl.blockedJustTriggered then
pl.blockedJustTriggered = false
Sounds.play("motion_blocked")
end
Enemies.update(dt, pl.x, pl.z, pl.angle)
Projectiles.update(dt, pl, Enemies.get(), Obstacles, Enemies.getUFO())
Horizon.update(dt)
Effects.update(dt)
Debris.update(dt)
-- Check if enemy was hit
local enemy = Enemies.get()
if enemy and not enemy.alive then
local pts, ex, ez = Enemies.destroy()
World.addScore(pts)
Sounds.play("hit_enemy")
Debris.explode(ex, 0, ez)
end
-- UFO hit processing (Projectiles.update flags .hit; finalise here)
local ufo = Enemies.getUFO()
if ufo and ufo.hit then
local pts, ux, uz = Enemies.destroyUFO()
World.addScore(pts)
Sounds.play("hit_enemy")
Debris.explode(ux, 40, uz)
end
-- Check if player was hit
if pl.wasHit then
pl.wasHit = false
if pl.alive then
Player.die()
Sounds.play("hit_player")
Sounds.play("screen_crack")
Effects.triggerCrack()
Debris.explode(pl.x, 0, pl.z)
World.lives = World.lives - 1
end
end
-- Respawn or game over
if not pl.alive then
if pl.deathTimer >= RESPAWN_DELAY then
if World.lives > 0 then
-- On respawn the scene is reset: fresh obstacle placement,
-- enemy cleared, projectiles wiped, debris dropped.
Obstacles.init()
Enemies.clear()
Projectiles.clear()
Debris.clear()
Effects.clear()
Player.respawn()
Sounds.play("enemy_appear")
else
World.state = "game_over"
World.gameOverTimer = 0
Sounds.play("hit_player")
end
end
end
elseif World.state == "game_over" then
World.gameOverTimer = World.gameOverTimer + 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()
local sw, sh = World.screenW, World.screenH
if World.state == "title" then
drawTitleScreen()
return
end
if World.state == "high_score_entry" then
love.graphics.setColor(p.bg)
love.graphics.rectangle("fill", 0, 0, sw, sh)
HighScores.drawEntry(sw, sh, p, Fonts)
return
end
drawGameWorld()
if World.state == "game_over" then
local t = love.timer.getTime()
local midY = sh * 0.4
-- Dim overlay
love.graphics.setColor(0, 0, 0, 0.6)
love.graphics.rectangle("fill", 0, World.radarH, sw, World.viewH)
if World.gameOverTimer > 0.5 then
love.graphics.setFont(Fonts.large)
local textPulse = 0.7 + math.sin(t * 3) * 0.3
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], textPulse)
love.graphics.printf("GAME OVER", 0, midY, sw, "center")
end
if World.gameOverTimer > 1.5 then
love.graphics.setFont(Fonts.medium)
love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.7)
love.graphics.printf("SCORE: " .. string.format("%09d", World.score), 0, midY + Fonts.large:getHeight() + 8, sw, "center")
end
if World.gameOverTimer > 2.5 then
local pulse = 0.3 + math.sin(t * 3) * 0.3
love.graphics.setFont(Fonts.small)
love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse + 0.2)
love.graphics.printf("PRESS ENTER", 0, midY + Fonts.large:getHeight() + Fonts.medium:getHeight() + 24, sw, "center")
end
end
end
function love.keypressed(key)
if World.state == "title" then
if key == "return" then startGame() end
if key == "tab" then Input.toggleMode() end
if key == "escape" then love.event.quit() end
return
end
if World.state == "high_score_entry" then
local result = HighScores.keypressedEntry(key)
if result == "done" then
World.highScore = HighScores.getHighest()
World.state = "title"
end
return
end
if World.state == "playing" then
if key == "space" then tryFire(Player.get()) 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
function love.gamepadpressed(joystick, button)
if World.state == "title" then
if button == "start" or button == "a" then startGame() end
if button == "back" or button == "y" then Input.toggleMode() end
return
end
if World.state == "playing" then
if button == "a" or button == "rightshoulder" then tryFire(Player.get()) end
return
end
if World.state == "game_over" and World.gameOverTimer > 2.0 then
if button == "a" or button == "start" then
if HighScores.isHighScore(World.score) then
World.state = "high_score_entry"
HighScores.startEntry(World.score)
else
World.state = "title"
end
end
end
end
function love.joystickadded(js)
Input.joystickAdded(js)
end
function love.joystickremoved(js)
Input.joystickRemoved(js)
end
function love.gamepadaxis(joystick, axis, value)
-- Fire on right trigger press
if axis == "triggerright" and value > 0.8 and World.state == "playing" then
tryFire(Player.get())
end
end

64
rendering/fonts.lua Normal file
View file

@ -0,0 +1,64 @@
local Fonts = {}
Fonts.small = nil
Fonts.medium = nil
Fonts.large = nil
Fonts.currentH = 0
Fonts.fontPath = nil
Fonts.fontData = nil
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*$")
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 ~= "" then
local test = io.open(path, "r")
if test then test:close(); return path end
end
return nil
end
function Fonts.init(scale)
local h = love.graphics.getHeight()
if h == Fonts.currentH then return end
Fonts.currentH = h
if not Fonts.fontPath then
Fonts.fontPath = Fonts.detectSystemFont() or false
end
if Fonts.fontPath and not Fonts.fontData then
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
local sS = math.max(10, math.floor(h * 0.018))
local sM = math.max(12, math.floor(h * 0.025))
local sL = math.max(16, math.floor(h * 0.045))
if Fonts.fontData then
Fonts.small = love.graphics.newFont(Fonts.fontData, sS)
Fonts.medium = love.graphics.newFont(Fonts.fontData, sM)
Fonts.large = love.graphics.newFont(Fonts.fontData, sL)
else
Fonts.small = love.graphics.newFont(sS)
Fonts.medium = love.graphics.newFont(sM)
Fonts.large = love.graphics.newFont(sL)
end
end
return Fonts

69
rendering/palette.lua Normal file
View file

@ -0,0 +1,69 @@
-- Palette auto-detected from current Omarchy system theme
local Palette = {}
local theme = {}
local function hexToRGB(hex)
-- Accept only well-formed #rrggbb; anything else falls back to white rather
-- than returning {nil, nil, nil} which would crash love.graphics.setColor.
if type(hex) ~= "string" then return {1, 1, 1} end
local rr, gg, bb = hex:match("^#?(%x%x)(%x%x)(%x%x)$")
if not rr then return {1, 1, 1} end
return { tonumber(rr, 16) / 255, tonumber(gg, 16) / 255, tonumber(bb, 16) / 255 }
end
function Palette.loadFromSystem()
local path = os.getenv("HOME") .. "/.config/omarchy/current/theme/ghostty.conf"
local f = io.open(path, "r")
if not f then
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
-- Require exactly 6 hex digits so partial/truncated entries don't slip through
local key, val = line:match("^(%S+)%s*=%s*(#%x%x%x%x%x%x)%s*$")
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%x%x%x%x%x)%s*$")
if idx and hex then theme["color" .. idx] = hexToRGB(hex) end
end
f:close()
theme.dim = theme.color8 or theme.color0 or {0.2, 0.2, 0.2}
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
function Palette.get()
return {
bg = theme.bg,
fg = theme.fg,
tank = theme.fg,
enemy = theme.color4,
obstacle = theme.color2 or theme.color3,
projectile = theme.accent,
ground = theme.color0,
mountain = theme.color8 or theme.dim,
radar_bg = theme.color0,
radar_fg = theme.fg,
hud = theme.fg,
bright = theme.accent,
dim = theme.dim,
explosion = theme.accent,
}
end
return Palette

145
rendering/projection.lua Normal file
View file

@ -0,0 +1,145 @@
-- Minimal wireframe 3D projection engine
-- Flat ground plane, 60-degree horizontal FOV, near-plane clipping.
local Projection = {}
local cam = { x = 0, y = 9.7, z = 0, angle = 0, cosA = 1, sinA = 0 }
local CAM_HEIGHT = 9.7 -- camera eye at the tank's barrel height
local vp = { x = 0, y = 0, w = 1024, h = 600, halfW = 512, halfH = 300, horizonY = 0 }
local NEAR = 2.0
local SQRT3 = math.sqrt(3)
local focalLength = 512
function Projection.init(viewX, viewY, viewW, viewH)
vp.x = viewX
vp.y = viewY
vp.w = viewW
vp.h = viewH
vp.halfW = viewW / 2
vp.halfH = viewH / 2
-- 60° total horizontal FOV: focal = halfW * sqrt(3). Half-angle = atan(1/sqrt(3)) = 30°.
focalLength = vp.halfW * SQRT3
vp.horizonY = viewY + viewH * 0.5 -- horizon at exact centre; projection math treats cy=0 as horizon
end
function Projection.setCamera(x, z, angle)
-- World convention: at angle=0, player faces +z (into screen).
-- Forward unit vector = (sin(angle), cos(angle)). Camera rotates world by +angle
-- so that the player's forward direction maps to camera-space +z. Camera sits
-- at CAM_HEIGHT above the ground so the ground plane renders below horizon.
cam.x = x
cam.y = CAM_HEIGHT
cam.z = z
cam.angle = angle
cam.cosA = math.cos(angle)
cam.sinA = math.sin(angle)
end
function Projection.getViewport()
return vp
end
-- Transform world point to camera space
function Projection.worldToCamera(wx, wy, wz)
local dx = wx - cam.x
local dy = wy - cam.y
local dz = wz - cam.z
local cx = dx * cam.cosA - dz * cam.sinA
local cz = dx * cam.sinA + dz * cam.cosA
return cx, dy, cz
end
-- Project camera-space point to screen
function Projection.cameraToScreen(cx, cy, cz)
if cz <= NEAR then return nil, nil end
local sx = vp.x + vp.halfW + (cx / cz) * focalLength
local sy = vp.horizonY - (cy / cz) * focalLength
return sx, sy
end
-- Combined: world to screen
function Projection.projectPoint(wx, wy, wz)
local cx, cy, cz = Projection.worldToCamera(wx, wy, wz)
if cz <= NEAR then return nil, nil, cz end
local sx, sy = Projection.cameraToScreen(cx, cy, cz)
return sx, sy, cz
end
-- Clip and draw a 3D line segment
function Projection.drawLine3D(x1, y1, z1, x2, y2, z2)
local cx1, cy1, cz1 = Projection.worldToCamera(x1, y1, z1)
local cx2, cy2, cz2 = Projection.worldToCamera(x2, y2, z2)
-- Both behind camera
if cz1 <= NEAR and cz2 <= NEAR then return end
-- Clip against near plane
if cz1 <= NEAR then
local t = (NEAR - cz1) / (cz2 - cz1)
cx1 = cx1 + t * (cx2 - cx1)
cy1 = cy1 + t * (cy2 - cy1)
cz1 = NEAR
elseif cz2 <= NEAR then
local t = (NEAR - cz2) / (cz1 - cz2)
cx2 = cx2 + t * (cx1 - cx2)
cy2 = cy2 + t * (cy1 - cy2)
cz2 = NEAR
end
local sx1 = vp.x + vp.halfW + (cx1 / cz1) * focalLength
local sy1 = vp.horizonY - (cy1 / cz1) * focalLength
local sx2 = vp.x + vp.halfW + (cx2 / cz2) * focalLength
local sy2 = vp.horizonY - (cy2 / cz2) * focalLength
love.graphics.line(sx1, sy1, sx2, sy2)
end
-- Draw a wireframe model at a world position with Y-axis rotation
function Projection.drawModel(model, wx, wy, wz, rotY)
rotY = rotY or 0
local cosR = math.cos(rotY)
local sinR = math.sin(rotY)
for _, edge in ipairs(model) do
-- Rotate edge vertices around Y axis
local lx1 = edge[1] * cosR - edge[3] * sinR
local lz1 = edge[1] * sinR + edge[3] * cosR
local lx2 = edge[4] * cosR - edge[6] * sinR
local lz2 = edge[4] * sinR + edge[6] * cosR
Projection.drawLine3D(
wx + lx1, wy + edge[2], wz + lz1,
wx + lx2, wy + edge[5], wz + lz2
)
end
end
-- Rotate (x, y, z) through Euler angles (rx, ry, rz) applied in order X→Y→Z.
-- Used by tumbling debris.
local function rotateXYZ(x, y, z, cx, sx, cy, sy, cz, sz)
-- X-axis rotation
local y1 = y * cx - z * sx
local z1 = y * sx + z * cx
-- Y-axis rotation
local x2 = x * cy + z1 * sy
local z2 = -x * sy + z1 * cy
-- Z-axis rotation
local x3 = x2 * cz - y1 * sz
local y3 = x2 * sz + y1 * cz
return x3, y3, z2
end
-- Draw a wireframe model with full Euler-angle rotation (rx, ry, rz).
function Projection.drawModelXYZ(model, wx, wy, wz, rx, ry, rz)
rx = rx or 0; ry = ry or 0; rz = rz or 0
local cx, sx = math.cos(rx), math.sin(rx)
local cy, sy = math.cos(ry), math.sin(ry)
local cz, sz = math.cos(rz), math.sin(rz)
for _, edge in ipairs(model) do
local x1, y1, z1 = rotateXYZ(edge[1], edge[2], edge[3], cx, sx, cy, sy, cz, sz)
local x2, y2, z2 = rotateXYZ(edge[4], edge[5], edge[6], cx, sx, cy, sy, cz, sz)
Projection.drawLine3D(wx + x1, wy + y1, wz + z1, wx + x2, wy + y2, wz + z2)
end
end
return Projection