-- The demo kit: what the three demos share. A demo loads it with dofile(DIR .. "../kit.lua") and -- gets back one table. -- -- Time is the music's. K.time() is where the listener is in the track: musicGetPosition while the -- demo plays, or a clock that moves exactly one frame a frame when DEMO_CAPTURE asks for a capture -- (see util/demoCapture.sh), so a captured frame lands on the music added afterwards. The opening -- logo card runs from -LOGO_TIME to 0, before the music, and the closing one after the end, so a -- capture or a recording takes them like any other part of the demo. Everything -- else is read off that time: beats and bars from the beat list util/demoSync.py wrote, the drums, -- the bass, the loudness and the brightness as envelopes from 0 to 1, and the drum hits as lists. -- -- A demo is a list of scenes, each with the bars it spans: { from = 17, to = 33, enter = fn, draw = -- fn, leave = fn }. K.run calls enter once as the scene starts, draw every frame it is on with the -- time, the bar, and how far through it is from 0 to 1, and leave once as it ends. Scenes may -- overlap, and are drawn in list order. Every demo opens on the Singe logo before its music starts -- and closes on the Kangaroo Punch logo after it ends (art/, from util/demoLogos.sh). local K = {} local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local ENVELOPES = { "kick", "snare", "hat", "bass", "level", "bright", "noteClear" } local HIT_LISTS = { "kicks", "snares", "hats" } local LEVELS = 63 local PIXEL_PNG = "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0b\x49\x44\x41\x54\x78\x9c\x63\xf8\x0f\x04\x00\x09\xfb\x03\xfd\xfb\x5e\x6b\x2b\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82" local FONT_FILE = "Singe/FreeSansBold.ttf" local TAIL = 2 -- Seconds the demo runs on after the music ends, fading to black local LOGO_FADE = 1.0 -- The logo cards before and after a demo: seconds fading up from black ... local LOGO_HOLD = 1.5 -- ... held ... local LOGO_TIME = LOGO_FADE * 2 + LOGO_HOLD -- ... and the whole card, back to black local sync = nil local music = nil local fonts = {} local widths = {} local pixel = nil local logos = {} local startTicks = nil local lastTime = -math.huge local playing = false -- Whether the music has been started (K.run) local lastTicks = 0 local frames = 0 local stats = { second = 0, frames = 0, worst = 0, last = 0 } -- The scrollers' letters: each a sprite made once per font, white, so it can be tinted. local glyphs = {} local function glyph(font, c) local key = font .. c local g = glyphs[key] if not g then fontSelect(font) colorForeground(255, 255, 255, 255) fontQuality(FONT_QUALITY_BLENDED) local s = fontToSprite(c) g = { sprite = s, w = spriteGetWidth(s) } glyphs[key] = g end return g end -- The block letters of K.blockFont: the shade of each way a face can point (front, top, left, right -- and back, bottom), as a strip of greys the hue materials multiply; how many hues round the wheel; -- how deep a letter is in blocks; and how far in front of the camera the letters rest, nearer than -- any demo's fog starts. local BLOCK_SHADES = { 255, 215, 175, 140, 105 } local BLOCK_HUES = 24 local BLOCK_DEPTH = 1.6 local BLOCK_DISTANCE = 6 -- One glyph's mesh: a block for every lit cell, with only the faces no neighbour covers, in cells -- centred on the glyph, each face's texture coordinate on its shade. local function blockMesh(rows) local positions = {} local normals = {} local uvs = {} local indices = {} local cols = #rows[1] local z0 = -BLOCK_DEPTH / 2 local z1 = BLOCK_DEPTH / 2 local function lit(r, c) return rows[r] ~= nil and rows[r]:sub(c, c) == "#" end local function face(shade, nx, ny, nz, corners) local base = #positions / 3 for k = 1, 12, 3 do positions[#positions + 1] = corners[k] positions[#positions + 1] = corners[k + 1] positions[#positions + 1] = corners[k + 2] normals[#normals + 1] = nx normals[#normals + 1] = ny normals[#normals + 1] = nz uvs[#uvs + 1] = (shade - 0.5) / #BLOCK_SHADES uvs[#uvs + 1] = 0.5 end for _, k in ipairs({ 1, 2, 3, 1, 3, 4 }) do indices[#indices + 1] = base + k end end for r = 1, #rows do for c = 1, cols do if lit(r, c) then local x0 = c - 1 - cols / 2 local x1 = x0 + 1 local y1 = #rows / 2 - (r - 1) local y0 = y1 - 1 -- Each face's corners run counter-clockwise seen from outside. face(1, 0, 0, 1, { x0, y0, z1, x1, y0, z1, x1, y1, z1, x0, y1, z1 }) face(4, 0, 0, -1, { x1, y0, z0, x0, y0, z0, x0, y1, z0, x1, y1, z0 }) if not lit(r, c - 1) then face(3, -1, 0, 0, { x0, y0, z0, x0, y0, z1, x0, y1, z1, x0, y1, z0 }) end if not lit(r, c + 1) then face(4, 1, 0, 0, { x1, y0, z1, x1, y0, z0, x1, y1, z0, x1, y1, z1 }) end if not lit(r - 1, c) then face(2, 0, 1, 0, { x0, y1, z1, x1, y1, z1, x1, y1, z0, x0, y1, z0 }) end if not lit(r + 1, c) then face(5, 0, -1, 0, { x0, y0, z0, x1, y0, z0, x1, y0, z1, x0, y0, z1 }) end end end end return meshNew(positions, normals, uvs, indices) end -- The pace of a scroller's text between t0 and t1, worked out once and remembered: the text to show -- (said again as often as it needs to be to go no slower than slowest) and the speed in pixels a -- second. The last letter is gone SCROLL_CLEAR before t1, so the words have finished before the cut. local SCROLL_SPEED_MIN = 120 -- Slower than this a scroller drags: short text is repeated local SCROLL_SPEED_MAX = 520 -- Faster than this it cannot be read: long text is warned about local SCROLL_CLEAR = 1.5 -- Seconds of clear screen a scroller leaves before its scene ends local SCROLL_3D_SLOWEST = 60 -- The 3D scroller's letters are bigger, so they may go slower local paces = {} local function scrollerPace(font, text, t0, t1, slowest) local key = tostring(font) .. "|" .. t0 .. "|" .. t1 .. "|" .. (slowest or SCROLL_SPEED_MIN) .. "|" .. text local paced = paces[key] if not paced then local span = math.max(t1 - t0 - SCROLL_CLEAR, 0.001) local shown = text local width = K.scrollerWidth(font, shown) while (width + K.W) / span < (slowest or SCROLL_SPEED_MIN) do shown = shown .. " " .. text width = K.scrollerWidth(font, shown) end paced = { text = shown, speed = (width + K.W) / span } if paced.speed > SCROLL_SPEED_MAX then local fits = math.floor(#text * (SCROLL_SPEED_MAX * span - K.W) / width) debugPrint(string.format("DEMO scroller too long to read: %d characters in %.1f s scroll at %d pixels a second; about %d would read well. It begins \"%s\"", #text, span, math.floor(paced.speed), math.max(0, fits), text:sub(1, 30))) end paces[key] = paced end return paced end K.W = 640 K.H = 480 -- The fractional bar at time t, counting from 1: bar 1 starts on the beat the sync data names. function K.bar(t) return (K.beat(t) - (sync.barStart - 1)) / 4 + 1 end -- When bar b starts; fractional bars land between beats. function K.barTime(b) return K.beatTime((b - 1) * 4 + (sync.barStart - 1)) end -- The fractional beat at time t, counting the first beat of the list as 0. Before the first beat -- and after the last the spacing of the nearest two carries on. function K.beat(t) local beats = sync.beats local n = #beats local lo = 1 local hi = n if t <= beats[1] then return (t - beats[1]) / (beats[2] - beats[1]) end if t >= beats[n] then return n - 1 + (t - beats[n]) / (beats[n] - beats[n - 1]) end while hi - lo > 1 do local mid = (lo + hi) // 2 if beats[mid] <= t then lo = mid else hi = mid end end return lo - 1 + (t - beats[lo]) / (beats[hi] - beats[lo]) end -- When fractional beat i falls. function K.beatTime(i) local beats = sync.beats local n = #beats local whole = math.floor(i) local part = i - whole if whole < 0 then return beats[1] + i * (beats[2] - beats[1]) end if whole >= n - 1 then return beats[n] + (i - (n - 1)) * (beats[n] - beats[n - 1]) end return beats[whole + 1] + part * (beats[whole + 2] - beats[whole + 1]) end -- Vector bobs: count balls on a shape that changes every two bars (a ring, a helix, a sphere, a -- flower), turned in 3D and drawn back to front at the size their distance gives them, each tinted -- round the colour wheel. sprite is a white shaded ball; spread scales the shape; size is a ball's -- radius in pixels at the shape's middle distance. local BOB_DEPTH = 5 local function bobShape(kind, i, count) local k = (i - 1) / count local a = k * 2 * math.pi if kind == 0 then return math.cos(a) * 1.6, math.sin(a * 3) * 0.3, math.sin(a) * 1.6 elseif kind == 1 then return math.cos(a * 3) * 1.0, (k - 0.5) * 3.2, math.sin(a * 3) * 1.0 elseif kind == 2 then local phi = math.acos(1 - 2 * (i - 0.5) / count) local theta = math.pi * (1 + math.sqrt(5)) * i return math.cos(theta) * math.sin(phi) * 1.5, math.cos(phi) * 1.5, math.sin(theta) * math.sin(phi) * 1.5 end local r = 1 + 0.5 * math.sin(a * 5) return math.cos(a) * r * 1.4, math.sin(a) * r * 1.4, math.sin(a * 5) * 0.4 end -- Block letters for K.scroller3D, from a pixel font: a table of glyphs, each its rows top down with -- "#" lit, all the same size. Each glyph becomes a mesh of blocks the first time it is shown, unlit -- and shaded by which way its faces point, so it reads as solid in any scene's light or none. The -- letters hang off the camera in the front layer, so they stand over the overlay and the scene -- alike wherever the camera goes. cell is a block's size in pixels as a letter rests; alpha (255 -- for solid) makes them see-through. function K.blockFont(glyphs, camera, fov, cell, alpha) local _, rows = next(glyphs) local shade = spriteNew(#BLOCK_SHADES, 1) local bytes = {} local font = { glyphs = glyphs, meshes = {}, materials = {}, nodes = {}, cell = cell, advance = (#rows[1] + 1) * cell, units = 2 * BLOCK_DISTANCE * math.tan(math.rad(fov / 2)) / K.H, pivot = nodeNew(camera) } for i, v in ipairs(BLOCK_SHADES) do bytes[i] = string.char(v, v, v, 255) end spriteSetPixels(shade, table.concat(bytes)) for i = 1, BLOCK_HUES do local m = materialNew() local r, g, b = K.hue((i - 1) / BLOCK_HUES) materialSetColor(m, r, g, b, alpha) materialSetTexture(m, shade) materialSetFilter(m, FILTER_NEAREST) materialSetUnlit(m, true) materialSetBlend(m, alpha < 255) font.materials[i] = m end nodeSetLayer(font.pivot, LAYER_FRONT) nodeSetVisible(font.pivot, false) return font end function K.bobs(t, bar, sprite, count, spread, size, cx, cy) local shape = math.floor((bar - 1) / 2) % 4 local next = (shape + 1) % 4 local morph = K.smooth(0.75, 1.0, ((bar - 1) / 2) % 1) local ay = t * 0.9 local ax = t * 0.5 local pts = {} local pump = 1 + 0.25 * K.decay("kicks", t, 8) for i = 1, count do local x1, y1, z1 = bobShape(shape, i, count) local x2, y2, z2 = bobShape(next, i, count) local x = K.lerp(x1, x2, morph) * spread * pump local y = K.lerp(y1, y2, morph) * spread * pump local z = K.lerp(z1, z2, morph) * spread * pump local x3 = x * math.cos(ay) + z * math.sin(ay) local z3 = -x * math.sin(ay) + z * math.cos(ay) local y3 = y * math.cos(ax) - z3 * math.sin(ax) local z4 = y * math.sin(ax) + z3 * math.cos(ax) pts[i] = { x = x3, y = y3, z = z4 + BOB_DEPTH, i = i } end table.sort(pts, function(a, b) return a.z > b.z end) for _, p in ipairs(pts) do local s = BOB_DEPTH / p.z local r = size * s local px = cx + p.x * s * 60 local py = cy + p.y * s * 60 local cr, cg, cb = K.hue(p.i / count + t * 0.1) spriteSetTint(sprite, cr, cg, cb) spriteDraw(sprite, math.floor(px - r), math.floor(py - r), math.floor(px + r), math.floor(py + r)) end spriteSetTint(sprite, 255, 255, 255) end -- A filled rectangle, corners inclusive, in any colour and alpha: the kit's one-pixel sprite -- stretched and tinted. blend is a SPRITE_ constant, alpha by default. function K.box(x1, y1, x2, y2, r, g, b, a, blend) if x2 < x1 or y2 < y1 then return end spriteSetTint(pixel, r, g, b) spriteSetAlpha(pixel, a or 255) spriteSetBlend(pixel, blend or SPRITE_ALPHA) spriteDraw(pixel, math.floor(x1), math.floor(y1), math.floor(x2), math.floor(y2)) end -- A chrome material: metal, polished to roughness, tinted r, g, b. It needs a sky to reflect. function K.chrome(r, g, b, roughness) local m = materialNew() materialSetColor(m, r, g, b) materialSetMetallic(m, 1) materialSetRoughness(m, roughness) return m end -- Text running round a circle of radius r about cx, cy, each letter turned to lie along it and the -- whole of it turning at speed degrees a second. The text is written in a letter at a time over the -- first half of t0 to t1, whatever its length, so it can be read as it goes round. Text longer than -- the circle's edge overlaps itself: the trace says so once. function K.circleScroller(t, font, text, cx, cy, r, speed, t0, t1) local rate = #text / math.max((t1 - t0) / 2, 0.001) local shown = math.min(#text, math.floor((t - t0) * rate)) if not paces[font .. "|circle|" .. r .. "|" .. text] then paces[font .. "|circle|" .. r .. "|" .. text] = true if K.scrollerWidth(font, text) > 2 * math.pi * r then debugPrint(string.format("DEMO circle text too long: %d pixels of text round %d pixels of circle; cut it to about %d characters", K.scrollerWidth(font, text), math.floor(2 * math.pi * r), math.floor(#text * 2 * math.pi * r / K.scrollerWidth(font, text)))) end end local a = -90 + (t - t0) * speed for i = 1, shown do local g = glyph(font, text:sub(i, i)) local middle = a + math.deg(g.w / 2 / r) local rad = math.rad(middle) local cr, cg, cb = K.hue(i / #text + t * 0.15) -- Each letter is drawn by its middle, half its own width on from where the last one ended, -- so a narrow letter is not overlapped by its neighbours. spriteSetTint(g.sprite, cr, cg, cb) spriteRotate(g.sprite, math.floor(middle + 90) % 360) spriteDraw(g.sprite, math.floor(cx + math.cos(rad) * r), math.floor(cy + math.sin(rad) * r), true) spriteRotate(g.sprite, 0) a = a + math.deg(g.w / r) end end function K.clamp(v, lo, hi) if v < lo then return lo end if v > hi then return hi end return v end -- A crawl: lines of text on a plane laid down toward the horizon, sliding away from the viewer and -- shrinking as they go, faded into the distance. The first line starts at the bottom of the screen -- at t0 and the last reaches the horizon at t1. function K.crawl(t, font, lines, t0, t1, horizonY) local spacing = 1.4 local total = #lines * spacing + 6 local along = K.clamp((t - t0) / (t1 - t0), 0, 1) * total for i, line in ipairs(lines) do local z = 1 + along - (i - 1) * spacing if z > 0.35 then local scale = 1.1 / z local y = horizonY + (K.H - horizonY) * 1.1 / z local w = K.scrollerWidth(font, line) * scale local fade = K.clamp(1 - (z - 3) / 4, 0, 1) local x = K.W / 2 - w / 2 if fade > 0 and y < K.H + 40 then for c = 1, #line do local g = glyph(font, line:sub(c, c)) local gw = g.w * scale local gh = spriteGetHeight(g.sprite) * scale spriteSetTint(g.sprite, 255, math.floor(200 + 55 * fade), 120 + math.floor(100 * (1 - fade))) spriteSetAlpha(g.sprite, math.floor(255 * fade)) spriteDraw(g.sprite, math.floor(x), math.floor(y - gh), math.floor(x + gw), math.floor(y)) spriteSetAlpha(g.sprite, 255) x = x + gw end end end end end -- How long ago the most recent hit in a list ("kicks", "snares", "hats") was at time t; huge when -- there has been none. An effect fed exp(-age * k) flashes on every hit and dies away. function K.decay(list, t, k) return math.exp(-K.hitAge(list, t) * k) end -- A flat disc of radius r facing up (+Y), a texture laid across it: a coin's face, a record. function K.disc(r, segments) local positions = { 0, 0, 0 } local normals = { 0, 1, 0 } local uvs = { 0.5, 0.5 } local indices = {} for i = 0, segments do local a = i / segments * 2 * math.pi positions[#positions + 1] = math.cos(a) * r positions[#positions + 1] = 0 positions[#positions + 1] = math.sin(a) * r normals[#normals + 1] = 0 normals[#normals + 1] = 1 normals[#normals + 1] = 0 uvs[#uvs + 1] = 0.5 + 0.5 * math.cos(a) uvs[#uvs + 1] = 0.5 + 0.5 * math.sin(a) end for i = 1, segments do indices[#indices + 1] = 1 indices[#indices + 1] = i + 2 indices[#indices + 1] = i + 1 end return meshNew(positions, normals, uvs, indices) end function K.ease(t) t = K.clamp(t, 0, 1) return t * t * (3 - 2 * t) end function K.easeIn(t) t = K.clamp(t, 0, 1) return t * t * t end function K.easeInOut(a, b, t) return a + (b - a) * (0.5 - math.cos(K.clamp(t, 0, 1) * math.pi) / 2) end function K.easeOut(t) t = 1 - K.clamp(t, 0, 1) return 1 - t * t * t end -- An envelope's value at time t, 0 to 1: "kick", "snare", "hat", "bass", "level", "bright", or -- "noteClear". function K.env(name, t) local curve = sync[name] local f = t * sync.rate local i = math.floor(f) if i < 0 then return curve[1] end if i >= #curve - 1 then return curve[#curve] end return curve[i + 1] + (f - i) * (curve[i + 2] - curve[i + 1]) end -- The whole screen in one colour: a flash, a fade, or a tint. function K.flash(r, g, b, a, blend) K.box(0, 0, K.W - 1, K.H - 1, r, g, b, a, blend) end -- Coloured glass: see-through by alpha, polished, drawn after everything solid. function K.glass(r, g, b, alpha) local m = materialNew() materialSetColor(m, r, g, b, alpha or 90) materialSetBlend(m, true) materialSetRoughness(m, 0.05) return m end -- Seconds since the most recent hit in a list at time t. function K.hitAge(list, t) local hits = sync[list] local lo = 0 local hi = #hits + 1 while hi - lo > 1 do local mid = (lo + hi) // 2 if hits[mid] <= t then lo = mid else hi = mid end end if lo == 0 then return math.huge end return t - hits[lo] end -- How many hits in a list fall after t0 and up to t1: what a frame's worth of time held. function K.hitCount(list, t0, t1) local count = 0 for _, h in ipairs(sync[list]) do if h > t1 then break end if h > t0 then count = count + 1 end end return count end -- A full-bright colour from a hue, 0 to 1 round the wheel. function K.hue(h) local f = (h % 1) * 6 local i = math.floor(f) local p = f - i local c = ({ { 1, p, 0 }, { 1 - p, 1, 0 }, { 0, 1, p }, { 0, 1 - p, 1 }, { p, 0, 1 }, { 1, 0, 1 - p } })[i + 1] return math.floor(c[1] * 255), math.floor(c[2] * 255), math.floor(c[3] * 255) end function K.lerp(a, b, t) return a + (b - a) * t end -- Reads the demo's sync data and its music, makes the overlay, and loads the kit's own pixel. -- Envelopes become arrays of numbers from 0 to 1 here, once. function K.load(dir, width, height) local value = {} K.W = width or K.W K.H = height or K.H overlaySetResolution(K.W, K.H) for i = 1, #ALPHABET do value[ALPHABET:byte(i)] = (i - 1) / LEVELS end sync = dofile(dir .. "sync.lua") for _, name in ipairs(ENVELOPES) do local text = sync[name] local curve = {} for i = 1, #text do curve[i] = value[text:byte(i)] end sync[name] = curve end local notes = {} for i = 1, #sync.note do notes[i] = math.floor(value[sync.note:byte(i)] * LEVELS + 0.5) end sync.note = notes for _, name in ipairs(HIT_LISTS) do sync[name] = sync[name] or {} end K.duration = sync.duration if not DEMO_CAPTURE then music = musicLoad(dir .. "music.mp3") end pixel = spriteLoadData(PIXEL_PNG) -- The logos' lettering is opaque black, which the colour key would drop: they go by alpha alone. drawTransparent(true) logos.singe = spriteLoad(dir .. "../art/singeLogo.png") logos.kangaroo = spriteLoad(dir .. "../art/kangarooPunchLogo.png") drawTransparent(false) return sync end -- The strongest pitch class at time t (0 is C) and how clearly it stands out, 0 to 1. function K.note(t) local i = K.clamp(math.floor(t * sync.rate) + 1, 1, #sync.note) return sync.note[i], sync.noteClear[i] end -- Every mesh node under a model instance gets the material. function K.paint(node, material) nodeSetMaterial(node, material) for _, child in ipairs(nodeGetChildren(node)) do K.paint(child, material) end end -- A palette for spriteSetPixels: 256 colours of three bytes, a smooth ramp through stops, each -- { position 0 to 1, { r, g, b } }. cycle doubles it, so palette:sub() can take any rotation of it. function K.palette(stops, cycle) local out = {} for i = 0, 255 do local t = i / 255 local c = stops[#stops][2] for k = 1, #stops - 1 do local a = stops[k] local b = stops[k + 1] if t >= a[1] and t <= b[1] then local f = (t - a[1]) / (b[1] - a[1]) c = { a[2][1] + (b[2][1] - a[2][1]) * f, a[2][2] + (b[2][2] - a[2][2]) * f, a[2][3] + (b[2][3] - a[2][3]) * f } break end end out[#out + 1] = string.char(math.floor(c[1]), math.floor(c[2]), math.floor(c[3])) end out = table.concat(out) return cycle and (out .. out) or out end -- Plasma, live: four interfering waves summed from a table of sines at every pixel of a width by -- height sprite, coloured through a cycling palette, the way the old demos did it. K.plasmaUpdate -- draws the next picture into fx.sprite; stretch it over the screen or put it on a surface. function K.plasmaNew(width, height, stops) local fx = { w = width, h = height, sprite = spriteNew(width, height), palette = K.palette(stops, true), sine = {}, ring = {}, rows = {}, row = {} } for i = 0, 511 do fx.sine[i] = math.floor(32 + 31.5 * math.sin(i / 512 * 2 * math.pi)) end -- Distances from the middle of a picture twice the size, so the ripple's centre can move anywhere -- in the plasma by moving where the plasma is read from: no square root at a pixel a frame. for y = 0, height * 2 - 1 do for x = 0, width * 2 - 1 do local dx = x - width local dy = y - height fx.ring[y * width * 2 + x] = math.floor(math.sqrt(dx * dx + dy * dy) * 9) end end return fx end function K.plasmaUpdate(fx, t) local sine = fx.sine local row = fx.row local rows = fx.rows local w = fx.w local a = math.floor(t * 90) local b = math.floor(t * 61) local c = math.floor(t * 137) local cx = math.floor(w / 2 + math.sin(t * 0.7) * w * 0.3) local cy = math.floor(fx.h / 2 + math.cos(t * 0.9) * fx.h * 0.3) local char = string.char local unpack = table.unpack local ring = fx.ring local span = w * 2 for y = 0, fx.h - 1 do local sy = sine[(y * 5 + b) & 511] + sine[(y * 2 - c) & 511] local base = (y - cy + fx.h) * span + (w - cx) for x = 0, w - 1 do row[x + 1] = (sine[(x * 4 + a) & 511] + sy + sine[(x * 3 + y * 3 + c) & 511] + sine[(ring[base + x] - a * 2) & 511]) & 255 end rows[y + 1] = char(unpack(row, 1, w)) end local turn = math.floor(t * 40) % 256 spriteSetPixels(fx.sprite, table.concat(rows, "", 1, fx.h), fx.palette:sub(turn * 3 + 1, turn * 3 + 768)) end -- Starts the music and the clock, and takes over onOverlayUpdate to run the scenes. Under -- DEMO_CAPTURE = { from = seconds, to = seconds, every = frames } nothing plays: the clock starts -- at from and moves by the frame (run Singe with --deterministic), every frame (or every "every"th) -- is saved as frameNNNNNN.png, and the demo quits at to. -- A logo card p seconds in: the screen fades from black to white with the logo coming up on it, -- holds, and fades back to black, the way Singe's own start does. The logos' lettering is black. local function logoCard(sprite, p) local v = math.floor(255 * K.clamp(math.min(p, LOGO_TIME - p) / LOGO_FADE, 0, 1)) local x = (K.W - spriteGetWidth(sprite)) // 2 local y = (K.H - spriteGetHeight(sprite)) // 2 K.flash(v, v, v, 255) spriteSetAlpha(sprite, v) spriteDraw(sprite, x, y) end function K.run(scenes) local active = {} local ending = sync.duration + TAIL if DEMO_CAPTURE then debugPrint(string.format("DEMO capture from %.3f to %.3f", DEMO_CAPTURE.from, DEMO_CAPTURE.to)) end function onOverlayUpdate() local t = K.time() frames = frames + 1 colorBackground(0, 0, 0, 0) overlayClear() if not DEMO_CAPTURE and not playing and t >= 0 then musicPlay(music) playing = true end -- The Singe card before the music, the demo, and the Kangaroo Punch card after it. if t < 0 then logoCard(logos.singe, t + LOGO_TIME) elseif t >= ending then logoCard(logos.kangaroo, t - ending) else local bar = K.bar(t) if DEMO_STATS then K.stats(t, bar) end for i, scene in ipairs(scenes) do local on = bar >= scene.from and bar < scene.to if on and not active[i] then active[i] = true if scene.enter then scene.enter(t) end elseif not on and active[i] then active[i] = false if scene.leave then scene.leave(t) end end if on and scene.draw then scene.draw(t, bar, (bar - scene.from) / (scene.to - scene.from)) end end if t > sync.duration then K.flash(0, 0, 0, math.floor(255 * K.clamp((t - sync.duration) / TAIL, 0, 1))) end end if DEMO_CAPTURE then if frames % (DEMO_CAPTURE.every or 1) == 0 then singeScreenshot(string.format("frame%06d", frames)) end if t >= DEMO_CAPTURE.to then debugPrint(string.format("DEMO captured %d frames", frames)) singeQuit() end elseif t >= ending + LOGO_TIME then singeQuit() end return OVERLAY_UPDATED end end -- A flat quad that exactly fills the camera's view at distance d (oversize makes it larger, to stay -- covering while it turns): for a video over, or as, the whole picture in 3D, where it can turn and -- glow. What comes back is a pivot on the camera's axis with the quad stood up under it, so the -- pivot's roll, nodeSetRotation(q, 0, 0, angle), turns the quad in its own plane. It starts hidden. function K.screenQuad(camera, fov, material, d, oversize) local h = 2 * d * math.tan(math.rad(fov / 2)) * (oversize or 1) local pivot = nodeNew(camera) local quad = nodeNew(pivot) nodeSetPosition(pivot, 0, 0, -d) nodeSetMesh(quad, meshPlane(h * K.W / K.H, h), material) nodeSetRotation(quad, 90, 0, 0) nodeSetVisible(pivot, false) return pivot end -- A scroller: each letter coloured by where it is on the screen. The text enters from the right at -- t0 and its last letter leaves at the left at t1, whatever its length: the speed comes from the -- text. A text too short to fill the time at a readable speed is said again until it does, and one -- too long to read at its speed still ends at t1, with a warning in the trace saying how much to cut. -- style chooses how the letters move: "sine" (the default) rides a wave of amplitude amp; "bounce" -- hops each letter in turn, higher on the kicks; "zoom" swells the letters as they pass the middle -- of the screen; "spin" turns each letter right round as it crosses. function K.scroller(t, font, text, y, amp, t0, t1, style) local paced = scrollerPace(font, text, t0, t1) local x = K.W - (t - t0) * paced.speed local kick = K.decay("kicks", t, 6) text = paced.text for i = 1, #text do local g = glyph(font, text:sub(i, i)) if x > K.W then break end if x + g.w > 0 then local r, gg, b = K.hue(x / K.W * 0.5 + t * 0.2) local h = spriteGetHeight(g.sprite) spriteSetTint(g.sprite, r, gg, b) if style == "bounce" then local hop = math.abs(math.sin(t * 5 + i * 0.45)) * amp * (0.5 + kick) spriteDraw(g.sprite, math.floor(x), math.floor(y - hop)) elseif style == "zoom" then -- Laid out at the zoomed width, so the swollen letters push their neighbours apart -- rather than piling onto them. local mid = K.clamp(x + g.w / 2 - K.W / 2, -K.W / 2, K.W / 2) local scale = 1 + 0.9 * math.cos(mid / K.W * math.pi) ^ 3 local w = g.w * scale local hh = h * scale spriteDraw(g.sprite, math.floor(x), math.floor(y + h - hh), math.floor(x + w), math.floor(y + h)) x = x + w - g.w elseif style == "spin" then -- Each letter spins in from the right, reads upright across the middle, and spins -- away again at the left. local spin = K.smooth(K.W * 0.62, K.W, x) - K.smooth(K.W * 0.38, 0, x) spriteRotate(g.sprite, math.floor(360 * spin) % 360) spriteDraw(g.sprite, math.floor(x + g.w / 2), math.floor(y + h / 2 + math.sin(x * 0.012 + t * 3) * amp), true) spriteRotate(g.sprite, 0) else spriteDraw(g.sprite, math.floor(x), math.floor(y + math.sin(x * 0.018 + t * 4) * amp)) end end x = x + g.w end end -- A scroller of solid letters from K.blockFont, in front of everything. The letters ride a path -- that swings toward the viewer and back, rising and falling on a wave around the line cy, each one -- rocking and banked along the wave, and once every four bars a spin runs through them from right to -- left. Coloured round the wheel. Paced like K.scroller: in at t0 and out at t1, whatever the -- text's length. K.scroller3DStop hides the letters once the scene is over. function K.scroller3D(t, font, text, cy, t0, t1) local paced = scrollerPace(font, text, t0, t1, SCROLL_3D_SLOWEST) local x = K.W - (t - t0) * paced.speed local into = (K.bar(t) - 1) % 4 local u = font.units local used = 0 text = paced.text nodeSetVisible(font.pivot, true) for i = math.max(1, math.floor(-x / font.advance)), #text do local left = x + (i - 1) * font.advance local c = text:sub(i, i) if left > K.W then break end if font.glyphs[c] and c ~= " " and left + font.advance > 0 then local mid = left + font.advance / 2 local wave = mid * 0.011 + t * 1.8 -- Never farther than resting: nearer only pushes a letter outward, so none shows before -- t0 or after t1. local near = 0.82 - 0.18 * math.sin(mid * 0.006 + t * 1.1) local sy = cy + math.sin(wave) * 46 local roll = -math.deg(math.atan(math.cos(wave) * 0.011 * 46)) * 0.6 local spin = 360 * K.ease(K.clamp(into * 1.6 - (1 - mid / K.W) * 0.6, 0, 1)) local hue = math.floor(((mid / K.W * 0.4 + t * 0.15) % 1) * BLOCK_HUES) + 1 local node used = used + 1 node = font.nodes[used] if not node then node = nodeNew(font.pivot) nodeSetShadow(node, false) font.nodes[used] = node end if not font.meshes[c] then font.meshes[c] = blockMesh(font.glyphs[c]) end nodeSetMesh(node, font.meshes[c], font.materials[hue]) nodeSetPosition(node, (mid - K.W / 2) * u, (K.H / 2 - sy) * u, -BLOCK_DISTANCE * near) nodeSetRotation(node, 12 * math.cos(wave), 28 * math.sin(mid * 0.008 + t * 1.3) + spin, roll) nodeSetScale(node, font.cell * u) nodeSetVisible(node, true) end end for i = used + 1, #font.nodes do nodeSetVisible(font.nodes[i], false) end end function K.scroller3DStop(font) nodeSetVisible(font.pivot, false) end -- How wide text is in a font, in pixels: a loaded font, or block letters from K.blockFont. function K.scrollerWidth(font, text) local w = 0 if type(font) == "table" then return #text * font.advance end for i = 1, #text do w = w + glyph(font, text:sub(i, i)).w end return w end function K.smooth(edge0, edge1, x) return K.ease((x - edge0) / (edge1 - edge0)) end -- A tape's snow, live: grain streaked along each line, two tracking bands rolling up the picture. function K.snowNew(width, height) local grey = {} for i = 0, 255 do grey[#grey + 1] = string.char(math.floor(i * 0.95), math.floor(i * 0.9), math.min(255, math.floor(i * 1.05))) end return { w = width, h = height, sprite = spriteNew(width, height), palette = table.concat(grey), rows = {}, row = {} } end function K.snowUpdate(fx, t) local row = fx.row local rows = fx.rows local w = fx.w local random = math.random local char = string.char local unpack = table.unpack local band1 = (fx.h - (t * 60) % (fx.h + 40)) + 20 local band2 = (fx.h - (t * 60 + fx.h / 2) % (fx.h + 40)) + 20 for y = 0, fx.h - 1 do local d1 = (y - band1) * (y - band1) local d2 = (y - band2) * (y - band2) local lift = math.floor(70 * math.exp(-d1 / 60) + 50 * math.exp(-d2 / 20)) local gain = 0.55 + 0.45 * math.exp(-d1 / 60) + 0.6 * math.exp(-d2 / 20) local last = 0 for x = 1, w do local v = random() ^ 1.6 * 200 last = (v + last * 2) / 3 row[x] = math.min(255, math.floor(last * gain) + lift) end rows[y + 1] = char(unpack(row, 1, w)) end spriteSetPixels(fx.sprite, table.concat(rows, "", 1, fx.h), fx.palette) end -- Under DEMO_STATS, once a second: frames drawn in the last second, and the longest gap between two. function K.stats(t, bar) local now = singeGetTicks() if stats.last > 0 then stats.worst = math.max(stats.worst, now - stats.last) end stats.last = now stats.frames = stats.frames + 1 if now - stats.second >= 1000 then debugPrint(string.format("DEMO t %6.2f bar %6.2f %3d fps worst %3d ms", t, bar, stats.frames, stats.worst)) stats.second = now stats.frames = 0 stats.worst = 0 end end -- A full-screen flash that fires on every hit in a list and dies away at rate k: a = 0 is off. function K.strobe(t, list, k, r, g, b, a) local v = K.decay(list, t, k) if v > 0.02 and (a or 255) > 0 then K.flash(r, g, b, math.floor((a or 255) * v)) end end -- Text in the bundled font at a point size, in a colour, left, centred ("center"), or right -- aligned on x. function K.text(x, y, text, size, r, g, b, align) local font = fonts[size] if not font then font = fontLoad(FONT_FILE, size) fonts[size] = font end fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) colorForeground(r, g, b, 255) if align then local w = K.textWidth(text, size) fontSelect(font) if align == "center" then x = x - w // 2 else x = x - w end end fontPrint(math.floor(x), math.floor(y), text) end -- How wide text is in the bundled font at a point size, in pixels; measured once and remembered. function K.textWidth(text, size) local key = size .. ":" .. text local w = widths[key] if not w then local font = fonts[size] if not font then font = fontLoad(FONT_FILE, size) fonts[size] = font end fontSelect(font) local sprite = fontToSprite(text) w = spriteGetWidth(sprite) widths[key] = w spriteUnload(sprite) end return w end -- Where the listener is in the music, in seconds. It never runs backwards, which a mixer -- position read just after a buffer moved can otherwise do by a millisecond, and once the music has -- ended it runs on with the clock. function K.time() local now = singeGetTicks() local t = 0 startTicks = startTicks or now if DEMO_CAPTURE then t = DEMO_CAPTURE.from + (now - startTicks) / 1000 elseif not playing then t = -LOGO_TIME + (now - startTicks) / 1000 else t = musicGetPosition(music) if t < 0 then t = (lastTime > 0) and (lastTime + (now - lastTicks) / 1000) or 0 end end lastTime = math.max(lastTime, t) lastTicks = now return lastTime end -- A (2, 3) torus knot as a tube, built vertex by vertex for meshNew: the curve, a frame along it, and a ring -- of vertices round each point. function K.torusKnot(radius, tube, segments, sides) local positions = {} local normals = {} local uvs = {} local indices = {} local function curve(phi) local r = radius * (1 + 0.42 * math.cos(3 * phi)) return r * math.cos(2 * phi), r * math.sin(2 * phi), radius * 0.42 * math.sin(3 * phi) end for i = 0, segments - 1 do local phi = i / segments * 2 * math.pi local px, py, pz = curve(phi) local qx, qy, qz = curve(phi + 0.01) local tx = qx - px local ty = qy - py local tz = qz - pz local tl = math.sqrt(tx * tx + ty * ty + tz * tz) tx = tx / tl ty = ty / tl tz = tz / tl -- A normal that never lines up with the tangent: the knot never runs straight up the z axis. local nx = ty * 1 - tz * 0 local ny = tz * 0 - tx * 1 local nz = tx * 0 - ty * 0 local nl = math.sqrt(nx * nx + ny * ny + nz * nz) + 1e-9 nx = nx / nl ny = ny / nl nz = nz / nl local bx = ty * nz - tz * ny local by = tz * nx - tx * nz local bz = tx * ny - ty * nx for j = 0, sides - 1 do local a = j / sides * 2 * math.pi local cx = math.cos(a) * nx + math.sin(a) * bx local cy = math.cos(a) * ny + math.sin(a) * by local cz = math.cos(a) * nz + math.sin(a) * bz positions[#positions + 1] = px + cx * tube positions[#positions + 1] = py + cy * tube positions[#positions + 1] = pz + cz * tube normals[#normals + 1] = cx normals[#normals + 1] = cy normals[#normals + 1] = cz uvs[#uvs + 1] = i / segments uvs[#uvs + 1] = j / sides end end for i = 0, segments - 1 do local i2 = (i + 1) % segments for j = 0, sides - 1 do local j2 = (j + 1) % sides local a = i * sides + j + 1 local b = i2 * sides + j + 1 local c = i2 * sides + j2 + 1 local d = i * sides + j2 + 1 indices[#indices + 1] = a indices[#indices + 1] = b indices[#indices + 1] = c indices[#indices + 1] = a indices[#indices + 1] = c indices[#indices + 1] = d end end return meshNew(positions, normals, uvs, indices) end -- The tunnel, live: every pixel's angle round the middle and its depth (the inverse of its distance -- from the middle) are worked out once into tables, and each frame looks up a texture of rings and -- spokes at those, moved along by travel and round by turn, both 0 to 1 a lap. Shaded darker at -- the edge and burning to white in the far middle, through the palette. function K.tunnelNew(width, height, stops) local fx = { w = width, h = height, sprite = spriteNew(width, height), palette = K.palette(stops), angle = {}, depth = {}, shade = {}, core = {}, texture = {}, rows = {}, row = {} } local rings = 16 local spokes = 16 for y = 0, height - 1 do for x = 0, width - 1 do local dx = (x - width / 2 + 0.5) / (height / 2) local dy = (y - height / 2 + 0.5) / (height / 2) local r = math.sqrt(dx * dx + dy * dy) + 1e-6 local i = y * width + x fx.angle[i] = math.floor((math.atan(dy, dx) / (2 * math.pi) + 0.5) * 256) & 255 fx.depth[i] = math.floor(0.35 / r * 256) & 255 fx.shade[i] = math.floor(K.clamp(r * 1.6, 0, 1) ^ 0.8 * 256) fx.core[i] = math.floor(255 * math.exp(-r * 9)) end end -- The texture: glowing ring edges, glowing spokes, and a checker of the two, 256 by 256. for v = 0, 255 do local edge = math.abs(((v * rings / 256) % 1) - 0.5) * 2 local ring = math.exp(-(1 - edge) * 9) for u = 0, 255 do local spokeEdge = math.abs(((u * spokes / 256) % 1) - 0.5) * 2 local spoke = math.exp(-(1 - spokeEdge) * 12) local check = ((v * rings // 256) + (u * spokes // 256)) % 2 local value = 0.18 + 0.22 * check + 0.75 * ring + 0.45 * spoke fx.texture[v * 256 + u] = math.floor(K.clamp(value, 0, 1) * 255) end end return fx end function K.tunnelUpdate(fx, travel, turn) local angle = fx.angle local depth = fx.depth local shade = fx.shade local core = fx.core local texture = fx.texture local row = fx.row local rows = fx.rows local w = fx.w local dv = math.floor(travel * 256) local du = math.floor(turn * 256) local char = string.char local unpack = table.unpack local min = math.min local i = 0 for y = 0, fx.h - 1 do for x = 1, w do local value = (texture[((depth[i] + dv) & 255) * 256 + ((angle[i] + du) & 255)] * shade[i]) >> 8 row[x] = min(255, value + core[i]) i = i + 1 end rows[y + 1] = char(unpack(row, 1, w)) end spriteSetPixels(fx.sprite, table.concat(rows, "", 1, fx.h), fx.palette) end return K