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