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