oma-tank/main.lua
2026-04-18 11:35:06 +01:00

463 lines
14 KiB
Lua

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