singe/demos/mirrorball/mirrorball.singe
2026-10-05 22:12:53 -05:00

1369 lines
46 KiB
Text

-- Mirrorball: a demo for Singe, to "Vintage Funky Groove" by miiira.
--
-- A night in a nineties club, in phrases as the song is built: a choppy intro, the ball coming down
-- over a lit floor, the oldschool effects the dance parties ran on (copper bars, a rotozoomer, a
-- twister, vector bobs, glenz glass, a bouncing checkered ball), a dot cloud, an equalizer city, a
-- turntable, a laser show, a breakdown in the dark, and a party that cuts faster and faster until
-- the ball shatters on the last downbeat.
--
-- The song speeds up as it goes, from about 111 to 117 beats a minute, so nothing here assumes a
-- tempo: bars and beats come from the beat list in sync.lua, and everything that lands on a beat
-- lands where the drummer put it.
dofile("Singe/Framework.singe")
local K = dofile(DIR .. "../kit.lua")
local W = 640
local H = 480
local FOV = 60
local TILES = 12 -- The dance floor is TILES by TILES
local TILE = 2 -- World units across a tile
local BALL_Y = 6.2 -- Where the ball hangs
local BALL_R = 1.45
local FACET = 0.2
local SPARKLES = 70 -- Points of light the ball throws on the walls
local LASERS = 24
local LASER_LEN = 60 -- How far a beam reaches
local LASER_KINDS = 6 -- Patterns the lasers step through (lasers)
local DOTS = 320 -- The dot cloud's dots: 32 round by 10 for the torus
local DOT_SHAPES = 6
local EQ_COLS = 16 -- The equalizer's bands, left to right ...
local EQ_ROWS = 12 -- ... and how many moments of history run back from the front ...
local EQ_STEP = 0.07 -- ... this many seconds apart
local RECORD_R = 4.5 -- The record on the turntable
local RECORD_RPM = 33.3
local GROOVES = 256 -- The record's texture, a side
local RAIN_MAX = 70 -- Balls that fall on the snares in the boing scene
local TWIST_R = 92 -- The twister's half width, in pixels
local TWIST_ROW = 2 -- Screen rows each slice of the twister covers
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, { 20, 0, 10 } }, { 0.25, { 150, 20, 40 } }, { 0.50, { 255, 150, 30 } }, { 0.70, { 255, 230, 140 } }, { 0.85, { 255, 255, 255 } }, { 1.00, { 20, 0, 10 } } }
local TUNNEL_STOPS = { { 0.00, { 20, 0, 10 } }, { 0.30, { 150, 20, 40 } }, { 0.55, { 255, 150, 30 } }, { 0.80, { 255, 230, 140 } }, { 1.00, { 255, 255, 255 } } }
local DISCO = { { 255, 40, 80 }, { 255, 160, 20 }, { 255, 240, 60 }, { 60, 255, 120 }, { 40, 200, 255 }, { 160, 60, 255 } }
local sync = K.load(DIR, W, H)
local art = {}
local world = {}
local lastT = 0
local SCROLL_TEXT = "MIRRORBALL ... A SINGE DEMO ... THIS GROOVE SPEEDS UP AS IT GOES, AND EVERY FLASH AND EVERY CUT FOLLOWS IT, BEAT BY BEAT ..."
local SECOND_TEXT = "COPPER BARS, A ROTOZOOMER, A TWISTER DRAWN ONE SCANLINE AT A TIME, AND GLENZ GLASS ... THE OLD TRICKS, ON A NEW ENGINE ..."
local PARTY_TEXT = "GREETINGS TO EVERYONE ON THE DANCE FLOOR ... KEEP IT FUNKY ... MIRRORBALL ... A SINGE DEMO ..."
-- Loading --------------------------------------------------------------------------------------------
local function loadArt()
for _, name in ipairs({ "logo", "singe", "twister", "copper", "bob", "glow", "sparkle" }) do
art[name] = spriteLoad(DIR .. "art/" .. name .. ".png")
end
spriteQuality(art.logo, RENDER_SMOOTH)
spriteQuality(art.singe, RENDER_SMOOTH)
art.font = fontLoad("Singe/FreeSansBold.ttf", 44)
-- 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)
end
-- The world --------------------------------------------------------------------------------------------
local function buildFloor()
local tileImage = spriteLoad(DIR .. "art/tile.png")
local mesh = meshBox(TILE * 0.96, 0.2, TILE * 0.96)
world.tiles = {}
for i = 1, TILES do
for j = 1, TILES do
local m = materialNew()
local n = nodeNew()
materialSetColor(m, 40, 30, 50)
materialSetEmissiveMap(m, tileImage)
materialSetRoughness(m, 0.2)
materialSetMetallic(m, 0.3)
nodeSetMesh(n, mesh, m)
nodeSetPosition(n, (i - TILES / 2 - 0.5) * TILE, -0.1, (j - TILES / 2 - 0.5) * TILE)
world.tiles[#world.tiles + 1] = { node = n, material = m, i = i, j = j }
end
end
-- One static body under them all for whatever falls, and walls round the edge to keep it on.
world.floorBody = nodeNew()
nodeSetPosition(world.floorBody, 0, -0.2, 0)
bodyNew(world.floorBody, BODY_STATIC, SHAPE_BOX, TILES * TILE, 0.2, TILES * TILE)
local half = TILES * TILE / 2
for _, wall in ipairs({ { half, 0, 0.5, TILES * TILE }, { -half, 0, 0.5, TILES * TILE }, { 0, half, TILES * TILE, 0.5 }, { 0, -half, TILES * TILE, 0.5 } }) do
local n = nodeNew()
nodeSetPosition(n, wall[1], 2, wall[2])
bodyNew(n, BODY_STATIC, SHAPE_BOX, wall[3], 4, wall[4])
end
end
-- The ball: facets in rings of latitude round a dark core, all one mesh and one material so they
-- draw in one call, on a pivot that turns. Each facet faces out from the centre.
local FACET_GROUPS = 4
local function buildBall()
local mesh = meshBox(FACET * 0.92, FACET * 0.92, 0.04)
-- Facets in four groups, each its own material, so each group can catch the light in turn: a
-- real ball's facets flash as they come round to a lamp. Four materials still draw in four calls.
world.facetMaterials = {}
for g = 1, FACET_GROUPS do
world.facetMaterials[g] = K.chrome(200 + g * 10, 205 + g * 8, 225, 0.06 + g * 0.03)
end
world.ball = nodeNew()
world.facets = {}
local rings = math.floor(math.pi * BALL_R / FACET)
for r = 1, rings - 1 do
local lat = -math.pi / 2 + r / rings * math.pi
local count = math.max(4, math.floor(2 * math.pi * BALL_R * math.cos(lat) / FACET))
for c = 0, count - 1 do
local lon = (c + (r % 2) * 0.5) / count * 2 * math.pi
local f = nodeNew(world.ball)
local x = math.cos(lat) * math.cos(lon) * BALL_R
local y = math.sin(lat) * BALL_R
local z = math.cos(lat) * math.sin(lon) * BALL_R
nodeSetMesh(f, mesh, world.facetMaterials[math.random(1, FACET_GROUPS)])
nodeSetPosition(f, x, y, z)
nodeLookAt(f, 0, 0, 0)
world.facets[#world.facets + 1] = { node = f, x = x, y = y, z = z }
end
end
local core = materialNew()
materialSetColor(core, 10, 10, 14)
world.core = nodeNew(world.ball)
nodeSetMesh(world.core, meshSphere(BALL_R - 0.03, 24), core)
local rope = materialNew()
materialSetColor(rope, 30, 30, 34)
world.rope = nodeNew()
nodeSetMesh(world.rope, meshCylinder(0.03, 12, 6), rope)
nodeSetPosition(world.ball, 0, BALL_Y, 0)
-- The light the ball throws: sparkles on a great sphere round it, turning with it.
world.sparkleRing = nodeNew()
nodeSetPosition(world.sparkleRing, 0, BALL_Y, 0)
world.sparkles = {}
for i = 1, SPARKLES do
local phi = math.acos(1 - 2 * (i - 0.5) / SPARKLES) * 0.75
local theta = math.pi * (1 + math.sqrt(5)) * i
local n = nodeNew(world.sparkleRing)
nodeSetSprite(n, art.sparkle, 1.8, false)
nodeSetBillboard(n, BILLBOARD_ALL)
nodeSetPosition(n, math.cos(theta) * math.sin(phi + 0.6) * 16, -math.cos(phi + 0.6) * 9, math.sin(theta) * math.sin(phi + 0.6) * 16)
world.sparkles[i] = n
end
end
local function buildLights()
world.spots = {}
for i = 1, 4 do
local l = lightNew(LIGHT_SPOT)
local c = DISCO[i + 1]
lightSetColor(l, c[1], c[2], c[3])
lightSetIntensity(l, 0)
lightSetRange(l, 60)
lightSetCone(l, 10, 18)
world.spots[i] = l
end
world.ballSpot = lightNew(LIGHT_SPOT)
nodeSetPosition(world.ballSpot, 0, 14, 8)
nodeLookAt(world.ballSpot, 0, BALL_Y, 0)
lightSetColor(world.ballSpot, 255, 250, 240)
lightSetIntensity(world.ballSpot, 0)
lightSetCone(world.ballSpot, 5, 9)
world.sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(world.sun, 4, 10, 6)
nodeLookAt(world.sun, 0, 0, 0)
lightSetIntensity(world.sun, 0)
lightSetShadow(world.sun, true)
end
-- Lasers: thin bright rods, each on an arm placed and aimed every frame by the pattern (lasers),
-- in the club's colours. The rod hangs off its arm's -Z, the way nodeLookAt points.
local function buildLasers()
local mesh = meshBox(1, 1, LASER_LEN)
world.laserMaterials = {}
for i, c in ipairs(DISCO) do
local m = materialNew()
materialSetColor(m, 0, 0, 0)
materialSetEmissive(m, c[1], c[2], c[3])
world.laserMaterials[i] = m
end
world.laserFan = nodeNew()
world.lasers = {}
for i = 1, LASERS do
local arm = nodeNew(world.laserFan)
local rod = nodeNew(arm)
nodeSetMesh(rod, mesh, world.laserMaterials[i % #DISCO + 1])
nodeSetPosition(rod, 0, 0, -LASER_LEN / 2)
nodeSetShadow(rod, false)
world.lasers[i] = { arm = arm, rod = rod }
end
end
-- Glenz vectors: a glass octahedron inside a glass cube, both faceted, turning against each other.
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 faces = { { 1, 3, 5 }, { 5, 3, 2 }, { 2, 3, 6 }, { 6, 3, 1 }, { 5, 4, 1 }, { 2, 4, 5 }, { 6, 4, 2 }, { 1, 4, 6 } }
local positions = {}
local normals = {}
local indices = {}
for _, f in ipairs(faces) do
local a = v[f[1]]
local b = v[f[2]]
local c = v[f[3]]
local nx = (a[1] + b[1] + c[1]) / 3
local ny = (a[2] + b[2] + c[2]) / 3
local nz = (a[3] + b[3] + c[3]) / 3
local l = math.sqrt(nx * nx + ny * ny + nz * nz)
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
local function buildGlenz()
world.glenz = nodeNew()
world.glenzOuter = nodeNew(world.glenz)
world.glenzInner = nodeNew(world.glenz)
local outer = K.glass(255, 60, 200, 110)
local inner = K.glass(60, 220, 255, 150)
materialSetDoubleSided(outer, true)
materialSetEmissive(outer, 70, 12, 60)
materialSetEmissive(inner, 12, 60, 75)
nodeSetMesh(world.glenzOuter, meshBox(2.4, 2.4, 2.4), outer)
nodeSetMesh(world.glenzInner, octahedron(1.25), inner)
end
-- The boing ball, and the balls that rain on the snares.
local function buildBoing()
local skin = materialNew()
materialSetTexture(skin, spriteLoad(DIR .. "art/boing.png"))
materialSetRoughness(skin, 0.35)
world.boing = nodeNew()
nodeSetMesh(world.boing, meshSphere(1.6, 32), skin)
world.rainMesh = meshSphere(0.32, 16)
world.rainMaterials = {}
for i, c in ipairs(DISCO) do
local m = materialNew()
materialSetColor(m, c[1], c[2], c[3])
materialSetMetallic(m, 0.4)
materialSetRoughness(m, 0.2)
world.rainMaterials[i] = m
end
world.rain = {}
end
-- Screens for the 2D-in-3D effects: the rotozoomer, the tunnel, and a kaleidoscope of four mirrored
-- copies of the plasma.
local function buildScreens()
local roto = materialNew()
materialSetTexture(roto, spriteLoad(DIR .. "art/roto.png"))
materialSetUnlit(roto, true)
materialSetDoubleSided(roto, true)
materialSetFilter(roto, FILTER_NEAREST)
world.rotoMaterial = roto
world.roto = K.screenQuad(world.camera, FOV, roto, 2, 2.2)
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, 60, 1.8)
world.kaleido = nodeNew(world.camera)
world.kaleidoMaterials = {}
nodeSetPosition(world.kaleido, 0, 0, -2)
local h = 2 * 2 * math.tan(math.rad(FOV / 2))
local w = h * W / H
for _, q in ipairs({ { -1, 1, 1, 1 }, { 1, 1, -1, 1 }, { -1, -1, 1, -1 }, { 1, -1, -1, -1 } }) do
local m = materialNew()
local n = nodeNew(world.kaleido)
materialSetTexture(m, art.plasma.sprite)
world.kaleidoMaterials[#world.kaleidoMaterials + 1] = m
materialSetUnlit(m, true)
materialSetDoubleSided(m, true)
materialSetTiling(m, q[3], q[4])
nodeSetMesh(n, meshPlane(w / 2, h / 2), m)
nodeSetPosition(n, q[1] * w / 4, q[2] * h / 4, 0)
nodeSetRotation(n, 90, 0, 0)
end
end
-- The dot cloud: glowing dots that gather into a new shape on every bar. Each shape is worked out
-- once, a point per dot.
local function dotShape(shape, i)
local f = (i - 0.5) / DOTS
if shape == 0 then
-- A sphere, the dots spread evenly by the golden angle.
local y = 1 - 2 * f
local r = math.sqrt(1 - y * y)
local a = i * 2.39996
return math.cos(a) * r * 3, y * 3, math.sin(a) * r * 3
elseif shape == 1 then
-- A torus, 32 round and 10 about the tube.
local a = (i % 32) / 32 * 2 * math.pi
local b = math.floor(i / 32) / 10 * 2 * math.pi
return (2.6 + math.cos(b)) * math.cos(a), math.sin(b), (2.6 + math.cos(b)) * math.sin(a)
elseif shape == 2 then
-- A cube's six faces, a grid on each.
local k = math.floor((i - 1) / 6)
local u = (k % 8) / 7 * 2 - 1
local v = math.floor(k / 8) % 8 / 7 * 2 - 1
local p = ({ { 1, u, v }, { -1, u, v }, { u, 1, v }, { u, -1, v }, { u, v, 1 }, { u, v, -1 } })[(i - 1) % 6 + 1]
return p[1] * 2.2, p[2] * 2.2, p[3] * 2.2
elseif shape == 3 then
-- Two strands of a helix.
local k = math.floor((i - 1) / 2)
local a = k * 0.25 + (i % 2) * math.pi
return math.cos(a) * 1.8, (k / (DOTS / 2) - 0.5) * 7, math.sin(a) * 1.8
elseif shape == 4 then
-- A flower of eight petals, rippling.
local a = f * 2 * math.pi
local r = 3.2 * math.cos(4 * a)
return math.cos(a) * r, 0.8 * math.sin(8 * a), math.sin(a) * r
end
-- A flat grid, which the music makes into waves (dotsUpdate).
return (i % 20) / 19 * 7 - 3.5, 0, math.floor(i / 20) / 15 * 6 - 3
end
local function buildDots()
local mesh = meshSphere(0.11, 8)
world.dots = nodeNew()
world.dotNodes = {}
world.dotMaterials = {}
world.dotShapes = {}
for i, c in ipairs(DISCO) do
local m = materialNew()
materialSetColor(m, 0, 0, 0)
materialSetEmissive(m, c[1], c[2], c[3])
world.dotMaterials[i] = m
end
for i = 1, DOTS do
local n = nodeNew(world.dots)
nodeSetMesh(n, mesh, world.dotMaterials[i % #DISCO + 1])
nodeSetShadow(n, false)
world.dotNodes[i] = n
end
for shape = 0, DOT_SHAPES - 1 do
local points = {}
for i = 1, DOTS do
points[i] = { dotShape(shape, i) }
end
world.dotShapes[shape + 1] = points
end
end
-- The equalizer: a city of bars, a band of the music to each column and the last moments of it
-- running back in the rows, so the beat rolls away from the viewer.
local function buildEqualizer()
local mesh = meshBox(0.8, 1, 0.8)
world.eq = nodeNew()
world.eqBars = {}
world.eqMaterials = {}
for c = 1, EQ_COLS do
local m = materialNew()
materialSetColor(m, 20, 20, 30)
materialSetMetallic(m, 0.6)
materialSetRoughness(m, 0.25)
world.eqMaterials[c] = m
world.eqBars[c] = {}
for r = 1, EQ_ROWS do
local n = nodeNew(world.eq)
nodeSetMesh(n, mesh, m)
world.eqBars[c][r] = n
end
end
end
-- The turntable: a record of grooves round a red label with the Singe logo on it, on a chrome
-- platter, and a tonearm resting on it. The grooves are drawn here, a pixel at a time.
local function buildTurntable()
local grooves = spriteNew(GROOVES, GROOVES)
local pixels = {}
local vinyl = materialNew()
local label = materialNew()
local black = materialNew()
local steel = K.chrome(200, 200, 210, 0.15)
for y = 0, GROOVES - 1 do
for x = 0, GROOVES - 1 do
local d = math.sqrt((x - GROOVES / 2 + 0.5) ^ 2 + (y - GROOVES / 2 + 0.5) ^ 2) / (GROOVES / 2)
local v = math.floor(18 + 10 * (0.5 + 0.5 * math.sin(d * 420)))
if d < 0.34 then
pixels[#pixels + 1] = (d > 0.32) and string.char(240, 200, 90, 255) or string.char(190, 25, 50, 255)
else
-- The gaps between the tracks are smooth, and shine.
for _, gap in ipairs({ 0.52, 0.68, 0.83 }) do
if math.abs(d - gap) < 0.008 then
v = 45
end
end
pixels[#pixels + 1] = string.char(v, v, v + 2, 255)
end
end
end
spriteSetPixels(grooves, table.concat(pixels))
materialSetTexture(vinyl, grooves)
materialSetRoughness(vinyl, 0.3)
materialSetMetallic(vinyl, 0.2)
materialSetTexture(label, art.singe)
materialSetBlend(label, true)
materialSetUnlit(label, true)
materialSetColor(black, 6, 6, 8)
materialSetRoughness(black, 0.25)
world.vinyl = nodeNew()
world.record = nodeNew(world.vinyl)
nodeSetMesh(world.record, K.disc(RECORD_R, 96), vinyl)
local logo = nodeNew(world.record)
local w = RECORD_R * 0.55
nodeSetMesh(logo, meshPlane(w, w * spriteGetHeight(art.singe) / spriteGetWidth(art.singe)), label)
nodeSetPosition(logo, 0, 0.01, 0)
local platter = nodeNew(world.vinyl)
nodeSetMesh(platter, meshCylinder(RECORD_R + 0.15, 0.3, 64), steel)
nodeSetPosition(platter, 0, -0.16, 0)
local plinth = nodeNew(world.vinyl)
nodeSetMesh(plinth, meshBox(12, 0.8, 11), black)
nodeSetPosition(plinth, 0.8, -0.72, 0)
local spindle = nodeNew(world.vinyl)
nodeSetMesh(spindle, meshCylinder(0.08, 0.4, 12), steel)
-- The tonearm turns about its post at the back right; its head rests on the record.
world.tonearm = nodeNew(world.vinyl)
nodeSetPosition(world.tonearm, 5.6, 0.55, -3.8)
local post = nodeNew(world.vinyl)
nodeSetMesh(post, meshCylinder(0.3, 0.9, 16), steel)
nodeSetPosition(post, 5.6, 0.1, -3.8)
local arm = nodeNew(world.tonearm)
nodeSetMesh(arm, meshBox(0.12, 0.12, 6.4), steel)
nodeSetPosition(arm, 0, 0, 3.2)
local head = nodeNew(world.tonearm)
nodeSetMesh(head, meshBox(0.5, 0.2, 0.9), black)
nodeSetPosition(head, 0, -0.35, 6.4)
end
local function buildWorld()
sceneEnable(true)
sceneSetSky(DIR .. "art/club.png")
sceneSetSkyIntensity(1.0)
sceneSetAmbient(20, 16, 30)
sceneSetBloom(0.7, 0.55)
world.camera = nodeNew()
cameraSet(world.camera)
cameraSetPerspective(FOV, 0.1, 400)
buildFloor()
buildBall()
buildLights()
buildLasers()
buildGlenz()
buildBoing()
buildScreens()
buildDots()
buildEqualizer()
buildTurntable()
end
-- What is shown --------------------------------------------------------------------------------------
local function setVisible(list, on)
for _, n in ipairs(list) do
nodeSetVisible(n, on)
end
end
local function hideAll()
for _, tile in ipairs(world.tiles) do
nodeSetVisible(tile.node, false)
end
setVisible({ world.ball, world.rope, world.sparkleRing, world.laserFan, world.glenz, world.boing, world.roto, world.tunnel, world.kaleido, world.dots, world.eq, world.vinyl }, false)
for _, l in ipairs(world.spots) do
lightSetIntensity(l, 0)
end
lightSetIntensity(world.ballSpot, 0)
lightSetIntensity(world.sun, 0)
sceneSetFog()
sceneSetSkyIntensity(1.0)
sceneSetBloom(0.7, 0.55)
end
local function showClub(withBall)
for _, tile in ipairs(world.tiles) do
nodeSetVisible(tile.node, true)
end
setVisible({ world.ball, world.rope, world.sparkleRing }, withBall ~= false)
end
-- The club in motion ----------------------------------------------------------------------------------
-- The ball turns, hangs at height y, and its sparkles turn with it, twinkling on the hats.
local function ballSpin(t, y, speed, sparkle)
local spin = t * (speed or 30)
nodeSetPosition(world.ball, 0, y, 0)
nodeSetRotation(world.ball, 0, spin, 0)
nodeSetPosition(world.rope, 0, y + 6, 0)
nodeSetPosition(world.sparkleRing, 0, y, 0)
nodeSetRotation(world.sparkleRing, 0, spin, 0)
local s = (sparkle or 1) * (0.6 + 0.6 * K.env("hat", t))
for g, m in ipairs(world.facetMaterials) do
local v = (sparkle or 1) * (40 + 215 * math.max(0, math.sin(t * 9 + g * 1.7)) ^ 6 + 60 * K.decay("kicks", t, 8))
materialSetEmissive(m, math.floor(math.min(255, v)), math.floor(math.min(255, v)), math.floor(math.min(255, v * 1.05)))
end
nodeSetScale(world.sparkleRing, 1)
for i, n in ipairs(world.sparkles) do
nodeSetScale(n, s * (0.5 + 0.5 * math.sin(t * 7 + i * 2.3)))
end
lightSetIntensity(world.ballSpot, 60 * (sparkle or 1))
end
-- The spotlights sweep the room on circles of their own, flaring on the kicks.
local function spots(t, level)
local flare = 1 + 1.5 * K.decay("kicks", t, 6)
for i, l in ipairs(world.spots) do
local a = t * 0.7 + i * math.pi / 2
nodeSetPosition(l, math.cos(i * 1.57) * 10, 11, math.sin(i * 1.57) * 10)
nodeLookAt(l, math.cos(a) * 6, 0, math.sin(a) * 6)
lightSetIntensity(l, 120 * level * flare)
end
end
-- The floor's patterns, one per phrase: a checker that swaps on every beat, ripples from the middle
-- on the kicks, diagonal waves, and a scatter that changes every beat. level dims it all.
local function floor(t, pattern, level)
local b = K.beat(t)
local whole = math.floor(b)
local ripple = K.hitAge("kicks", t) * 14
local colour = DISCO[whole % #DISCO + 1]
for _, tile in ipairs(world.tiles) do
local i = tile.i
local j = tile.j
local v = 0
if pattern == 0 then
v = ((i + j + whole) % 2 == 0) and 1 or 0.08
elseif pattern == 1 then
local d = math.sqrt((i - TILES / 2 - 0.5) ^ 2 + (j - TILES / 2 - 0.5) ^ 2) * TILE
v = math.max(0.06, math.exp(-math.abs(d - ripple) * 0.9))
elseif pattern == 2 then
v = 0.5 + 0.5 * math.sin((i + j) * 0.8 - t * 6)
else
v = (((i * 7 + j * 13 + whole * 31) % 17) < 6) and 1 or 0.05
end
local c = colour
if pattern == 2 or pattern == 1 then
c = DISCO[(i + j + whole) % #DISCO + 1]
end
v = v * level
materialSetEmissive(tile.material, math.floor(c[1] * v), math.floor(c[2] * v), math.floor(c[3] * v))
end
end
-- A repeatable number from 0 to 1 for a pair of numbers, so a pattern lands the same way every run.
local function hash(a, b)
return (math.sin(a * 12.9898 + b * 78.233) * 43758.5453) % 1
end
-- The lasers, stepping through their patterns perBar times a bar: a fan sweeping from the back
-- wall, scissors from the two corners, beams thrown off the turning ball, a tunnel round the camera,
-- a sheet that rolls over, and chaos, thrown somewhere new on every snare. The colours step round on
-- every beat, beams chase on the hats, and they fatten on the kicks and flare on the snares.
local function lasers(t, level, perBar)
local kind = math.floor((K.bar(t) - 1) * (perBar or 1)) % LASER_KINDS
local beat = K.beat(t)
local chase = math.floor(beat * 4)
local width = 0.05 + 0.12 * K.decay("kicks", t, 7)
local glow = level * (0.6 + 0.4 * K.decay("snares", t, 8))
local snares = K.hitCount("snares", 0, t)
local cx, cy, cz = nodeGetWorldPosition(world.camera)
for i, c in ipairs(DISCO) do
materialSetEmissive(world.laserMaterials[i], math.floor(c[1] * glow), math.floor(c[2] * glow), math.floor(c[3] * glow))
end
for i, beam in ipairs(world.lasers) do
local f = (i - 0.5) / LASERS
local ox = 0
local oy = 9
local oz = -16
local dx = 0
local dy = 0
local dz = 1
local on = true
if kind == 0 then
local a = math.rad(math.sin(t * 1.3) * 25 + (f - 0.5) * (110 + 40 * math.sin(t * 0.7)))
local e = math.rad(-12 + 14 * math.sin(t * 0.9 + i))
dx = math.sin(a) * math.cos(e)
dy = math.sin(e)
dz = math.cos(a) * math.cos(e)
on = (i + chase) % 3 ~= 0
elseif kind == 1 then
local side = (i % 2 == 0) and 1 or -1
local k = math.floor((i - 1) / 2)
local a = math.rad(-side * (15 + k * 5 + 30 * math.sin(t * 2.4)))
local e = math.rad(-8 - 6 * math.sin(t * 3 + k))
ox = side * 12
oy = 8
oz = -12
dx = math.sin(a) * math.cos(e)
dy = math.sin(e)
dz = math.cos(a) * math.cos(e)
elseif kind == 2 then
local y = -0.85 + 0.9 * f
local r = math.sqrt(1 - y * y)
local a = i * 2.39996 + t * 0.9
oy = BALL_Y
oz = 0
dx = math.cos(a) * r
dy = y
dz = math.sin(a) * r
elseif kind == 3 then
local a = i / LASERS * 2 * math.pi + t * 0.8
ox = math.cos(a) * 9
oy = 5 + math.sin(a) * 6
oz = -18
dx = cx + math.cos(a) * 2.5 - ox
dy = cy + math.sin(a) * 2.5 - oy
dz = cz - oz
elseif kind == 4 then
local p = math.rad((f - 0.5) * 160)
local r = math.rad(t * 70)
oy = 10
dx = math.sin(p) * math.cos(r)
dy = math.sin(p) * math.sin(r) - 0.25
dz = math.cos(p)
on = (i + chase) % 2 == 0
else
ox = ((i % 3) - 1) * 12
oy = 9 + (i % 3 == 1 and 1 or 0)
oz = -14
dx = hash(i, snares) * 2 - 1 + 0.1 * math.sin(t * 3 + i)
dy = -hash(snares, i) * 0.8
dz = 0.3 + hash(i + 7, snares)
end
nodeSetVisible(beam.arm, on)
nodeSetPosition(beam.arm, ox, oy, oz)
nodeLookAt(beam.arm, ox + dx, oy + dy, oz + dz)
nodeSetScale(beam.rod, width, width, 1)
nodeSetMaterial(beam.rod, world.laserMaterials[(i + math.floor(beat)) % #DISCO + 1])
end
end
local function orbit(t, radius, height, speed, lookY, phase)
local a = t * speed + (phase or 0)
nodeSetPosition(world.camera, math.cos(a) * radius, height, math.sin(a) * radius)
nodeLookAt(world.camera, 0, lookY, 0)
end
-- 2D effects --------------------------------------------------------------------------------------------
-- Copper bars: count bars swinging up and down, coloured by hue, each a gradient strip stretched.
local function copperBars(t, count, y0, amp, hueShift, alpha)
for i = 1, count do
local y = y0 + math.sin(t * 2.1 + i * 0.55) * amp
local r, g, b = K.hue(i / count * 0.6 + hueShift)
spriteSetTint(art.copper, r, g, b)
spriteSetAlpha(art.copper, alpha or 255)
spriteDraw(art.copper, 0, math.floor(y), W - 1, math.floor(y) + 23)
end
spriteSetTint(art.copper, 255, 255, 255)
spriteSetAlpha(art.copper, 255)
end
-- The twister: a square bar turning about the vertical, its turn varying down the screen. Each
-- scanline shows the faces whose edges come round in order there, each face one row of its texture
-- stretched between its two edges and shaded by how squarely it faces the viewer.
local function twister(t, cx)
local faceW = 64
for y = 0, H - 1, TWIST_ROW do
local a = t * 1.6 + math.sin(y * 0.011 + t * 1.2) * 2.2 + math.sin(t * 0.5) * y * 0.004
local row = math.floor(y + t * 60) % 64
local x = {}
local wobble = math.sin(y * 0.02 + t * 2) * 30
for e = 0, 4 do
x[e] = cx + wobble + math.sin(a + e * math.pi / 2) * TWIST_R
end
for e = 0, 3 do
if x[e + 1] > x[e] + 0.5 then
local shade = math.floor(80 + 175 * math.sin(math.min(math.pi, (x[e + 1] - x[e]) / (2 * TWIST_R) * math.pi * 0.72)))
spriteSetTint(art.twister, shade, shade, shade)
spriteDrawGrid(art.twister, math.floor(x[e]), y, e * faceW, row, faceW, 1, math.floor(x[e + 1]), y + TWIST_ROW - 1)
end
end
end
spriteSetTint(art.twister, 255, 255, 255)
end
-- The title, a letter at a time: each falls from above, lands on its beat, and bounces lower each
-- beat after.
local TITLE = "MIRRORBALL"
local function titleDrop(t, firstBeat, beatsApart, y)
local b = K.beat(t)
local widths = {}
local total = 0
for i = 1, #TITLE do
widths[i] = K.textWidth(TITLE:sub(i, i), 72) + 4
total = total + widths[i]
end
local x = W / 2 - total / 2
for i = 1, #TITLE do
local land = firstBeat + (i - 1) * beatsApart
local since = b - land
if since > -1 then
local lift = 0
if since < 0 then
lift = (since * since) * 300
else
local bounce = since % 1
local height = 60 * math.exp(-math.floor(since) * 0.8)
lift = 4 * height * bounce * (1 - bounce)
end
local r, g, bb = K.hue(i / #TITLE + t * 0.05)
K.text(x + widths[i] / 2, y - lift, TITLE:sub(i, i), 72, r, g, bb, "center")
end
x = x + widths[i]
end
end
-- The shatter -------------------------------------------------------------------------------------------
-- Every facet leaves the ball: put where it is in the world, given a body, and thrown outward and
-- up with a spin.
local function shatter(t)
local spin = math.rad(t * world.lastSpin)
local c = math.cos(spin)
local s = math.sin(spin)
local by = world.lastBallY
nodeSetVisible(world.core, false)
nodeSetVisible(world.rope, false)
for _, f in ipairs(world.facets) do
-- The ball turned about y by spin; the facet's place in the room follows.
local x = f.x * c + f.z * s
local z = -f.x * s + f.z * c
nodeSetParent(f.node, 0)
nodeSetPosition(f.node, x, by + f.y, z)
nodeLookAt(f.node, 0, by, 0)
bodyNew(f.node, BODY_DYNAMIC, SHAPE_BOX, FACET, FACET, 0.04)
bodySetMass(f.node, 0.05)
bodySetBounce(f.node, 0.35)
local out = 3 + math.random() * 5
bodySetVelocity(f.node, x / BALL_R * out, (f.y / BALL_R) * out + 3, z / BALL_R * out)
bodySetAngularVelocity(f.node, (math.random() - 0.5) * 20, (math.random() - 0.5) * 20, (math.random() - 0.5) * 20)
end
world.shattered = true
end
-- Scenes placed more than once ---------------------------------------------------------------------
-- Each is a scene table without its bars; at() gives it them, and a flash of white as it starts.
local EQ_BANDS = { "kick", "kick", "kick", "bass", "bass", "bass", "level", "level", "bright", "bright", "snare", "snare", "snare", "hat", "hat", "hat" }
local function at(from, to, scene)
local placed = {}
local start = K.barTime(from)
for key, value in pairs(scene) do
placed[key] = value
end
placed.from = from
placed.to = to
placed.draw = function(t, bar, k)
scene.draw(t, bar, k)
K.flash(255, 255, 255, math.floor(255 * math.exp(-math.max(0, t - start) * 9)))
end
return placed
end
-- The dot cloud gathers into a new shape on every bar, the dots setting off one after another, and
-- swells on the kicks. The flat grid rides the music as waves.
local DOT_CLOUD = {
enter = function(t)
hideAll()
nodeSetVisible(world.dots, true)
sceneSetSkyIntensity(0.35)
end,
draw = function(t, bar, k)
local whole = math.floor(bar)
local before = (whole - 2) % DOT_SHAPES + 1
local now = (whole - 1) % DOT_SHAPES + 1
local into = (bar - whole) * 4
local pulse = 1 + 0.25 * K.decay("kicks", t, 7)
local level = K.env("level", t)
local glow = 0.6 + 0.6 * K.env("hat", t)
for i, n in ipairs(world.dotNodes) do
local m = K.ease(K.clamp(into * 1.4 - (i / DOTS) * 0.6, 0, 1))
local a = world.dotShapes[before][i]
local b = world.dotShapes[now][i]
local x = K.lerp(a[1], b[1], m) * pulse
local z = K.lerp(a[3], b[3], m) * pulse
local flat = (before == DOT_SHAPES and 1 - m or 0) + (now == DOT_SHAPES and m or 0)
nodeSetPosition(n, x, K.lerp(a[2], b[2], m) * pulse + flat * math.sin(x * 1.3 + z * 0.7 + t * 5) * 1.2 * level, z)
end
for i, c in ipairs(DISCO) do
materialSetEmissive(world.dotMaterials[i], math.floor(math.min(255, c[1] * glow)), math.floor(math.min(255, c[2] * glow)), math.floor(math.min(255, c[3] * glow)))
end
nodeSetPosition(world.dots, 0, 2.5, 0)
nodeSetRotation(world.dots, 20 * math.sin(t * 0.5), t * 40, 0)
nodeSetPosition(world.camera, 0, 3.2, 7.5)
nodeLookAt(world.camera, 0, 2.5, 0)
K.strobe(t, "kicks", 14, 255, 255, 255, 30)
end,
}
-- The equalizer city, flown over low: each column a band of the music, each row the same band a
-- moment earlier, coloured round the wheel and lit by how loud the front row is.
local EQUALIZER = {
enter = function(t)
hideAll()
nodeSetVisible(world.eq, true)
sceneSetSkyIntensity(0.7)
lightSetIntensity(world.sun, 3)
end,
draw = function(t, bar, k)
for c = 1, EQ_COLS do
local band = EQ_BANDS[c]
local tilt = 0.75 + 0.25 * math.sin(c * 1.7)
local front = K.env(band, t) * tilt
local cr, cg, cb = K.hue(c / EQ_COLS * 0.8 + t * 0.05)
local g = 0.2 + 0.9 * front
for r = 1, EQ_ROWS do
local h = 0.15 + 5.5 * K.env(band, t - (r - 1) * EQ_STEP) * tilt
local n = world.eqBars[c][r]
nodeSetPosition(n, c - EQ_COLS / 2 - 0.5, h / 2, -(r - 1))
nodeSetScale(n, 1, h, 1)
end
materialSetEmissive(world.eqMaterials[c], math.floor(math.min(255, cr * g)), math.floor(math.min(255, cg * g)), math.floor(math.min(255, cb * g)))
end
nodeSetPosition(world.camera, math.sin(t * 0.35) * 8, 3.2 + math.sin(t * 0.6), 7)
nodeLookAt(world.camera, 0, 1.2, -5)
sceneSetBloom(0.6, 0.6 + 0.4 * K.decay("kicks", t, 7))
end,
}
-- The turntable: the record turns at its speed and is scratched back and forth on every snare, the
-- tonearm creeps inward, and the camera cuts round it every two beats.
local TURNTABLE_SHOTS = {
{ -7, 2.2, 6, 0, 0, 0 }, { 0, 9, 0.1, 0, 0, 0 }, { 6.5, 1.4, 3.5, 2.5, 0.2, 1.5 }, { -1.5, 0.7, 6, 0, 0.1, 0 },
}
local TURNTABLE = {
enter = function(t)
hideAll()
nodeSetVisible(world.vinyl, true)
sceneSetSkyIntensity(0.6)
lightSetIntensity(world.sun, 2)
world.recordLast = t
end,
draw = function(t, bar, k)
local age = K.hitAge("snares", t)
local scratch = -45 * math.sin(age * 2 * math.pi * 3) * math.exp(-age * 7)
local shot = TURNTABLE_SHOTS[math.floor(K.beat(t) / 2) % #TURNTABLE_SHOTS + 1]
local drift = math.sin(t * 0.7) * 0.4
world.recordAngle = (world.recordAngle or 0) + (t - world.recordLast) * RECORD_RPM * 6
world.recordLast = t
nodeSetRotation(world.record, 0, -(world.recordAngle + scratch), 0)
nodeSetRotation(world.tonearm, 0, -21 - 6 * k, 0)
nodeSetPosition(world.camera, shot[1] + drift, shot[2], shot[3])
nodeLookAt(world.camera, shot[4], shot[5], shot[6])
spots(t, 0.8)
K.strobe(t, "kicks", 16, 255, 255, 255, 25)
end,
}
-- The party: the club with everything on. The final version turns the camera faster and the lasers
-- change twice a bar.
local function party(final)
return {
enter = function(t)
hideAll()
showClub(true)
nodeSetVisible(world.laserFan, true)
end,
draw = function(t, bar, k)
if final then
orbit(t, 9 + 3 * math.sin(t * 0.5), 2 + 2 * math.sin(t * 0.3), 0.45, BALL_Y - 1)
else
orbit(t, 13, 3.5, 0.18, BALL_Y - 1.5)
end
ballSpin(t, BALL_Y, final and 70 or 40, 1)
spots(t, 1)
lasers(t, 1, final and 2 or 1)
floor(t, math.floor((bar - 97) / 2) % 4, 1)
sceneSetBloom(0.7, 0.55 + (final and 0.4 or 0.2) * K.decay("kicks", t, 7))
K.bobs(t, bar, art.bob, 36, 0.9, 13, W / 2, 150)
K.strobe(t, "kicks", 18, 255, 255, 255, final and 50 or 25)
end,
}
end
-- The scenes -------------------------------------------------------------------------------------------
local SCENES = {
-- Bars 1 to 8: the choppy intro. Copper bars light only while the band plays, so the gaps in
-- the groove are black; the title drops in a letter every two beats.
{
from = 0, to = 9,
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
K.flash(0, 0, 0, 255)
local gate = K.clamp((K.env("level", t) - 0.15) * 2.5, 0, 1)
copperBars(t, 10, 210, 170, t * 0.05, math.floor(255 * gate))
titleDrop(t, K.beat(K.barTime(2)), 2, 230)
if bar >= 7 then
local w = spriteGetWidth(art.singe) * 0.5
spriteSetAlpha(art.singe, math.floor(255 * K.smooth(7, 7.5, bar)))
spriteDraw(art.singe, math.floor(W / 2 - w / 2), 330, math.floor(W / 2 + w / 2), 330 + spriteGetHeight(art.singe) // 2)
spriteSetAlpha(art.singe, 255)
K.text(W / 2, 312, "PRESENTED WITH", 18, 200, 180, 140, "center")
end
K.strobe(t, "kicks", 16, 255, 220, 160, 40)
if bar > 8.75 then
K.flash(255, 255, 255, math.floor(255 * K.smooth(8.75, 9, bar)))
end
end,
},
-- Bars 9 to 16: the club. The ball comes down over the floor, the spots sweep, the floor
-- checker swaps on every beat, and the title settles at the bottom.
{
from = 9, to = 17,
enter = function(t)
hideAll()
showClub(true)
end,
draw = function(t, bar, k)
local drop = K.easeOut((bar - 9) / 2)
ballSpin(t, K.lerp(16, BALL_Y, drop), 30, drop)
orbit(t, 13 - 3 * k, 2.5 + 2 * k, 0.12, 3.5 + 1.5 * drop)
spots(t, drop)
floor(t, 0, 0.3 + 0.7 * drop)
if bar >= 13 then
local a = math.floor(255 * K.smooth(13, 13.5, bar))
local w = spriteGetWidth(art.logo) * 0.85
local h = spriteGetHeight(art.logo) * 0.85
spriteSetAlpha(art.logo, a)
spriteDraw(art.logo, math.floor(W / 2 - w / 2), math.floor(H - 20 - h), math.floor(W / 2 + w / 2), H - 20)
spriteSetAlpha(art.logo, 255)
end
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(9, 9.25, bar))))
end,
},
-- Bars 17 to 24: the lead comes in, and the rotozoomer with it: the floor's own disco tile, on a
-- screen that turns and zooms, under copper bars coloured by the note being played and the
-- scroller.
{
from = 17, to = 25,
enter = function(t)
hideAll()
nodeSetVisible(world.roto, true)
end,
draw = function(t, bar, k)
nodeSetPosition(world.camera, 0, 0, 0)
nodeLookAt(world.camera, 0, 0, -1)
local zoom = 4 + 3 * math.sin(t * 0.6) + 2 * K.decay("kicks", t, 7)
materialSetTiling(world.rotoMaterial, zoom * W / H, zoom)
nodeSetRotation(world.roto, 0, 0, t * 30 + math.sin(t * 0.8) * 40)
local note, clear = K.note(t)
copperBars(t, 6, 40, 30, note / 12, math.floor(120 + 135 * clear))
K.scroller(t, art.font, SCROLL_TEXT, H - 80, 22, K.barTime(17), K.barTime(25))
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(17, 17.2, bar))))
end,
},
-- Bars 25 to 28: a kaleidoscope of the plasma, four mirrored copies, under vector bobs that take
-- a new shape every two bars. Then the dot cloud.
{
from = 25, to = 29,
enter = function(t)
hideAll()
nodeSetVisible(world.kaleido, true)
end,
draw = function(t, bar, k)
K.plasmaUpdate(art.plasma, t)
for _, m in ipairs(world.kaleidoMaterials) do
materialSetTexture(m, art.plasma.sprite)
end
nodeSetPosition(world.camera, 0, 0, 0)
nodeLookAt(world.camera, 0, 0, -1)
nodeSetRotation(world.kaleido, 0, 0, math.sin(t * 0.4) * 20)
nodeSetScale(world.kaleido, 1.15 + 0.1 * K.decay("kicks", t, 6))
K.bobs(t, bar, art.bob, 56, 1.4, 21, W / 2, H / 2)
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(25, 25.2, bar))))
end,
},
at(29, 33, DOT_CLOUD),
-- Bars 33 to 40: the chop comes back, and the picture cuts on every beat between the club's
-- corners; the gaps in the groove go black.
{
from = 33, to = 41,
enter = function(t)
hideAll()
showClub(true)
nodeSetVisible(world.laserFan, true)
end,
draw = function(t, bar, k)
local beat = math.floor(K.beat(t))
local shots = {
{ 0, 1.5, 10, 0, 5, 0 }, { 9, 9, 9, 0, 3, 0 }, { 0, 13, 0.1, 0, 0, 0 }, { -3, 6.5, 3, 0, 6.2, 0 },
{ 12, 1, -4, 0, 4, 0 }, { 0, 4, -11, 0, 5, 0 }, { -9, 2, 6, 0, 6, 0 }, { 3, 5.5, 2.5, 0, 6.2, 0 },
}
local s = shots[beat % #shots + 1]
nodeSetPosition(world.camera, s[1], s[2], s[3])
nodeLookAt(world.camera, s[4], s[5], s[6])
ballSpin(t, BALL_Y, 40, 1)
spots(t, 1)
lasers(t, 0.8)
floor(t, 3, 1)
local gate = K.clamp((K.env("level", t) - 0.12) * 3, 0, 1)
K.flash(0, 0, 0, math.floor(255 * (1 - gate)))
K.flash(255, 255, 255, math.floor(160 * math.exp(-(K.beat(t) % 1) * 14)))
end,
},
-- Bars 41 to 48: the twister over copper bars, drawn one scanline at a time.
{
from = 41, to = 49,
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
K.flash(14, 4, 22, 255)
copperBars(t, 14, 220, 200, t * 0.03, 200)
twister(t, W / 2 + math.sin(t * 0.7) * 120)
K.scroller(t, art.font, SECOND_TEXT, 30, 14, K.barTime(41), K.barTime(49))
K.strobe(t, "kicks", 14, 255, 255, 255, 50)
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(41, 41.2, bar))))
end,
},
-- Bars 49 to 52: the boing ball, bouncing on every beat and crossing the room every bar, while
-- a ball drops from the ceiling on every snare and piles up on the floor. Then the equalizer.
{
from = 49, to = 53,
enter = function(t)
hideAll()
showClub(false)
nodeSetVisible(world.boing, true)
lightSetIntensity(world.sun, 3)
end,
draw = function(t, bar, k)
local b = K.beat(t)
local hop = b % 1
local across = math.sin((bar - 49) * math.pi)
nodeSetPosition(world.boing, across * 8, 1.6 + 4 * 4 * hop * (1 - hop) * 1.4, 2)
nodeSetRotation(world.boing, 20, t * 120, 0)
orbit(t, 12, 4, 0.08, 2.5, 1.2)
floor(t, 1, 0.8)
spots(t, 0.6)
if K.hitCount("snares", lastT, t) > 0 and #world.rain < RAIN_MAX then
local n = nodeNew()
nodeSetMesh(n, world.rainMesh, world.rainMaterials[#world.rain % #world.rainMaterials + 1])
nodeSetPosition(n, (math.random() - 0.5) * 16, 14, (math.random() - 0.5) * 10 - 2)
bodyNew(n, BODY_DYNAMIC, SHAPE_SPHERE, 0.32)
bodySetMass(n, 0.5)
bodySetBounce(n, 0.7)
world.rain[#world.rain + 1] = n
end
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(49, 49.2, bar))))
end,
leave = function(t)
for _, n in ipairs(world.rain) do
bodyDelete(n)
nodeDelete(n)
end
world.rain = {}
end,
},
at(53, 57, EQUALIZER),
-- Bars 57 to 60: glenz glass in the tunnel, the tunnel scrubbed by the kick. Then the turntable.
{
from = 57, to = 61,
enter = function(t)
hideAll()
nodeSetVisible(world.tunnel, true)
nodeSetVisible(world.glenz, true)
world.tunnelPhase = 0
world.tunnelLast = t
sceneSetSkyIntensity(1.5)
lightSetIntensity(world.sun, 5)
end,
draw = function(t, bar, k)
nodeSetPosition(world.camera, 0, 0, 0)
nodeLookAt(world.camera, 0, 0, -1)
world.tunnelPhase = world.tunnelPhase + (t - world.tunnelLast) * (0.5 + 1.5 * K.decay("kicks", t, 5))
world.tunnelLast = t
K.tunnelUpdate(art.tunnel, (world.tunnelPhase / TUNNEL_LAP) % 1, 0)
materialSetTexture(world.tunnelMaterial, art.tunnel.sprite)
nodeSetRotation(world.tunnel, 0, 0, -t * 20)
local pump = 1 + 0.2 * K.decay("kicks", t, 8)
nodeSetPosition(world.glenz, 0, 0, -6)
nodeSetScale(world.glenz, pump)
nodeSetRotation(world.glenzOuter, t * 37, t * 53, t * 19)
nodeSetRotation(world.glenzInner, -t * 61, t * 29, -t * 43)
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(57, 57.2, bar))))
end,
},
at(61, 65, TURNTABLE),
-- Bars 65 to 72: the laser show, in a haze, the camera moving through the beams.
{
from = 65, to = 73,
enter = function(t)
hideAll()
showClub(true)
nodeSetVisible(world.laserFan, true)
sceneSetFog(16, 8, 30, 6, 70)
end,
draw = function(t, bar, k)
orbit(t, 12, 2 + math.sin(t * 0.4) * 1.5, -0.15, 5)
ballSpin(t, BALL_Y, 45, 1)
spots(t, 0.8)
lasers(t, 1, 2)
floor(t, 2, 0.7)
K.strobe(t, "snares", 20, 255, 255, 255, 60)
end,
},
-- Bars 73 to 80: the plasma flat on the screen, the Singe logo rotozoomed over it, a ring of bobs.
{
from = 73, to = 81,
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
K.plasmaUpdate(art.plasma, t)
spriteDraw(art.plasma.sprite, 0, 0, W - 1, H - 1)
local punch = 1 + 0.3 * K.decay("kicks", t, 8)
for i = 1, 12 do
local a = i / 12 * 2 * math.pi + t
local r = 190 * punch
local cr, cg, cb = K.hue(i / 12 + t * 0.1)
spriteSetTint(art.bob, cr, cg, cb)
spriteDraw(art.bob, math.floor(W / 2 + math.cos(a) * r - 18), math.floor(H / 2 + math.sin(a) * r * 0.8 - 18), math.floor(W / 2 + math.cos(a) * r + 18), math.floor(H / 2 + math.sin(a) * r * 0.8 + 18))
end
spriteSetTint(art.bob, 255, 255, 255)
spriteRotateAndScale(art.singe, math.sin(t * 0.9) * 35, (1 + 0.4 * math.sin(t * 1.1)) * punch)
spriteDraw(art.singe, W // 2, H // 2, true)
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(73, 73.2, bar))))
end,
leave = function(t)
spriteRotateAndScale(art.singe, 0, 1)
end,
},
-- Bars 81 to 88: the breakdown. The kick is gone; the club is dark but for the ball and its
-- sparkles, turning slowly. When the bass returns at bar 85 the floor comes back a tile a beat.
{
from = 81, to = 89,
enter = function(t)
hideAll()
showClub(true)
end,
draw = function(t, bar, k)
orbit(t, 9, 3 + k * 2, 0.06, BALL_Y)
ballSpin(t, BALL_Y, 12, 0.8 + 0.4 * k)
sceneSetSkyIntensity(0.4 + 0.6 * k)
local lit = (bar >= 85) and math.floor(K.beat(t) - K.beat(K.barTime(85))) * 9 or 0
for n, tile in ipairs(world.tiles) do
local on = ((n * 37) % #world.tiles) < lit
local c = DISCO[n % #DISCO + 1]
local v = on and 0.8 or 0
materialSetEmissive(tile.material, math.floor(c[1] * v), math.floor(c[2] * v), math.floor(c[3] * v))
end
end,
},
-- Bars 89 to 96: the build. Everything comes back on, the camera climbs, and the beat strobes.
{
from = 89, to = 97,
enter = function(t)
hideAll()
showClub(true)
nodeSetVisible(world.laserFan, true)
end,
draw = function(t, bar, k)
orbit(t, 14 - 4 * k, 2 + 7 * k, 0.2, BALL_Y - 2 * k)
ballSpin(t, BALL_Y, 30 + 30 * k, 1)
spots(t, k)
lasers(t, k)
floor(t, math.floor((bar - 89) / 2) % 4, 0.4 + 0.6 * k)
K.flash(255, 255, 255, math.floor(110 * k * math.exp(-(K.beat(t) % 1) * 10)))
copperBars(t, 4, 10, 8, t * 0.1, math.floor(200 * k))
copperBars(t, 4, H - 40, 8, t * 0.1 + 0.5, math.floor(200 * k))
end,
},
-- Bars 97 to 112: the party, cutting faster as it goes: the club, the dot cloud, the equalizer,
-- the club with the camera spinning, the turntable, and the club again to the last downbeat.
at(97, 101, party(false)),
at(101, 103, DOT_CLOUD),
at(103, 105, EQUALIZER),
at(105, 107, party(true)),
at(107, 109, TURNTABLE),
at(109, 113, party(true)),
-- Over the whole party, whatever is cut to: the scroller, and the flash into the last downbeat.
{
from = 97, to = 113,
draw = function(t, bar, k)
K.scroller(t, art.font, PARTY_TEXT, H - 70, 16, K.barTime(97), K.barTime(113))
if bar > 112.5 then
K.flash(255, 255, 255, math.floor(200 * K.smooth(112.5, 113, bar)))
end
end,
},
-- Bar 113 on: the last downbeat. The ball shatters, its facets rain onto the floor, and the
-- lights go down.
{
from = 113, to = 200,
enter = function(t)
hideAll()
showClub(true)
world.lastSpin = 70
world.lastBallY = BALL_Y
shatter(t)
world.endCamera = t
end,
draw = function(t, bar, k)
local p = t - K.barTime(113)
orbit(world.endCamera + p * 0.3, 13 + p * 0.5, 3 + p * 0.25, 0.2, 2)
lightSetIntensity(world.ballSpot, 0)
sceneSetSkyIntensity(math.max(0.25, 1 - p * 0.12))
floor(t, 1, math.max(0, 1 - p * 0.15))
for i, l in ipairs(world.spots) do
lightSetIntensity(l, math.max(0, 120 - p * 25))
end
K.flash(255, 255, 255, math.floor(255 * math.exp(-p * 3)))
end,
},
-- 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,
},
}
loadArt()
buildWorld()
hideAll()
K.run(SCENES)