Tessarune
Arcane Curiosities Vol.1 · hourglass.js

砂時計

32×48 · JavaScript 251 行

32×48 の砂時計で、ろくろで挽いた木と真鍮の枠を回転体の立体として組んでいるので、置いた姿も、ひっくり返る途中のどの角度も同じ模型から出てきます。ガラスは透けていて、輪郭線と 1px の縁と光の点の奥に、砂や下のふた、床が見えます。idle(16 コマ)は砂の山は動かさず、落ちる砂の筋と、滑り込み転がる粒だけが動きます。flip(10 コマの一回再生)は跳ねて手前へ 180 度回り、砂が下になったほうへ移り、土ぼこりを上げて着地し、idle の最初のコマで終わります。

砂時計 — 描画結果6×
このプログラムで描画(変種「flip」) · 32×48 · 10 コマ
Arcane Curiosities Vol.1動くスプライト素材: Arcane Curiosities Vol.1(itch.io)動く小物 12 点・52 アニメ

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

hourglass.js · 1–9
// Hourglass in a turned wood-and-brass frame — 32x48, top-down 3/4, key light top-left.// The frame is modelled as solids of revolution (two turned caps, a two-bulb glass, two turned brass posts) and// ray-cast per pixel, so the same model gives the resting pose and every angle of the flip. The camera elevation// matches the pack (ellipse ratio 0.35). Glass is see-through: ink silhouette, a 1px glass rim, a specular glint,// and whatever lies behind it (sand, the lower cap) — or the floor.// idle: 16 frames @ 10 fps, loop. The heaps hold still (a timer mid-pour); the stream of grains, the grains sliding//   into the top funnel and the grains rolling down the bottom cone move, so the loop reads continuous.// flip: 10 frames, one-shot. A hop, a 180° end-over-end tumble toward the camera (the sand slides to whichever bulb//   is lower), landing with dust; the last frame is idle frame 0 (the frame is symmetric, the sand amounts swap).

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

