322 lines
11 KiB
Text
322 lines
11 KiB
Text
-- 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()
|