-- 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)