^ """"""""""  >?""""  ""  ""  UU"  R^" "  " " "%" ""  PP " ] P"  ."."  ^ ""^^"""^^"""P%"" ^""PUUM N    N  PPPU DD P^ DD PU^N ND P^D UPP^NN   N N                                 N D  ND N N  D ND DNNDN @  N                rA-- title: Orbital Artillery -- author: frantisek -- desc: Worms / Scorched Earth style 2-player artillery game in space. -- site: website link -- license: MIT License -- version: 0.1 -- script: lua -- ========================================================= -- ORBITAL ARTILLERY -- Two tanks sit on separate planets and take turns shooting -- each other. Every planet pulls the shell toward it with its -- own gravity, so the flight path curves. After each shot the -- planets spin a little, carrying the tanks with them, so the -- next shot is never quite the same as the last. -- ========================================================= G = 1.02 -- global gravity constant (~15% weaker than the original 1.2, easier to escape a well) SPIN_MULT = 3 -- planets (and the tanks riding them) spin 3x faster between rounds -- --------------------------------------------------------- -- WORLD SETUP -- --------------------------------------------------------- local function dist(x1, y1, x2, y2) local dx, dy = x2 - x1, y2 - y1 return math.sqrt(dx * dx + dy * dy) end math.randomseed(tstamp()) planets = {} tanks = { { color = 9, power = 50, hp = 3, spriteId = 1, color= 2}, -- player 1: blue { color = 2, power = 50, hp = 3, spriteId = 2, color= 9}, -- player 2: red } -- builds a fresh, randomized, non-overlapping set of planets and -- drops the two tanks on two different (also random) planets local function generateMap() local newPlanets = {} local tries = 0 while #newPlanets < 4 and tries < 4000 do tries = tries + 1 local r = math.random(12, 20) local x = math.random(25 + r, 215 - r) local y = math.random(25 + r, 105 - r) local ok = true for _, p in ipairs(newPlanets) do if dist(x, y, p.x, p.y) < (r + p.r + 16) then ok = false break end end if ok then newPlanets[#newPlanets + 1] = { x = x, y = y, r = r } end end -- extremely unlikely fallback if the map couldn't fit 4 circles if #newPlanets < 4 then newPlanets = { { x = 40, y = 40, r = 18 }, { x = 190, y = 30, r = 14 }, { x = 50, y = 100, r = 16 }, { x = 170, y = 100, r = 20 }, } end for _, p in ipairs(newPlanets) do p.mass = p.r * p.r * 3 p.rotation = 0 local sign = (math.random(0, 1) == 0) and 1 or -1 p.spin = sign * (0.04 + math.random() * 0.03) * SPIN_MULT end planets = newPlanets -- put the tanks on the two planets that are furthest apart from each other local bestI, bestJ, bestD = 1, 2, -1 for i = 1, #planets do for j = i + 1, #planets do local d = dist(planets[i].x, planets[i].y, planets[j].x, planets[j].y) if d > bestD then bestD, bestI, bestJ = d, i, j end end end tanks[1].planetIndex = bestI tanks[2].planetIndex = bestJ -- point each tank's starting facing roughly at the other tank's planet local p1, p2 = planets[bestI], planets[bestJ] local a1 = math.atan(p2.y - p1.y, p2.x - p1.x) local a2 = math.atan(p1.y - p2.y, p1.x - p2.x) tanks[1].startAngle = a1 tanks[2].startAngle = a2 tanks[1].aimAngle = math.deg(a1) % 360 tanks[2].aimAngle = math.deg(a2) % 360 tanks[1].angle = a1 tanks[2].angle = a2 end -- a handful of fixed "stars" for a bit of background flavour stars = {} for i = 1, 40 do stars[i] = { x = math.random(0, 239), y = math.random(0, 135), c = (math.random(0, 1) == 0) and 13 or 15 } end current = 1 -- which player (tank index) is currently aiming phase = "aim" -- "setup" | "choose" | "aim" | "walk" | "fly" | "result" | "gameover" choice = 1 -- 1 = Shoot, 2 = Walk (used during "choose" phase) setupChoice = 1 -- 1 = Reshuffle Map, 2 = Start Battle (used during "setup" phase) walkStartAngle = 0 -- tank angle when "walk" phase began, used to cap movement range message = "" resultTimer = 0 proj = nil -- {x,y,vx,vy} projTrail = {} winner = nil shotHistory = { {}, {} } -- per-tank list of up to 2 previous flight trails, drawn as faint reference lines -- --------------------------------------------------------- -- HELPERS -- --------------------------------------------------------- local function tankPos(tank) local p = planets[tank.planetIndex] local offset = p.r + 3 local x = p.x + math.cos(tank.angle) * offset local y = p.y + math.sin(tank.angle) * offset return x, y end local function gravityAccel(x, y) local ax, ay = 0, 0 for _, p in ipairs(planets) do local dx, dy = p.x - x, p.y - y local d2 = dx * dx + dy * dy local d = math.sqrt(d2) if d > 1 then local g = (G * p.mass) / d2 ax = ax + g * dx / d ay = ay + g * dy / d end end return ax, ay end local function planetHit(x, y) for i, p in ipairs(planets) do if dist(x, y, p.x, p.y) <= p.r then return i end end return nil end local function tankHit(x, y) for i, tank in ipairs(tanks) do local tx, ty = tankPos(tank) if dist(x, y, tx, ty) <= 5 then return i end end return nil end TRAJECTORY_PREVIEW_STEPS = 8 -- number of raw physics frames shown; a faster shot covers more ground so its line is longer local function simulateTrajectory(x, y, vx, vy, steps) local pts = { { x = x, y = y } } for i = 1, steps do local ax, ay = gravityAccel(x, y) vx, vy = vx + ax, vy + ay x, y = x + vx, y + vy pts[#pts + 1] = { x = x, y = y } if planetHit(x, y) or x < -100 or x > 340 or y < -100 or y > 240 then break end end return pts end -- --------------------------------------------------------- -- GAME FLOW -- --------------------------------------------------------- function resetGame() generateMap() -- fresh random layout phase = "setup" setupChoice = 1 message = "" proj = nil projTrail = {} winner = nil end local function startGame() tanks[1].angle = tanks[1].startAngle tanks[2].angle = tanks[2].startAngle tanks[1].power, tanks[1].hp = 50, 3 tanks[2].power, tanks[2].hp = 50, 3 current = 1 phase = "choose" choice = 1 shotHistory = { {}, {} } end resetGame() local function updateSetup() if btnp(0) then setupChoice = 1 end -- Up: Reshuffle Map if btnp(1) then setupChoice = 2 end -- Down: Start Battle if btnp(2) or btnp(3) then setupChoice = (setupChoice == 1) and 2 or 1 end -- Left/Right: toggle if btnp(4) then if setupChoice == 1 then generateMap() -- reshuffle and stay on the setup menu else startGame() end end end local function updateChoose() if btnp(0) then choice = 1 end -- Up: Shoot if btnp(1) then choice = 2 end -- Down: Walk if btnp(2) or btnp(3) then choice = (choice == 1) and 2 or 1 end -- Left/Right: toggle if btnp(4) then if choice == 1 then phase = "aim" else walkStartAngle = tanks[current].angle phase = "walk" end end end local function fireProjectile() local tank = tanks[current] local tx, ty = tankPos(tank) local rad = math.rad(tank.aimAngle) local speed = 1 + tank.power / 100 * 7 local dirx, diry = math.cos(rad), math.sin(rad) proj = { x = tx + dirx * 8, y = ty + diry * 8, vx = dirx * speed, vy = diry * speed } projTrail = {} phase = "fly" end local function endRound(msg) message = msg phase = "result" resultTimer = 90 end local function updateAim() local tank = tanks[current] local fine = btn(5) -- hold B/X to fine-tune aim & power local angleStep = fine and 0.2 or 1.5 local powerStep = fine and 0.2 or 1 if btn(2) then tank.aimAngle = tank.aimAngle - angleStep end if btn(3) then tank.aimAngle = tank.aimAngle + angleStep end if tank.aimAngle < 0 then tank.aimAngle = tank.aimAngle + 360 end if tank.aimAngle >= 360 then tank.aimAngle = tank.aimAngle - 360 end if btn(0) then tank.power = math.min(100, tank.power + powerStep) end if btn(1) then tank.power = math.max(0, tank.power - powerStep) end if btnp(4) then fireProjectile() end end local function updateWalk() local tank = tanks[current] local fine = btn(5) -- hold B/X to fine-tune the walk local step = fine and 0.008 or 0.03 if btn(2) then tank.angle = tank.angle - step end if btn(3) then tank.angle = tank.angle + step end if btnp(4) then endRound("Player " .. current .. " moved to a new position.") end end local FLIGHT_DT = 1 / 3 -- sub-stepped time factor: same trajectory/ODE, just 3x more frames to fly it particles = {} -- little debris bits thrown out when a tank is hit local function spawnExplosion(x, y) for i = 1, 14 do local ang = math.random() * 2 * math.pi local spd = 0.5 + math.random() * 2.5 particles[#particles + 1] = { x = x, y = y, vx = math.cos(ang) * spd, vy = math.sin(ang) * spd, life = 20 + math.random(0, 20), } end end local function updateParticles() for i = #particles, 1, -1 do local p = particles[i] p.x = p.x + p.vx p.y = p.y + p.vy p.vx = p.vx * 0.95 p.vy = p.vy * 0.95 p.life = p.life - 1 if p.life <= 0 then table.remove(particles, i) end end end local function recordShot() local hist = shotHistory[current] hist[#hist + 1] = projTrail if #hist > 2 then table.remove(hist, 1) end end local function updateFly() local ax, ay = gravityAccel(proj.x, proj.y) proj.vx = proj.vx + ax * FLIGHT_DT proj.vy = proj.vy + ay * FLIGHT_DT proj.x = proj.x + proj.vx * FLIGHT_DT proj.y = proj.y + proj.vy * FLIGHT_DT projTrail[#projTrail + 1] = { x = proj.x, y = proj.y } local hitTank = tankHit(proj.x, proj.y) if hitTank then tanks[hitTank].hp = tanks[hitTank].hp - 1 recordShot() spawnExplosion(proj.x, proj.y) if hitTank == current then sfx(0, "C-3", 20, 0, 12) -- lower "oops" tone for friendly fire endRound("Player " .. current .. " hit their OWN tank!") else sfx(0, "E-5", 15, 0, 15) -- bright "ding" for a clean hit endRound("Player " .. current .. " scored a direct hit!") end return end if planetHit(proj.x, proj.y) then recordShot() sfx(0, "C-2", 10, 0, 10) -- dull thud endRound("The shell slammed into a planet.") return end if proj.x < -60 or proj.x > 300 or proj.y < -60 or proj.y > 200 then recordShot() sfx(0, "A-1", 8, 0, 6) -- soft, quiet miss endRound("The shell flew off into space...") return end end local function updateResult() resultTimer = resultTimer - 1 if resultTimer <= 0 then for i, tank in ipairs(tanks) do if tank.hp <= 0 then phase = "gameover" winner = (i == 1) and 2 or 1 sfx(0, "C-6", 50, 0, 15) -- victory tone return end end -- the planets drift a little, carrying their tanks with them for _, p in ipairs(planets) do p.rotation = p.rotation + p.spin end for _, tank in ipairs(tanks) do tank.angle = tank.angle + planets[tank.planetIndex].spin end current = (current == 1) and 2 or 1 choice = 1 phase = "choose" end end local function updateGameOver() if btnp(4) then resetGame() end end -- --------------------------------------------------------- -- DRAWING -- --------------------------------------------------------- local function drawStars() for _, s in ipairs(stars) do pix(s.x, s.y, s.c) end end local function drawPlanets() for _, p in ipairs(planets) do circ(p.x, p.y, p.r, 14) circb(p.x, p.y, p.r, 13) local lx = p.x + math.cos(p.rotation) * p.r local ly = p.y + math.sin(p.rotation) * p.r line(p.x, p.y, lx, ly, 4) end end -- Each tank body is an 8x8 tile (see the block at the end of the -- file: tile 1 is the blue tank, tile 2 is the red one). drawRotatedSprite -- draws that tile as two textured triangles (textri) whose destination -- corners are rotated around the tank's position, so the sprite spins -- smoothly to keep its "outward" edge (the light-colored tip, drawn on the -- tile's right side) pointing away from the planet. local function drawRotatedSprite(id, cx, cy, angle, scale, colorkey) local u0 = (id % 16) * 8 local v0 = math.floor(id / 16) * 8 local umid, vmid = u0 + 4, v0 + 4 local c, s = math.cos(angle), math.sin(angle) local function corner(u, v) local lx, ly = u - umid, v - vmid local dx = (lx * c - ly * s) * scale local dy = (lx * s + ly * c) * scale return cx + dx, cy + dy end local x1, y1 = corner(u0, v0) local x2, y2 = corner(u0 + 8, v0) local x3, y3 = corner(u0 + 8, v0 + 8) local x4, y4 = corner(u0, v0 + 8) textri(x1, y1, x2, y2, x3, y3, u0, v0, u0 + 7, v0, u0 + 7, v0 + 7, false, colorkey) textri(x1, y1, x3, y3, x4, y4, u0, v0, u0 + 7, v0 + 7, u0, v0 + 7, false, colorkey) end local function drawTanks() for i, tank in ipairs(tanks) do if tank.hp > 0 then local tx, ty = tankPos(tank) local rad = math.rad(tank.aimAngle) line(tx, ty, tx + math.cos(rad) * 9, ty + math.sin(rad) * 9, tank.color) drawRotatedSprite(tank.spriteId, tx, ty, tank.angle, 1, 0) if i == current and (phase == "aim" or phase == "choose" or phase == "walk") then circb(tx, ty, 5, 5) -- green ring marks the tank currently taking its turn end end end end local function drawParticles() for _, p in ipairs(particles) do local col = (p.life > 15) and 4 or ((p.life > 7) and 3 or 2) pix(math.floor(p.x + 0.5), math.floor(p.y + 0.5), col) end end local function drawShotHistory() for _, hist in ipairs(shotHistory) do for _, trail in ipairs(hist) do for i = 1, #trail - 1 do line(trail[i].x, trail[i].y, trail[i + 1].x, trail[i + 1].y, 14) end end end end local function drawHpPips(tank, x, y, dir) for i = 1, 3 do local px = x + dir * (i - 1) * 6 if i <= tank.hp then rect(px, y, 4, 4, tank.color) else rectb(px, y, 4, 4, 13) end end end local function drawHUD() drawHpPips(tanks[1], 4, 4, 1) drawHpPips(tanks[2], 236 - 12, 4, 1) if phase == "setup" then local t1 = "ORBITAL ARTILLERY" print(t1, 120 - (#t1 * 4) / 2, 34, 12, false, 1, true) local labels = { "RESHUFFLE MAP", "START BATTLE" } for i, label in ipairs(labels) do local col = (i == setupChoice) and 5 or 13 local prefix = (i == setupChoice) and "> " or " " local text = prefix .. label print(text, 120 - (#text * 4) / 2, 88 + (i - 1) * 10, col, false, 1, true) end local t4 = "UP/DOWN or LEFT/RIGHT: select Z: confirm" print(t4, 120 - (#t4 * 4) / 2, 116, 13, false, 1, true) elseif phase == "choose" then local tank = tanks[current] print("P" .. current .. " - choose action", 4, 14, tank.color, false, 1, true) local labels = { "SHOOT", "WALK" } for i, label in ipairs(labels) do local y = 30 + (i - 1) * 10 local col = (i == choice) and 5 or 13 local prefix = (i == choice) and "> " or " " print(prefix .. label, 4, y, col, false, 1, true) end print("UP/DOWN or LEFT/RIGHT: select Z: confirm", 4, 126, 13, false, 1, true) elseif phase == "walk" then local tank = tanks[current] print("P" .. current .. " - repositioning tank", 4, 14, tank.color, false, 1, true) print("LEFT/RIGHT: walk around planet Z: confirm", 4, 126, 13, false, 1, true) print("hold X: fine-tune", 4, 118, 13, false, 1, true) elseif phase == "aim" then local tank = tanks[current] print("P" .. current .. " AIM " .. math.floor(tank.aimAngle) .. " POW " .. math.floor(tank.power), 4, 14, tank.color, false, 1, true) print("ARROWS: aim/power Z: fire", 4, 126, 13, false, 1, true) print("hold X: fine-tune", 4, 118, 13, false, 1, true) elseif phase == "fly" then for _, pt in ipairs(projTrail) do pix(pt.x, pt.y, 10) end circ(proj.x, proj.y, 2, 12) elseif phase == "result" then local w = #message * 4 print(message, 120 - w / 2, 64, 12, false, 1, true) elseif phase == "gameover" then local msg = "PLAYER " .. winner .. " WINS!" local w = #msg * 4 print(msg, 120 - w / 2, 58, tanks[winner].color, false, 1, true) local msg2 = "press Z to play again" local w2 = #msg2 * 4 print(msg2, 120 - w2 / 2, 70, 12, false, 1, true) end end -- --------------------------------------------------------- -- MAIN LOOP -- --------------------------------------------------------- function TIC() updateParticles() -- debris keeps drifting/fading regardless of game phase if phase == "setup" then updateSetup() elseif phase == "choose" then updateChoose() elseif phase == "aim" then updateAim() elseif phase == "walk" then updateWalk() elseif phase == "fly" then updateFly() elseif phase == "result" then updateResult() elseif phase == "gameover" then updateGameOver() end cls(0) drawStars() drawPlanets() drawShotHistory() drawTanks() drawParticles() if phase == "aim" then local tank = tanks[current] local tx, ty = tankPos(tank) local rad = math.rad(tank.aimAngle) local speed = 1 + tank.power / 100 * 7 local pts = simulateTrajectory( tx + math.cos(rad) * 8, ty + math.sin(rad) * 8, math.cos(rad) * speed, math.sin(rad) * speed, TRAJECTORY_PREVIEW_STEPS) for i = 1, #pts - 1 do line(pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y, 1) end end drawHUD() end -- -- 000:50003000300020002000200030003000300030004000400050005000600060007000800090009000a000b000b000c000c000d000e000e000f000f000304000000000 --