1369 lines
46 KiB
Text
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)
|