Grimoire lectern
A 48×64 carved dark-wood lectern with brass fittings; above it a grimoire floats open and bobs, one page lifts and turns over every loop, and three glyph motes orbit it, passing behind and in front of the book. The loop is 16 frames. The furniture is a handful of solid shapes seen through the fixed 3/4 camera, so each of the four facings (down, up, left, right) carries its own lighting; right is drawn, not mirrored from left. “idle-down-violet” and “idle-down-green” swap the glow ramp.
6×
Get the animated sprites: Arcane Curiosities Vol.1 on itch.io12 animated props · 52 animations
The header. The comment at the top of the file is the brief: size, view, light direction and every variant.
// Grimoire lectern — 48x64, top-down 3/4, key light top-left.// A carved dark-wood lectern with brass fittings; above it a grimoire floats open and bobs, one page lifts and// turns over every loop, and three glyph motes orbit it (passing behind and in front of the book).// Facings: down / up / left / right. The furniture is modelled as a handful of convex solids (boxes, octagonal// prisms) that are ray-cast per pixel through the fixed 3/4 camera and lit from the top-left, so every facing// carries its own lighting — right is drawn, not mirrored from left.// Loop: 16 frames @ 10 fps. Variants: idle-<facing>, plus idle-down-violet / idle-down-green (glow ramp swap).The last lines. submit declares the canvas and lists the variants the renderer exports.
submit(Lectern, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.6, loop: true }, anchor: { x: OX, y: GROUND }, variants: [ idle("down"), idle("up"), idle("left"), idle("right"), idle("down", ["violet", swap("glow", "v")]), idle("down", ["green", swap("glow", "e")]), ],});Show the whole program (386 lines)
// Grimoire lectern — 48x64, top-down 3/4, key light top-left.// A carved dark-wood lectern with brass fittings; above it a grimoire floats open and bobs, one page lifts and// turns over every loop, and three glyph motes orbit it (passing behind and in front of the book).// Facings: down / up / left / right. The furniture is modelled as a handful of convex solids (boxes, octagonal// prisms) that are ray-cast per pixel through the fixed 3/4 camera and lit from the top-left, so every facing// carries its own lighting — right is drawn, not mirrored from left.// Loop: 16 frames @ 10 fps. Variants: idle-<facing>, plus idle-down-violet / idle-down-green (glow ramp swap).const W = 48, H = 64, OX = 24, GY = 57; // GY: screen row of the footprint centre (world origin)const SE = 0.45, CE = Math.sqrt(1 - SE * SE); // camera elevation: top faces x0.45, vertical faces x0.89const DIR = [0, CE, -SE]; // ray direction, into the sceneconst LIGHT = (() => { const v = [-0.55, -0.3, 0.78], l = Math.hypot(...v); return v.map((c) => c / l); })();const AZ = { down: 0, up: 180, left: -90, right: 90 };// ---- tiny vector kit ------------------------------------------------------------------------------------const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];const mul = (a, k) => [a[0] * k, a[1] * k, a[2] * k];const norm = (a) => mul(a, 1 / Math.hypot(a[0], a[1], a[2]));const rotX = (v, a) => [v[0], v[1] * Math.cos(a) - v[2] * Math.sin(a), v[1] * Math.sin(a) + v[2] * Math.cos(a)];const rotY = (v, a) => [v[0] * Math.cos(a) + v[2] * Math.sin(a), v[1], -v[0] * Math.sin(a) + v[2] * Math.cos(a)];const rotZ = (v, a) => [v[0] * Math.cos(a) - v[1] * Math.sin(a), v[0] * Math.sin(a) + v[1] * Math.cos(a), v[2]];const DEG = Math.PI / 180;// A frame = origin + three unit axes (world). Solids are convex: a list of planes n·p <= d.function frame(o, ax) { return { o, ax }; }function xf(fr, p) { return add(fr.o, add(add(mul(fr.ax[0], p[0]), mul(fr.ax[1], p[1])), mul(fr.ax[2], p[2]))); }function sub(fr, o, ax) { return frame(xf(fr, o), ax.map((a) => norm(add(add(mul(fr.ax[0], a[0]), mul(fr.ax[1], a[1])), mul(fr.ax[2], a[2]))))); }function solid(part, mat, fr, h, opts) { const planes = []; for (let i = 0; i < 3; i++) for (const s of [-1, 1]) { const n = mul(fr.ax[i], s); planes.push({ n, d: dot(n, fr.o) + h[i], tag: (s > 0 ? "+" : "-") + "xyz"[i] }); } if (opts && opts.oct) { // octagonal prism around the local z axis (apothem = h[0]) for (const [a, b, tag] of [[1, 1, "d1"], [1, -1, "d2"]]) for (const s of [-1, 1]) { const n = mul(norm(add(mul(fr.ax[0], a), mul(fr.ax[1], b))), s); planes.push({ n, d: dot(n, fr.o) + h[0], tag: (s > 0 ? "+" : "-") + tag }); } } if (opts && opts.cut) for (const c of opts.cut) planes.push(c); return { part, mat, fr, h, planes, ...(opts || {}) };}function hit(sh, p0) { let sIn = -1e9, sOut = 1e9, pl = null; for (const p of sh.planes) { const nd = dot(p.n, DIR), np = dot(p.n, p0); if (Math.abs(nd) < 1e-9) { if (np > p.d) return null; continue; } const s = (p.d - np) / nd; if (nd < 0) { if (s > sIn) { sIn = s; pl = p; } } else if (s < sOut) sOut = s; } return sIn <= sOut && pl ? { s: sIn, sh, pl } : null;}function trace(scene, x, y) { const hh = GY - (y + 0.5), p0 = [x + 0.5 - OX, hh * SE, hh * CE]; let best = null; for (const sh of scene) { const r = hit(sh, p0); if (r && (!best || r.s < best.s)) best = r; } if (!best) return null; const P = add(p0, mul(DIR, best.s)); const q = [P[0] - best.sh.fr.o[0], P[1] - best.sh.fr.o[1], P[2] - best.sh.fr.o[2]]; best.loc = best.sh.fr.ax.map((a) => dot(q, a)); // hit point in the solid's own frame best.n = best.pl.n; best.k = dot(best.n, LIGHT); return best;}// ---- the lectern (local frame: reader stands at -y) ---------------------------------------------------------// A slant-top box on an octagonal column. The box is a little narrower at the back than at the front, so from the// front its reading slope is a lit trapezoid receding upward; below it sits a darker carved front face and a// book-rest ledge along the low edge. From behind you see the tall back panel with the lid hinges, from the side// the wedge profile.const TILT = 25 * DEG; // reading slopeconst BOX = { hxF: 15, hxB: 12.5, hy: 10.5, zb: 27.5, front: 4 }; // half-widths front/back, half-depth, bottom, front heightconst hxAt = (ly) => BOX.hxF + (BOX.hxB - BOX.hxF) * (ly + BOX.hy) / (2 * BOX.hy);const slopeV = (ly) => (ly + BOX.hy) / Math.cos(TILT); // distance up the slope from the front edgeconst VMAX = slopeV(BOX.hy);function slopePoint(x, v, h) { // lectern-local point: v up the slope, h off its surface return [x, -BOX.hy + v * Math.cos(TILT) - h * Math.sin(TILT), BOX.zb + BOX.front + v * Math.sin(TILT) + h * Math.cos(TILT)];}function convex(part, mat, fr, planes, opts) { const P = planes.map(([nx, ny, nz, d, tag]) => { const l = Math.hypot(nx, ny, nz); const n = norm(add(add(mul(fr.ax[0], nx), mul(fr.ax[1], ny)), mul(fr.ax[2], nz))); return { n, d: d / l + dot(n, fr.o), tag }; }); return { part, mat, fr, h: [0, 0, 0], planes: P, ...(opts || {}) };}function lecternScene(az) { const W0 = frame([0, 0, 0], [rotZ([1, 0, 0], az), rotZ([0, 1, 0], az), [0, 0, 1]]); const I3 = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; const at = (p, h, part, mat, opts) => solid(part, mat, sub(W0, p, I3), h, opts); const { hxF, hxB, hy, zb, front } = BOX, st = Math.sin(TILT), ct = Math.cos(TILT); const slope = sub(W0, [0, -hy, zb + front], [[1, 0, 0], rotX([0, 1, 0], TILT), rotX([0, 0, 1], TILT)]); // tapered sides: the plane through (hxF,-hy) and (hxB,+hy) const side = [2 * hy, hxF - hxB], sideD = side[0] * hxF - side[1] * hy; const colTop = zb - 1.5, colMid = (6 + colTop) / 2; return [ at([0, 0, 1.25], [9.5, 9.5, 1.25], "plinth", "wood", { oct: true, carve: "plinth" }), at([0, 0, 3.5], [7, 7, 1], "plinth", "wood", { oct: true }), at([0, 0, 5.2], [4.8, 4.8, 0.7], "column", "brass", { oct: true }), at([0, 0, colMid], [3.3, 3.3, (colTop - 6) / 2], "column", "wood", { oct: true, carve: "column" }), at([0, 0, 10], [3.9, 3.9, 0.6], "column", "brass", { oct: true }), at([0, 0, colTop - 3.5], [3.9, 3.9, 0.6], "column", "brass", { oct: true }), at([0, 0, zb - 1], [5.2, 5.2, 1], "column", "wood", { oct: true }), // the slant-top box convex("desk", "wood", W0, [ [side[0], side[1], 0, sideD, "+x"], [-side[0], side[1], 0, sideD, "-x"], [0, -1, 0, hy, "-y"], [0, 1, 0, hy, "+y"], [0, 0, -1, -zb, "-z"], [0, -st, ct, hy * st + (zb + front) * ct, "top"], ], { carve: "box" }), // a moulded lip round the foot of the box (follows the taper) convex("desk", "wood", W0, [ [side[0], side[1], 0, sideD + 0.6 * Math.hypot(...side), "+x"], [-side[0], side[1], 0, sideD + 0.6 * Math.hypot(...side), "-x"], [0, -1, 0, hy + 0.8, "-y"], [0, 1, 0, hy + 0.6, "+y"], [0, 0, -1, -(zb - 0.2), "-z"], [0, 0, 1, zb + 1, "+z"], ], { carve: "moulding" }), // book-rest ledge along the low edge of the slope, capped in brass solid("desk", "wood", sub(slope, [0, 0.9, 0.9], I3), [hxF - 0.8, 0.9, 0.9], { carve: "ledge" }), solid("desk", "brass", sub(slope, [0, 0.9, 2.05], I3), [hxF - 0.8, 1.0, 0.3]), ];}// ---- the grimoire (book frame: spine along local y, pages spread along x, page normal +z) --------------------// Front: it lies on the desk's slope (same tilt), hovering a few px over the ledge. Behind: held up steeper so its// boards show. Sides: turned three-quarters toward the camera so the open spread reads as a shallow wedge with its// page stack and board, rather than collapsing to an edge-on sliver.const BOOK = { hw: 5.1, hd: 4.5, stack: 2 }; // half page width, half page height, half stack thicknessconst HOVER = { v: 10.5, h: 2.4 }; // front view: centre up the slope, height off the slopeconst POSE = { down: { yaw: 0, tilt: 25, open: 12, c: slopePoint(0, HOVER.v, HOVER.h) }, up: { yaw: 0, tilt: 55, open: 10, c: [0, 2, 48.6] }, left: { yaw: 22, tilt: 25, open: 16, c: slopePoint(0, HOVER.v - 0.5, HOVER.h + 1) }, right: { yaw: -22, tilt: 25, open: 16, c: slopePoint(0, HOVER.v - 0.5, HOVER.h + 1) },};function bookFrame(f) { const P = POSE[f], az = AZ[f] * DEG, yw = P.yaw * DEG, tl = P.tilt * DEG; const W0 = frame([0, 0, 0], [rotZ([1, 0, 0], az), rotZ([0, 1, 0], az), [0, 0, 1]]); return sub(W0, P.c, [rotZ([1, 0, 0], yw), rotZ(rotX([0, 1, 0], tl), yw), rotZ(rotX([0, 0, 1], tl), yw)]);}const PAGE_TOP = 0.7 + 2 * BOOK.stack; // page surface above the spinefunction bookScene(f, turn) { const B = bookFrame(f), { hw, hd, stack } = BOOK, I3 = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; const S = []; for (const side of [-1, 1]) { const a = side * -POSE[f].open * DEG; // each half rises from the spine: a V const half = sub(B, [0, 0, 0], [rotY([1, 0, 0], a), [0, 1, 0], rotY([0, 0, 1], a)]); S.push(solid("book", "leather", sub(half, [side * (hw + 0.3), 0, 0.15], I3), [hw + 0.6, hd + 0.6, 0.55], { carve: "cover", side })); S.push(solid("book", "page", sub(half, [side * (hw - 0.15), 0, 0.7 + stack], I3), [hw - 0.3, hd - 0.1, stack], { carve: "block", side })); } S.push(solid("book", "leather", sub(B, [0, 0, -0.2], I3), [1.3, hd + 0.6, 0.7], { carve: "spine" })); if (turn !== null) { // the turning page: three thin segments hinged at the spine, the free end lagging mid-turn (a curl) let p = [0, 0, PAGE_TOP]; const seg = (2 * hw - 0.6) / 3; for (let k = 0; k < 3; k++) { const ang = turn.a - turn.lag * (k + 0.5) / 3; const u = [Math.cos(ang), 0, Math.sin(ang)], w = [-Math.sin(ang), 0, Math.cos(ang)]; const c = add(p, mul(u, seg / 2)); S.push(solid("page", "leaf", sub(B, c, [u, [0, 1, 0], w]), [seg / 2 + 0.35, hd - 0.3, 0.5], { carve: "leaf", k, seg })); p = add(p, mul(u, seg)); } } return S;}// ---- shading ----------------------------------------------------------------------------------------------const RAMPS = { wood: ["w0", "w0", "w1", "w2", "w3"], brass: ["g0", "g0", "g1", "g2", "g3"], leather: ["w0", "r0", "r0", "r1", "r1"], page: ["n2", "n3", "n4", "n5", "n5"], leaf: ["n2", "n3", "n4", "n5", "n5"],};const toneIdx = (k) => (k > 0.88 ? 4 : k > 0.42 ? 3 : k > 0.2 ? 2 : k > -0.15 ? 1 : 0);// one magical rhythm shared by the sigil, the motes and the glow on the desk: two swells per loopconst pulse = (t) => 0.5 + 0.5 * Math.cos(TAU * 2 * t);const VIEW = { text: "n3" }; // set per facing before tracingfunction textAt(gut, ly, hd, width, seed) { const row = Math.floor((hd - ly) / 1.25); if ((row & 1) !== 1 || gut < 1.5 || gut > width - 0.5 || ly < -hd + 0.7) return false; return hash(row * 7 + seed, Math.floor(gut / 2.3)) > 0.18;}// What is printed on a page: a glowing sigil on the left page, a red initial and text on the right.// gut = distance from the gutter across the page, ly = along the spine (+ = head of the page). null = bare paper.const PAGE = { hd: BOOK.hd - 0.1, w: 2 * BOOK.hw - 0.6 };function pageArt(side, gut, ly, t) { if (side < 0) { const d = Math.hypot(gut - PAGE.w / 2 - 0.2, ly - 0.5); if (d < 2.7) { if (d < 0.85) return pulse(t) > 0.5 ? "glow3" : "glow2"; return d > 1.75 ? "glow1" : null; // paper shows between the ring and the core } } else if (gut > 0.6 && gut < 3.2 && ly > PAGE.hd - 3.3 && ly < PAGE.hd - 0.9) return "r1"; return textAt(gut, ly, PAGE.hd, PAGE.w, side) ? "text" : null;}function surface(r, t) { // base tone from the normal, then countable marks from the solid's own coordinates let i = toneIdx(r.k); const [lx, ly, lz] = r.loc, sh = r.sh, tag = r.pl.tag, ramp = RAMPS[sh.mat]; if (sh.carve === "box") { const { hy, zb, front } = BOX, tn = Math.tan(TILT); const ztop = (y) => zb + front + (y + hy) * tn; const groove = (d) => d > 1.3 && d < 2.1; // a carved inset line this far in from an edge if (tag === "top") { // the reading slope: lit far edge, brass corner caps, grain lines, the ledge's shadow along its foot const v = slopeV(ly), ex = hxAt(ly) - Math.abs(lx); if (ex < 2.3 && v > VMAX - 2.3 && ex + (VMAX - v) < 2.9) return RAMPS.brass[Math.min(4, i + (lx < 0 ? 1 : 0))]; if (v > VMAX - 0.9 || (lx < 0 && ex < 0.9)) return ramp[Math.min(4, i + 1)]; if (v < 2.6) return ramp[Math.max(0, i - 1)]; if (Math.abs(v - 6.2) < 0.45 && lx > -11 && lx < 4) i = Math.max(0, i - 1); if (Math.abs(v - 12.8) < 0.45 && lx > -5 && lx < 9) i = Math.max(0, i - 1); if (Math.abs(v - 16.2) < 0.45 && lx > -9 && lx < -1) i = Math.max(0, i - 1); } else if (tag === "-y") { // the front face: a darker band carved with a row of small arches const u = ((lx + 60) % 3.4) / 3.4, zz = lz - zb; if (zz > 0.9 && zz < front - 0.7 && u > 0.2 && u < 0.8) return zz > front - 1.4 && (u < 0.32 || u > 0.68) ? ramp[i] : "w0"; if (zz >= front - 0.7) return ramp[Math.min(4, i + 1)]; } else if (tag === "+x" || tag === "-x") { // side panel: a carved inset following the wedge const d = Math.min(ly + hy, hy - ly, lz - zb, (ztop(ly) - lz) * Math.cos(TILT)); if (groove(d)) i = Math.max(0, i - 1); } else if (tag === "+y") { // back panel: two carved panels and the lid's brass hinges along the top if (lz > ztop(hy) - 1.6 && Math.abs(Math.abs(lx) - 6.5) < 1.3) return RAMPS.brass[Math.min(4, i + (lz > ztop(hy) - 0.8 ? 1 : 0))]; const d = Math.min(BOX.hxB - Math.abs(lx), Math.abs(lx) + 0.3, lz - zb, ztop(hy) - lz); if (groove(d)) i = Math.max(0, i - 1); } } if (sh.carve === "column" && Math.abs(lz) < 6 && !tag.endsWith("z")) { // a twisted flute winding up the shaft const f = ((lz * 0.5 + (tag.charCodeAt(1) + tag.charCodeAt(0)) * 0.37) % 1 + 1) % 1; if (f < 0.28) i = Math.max(0, i - 1); } if (sh.carve === "plinth" && tag !== "+z" && Math.abs(lz) < 0.35) i = Math.max(0, i - 1); // moulding line if (sh.carve === "cover" && tag === "-z") { // outside of the board: brass corners and a boss const ex = Math.abs(lx) > sh.h[0] - 2, ey = Math.abs(ly) > sh.h[1] - 2; if (ex && ey) return RAMPS.brass[Math.min(4, i + 1)]; if (Math.hypot(lx - sh.side * 0.3, ly) < 1.6) return RAMPS.brass[Math.min(4, i + 1)]; } if (sh.carve === "block" && tag !== "+z") { // the page stack: fine lines of page edges, a shade darker toward the board const layer = Math.round((lz + sh.h[2]) * 2); return layer <= 1 ? "n3" : (layer & 1) ? "n3" : "n4"; } if (sh.carve === "block" && tag === "+z") { const gut = lx * sh.side + sh.h[0]; // 0 at the gutter if (gut < 0.55) return ramp[Math.max(0, i - 2)]; // the spine crease if (gut < 1.3) return ramp[Math.max(0, i - 1)]; // pages curving down into it const art = pageArt(sh.side, gut, ly, t); if (art) return art === "text" ? VIEW.text : art; } if (sh.carve === "leaf") { // the turning leaf wears the right page's art on its face and the left page's on its back, so the spread // looks the same before and after the turn and the loop closes without a pop const art = pageArt(tag === "+z" ? 1 : -1, sh.k * sh.seg + lx + sh.seg / 2 - 0.15, ly, t); if (art) return art === "text" ? (i >= 3 ? VIEW.text : "n2") : art; } return ramp[i];}// Resolve one pixel of a scene: surface colour, plus crisp pixel-art edges found by casting the neighbours.function shadePixel(scene, x, y, t) { const r = trace(scene, x, y); if (!r) return null; const nb = [trace(scene, x, y - 1), trace(scene, x - 1, y), trace(scene, x, y + 1), trace(scene, x + 1, y)]; // occlusion contour: a nearer solid right next to this pixel -> an inner ink line on the far side for (const q of nb) if (q && q.sh !== r.sh && q.s < r.s - 2.2 && q.sh.part !== "page") return "ink"; let key = surface(r, t); if (key === null) key = RAMPS[r.sh.mat][toneIdx(r.k)]; // bevel: the brighter face of a convex edge gets a lit rim along it const ramp = RAMPS[r.sh.mat]; if (ramp.includes(key)) { const i = ramp.indexOf(key); for (const q of [nb[0], nb[1], nb[2]]) { if (q && q.sh === r.sh && q.pl !== r.pl && q.k < r.k - 0.25 && r.k > 0.3) return ramp[Math.min(4, i + 1)]; } const dn = nb[2]; if (dn && dn.sh === r.sh && dn.pl !== r.pl && dot(r.n, [0, 0, 1]) > 0.5 && r.k > 0.3) return ramp[Math.min(4, i + 1)]; } return key;}// ---- caches: the furniture is static per facing; the book only changes with the page turn --------------------const LECTERN = {};function lecternPixels(f) { if (LECTERN[f]) return LECTERN[f]; const scene = lecternScene(AZ[f] * DEG); const px = []; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const r = trace(scene, x, y); px.push(r ? { key: shadePixel(scene, x, y, 0), part: r.sh.part, top: r.pl.tag === "top" ? r.loc : null } : null); } return (LECTERN[f] = px);}function pageTurn(t, f) { // lift at t=0.12, over the top at ~0.45, down by 0.8; the free end lags while it swings const p = Math.min(1, Math.max(0, (t - 0.12) / 0.66)); if (p <= 0 || p >= 1) return null; const e = p * p * (3 - 2 * p); const a0 = POSE[f].open * DEG, a1 = Math.PI - a0; return { a: a0 + (a1 - a0) * e + 0.14 * (1 - 2 * e), lag: 0.9 * Math.sin(Math.PI * e) };}// ---- motes ----------------------------------------------------------------------------------------------function motes(g, f, t, bob) { // three glyph motes on a level ring around the book; a third of a turn per loop (they are alike, so it closes). // Depth: a mote on the far half of the ring sits behind the book (+1), on the near half in front of it (-1). // the ring is centred on the book, pulled toward the column axis when the book leans out over the ledge const bc = POSE[f].c, c = rotZ([bc[0], bc[1] * 0.4, bc[2]], AZ[f] * DEG), flare = pulse(t) > 0.75; for (let i = 0; i < 3; i++) { const ang = TAU * (t / 3 + i / 3) + 0.4; const wx = Math.cos(ang) * 16, wy = Math.sin(ang) * 16, wz = c[2] + 1.5 + 2 * Math.sin(2 * ang); const sx = Math.round(OX + c[0] + wx), sy = Math.round(GY - ((c[1] + wy) * SE + wz * CE)) + bob; const behind = wy > 0; const tx = -Math.sin(ang), ty = Math.cos(ang) * SE; // screen direction of travel g.depth(behind ? 1 : -1, () => g.detached("mote" + i, () => { // a short fading trail, the rune (a small cross), a bright core; they flare together on the pulse g.pixel(Math.round(sx - tx * 2.6), Math.round(sy + ty * 2.6), "glow1"); g.pixel(Math.round(sx - tx * 3.9), Math.round(sy + ty * 3.9), "glow0"); for (const [dx, dy] of [[0, -1], [0, 1], [-1, 0], [1, 0]]) g.pixel(sx + dx, sy + dy, behind ? "glow1" : "glow2"); g.pixel(sx, sy, behind ? "glow2" : "glow3"); if (!behind && flare) for (const [dx, dy] of [[0, -2], [0, 2], [-2, 0], [2, 0]]) g.pixel(sx + dx, sy + dy, "glow1"); })); }}// Magic light on the reading slope under the book: checker glow that swells with the pulse.function slopeGlow(f, t, bob) { const c = POSE[f].c; const v0 = slopeV(c[1]) - bob * 0.8, x0 = c[0], P = pulse(t); const ax = BOOK.hw * 1.5 + 1.2 * P, av = BOOK.hd + 1.6 + 1.2 * P; return (x, y, top) => { const dx = (top[0] - x0) / ax, dv = (slopeV(top[1]) - v0) / av, d = dx * dx + dv * dv; if (d > 1) return null; // an ordered, soft falloff: a checker core that brightens on the pulse, a sparse lattice at the rim if (d < 0.5) return chk(x, y) ? (P > 0.5 && (x & 1) === 0 ? "glow2" : "glow1") : null; return (x + 2 * y) % 4 === 0 ? "glow1" : null; };}// ---- sprite -----------------------------------------------------------------------------------------------const GROUND = 61; // the plinth's near edge: anchor + contact shadowconst BOOK_CACHE = {};const Lectern = sprite(pal, (g, t, f) => { // bob: one pixel up and down, highest just before the page lifts const bob = Math.round(1.2 * Math.sin(TAU * (t - 0.35))); contactShadow(g, OX, GROUND, 12, 2); const lp = lecternPixels(f), glow = slopeGlow(f, t, bob); g.outlined("ink", () => { for (const part of ["plinth", "column", "desk"]) { g.part(part, () => g.field({ x: [0, W - 1], y: [0, H - 1] }, (x, y) => { const p = lp[y * W + x]; if (!p || p.part !== part) return null; return (p.top && glow(x, y, p.top)) || p.key; })); } }); const turn = pageTurn(t, f); const key = f + ":" + Math.round(t * 160); if (!BOOK_CACHE[key]) { const scene = bookScene(f, turn); const px = []; for (let y = 0; y < 44; y++) for (let x = 0; x < W; x++) px.push(shadePixel(scene, x, y, t)); BOOK_CACHE[key] = px; } const bp = BOOK_CACHE[key]; g.detached("grimoire", () => g.outlined("ink", () => g.at(0, bob, () => { g.field({ x: [0, W - 1], y: [0, 43] }, (x, y) => bp[y * W + x]); }))); motes(g, f, t, bob);});const idle = (f, extra) => ({ name: `idle-${f}${extra ? "-" + extra[0] : ""}`, args: [f], ...(extra ? { repalette: extra[1] } : {}) });submit(Lectern, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.6, loop: true }, anchor: { x: OX, y: GROUND }, variants: [ idle("down"), idle("up"), idle("left"), idle("right"), idle("down", ["violet", swap("glow", "v")]), idle("down", ["green", swap("glow", "e")]), ],});These excerpts omit the boilerplate prepended by the asset pack build script: a single-line runtime header, the shared palette, and common helper utilities (contactShadow, litIndex, flame, hash, chk, TAU). The displayed line numbers match the original source files. Each program was authored by an AI coding agent and adjusted after visual inspection of the rendered frames.
Have your agent write programs like this.
Tessarune gives it the drawing API reference, 25 production manuals and a renderer to check its work against.