482 lines
19 KiB
Lua
482 lines
19 KiB
Lua
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
|