singe/demos/kit.lua
2026-10-13 13:58:00 -05:00

1499 lines
48 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 DESIGN_H = 480 -- The demos are laid out this many units tall, whatever the window: K.S pixels to a unit
local QUAD_FRONT = 0.2 -- The screen quads (K.flash, K.under, K.backdrop): how far from the camera the flashes hang ...
local QUAD_UNDER = 0.25 -- ... the layer under the overlay, in front of the world ...
local QUAD_BACK = 0.9 -- ... and the backdrop, this share of the way to the far plane
local QUAD_STEP = 0.001 -- Each quad drawn after another in its layer comes this share nearer
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 LOGO_GAP = 24 -- Design units between the logos stacked on a card
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 }
-- How many of a sprite's pixels make one design unit (K.sprite); 1 when not said.
local density = {}
-- The scrollers' letters: each a sprite made once per font, white, so it can be tinted, and its
-- size in design units. Fonts from K.font are rendered at the window's resolution.
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)
density[s] = K.S
g = { sprite = s, w = spriteGetWidth(s) / K.S, h = spriteGetHeight(s) / K.S }
glyphs[key] = g
end
return g
end
-- The screen quads: pools of camera-facing quads, three layers deep, placed fresh every frame by
-- K.flash, K.fill, K.under, and K.backdrop and hidden again when a frame does not use them. They
-- draw on the GPU, where a whole-screen layer costs nothing; on the overlay it costs the CPU about
-- 8 ms at 1920 by 1080.
local screen = { camera = nil, fov = 60, far = 100, layers = {} }
local function quadLayer(name, distance, front)
local layer = { nodes = {}, used = 0, distance = distance, front = front }
screen.layers[name] = layer
return layer
end
-- The next quad of a layer, made the first time it is wanted: unlit, see-through, its own
-- material, hung off the camera.
local function quadNext(layer)
local q
layer.used = layer.used + 1
q = layer.nodes[layer.used]
if not q then
local m = materialNew()
local pivot = nodeNew(screen.camera)
local quad = nodeNew(pivot)
materialSetUnlit(m, true)
materialSetBlend(m, true)
materialSetDoubleSided(m, true)
nodeSetMesh(quad, screen.mesh, m)
nodeSetRotation(quad, 90, 0, 0)
nodeSetShadow(quad, false)
if layer.front then
nodeSetLayer(pivot, LAYER_FRONT)
end
q = { pivot = pivot, quad = quad, material = m, tiles = 1, nearest = false }
layer.nodes[layer.used] = q
end
nodeSetVisible(q.pivot, true)
return q
end
-- Places a layer's next quad over x1, y1 to x2, y2 in design units, in a colour, showing a sprite
-- or nothing, repeated tiles times down it (once when nil), sampled nearest when asked, and turned
-- by angle degrees about its middle. A sprite whose pixels change from frame to frame is sent again
-- only when dynamic.
local function quadPlace(layer, x1, y1, x2, y2, r, g, b, a, sprite, dynamic, tiles, nearest, angle)
local q = quadNext(layer)
local slope = 2 * math.tan(math.rad(screen.fov / 2)) / K.H
local dx = ((x1 + x2) / 2 - K.W / 2) * slope
local dy = (K.H / 2 - (y1 + y2) / 2) * slope
-- The scene draws see-through things farthest first by the distance of their middles from the
-- eye, so each quad slides along its own line of sight (where it shows does not change) until its
-- middle is as far as its turn in the layer says: then they draw in the order they were placed.
local d = layer.distance * (1 - QUAD_STEP * layer.used) / math.sqrt(1 + dx * dx + dy * dy)
local unit = d * slope
if sprite ~= q.sprite or dynamic then
materialSetTexture(q.material, sprite)
q.sprite = sprite
end
if (tiles or 1) ~= q.tiles then
q.tiles = tiles or 1
materialSetTiling(q.material, 1, q.tiles)
end
if (nearest or false) ~= q.nearest then
q.nearest = nearest or false
materialSetFilter(q.material, q.nearest and FILTER_NEAREST or FILTER_LINEAR)
end
materialSetColor(q.material, r, g, b, a or 255)
nodeSetPosition(q.pivot, dx * d, dy * d, -d)
nodeSetRotation(q.pivot, 0, 0, angle or 0)
nodeSetScale(q.quad, (x2 - x1) * unit, 1, (y2 - y1) * unit)
end
local function screenBegin()
for _, layer in pairs(screen.layers) do
layer.used = 0
end
end
local function screenEnd()
for _, layer in pairs(screen.layers) do
for i = layer.used + 1, #layer.nodes do
nodeSetVisible(layer.nodes[i].pivot, false)
end
end
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
-- A sprite behind the 3D world, over x1, y1 to x2, y2 in design units (the whole screen when
-- they are left out), tinted r, g, b, a; dynamic when its pixels change every frame. On the GPU.
function K.backdrop(sprite, x1, y1, x2, y2, r, g, b, a, dynamic)
quadPlace(screen.layers.back, x1 or 0, y1 or 0, x2 or K.W, y2 or K.H, r or 255, g or 255, b or 255, a, sprite, dynamic)
end
-- 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)
K.draw(sprite, px - r, py - r, px + r, 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)
K.draw(pixel, x1, y1, x2 + 1, y2 + 1)
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)
K.draw(g.sprite, cx + math.cos(rad) * r, 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 sprite over the whole screen and everything on it, repeated tiles times from top to bottom and
-- sampled nearest, at alpha a: scanlines, a grille, a screen door. On the GPU.
function K.cover(sprite, tiles, a)
quadPlace(screen.layers.front, 0, 0, K.W, K.H, 255, 255, 255, a, sprite, false, tiles, true)
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 = g.h * scale
spriteSetTint(g.sprite, 255, math.floor(200 + 55 * fade), 120 + math.floor(100 * (1 - fade)))
spriteSetAlpha(g.sprite, math.floor(255 * fade))
K.draw(g.sprite, x, y - gh, x + gw, 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
-- Draws a sprite in design units: at its own size from x, y, or centred on them when x2 is true,
-- or stretched from x, y up to (not including) x2, y2, so pieces that meet tile without gaps at any
-- scale. Its own size is its pixels over its density (K.sprite).
function K.draw(sprite, x, y, x2, y2)
local k = K.S / (density[sprite] or 1)
local w = 0
local h = 0
if y2 then
spriteDraw(sprite, math.floor(x * K.S), math.floor(y * K.S), math.floor(x2 * K.S) - 1, math.floor(y2 * K.S) - 1)
return
end
w = spriteGetWidth(sprite) * k
h = spriteGetHeight(sprite) * k
x = x * K.S
y = y * K.S
if x2 == true then
x = x - w / 2
y = y - h / 2
end
if k == 1 then
spriteDraw(sprite, math.floor(x), math.floor(y))
else
spriteDraw(sprite, math.floor(x), math.floor(y), math.floor(x + w), math.floor(y + h))
end
end
-- spriteDrawGrid in design units: the part sx, sy, w, h of the sprite (its own pixels) drawn at
-- x, y at its own size, or stretched up to (not including) x2, y2.
function K.drawGrid(sprite, x, y, sx, sy, w, h, x2, y2)
local k = 1 / (density[sprite] or 1)
x2 = x2 or (x + w * k)
y2 = y2 or (y + h * k)
spriteDrawGrid(sprite, math.floor(x * K.S), math.floor(y * K.S), sx, sy, w, h, math.floor(x2 * K.S) - 1, math.floor(y2 * K.S) - 1)
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 solid colour under the overlay and over the 3D world: a 2D scene's
-- background. On the GPU.
function K.fill(r, g, b)
quadPlace(screen.layers.under, 0, 0, K.W, K.H, r, g, b, 255)
end
-- The whole screen in one colour over everything, the 3D front layer included: a flash, a fade, or
-- a tint. On the GPU.
function K.flash(r, g, b, a)
if (a or 255) > 0 then
quadPlace(screen.layers.front, 0, 0, K.W, K.H, r, g, b, a)
end
end
-- A font of the bundled face at a size in design units, rendered at the window's resolution.
function K.font(size)
local real = math.floor(size * K.S + 0.5)
if not fonts[real] then
fonts[real] = fontLoad(FONT_FILE, real)
end
return fonts[real]
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
-- A sprite's height in design units.
function K.height(sprite)
return spriteGetHeight(sprite) / (density[sprite] or 1)
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)
local value = {}
-- The window's size is the canvas in games.dat; the demo works in design units, DESIGN_H tall.
K.S = discGetHeight() / DESIGN_H
K.W = discGetWidth() / K.S
K.H = DESIGN_H
overlaySetResolution(discGetWidth(), discGetHeight())
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 go by their alpha alone: the colour key would drop any black in them.
drawTransparent(true)
logos.opening = { K.vector(dir .. "../art/dragon.svg", K.W * 0.6, 270), K.vector(dir .. "../art/singeText.svg", K.W * 0.5, 84) }
logos.closing = { K.vector(dir .. "../art/kangarooPunch.svg", K.W * 0.8, 400) }
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: its logos, stacked and centred on black, fade up, hold, and fade back.
local function logoCard(card, p)
local v = math.floor(255 * K.clamp(math.min(p, LOGO_TIME - p) / LOGO_FADE, 0, 1))
local height = LOGO_GAP * (#card - 1)
local y = 0
for _, sprite in ipairs(card) do
height = height + K.height(sprite)
end
y = (K.H - height) / 2
K.fill(0, 0, 0)
for _, sprite in ipairs(card) do
spriteSetAlpha(sprite, v)
K.draw(sprite, K.W / 2, y + K.height(sprite) / 2, true)
y = y + K.height(sprite) + LOGO_GAP
end
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()
screenBegin()
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.opening, t + LOGO_TIME)
elseif t >= ending then
logoCard(logos.closing, t - ending)
else
local bar = K.bar(t)
if DEMO_STATS then
K.stats(t, bar)
end
-- The music's lead-in before the first bar belongs to no scene, so it stays black
-- rather than showing whatever 3D sky is set up behind the scenes.
if bar < 1 then
K.fill(0, 0, 0)
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
screenEnd()
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
-- Hangs the screen quads off the demo's camera, whose field of view and far plane they are placed
-- by. Call it once the camera is made, before any K.flash, K.fill, K.under, or K.backdrop.
function K.screen(camera, fov, far)
screen.camera = camera
screen.fov = fov
screen.far = far
screen.mesh = meshPlane(1, 1)
quadLayer("front", QUAD_FRONT, true)
quadLayer("under", QUAD_UNDER, false)
quadLayer("back", far * QUAD_BACK, false)
end
-- A flat quad that exactly fills the camera's view at distance d (oversize makes it larger; true
-- makes it as large as it must be to keep the screen covered however it turns, the screen's diagonal
-- over its height): 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 turning = math.sqrt(1 + (K.W / K.H) ^ 2)
local h = 2 * d * math.tan(math.rad(fov / 2)) * ((oversize == true) and turning or (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 = g.h
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)
K.draw(g.sprite, x, 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
K.draw(g.sprite, x, y + h - hh, x + w, 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)
K.draw(g.sprite, x + g.w / 2, y + h / 2 + math.sin(x * 0.012 + t * 3) * amp, true)
spriteRotate(g.sprite, 0)
else
K.draw(g.sprite, x, 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.
-- Loads a sprite whose pixels are drawn density to a design unit: art made for the window's
-- resolution says K.S and draws pixel for pixel; art made for the old 640 by 480 screen says 1.
function K.sprite(path, d)
local sprite = spriteLoad(path)
density[sprite] = d or 1
return sprite
end
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 = K.font(size)
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 * K.S), math.floor(y * K.S), text)
end
-- How wide text is in the bundled font at a size, in design units; measured once and remembered.
function K.textWidth(text, size)
local key = size .. ":" .. text
local w = widths[key]
if not w then
fontSelect(K.font(size))
local sprite = fontToSprite(text)
w = spriteGetWidth(sprite) / K.S
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
-- A sprite turned angle degrees and scaled about its middle at x, y, under the overlay and over the
-- 3D world, tinted r, g, b, a: spriteRotateAndScale and a centred draw, on the GPU, where turning a
-- large picture every frame costs nothing.
function K.turn(sprite, x, y, angle, scale, r, g, b, a)
local w = K.width(sprite) * (scale or 1) / 2
local h = K.height(sprite) * (scale or 1) / 2
quadPlace(screen.layers.under, x - w, y - h, x + w, y + h, r or 255, g or 255, b or 255, a, sprite, false, nil, false, angle)
end
-- A sprite, or a colour when it is nil, under the overlay and over the 3D world, over x1, y1 to
-- x2, y2 in design units, tinted r, g, b, a; dynamic when its pixels change every frame. For a
-- 2D effect as big as the screen, which the overlay would spend milliseconds a frame drawing. On
-- the GPU.
function K.under(sprite, x1, y1, x2, y2, r, g, b, a, dynamic)
quadPlace(screen.layers.under, x1, y1, x2, y2, r or 255, g or 255, b or 255, a, sprite, dynamic)
end
-- An SVG drawn to fit w by h design units, keeping its shape, rasterised at the window's resolution
-- so it is sharp at any size.
function K.vector(path, w, h)
local sprite = spriteLoad(path, math.floor(w * K.S), math.floor(h * K.S))
density[sprite] = K.S
return sprite
end
-- A sprite's width in design units.
function K.width(sprite)
return spriteGetWidth(sprite) / (density[sprite] or 1)
end
return K