Tessarune
Arcane Curiosities Vol.1 · grimoire_lectern.js

魔導書の書見台

48×64 · JavaScript 386 行

48×64 の、真鍮の金具を付けた濃い色の木の彫刻の書見台です。その上で魔導書が開いたまま浮かんで上下し、ループごとに 1 ページが持ち上がってめくれ、3 つの文字の光の粒が本の奥と手前を通って周りを回ります。ループは 16 コマです。台はいくつかの立体で組み、固定の 3/4 視点から見て描くので、4 方向(down、up、left、right)はそれぞれに光が当たり、右向きは左向きの裏返しではありません。idle-down-violet と idle-down-green は光の階調を差し替えたものです。

魔導書の書見台 — 描画結果6×
このプログラムで描画(変種「idle-down」) · 48×64 · 16 コマ
Arcane Curiosities Vol.1動くスプライト素材: Arcane Curiosities Vol.1(itch.io)動く小物 12 点・52 アニメ

冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。

grimoire_lectern.js · 1–7
// 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).

最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。

grimoire_lectern.js · 377–386
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")]),  ],});
プログラム全体を表示 (386 行)
grimoire_lectern.js · 386 行
// 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")]),  ],});

掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。

エージェントに、こうしたプログラムを書かせる。

Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。