singe/demos/insertCoin/insertCoin.singe

1207 lines
37 KiB
Text

-- Insert Coin: a demo for Singe, to "Game Is On" by poorartistt.
--
-- Forty years of video games in four minutes, an era to a section of the song: a phosphor bat and
-- ball, aliens in formation that step on the beat, a sixteen-bit runner over parallax hills and a
-- kart round a track seen in perspective, a tape loading, flat-shaded polygons with their wobble, modern glass
-- and chrome and physics, a countdown to continue, a boss in a storm of bullets, a bonus stage, the
-- high score table, and every era again, faster, at the end.
--
-- Everything moves on the music: the aliens step on the beat, the ball meets the bats on the beat,
-- the runner jumps on the snare, the boss fires on the kick, the hats, and the snare.
dofile("Singe/Framework.singe")
local K = dofile(DIR .. "../kit.lua")
local W = 640
local H = 480
local FOV = 60
local PHOSPHOR = { 70, 255, 140 }
local NEON = { { 255, 60, 90 }, { 255, 170, 40 }, { 255, 240, 70 }, { 70, 230, 120 }, { 60, 180, 255 }, { 190, 90, 255 } }
local ALIEN_COLS = 10
local ALIEN_ROWS = 5
local RING_EVERY = 14 -- World units between rings in the polygon era: one passes each beat
local BRICK_COLS = 9
local BRICK_ROWS = 6
local COIN_MAX = 150
local sync = K.load(DIR, W, H)
local beat = sync.beats[3] - sync.beats[2]
local art = {}
local video = {}
local world = {}
local lastT = 0
-- Pixel drawing ------------------------------------------------------------------------------------
-- Frame frame of a sheet of frames frameW wide, at x, y, scale pixels a pixel.
local function px(sprite, frameW, frame, x, y, scale)
local h = spriteGetHeight(sprite)
spriteDrawGrid(sprite, math.floor(x), math.floor(y), frame * frameW, 0, frameW, h, math.floor(x + frameW * scale) - 1, math.floor(y + h * scale) - 1)
end
-- Text in the 8 by 8 font, scale pixels a pixel. align "center" centres it on x.
local function ptext(x, y, text, scale, r, g, b, align)
local w = #text * 8 * scale
if align == "center" then
x = x - w / 2
end
spriteSetTint(art.font, r, g, b)
for i = 1, #text do
local c = text:byte(i) - 32
if c > 0 and c < 96 then
spriteDrawGrid(art.font, math.floor(x + (i - 1) * 8 * scale), math.floor(y), (c % 16) * 8, (c // 16) * 8, 8, 8, math.floor(x + i * 8 * scale) - 1, math.floor(y + 8 * scale) - 1)
end
end
spriteSetTint(art.font, 255, 255, 255)
end
-- The same, each letter its own colour round the wheel, cycling: the high score table's rainbow.
local function rainbow(x, y, text, scale, t, align)
local w = #text * 8 * scale
if align == "center" then
x = x - w / 2
end
for i = 1, #text do
local r, g, b = K.hue(i * 0.06 - t * 0.8)
ptext(x + (i - 1) * 8 * scale, y, text:sub(i, i), scale, r, g, b)
end
end
local function scanlines(alpha)
spriteSetAlpha(art.scanlines, alpha or 255)
spriteDraw(art.scanlines, 0, 0, W - 1, H - 1)
spriteSetAlpha(art.scanlines, 255)
end
-- Loading --------------------------------------------------------------------------------------------
local function loadArt()
for _, name in ipairs({ "invaderA", "invaderB", "cannon", "runner", "coin", "ship", "kart", "bossCore", "bossArm", "turret", "bullet", "font", "mountains", "hills", "ground", "scanlines" }) do
art[name] = spriteLoad(DIR .. "art/" .. name .. ".png")
end
spriteQuality(art.bossCore, RENDER_SMOOTH)
spriteQuality(art.bossArm, RENDER_SMOOTH)
video.tunnel = videoLoad(DIR .. "video/tunnel.mp4")
video.plasma = videoLoad(DIR .. "video/plasma.mp4")
video.tunnelFrames = videoGetFrameCount(video.tunnel)
videoSetVolume(video.tunnel, 0, 0)
videoSetVolume(video.plasma, 0, 0)
-- Explosions on the overlay: square sparks added over everything.
art.boom = emitterNew()
emitterSetBlend(art.boom, PARTICLE_ADD)
emitterSetRate(art.boom, 0)
emitterSetLife(art.boom, 0.3, 0.9)
emitterSetSpeed(art.boom, 60, 320)
emitterSetSpread(art.boom, 180)
emitterSetDrag(art.boom, 2)
emitterSetSize(art.boom, 9, 2)
emitterSetMax(art.boom, 4000)
emitterStart(art.boom)
end
local function boom(x, y, count, r, g, b)
emitterSetPosition(art.boom, x, y)
emitterSetColor(art.boom, 255, 255, 255, 255, r, g, b, 0)
emitterBurst(art.boom, count)
end
-- The world --------------------------------------------------------------------------------------------
local function flatMesh(verts, faces)
local positions = {}
local normals = {}
local indices = {}
for _, f in ipairs(faces) do
local a = verts[f[1]]
local b = verts[f[2]]
local c = verts[f[3]]
local ux = b[1] - a[1]
local uy = b[2] - a[2]
local uz = b[3] - a[3]
local vx = c[1] - a[1]
local vy = c[2] - a[2]
local vz = c[3] - a[3]
local nx = uy * vz - uz * vy
local ny = uz * vx - ux * vz
local nz = ux * vy - uy * vx
local l = math.sqrt(nx * nx + ny * ny + nz * nz) + 1e-9
for _, p in ipairs({ a, b, c }) do
positions[#positions + 1] = p[1]
positions[#positions + 1] = p[2]
positions[#positions + 1] = p[3]
normals[#normals + 1] = nx / l
normals[#normals + 1] = ny / l
normals[#normals + 1] = nz / l
indices[#indices + 1] = #positions / 3
end
end
return meshNew(positions, normals, nil, indices)
end
-- A low-poly ship: a few triangles, flat shaded, nose toward -Z.
local function shipMesh()
local v = {
{ 0, 0, -2.2 }, { -0.5, 0.15, 0.4 }, { 0.5, 0.15, 0.4 }, { 0, 0.5, 0.2 }, { 0, -0.2, 0.6 },
{ -1.8, -0.05, 0.9 }, { 1.8, -0.05, 0.9 }, { 0, 0.15, 1.0 },
}
local f = {
{ 1, 4, 2 }, { 1, 3, 4 }, { 1, 2, 5 }, { 1, 5, 3 }, { 2, 4, 8 }, { 4, 3, 8 }, { 2, 8, 5 }, { 3, 5, 8 },
{ 2, 6, 5 }, { 3, 5, 7 }, { 2, 5, 6 }, { 3, 7, 5 },
}
return flatMesh(v, f)
end
local function octahedron(r)
local v = { { r, 0, 0 }, { -r, 0, 0 }, { 0, r, 0 }, { 0, -r, 0 }, { 0, 0, r }, { 0, 0, -r } }
local f = { { 1, 3, 5 }, { 5, 3, 2 }, { 2, 3, 6 }, { 6, 3, 1 }, { 5, 4, 1 }, { 2, 4, 5 }, { 6, 4, 2 }, { 1, 4, 6 } }
return flatMesh(v, f)
end
local function buildRacer()
local ground = materialNew()
materialSetTexture(ground, spriteLoad(DIR .. "art/track.png"))
materialSetFilter(ground, FILTER_NEAREST)
materialSetUnlit(ground, true)
world.track = nodeNew()
nodeSetMesh(world.track, meshPlane(400, 400), ground)
local skyMaterial = materialNew()
materialSetTexture(skyMaterial, spriteLoad(DIR .. "art/raceSky.png"))
materialSetUnlit(skyMaterial, true)
materialSetDoubleSided(skyMaterial, true)
world.raceSky = nodeNew()
nodeSetMesh(world.raceSky, meshSphere(320, 32), skyMaterial)
world.rivals = {}
for i = 1, 5 do
local n = nodeNew()
local c = NEON[i]
nodeSetSprite(n, art.kart, 3.2, false)
nodeSetBillboard(n, BILLBOARD_Y)
world.rivals[i] = { node = n, offset = 0.12 + i * 0.09, tint = c }
end
end
local function buildPolygons()
local hull = materialNew()
materialSetColor(hull, 200, 210, 230)
materialSetRoughness(hull, 0.6)
world.ship = nodeNew()
nodeSetMesh(world.ship, shipMesh(), hull)
world.rings = {}
world.ringMaterials = {}
for i = 1, 6 do
local m = materialNew()
local c = NEON[i]
materialSetColor(m, c[1] // 2, c[2] // 2, c[3] // 2)
materialSetRoughness(m, 0.7)
world.ringMaterials[i] = m
end
local ringMesh = meshTorus(7, 0.6, 6)
for i = 1, 14 do
local n = nodeNew()
nodeSetMesh(n, ringMesh, world.ringMaterials[(i - 1) % 6 + 1])
world.rings[i] = n
end
local floorMaterial = materialNew()
materialSetTexture(floorMaterial, spriteLoad(DIR .. "art/ground.png"))
materialSetFilter(floorMaterial, FILTER_NEAREST)
materialSetTiling(floorMaterial, 30, 30)
materialSetColor(floorMaterial, 70, 55, 120)
world.polyFloor = nodeNew()
nodeSetMesh(world.polyFloor, meshPlane(300, 300, 4), floorMaterial)
world.enemies = {}
local enemyMaterial = materialNew()
materialSetColor(enemyMaterial, 255, 80, 60)
materialSetEmissive(enemyMaterial, 120, 20, 10)
local enemyMesh = octahedron(1.2)
for i = 1, 6 do
local n = nodeNew()
nodeSetMesh(n, enemyMesh, enemyMaterial)
world.enemies[i] = n
end
world.shardAnchor = nodeNew()
world.shards = emitterNew(world.shardAnchor)
emitterSetRate(world.shards, 0)
emitterSetLife(world.shards, 0.4, 1.0)
emitterSetSpeed(world.shards, 6, 16)
emitterSetSpread(world.shards, 180)
emitterSetGravity(world.shards, 0, -10, 0)
emitterSetSize(world.shards, 0.5, 0.1)
emitterSetColor(world.shards, 255, 200, 80, 255, 255, 60, 20, 0)
emitterSetBlend(world.shards, PARTICLE_ADD)
emitterStart(world.shards)
world.light = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(world.light, 3, 8, 4)
nodeLookAt(world.light, 0, 0, 0)
lightSetIntensity(world.light, 0)
end
-- The modern era: a wall of glossy bricks on a dark floor, and chrome balls to knock it down.
local function buildModern()
local floorMaterial = materialNew()
materialSetColor(floorMaterial, 30, 30, 40)
materialSetMetallic(floorMaterial, 0.8)
materialSetRoughness(floorMaterial, 0.15)
world.modernFloor = nodeNew()
nodeSetMesh(world.modernFloor, meshBox(60, 0.4, 60), floorMaterial)
nodeSetPosition(world.modernFloor, 0, -0.2, 0)
bodyNew(world.modernFloor, BODY_STATIC, SHAPE_BOX, 60, 0.4, 60)
world.brickMaterials = {}
for i, c in ipairs(NEON) do
local m = materialNew()
materialSetColor(m, c[1], c[2], c[3])
materialSetMetallic(m, 0.3)
materialSetRoughness(m, 0.12)
world.brickMaterials[i] = m
end
world.brickMesh = meshBox(1.2, 0.6, 0.6)
world.bricks = {}
world.ballMesh = meshSphere(0.45, 24)
world.ballMaterial = K.chrome(240, 240, 250, 0.04)
world.balls = {}
world.spark = nodeNew()
world.sparks = emitterNew(world.spark)
emitterSetBlend(world.sparks, PARTICLE_ADD)
emitterSetRate(world.sparks, 0)
emitterSetLife(world.sparks, 0.3, 0.8)
emitterSetSpeed(world.sparks, 2, 7)
emitterSetSpread(world.sparks, 180)
emitterSetGravity(world.sparks, 0, -9, 0)
emitterSetSize(world.sparks, 0.08, 0.02)
emitterSetColor(world.sparks, 255, 240, 200, 255, 255, 120, 40, 0)
emitterSetTrail(world.sparks, 5, 0.03)
emitterStart(world.sparks)
world.sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(world.sun, -6, 10, 8)
nodeLookAt(world.sun, 0, 0, 0)
lightSetIntensity(world.sun, 0)
lightSetShadow(world.sun, true)
end
local function buildBonus()
local tunnel = materialNew()
materialSetVideo(tunnel, video.tunnel)
materialSetUnlit(tunnel, true)
materialSetDoubleSided(tunnel, true)
world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 80, 1.8)
world.coinMesh = meshCylinder(0.6, 0.12, 20)
world.coinMaterial = K.chrome(255, 200, 60, 0.25)
materialSetEmissive(world.coinMaterial, 150, 100, 10)
world.coins = {}
world.bin = nodeNew()
nodeSetPosition(world.bin, 0, -100, 0)
world.binWalls = {}
for _, w in ipairs({ { 0, -4.6, 0, 16, 0.4, 8 }, { -8, 0, 0, 0.4, 12, 8 }, { 8, 0, 0, 0.4, 12, 8 }, { 0, 0, -4, 16, 12, 0.4 }, { 0, 0, 4, 16, 12, 0.4 } }) do
local n = nodeNew()
nodeSetPosition(n, w[1], w[2] - 100, w[3])
bodyNew(n, BODY_KINEMATIC, SHAPE_BOX, w[4], w[5], w[6])
world.binWalls[#world.binWalls + 1] = { node = n, x = w[1], y = w[2], z = w[3] }
end
end
local function buildWorld()
sceneEnable(true)
sceneSetSky(DIR .. "art/duskSky.png")
sceneSetBloom(0.8, 0.5)
sceneSetAmbient(40, 40, 50)
world.camera = nodeNew()
cameraSet(world.camera)
cameraSetPerspective(FOV, 0.1, 800)
buildRacer()
buildPolygons()
buildModern()
buildBonus()
end
local function hideAll()
for _, n in ipairs({ world.track, world.raceSky, world.ship, world.polyFloor, world.modernFloor, world.tunnel }) do
nodeSetVisible(n, false)
end
for _, r in ipairs(world.rivals) do
nodeSetVisible(r.node, false)
end
for _, n in ipairs(world.rings) do
nodeSetVisible(n, false)
end
for _, n in ipairs(world.enemies) do
nodeSetVisible(n, false)
end
lightSetIntensity(world.light, 0)
lightSetIntensity(world.sun, 0)
sceneSetFog()
sceneSetSkyIntensity(1)
-- Behind a 2D era the 3D layer is plain black.
nodeSetPosition(world.camera, 0, -500, 0)
nodeLookAt(world.camera, 0, -500, -1)
end
-- The eras ----------------------------------------------------------------------------------------------
-- Each is a scene table without its bars, so the finale can run them again: enter, draw(t, bar,
-- k), and leave. An era's own time is the music's, so whatever it plays lands on the beat wherever
-- it is placed, as long as it is placed on a whole bar.
local ERA = {}
-- Switching on: the tube warms up from a line, INSERT COIN blinks on the beat, and a coin spins
-- down into the slot on the downbeat of bar 5.
ERA.boot = {
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
K.flash(0, 0, 0, 255)
local warm = K.smooth(1, 1.6, bar)
if warm < 1 then
local h = math.max(2, H * warm * warm)
K.box(0, H / 2 - h / 2, W - 1, H / 2 + h / 2, 255, 255, 255, math.floor(255 * (1 - warm * 0.85)))
return
end
if (K.beat(t) % 1) < 0.5 then
ptext(W / 2, 200, "INSERT COIN", 5, 255, 255, 255, "center")
end
local land = K.barTime(5)
local fall = K.clamp((t - (land - beat * 4)) / (beat * 4), 0, 1)
local cy = K.lerp(-60, 320, fall * fall)
local spin = math.floor(t * 12) % 4
if fall < 1 then
px(art.coin, 8, spin, W / 2 - 32, cy, 8)
end
K.box(W / 2 - 40, 392, W / 2 + 40, 402, 90, 90, 100, 255)
ptext(W / 2, 430, (fall >= 1) and "CREDIT 1" or "CREDIT 0", 3, 255, 220, 80, "center")
scanlines(160)
end,
}
-- 1-bit: two bats and a ball in green phosphor. The ball crosses the court once a beat, so it meets
-- a bat on every beat, and its afterglow trails behind it.
ERA.pong = {
enter = function(t)
hideAll()
world.pongScore = { 0, 0 }
end,
draw = function(t, bar, k)
K.flash(0, 6, 2, 255)
local b = K.beat(t)
local left = 60
local right = W - 60
for y = 20, H - 20, 24 do
K.box(W / 2 - 3, y, W / 2 + 3, y + 12, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], 160)
end
local function ballAt(beatPos)
local whole = math.floor(beatPos)
local p = beatPos - whole
local x = (whole % 2 == 0) and K.lerp(left + 12, right - 12, p) or K.lerp(right - 12, left + 12, p)
local tri = (beatPos * 0.37) % 2
tri = (tri < 1) and tri or (2 - tri)
return x, K.lerp(40, H - 40, tri)
end
for g = 6, 1, -1 do
local gx, gy = ballAt(b - g * 0.02)
K.box(gx - 7, gy - 7, gx + 7, gy + 7, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], 30 + (6 - g) * 20)
end
local x, y = ballAt(b)
K.box(x - 8, y - 8, x + 8, y + 8, 220, 255, 230, 255)
local _, ly = ballAt(math.floor(b) + ((math.floor(b) % 2 == 0) and 0 or 1))
local _, ry = ballAt(math.floor(b) + ((math.floor(b) % 2 == 0) and 1 or 0))
K.box(left - 10, ly - 40, left, ly + 40, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], 255)
K.box(right, ry - 40, right + 10, ry + 40, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], 255)
local points = math.floor(b / 4)
ptext(W / 2 - 80, 30, string.format("%02d", points % 100), 6, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], "center")
ptext(W / 2 + 80, 30, string.format("%02d", (points * 3) % 100), 6, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], "center")
K.strobe(t, "kicks", 20, PHOSPHOR[1], PHOSPHOR[2], PHOSPHOR[3], 30)
scanlines(200)
end,
}
-- 8-bit: a formation of aliens that steps on every beat, the cannon below firing on every snare and
-- taking out the lowest alien above it.
local function alienPositions(step)
local cycle = step % 40
local dx = (cycle < 20) and cycle or (40 - cycle)
return 48 + dx * 8, 60 + math.floor(step / 20) * 16
end
ERA.invaders = {
enter = function(t)
hideAll()
world.alive = {}
for i = 1, ALIEN_COLS * ALIEN_ROWS do
world.alive[i] = true
end
world.shots = {}
world.alienBase = K.beat(t)
end,
draw = function(t, bar, k)
K.flash(0, 0, 8, 255)
local step = math.floor(K.beat(t) - world.alienBase)
local ox, oy = alienPositions(step)
local frame = step % 2
local cannonX = W / 2 + math.sin(t * 1.4) * 220
-- On a snare, a shot from the cannon at the lowest alien in the column above it.
if K.hitCount("snares", lastT, t) > 0 then
local col = math.floor((cannonX - ox) / 52 + 0.5)
if col >= 0 and col < ALIEN_COLS then
for row = ALIEN_ROWS - 1, 0, -1 do
local i = row * ALIEN_COLS + col + 1
if world.alive[i] then
world.shots[#world.shots + 1] = { x = cannonX, start = t, target = i }
break
end
end
end
end
for row = 0, ALIEN_ROWS - 1 do
local c = NEON[row % #NEON + 1]
local sprite = (row < 2) and art.invaderA or art.invaderB
spriteSetTint(sprite, c[1], c[2], c[3])
for col = 0, ALIEN_COLS - 1 do
if world.alive[row * ALIEN_COLS + col + 1] then
px(sprite, (row < 2) and 12 or 12, frame, ox + col * 52, oy + row * 40, 4)
end
end
spriteSetTint(sprite, 255, 255, 255)
end
for i = #world.shots, 1, -1 do
local s = world.shots[i]
local row = (s.target - 1) // ALIEN_COLS
local col = (s.target - 1) % ALIEN_COLS
local tx = ox + col * 52 + 24
local ty = oy + row * 40 + 16
local p = (t - s.start) / (beat * 0.35)
if p >= 1 then
if world.alive[s.target] then
world.alive[s.target] = false
local c = NEON[row % #NEON + 1]
boom(tx, ty, 60, c[1], c[2], c[3])
end
table.remove(world.shots, i)
else
local sx = K.lerp(s.x, tx, p)
local sy = K.lerp(H - 70, ty, p)
K.box(sx - 2, sy - 10, sx + 2, sy + 10, 255, 255, 255, 255)
end
end
spriteSetTint(art.cannon, 80, 255, 120)
px(art.cannon, 13, 0, cannonX - 26, H - 64, 4)
spriteSetTint(art.cannon, 255, 255, 255)
K.box(0, H - 28, W - 1, H - 26, 80, 255, 120, 255)
ptext(20, 14, string.format("SCORE %05d", (ALIEN_COLS * ALIEN_ROWS - (function() local n = 0 for _, a in ipairs(world.alive) do if a then n = n + 1 end end return n end)()) * 50), 2, 255, 255, 255)
emitterDraw(art.boom)
scanlines(180)
end,
}
-- 16-bit: a runner over parallax hills. The ground runs at a steady speed, the runner jumps on every
-- snare, as high as the note being played says, and the coins are placed where the jumps will take
-- him.
local RUN_SPEED = 260
local RUNNER_X = 170
ERA.runner = {
enter = function(t)
hideAll()
world.collected = {}
world.runScore = 0
end,
draw = function(t, bar, k)
for i = 0, 11 do
local r = 40 + i * 12
local g = 90 + i * 12
local b = 210 + i * 4
K.box(0, i * 30, W - 1, i * 30 + 29, r, g, math.min(255, b), 255)
end
local function layer(sprite, speed, y, scale)
local w = spriteGetWidth(sprite) * scale
local offset = (t * speed) % w
for x = -offset, W, w do
spriteDraw(sprite, math.floor(x), y, math.floor(x + w) - 1, y + spriteGetHeight(sprite) * scale - 1)
end
end
layer(art.mountains, RUN_SPEED * 0.15, 170, 2)
layer(art.hills, RUN_SPEED * 0.4, 260, 2)
layer(art.ground, RUN_SPEED, H - 96, 2)
local groundY = H - 96 - 64
local b = K.beat(t)
local jumpY = 0
-- Jumps on the backbeat: beats 2 and 4 of each bar, a beat long.
local whole = math.floor(b)
local p = b - whole
if (whole - (sync.barStart - 1)) % 2 == 1 then
local note = K.note(K.beatTime(whole))
jumpY = 4 * p * (1 - p) * (90 + note * 8)
end
-- Coins at the top of every jump, a beat's worth of ground apart.
for j = math.floor(b) - 2, math.floor(b) + 4 do
if (j - (sync.barStart - 1)) % 2 == 1 then
local apex = K.beatTime(j + 0.5)
local cx = RUNNER_X + 24 + (apex - t) * RUN_SPEED
local note = K.note(K.beatTime(j))
local cy = groundY - (90 + note * 8) + 8
if t < apex then
px(art.coin, 8, math.floor(t * 10) % 4, cx, cy, 4)
elseif not world.collected[j] then
world.collected[j] = true
world.runScore = world.runScore + 100
boom(RUNNER_X + 32, cy + 16, 30, 255, 230, 80)
end
end
end
local frame = (jumpY > 0) and 4 or (math.floor(b * 4) % 4)
px(art.runner, 16, frame, RUNNER_X, groundY - jumpY, 4)
ptext(20, 14, string.format("COINS %03d", world.runScore // 100), 2, 255, 255, 255)
ptext(W - 20 - 9 * 16, 14, string.format("WORLD 1-%d", math.floor(bar - 16) // 2 + 1), 2, 255, 255, 255)
emitterDraw(art.boom)
end,
}
-- 16-bit, the other trick: a kart on a flat track seen in perspective, the sky turning with it. It
-- laps once every four bars.
local function trackPoint(a)
return math.cos(a) * 144, math.sin(a) * 104
end
ERA.racer = {
enter = function(t)
hideAll()
nodeSetVisible(world.track, true)
nodeSetVisible(world.raceSky, true)
for _, r in ipairs(world.rivals) do
nodeSetVisible(r.node, true)
end
world.raceBase = t
end,
draw = function(t, bar, k)
local lap = (bar - 1) / 4
local a = lap * 2 * math.pi
local x, z = trackPoint(a)
local ax, az = trackPoint(a + 0.08)
nodeSetPosition(world.camera, x, 4.5, z)
nodeLookAt(world.camera, ax, 3.2, az)
nodeSetPosition(world.raceSky, x, 0, z)
for i, r in ipairs(world.rivals) do
local ra = a + r.offset + 0.02 * math.sin(t * 2 + i)
local rx, rz = trackPoint(ra)
nodeSetPosition(r.node, rx + math.sin(i * 2.1) * 6, 1.4, rz + math.cos(i * 1.7) * 6)
end
local lean = math.sin(a * 2) * 8
spriteRotateAndScale(art.kart, lean, 6)
spriteDraw(art.kart, W // 2, H - 70, true)
ptext(20, 14, string.format("LAP %d/3", math.floor(lap) % 3 + 1), 3, 255, 255, 255)
ptext(W - 20 - 6 * 24, 14, string.format("POS %d", 6 - math.floor(lap * 1.5) % 5), 3, 255, 240, 80)
local laps = (t - world.raceBase)
ptext(W - 20 - 8 * 16, 48, string.format("%02d\"%02d", math.floor(laps) % 60, math.floor(laps * 100) % 100), 2, 255, 255, 255)
end,
leave = function(t)
spriteRotateAndScale(art.kart, 0, 1)
end,
}
-- Loading: a tape loading, the border awash with stripes, a bar filling to the riser.
ERA.loading = {
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
local border = { { 20, 20, 120 }, { 255, 255, 80 }, { 60, 200, 200 }, { 200, 60, 200 }, { 255, 140, 60 }, { 255, 255, 255 }, { 40, 40, 40 } }
local seed = math.floor(t * 50)
local band = 3 + math.floor(6 * (1 - k))
for y = 0, H - 1, band do
local c = border[((seed * 31 + y * 7) // band) % #border + 1]
K.box(0, y, W - 1, y + band - 1, c[1], c[2], c[3], 255)
end
K.box(64, 56, W - 65, H - 57, 60, 60, 200, 255)
ptext(96, 90, "PRESS PLAY ON TAPE", 2, 160, 160, 255)
ptext(96, 130, "OK", 2, 160, 160, 255)
ptext(96, 170, "SEARCHING", 2, 160, 160, 255)
ptext(96, 210, "FOUND GAME ON", 2, 160, 160, 255)
ptext(96, 250, "LOADING", 2, 160, 160, 255)
if (t * 3) % 1 < 0.5 then
K.box(96 + 8 * 16, 250, 96 + 8 * 16 + 14, 265, 160, 160, 255, 255)
end
local fill = K.clamp(k, 0, 1)
K.box(96, 320, W - 97, 350, 20, 20, 90, 255)
K.box(98, 322, 98 + (W - 196 - 4) * fill, 348, 160, 160, 255, 255)
ptext(W / 2, 370, string.format("%3d%%", math.floor(fill * 100)), 2, 255, 255, 255, "center")
scanlines(120)
if bar > 40.6 then
K.flash(255, 255, 255, math.floor(255 * K.smooth(40.6, 41, bar)))
end
end,
}
-- 32-bit: a flat-shaded ship through hexagonal rings, one ring a beat, in a dense fog, every
-- position snapped to a coarse grid as the machines of the time snapped theirs. From bar 49 enemy
-- crystals come at the ship and burst on the snare.
local function snap(v)
return math.floor(v * 12 + 0.5) / 12
end
ERA.polygons = {
enter = function(t)
hideAll()
nodeSetVisible(world.ship, true)
nodeSetVisible(world.polyFloor, true)
for _, n in ipairs(world.rings) do
nodeSetVisible(n, true)
end
lightSetIntensity(world.light, 3)
sceneSetFog(30, 10, 50, 10, 120)
sceneSetSkyIntensity(0)
world.enemyBase = K.beat(t)
end,
draw = function(t, bar, k)
local b = K.beat(t)
local z = -b * RING_EVERY
local sway = math.sin(t * 0.9) * 3
nodeSetPosition(world.ship, snap(sway), snap(2 + math.sin(t * 1.3) * 0.8), snap(z - 8))
nodeSetRotation(world.ship, 0, 0, snap(math.cos(t * 0.9) * -25))
nodeSetPosition(world.camera, snap(sway * 0.6), snap(3.4), snap(z))
nodeLookAt(world.camera, sway * 0.8, 2, z - 30)
nodeSetPosition(world.polyFloor, 0, -3, z - (z % 20))
local first = math.floor(b)
for i, n in ipairs(world.rings) do
local rb = first + i - 1
nodeSetPosition(n, 0, 2, snap(-rb * RING_EVERY - 8))
nodeSetRotation(n, 90, rb * 15, 0)
end
for i, m in ipairs(world.ringMaterials) do
local v = K.decay("kicks", t, 6)
local c = NEON[i]
materialSetEmissive(m, math.floor(c[1] * v), math.floor(c[2] * v), math.floor(c[3] * v))
end
if bar >= 49 then
for i, n in ipairs(world.enemies) do
local eb = math.floor(b) + i * 2
local ez = -eb * RING_EVERY - 8
local alive = (eb - b) > 0.35
nodeSetVisible(n, alive)
nodeSetPosition(n, snap(math.sin(eb * 1.7) * 3), snap(2 + math.cos(eb * 2.3) * 1.5), snap(ez))
nodeSetRotation(n, t * 90, t * 130, 0)
end
if K.hitCount("snares", lastT, t) > 0 then
nodeSetPosition(world.shardAnchor, sway, 2, z - 20)
emitterBurst(world.shards, 80)
end
end
end,
}
-- Now: glass, chrome, and physics. A wall of glossy bricks under a dusk sky, and on every kick a
-- chrome ball thrown into it; the wall is built again at bar 65 and comes down harder.
local function buildWall()
for _, b in ipairs(world.bricks) do
bodyDelete(b)
nodeDelete(b)
end
for _, b in ipairs(world.balls) do
bodyDelete(b)
nodeDelete(b)
end
world.bricks = {}
world.balls = {}
for row = 0, BRICK_ROWS - 1 do
for col = 0, BRICK_COLS - 1 do
local n = nodeNew()
nodeSetMesh(n, world.brickMesh, world.brickMaterials[(row + col) % #world.brickMaterials + 1])
nodeSetPosition(n, (col - (BRICK_COLS - 1) / 2) * 1.25 + ((row % 2) * 0.62 - 0.31), 0.3 + row * 0.62, 0)
bodyNew(n, BODY_DYNAMIC, SHAPE_BOX, 1.2, 0.6, 0.6)
bodySetMass(n, 1)
bodySetFriction(n, 0.8)
world.bricks[#world.bricks + 1] = n
end
end
end
ERA.modern = {
enter = function(t)
hideAll()
nodeSetVisible(world.modernFloor, true)
lightSetIntensity(world.sun, 4)
sceneSetSkyIntensity(1.2)
buildWall()
world.wallBuilt = K.bar(t)
end,
draw = function(t, bar, k)
if bar >= 65 and world.wallBuilt < 65 then
buildWall()
world.wallBuilt = bar
end
local a = t * 0.12
nodeSetPosition(world.camera, math.sin(a) * 7, 2.4 + math.sin(t * 0.3) * 0.6, 7.5 + math.cos(a) * 2)
nodeLookAt(world.camera, 0, 1.6, 0)
if K.hitCount("kicks", lastT, t) > 0 and #world.balls < 40 then
local n = nodeNew()
local tx = (math.random() - 0.5) * 8
local ty = 0.5 + math.random() * 3
nodeSetMesh(n, world.ballMesh, world.ballMaterial)
nodeSetPosition(n, tx * 0.3, ty + 1.5, 14)
bodyNew(n, BODY_DYNAMIC, SHAPE_SPHERE, 0.45)
bodySetMass(n, (bar >= 65) and 8 or 4)
bodySetVelocity(n, tx * 0.9, 1.2, -32)
world.balls[#world.balls + 1] = n
end
end,
leave = function(t)
for _, b in ipairs(world.bricks) do
bodyDelete(b)
nodeDelete(b)
end
for _, b in ipairs(world.balls) do
bodyDelete(b)
nodeDelete(b)
end
world.bricks = {}
world.balls = {}
end,
}
-- Game over, and a countdown to continue, a number a bar, glitching, until the coin goes in.
ERA.continue = {
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
K.flash(0, 0, 0, 255)
local n = 8 - math.floor(bar - 73)
if bar < 74 then
ptext(W / 2, 200, "GAME OVER", 6, 255, 60, 60, "center")
else
ptext(W / 2, 80, "CONTINUE?", 4, 255, 255, 255, "center")
local pulse = 1 + 0.15 * K.decay("snares", t, 6)
local s = math.floor(18 * pulse)
local glitch = (math.random() < 0.15) and math.random(-20, 20) or 0
ptext(W / 2 + glitch, 170, tostring(math.max(1, n)), s, 255, 220, 60, "center")
for i = 1, 3 do
local y = math.random(0, H)
K.box(0, y, W - 1, y + math.random(1, 4), 255, 255, 255, math.random(20, 90))
end
end
if bar > 80.25 then
local p = K.smooth(80.25, 80.95, bar)
px(art.coin, 8, math.floor(t * 12) % 4, W / 2 - 32, K.lerp(-60, 380, p * p), 8)
end
ptext(W / 2, 430, "CREDIT 0", 3, 255, 220, 80, "center")
scanlines(160)
end,
}
-- The boss: a core and four turning arms with turrets, firing rings on the kick, spirals on the
-- hats, and aimed shots on the snare, while the ship below weaves and fires back. Its health runs
-- out over sixteen bars, and it goes up on the last.
ERA.boss = {
enter = function(t)
hideAll()
world.bullets = {}
world.bossHit = 0
videoPlay(video.plasma)
end,
draw = function(t, bar, k)
videoDraw(video.plasma, 0, 0, W - 1, H - 1)
K.flash(0, 0, 10, 200)
local dt = t - lastT
local cx = W / 2 + math.sin(t * 0.5) * 60
local cy = 150 + math.sin(t * 0.8) * 20
local spin = t * 40
local shipX = W / 2 + math.sin(t * 1.1) * 210 + math.sin(t * 2.7) * 40
local shipY = H - 70
local dying = bar >= 96
-- Stars streaming past.
for i = 1, 60 do
local sy = (i * 97 + t * (60 + (i % 5) * 50)) % H
local sx = (i * 173) % W
K.box(sx, sy, sx + 1, sy + 1 + (i % 3), 255, 255, 255, 120 + (i % 4) * 30)
end
if not dying then
if K.hitCount("kicks", lastT, t) > 0 then
for i = 1, 20 do
local a = i / 20 * 2 * math.pi + t
world.bullets[#world.bullets + 1] = { x = cx, y = cy, vx = math.cos(a) * 150, vy = math.sin(a) * 150, c = 1 }
end
end
if K.hitCount("hats", lastT, t) > 0 then
for arm = 0, 3 do
local a = math.rad(spin) + arm * math.pi / 2
world.bullets[#world.bullets + 1] = { x = cx + math.cos(a) * 110, y = cy + math.sin(a) * 110, vx = math.cos(a + 1.2) * 180, vy = math.sin(a + 1.2) * 180, c = 2 }
end
end
if K.hitCount("snares", lastT, t) > 0 then
for i = -1, 1 do
local a = math.atan(shipY - cy, shipX - cx) + i * 0.18
world.bullets[#world.bullets + 1] = { x = cx, y = cy, vx = math.cos(a) * 240, vy = math.sin(a) * 240, c = 3 }
end
end
end
-- The arms and turrets, then the core over them, flashing white while it is being hit.
local hitFlash = (math.floor(t * 30) % 2 == 0) and not dying
for arm = 0, 3 do
local a = spin + arm * 90
local ax = cx + math.cos(math.rad(a)) * 60
local ay = cy + math.sin(math.rad(a)) * 60
spriteRotate(art.bossArm, a)
spriteDraw(art.bossArm, math.floor(ax), math.floor(ay), true)
spriteDraw(art.turret, math.floor(cx + math.cos(math.rad(a)) * 110) - 14, math.floor(cy + math.sin(math.rad(a)) * 110) - 14)
end
spriteSetTint(art.bossCore, 255, hitFlash and 200 or 255, hitFlash and 200 or 255)
spriteRotateAndScale(art.bossCore, -spin * 0.5, 1.4 + 0.1 * K.decay("kicks", t, 8))
spriteDraw(art.bossCore, math.floor(cx), math.floor(cy), true)
spriteSetTint(art.bossCore, 255, 255, 255)
-- Bullets.
local tints = { { 255, 80, 220 }, { 80, 230, 255 }, { 255, 230, 80 } }
for i = #world.bullets, 1, -1 do
local bl = world.bullets[i]
bl.x = bl.x + bl.vx * dt
bl.y = bl.y + bl.vy * dt
if bl.x < -20 or bl.x > W + 20 or bl.y < -20 or bl.y > H + 20 then
table.remove(world.bullets, i)
else
local c = tints[bl.c]
spriteSetTint(art.bullet, c[1], c[2], c[3])
spriteDraw(art.bullet, math.floor(bl.x) - 6, math.floor(bl.y) - 6)
end
end
spriteSetTint(art.bullet, 255, 255, 255)
-- The ship and its fire.
for i = 0, 6 do
local fy = shipY - ((t * 900 + i * 70) % 420)
K.box(shipX - 2, fy, shipX + 2, fy + 12, 255, 255, 150, 255)
end
px(art.ship, 16, 0, shipX - 32, shipY - 32, 4)
-- Health.
local health = 1 - K.clamp((bar - 81) / 15, 0, 1)
K.box(120, 18, W - 121, 30, 60, 0, 0, 255)
K.box(122, 20, 122 + (W - 244 - 4) * health, 28, 255, 60, 60, 255)
ptext(W / 2, 36, "BOSS", 2, 255, 255, 255, "center")
if dying and K.hitCount("hats", lastT, t) > 0 then
boom(cx + math.random(-90, 90), cy + math.random(-90, 90), 90, NEON[math.random(1, 6)][1], NEON[math.random(1, 6)][2], 60)
end
emitterDraw(art.boom)
if bar > 96.85 then
K.flash(255, 255, 255, math.floor(255 * K.smooth(96.85, 97, bar)))
end
end,
leave = function(t)
videoPause(video.plasma)
spriteRotate(art.bossArm, 0)
spriteRotateAndScale(art.bossCore, 0, 1)
end,
}
-- The bonus stage: down the tunnel, the coins pour in on the beat and pile up, and the bonus counts.
ERA.bonus = {
enter = function(t)
hideAll()
nodeSetVisible(world.tunnel, true)
for _, w in ipairs(world.binWalls) do
nodeSetPosition(w.node, w.x, w.y, w.z)
end
world.phase = 0
world.phaseT = t
world.bonusBase = t
sceneSetSkyIntensity(1.4)
lightSetIntensity(world.sun, 3)
end,
draw = function(t, bar, k)
nodeSetPosition(world.camera, 0, 6, 12)
nodeLookAt(world.camera, 0, -2.5, 0)
world.phase = (world.phase + (t - world.phaseT) * (0.6 + 1.5 * K.decay("kicks", t, 5))) % 1
world.phaseT = t
videoSeek(video.tunnel, math.floor(world.phase * video.tunnelFrames))
nodeSetRotation(world.tunnel, 0, 0, t * 30)
local beats = math.floor((K.beat(t) - K.beat(world.bonusBase)) * 2)
while #world.coins < math.min(COIN_MAX, beats) do
local n = nodeNew()
nodeSetMesh(n, world.coinMesh, world.coinMaterial)
nodeSetPosition(n, (math.random() - 0.5) * 12, 9, (math.random() - 0.5) * 5)
nodeSetRotation(n, math.random(0, 360), math.random(0, 360), 0)
bodyNew(n, BODY_DYNAMIC, SHAPE_CYLINDER, 0.6, 0.12)
bodySetMass(n, 0.3)
bodySetBounce(n, 0.4)
bodySetAngularVelocity(n, (math.random() - 0.5) * 12, (math.random() - 0.5) * 12, 0)
world.coins[#world.coins + 1] = n
end
ptext(W / 2, 30, "BONUS STAGE", 4, 255, 230, 80, "center")
ptext(W / 2, 80, string.format("%07d", #world.coins * 500), 4, 255, 255, 255, "center")
end,
leave = function(t)
for _, n in ipairs(world.coins) do
bodyDelete(n)
nodeDelete(n)
end
world.coins = {}
for _, w in ipairs(world.binWalls) do
nodeSetPosition(w.node, w.x, w.y - 100, w.z)
end
end,
}
-- The high score table, which is also the credits.
ERA.scores = {
enter = function(t)
hideAll()
videoPlay(video.plasma)
end,
draw = function(t, bar, k)
videoDraw(video.plasma, 0, 0, W - 1, H - 1)
K.flash(0, 0, 20, 190)
rainbow(W / 2, 40, "HIGH SCORES", 4, t, "center")
local rows = {
{ "1ST", "999990", "CODE CLAUDE" },
{ "2ND", "888880", "MUSIC POORARTISTT" },
{ "3RD", "777770", "ENGINE SINGE" },
{ "4TH", "666660", "PIXELS CLAUDE" },
{ "5TH", "555550", "YOU THANKS!" },
}
for i, r in ipairs(rows) do
if bar >= 113 + (i - 1) * 1.2 then
local y = 120 + i * 52
ptext(40, y, r[1], 3, 255, 255, 255)
ptext(140, y, r[2], 3, 255, 230, 80)
rainbow(330, y, r[3], 2, t + i * 0.3)
end
end
scanlines(120)
end,
leave = function(t)
videoPause(video.plasma)
end,
}
-- The end: game over, thanks, and the set switched off on the last hit.
ERA.outro = {
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
K.flash(0, 0, 0, 255)
local p = t - K.barTime(137)
local last = K.duration - 0.9
if t < last then
ptext(W / 2, 140, "GAME OVER", 7, 255, 60, 60, "center")
if p > 2 then
rainbow(W / 2, 260, "THANKS FOR PLAYING", 3, t, "center")
end
if p > 4 and (t * 2) % 1 < 0.5 then
ptext(W / 2, 340, "PRESS START", 3, 255, 255, 255, "center")
end
scanlines(160)
else
local q = (t - last) / 0.35
if q < 1 then
local h = math.max(2, H * (1 - q) ^ 3)
local w = (q < 0.6) and W or W * (1 - (q - 0.6) / 0.4)
K.box(W / 2 - w / 2, H / 2 - h / 2, W / 2 + w / 2, H / 2 + h / 2, 255, 255, 255, 255)
end
end
end,
}
-- The finale runs the eras again, two bars each, every one on its own beat; its time is moved by
-- whole bars, so the beat lands where it did.
local MONTAGE = {
{ era = "pong", bar = 5, label = "1-BIT" }, { era = "invaders", bar = 9, label = "8-BIT" }, { era = "runner", bar = 17, label = "16-BIT" },
{ era = "racer", bar = 25, label = "16-BIT" }, { era = "polygons", bar = 49, label = "32-BIT" }, { era = "boss", bar = 85, label = "BOSS" },
{ era = "bonus", bar = 97, label = "BONUS" },
}
ERA.montage = {
enter = function(t)
world.montageCut = 0
end,
draw = function(t, bar, k)
local cut = math.floor((bar - 121) / 2) + 1
local final = cut > #MONTAGE
local m = MONTAGE[math.min(cut, #MONTAGE)]
if final then
-- The last two bars: the name, slammed in on the downbeat, over everything flashing.
if world.montageCut ~= cut then
if world.montageEra and ERA[world.montageEra].leave then
ERA[world.montageEra].leave(t)
end
world.montageEra = nil
world.montageCut = cut
hideAll()
end
local r, g, b = K.hue(t * 0.7)
K.flash(r // 3, g // 3, b // 3, 255)
local s = 10 + 4 * math.exp(-(bar - 135) * 6)
rainbow(W / 2, 170, "GAME ON", math.floor(s), t, "center")
K.strobe(t, "kicks", 12, 255, 255, 255, 120)
scanlines(160)
return
end
local shift = K.barTime(m.bar) - K.barTime(121 + (cut - 1) * 2)
local et = t + shift
if world.montageCut ~= cut then
if world.montageEra and ERA[world.montageEra].leave then
ERA[world.montageEra].leave(t)
end
world.montageEra = m.era
world.montageCut = cut
ERA[m.era].enter(et)
end
local saved = lastT
lastT = saved + shift
ERA[m.era].draw(et, K.bar(et), 0)
lastT = saved
ptext(W - 20 - #m.label * 24, H - 40, m.label, 3, 255, 255, 255)
K.flash(255, 255, 255, math.floor(220 * math.exp(-((bar - 121) % 2) * 2 * 1.7778 * 10)))
end,
leave = function(t)
if world.montageEra and ERA[world.montageEra].leave then
ERA[world.montageEra].leave(t)
end
end,
}
local function place(era, from, to)
return { from = from, to = to, enter = ERA[era].enter, draw = ERA[era].draw, leave = ERA[era].leave }
end
local SCENES = {
place("boot", 0, 5),
place("pong", 5, 9),
place("invaders", 9, 17),
place("runner", 17, 25),
place("racer", 25, 33),
place("loading", 33, 41),
place("polygons", 41, 57),
place("modern", 57, 73),
place("continue", 73, 81),
place("boss", 81, 97),
place("bonus", 97, 113),
place("scores", 113, 121),
place("montage", 121, 137),
place("outro", 137, 200),
-- The time at the end of each frame, for "what happened since last frame".
{
from = -10, to = 1000,
draw = function(t, bar, k)
lastT = t
end,
},
}
-- A ball meeting a brick throws sparks where they met.
function onCollision(a, b, x, y, z, speed)
if speed > 4 then
nodeSetPosition(world.spark, x, y, z)
emitterBurst(world.sparks, math.min(60, math.floor(speed * 3)))
end
end
loadArt()
buildWorld()
hideAll()
K.run(SCENES)