949 lines
31 KiB
Text
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)
|