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