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

482 lines
19 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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