463 lines
14 KiB
Lua
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
|