1250 lines
39 KiB
Lua
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
|