hourglass.js · 244–251
submit(Hourglass, {  size: [32, 48], origin: "top-left", kind: "icon", scale: 1,  anchor: { x: 16, y: 45 },  variants: [    { name: "idle", args: ["idle"], animate: { fps: 10, duration: 1.6, loop: true } },    { name: "flip", args: ["flip"], animate: { fps: 10, duration: 1.0, loop: false } },  ],});
プログラム全体を表示 (251 行)
hourglass.js · 251 行
// Hourglass in a turned wood-and-brass frame — 32x48, top-down 3/4, key light top-left.// The frame is modelled as solids of revolution (two turned caps, a two-bulb glass, two turned brass posts) and// ray-cast per pixel, so the same model gives the resting pose and every angle of the flip. The camera elevation// matches the pack (ellipse ratio 0.35). Glass is see-through: ink silhouette, a 1px glass rim, a specular glint,// and whatever lies behind it (sand, the lower cap) — or the floor.// idle: 16 frames @ 10 fps, loop. The heaps hold still (a timer mid-pour); the stream of grains, the grains sliding//   into the top funnel and the grains rolling down the bottom cone move, so the loop reads continuous.// flip: 10 frames, one-shot. A hop, a 180° end-over-end tumble toward the camera (the sand slides to whichever bulb//   is lower), landing with dust; the last frame is idle frame 0 (the frame is symmetric, the sand amounts swap).const W = 32, H = 48, GROUND = 45;const ELEV = Math.asin(0.35);                    // camera elevationconst CXS = 16, CYS = 26;                        // screen position of the hourglass centre at restconst HC0 = 13.5, HC1 = 16.5, RC = 10.5;         // caps: inner face, outer face, radiusconst PR = 9.2;                                  // posts' distance from the axis (left and right)const LIGHT = [-0.5, 0.45, 0.74];                // view space: right, up, toward the cameraconst GLINT = (() => { const v = [-0.6, 0.55, 0.58], n = Math.hypot(...v); return v.map((c) => c / n); })();   // glass normal that mirrors the key lightconst LTOP = 4.4, LAPEX = -5.2;                  // idle sand: top level (h), bottom cone apex (h)const N_IDLE = 16, N_FLIP = 10;// glass radius along the axis (u = |h| / HC0): waist, two teardrop bulbs, necks into the capsconst PROFILE = [[0, 0.9], [0.08, 1.6], [0.2, 3.5], [0.35, 5.5], [0.5, 6.8], [0.65, 7.25], [0.78, 7.0], [0.88, 6.0], [0.95, 4.9], [1, 4.1]];function glassR(h) {  const u = Math.min(1, Math.abs(h) / HC0);  for (let i = 1; i < PROFILE.length; i++) {    const [u0, r0] = PROFILE[i - 1], [u1, r1] = PROFILE[i];    if (u <= u1) return r0 + ((r1 - r0) * (u - u0)) / (u1 - u0);  }  return PROFILE[PROFILE.length - 1][1];}function postR(h) {                              // turned post: a knob at the waist and at each foot  const a = Math.abs(h);  return a < 1.4 ? 1.55 : a > 11.4 ? 1.4 : 0.95;}// ---- sand ----------------------------------------------------------------------------------------------------function idleSand(h, z, X) {  const rho = Math.hypot(X, z);  if (h > 0) return h <= LTOP - 2.4 * Math.max(0, 1 - rho / 3.4);   // top: flat level with a funnel dip  return h <= LAPEX - rho * 0.85;                                   // bottom: a cone heap}// volume samples of one bulb (h > 0); the other bulb is its mirrorconst BULB = [];for (let h = 0.3; h < HC0; h += 0.6) for (let x = -7.5; x <= 7.5; x += 0.6) for (let z = -7.5; z <= 7.5; z += 0.6) {  if (x * x + z * z <= glassR(h) ** 2) BULB.push([h, z, x]);}const F_TOP = BULB.filter(([h, z, x]) => idleSand(h, z, x)).length / BULB.length;const F_BOT = BULB.filter(([h, z, x]) => idleSand(-h, z, x)).length / BULB.length;// level (world height) under which a fraction f of a bulb lies, for a frame tilted by thetafunction levelFor(sign, theta, f) {  const ct = Math.cos(theta), st = Math.sin(theta);  const ys = BULB.map(([h, z]) => sign * h * ct - z * st).sort((a, b) => a - b);  return ys[Math.min(ys.length - 1, Math.max(0, Math.floor(f * ys.length)))];}// ---- ray cast --------------------------------------------------------------------------------------------------// Screen (X right, Y down from the centre) + depth d (toward the camera) <-> object (x, h up the axis, z front)://   h = -Y cos(phi) + d sin(phi),  z = Y sin(phi) + d cos(phi),  phi = theta (tilt) + ELEV.function castRay(X, Y, cp, sp, sand) {  if (Math.abs(X) > RC + 0.6) return null;  const hA = -Y * cp, zA = Y * sp;  let glass = null;  for (let d = 21; d >= -21; d -= 0.25) {    const h = hA + d * sp, z = zA + d * cp, ah = Math.abs(h);    if (ah > HC1) continue;    const rr = X * X + z * z;    if (ah >= HC0) {      if (rr > RC * RC) continue;      const dOut = HC1 - ah, dIn = ah - HC0, dSide = RC - Math.sqrt(rr);      let n;      if (dOut <= dSide && dOut <= dIn) n = [0, Math.sign(h), 0];      else if (dIn <= dSide) n = [0, -Math.sign(h), 0];      else { const r = Math.sqrt(rr); n = [X / r, 0, z / r]; }      return { part: "cap", d, h, z, X, n, glass };    }    for (const s of [-1, 1]) {      const px = X - s * PR, pr = postR(h);      if (px * px + z * z <= pr * pr) { const r = Math.hypot(px, z) || 1; return { part: "post", d, h, z, X, n: [px / r, 0, z / r], glass }; }    }    const gr = glassR(h);    if (rr <= gr * gr) {      if (!glass) glass = { d, h, z };      const top = sand(h, z, X);      if (top) {        const r = Math.sqrt(rr) || 1, wall = gr - r < 0.7;        return { part: "sand", d, h, z, X, n: wall ? [X / r, 0, z / r] : top, glass };      }    }  }  return glass ? { part: "glass", d: glass.d, glass } : null;}const toView = (n, cp, sp) => [n[0], n[1] * cp - n[2] * sp, n[1] * sp + n[2] * cp];const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];function capTone(hit, dif, sp) {  const ah = Math.abs(hit.h), rho = Math.hypot(hit.X, hit.z);  const face = hit.n[0] === 0 && hit.n[2] === 0;  if (!face && ah > HC0 + 1.1 && ah < HC0 + 1.9) return dif > 0.75 ? "g3" : dif > 0.3 ? "g2" : dif > -0.1 ? "g1" : "g0";  // brass inlay  let k = dif > 0.8 ? 4 : dif > 0.58 ? 3 : dif > 0.3 ? 2 : dif > -0.05 ? 1 : 0;  if (face) {    const outer = ah > HC1 - 0.5;    if (rho > RC - 0.9) k = -hit.X - hit.z > 0 ? 4 : 2;                           // turned bevel catches the light    else if (outer && rho > 1) {                                                    // one turned groove, 1px on screen      const c = hit.X / rho, s2 = hit.z / rho, f = Math.sqrt(c * c + s2 * s2 * sp * sp);      if (Math.abs(rho - 6.6) < 0.55 / Math.max(0.3, f)) k = Math.max(0, k - 1);    } else if (!outer && rho > 4.1 && rho < 5.1) return "g2";                         // brass seat round the glass  }  return ["w0", "w1", "w2", "w3", "w4"][k];}// Render one pose to a per-pixel {key, part} buffer.function renderPose(theta, hop, sand, squash) {  const phi = theta + ELEV, cp = Math.cos(phi), sp = Math.sin(phi);  const hits = new Array(W * H).fill(null);  const bottomY = CYS + 19.1;                     // squash about the ground contact  for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {    let Y = y + 0.5 - CYS + hop;    if (squash) Y = (y + 0.5 - bottomY) / squash + bottomY - CYS + hop;    hits[y * W + x] = castRay(x + 0.5 - CXS, Y, cp, sp, sand);  }  const at = (x, y) => (x < 0 || y < 0 || x >= W || y >= H ? null : hits[y * W + x]);  const opaque = (h) => h && h.part !== "glass";  const out = new Array(W * H).fill(null);  for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {    const hit = at(x, y), nb = [at(x - 1, y), at(x + 1, y), at(x, y - 1), at(x, y + 1)];    let key = null, part = null;    if (!hit) {      if (nb.some((n) => n)) { key = "ink"; part = "outline"; }                    // silhouette    } else {      // an outline grows from every nearer solid (not sand) onto what lies behind it, glass included      const front = nb.find((n) => opaque(n) && n.part !== "sand" && n.d > hit.d + 0.9 && (!opaque(hit) || n.part !== hit.part));      if (front) { key = "ink"; part = "outline"; }      else if (hit.part === "cap" || hit.part === "post" || hit.part === "sand") {        const dif = dot(toView(hit.n, cp, sp), LIGHT);        part = hit.part;        if (hit.part === "cap") key = capTone(hit, dif, Math.abs(sp));        else if (hit.part === "post") key = dif > 0.78 ? "g3" : dif > 0.3 ? "g2" : dif > -0.1 ? "g1" : "g0";        else if (hit.n[1] !== 0 && theta === 0 && hit.h > 0) {          if (hit.h < LTOP - 0.5) key = hit.z < 0 ? "g2" : "g1";                   // the funnel dip: far wall lit          else key = hit.X < -1.5 ? "g3" : "g2";                                    // top level, lit on the left        }        else key = dif > 0.8 ? "g3" : dif > 0.42 ? "g2" : "g1";      } else part = "glass";      // glass in front of this pixel: 1px rim just inside the silhouette, then the specular glint      if (key !== "ink" && hit.glass && (!opaque(hit) || hit.glass.d > hit.d)) {        const rimIdx = [0, 1, 2, 3].find((i) => !nb[i]);        if (rimIdx !== undefined) { key = rimIdx === 0 || rimIdx === 2 ? "n4" : "n2"; part = "glass"; }        else {          const { h, z } = hit.glass, X = x + 0.5 - CXS, gr = glassR(h);          const slope = (glassR(h + 0.3) - glassR(h - 0.3)) / 0.6;          const nv = toView([X / gr, -slope, z / gr], cp, sp), len = Math.hypot(...nv);          const s = dot(nv, GLINT) / len;          if (s > 0.975) { key = "n5"; part = "glass"; } else if (s > 0.94) { key = "b3"; part = "glass"; }        }      }    }    out[y * W + x] = key ? { key, part } : null;  }  return out;}// ---- poses ------------------------------------------------------------------------------------------------------const CACHE = {};const IDLE_POSE = () => CACHE.idle || (CACHE.idle = renderPose(0, 0, (h, z, X) => (idleSand(h, z, X) ? (h > 0 ? [0, 1, 0] : sandConeN(h, z, X)) : null)));function sandConeN(h, z, X) {                     // normal of the bottom cone (tilted out from the axis)  const rho = Math.hypot(X, z) || 1;  const n = [(X / rho) * 0.85, 1, (z / rho) * 0.85], l = Math.hypot(...n);  return n.map((c) => c / l);}// flip keyframes: tilt (deg), hop (px), squashconst FLIP = [[0, 0, 0], [12, 2, 0], [48, 6, 0], [92, 8, 0], [136, 6, 0], [170, 3, 0], [180, 0, 0.93], [180, 1, 0], [180, 0, 0], [180, 0, 0]];function flipPose(f) {  if (CACHE["f" + f]) return CACHE["f" + f];  const [deg, hop, squash] = FLIP[f];  let buf;  if (deg === 0 || deg === 180) {    buf = squash ? renderPose(0, 0, (h, z, X) => (idleSand(h, z, X) ? (h > 0 ? [0, 1, 0] : sandConeN(h, z, X)) : null), squash) : IDLE_POSE();  } else {    const th = (deg * Math.PI) / 180, k = deg / 180;    const la = levelFor(1, th, F_TOP + (F_BOT - F_TOP) * k), lb = levelFor(-1, th, F_BOT + (F_TOP - F_BOT) * k);    const ct = Math.cos(th), st = Math.sin(th), up = [0, ct, -st];              // world up in object space    buf = renderPose(th, hop, (h, z) => ((h * ct - z * st) <= (h > 0 ? la : lb) ? up : null));  }  return (CACHE["f" + f] = buf);}function drawPose(g, buf) {  for (const part of ["cap", "post", "glass", "sand", "outline"]) {    g.part(part === "outline" ? "outline" : part, () => g.field({ x: [0, W - 1], y: [0, H - 1] }, (x, y) => {      const c = buf[y * W + x];      return c && c.part === part ? c.key : null;    }));  }}// ---- idle motion: the stream, grains sliding into the funnel, grains rolling down the cone -----------------------// Pixel positions are read off the resting pose (waist x15-16 rows 25-26, cone apex (15,31), top level row 21).// Every cycle is 4 frames long, so the 16-frame loop closes exactly.function grains(g, t) {  const f = Math.round(t * N_IDLE) % N_IDLE, k = f % 4;  g.part("funnel", () => { g.pixel(14, 21, "g1"); g.pixel(15, 21, "g1"); g.pixel(15, 20, "g3"); });  g.part("stream", () => {    for (let y = 25; y <= 30; y++) g.pixel(15, y, (y - f + 16) % 4 === 0 ? "g1" : "g3");   // dark grains fall 1px/frame  });  g.part("rolling", () => {    const L = [[14, 32], [13, 33], [12, 33], [11, 34]], R = [[17, 32], [18, 33], [19, 33], [20, 34]];    const a = L[k], b = R[(k + 2) % 4];    g.pixel(a[0], a[1], "g2"); g.pixel(b[0], b[1], "g3");                      // grains rolling down both flanks    const T = [[12, 21], [13, 21], [13, 21], [14, 21]], U = [[18, 21], [17, 21], [16, 21], [16, 21]];    g.pixel(T[k][0], T[k][1], "g2"); g.pixel(U[(k + 1) % 4][0], U[(k + 1) % 4][1], "g3");   // sliding into the funnel  });  g.detached("bounce", () => {    if (k === 0) g.pixel(14, 30, "g3"); else if (k === 2) g.pixel(16, 30, "g2");   // grains kicked off the apex  });}function dust(g, f) {  g.detached("dust", () => {    const k = f - 6;    if (k < 0 || k > 2) return;    for (const s of [-1, 1]) {      const x = CXS + s * (12 + k * 2) - (s > 0 ? 1 : 0), y = GROUND - 1 - k;      g.pixel(x, y, ["n4", "n3", "n2"][k]);      if (k < 2) g.pixel(x + s, y + 1, "n3");    }  });}const Hourglass = sprite(pal, (g, t, mode) => {  if (mode === "flip") {    const f = Math.min(N_FLIP - 1, Math.round(t * N_FLIP));    const [deg, hop] = FLIP[f];    contactShadow(g, CXS, GROUND, 12 - Math.min(4, hop), 2);    drawPose(g, flipPose(f));    if (deg === 0 || deg === 180) grains(g, 0);                     // starts on and hands back to idle frame 0    dust(g, f);    return;  }  contactShadow(g, CXS, GROUND, 12, 2);  drawPose(g, IDLE_POSE());  grains(g, t);});submit(Hourglass, {  size: [32, 48], origin: "top-left", kind: "icon", scale: 1,  anchor: { x: 16, y: 45 },  variants: [    { name: "idle", args: ["idle"], animate: { fps: 10, duration: 1.6, loop: true } },    { name: "flip", args: ["flip"], animate: { fps: 10, duration: 1.0, loop: false } },  ],});

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

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

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