-- Sky Strike: a sprite-scaling flight game, the arcade kind where the world is flat pictures drawn -- bigger as they come closer. There is no 3D here at all. Every object has a position in a simple -- world (across, up, and ahead), each frame projects it to the screen with one division, and the -- sprite is drawn stretched to the size that distance gives it. -- -- Turning does the rest, and saves drawing pictures: the jet is one picture, turned for its bank -- and spun for a barrel roll; the horizon tilts by turning the whole sky and ground, one picture; -- and everything in the world turns with the horizon, the same picture at the same angle. -- -- Arrows steer, button 1 fires, button 2 rolls (and nothing touches you mid-roll). dofile("Singe/Framework.singe") local W = 320 local H = 240 local FOCAL = 160 -- Screen offset = world offset * FOCAL / distance ahead local EYE_HEIGHT = 30 -- The camera's height over the ground; the ground art assumes it local HORIZON_Y = 100 -- Where the horizon sits when the jet flies level local NEAR = 8 -- Closer than this ahead, a thing has gone past local FAR = 1200 -- Scenery appears this far ahead local SPEED = 260 -- World units a second local STRIPE = 40 -- The ground art's stripe, in world units local GROUND_FRAMES = 6 local BANK_MAX = 35 -- Degrees the jet banks at full turn local HORIZON_SHARE = 0.6 -- How much of the bank tilts the horizon rather than the jet local STEER = 120 -- World units a second across at full turn local CLIMB = 60 -- And up or down local ALTITUDE_MIN = 12 local ALTITUDE_MAX = 80 local ROLL_SECONDS = 0.7 local FIRE_GAP = 0.08 local SHOT_SPEED = 900 local SHOT_RANGE = 900 local ENEMY_SPEED = 160 -- On top of the jet's own local SAFE_SECONDS = 2 -- Untouchable after being hit local START_LIVES = 3 local JET_X = W / 2 local JET_Y = 190 local JET_DROP = 7 -- The jet flies this far below the camera: where it is hit, and its guns local ENEMY_SPREAD = 12 -- Enemies arrive within this of the jet's height, to come at it -- What each kind of thing is: its picture, and its height in the world, which with the distance -- gives its height on screen. local KINDS = {} local held = {} local scenery = {} local enemies = {} local shots = {} local explosions = {} local camX = 0 local camY = EYE_HEIGHT local bank = 0 local pitch = 0 local roll = nil -- Seconds into a barrel roll, or nil local rollWay = 1 local travelled = 0 local fireWait = 0 local spawnWait = 0 local safeUntil = 0 local score = 0 local lives = START_LIVES local clock = 0 local lastTicks = nil local ground = nil local function loadKind(name, height) local sprite = spriteLoad(DIR .. "art/" .. name .. ".png") spriteQuality(sprite, RENDER_SMOOTH) return { sprite = sprite, height = height, natural = spriteGetHeight(sprite), angle = nil } end local function reset() scenery = {} enemies = {} shots = {} explosions = {} camX = 0 camY = EYE_HEIGHT bank = 0 pitch = 0 roll = nil score = 0 lives = START_LIVES safeUntil = 0 for _ = 1, 60 do local thing = { kind = (math.random() < 0.7) and KINDS.tree or KINDS.rock, x = math.random(-600, 600), z = math.random(NEAR, FAR) } thing.y = thing.kind.height / 2 scenery[#scenery + 1] = thing end for _ = 1, 12 do scenery[#scenery + 1] = { kind = KINDS.cloud, x = math.random(-900, 900), y = math.random(110, 220), z = math.random(NEAR, FAR) } end end -- Where a point of the world lands on the screen, and how many pixels a world unit is there: the -- one division, then the turn of the horizon about its own point. nil when it is behind the camera. local function project(x, y, z, horizonX, horizonY, cosA, sinA) if z < NEAR then return nil end local perUnit = FOCAL / z local dx = (x - camX) * perUnit local dy = (camY - y) * perUnit return horizonX + dx * cosA - dy * sinA, horizonY + dx * sinA + dy * cosA, perUnit end -- A thing drawn at its distance: its kind's picture, turned to the angle (once a frame for all of a -- kind, since they share it), stretched to its height there. local function drawThing(kind, sx, sy, perUnit, angle) if kind.angle ~= angle then spriteRotate(kind.sprite, angle) kind.angle = angle end local scale = kind.height * perUnit / kind.natural local w = spriteGetWidth(kind.sprite) * scale local h = spriteGetHeight(kind.sprite) * scale if (sx + w / 2 < 0) or (sx - w / 2 > W) or (sy + h / 2 < 0) or (sy - h / 2 > H) or (w < 1) then return end spriteDraw(kind.sprite, sx - w / 2, sy - h / 2, sx + w / 2, sy + h / 2) end local function explode(x, y, z) explosions[#explosions + 1] = { x = x, y = y, z = z, age = 0, spin = math.random(0, 359) } end local function step(dt) local across = (held[SWITCH_RIGHT] and 1 or 0) - (held[SWITCH_LEFT] and 1 or 0) local climb = (held[SWITCH_UP] and 1 or 0) - (held[SWITCH_DOWN] and 1 or 0) clock = clock + dt travelled = travelled + SPEED * dt camX = camX + across * STEER * dt camY = math.max(ALTITUDE_MIN, math.min(ALTITUDE_MAX, camY + climb * CLIMB * dt)) -- The bank and the nose ease toward the stick rather than snapping. bank = bank + (across * BANK_MAX - bank) * math.min(1, dt * 6) pitch = pitch + (climb * 18 - pitch) * math.min(1, dt * 5) if roll then roll = roll + dt if roll >= ROLL_SECONDS then roll = nil end end -- The world comes toward the jet; what passes goes round to the far end again. for _, thing in ipairs(scenery) do thing.z = thing.z - SPEED * dt if thing.z < NEAR then thing.z = thing.z + FAR thing.x = camX + math.random(-600, 600) + ((thing.kind == KINDS.cloud) and math.random(-300, 300) or 0) end end -- Enemies come in from ahead, weaving, and bank into their weave. spawnWait = spawnWait - dt if spawnWait <= 0 and lives > 0 then spawnWait = 0.8 + math.random() * 1.2 enemies[#enemies + 1] = { x = camX + math.random(-60, 60), y = math.max(5, camY - JET_DROP + math.random(-ENEMY_SPREAD, ENEMY_SPREAD)), z = SHOT_RANGE, phase = math.random() * 6.28, weave = 20 + math.random() * 30 } end for i = #enemies, 1, -1 do local enemy = enemies[i] local sway = math.cos(clock * 2 + enemy.phase) enemy.z = enemy.z - (SPEED + ENEMY_SPEED) * dt enemy.x = enemy.x + sway * enemy.weave * dt enemy.lean = sway * 25 if enemy.z < NEAR then -- It reached the jet: a hit unless it is off to one side, or the jet is rolling or safe. if math.abs(enemy.x - camX) < 14 and math.abs(enemy.y - (camY - JET_DROP)) < 12 and not roll and clock > safeUntil and lives > 0 then lives = lives - 1 safeUntil = clock + SAFE_SECONDS explode(camX, camY - JET_DROP, NEAR + 20) end table.remove(enemies, i) end end -- Shots fly ahead from the jet's guns, and what they meet blows up. fireWait = fireWait - dt if held[SWITCH_BUTTON1] and fireWait <= 0 and lives > 0 then fireWait = FIRE_GAP shots[#shots + 1] = { x = camX, y = camY - JET_DROP, z = NEAR + 4 } end for i = #shots, 1, -1 do local shot = shots[i] local hit = false shot.z = shot.z + SHOT_SPEED * dt for j = #enemies, 1, -1 do local enemy = enemies[j] if math.abs(enemy.x - shot.x) < 10 and math.abs(enemy.y - shot.y) < 8 and math.abs(enemy.z - shot.z) < 30 then explode(enemy.x, enemy.y, enemy.z) table.remove(enemies, j) score = score + 1000 hit = true break end end if hit or shot.z > SHOT_RANGE then table.remove(shots, i) end end for i = #explosions, 1, -1 do local blast = explosions[i] blast.age = blast.age + dt blast.z = blast.z - SPEED * dt * 0.5 blast.spin = blast.spin + 400 * dt if blast.age > 0.6 or blast.z < NEAR then table.remove(explosions, i) end end end function onInputPressed(what) held[what] = true if what == SWITCH_BUTTON2 and not roll and lives > 0 then roll = 0 rollWay = (held[SWITCH_LEFT] and -1) or 1 elseif what == SWITCH_BUTTON1 and lives <= 0 then reset() end end function onInputReleased(what) held[what] = false end function onOverlayUpdate() local now = singeGetTicks() local dt = lastTicks and math.min(0.05, (now - lastTicks) / 1000) or 0 lastTicks = now step(dt) -- The horizon tilts against the bank and drops as the nose comes up. The sky and the ground are -- one picture turned about the horizon point; the frame is chosen by how far the ground has -- moved through a stripe, which is what makes it rush past. local tilt = -bank * HORIZON_SHARE local radians = math.rad(tilt) local cosA = math.cos(radians) local sinA = math.sin(radians) local horizonX = W / 2 local horizonY = HORIZON_Y + pitch local frame = math.floor((travelled % STRIPE) / STRIPE * GROUND_FRAMES) + 1 overlayClear() spriteRotateFrame(ground, tilt, frame) spriteDrawRotatedFrame(ground, horizonX, horizonY) -- Everything in the world, farthest first, so the near hides the far. local drawn = {} for _, thing in ipairs(scenery) do drawn[#drawn + 1] = { kind = thing.kind, x = thing.x, y = thing.y, z = thing.z, angle = tilt } end for _, enemy in ipairs(enemies) do drawn[#drawn + 1] = { kind = KINDS.enemy, x = enemy.x, y = enemy.y, z = enemy.z, angle = tilt + enemy.lean } end for _, shot in ipairs(shots) do drawn[#drawn + 1] = { kind = KINDS.shot, x = shot.x, y = shot.y, z = shot.z, angle = 0 } end for _, blast in ipairs(explosions) do drawn[#drawn + 1] = { kind = KINDS.fireball, x = blast.x, y = blast.y, z = blast.z, angle = math.floor(blast.spin) % 360, grow = 0.5 + blast.age * 2.5 } end table.sort(drawn, function(a, b) return a.z > b.z end) for _, item in ipairs(drawn) do local sx, sy, perUnit = project(item.x, item.y, item.z, horizonX, horizonY, cosA, sinA) if sx then drawThing(item.kind, sx, sy, perUnit * (item.grow or 1), item.angle) end end -- The jet: one picture, turned for its share of the bank, and spun right round for a roll. -- It blinks while it cannot be hit. if lives > 0 and (clock > safeUntil or math.floor(clock * 10) % 2 == 0) then local angle = bank * (1 - HORIZON_SHARE) if roll then angle = angle + rollWay * 360 * (roll / ROLL_SECONDS) end spriteRotate(KINDS.jet.sprite, angle) spriteDraw(KINDS.jet.sprite, JET_X + bank * 0.8, JET_Y - pitch * 0.5, true) end overlayPrint(1, 0, string.format("SCORE %06d", score)) overlayPrint(1, 1, "LIVES " .. lives) if lives <= 0 then overlayPrint(14, 8, "GAME OVER") overlayPrint(9, 10, "Button 1 flies again") end return OVERLAY_UPDATED end overlaySetResolution(W, H) KINDS.tree = loadKind("tree", 22) KINDS.rock = loadKind("rock", 8) KINDS.cloud = loadKind("cloud", 40) KINDS.enemy = loadKind("enemy", 10) KINDS.shot = loadKind("shot", 3) KINDS.fireball = loadKind("fireball", 18) KINDS.jet = loadKind("jet", 40) ground = spriteLoadFrames(GROUND_FRAMES, DIR .. "art/ground.png") reset()