local World = require("game.world") local Palette = require("rendering.palette") local Projection = require("rendering.projection") local Models = require("data.models") local Projectiles = {} local active = {} local SPEED = 540 -- world units/sec — shell travels fast local LIFETIME = 2.0 function Projectiles.fire(x, z, angle, owner) -- Forward = (sin(angle), cos(angle)) table.insert(active, { x = x, z = z, y = 10, angle = angle, vx = math.sin(angle) * SPEED, vz = math.cos(angle) * SPEED, owner = owner or "player", life = LIFETIME, }) end -- Closest distance from a point (px, pz) to a line segment ((ax, az)-(bx, bz)). -- Used for swept-sphere hit tests so fast-moving shells can't tunnel through a target. local function segmentDist(px, pz, ax, az, bx, bz) local lx, lz = bx - ax, bz - az local len2 = lx * lx + lz * lz if len2 < 1e-6 then local dx, dz = px - ax, pz - az return math.sqrt(dx * dx + dz * dz) end local t = ((px - ax) * lx + (pz - az) * lz) / len2 if t < 0 then t = 0 elseif t > 1 then t = 1 end local cx, cz = ax + t * lx, az + t * lz local dx, dz = px - cx, pz - cz return math.sqrt(dx * dx + dz * dz) end function Projectiles.update(dt, player, enemy, obstacles, ufo) local obstacleList = obstacles and obstacles.getAll() or nil for i = #active, 1, -1 do local b = active[i] local oldX, oldZ = b.x, b.z b.x = b.x + b.vx * dt b.z = b.z + b.vz * dt b.x, b.z = World.wrapField(b.x, b.z) b.life = b.life - dt local remove = false if b.life <= 0 then remove = true end -- Obstacle hit — swept test against every obstacle (prevents tunnelling at 540 u/s). -- Shells that are flying above the obstacle's top pass over cleanly so -- the player can shoot over low slabs like the arcade. if not remove and obstacleList then for _, o in ipairs(obstacleList) do if b.y <= (o.height or 999) then local d = segmentDist(o.x, o.z, oldX, oldZ, b.x, b.z) if d < (o.radius or 20) + 3 then remove = true break end end end end -- Player bullet vs enemy — swept test: check the segment (oldX,oldZ)→(b.x,b.z) if not remove and b.owner == "player" and enemy and enemy.alive then local dist = segmentDist(enemy.x, enemy.z, oldX, oldZ, b.x, b.z) if dist < enemy.radius then remove = true enemy.hit = true end end -- Player bullet vs saucer/UFO — bonus target; uses its full radius if not remove and b.owner == "player" and ufo and ufo.alive then local dist = segmentDist(ufo.x, ufo.z, oldX, oldZ, b.x, b.z) if dist < ufo.radius then remove = true ufo.hit = true end end -- Enemy bullet vs player — same swept test if not remove and b.owner == "enemy" and player and player.alive and player.invulnTimer <= 0 then local dist = segmentDist(player.x, player.z, oldX, oldZ, b.x, b.z) if dist < 15 then remove = true player.wasHit = true end end if remove then table.remove(active, i) end end end function Projectiles.clear() active = {} end function Projectiles.hasPlayerShot() for _, b in ipairs(active) do if b.owner == "player" then return true end end return false end function Projectiles.draw(playerX, playerZ) local p = Palette.get() love.graphics.setColor(p.projectile) love.graphics.setLineWidth(2) for _, b in ipairs(active) do local dx, dz = World.wrappedDist(playerX, playerZ, b.x, b.z) local wx = playerX + dx local wz = playerZ + dz -- drawModel rotY = -angle so the shell's local +z nose aligns with its direction Projection.drawModel(Models.shell, wx, b.y, wz, -b.angle) end end return Projectiles