singe/demos/mirrorball/mirrorball.singe

949 lines
31 KiB
Text

-- Mirrorball: a demo for Singe, to "Vintage Funky Groove" by miiira.
--
-- A night in a nineties club, in eight-bar 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 laser show, a
-- breakdown in the dark, and a finale in which 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 = 9
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 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 video = {}
local world = {}
local lastT = 0
local SCROLL_TEXT = " MIRRORBALL ... A SINGE DEMO ... THIS GROOVE SPEEDS UP AS IT GOES, AND EVERY FLASH, EVERY CUT AND EVERY BOUNCE FOLLOWS IT, BEAT BY BEAT, BECAUSE THE BEATS WERE READ OUT OF THE MUSIC ... COPPER BARS, A ROTOZOOMER, A TWISTER DRAWN ONE SCANLINE AT A TIME, GLENZ GLASS AND A BOUNCING BALL: THE OLD TRICKS, ON A NEW ENGINE ... GREETINGS TO EVERYONE WHO EVER CARRIED A COMPUTER TO A PARTY ... "
-- 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)
video.plasma = videoLoad(DIR .. "video/plasma.mp4")
video.tunnel = videoLoad(DIR .. "video/tunnel.mp4")
video.tunnelFrames = videoGetFrameCount(video.tunnel)
videoSetVolume(video.plasma, 0, 0)
videoSetVolume(video.tunnel, 0, 0)
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 fanning out from a point high on the back wall.
local function buildLasers()
local m = materialNew()
materialSetColor(m, 0, 0, 0)
materialSetEmissive(m, 60, 255, 90)
world.laserMaterial = m
world.laserFan = nodeNew()
nodeSetPosition(world.laserFan, 0, 9, -16)
world.lasers = {}
for i = 1, LASERS do
local arm = nodeNew(world.laserFan)
local rod = nodeNew(arm)
nodeSetMesh(rod, meshBox(0.1, 0.1, 40), m)
nodeSetPosition(rod, 0, 0, 20)
world.lasers[i] = arm
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()
materialSetVideo(tunnel, video.tunnel)
materialSetUnlit(tunnel, true)
materialSetDoubleSided(tunnel, true)
world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 60, 1.8)
world.kaleido = nodeNew(world.camera)
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)
materialSetVideo(m, video.plasma)
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
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()
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 }, false)
for _, l in ipairs(world.spots) do
lightSetIntensity(l, 0)
end
lightSetIntensity(world.ballSpot, 0)
lightSetIntensity(world.sun, 0)
sceneSetFog()
sceneSetSkyIntensity(1.0)
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
local function lasers(t, level)
for i, arm in ipairs(world.lasers) do
local spread = (i - (LASERS + 1) / 2) / LASERS
local sweep = math.sin(t * 1.3) * 25 + spread * (60 + 30 * math.sin(t * 0.7))
nodeSetRotation(arm, 0, sweep, -10 + 12 * math.sin(t * 0.9 + i))
end
local v = math.floor(255 * level * (0.6 + 0.4 * K.decay("snares", t, 8)))
materialSetEmissive(world.laserMaterial, v // 4, v, v // 3)
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
-- 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, 180, K.barTime(17))
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(17, 17.2, bar))))
end,
},
-- Bars 25 to 32: a kaleidoscope of the plasma, four mirrored copies, under vector bobs that take
-- a new shape every two bars.
{
from = 25, to = 33,
enter = function(t)
hideAll()
nodeSetVisible(world.kaleido, true)
videoPlay(video.plasma)
end,
draw = function(t, bar, k)
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,
},
-- 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, SCROLL_TEXT, 30, 14, 200, K.barTime(41) - 40)
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 56: 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.
{
from = 49, to = 57,
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,
},
-- Bars 57 to 64: glenz glass in the tunnel, the tunnel scrubbed by the kick.
{
from = 57, to = 65,
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))) % 1
world.tunnelLast = t
videoSeek(video.tunnel, math.floor(world.tunnelPhase * video.tunnelFrames))
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,
},
-- 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)
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()
videoPlay(video.plasma)
end,
draw = function(t, bar, k)
videoDraw(video.plasma, 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, then everything at once. The floor changes pattern every phrase,
-- the bobs and the scroller come up over the club, and in the last phrase the camera spins.
{
from = 97, to = 113,
enter = function(t)
hideAll()
showClub(true)
nodeSetVisible(world.laserFan, true)
end,
draw = function(t, bar, k)
local final = bar >= 105
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)
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.scroller(t, art.font, " GREETINGS TO EVERYONE ON THE DANCE FLOOR ... KEEP IT FUNKY ... MIRRORBALL ... A SINGE DEMO ... ", H - 70, 16, 190, K.barTime(97))
K.strobe(t, "kicks", 18, 255, 255, 255, final and 50 or 25)
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, the lights
-- go down, and the credits come up.
{
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)))
local lines = {
{ "MIRRORBALL", 56, 255, 200, 90 },
{ "music \"Vintage Funky Groove\" by miiira", 26, 255, 255, 255 },
{ "code and art Claude", 26, 255, 255, 255 },
{ "engine Singe", 26, 255, 255, 255 },
}
local y = 90
for i, line in ipairs(lines) do
local a = K.smooth(1.5 + i * 0.4, 2.1 + i * 0.4, p) * (1 - K.smooth(K.duration - K.barTime(113) - 1, K.duration - K.barTime(113) + 1, p))
if a > 0 then
K.text(W / 2, y, line[1], line[2], math.floor(line[3] * a), math.floor(line[4] * a), math.floor(line[5] * a), "center")
end
y = y + line[2] + 22
end
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)