commit 026e607b075525be88b8d312d04274e40bc87183 Author: 28allday Date: Sat Apr 18 11:35:06 2026 +0100 Initial public release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a29cbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +CLAUDE.md +.claude/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bec152 --- /dev/null +++ b/README.md @@ -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 0–17 s intervals + +### Evasion penalty + +Avoid a tank for 48–64 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 diff --git a/audio/sounds.lua b/audio/sounds.lua new file mode 100644 index 0000000..383b2e3 --- /dev/null +++ b/audio/sounds.lua @@ -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 diff --git a/conf.lua b/conf.lua new file mode 100644 index 0000000..3126a05 --- /dev/null +++ b/conf.lua @@ -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 diff --git a/data/models.lua b/data/models.lua new file mode 100644 index 0000000..b6d1d62 --- /dev/null +++ b/data/models.lua @@ -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 diff --git a/game/camera.lua b/game/camera.lua new file mode 100644 index 0000000..bd686b6 --- /dev/null +++ b/game/camera.lua @@ -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 diff --git a/game/debris.lua b/game/debris.lua new file mode 100644 index 0000000..c5e3d32 --- /dev/null +++ b/game/debris.lua @@ -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 diff --git a/game/effects.lua b/game/effects.lua new file mode 100644 index 0000000..06b93da --- /dev/null +++ b/game/effects.lua @@ -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 diff --git a/game/enemies.lua b/game/enemies.lua new file mode 100644 index 0000000..3e05f8b --- /dev/null +++ b/game/enemies.lua @@ -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, 48–64s 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 0–17 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 (48–64s) 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 diff --git a/game/highscores.lua b/game/highscores.lua new file mode 100644 index 0000000..9e6eb4d --- /dev/null +++ b/game/highscores.lua @@ -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 diff --git a/game/horizon.lua b/game/horizon.lua new file mode 100644 index 0000000..008b5aa --- /dev/null +++ b/game/horizon.lua @@ -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 diff --git a/game/hud.lua b/game/hud.lua new file mode 100644 index 0000000..520acf6 --- /dev/null +++ b/game/hud.lua @@ -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 diff --git a/game/input.lua b/game/input.lua new file mode 100644 index 0000000..cac1626 --- /dev/null +++ b/game/input.lua @@ -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 diff --git a/game/obstacles.lua b/game/obstacles.lua new file mode 100644 index 0000000..f8ce0e0 --- /dev/null +++ b/game/obstacles.lua @@ -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 +-- ~5–7 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 diff --git a/game/player.lua b/game/player.lua new file mode 100644 index 0000000..7c24e63 --- /dev/null +++ b/game/player.lua @@ -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 diff --git a/game/projectiles.lua b/game/projectiles.lua new file mode 100644 index 0000000..8d7456b --- /dev/null +++ b/game/projectiles.lua @@ -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 diff --git a/game/radar.lua b/game/radar.lua new file mode 100644 index 0000000..43560dd --- /dev/null +++ b/game/radar.lua @@ -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 diff --git a/game/world.lua b/game/world.lua new file mode 100644 index 0000000..431c48f --- /dev/null +++ b/game/world.lua @@ -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 diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..addf51b --- /dev/null +++ b/icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ed3d53e --- /dev/null +++ b/install.sh @@ -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 "" diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..77e55a8 --- /dev/null +++ b/main.lua @@ -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 diff --git a/rendering/fonts.lua b/rendering/fonts.lua new file mode 100644 index 0000000..4f8558d --- /dev/null +++ b/rendering/fonts.lua @@ -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 diff --git a/rendering/palette.lua b/rendering/palette.lua new file mode 100644 index 0000000..ec504eb --- /dev/null +++ b/rendering/palette.lua @@ -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 diff --git a/rendering/projection.lua b/rendering/projection.lua new file mode 100644 index 0000000..3f1ef10 --- /dev/null +++ b/rendering/projection.lua @@ -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