singe/demos/neonCoast/neonCoast.singe
2026-10-06 16:54:44 -05:00

1399 lines
53 KiB
Text

-- Neon Coast: a demo for Singe, to "Neons in Napoli" by eliza_music.
--
-- An eighties night drive, each scene on the bars of the track it belongs to: where the bass drops
-- out, where it slams back, the breakdown, and the dead stop before the last chorus. The song's
-- structure is in demos/README.md, and sync.lua holds what util/demoSync.py heard in it. Every
-- motion runs on the music's clock, never the frame count, so it stays with the song on any machine.
--
-- What it shows off: the 3D scene (a chrome logotype, a chrome knot, and the dragon under a sky they
-- reflect, glass, bloom, fog, a neon city, mountains, a cloth flag waving in the wind), everything
-- per pixel worked out live in Lua (the tunnel, the plasma, and the tape's snow, handed to sprites
-- and to surfaces in 3D every frame), and sprites rotated, scaled, tinted, and added together: a
-- road drawn a screen row at a time, a car rendered by Singe into sprites, vector bobs, scrollers of
-- five kinds, a rotozoomed logo, and fireworks. Nothing on the screen is a recording.
dofile("Singe/Framework.singe")
local K = dofile(DIR .. "../kit.lua")
local sync = K.load(DIR)
local W = K.W -- The screen in design units: 480 tall (kit.lua)
local H = K.H
local FOV = 62 -- The 3D camera's vertical field of view, in degrees
local GRID_CELL = 4 -- The floor grid's cell in world units: the tiling the floor uses
local FLOOR_SIZE = 600
local CRUISE = 14 -- World units a second the floor runs under the camera
-- The road, drawn a row at a time as the arcade racers drew theirs.
local HORIZON_Y = H / 2
local FOCAL = 320 -- Screen offset = world offset * FOCAL / distance ahead
local CAM_HEIGHT = 46
local SEG = 20 -- World units of road in a segment
local DRAW_SEGS = 150
local ROAD_HALF = 110
local STRIP_W = 256
local HAZE = 8
local LAMP_EVERY = 8 -- Segments between streetlights: two pass the car each beat
local SEGS_PER_BAR = LAMP_EVERY * 8
local PLAYER_AHEAD = 72 -- The car's distance ahead of the camera
local CAR_FRAMES = 7 -- The car's sheet: steering from hard left to hard right
local CAR_SCALE = 0.85 -- The car's size: design units to a unit of its sheet as it was drawn for 640 by 480
local ART_DENSITY = 2.25 -- The drawn and rendered art's pixels to a design unit (util/neonCoastArt.py, util/neonCoastCar.sh DENSITY)
local TRAFFIC_FRAMES = 4 -- The traffic sheet: the same car in four other paints
local TRAFFIC_WIDTH = 34 -- A car's width in world units: what our own car is on the road
local TRAFFIC_LANE = 73 -- How far either side of the middle the outside lanes run
local TRAFFIC_PACE = 0.6 -- Traffic's speed, as a share of ours: we pass it
local TRAFFIC_GAP = 300 -- World units from one car to the next, give or take a third
-- The city, in 3D: rows of towers either side of an avenue, repeating every CITY_PERIOD along it,
-- so a camera flying down it can wrap round without a seam. The road scenes see it from afar.
local CITY_ROW = 52
local CITY_WEAVE = 208 -- World units from one swerve to the same swerve again: a bar's flight
local CITY_PERIOD = CITY_WEAVE * 3
local CITY_ISLAND = 2 -- Rows from one tower in the middle of the avenue to the next
local CITY_SWERVE = 14 -- How far either side of the middle the flight swings
local CITY_AVENUE = 30 -- The avenue's half width, to the first row of towers
local ROAD_EYE_Z = 300 -- Where the road scenes' 3D camera stands, looking at the city
local CITY_TURN = 40 -- Degrees a second the city swings past at a bend of 1
-- The run over the mountains: two terrain tiles leapfrogging.
local PEAK_TILE = 400
local PEAK_WIDTH = 1400
local PEAK_TILES = 8 -- Tiles ahead of the camera: 3,200 world units of mountains
local PEAK_FOG_NEAR = 400
local PEAK_FOG_FAR = 2700 -- Wholly fogged before the farthest tile's near edge, so no tile pops in
local PEAK_HAZE = { 225, 40, 125 } -- The sky's colour just above the horizon: the far ridges melt into it
local PEAK_SPEED = 240
local beat = sync.beats[3] - sync.beats[2]
local speed = LAMP_EVERY * SEG / (beat / 2) -- So two streetlights pass on every beat
-- Pictures, the live effects, and the 3D world, made once.
local art = {}
local world = {}
-- The road's state while a road scene is on.
local road = { track = {}, start = 0 }
-- The music's time at the end of the last frame, for "what happened since last frame".
local lastT = 0
local SCROLL_TEXT = "NEON COAST ... A SINGE DEMO ... EVERY BEAT, BAR, AND DRUM HIT HERE WAS READ OUT OF THE MUSIC ... THIS TUNNEL IS WORKED OUT IN LUA, EVERY PIXEL OF EVERY FRAME, AND THE KICK DRUM DRIVES IT ... GREETINGS TO ALL WHO STAY UP LATE MAKING THINGS SHINE ..."
local ROAD_TEXT = "THE ROAD IS DRAWN ONE SCANLINE AT A TIME, THE WAY THE ARCADE RACERS DID IT ... THE CAR IS A 3D MODEL THAT SINGE RENDERED INTO SPRITES ... THE CITY ON THE HORIZON IS REAL 3D, TURNING WITH EVERY BEND ..."
local CHROME_TEXT = "CHROME, GLASS, AND BLOOM ... POINT LIGHTS AND FOG ... PARTICLES WITH TRAILS ... ALL OF IT REFLECTING A SKY PAINTED BY ARITHMETIC ..."
local CIRCLE_TEXT = "A PLASMA WORKED OUT IN LUA, EVERY PIXEL, EVERY FRAME ... "
local CRAWL_LINES = { "SINGE 3", "", "LUA 5.4 SCRIPTING", "LASERDISC AND VIDEO PLAYBACK", "3D SCENES WITH GLTF MODELS", "PHYSICS, CLOTH, AND RAGDOLLS", "GUIS, MIDI, AND A MICROPHONE", "WINDOWS, MACOS, LINUX, AND PI" }
local FLAG_TEXT = "THIS FLAG IS A SOFT BODY, BLOWN BY THE KICK DRUM ... SINGE RECORDS ITS OWN VIDEO, PICTURE AND SOUND ... ONLINE HIGH SCORES ... A WHOLE GAME PACKED IN ONE FILE ... FROM DESKTOPS TO HANDHELDS ..."
local QUESTION = { "ARE", "YOU", "READY?" }
local ENGINE_TEXT = "G A M E E N G I N E"
-- Each firework's colour as it bursts, and the colour it fades into as it falls.
local FIREWORK_COLOURS = {
{ 255, 40, 200, 120, 20, 255 }, -- Magenta to violet
{ 40, 230, 255, 40, 80, 255 }, -- Cyan to blue
{ 255, 210, 60, 255, 60, 20 }, -- Gold to red
{ 90, 255, 120, 20, 200, 220 }, -- Green to teal
{ 255, 90, 60, 255, 30, 140 }, -- Orange to pink
{ 170, 120, 255, 255, 60, 200 }, -- Lilac to magenta
}
local REWIND_SNOW = 170 -- How strongly the snow shows over a rewind
-- The city flight's rewinds, in bars from its start: from, to, and how many times faster than it
-- played. Between them it plays.
local CITY_REWINDS = { { 2, 3, 1.5 }, { 5, 6, 1.5 }, { 8, 10, 3 } }
local LOGO_DROP = 0.5 -- World units below the logotype's base the line under it sits
-- The per-pixel effects' sizes and colours. The plasma's palette ends where it starts, so it cycles
-- with no seam; the tunnel's runs from the dark edge to the white-hot middle.
local FX_W = 320
local FX_H = 240
local SNOW_W = 160
local SNOW_H = 120
local TUNNEL_LAP = 4 -- The tunnel's travel counts a lap of its texture as this many passes
local PLASMA_STOPS = { { 0.00, { 4, 0, 16 } }, { 0.20, { 60, 10, 120 } }, { 0.40, { 255, 30, 170 } }, { 0.55, { 255, 140, 60 } }, { 0.70, { 255, 230, 150 } }, { 0.85, { 40, 230, 255 } }, { 1.00, { 4, 0, 16 } } }
local TUNNEL_STOPS = { { 0.00, { 4, 0, 16 } }, { 0.25, { 60, 10, 120 } }, { 0.50, { 255, 30, 170 } }, { 0.70, { 255, 140, 60 } }, { 0.85, { 255, 230, 150 } }, { 1.00, { 255, 250, 255 } } }
local DRAGON_YAW = -160 -- The dragon faces +X; this turns it toward the camera
local FLAP_MAX = 34 -- Degrees a wing rises and falls
local NOD_MAX = 14 -- Degrees the head tips up and down
local NECK_SHARE = 0.35 -- How much of the head's nod the neck takes as well
-- Loading --------------------------------------------------------------------------------------------
local function loadArt()
for _, name in ipairs({ "road", "glow" }) do
art[name] = spriteLoad(DIR .. "art/" .. name .. ".png")
end
for _, name in ipairs({ "palm", "lamp", "bob", "logo", "singe" }) do
art[name] = K.sprite(DIR .. "art/" .. name .. ".png", ART_DENSITY)
end
art.car = spriteLoadFrames(CAR_FRAMES, DIR .. "art/car.png")
art.traffic = spriteLoad(DIR .. "art/traffic.png")
spriteQuality(art.traffic, RENDER_SMOOTH)
spriteQuality(art.car, RENDER_SMOOTH)
spriteQuality(art.singe, RENDER_SMOOTH)
spriteQuality(art.logo, RENDER_SMOOTH)
spriteQuality(art.palm, RENDER_SMOOTH)
-- The per-pixel effects, worked out live in Lua a frame at a time and handed to sprites.
art.snow = K.snowNew(SNOW_W, SNOW_H)
art.plasma = K.plasmaNew(FX_W, FX_H, PLASMA_STOPS)
art.tunnel = K.tunnelNew(FX_W, FX_H, TUNNEL_STOPS)
end
local function buildWorld()
sceneEnable(true)
sceneSetSky(DIR .. "art/sky.png")
sceneSetSkyIntensity(1.0)
sceneSetBloom(0.75, 0.6)
sceneSetFog(110, 28, 100, 60, 260)
world.camera = nodeNew()
cameraSet(world.camera)
cameraSetPerspective(FOV, 0.1, 4000)
K.screen(world.camera, FOV, 4000)
-- A dark glossy floor that reflects the sun, with the grid in it as light alone: a black base
-- colour would tint the reflection black too.
world.floorMaterial = materialNew()
materialSetColor(world.floorMaterial, 70, 30, 90)
materialSetEmissiveMap(world.floorMaterial, spriteLoad(DIR .. "art/grid.png"))
materialSetEmissive(world.floorMaterial, 255, 255, 255)
materialSetTiling(world.floorMaterial, FLOOR_SIZE / GRID_CELL, FLOOR_SIZE / GRID_CELL)
materialSetMetallic(world.floorMaterial, 0.85)
materialSetRoughness(world.floorMaterial, 0.3)
world.floor = nodeNew()
nodeSetMesh(world.floor, meshPlane(FLOOR_SIZE, FLOOR_SIZE), world.floorMaterial)
local hills = materialNew()
materialSetColor(hills, 50, 14, 70)
materialSetEmissiveMap(hills, spriteLoad(DIR .. "art/gridCyan.png"))
materialSetEmissive(hills, 255, 255, 255)
materialSetTiling(hills, 48, 8)
world.mountains = nodeNew()
nodeSetMesh(world.mountains, meshHeightmap(DIR .. "art/mountains.png", 520, 34, 70), hills)
nodeSetPosition(world.mountains, 0, 0, -230)
world.logo = modelInstance(modelLoad("Singe/SingeText.glb"))
K.paint(world.logo, K.chrome(255, 225, 240, 0.06))
-- The knot, and glass spheres to circle it.
world.knotMaterial = K.chrome(245, 245, 255, 0.05)
world.knot = nodeNew()
nodeSetMesh(world.knot, K.torusKnot(1.6, 0.42, 220, 18), world.knotMaterial)
world.spheres = {}
for i = 1, 6 do
local r, g, b = K.hue(i / 6)
local s = nodeNew()
local m = K.glass(r, g, b)
materialSetColor(m, r, g, b, 150)
materialSetEmissive(m, r // 3, g // 3, b // 3)
nodeSetMesh(s, meshSphere(0.55, 32), m)
world.spheres[i] = s
end
world.sparks = emitterNew(world.knot)
emitterSetBlend(world.sparks, PARTICLE_ADD)
emitterSetRate(world.sparks, 0)
emitterSetLife(world.sparks, 0.5, 1.2)
emitterSetSpeed(world.sparks, 3, 7)
emitterSetSpread(world.sparks, 180)
emitterSetGravity(world.sparks, 0, -4, 0)
emitterSetSize(world.sparks, 0.12, 0.02)
emitterSetColor(world.sparks, 255, 200, 255, 255, 255, 40, 200, 0)
emitterSetRadius(world.sparks, 1.5)
emitterSetTrail(world.sparks, 6, 0.04)
emitterStart(world.sparks)
-- Two coloured lights that circle the knot, so its chrome has something bright to catch.
world.lights = {}
for i, c in ipairs({ { 255, 60, 200 }, { 60, 220, 255 } }) do
local l = lightNew(LIGHT_POINT)
lightSetColor(l, c[1], c[2], c[3])
lightSetIntensity(l, 0)
lightSetRange(l, 30)
world.lights[i] = l
end
-- Billboards round the knot, each showing the plasma; the plasma is drawn afresh each frame they
-- are shown and put on them again.
local screen = materialNew()
world.screenMaterial = screen
materialSetTexture(screen, art.plasma.sprite)
materialSetUnlit(screen, true)
materialSetDoubleSided(screen, true)
world.billboards = {}
for i = 1, 4 do
local a = (i - 0.5) / 4 * 2 * math.pi
local b = nodeNew()
nodeSetMesh(b, meshPlane(6.4, 4.8), screen)
nodeSetPosition(b, math.sin(a) * 15, 3.2, math.cos(a) * 15)
nodeSetRotation(b, 90, math.deg(a) + 180, 0)
world.billboards[i] = b
end
-- The tunnel, on a quad that fills the camera's view, so it can turn in its own plane and the
-- GPU smooths it up to the screen. It is drawn afresh each frame and put on the quad again.
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, 2, true)
-- The flag on its pole, cloth that the kicks blow about.
local pole = materialNew()
materialSetColor(pole, 40, 30, 50)
materialSetMetallic(pole, 1)
materialSetRoughness(pole, 0.3)
world.pole = nodeNew()
nodeSetMesh(world.pole, meshCylinder(0.06, 7, 10), pole)
nodeSetPosition(world.pole, -2.2, 3.5, -6)
local cloth = materialNew()
materialSetTexture(cloth, spriteLoad(DIR .. "art/flag.png"))
materialSetDoubleSided(cloth, true)
materialSetRoughness(cloth, 0.7)
materialSetEmissiveMap(cloth, spriteLoad(DIR .. "art/flag.png"))
materialSetEmissive(cloth, 170, 170, 170)
world.flag = nodeNew()
nodeSetMesh(world.flag, meshPlane(5.2, 3.25, 30, 18), cloth)
nodeSetPosition(world.flag, 0.4, 5.2, -6)
nodeSetRotation(world.flag, 90, 0, 0)
softNew(world.flag, SOFT_CLOTH)
softSetStiffness(world.flag, 0.9, 0.02)
softSetMass(world.flag, 0.35)
softSetDamping(world.flag, 0.05)
for i = 0, 5 do
softPin(world.flag, -2.2, 3.575 + i * 0.65, -6)
end
-- Light beams that shoot along the floor at the camera, one on each kick.
local beam = materialNew()
materialSetColor(beam, 0, 0, 0)
materialSetEmissive(beam, 120, 240, 255)
world.beams = {}
for i = 1, 16 do
local b = nodeNew()
nodeSetMesh(b, meshBox(0.12, 0.06, 9), beam)
nodeSetVisible(b, false)
world.beams[i] = { node = b, z = -1000, x = 0 }
end
world.nextBeam = 1
-- The city: four sizes of tower, each its own mesh and material so the windows keep their size,
-- in rows either side of the avenue down the middle.
local facade = spriteLoad(DIR .. "art/facade.png")
local kinds = { { 12, 40, 12 }, { 16, 70, 14 }, { 10, 28, 10 }, { 18, 120, 16 } }
world.city = nodeNew()
world.towers = {}
for i, k in ipairs(kinds) do
local m = materialNew()
materialSetTexture(m, facade)
materialSetEmissiveMap(m, facade)
materialSetEmissive(m, 255, 255, 255)
materialSetTiling(m, k[1] / 12, k[2] / 24)
materialSetRoughness(m, 0.3)
materialSetMetallic(m, 0.4)
world.towers[i] = { mesh = meshBox(k[1], k[2], k[3]), material = m, h = k[2] }
end
-- Three periods of it, so a camera anywhere in the first sees nothing but city to the fog. Every
-- CITY_ISLAND'th row has a tall tower in the middle of the avenue, halfway through a swerve.
for row = 0, 3 * CITY_PERIOD / CITY_ROW - 1 do
local z = -40 - row * CITY_ROW
for side = -1, 1, 2 do
for col = 0, 1 do
local seed = ((row % (CITY_PERIOD / CITY_ROW)) * 7 + col * 13 + (side + 1) * 5) % 17
local kind = world.towers[seed % 4 + 1]
local n = nodeNew(world.city)
nodeSetMesh(n, kind.mesh, kind.material)
nodeSetPosition(n, side * (CITY_AVENUE + col * 24 + seed % 5), kind.h / 2, z)
end
end
if row % CITY_ISLAND == CITY_ISLAND // 2 then
local island = nodeNew(world.city)
nodeSetMesh(island, world.towers[4].mesh, world.towers[4].material)
nodeSetPosition(island, 0, world.towers[4].h / 2, z)
end
end
nodeSetVisible(world.city, false)
-- A skyline right across the horizon for the road scenes, so the city is there whichever way the
-- road has turned.
world.skyline = nodeNew()
for i = 0, 79 do
local x = -1000 + i * 25
local kind = world.towers[(i * 7) % 4 + 1]
local n = nodeNew(world.skyline)
if math.abs(x) > 110 then
nodeSetMesh(n, kind.mesh, kind.material)
nodeSetPosition(n, x, kind.h / 2, -160 - (i * 37) % 220)
end
end
nodeSetVisible(world.skyline, false)
-- Terrain for the run over the mountains: PEAK_TILES tiles, leapfrogged as the camera passes them.
local peaks = materialNew()
materialSetColor(peaks, 40, 12, 60)
materialSetEmissiveMap(peaks, spriteLoad(DIR .. "art/gridCyan.png"))
materialSetEmissive(peaks, 255, 255, 255)
materialSetTiling(peaks, 40 * PEAK_WIDTH / PEAK_TILE, 40)
world.peaks = {}
local peakMesh = meshHeightmap(DIR .. "art/terrain.png", PEAK_WIDTH, 70, PEAK_TILE)
for i = 1, PEAK_TILES do
local n = nodeNew()
nodeSetMesh(n, peakMesh, peaks)
nodeSetVisible(n, false)
world.peaks[i] = n
end
-- The dragon, its wings, neck, and head hung so they turn at their own joints (the model carries
-- them baked in; see media/DragonPivots.json). The head hangs off the neck, so a nod of the neck
-- carries the head with it.
world.dragon = modelInstance(modelLoad("Singe/DragonModel.glb"))
world.wingL = nodeFind("wingL", world.dragon)
world.wingR = nodeFind("wingR", world.dragon)
world.neck = nodeFind("neck", world.dragon)
world.head = nodeFind("head", world.dragon)
local nx, ny, nz = nodeGetPosition(world.neck)
local hx, hy, hz = nodeGetPosition(world.head)
nodeSetParent(world.head, world.neck)
nodeSetPosition(world.head, hx - nx, hy - ny, hz - nz)
nodeSetVisible(world.dragon, false)
-- Stars streaming at the camera for the run into hyperspace.
world.warpAnchor = nodeNew(world.camera)
nodeSetPosition(world.warpAnchor, 0, 0, -60)
world.warp = emitterNew(world.warpAnchor)
emitterSetBlend(world.warp, PARTICLE_ADD)
emitterSetRate(world.warp, 0)
emitterSetLife(world.warp, 0.6, 1.0)
emitterSetSpeed(world.warp, 40, 80)
emitterSetDirection(world.warp, 0, 0, 1)
emitterSetSpread(world.warp, 25)
emitterSetRadius(world.warp, 18)
emitterSetSize(world.warp, 0.25, 0.1)
emitterSetColor(world.warp, 200, 240, 255, 255, 255, 80, 220, 0)
emitterSetTrail(world.warp, 10, 0.08)
emitterStart(world.warp)
end
-- A beam leaves the horizon on each kick and runs past the camera.
local BEAM_SPEED = 160
local function beams(t, lastT, on, originZ, rate)
if on and K.hitCount("kicks", lastT, t) > 0 then
local b = world.beams[world.nextBeam]
b.z = -160
b.x = (math.random() - 0.5) * 30
world.nextBeam = world.nextBeam % #world.beams + 1
end
for _, b in ipairs(world.beams) do
b.z = b.z + (rate or BEAM_SPEED) * (t - lastT)
nodeSetVisible(b.node, on and b.z < 20)
nodeSetPosition(b.node, b.x, 0.05, (originZ or 0) + b.z)
end
end
-- The knot's lights circle it, brightening with the kicks; off when the knot is.
local function knotLights(t, on)
for i, l in ipairs(world.lights) do
local a = t * 1.3 + i * math.pi
nodeSetPosition(l, math.cos(a) * 4, 4.5 + math.sin(t * 0.9 + i), math.sin(a) * 4)
lightSetIntensity(l, on and (25 + 40 * K.decay("kicks", t, 7)) or 0)
end
end
-- Everything in the world is hidden, and a scene shows what it uses.
local function hideAll()
for _, node in ipairs({ world.floor, world.mountains, world.logo, world.knot, world.tunnel, world.pole, world.flag }) do
nodeSetVisible(node, false)
end
for _, s in ipairs(world.spheres) do
nodeSetVisible(s, false)
end
for _, b in ipairs(world.billboards) do
nodeSetVisible(b, false)
end
emitterSetRate(world.sparks, 0)
emitterSetRate(world.warp, 0)
knotLights(0, false)
sceneSetSkyIntensity(1.0)
sceneSetFog(110, 28, 100, 60, 260)
for _, b in ipairs(world.beams) do
nodeSetVisible(b.node, false)
end
nodeSetVisible(world.city, false)
nodeSetVisible(world.skyline, false)
nodeSetVisible(world.dragon, false)
for _, n in ipairs(world.peaks) do
nodeSetVisible(n, false)
end
nodeSetRotation(world.camera, 0, 0, 0)
end
local function show(...)
for _, node in ipairs({ ... }) do
nodeSetVisible(node, true)
end
end
-- The grid --------------------------------------------------------------------------------------------
local function gridCamera(t, height, lookUp)
local sway = math.sin(t * 0.7) * 0.6
nodeSetPosition(world.camera, sway, height, 6)
nodeLookAt(world.camera, sway * 0.3, height + (lookUp or 0.6), -20)
nodeSetPosition(world.floor, 0, 0, (t * CRUISE) % GRID_CELL)
end
-- The grid flares on every kick.
local function gridPulse(t, base)
local k = base + 0.9 * K.decay("kicks", t, 7)
local v = math.floor(K.clamp(k, 0, 1) * 255)
materialSetEmissive(world.floorMaterial, v, v, v)
end
-- The road ---------------------------------------------------------------------------------------------
-- A road built a bar at a time: each piece is { bars, curve, rise }, easing in and out of its bend
-- and its climb, so every bend begins on a downbeat.
local function buildRoad(pieces)
local track = {}
local y = 0
for _, p in ipairs(pieces) do
local n = p[1] * SEGS_PER_BAR
local y0 = y
local y1 = y + (p[3] or 0)
for i = 0, n - 1 do
local k = i / n
local c = p[2] * math.sin(k * math.pi)
local y2 = K.easeInOut(y0, y1, (i + 1) / n)
track[#track + 1] = { curve = c, y1 = y, y2 = y2, things = {} }
y = y2
end
end
-- Streetlights on both sides on every LAMP_EVERY'th segment, palms between.
for i, segment in ipairs(track) do
if (i - 1) % LAMP_EVERY == 0 then
segment.things[#segment.things + 1] = { sprite = art.lamp, x = -ROAD_HALF - 18, width = 30, lamp = true }
segment.things[#segment.things + 1] = { sprite = art.lamp, x = ROAD_HALF + 18, width = -30, lamp = true }
elseif (i - 1) % LAMP_EVERY == LAMP_EVERY // 2 then
segment.things[#segment.things + 1] = { sprite = art.palm, x = -ROAD_HALF - 90 - (i * 37 % 60), width = 110 }
segment.things[#segment.things + 1] = { sprite = art.palm, x = ROAD_HALF + 90 + (i * 53 % 60), width = 110 }
end
end
return track
end
-- Cars ahead in the outside lanes, each its own colour, from a little ahead of the car to the end of
-- the road, so there is always one to pass. Each moves at TRAFFIC_PACE of our speed from t0.
local function startTraffic(t0)
local z = PLAYER_AHEAD + 600
road.traffic = {}
road.trafficT0 = t0
while z < #road.track * SEG do
local n = #road.traffic
road.traffic[n + 1] = { z = z, x = ((n % 2 == 0) and -1 or 1) * TRAFFIC_LANE, paint = n % TRAFFIC_FRAMES }
z = z + TRAFFIC_GAP * (0.67 + 0.66 * ((n * 7919) % 100) / 100)
end
end
local function roadSegment(index)
local n = #road.track
return road.track[((index % n) + n) % n + 1]
end
-- The road from where the camera is: sky and city first, then the road nearest first, each segment
-- filling the screen rows between its near edge and its far edge with one row of the strip art
-- stretched to the road's width at that row, then streetlights and palms farthest first.
local function drawRoad(t, position, bright, airY)
local base = math.floor(position / SEG)
local within = position - base * SEG
local car = roadSegment(math.floor((position + PLAYER_AHEAD) / SEG))
local carK = ((position + PLAYER_AHEAD) % SEG) / SEG
local camY = airY or (car.y1 + (car.y2 - car.y1) * carK + CAM_HEIGHT)
local x = 0
local dx = -roadSegment(base).curve * within / SEG
local clip = H
local drawn = {}
local tint = math.floor(170 + 85 * bright)
-- The city swings the other way as the road bends: the 3D camera turns with the bends, by the
-- second rather than by the frame.
road.cityX = (road.cityX or 0) + car.curve * CITY_TURN * math.max(0, t - (road.cityT or t))
road.cityT = t
spriteSetTint(art.road, tint, tint, tint)
for n = 0, DRAW_SEGS - 1 do
local index = base + n
local segment = roadSegment(index)
local z1 = math.max(1, n * SEG - within)
local z2 = n * SEG - within + SEG
local s1 = FOCAL / z1
local s2 = FOCAL / z2
segment.sx1 = W / 2 + x * s1
segment.sy1 = HORIZON_Y - (segment.y1 - camY) * s1
segment.s1 = s1
segment.sx2 = W / 2 + (x + dx) * s2
segment.sy2 = HORIZON_Y - (segment.y2 - camY) * s2
segment.s2 = s2
segment.clip = clip
segment.index = index
x = x + dx
dx = dx + segment.curve
drawn[#drawn + 1] = segment
if segment.sy2 < clip and segment.sy1 > segment.sy2 then
local top = math.max(0, math.ceil(segment.sy2))
local bottom = math.min(clip - 1, math.floor(segment.sy1))
local light = (index // 3) % 2
local haze = math.min(HAZE - 1, math.floor(z1 / (DRAW_SEGS * SEG) * HAZE * 1.4))
local height = segment.sy1 - segment.sy2
if top <= bottom then
K.drawGrid(art.road, 0, top, 0, (2 + light) * HAZE + haze, STRIP_W, 1, W, bottom + 1)
-- The road itself a screen row at a time, the window's rows rather than design
-- units, so its edges are as fine as the screen.
for real = math.floor((bottom + 1) * K.S) - 1, math.floor(top * K.S), -1 do
local row = real / K.S
local k = (segment.sy1 - row) / height
local cx = segment.sx1 + (segment.sx2 - segment.sx1) * k
local half = ROAD_HALF * (s1 + (s2 - s1) * k)
spriteDrawGrid(art.road, math.floor((cx - half) * K.S), real, 0, light * HAZE + haze, STRIP_W, 1, math.floor((cx + half) * K.S), real)
end
clip = top
end
end
end
-- Things by the road, farthest first, each cut off by any nearer crest.
local lampGlow = 0.55 + 0.45 * K.decay("snares", t, 5)
local cars = {}
for _, c in ipairs(road.traffic or {}) do
local z = c.z + speed * TRAFFIC_PACE * (t - road.trafficT0)
local index = math.floor(z / SEG)
cars[index] = cars[index] or {}
cars[index][#cars[index] + 1] = { car = c, k = (z % SEG) / SEG }
end
for i = #drawn, 2, -1 do
local segment = drawn[i]
for _, thing in ipairs(segment.things) do
local s = segment.s1
local w = math.abs(thing.width) * s
local sw = spriteGetWidth(thing.sprite)
local sh = spriteGetHeight(thing.sprite)
local h = w * sh / sw
local cx = segment.sx1 + thing.x * s
local foot = segment.sy1
local top = foot - h
if w >= 1 and cx + w > 0 and cx - w < W and top < segment.clip then
local left = cx - w / 2
local right = cx + w / 2
if thing.width < 0 then
left, right = right, left
end
if foot <= segment.clip then
K.draw(thing.sprite, math.min(left, right), top, math.max(left, right), foot)
else
local shown = math.min(sh, math.max(1, math.floor(sh * (segment.clip - top) / h)))
K.drawGrid(thing.sprite, math.min(left, right), top, 0, 0, sw, shown, math.max(left, right), segment.clip)
end
if thing.lamp and foot <= segment.clip + h * 0.9 then
-- The lamp's light: a glow added over its head, the head being at the arm's end.
local headX = cx + ((thing.width > 0) and -0.39 or 0.39) * w
local g = w * 3.2 * lampGlow
spriteSetBlend(art.glow, SPRITE_ADD)
spriteSetTint(art.glow, 255, 220, 170)
spriteSetAlpha(art.glow, 255)
K.draw(art.glow, headX - g / 2, top + h * 0.07 - g / 2, headX + g / 2, top + h * 0.07 + g / 2)
end
end
end
-- Traffic on this segment, between its near and far edges, cut off by a nearer crest the same way.
for _, entry in ipairs(cars[segment.index] or {}) do
local s1 = segment.s1 + (segment.s2 - segment.s1) * entry.k
local w = TRAFFIC_WIDTH * s1
local frame = spriteGetWidth(art.traffic) / TRAFFIC_FRAMES
local sh = spriteGetHeight(art.traffic)
local h = w * sh / frame
local cx = segment.sx1 + (segment.sx2 - segment.sx1) * entry.k + entry.car.x * s1
local foot = segment.sy1 + (segment.sy2 - segment.sy1) * entry.k
local top = foot - h
if w >= 2 and cx + w > 0 and cx - w < W and top < segment.clip then
local shown = math.min(sh, math.max(1, math.floor(sh * (math.min(foot, segment.clip) - top) / h)))
local bottom = math.min(foot, segment.clip)
K.drawGrid(art.traffic, cx - w / 2, top, entry.car.paint * frame, 0, frame, shown, cx + w / 2, bottom)
-- Its taillights, added over the road.
if foot <= segment.clip then
spriteSetBlend(art.glow, SPRITE_ADD)
spriteSetTint(art.glow, 255, 30, 60)
spriteSetAlpha(art.glow, 150)
K.draw(art.glow, cx - w * 0.75, top + h * 0.45, cx + w * 0.75, top + h * 0.95)
spriteSetAlpha(art.glow, 255)
end
end
end
end
spriteSetBlend(art.glow, SPRITE_ALPHA)
return car
end
-- The car: a frame of the rendered sheet for how hard it is steering into the bend (frame 1 has the
-- nose turned hardest right, frame 7 hardest left), turned a few degrees more into the bend, bounced
-- by the kick, with its taillights and its underglow added over the road.
local function drawCar(t, car, lift, spin)
local steer = K.clamp(math.floor(car.curve * 6 + 0.5), -3, 3)
local lean = car.curve * 4 + (spin or 0)
local bounce = 3 * K.decay("kicks", t, 10)
local cx = W / 2 + math.sin(t * 0.9) * 18
local cy = H - 64 - bounce - (lift or 0)
spriteRotateFrame(art.car, lean, 4 - steer)
spriteDrawRotatedFrame(art.car, math.floor(cx * K.S), math.floor(cy * K.S), CAR_SCALE * K.S / ART_DENSITY)
local glowW = 230 + 70 * K.decay("kicks", t, 6)
spriteSetBlend(art.glow, SPRITE_ADD)
spriteSetTint(art.glow, 255, 30, 60)
spriteSetAlpha(art.glow, 170)
K.draw(art.glow, cx - glowW / 2, cy - 22, cx + glowW / 2, cy + 30)
spriteSetTint(art.glow, 255, 40, 200)
K.draw(art.glow, cx - 140, cy + 30 + (lift or 0), cx + 140, cy + 66 + (lift or 0))
spriteSetBlend(art.glow, SPRITE_ALPHA)
spriteSetTint(art.glow, 255, 255, 255)
spriteSetAlpha(art.glow, 255)
end
-- The 3D camera for a road scene: level, so the sky's horizon is the road's, far back from the
-- city and turning as the road bends so the city swings past.
local function roadSky()
nodeSetVisible(world.city, true)
nodeSetVisible(world.skyline, true)
nodeSetPosition(world.camera, 0, 6, ROAD_EYE_Z)
nodeSetRotation(world.camera, 0, -(road.cityX or 0), 0)
sceneSetFog(70, 18, 80, 450, 2000)
end
-- Puts a node in the sky over the road: across, up, and ahead of the road scenes' 3D camera, which
-- turns as the road bends, so the node stays where it was put in the view.
local function inRoadSky(node, across, up, ahead)
local yaw = math.rad(-(road.cityX or 0))
local fx = -math.sin(yaw)
local fz = -math.cos(yaw)
nodeSetPosition(node, math.cos(yaw) * across + fx * ahead, 6 + up, ROAD_EYE_Z - math.sin(yaw) * across + fz * ahead)
end
-- Where the flight down the avenue is across it, s world units along: a swerve every CITY_WEAVE
-- that takes it round each tower standing in the middle, CITY_SWERVE either side. s is 0 where the
-- first of those towers stands, so the flight is at its widest as it passes each one.
local function citySwerve(s)
return CITY_SWERVE * math.cos(math.pi * s / (CITY_WEAVE / 2))
end
-- Down the avenue between the towers at a steady speed, a swerve round a tower in the middle on every
-- half bar, the camera banking into each swerve and rolling right round on the first beat of every
-- second bar. The city repeats every CITY_PERIOD, so the camera wraps without a seam.
local function cityRun(t, t0)
local barLength = beat * 4
local s = ((t - t0) * CITY_WEAVE / barLength) % CITY_PERIOD
local ahead = 30
local x = citySwerve(s)
local xAhead = citySwerve(s + ahead)
local turn = citySwerve(s + ahead) - 2 * citySwerve(s + ahead / 2) + x
local z = -40 - CITY_ROW * (CITY_ISLAND // 2) - s
local yaw = -math.deg(math.atan(xAhead - x, ahead))
local bank = turn * 9
local barNow = K.bar(t)
local rollPhase = (barNow - 1) % 2
local roll = 0
-- The roll takes the first two beats of the bar.
if rollPhase < 0.25 then
roll = 360 * K.ease(rollPhase / 0.25)
end
nodeSetVisible(world.city, true)
nodeSetVisible(world.floor, true)
nodeSetPosition(world.floor, 0, 0, z - z % GRID_CELL)
nodeSetPosition(world.camera, x, 12 + 3 * math.sin(s / CITY_WEAVE * math.pi * 2), z)
nodeSetRotation(world.camera, -4, yaw, bank + roll)
sceneSetFog(90, 24, 96, 90, 520)
gridPulse(t, 0.3)
beams(t, lastT, true, z, BEAM_SPEED * 3)
end
-- Low and fast over the mountains toward the sun, the camera banking with the bass. The tiles
-- leapfrog, so the mountains run on to the fog however far it goes.
local function peakRun(t, t0)
local z = -(t - t0) * PEAK_SPEED
local first = math.floor(-z / PEAK_TILE)
for i, n in ipairs(world.peaks) do
nodeSetVisible(n, true)
nodeSetPosition(n, 0, -8, -(first + i - 1) * PEAK_TILE - PEAK_TILE / 2)
end
nodeSetVisible(world.floor, false)
nodeSetPosition(world.camera, math.sin(t * 0.7) * 6, 14 + math.sin(t * 0.9) * 4, z)
nodeSetRotation(world.camera, 2, math.sin(t * 0.35) * 6, math.sin(t * 0.7) * -10 * (0.5 + K.env("bass", t)))
sceneSetFog(PEAK_HAZE[1], PEAK_HAZE[2], PEAK_HAZE[3], PEAK_FOG_NEAR, PEAK_FOG_FAR)
end
-- How high the road is under the car at a road position.
local function roadHeight(position)
local z = position + PLAYER_AHEAD
local segment = roadSegment(math.floor(z / SEG))
local k = (z % SEG) / SEG
return segment.y1 + (segment.y2 - segment.y1) * k
end
local function roadPosition(t)
return speed * (t - road.start) - PLAYER_AHEAD
end
-- Fireworks ----------------------------------------------------------------------------------------
-- Fireworks: a burst on every snare, in a random neon colour, falling and fading.
-- A burst on every snare from one of the coloured emitters in turn, each colour its own emitter so a
-- new burst never recolours the sparks of the last, with a white crackle at its heart.
local function fireworks(t, lastT)
if K.hitCount("snares", lastT, t) > 0 then
local x = math.random(80, math.floor(W - 80))
local y = math.random(60, H / 2 - 40)
art.nextFirework = art.nextFirework % #art.fireworks + 1
emitterSetPosition(art.fireworks[art.nextFirework], x * K.S, y * K.S)
emitterBurst(art.fireworks[art.nextFirework], 140)
emitterSetPosition(art.crackle, x * K.S, y * K.S)
emitterBurst(art.crackle, 30)
end
for _, e in ipairs(art.fireworks) do
emitterDraw(e)
end
emitterDraw(art.crackle)
end
-- The scenes -------------------------------------------------------------------------------------------
local SCENES = {
-- Bars 1 to 3: pads only. The tape starts: snow, the deck's own lettering, and the title typed
-- out a letter a beat.
{
from = 1, to = 4,
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
K.snowUpdate(art.snow, t)
K.under(art.snow.sprite, 0, 0, W, H, nil, nil, nil, nil, true)
local dark = 1 - 0.75 * K.smooth(1.0, 2.0, bar) + 0.75 * K.smooth(3.5, 3.95, bar)
-- The snow dims, under the deck's lettering.
K.under(nil, 0, 0, W, H, 0, 0, 0, math.floor(255 * K.clamp(dark, 0, 1)))
local blink = (math.floor(t * 2) % 2 == 0)
K.text(40, 34, "PLAY", 30, 255, 255, 255)
if blink then
K.text(126, 34, ">", 30, 255, 255, 255)
end
K.text(W - 40, H - 70, string.format("0:%02d:%02d", math.floor(t / 60), math.floor(t % 60)), 26, 255, 255, 255, "right")
local title = "A SINGE PRODUCTION"
local shown = math.floor(K.clamp((K.beat(t) - 1) * 3, 0, #title))
if shown > 0 then
local jitter = (bar > 3.5) and math.random(-4, 4) or 0
K.text(W / 2 + jitter, H / 2 - 18, title:sub(1, shown), 32, 255, 120, 230, "center")
end
end,
},
-- Bars 4 to 16: the bass comes in, and the sunset grid with it. The logotype rises from the
-- horizon at bar 9, and the title arrives at bar 13.
{
from = 4, to = 17,
enter = function(t)
hideAll()
show(world.floor, world.mountains)
sceneSetBloom(0.75, 0.6)
end,
draw = function(t, bar, k)
local crane = K.easeOut((bar - 4) / 5)
gridCamera(t, K.lerp(0.35, 1.4, crane), K.lerp(1.6, 0.6, crane))
nodeSetPosition(world.floor, 0, 0, (t * CRUISE * 4) % GRID_CELL)
gridPulse(t, 0.35)
beams(t, lastT, true)
sceneSetBloom(0.75, 0.55 + 0.6 * K.decay("kicks", t, 8))
if bar >= 9 then
local rise = K.easeOut((bar - 9) / 2)
show(world.logo)
nodeSetPosition(world.logo, 0, K.lerp(-3, 2.6, rise), -16)
nodeSetRotation(world.logo, 0, math.sin(t * 0.6) * 12, 0)
end
if bar >= 13 then
-- The title slams in on the downbeat and settles; a shine runs across it each bar.
local p = (bar - 13)
local scale = 1 + 2.5 * math.exp(-p * 9)
local lw = K.width(art.logo) * 0.8 * scale
local lh = K.height(art.logo) * 0.8 * scale
local y = 395
spriteSetAlpha(art.logo, math.floor(255 * K.clamp(p * 6, 0, 1)))
K.draw(art.logo, W / 2 - lw / 2, y - lh / 2, W / 2 + lw / 2, y + lh / 2)
spriteSetAlpha(art.logo, 255)
-- GAME ENGINE under the logotype, wherever the logotype is on the screen, spelled out
-- a letter at a time across the first beat.
local _, under = sceneProject(0, 2.6 - LOGO_DROP, -16)
under = under / K.S
local letters = math.floor(K.clamp(p * 4 * #ENGINE_TEXT, 0, #ENGINE_TEXT))
if letters > 0 then
K.text(W / 2, under, ENGINE_TEXT:sub(1, letters), 24, 200, 240, 255, "center")
end
end
local fade = 1 - K.smooth(4, 4.5, bar)
if fade > 0 then
K.flash(0, 0, 0, math.floor(255 * fade))
end
K.strobe(t, "kicks", 14, 255, 120, 220, (bar >= 13) and 40 or 0)
end,
},
-- Bars 17 and 18: the bass drops out under a snare build. The logotype turns once, the fog
-- closes in, and the screen strobes with the snare roll, faster and faster.
{
from = 17, to = 19,
enter = function(t)
hideAll()
show(world.floor, world.mountains, world.logo)
end,
draw = function(t, bar, k)
gridCamera(t, 1.4 + k * 3, 0.6 - k * 1.2)
gridPulse(t, 0.15)
nodeSetPosition(world.logo, 0, 2.6, -16 + k * 6)
nodeSetRotation(world.logo, 0, K.easeIn(k) * 360, 0)
sceneSetFog(110, 28, 100, 60 - k * 50, 260 - k * 200)
K.strobe(t, "snares", 22, 255, 255, 255, 90 + 120 * k)
end,
leave = function(t)
sceneSetFog(110, 28, 100, 60, 260)
end,
},
-- Bars 19 to 28: the night drive, fast. Two streetlights pass on every beat, the bends begin
-- on downbeats, and the city swings past under the sun.
{
from = 19, to = 29,
enter = function(t)
hideAll()
-- Ten bars of road, the last two a long climb to a crest, then the drop the car flies over
-- in bars 29 and 30, and road beyond it to land on.
road.track = buildRoad({ { 2, 0 }, { 2, 0.55 }, { 2, -0.6, 140 }, { 2, 0.3, -140 }, { 2, 0, 320 }, { 1, 0, -500 }, { 2, 0, -150 }, { 2, 0 } })
road.start = K.barTime(19)
road.cityX = 0
road.cityT = nil
startTraffic(K.barTime(19))
end,
draw = function(t, bar, k)
roadSky()
local car = drawRoad(t, roadPosition(t), K.decay("kicks", t, 6))
drawCar(t, car)
K.scroller(t, art.smallFont, ROAD_TEXT, 44, 18, K.barTime(19), K.barTime(29), "bounce")
K.strobe(t, "kicks", 30, 255, 255, 255, (bar < 19.02) and 255 or 0)
local intro = 1 - K.smooth(19, 19.25, bar)
if intro > 0 then
K.flash(255, 255, 255, math.floor(255 * intro))
end
end,
},
-- Bars 29 and 30: the bass drops out again, and the car leaves the crest and flies, in slow
-- motion, the road dropping away far beneath it, until it lands on the downbeat of bar 31. The
-- camera flies with the car, so the car holds its place on the screen and the road does the
-- falling.
{
from = 29, to = 31,
enter = function(t)
road.flyStart = t
road.flyFrom = roadPosition(t)
road.flyAt = 0
road.flyLast = t
road.takeoff = roadHeight(road.flyFrom) + CAM_HEIGHT
road.landing = roadHeight(road.flyFrom + speed * (K.barTime(31) - t) * 0.45) + CAM_HEIGHT
end,
draw = function(t, bar, k)
-- The road runs on at a quarter of the speed by a third of the way through the jump, and back
-- up to speed for the landing.
local pace = K.lerp(1, 0.25, K.ease(k * 3)) + 0.75 * K.smooth(0.8, 1.0, k)
road.flyAt = road.flyAt + (t - road.flyLast) * speed * pace
road.flyLast = t
local position = road.flyFrom + road.flyAt
local air = math.max(K.lerp(road.takeoff, road.landing, k * k) + 180 * math.sin(math.pi * k), roadHeight(position) + CAM_HEIGHT)
local ground = roadHeight(position) + CAM_HEIGHT
local camera = math.max(ground, K.lerp(road.takeoff, road.landing, k * k) + 50 * math.sin(math.pi * k))
local lift = K.clamp((air - camera) * FOCAL / PLAYER_AHEAD * 0.35, 0, 170)
roadSky()
local car = drawRoad(t, position, 0, camera)
drawCar(t, car, lift, 6 * math.sin(k * math.pi * 3) * (1 - k))
K.strobe(t, "snares", 18, 255, 255, 255, 120 * k)
end,
},
-- Bars 31 to 38: chrome. A knot, glass round it, and the grid; the camera cuts on every
-- downbeat, the knot pumps on the kicks, and sparks fly off it on the snares.
{
from = 31, to = 39,
enter = function(t)
hideAll()
show(world.floor, world.mountains, world.knot)
for _, s in ipairs(world.spheres) do
show(s)
end
end,
draw = function(t, bar, k)
local shot = math.floor(bar - 31)
local inner = (bar - 31) % 1
local shots = {
{ 0, 3.0, 9, 0, 3, 0 }, { 6, 1.2, 4, 0, 3.2, 0 }, { -5, 6, 6, 0, 2.6, 0 }, { 0, 1.0, 5.5, 0, 3.8, 0 },
{ 8, 4, -2, 0, 3, 0 }, { -7, 2, 3, 0, 3, -1 }, { 0, 9, 2, 0, 3, 0 }, { 3, 2.4, 7, 0, 3, 0 },
}
local c = shots[shot % #shots + 1]
local drift = inner * 2
nodeSetPosition(world.camera, c[1] + drift, c[2] + drift * 0.3, c[3] - drift)
nodeLookAt(world.camera, c[4], c[5], c[6])
local pump = 1 + 0.18 * K.decay("kicks", t, 9)
nodeSetPosition(world.knot, 0, 3, 0)
nodeSetScale(world.knot, pump)
nodeSetRotation(world.knot, t * 23, t * 41, t * 11)
for i, sphere in ipairs(world.spheres) do
local a = t * 0.8 + i / #world.spheres * 2 * math.pi
nodeSetPosition(sphere, math.cos(a) * 3.6, 3 + math.sin(a * 2 + t) * 1.1, math.sin(a) * 3.6)
end
nodeSetPosition(world.floor, 0, 0, (t * 4) % GRID_CELL)
gridPulse(t, 0.3)
knotLights(t, true)
sceneSetSkyIntensity(1.5)
if K.hitCount("snares", lastT, t) > 0 then
emitterBurst(world.sparks, 60)
end
K.scroller(t, art.scrollFont, CHROME_TEXT, H - 70, 10, K.barTime(31), K.barTime(39), "spin")
-- Each cut opens on a white frame.
K.flash(255, 255, 255, math.floor(200 * math.exp(-inner * 1.967 * 14)))
end,
},
-- Bars 39 to 48: the flight down the avenue of a neon city, on tape: it plays for two bars,
-- rewinds for one under snow, plays again, rewinds again, plays, and on the last two bars rewinds
-- fast and stops dead. The tape is a clock of its own, moved forward while it plays and back
-- while it rewinds, and the flight is wherever that clock says.
{
from = 39, to = 49,
enter = function(t)
hideAll()
road.tape = 0
road.tapeLast = t
end,
draw = function(t, bar, k)
local into = bar - 39
local rate = 1
local label = nil
local resumed = false
for _, span in ipairs(CITY_REWINDS) do
if into >= span[1] and into < span[2] then
rate = -span[3]
end
resumed = resumed or (into >= span[2] and into < span[2] + 0.25)
end
road.tape = road.tape + (t - road.tapeLast) * rate
road.tapeLast = t
cityRun(K.barTime(39) + road.tape, K.barTime(39))
if rate < 0 then
K.snowUpdate(art.snow, t)
K.under(art.snow.sprite, 0, 0, W, H, 255, 255, 255, REWIND_SNOW, true)
label = "<< REW"
elseif resumed then
label = "PLAY >"
end
if label then
K.text(40, 34, label, 30, 255, 255, 255)
end
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(39, 39.15, bar))))
-- The picture rolls as the tape slows, and stops dead.
if bar > 48.5 then
K.flash(0, 0, 0, math.floor(255 * K.smooth(48.5, 48.95, bar)))
end
end,
leave = function(t)
hideAll()
end,
},
-- Bars 49 to 58: the tunnel, drawn live a frame at a time, on a quad that turns with the bass;
-- the kick pushes it along. Vector bobs morph every two bars, and the scroller runs under them.
{
from = 49, to = 59,
enter = function(t)
hideAll()
show(world.tunnel)
road.tunnelPhase = 0
road.tunnelLast = t
sceneSetBloom(0.6, 0.5)
end,
draw = function(t, bar, k)
nodeSetPosition(world.camera, 0, 0, 0)
nodeLookAt(world.camera, 0, 0, -1)
-- Through the tunnel at a speed the kick pushes, in passes a second.
local dt = t - road.tunnelLast
local push = 0.9 + 2.2 * K.decay("kicks", t, 5)
road.tunnelLast = t
road.tunnelPhase = road.tunnelPhase + dt * push
K.tunnelUpdate(art.tunnel, (road.tunnelPhase / TUNNEL_LAP) % 1, 0)
materialSetTexture(world.tunnelMaterial, art.tunnel.sprite)
nodeSetRotation(world.tunnel, 0, 0, t * 25 + math.sin(t * 0.5) * 40 * K.env("bass", t))
K.bobs(t, bar, art.bob, 48, 1.3, 20, W / 2, H / 2 - 20)
K.scroller(t, art.scrollFont, SCROLL_TEXT, H - 70, 18, K.barTime(49), K.barTime(59))
local intro = 1 - K.smooth(49, 49.2, bar)
if intro > 0 then
K.flash(255, 255, 255, math.floor(255 * intro))
end
end,
},
-- Bars 59 and 60: the bass drops out. The tunnel slows to a stop, the bobs fall in to a point,
-- and the question is asked a word a beat.
{
from = 59, to = 61,
draw = function(t, bar, k)
local dt = t - road.tunnelLast
road.tunnelLast = t
road.tunnelPhase = road.tunnelPhase + dt * 0.9 * (1 - k)
K.tunnelUpdate(art.tunnel, (road.tunnelPhase / TUNNEL_LAP) % 1, 0)
materialSetTexture(world.tunnelMaterial, art.tunnel.sprite)
nodeSetRotation(world.tunnel, 0, 0, t * 25 * (1 - k))
K.bobs(t, bar, art.bob, 48, 1.3 * (1 - K.easeIn(k)), 20, W / 2, H / 2 - 20)
local words = math.min(#QUESTION, math.floor((bar - 59) * 4) + 1)
K.text(W / 2, H / 2 + 120, table.concat(QUESTION, " ", 1, words), 40, 255, 255, 255, "center")
K.flash(0, 0, 0, math.floor(140 * k))
end,
leave = function(t)
hideAll()
end,
},
-- Bars 61 to 64: plasma, drawn live a frame at a time, under a rotozoomed logo and a ring of turning palms, punched in on the
-- kicks.
{
from = 61, to = 65,
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
K.plasmaUpdate(art.plasma, t)
K.under(art.plasma.sprite, 0, 0, W, H, nil, nil, nil, nil, true)
local punch = 1 + 0.25 * K.decay("kicks", t, 8)
for i = 1, 8 do
local a = i / 8 * 360 + t * 30
local r = 170 * punch
local px = W / 2 + math.cos(math.rad(a)) * r
local py = H / 2 + math.sin(math.rad(a)) * r
K.turn(art.palm, px, py, a + 90, 0.55 * punch)
end
local scale = (0.9 + 0.35 * math.sin(t * 1.3)) * punch
local angle = math.sin(t * 0.7) * 25
K.turn(art.singe, W / 2, H / 2, angle, scale)
K.circleScroller(t, art.smallFont, CIRCLE_TEXT, W / 2, H / 2, 190, 50, K.barTime(61), K.barTime(65))
K.strobe(t, "kicks", 12, 255, 255, 255, 70)
local intro = 1 - K.smooth(61, 61.15, bar)
if intro > 0 then
K.flash(255, 255, 255, math.floor(255 * intro))
end
end,
},
-- Bars 65 to 68: low and fast over neon mountains toward the sun.
{
from = 65, to = 69,
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
peakRun(t, K.barTime(65))
K.crawl(t, art.scrollFont, CRAWL_LINES, K.barTime(65), K.barTime(69), H * 0.45)
K.strobe(t, "kicks", 14, 255, 120, 220, 50)
K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(65, 65.15, bar))))
end,
},
-- Bars 69 to 76: the breakdown, then the kick's return. A flag on the night grid, waving in a
-- wind that gusts on every kick once the drums are back, under a zooming scroller.
{
from = 69, to = 77,
enter = function(t)
hideAll()
show(world.floor, world.mountains, world.pole, world.flag)
sceneSetSkyIntensity(0.55)
sceneSetBloom(0.7, 0.5)
end,
draw = function(t, bar, k)
local a = math.sin(t * 0.3) * 0.8
nodeSetPosition(world.camera, 0.2 + math.sin(a) * 6.5, 3.6 + math.sin(t * 0.3) * 0.6, -6 + math.cos(a) * 6.5)
nodeLookAt(world.camera, 0.4, 5.0, -6)
nodeSetPosition(world.floor, 0, 0, (t * 2) % GRID_CELL)
-- The wind is the cloth's gravity tipped sideways: a steady breeze along the flag, and a push
-- across its face that swings back and forth several times a second, which the soft cloth
-- turns into ripples running out to the free end.
local gust = (bar >= 73) and K.decay("kicks", t, 4) or 0
physicsSetGravity(20 + 5 * math.sin(t * 2.1) + 20 * gust, -9.81 + 4 * math.sin(t * 9.1), 7 * math.sin(t * 7.3) + 5 * math.sin(t * 11.9 + 1))
K.scroller(t, art.scrollFont, FLAG_TEXT, H - 80, 0, K.barTime(69), K.barTime(77), "zoom")
gridPulse(t, 0.12 + ((bar >= 73) and 0.2 or 0))
local fadeIn = 1 - K.smooth(69, 69.5, bar)
if fadeIn > 0 then
K.flash(0, 0, 0, math.floor(255 * fadeIn))
end
end,
leave = function(t)
physicsSetGravity(0, -9.81, 0)
sceneSetSkyIntensity(1.0)
end,
},
-- Bars 77 and 78: the riser. Into hyperspace toward the sun, faster and whiter, to the dead
-- stop just before bar 79.
{
from = 77, to = 79,
enter = function(t)
hideAll()
show(world.floor, world.mountains)
end,
draw = function(t, bar, k)
local z = 6 - K.easeIn(k) * 120
nodeSetPosition(world.camera, 0, 1.6, z)
nodeLookAt(world.camera, 0, 1.9, z - 20)
nodeSetPosition(world.floor, 0, 0, z - z % GRID_CELL)
emitterSetRate(world.warp, math.floor(200 + 1600 * k))
gridPulse(t, 0.3 + 0.7 * k)
sceneSetExposure(k * 2.5)
local white = K.smooth(78.3, 78.85, bar)
if bar >= 78.92 then
K.flash(0, 0, 0, 255)
elseif white > 0 then
K.flash(255, 255, 255, math.floor(255 * white))
end
end,
leave = function(t)
sceneSetExposure(0)
emitterSetRate(world.warp, 0)
emitterClear(world.warp)
end,
},
-- Bars 79 to 94: everything. Every two bars it cuts between the road, the city, the knot among
-- its billboards, and the mountains, with fireworks on the snares; the last four bars are the
-- road. Over the road the logotype hangs in the sky with the dragon beside it, beating its wings
-- on every beat and nodding on every second one.
{
from = 79, to = 95,
enter = function(t)
hideAll()
road.track = buildRoad({ { 2, 0.5 }, { 2, -0.5 }, { 2, 0.4, 200 }, { 2, -0.4, -200 }, { 2, 0.6 }, { 2, -0.6 }, { 2, 0.3, 150 }, { 2, 0, -150 }, { 2, 0 } })
road.start = K.barTime(79)
road.cityX = 0
road.cityT = nil
startTraffic(K.barTime(79))
end,
draw = function(t, bar, k)
local phrase = math.floor((bar - 79) / 2)
local kind = (bar >= 91) and 0 or phrase % 4
hideAll()
if kind == 0 then
roadSky()
show(world.logo, world.dragon)
inRoadSky(world.logo, -4, 8, 26)
nodeSetRotation(world.logo, 0, -(road.cityX or 0) + math.sin(t * 0.8) * 20, math.sin(t * 0.5) * 6)
local b = K.beat(t)
local flap = FLAP_MAX * math.sin(b * 2 * math.pi)
local nod = NOD_MAX * math.sin(b * math.pi)
inRoadSky(world.dragon, 6.5, 0.5 + 0.6 * math.sin(b * 2 * math.pi + 1), 24)
nodeSetRotation(world.dragon, 0, -(road.cityX or 0) + DRAGON_YAW, 0)
nodeSetScale(world.dragon, 1.25)
nodeSetRotation(world.wingL, -flap, 0, 0)
nodeSetRotation(world.wingR, flap, 0, 0)
nodeSetRotation(world.head, 0, 0, nod)
nodeSetRotation(world.neck, 0, 0, nod * NECK_SHARE)
local car = drawRoad(t, roadPosition(t), K.decay("kicks", t, 6))
drawCar(t, car)
elseif kind == 1 then
cityRun(t, K.barTime(79))
elseif kind == 2 then
show(world.floor, world.mountains, world.knot)
for _, sphere in ipairs(world.spheres) do
show(sphere)
end
for _, b in ipairs(world.billboards) do
show(b)
end
K.plasmaUpdate(art.plasma, t)
materialSetTexture(world.screenMaterial, art.plasma.sprite)
local a = t * 0.4 + phrase
nodeSetPosition(world.camera, math.sin(a) * 9, 2.5 + math.sin(t * 0.7), math.cos(a) * 9)
nodeLookAt(world.camera, 0, 3, 0)
nodeSetPosition(world.knot, 0, 3, 0)
nodeSetScale(world.knot, 1 + 0.2 * K.decay("kicks", t, 9))
nodeSetRotation(world.knot, t * 23, t * 41, t * 11)
for i, sphere in ipairs(world.spheres) do
local b = t * 0.8 + i / #world.spheres * 2 * math.pi
nodeSetPosition(sphere, math.cos(b) * 3.6, 3 + math.sin(b * 2 + t) * 1.1, math.sin(b) * 3.6)
end
gridPulse(t, 0.35)
knotLights(t, true)
beams(t, lastT, true)
sceneSetSkyIntensity(1.5)
nodeSetPosition(world.floor, 0, 0, (t * 4) % GRID_CELL)
else
peakRun(t, K.barTime(79))
end
fireworks(t, lastT)
local cut = ((bar - 79) / 2) % 1
K.flash(255, 255, 255, math.floor(220 * math.exp(-cut * 2 * 1.967 * 12)))
end,
},
-- Bar 95 on: the last hit, and the set switched off the old way: the picture collapses to a
-- line, then to a point, then is gone, and the screen stays dark to the end of the music.
{
from = 95, to = 200,
enter = function(t)
hideAll()
end,
draw = function(t, bar, k)
local p = t - K.barTime(95)
K.fill(0, 0, 0)
if p < 0.6 then
local hgt = (p < 0.25) and K.lerp(H, 3, K.easeIn(p / 0.25)) or 3
local wid = (p < 0.25) and W or K.lerp(W, 2, K.easeIn((p - 0.25) / 0.3))
K.under(nil, W / 2 - wid / 2, H / 2 - hgt / 2, W / 2 + wid / 2, H / 2 + hgt / 2, 255, 240, 255, 255)
end
end,
},
}
loadArt()
art.scrollFont = K.font(44)
art.smallFont = K.font(30)
art.fireworks = {}
art.nextFirework = 0
for _, c in ipairs(FIREWORK_COLOURS) do
local e = emitterNew()
emitterSetBlend(e, PARTICLE_ADD)
emitterSetRate(e, 0)
emitterSetLife(e, 0.8, 1.6)
emitterSetSpeed(e, 80 * K.S, 260 * K.S)
emitterSetSpread(e, 180)
emitterSetGravity(e, 0, 160 * K.S)
emitterSetDrag(e, 1.2)
emitterSetSize(e, 8 * K.S, 2 * K.S)
emitterSetTrail(e, 6, 3 * K.S)
emitterSetColor(e, c[1], c[2], c[3], 255, c[4], c[5], c[6], 0)
emitterSetMax(e, 800)
emitterStart(e)
art.fireworks[#art.fireworks + 1] = e
end
art.crackle = emitterNew()
emitterSetBlend(art.crackle, PARTICLE_ADD)
emitterSetRate(art.crackle, 0)
emitterSetLife(art.crackle, 0.2, 0.5)
emitterSetSpeed(art.crackle, 20 * K.S, 90 * K.S)
emitterSetSpread(art.crackle, 180)
emitterSetSize(art.crackle, 5 * K.S, 1 * K.S)
emitterSetColor(art.crackle, 255, 255, 255, 255, 255, 240, 200, 0)
emitterSetMax(art.crackle, 300)
emitterStart(art.crackle)
buildWorld()
hideAll()
-- The last scene of each frame remembers the time, for "what happened since last frame".
SCENES[#SCENES + 1] = {
from = 0, to = 1000,
draw = function(t, bar, k)
lastT = t
end,
}
K.run(SCENES)