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