singe/demos/kit.lua
2026-10-05 22:12:53 -05:00

1250 lines
39 KiB
Lua

-- 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