singe/demos/insertCoin/insertCoin.singe
2026-10-06 16:54:44 -05:00

1931 lines
64 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 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 } }
-- The high score table near the end: edit it freely. Rows show top to bottom in the order written
-- here, ranked 1ST, 2ND, and so on; any number of rows fits, and a name too long for the column is
-- drawn smaller. Scores show as six digits.
local HIGH_SCORES = {
{ "SINGE", 999990 },
{ "MAME", 874350 },
{ "STELLA", 762100 },
{ "VICE", 658400 },
{ "DAPHNE", 541750 },
{ "DOSBOX", 433200 },
{ "FCEUX", 356900 },
{ "SNES9X", 284150 },
{ "MEDNAFEN", 197600 },
{ "HYPSEUS", 121050 },
}
-- The big scroller that runs over the whole demo until the recap: what this is, and what Singe does.
local FEATURE_TEXT = "YOU ARE WATCHING INSERT COIN, A DEMO OF THE SINGE GAME ENGINE ... EVERYTHING HERE RUNS LIVE, IN REAL TIME, TIMED TO THE MUSIC BEAT BY BEAT ... SINGE PLAYS LASERDISC GAMES AND MAKES NEW ONES ... GAMES ARE WRITTEN IN LUA ... SPRITES SCALE, TURN, TINT, AND GLOW ... VIDEO IN ALMOST ANY FORMAT, HARDWARE DECODED ... A 3D ENGINE WITH GLTF MODELS, LIGHTS, SHADOWS, BLOOM, AND FOG ... PHYSICS, CLOTH, ROPES, RAGDOLLS, AND VEHICLES ... PARTICLES IN 2D AND 3D ... GUIS, MIDI, AND THE MICROPHONE ... LIGHT GUNS AND EVERY KIND OF CONTROLLER ... ONLINE HIGH SCORES ... IT RECORDS ITS OWN VIDEO ... ONE FILE PER GAME ... WINDOWS, MACOS, LINUX, RASPBERRY PI, AND HANDHELDS ... SINGE ... "
local FEATURE_ALPHA = 190 -- How see-through its letters are: 0 to 255
local FEATURE_CELL = 12 -- A block of its letters, in pixels, as a letter rests
local ART_DENSITY = 2.25 -- The boss's, its bullets', and the targets' pixels to a design unit (util/insertCoinArt.py DENSITY)
local FX_W = 320 -- The live plasma's and tunnel's size, stretched to wherever they are shown
local FX_H = 240
local TUNNEL_LAP = 4 -- The tunnel's travel counts a lap of its texture as this many passes
local PLASMA_STOPS = { { 0.00, { 0, 0, 20 } }, { 0.25, { 10, 40, 160 } }, { 0.50, { 20, 200, 120 } }, { 0.70, { 240, 240, 60 } }, { 0.85, { 255, 255, 255 } }, { 1.00, { 0, 0, 20 } } }
local TUNNEL_STOPS = { { 0.00, { 0, 0, 20 } }, { 0.30, { 10, 40, 160 } }, { 0.55, { 20, 200, 120 } }, { 0.80, { 240, 240, 60 } }, { 1.00, { 255, 255, 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
-- The vector era: a field of the camera's view at z = 0, and the asteroids' sizes.
local VEC_HALF_W = 30
local VEC_HALF_H = 23
local VEC_SIZES = { 3.2, 1.9, 1.0 } -- Radius of a large, a medium, and a small asteroid
local VEC_POOL = { 6, 12, 24 } -- How many of each the field can hold
local VEC_EDGE = 0.14 -- How thick a glowing line is
local VEC_SHOTS = 24
local VEC_TURN = 540 -- Degrees a second the ship turns to aim
-- The light-gun era: three rails of targets, each sliding across in RAIL_BEATS beats.
local RAILS = { 150, 255, 360 }
local RAIL_BEATS = 4
local RAIL_GAP = 210 -- Pixels from one target to the next on a rail
-- The versus era.
local FIGHTER_SCALE = 4
local FIGHTER_FRAME = 40
local FIGHT_DAMAGE = 0.03
-- The rhythm era: the highway's lanes, what they are fed from, and how far ahead notes show.
local RHYTHM_LANES = { { list = "kicks", label = "KICK", rgb = { 255, 70, 90 } }, { list = "snares", label = "SNARE", rgb = { 80, 220, 255 } }, { list = "hats", label = "HAT", rgb = { 255, 220, 70 } } }
local RHYTHM_LOOK = 1.6 -- Seconds of notes on the highway
local RHYTHM_TOP = 120 -- Where the highway meets the horizon
local RHYTHM_HIT = 400 -- The hit line
local RHYTHM_SPAN = 130 -- Pixels between lanes at the hit line
local KART_HEIGHT = 3.2 -- The rival karts' height in the racer's world
local KART_SCALE = 4 -- The player's kart: screen pixels a pixel of its picture
local KART_W = 48 -- The kart pictures' size
local KART_H = 29
local GATE_RADIUS = 4.5 -- The gates' size: tight enough that the ship has to thread them
local COIN_ALIVE = 60 -- Coins in the bonus stage at once; the oldest are swept away first
local COIN_BELT = 11 -- World units a second the tunnel floor carries a coin, at its slowest
local COIN_RATE = 4 -- Coins dropped a beat
local COIN_RADIUS = 1.0
local COIN_THICK = 0.2
local BRICK_COLS = 9
local BRICK_ROWS = 6
local sync = K.load(DIR)
local W = K.W -- The screen in design units: 480 tall (kit.lua)
local H = K.H
local beat = sync.beats[3] - sync.beats[2]
local art = {}
local world = {}
local lastT = 0
-- 1ST, 2ND, 3RD, 4TH ... 11TH, 12TH, 13TH ... 21ST.
local function ordinal(n)
local last = n % 10
local suffix = "TH"
if (n % 100 < 11) or (n % 100 > 13) then
suffix = ({ "ST", "ND", "RD" })[last] or "TH"
end
return n .. suffix
end
-- Pixel drawing ------------------------------------------------------------------------------------
-- Pixel art is drawn a whole number of the window's pixels to each of its own, the nearest to scale
-- design units, so every art pixel is the same size and crisp on any screen.
local function pixels(scale)
return math.max(1, math.floor(scale * K.S + 0.5))
end
-- Frame frame of a sheet of frames frameW wide, at x, y, scale design units a pixel.
local function px(sprite, frameW, frame, x, y, scale)
local h = spriteGetHeight(sprite)
local size = pixels(scale)
local rx = math.floor(x * K.S)
local ry = math.floor(y * K.S)
spriteDrawGrid(sprite, rx, ry, frame * frameW, 0, frameW, h, rx + frameW * size - 1, ry + h * size - 1)
end
-- How wide text in the 8 by 8 font is at scale, in design units.
local function ptextWidth(text, scale)
return #text * 8 * pixels(scale) / K.S
end
-- Text in the 8 by 8 font, scale design units a pixel. align "center" centres it on x.
local function ptext(x, y, text, scale, r, g, b, align)
local size = pixels(scale)
local rx = 0
local ry = math.floor(y * K.S)
if align == "center" then
x = x - ptextWidth(text, scale) / 2
end
rx = math.floor(x * K.S)
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, rx + (i - 1) * 8 * size, ry, (c % 16) * 8, (c // 16) * 8, 8, 8, rx + i * 8 * size - 1, ry + 8 * size - 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 step = ptextWidth("X", scale)
if align == "center" then
x = x - ptextWidth(text, scale) / 2
end
for i = 1, #text do
local r, g, b = K.hue(i * 0.06 - t * 0.8)
ptext(x + (i - 1) * step, y, text:sub(i, i), scale, r, g, b)
end
end
-- A CRT's scanlines over everything: one screen row in three darkened.
local function scanlines(alpha)
K.cover(art.scanlines, discGetHeight() / spriteGetHeight(art.scanlines), alpha)
end
-- Loading --------------------------------------------------------------------------------------------
local function loadArt()
for _, name in ipairs({ "invaderA", "invaderB", "cannon", "runner", "coin", "ship", "kart", "font", "mountains", "hills", "ground", "scanlines", "fighterA", "fighterB" }) do
art[name] = spriteLoad(DIR .. "art/" .. name .. ".png")
end
for _, name in ipairs({ "bossCore", "bossArm", "turret", "bullet", "target" }) do
art[name] = K.sprite(DIR .. "art/" .. name .. ".png", ART_DENSITY)
end
spriteQuality(art.bossCore, RENDER_SMOOTH)
spriteQuality(art.bossArm, RENDER_SMOOTH)
-- The plasma and the tunnel, worked out live in Lua a frame at a time.
art.plasma = K.plasmaNew(FX_W, FX_H, PLASMA_STOPS)
art.tunnel = K.tunnelNew(FX_W, FX_H, TUNNEL_STOPS)
-- 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 * K.S, 320 * K.S)
emitterSetSpread(art.boom, 180)
emitterSetDrag(art.boom, 2)
emitterSetSize(art.boom, 9 * K.S, 2 * K.S)
emitterSetMax(art.boom, 4000)
emitterStart(art.boom)
end
local function boom(x, y, count, r, g, b)
emitterSetPosition(art.boom, x * K.S, y * K.S)
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()
-- A crisp picture on a quad that the racer turns to face the camera: the same nearest-pixel look as the
-- player's kart, where a sprite node would be smoothed.
local m = materialNew()
local quad = nodeNew(n)
materialSetTexture(m, spriteLoad(DIR .. "art/kart" .. i .. ".png"))
materialSetFilter(m, FILTER_NEAREST)
materialSetUnlit(m, true)
materialSetDoubleSided(m, true)
materialSetCutoff(m, 0.5)
nodeSetMesh(quad, meshPlane(KART_HEIGHT * KART_W / KART_H, KART_HEIGHT), m)
nodeSetRotation(quad, 90, 0, 0)
world.rivals[i] = { node = n, offset = 0.12 + i * 0.09 }
end
end
local function buildPolygons()
local hull = materialNew()
materialSetColor(hull, 200, 210, 230)
materialSetRoughness(hull, 0.6)
-- The ship turns on a pivot that faces along the path, and banks on its own node under it.
world.shipPivot = nodeNew()
world.ship = nodeNew(world.shipPivot)
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(GATE_RADIUS, 0.45, 6)
-- Each gate is a pivot that faces along the path, with the hexagon stood up under it so it can
-- turn in its own plane.
world.gates = {}
for i = 1, 14 do
local n = nodeNew()
local ring = nodeNew(n)
nodeSetMesh(ring, ringMesh, world.ringMaterials[(i - 1) % 6 + 1])
world.rings[i] = n
world.gates[i] = ring
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()
world.tunnelMaterial = tunnel
materialSetTexture(tunnel, art.tunnel.sprite)
materialSetUnlit(tunnel, true)
materialSetDoubleSided(tunnel, true)
world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 80, true)
-- A coin is a gold rim and two struck faces: the name on one side and the dragon on the other.
world.coinMesh = meshCylinder(COIN_RADIUS, COIN_THICK, 24)
world.coinMaterial = K.chrome(255, 200, 60, 0.25)
materialSetEmissive(world.coinMaterial, 150, 100, 10)
world.faceMesh = K.disc(COIN_RADIUS, 32)
world.faceMaterials = {}
for i, file in ipairs({ "coinSinge.png", "coinDragon.png" }) do
local m = materialNew()
local image = spriteLoad(DIR .. "art/" .. file)
materialSetTexture(m, image)
materialSetEmissiveMap(m, image)
materialSetEmissive(m, 150, 120, 60)
materialSetMetallic(m, 0.8)
materialSetRoughness(m, 0.3)
world.faceMaterials[i] = m
end
world.coins = {}
-- The tunnel's floor, which nobody sees: a long slab the coins land on, running from far down
-- the tunnel to behind the camera. It waits out of the way until the bonus stage.
world.coinFloor = nodeNew()
nodeSetPosition(world.coinFloor, 0, -200, 0)
bodyNew(world.coinFloor, BODY_KINEMATIC, SHAPE_BOX, 24, 0.4, 140)
end
-- The vector era: every line a thin glowing box, so bloom makes it glow as a vector monitor's beam
-- did. Asteroids are outlines of ten jagged points, a pool of each size; the ship is a triangle.
local function edge(parent, x1, y1, x2, y2)
local n = nodeNew(parent)
local dx = x2 - x1
local dy = y2 - y1
nodeSetMesh(n, world.vecEdge, world.vecGlow)
nodeSetPosition(n, (x1 + x2) / 2, (y1 + y2) / 2, 0)
nodeSetRotation(n, 0, 0, math.deg(math.atan(dy, dx)))
nodeSetScale(n, math.sqrt(dx * dx + dy * dy) + VEC_EDGE, VEC_EDGE, VEC_EDGE)
return n
end
local function buildVector()
world.vecEdge = meshBox(1, 1, 1)
world.vecGlow = materialNew()
materialSetColor(world.vecGlow, 0, 0, 0)
materialSetEmissive(world.vecGlow, 210, 235, 255)
world.vecField = nodeNew()
world.asteroids = {}
for size, count in ipairs(VEC_POOL) do
for i = 1, count do
local n = nodeNew(world.vecField)
local points = {}
for p = 0, 9 do
local a = p / 10 * 2 * math.pi
local r = VEC_SIZES[size] * (0.55 + 0.45 * ((p * 7 + i * 13 + size * 5) % 11) / 10)
points[#points + 1] = { math.cos(a) * r, math.sin(a) * r }
end
for p = 1, #points do
local q = points[p % #points + 1]
edge(n, points[p][1], points[p][2], q[1], q[2])
end
nodeSetVisible(n, false)
world.asteroids[#world.asteroids + 1] = { node = n, size = size, alive = false }
end
end
world.vecShip = nodeNew(world.vecField)
edge(world.vecShip, 1.6, 0, -1.1, 0.95)
edge(world.vecShip, 1.6, 0, -1.1, -0.95)
edge(world.vecShip, -0.7, 0.75, -0.7, -0.75)
world.vecFlame = nodeNew(world.vecShip)
edge(world.vecFlame, -0.7, 0.4, -1.7, 0)
edge(world.vecFlame, -0.7, -0.4, -1.7, 0)
world.vecShots = {}
for i = 1, VEC_SHOTS do
local n = nodeNew(world.vecField)
nodeSetMesh(n, world.vecEdge, world.vecGlow)
nodeSetScale(n, 0.35, 0.35, 0.35)
world.vecShots[i] = { node = n, alive = false }
end
world.vecBurstAt = nodeNew()
world.vecBurst = emitterNew(world.vecBurstAt)
emitterSetBlend(world.vecBurst, PARTICLE_ADD)
emitterSetRate(world.vecBurst, 0)
emitterSetLife(world.vecBurst, 0.4, 0.9)
emitterSetSpeed(world.vecBurst, 6, 22)
emitterSetSpread(world.vecBurst, 180)
emitterSetSize(world.vecBurst, 0.3, 0.05)
emitterSetColor(world.vecBurst, 255, 255, 255, 255, 160, 210, 255, 0)
emitterStart(world.vecBurst)
nodeSetVisible(world.vecField, false)
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)
K.screen(world.camera, FOV, 800)
art.blocks = K.blockFont(dofile(DIR .. "art/font.lua"), world.camera, FOV, FEATURE_CELL, FEATURE_ALPHA)
buildRacer()
buildPolygons()
buildModern()
buildBonus()
buildVector()
end
local function hideAll()
for _, n in ipairs({ world.track, world.raceSky, world.shipPivot, 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
nodeSetVisible(world.vecField, false)
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.fill(0, 0, 0)
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,
}
-- Analog: 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.fill(0, 6, 2)
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.fill(0, 0, 8)
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 = K.width(sprite) * scale
local offset = (t * speed) % w
for x = -offset, W, w do
K.draw(sprite, x, y, x + w, y + K.height(sprite) * scale)
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)
local px2 = rx + math.sin(i * 2.1) * 6
local pz2 = rz + math.cos(i * 1.7) * 6
-- Turned square to the camera, level, so the picture is never seen edge on.
nodeSetPosition(r.node, px2, 1.4, pz2)
nodeLookAt(r.node, px2 + (px2 - x), 1.4, pz2 + (pz2 - z))
end
local lean = math.sin(a * 2) * 8
-- The player's kart, crisp, frame 1 of the sheet, sliding with the lean rather than turning.
px(art.kart, KART_W, 0, W / 2 - KART_W / 2 * KART_SCALE + lean * 2, H - 30 - KART_H * KART_SCALE, KART_SCALE)
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)
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", 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 flying a winding path through hexagonal gates, one gate a beat. Each
-- gate stands on the path, facing along it and turning in its own plane, so the ship weaves through
-- them, banking into every turn; every position is snapped to a coarse grid as the machines of the
-- time snapped theirs. Enemy crystals wait along the path and burst on the snare.
local function snap(v)
return math.floor(v * 12 + 0.5) / 12
end
-- The path, s world units along it: across and up, and the depth, which is -s.
local function gatePath(s)
return 7 * math.sin(s * 0.045) + 3 * math.sin(s * 0.11 + 1), 3 + 2.5 * math.sin(s * 0.033 + 2), -s
end
-- Puts a pivot on the path at s, facing along it.
local function onPath(node, s)
local x, y, z = gatePath(s)
local ax, ay, az = gatePath(s + 2)
nodeSetPosition(node, snap(x), snap(y), snap(z))
nodeLookAt(node, ax, ay, az)
end
ERA.polygons = {
enter = function(t)
hideAll()
nodeSetVisible(world.shipPivot, 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 s = b * RING_EVERY
local x0 = gatePath(s - 4)
local x1 = gatePath(s)
local x2 = gatePath(s + 4)
local bank = K.clamp((x0 - 2 * x1 + x2) * -60, -60, 60)
local cx, cy, cz = gatePath(s - 7)
local lx, ly, lz = gatePath(s + 12)
-- The ship on the path, banked into the turn; the camera on the path behind it.
onPath(world.shipPivot, s)
nodeSetRotation(world.ship, 0, 0, snap(bank))
nodeSetPosition(world.camera, snap(cx), snap(cy + 1.8), snap(cz))
nodeLookAt(world.camera, lx, ly + 0.6, lz)
nodeSetPosition(world.polyFloor, 0, -6, -s - (-s % 20))
local first = math.floor(b)
for i, n in ipairs(world.rings) do
local rb = first + i - 1
onPath(n, rb * RING_EVERY)
nodeSetRotation(world.gates[i], 90, rb * 15 + t * 40 * ((rb % 2 == 0) and 1 or -1), 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 ex, ey, ez = gatePath(eb * RING_EVERY + RING_EVERY / 2)
local alive = (eb + 0.5 - b) > 0.35
nodeSetVisible(n, alive)
nodeSetPosition(n, snap(ex + math.sin(eb * 1.7) * 2), snap(ey + math.cos(eb * 2.3) * 1.2), snap(ez))
nodeSetRotation(n, t * 90, t * 130, 0)
end
if K.hitCount("snares", lastT, t) > 0 then
local ax, ay, az = gatePath(s + 20)
nodeSetPosition(world.shardAnchor, ax, ay, az)
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 69 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 >= 69 and world.wallBuilt < 69 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 >= 69) 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.fill(0, 0, 0)
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 eight bars, and it goes up on the last.
ERA.boss = {
enter = function(t)
hideAll()
world.bullets = {}
world.bossHit = 0
end,
draw = function(t, bar, k)
K.plasmaUpdate(art.plasma, t)
K.under(art.plasma.sprite, 0, 0, W, H, nil, nil, nil, nil, true)
K.under(nil, 0, 0, W, H, 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
K.turn(art.bossArm, ax, ay, a)
K.turn(art.turret, cx + math.cos(math.rad(a)) * 110, cy + math.sin(math.rad(a)) * 110, 0)
end
K.turn(art.bossCore, cx, cy, -spin * 0.5, 1.4 + 0.1 * K.decay("kicks", t, 8), 255, hitFlash and 200 or 255, hitFlash and 200 or 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])
K.draw(art.bullet, bl.x, bl.y, true)
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 - 89) / 7, 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,
}
-- The bonus stage: coins pour down further along the tunnel, COIN_RATE a beat, land on its floor, and the
-- tunnel's own motion carries them toward the camera and off the bottom of the screen; every coin
-- that rides past counts.
ERA.bonus = {
enter = function(t)
hideAll()
nodeSetVisible(world.tunnel, true)
nodeSetPosition(world.coinFloor, 0, -3.2, -50)
world.phase = 0
world.phaseT = t
world.bonusBase = t
world.dropped = 0
world.collected = 0
sceneSetSkyIntensity(1.4)
lightSetIntensity(world.sun, 3)
end,
draw = function(t, bar, k)
local push = 0.6 + 1.5 * K.decay("kicks", t, 5)
nodeSetPosition(world.camera, 0, 0.5, 14)
nodeLookAt(world.camera, 0, 0.5, -100)
world.phase = world.phase + (t - world.phaseT) * push
world.phaseT = t
K.tunnelUpdate(art.tunnel, (world.phase / TUNNEL_LAP) % 1, 0)
materialSetTexture(world.tunnelMaterial, art.tunnel.sprite)
nodeSetRotation(world.tunnel, 0, 0, t * 30)
local due = math.floor((K.beat(t) - K.beat(world.bonusBase)) * COIN_RATE)
while world.dropped < due do
local n = nodeNew()
world.dropped = world.dropped + 1
nodeSetMesh(n, world.coinMesh, world.coinMaterial)
for side = 1, 2 do
local face = nodeNew(n)
nodeSetMesh(face, world.faceMesh, world.faceMaterials[side])
nodeSetPosition(face, 0, (side == 1) and (COIN_THICK / 2 + 0.005) or -(COIN_THICK / 2 + 0.005), 0)
nodeSetRotation(face, 0, 0, (side == 1) and 0 or 180)
end
nodeSetPosition(n, (math.random() - 0.5) * 9, 7 + math.random() * 4, -30 - math.random() * 30)
nodeSetRotation(n, math.random(0, 360), math.random(0, 360), 0)
bodyNew(n, BODY_DYNAMIC, SHAPE_CYLINDER, COIN_RADIUS, COIN_THICK)
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
-- Coins on the floor ride the tunnel toward the camera at its speed; past the camera they go.
for i = #world.coins, 1, -1 do
local n = world.coins[i]
local x, y, z = nodeGetPosition(n)
local vx, vy, vz = bodyGetVelocity(n)
if z > 16 or #world.coins - i >= COIN_ALIVE then
bodyDelete(n)
nodeDelete(n)
table.remove(world.coins, i)
world.collected = world.collected + 1
elseif y < -1.8 then
bodySetVelocity(n, vx * 0.95, vy, COIN_BELT * push)
end
end
ptext(W / 2, 30, "BONUS STAGE", 4, 255, 230, 80, "center")
ptext(W / 2, 80, string.format("%07d", world.collected * 500), 4, 255, 255, 255, "center")
end,
leave = function(t)
for _, n in ipairs(world.coins) do
bodyDelete(n)
nodeDelete(n)
end
world.coins = {}
nodeSetPosition(world.coinFloor, 0, -200, 0)
end,
}
-- The high score table, which is also the credits.
ERA.scores = {
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
K.plasmaUpdate(art.plasma, t)
K.under(art.plasma.sprite, 0, 0, W, H, nil, nil, nil, nil, true)
K.under(nil, 0, 0, W, H, 0, 0, 20, 190)
rainbow(W / 2, 40, "HIGH SCORES", 4, t, "center")
-- Rows share the space under the title, centred in it, and appear one after another over the
-- first half of the scene, however many there are. The table is centred across the screen by
-- its widest row; a name too long for the screen is drawn a size smaller.
local count = #HIGH_SCORES
local top = 110
local room = H - 40 - top
local spacing = math.min(52, room / math.max(count, 1))
local scale = (spacing >= 34) and 3 or 2
local scoreAt = 100
local nameAt = 290
local fit = math.floor((W - 80 - nameAt) / ptextWidth("X", scale))
local sizes = {}
local widest = 0
for i, row in ipairs(HIGH_SCORES) do
local name = tostring(row[1])
sizes[i] = (#name > fit) and math.max(1, scale - 1) or scale
widest = math.max(widest, ptextWidth(name, sizes[i]))
end
local left = (W - nameAt - widest) / 2
top = top + (room - spacing * count) / 2
for i, row in ipairs(HIGH_SCORES) do
if bar >= 113 + (i - 1) * 4 / count then
local y = top + (i - 1) * spacing
ptext(left, y, ordinal(i), scale, 255, 255, 255)
ptext(left + scoreAt, y, string.format("%06d", math.floor(row[2] or 0) % 1000000), scale, 255, 230, 80)
rainbow(left + nameAt, y, tostring(row[1]):upper(), sizes[i], t + i * 0.3)
end
end
scanlines(120)
end,
leave = function(t)
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.fill(0, 0, 0)
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,
}
-- Vector: wireframe asteroids drifting in the dark, glowing as a vector monitor's beam glowed. On
-- every snare the ship turns to the biggest rock, fires, and the rock splits in two as the shot
-- reaches it, down to the smallest, which bursts; on every hat it sprays a shot the way it faces.
-- The ship turns to face its mark before it fires, rather than snapping round.
local function vecSpawn(size, x, y)
for _, a in ipairs(world.asteroids) do
if a.size == size and not a.alive then
local angle = math.random() * 2 * math.pi
local pace = (4 + size * 1.5) * (0.6 + math.random() * 0.8)
a.alive = true
a.x = x
a.y = y
a.vx = math.cos(angle) * pace / size
a.vy = math.sin(angle) * pace / size
a.spin = (math.random() - 0.5) * 120
a.angle = math.random() * 360
nodeSetVisible(a.node, true)
return a
end
end
end
local function vecRefill()
local left = 0
for _, a in ipairs(world.asteroids) do
if a.alive then
left = left + 1
end
end
if left < 3 then
for i = 1, 4 do
local side = (i % 2 == 0) and 1 or -1
vecSpawn(1, side * VEC_HALF_W, (math.random() - 0.5) * VEC_HALF_H * 2)
end
end
end
local function vecFire(x, y, angle, speed, target)
for _, s in ipairs(world.vecShots) do
if not s.alive then
s.alive = true
s.x = x
s.y = y
s.vx = math.cos(math.rad(angle)) * speed
s.vy = math.sin(math.rad(angle)) * speed
s.life = 0.9
s.target = target
nodeSetVisible(s.node, true)
return
end
end
end
ERA.vector = {
enter = function(t)
hideAll()
nodeSetVisible(world.vecField, true)
for _, a in ipairs(world.asteroids) do
a.alive = false
nodeSetVisible(a.node, false)
end
for _, s in ipairs(world.vecShots) do
s.alive = false
nodeSetVisible(s.node, false)
end
vecRefill()
world.vecAngle = 90
world.vecPending = nil
world.vecScore = 0
sceneSetSkyIntensity(0)
sceneSetBloom(0.4, 0.9)
end,
draw = function(t, bar, k)
local dt = math.max(0, t - lastT)
nodeSetPosition(world.camera, 0, 0, 40)
nodeLookAt(world.camera, 0, 0, 0)
for _, a in ipairs(world.asteroids) do
if a.alive then
a.x = (a.x + a.vx * dt + VEC_HALF_W) % (VEC_HALF_W * 2) - VEC_HALF_W
a.y = (a.y + a.vy * dt + VEC_HALF_H) % (VEC_HALF_H * 2) - VEC_HALF_H
a.angle = a.angle + a.spin * dt
nodeSetPosition(a.node, a.x, a.y, 0)
nodeSetRotation(a.node, 0, 0, a.angle)
end
end
-- On a snare, the ship picks the biggest rock and turns toward it; it fires once it faces it.
if K.hitCount("snares", lastT, t) > 0 then
local best = nil
for _, a in ipairs(world.asteroids) do
if a.alive and (best == nil or a.size < best.size) then
best = a
end
end
world.vecPending = best
end
if world.vecPending and not world.vecPending.alive then
world.vecPending = nil
end
if world.vecPending then
local want = math.deg(math.atan(world.vecPending.y, world.vecPending.x))
local diff = (want - world.vecAngle + 540) % 360 - 180
local step = VEC_TURN * dt
world.vecAngle = world.vecAngle + K.clamp(diff, -step, step)
if math.abs(diff) <= step then
vecFire(0, 0, world.vecAngle, 80, world.vecPending)
world.vecPending = nil
end
end
if K.hitCount("hats", lastT, t) > 0 then
vecFire(0, 0, world.vecAngle + (math.random() - 0.5) * 30, 60, nil)
end
for _, s in ipairs(world.vecShots) do
if s.alive then
s.life = s.life - dt
if s.target then
-- The aimed shot homes on its rock and splits it on arrival.
local dx = s.target.x - s.x
local dy = s.target.y - s.y
local d = math.sqrt(dx * dx + dy * dy)
if d < VEC_SIZES[s.target.size] or not s.target.alive then
if s.target.alive then
s.target.alive = false
nodeSetVisible(s.target.node, false)
nodeSetPosition(world.vecBurstAt, s.target.x, s.target.y, 0)
emitterBurst(world.vecBurst, 40 * (4 - s.target.size))
world.vecScore = world.vecScore + ({ 20, 50, 100 })[s.target.size]
if s.target.size < 3 then
vecSpawn(s.target.size + 1, s.target.x, s.target.y)
vecSpawn(s.target.size + 1, s.target.x, s.target.y)
end
end
s.alive = false
else
s.vx = dx / d * 80
s.vy = dy / d * 80
end
end
s.x = s.x + s.vx * dt
s.y = s.y + s.vy * dt
if s.life <= 0 or math.abs(s.x) > VEC_HALF_W or math.abs(s.y) > VEC_HALF_H then
s.alive = false
end
nodeSetVisible(s.node, s.alive)
nodeSetPosition(s.node, s.x, s.y, 0)
end
end
vecRefill()
nodeSetRotation(world.vecShip, 0, 0, world.vecAngle)
nodeSetVisible(world.vecFlame, K.decay("kicks", t, 10) > 0.3)
ptext(30, 20, string.format("%05d", world.vecScore), 3, 210, 235, 255)
end,
leave = function(t)
sceneSetBloom(0.8, 0.5)
end,
}
-- Light gun: a shooting gallery, the cabinets Singe has always played. Bullseyes slide along three
-- rails; the crosshair glides to the nearest and, on every snare, the screen flashes white the way a
-- light gun's cabinet flashes to find where the gun points, and the target spins away, hit.
ERA.gallery = {
enter = function(t)
hideAll()
world.gallery = { cx = W / 2, cy = H / 2, aim = nil, falling = {}, hits = 0, flash = -10, shot = {} }
end,
draw = function(t, bar, k)
local g = world.gallery
local dt = math.max(0, t - lastT)
local b = K.beat(t)
local live = {}
-- The backboard: planks, a banner, and the rails.
for x = 0, W, 48 do
local shade = (x // 48) % 2 == 0 and 0 or 12
K.box(x, 0, x + 47, H - 1, 120 + shade, 70 + shade, 40 + shade // 2, 255)
K.box(x + 46, 0, x + 47, H - 1, 70, 40, 20, 255)
end
K.box(0, 0, W - 1, 56, 120, 20, 30, 255)
ptext(W / 2, 16, "SHOOTING GALLERY", 3, 255, 230, 120, "center")
-- The targets on each rail, sliding across a RAIL_GAP apart and round again; a hit one is gone
-- from its rail until it comes round the next time.
for rail, y in ipairs(RAILS) do
local dir = (rail % 2 == 1) and 1 or -1
local travel = (b / RAIL_BEATS) * (W + RAIL_GAP)
K.box(0, y + 70, W - 1, y + 78, 60, 35, 20, 255)
local period = (math.ceil(W / RAIL_GAP) + 2) * RAIL_GAP
for slot = 0, math.ceil(W / RAIL_GAP) + 1 do
local along = travel + slot * RAIL_GAP
local x = along % period - RAIL_GAP
local id = rail .. ":" .. slot .. ":" .. math.floor(along / period)
if dir < 0 then
x = W - x
end
if not g.shot[id] and x > -40 and x < W + 40 then
K.draw(art.target, x - 32, y - 20)
live[#live + 1] = { id = id, x = x, y = y + 12 }
end
end
end
-- The crosshair glides toward the target nearest it that is well on screen.
local best = nil
local bestD = math.huge
for _, tg in ipairs(live) do
if tg.x > 60 and tg.x < W - 60 then
local d = (tg.x - g.cx) ^ 2 + (tg.y - g.cy) ^ 2
if d < bestD then
best = tg
bestD = d
end
end
end
if best then
local ease = math.min(1, dt * 10)
g.cx = g.cx + (best.x - g.cx) * ease
g.cy = g.cy + (best.y - g.cy) * ease
end
-- A snare fires: the light gun's white flash, and whatever is under the crosshair is hit.
if K.hitCount("snares", lastT, t) > 0 then
g.flash = t
if best and math.abs(best.x - g.cx) < 40 and math.abs(best.y - g.cy) < 40 then
g.shot[best.id] = true
g.hits = g.hits + 1
g.falling[#g.falling + 1] = { x = best.x, y = best.y - 12, vx = (math.random() - 0.5) * 120, vy = -220, spin = 0, t = t }
boom(best.x, best.y, 30, 255, 220, 120)
end
end
-- Hit targets spin and fall: one picture, turned.
for i = #g.falling, 1, -1 do
local f = g.falling[i]
f.vy = f.vy + 900 * dt
f.x = f.x + f.vx * dt
f.y = f.y + f.vy * dt
f.spin = f.spin + 900 * dt
if f.y > H + 80 then
table.remove(g.falling, i)
else
spriteRotate(art.target, math.floor(f.spin) % 360)
K.draw(art.target, f.x, f.y, true)
end
end
spriteRotate(art.target, 0)
emitterDraw(art.boom)
-- The crosshair.
local cx = math.floor(g.cx)
local cy = math.floor(g.cy)
-- The ring, drawn a screen pixel at a time as thick as one design unit.
colorForeground(255, 40, 40, 255)
for r = math.floor(26 * K.S), math.floor(28 * K.S) - 1 do
overlayCircle(math.floor(cx * K.S), math.floor(cy * K.S), r)
end
K.box(cx - 40, cy - 1, cx - 12, cy + 1, 255, 40, 40, 255)
K.box(cx + 12, cy - 1, cx + 40, cy + 1, 255, 40, 40, 255)
K.box(cx - 1, cy - 40, cx + 1, cy - 12, 255, 40, 40, 255)
K.box(cx - 1, cy + 12, cx + 1, cy + 40, 255, 40, 40, 255)
ptext(20, H - 40, string.format("HITS %03d", g.hits), 3, 255, 255, 255)
if t - g.flash < 0.05 then
K.flash(255, 255, 255, 255)
end
scanlines(90)
end,
}
-- Versus: two fighters face off. ROUND 1 and FIGHT! land on the first bar's downbeats; then the
-- red fighter strikes on every kick drum and the blue on every snare, each hit knocking the other's
-- health down, with sparks and a shake, until the last beat puts blue down for good.
local function fighterDraw(sprite, frame, x, y)
px(sprite, FIGHTER_FRAME, frame, x, y, FIGHTER_SCALE)
end
ERA.fighter = {
enter = function(t)
hideAll()
world.fight = { red = 1, blue = 1, redPose = 0, bluePose = 0, redUntil = 0, blueUntil = 0, shake = 0, start = K.bar(t) }
end,
draw = function(t, bar, k)
local f = world.fight
local into = bar - f.start
local ox = 0
local oy = 0
local floor = H - 50
local ko = into >= 7.5
-- The stage: a dusk sky in bands, far hills, and a floor.
for i = 0, 9 do
K.box(0, i * 33, W - 1, i * 33 + 32, 60 + i * 14, 30 + i * 8, 90 + i * 6, 255)
end
K.draw(art.hills, 0, floor - 150, W, floor - 30)
K.box(0, floor - 30, W - 1, H - 1, 90, 60, 50, 255)
for x = 0, W, 64 do
K.box(x, floor - 30, x + 2, H - 1, 70, 45, 38, 255)
end
if into >= 1 and not ko then
if K.hitCount("kicks", lastT, t) > 0 then
f.redPose = (math.random() < 0.5) and 1 or 2
f.redUntil = t + 0.18
f.bluePose = 3
f.blueUntil = t + 0.25
f.blue = math.max(0.06, f.blue - FIGHT_DAMAGE)
f.shake = t
boom(W / 2 + 10, floor - 120, 40, 255, 230, 80)
end
if K.hitCount("snares", lastT, t) > 0 then
f.bluePose = (math.random() < 0.5) and 1 or 2
f.blueUntil = t + 0.18
f.redPose = 3
f.redUntil = t + 0.25
f.red = math.max(0.06, f.red - FIGHT_DAMAGE * 0.8)
f.shake = t
boom(W / 2 - 10, floor - 120, 40, 120, 200, 255)
end
end
if ko then
f.blue = 0
f.bluePose = 3
f.blueUntil = t + 1
end
if t - f.shake < 0.12 then
ox = math.random(-6, 6)
oy = math.random(-4, 4)
end
local bob = math.floor(2 * math.sin(K.beat(t) * math.pi * 2))
fighterDraw(art.fighterA, (t < f.redUntil) and f.redPose or 0, W / 2 - 150 + ox, floor - 48 * FIGHTER_SCALE + oy + ((t < f.redUntil) and 0 or bob))
fighterDraw(art.fighterB, (t < f.blueUntil) and f.bluePose or 0, W / 2 - 10 + ox, floor - 48 * FIGHTER_SCALE + oy + ((t < f.blueUntil) and 0 or bob))
emitterDraw(art.boom)
-- Health, names, and the clock.
K.box(20, 20, 270, 38, 60, 0, 0, 255)
K.box(22, 22, 22 + 246 * f.red, 36, 255, 220, 40, 255)
K.box(W - 271, 20, W - 21, 38, 60, 0, 0, 255)
K.box(W - 23 - 246 * f.blue, 22, W - 23, 36, 255, 220, 40, 255)
ptext(20, 44, "P1", 2, 255, 120, 120)
ptext(W - 20 - 3 * 16, 44, "CPU", 2, 140, 180, 255)
ptext(W / 2, 18, string.format("%02d", math.max(0, 99 - math.floor(K.beat(t) - K.beat(K.barTime(f.start))))), 3, 255, 255, 255, "center")
if into < 0.5 then
ptext(W / 2, 160, "ROUND 1", 7, 255, 230, 80, "center")
elseif into < 1 then
ptext(W / 2, 150, "FIGHT!", 9, 255, 80, 60, "center")
elseif ko then
ptext(W / 2, 150, "K.O.", 10, 255, 60, 60, "center")
end
scanlines(110)
end,
}
-- Rhythm: a note highway whose notes are the song's own drums, read out of the music: kicks,
-- snares, and hats each in their lane, scrolling down to the hit line and landing on it exactly as
-- the drum sounds. Every hit is PERFECT, of course: the music is playing itself.
local function rhythmY(ahead)
local s = 1 / (1 + 3 * ahead / RHYTHM_LOOK)
return RHYTHM_TOP + (RHYTHM_HIT - RHYTHM_TOP) * s, s
end
ERA.rhythm = {
enter = function(t)
hideAll()
world.rhythm = { score = 0, combo = 0, flash = { -10, -10, -10 }, words = {} }
end,
draw = function(t, bar, k)
local r = world.rhythm
K.plasmaUpdate(art.plasma, t)
K.under(art.plasma.sprite, 0, 0, W, H, nil, nil, nil, nil, true)
K.under(nil, 0, 0, W, H, 0, 0, 20, 200)
-- The highway, a row at a time, narrowing to the horizon, and the beat lines rushing down it.
for y = RHYTHM_TOP, H - 1, 2 do
local s = (y - RHYTHM_TOP) / (RHYTHM_HIT - RHYTHM_TOP)
local half = RHYTHM_SPAN * 1.7 * s
K.box(W / 2 - half, y, W / 2 + half, y + 1, 20, 14, 40, 230)
end
local b = K.beat(t)
for i = math.ceil(b), math.floor(b) + 5 do
local ahead = K.beatTime(i) - t
if ahead >= 0 and ahead <= RHYTHM_LOOK then
local y, s = rhythmY(ahead)
K.box(W / 2 - RHYTHM_SPAN * 1.7 * s, y, W / 2 + RHYTHM_SPAN * 1.7 * s, y + math.max(1, 3 * s), 90, 70, 140, 255)
end
end
-- The notes: each lane's drum hits in the next RHYTHM_LOOK seconds.
for lane, info in ipairs(RHYTHM_LANES) do
local offset = (lane - 2) * RHYTHM_SPAN
local c = info.rgb
for _, hit in ipairs(sync[info.list]) do
local ahead = hit - t
if ahead > RHYTHM_LOOK then
break
end
if ahead >= 0 then
local y, s = rhythmY(ahead)
local x = W / 2 + offset * s
local w = 46 * s
local h = 14 * s
K.box(x - w, y - h, x + w, y + h, c[1], c[2], c[3], 255)
K.box(x - w, y - h, x + w, y - h * 0.4, 255, 255, 255, 160)
end
end
-- A hit since last frame: the target flashes, sparks fly, and a word pops up.
if K.hitCount(info.list, lastT, t) > 0 then
r.flash[lane] = t
r.combo = r.combo + 1
r.score = r.score + 100 + 10 * math.min(r.combo, 50)
r.words[lane] = t
boom(W / 2 + offset, RHYTHM_HIT, 25, c[1], c[2], c[3])
end
local glow = math.exp(-(t - r.flash[lane]) * 12)
local x = W / 2 + offset
K.box(x - 50, RHYTHM_HIT - 18, x + 50, RHYTHM_HIT + 18, c[1], c[2], c[3], math.floor(60 + 195 * glow))
K.box(x - 44, RHYTHM_HIT - 12, x + 44, RHYTHM_HIT + 12, 10, 10, 20, math.floor(200 * (1 - glow)))
ptext(x, H - 40, info.label, 2, c[1], c[2], c[3], "center")
end
K.box(W / 2 - RHYTHM_SPAN * 1.7, RHYTHM_HIT - 1, W / 2 + RHYTHM_SPAN * 1.7, RHYTHM_HIT + 1, 255, 255, 255, 200)
emitterDraw(art.boom)
-- The latest hit's word in each lane, rising and gone in under half a second.
for lane, when in pairs(r.words) do
local age = t - when
if age < 0.4 then
local c = RHYTHM_LANES[lane].rgb
ptext(W / 2 + (lane - 2) * RHYTHM_SPAN, RHYTHM_HIT - 60 - age * 80, "PERFECT", 2, c[1], c[2], c[3], "center")
end
end
ptext(20, 20, string.format("SCORE %07d", r.score), 2, 255, 255, 255)
ptext(W - 20 - 10 * 16, 20, string.format("COMBO %4d", r.combo), 2, 255, 230, 80)
scanlines(90)
end,
}
-- The finale runs the eras again, a bar each, every one on its own beat; its time is moved by whole
-- bars, so the beat lands where it did. The last two bars are GAME ON.
local MONTAGE = {
{ era = "pong", bar = 6, label = "ANALOG" }, { era = "invaders", bar = 12, label = "8-BIT" },
{ era = "runner", bar = 19, label = "16-BIT" }, { era = "racer", bar = 27, label = "16-BIT" },
{ era = "loading", bar = 37, label = "TAPE" }, { era = "vector", bar = 44, label = "VECTOR" },
{ era = "polygons", bar = 52, label = "32-BIT" }, { era = "gallery", bar = 59, label = "LIGHT GUN" },
{ era = "modern", bar = 67, label = "NOW" }, { era = "continue", bar = 76, label = "CONTINUE" },
{ era = "fighter", bar = 84, label = "VERSUS" }, { era = "boss", bar = 92, label = "BOSS" },
{ era = "bonus", bar = 100, label = "BONUS" }, { era = "rhythm", bar = 108, label = "RHYTHM" },
}
ERA.montage = {
enter = function(t)
world.montageCut = 0
end,
draw = function(t, bar, k)
local cut = math.floor(bar - 121) + 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.fill(r // 3, g // 3, b // 3)
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)
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) % 1) * 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("vector", 41, 49),
place("polygons", 49, 57),
place("gallery", 57, 65),
place("modern", 65, 73),
place("continue", 73, 81),
place("fighter", 81, 89),
place("boss", 89, 97),
place("bonus", 97, 105),
place("rhythm", 105, 113),
place("scores", 113, 121),
-- Over every era until the recap, in the front layer so it stands over 2D and 3D alike.
{
from = 1, to = 121,
draw = function(t, bar, k)
K.scroller3D(t, art.blocks, FEATURE_TEXT, H * 0.5, K.barTime(1), K.barTime(121))
end,
leave = function(t)
K.scroller3DStop(art.blocks)
end,
},
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)