Tessarune
Arcane Curiosities Vol.1 · alembic.js

蒸留器

48×48 · JavaScript 222 行

48×48 の蒸留器で、小さな木の台の上、アルコールランプにかけた真鍮の輪に丸いガラスのフラスコが載り、銅の白鳥の首のような管が小さな円錐の受け瓶へ続いています。16 コマのループで、ランプが揺れ、中身が沸いて泡が弾け、湯気が首を昇り、1 ループに一滴が管の先でふくらんで受け瓶に落ち、液面に波紋が広がります。ガラスは輪郭線と強い光の筋で描き、空の部分からは床が透けて見えます。中身は階調の差し替えで緑・紫・青です。

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

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

alembic.js · 1–7
// Alembic (distillation set) on a small wooden stand — 48x48, top-down 3/4, key light top-left.// A round glass flask of brew sits in a brass ring over a spirit lamp; vapour climbs the neck into a copper swan-neck// tube that drips the distillate into a small conical receiving vial.// Loop: 16 frames @ 10 fps. The lamp flickers, the brew boils (bubbles rise and pop), vapour rises into the neck,// and each loop one drop swells at the spout, falls through the vial's neck and ripples the distillate.// Glass language: ink rim, a hard highlight streak, the floor shows through wherever the glass is empty.// Variants: brew colour green / violet / blue (ramp swap — no new colours).

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

alembic.js · 213–222
submit(Alembic, {  size: [48, 48], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.6, loop: true },  anchor: { x: 24, y: 45 },  variants: [    { name: "idle-green" },    { name: "idle-violet", repalette: swap("brew", "v") },    { name: "idle-blue", repalette: swap("brew", "b") },  ],});
プログラム全体を表示 (222 行)
alembic.js · 222 行
// Alembic (distillation set) on a small wooden stand — 48x48, top-down 3/4, key light top-left.// A round glass flask of brew sits in a brass ring over a spirit lamp; vapour climbs the neck into a copper swan-neck// tube that drips the distillate into a small conical receiving vial.// Loop: 16 frames @ 10 fps. The lamp flickers, the brew boils (bubbles rise and pop), vapour rises into the neck,// and each loop one drop swells at the spout, falls through the vial's neck and ripples the distillate.// Glass language: ink rim, a hard highlight streak, the floor shows through wherever the glass is empty.// Variants: brew colour green / violet / blue (ramp swap — no new colours).const CX = 24, GROUND = 45;const BULB = { cx: 15.5, cy: 20.5, r: 9 };                 // flask bulb (centre on pixel 15)const LEVEL = BULB.cy + 1;                                // brew surface lineconst NECK = { x0: 13, x1: 17, top: 8 };                  // ink columns x0 / x1, glass betweenconst VIAL = { xc: 37.5, rim: 26, bottom: 40, level: 35 };const SPOUT = [37, 21];                                   // last pixel of the tube; the drop hangs below itconst N = 16;                                             // frames per loopconst frameOf = (t) => Math.round(t * N) % N;// ---- glass helpers ---------------------------------------------------------------------------------------const inBulb = (x, y) => Math.hypot(x + 0.5 - BULB.cx, y + 0.5 - BULB.cy) <= BULB.r;const inNeck = (x, y) => x >= NECK.x0 && x <= NECK.x1 && y >= NECK.top && y <= BULB.cy;const inFlask = (x, y) => inBulb(x, y) || inNeck(x, y);const vialHalf = (y) => {  if (y === VIAL.rim) return 3.1;                         // flared lip  if (y <= 30) return 2.1;                                // neck  if (y >= VIAL.bottom) return 4.6;                       // rounded foot  return 2.6 + ((y - 31) / (VIAL.bottom - 1 - 31)) * 2.5; // cone};const inVial = (x, y) => y >= VIAL.rim && y <= VIAL.bottom && Math.abs(x + 0.5 - VIAL.xc) <= vialHalf(y);const rimOf = (inside, x, y) => !inside(x + 1, y) || !inside(x - 1, y) || !inside(x, y + 1) || !inside(x, y - 1);// Glass edge: ink silhouette, then a 1px inner rim of glass (lit on the top-left, neutral elsewhere, so it never// merges with the brew colour of any variant) so the// vessel keeps its shape on a dark floor while the middle stays see-through.function glassEdge(inside, x, y, litTest) {  if (rimOf(inside, x, y)) return "ink";  for (const [ox, oy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {    if (!rimOf(inside, x + ox, y + oy)) continue;    return litTest(ox, oy) ? "n4" : "n2";  }  return null;}function flask(g, t) {  const { cx, cy, r } = BULB;  const rs = Math.sqrt(r * r - (LEVEL - cy) ** 2) - 1.2, rys = 2;   // brew surface ellipse  const inSurface = (px, py) => ((px - cx) / rs) ** 2 + ((py - LEVEL) / rys) ** 2 <= 1;  const glowOn = Math.sin(TAU * t * 2) > 0;                          // lamp light through the brew  g.part("flask", () => g.field({ x: [cx - r - 1, cx + r + 1], y: [NECK.top, cy + r + 1] }, (x, y) => {    if (!inFlask(x, y)) return null;    const px = x + 0.5, py = y + 0.5, dx = px - cx, dy = py - cy, d = Math.hypot(dx, dy);    const brew = py > LEVEL || inSurface(px, py);    const edge = glassEdge(inFlask, x, y, (ox, oy) => (brew ? ox < 0 : ox < 0 || oy < 0));    if (edge === "ink") return edge;    const ang = Math.atan2(dy, dx);    const streak = d > r - 3.4 && d < r - 1.8 && ang < -1.8 && ang > -2.7;          // upper-left highlight arc    if (brew) {      if (edge) return edge === "n4" ? "brew2" : "brew0";                             // glass wall seen through brew      if (inSurface(px, py)) return dx > -6 && dx < -2.5 && Math.abs(py - LEVEL - 0.5) < 0.5 ? "brew3" : "brew2";  // lit surface + glint      if (Math.abs(dx) < 2 && dy > r - 3) return glowOn ? "brew3" : "brew2";         // lamp glow at the bottom      if (dx < -r + 3 && dy < 5) return "brew2";      if (dx > 1.5 && d > r - 3) return "brew0";                                      // far side, in shadow      return "brew1";    }    // empty glass: see-through, only the rim and the highlight    if (streak) return d > r - 2.6 && ang < -2.05 && ang > -2.45 ? "n5" : "b3";    if (edge) return edge;    return null;  }));  g.part("flaskGlint", () => { g.pixel(19, 26, "brew3"); g.pixel(20, 25, "brew2"); });   // inner reflection}function vial(g, t) {  const f = frameOf(t);  const ripple = f >= 12 && f <= 14 ? f - 11 : 0;  g.part("vial", () => g.field({ x: [31, 44], y: [VIAL.rim, VIAL.bottom] }, (x, y) => {    if (!inVial(x, y)) return null;    const px = x + 0.5, dx = px - VIAL.xc, hw = vialHalf(y);    if (y === VIAL.rim && Math.abs(dx) < 2.5 && !rimOf(inVial, x, y - 1) && inVial(x, y)) { /* lip */ }    const edge = glassEdge(inVial, x, y, (ox, oy) => ox < 0 || oy < 0);    if (edge === "ink") return y === VIAL.rim && Math.abs(dx) < 2.6 ? "b3" : "ink";   // lit lip of the mouth    if (y >= VIAL.level) {      if (edge) return edge === "n4" ? "brew3" : "brew0";      if (y === VIAL.level) {        if (ripple && Math.abs(Math.abs(dx) - ripple) < 0.6) return "brew3";            // ripple ring        return "brew3";      }      return dx < -hw + 2.5 ? "brew2" : dx > hw - 2.5 ? "brew1" : "brew2";    }    if (dx < -hw + 2.2 && dx > -hw + 1.2 && y > 30 && y < 34) return y === 32 ? "n5" : "b3";   // highlight    return edge;  }));}function drop(g, t) {  const f = frameOf(t), [sx, sy] = SPOUT;  g.detached("drop", () => {    if (f <= 2) g.pixel(sx, sy + 1, "brew2");                                   // a bead forms...    else if (f <= 5) { g.pixel(sx, sy + 1, "brew2"); g.pixel(sx, sy + 2, "brew1"); }    else if (f <= 8) { g.pixel(sx, sy + 1, "brew3"); g.pixel(sx, sy + 2, "brew2"); g.pixel(sx, sy + 3, "brew1"); }    else if (f <= 11) {                                                          // ...lets go and falls      const y = [sy + 5, sy + 8, sy + 12][f - 9];      g.pixel(sx, y, "brew3"); g.pixel(sx, y + 1, "brew2");    } else if (f === 12) { g.pixel(sx - 1, VIAL.level - 1, "brew3"); g.pixel(sx + 1, VIAL.level - 1, "brew3"); g.pixel(sx, VIAL.level - 2, "brew3"); }  });}function boil(g, t) {  const { cx, cy } = BULB;  g.detached("bubbles", () => {    for (let i = 0; i < 3; i++) {      const p = (t * 2 + i / 3) % 1;      const x = Math.round(cx - 0.5 + [-2, 1, 3][i] + Math.sin(TAU * (p + i * 0.3)) * 0.8);      const y = Math.round(cy + 6 - p * 6.5);      if (p < 0.8) {        g.pixel(x, y, "brew3");        if (p > 0.35) g.pixel(x + 1, y, "brew2");      } else if (p < 0.92) { g.pixel(x - 1, Math.floor(LEVEL) - 1, "brew3"); g.pixel(x + 1, Math.floor(LEVEL) - 1, "brew3"); }    }  });  g.detached("vapour", () => {    for (let i = 0; i < 2; i++) {      const p = (t + i / 2) % 1;      const x0 = cx - 0.5 + (i ? 3 : -3);      const x = Math.round(x0 + (cx - 0.5 - x0) * Math.min(1, p * 1.6));      const y = Math.round(LEVEL - 2 - p * 13);      if (p < 0.4) { g.pixel(x, y, "brew3"); g.pixel(x, y + 1, "brew2"); }      else if (p < 0.8) g.pixel(x, y, "brew2");      else g.pixel(x, y, "brew1");    }  });}function board(g) {  g.part("board", () => {    g.rect({ x: [3, 44], y: [38, 40] }, "w3", { fill: "w3" });           // top face    g.line([3, 41], [44, 41], "w4");                                     // lit front edge    g.rect({ x: [3, 44], y: [42, 43] }, "w2", { fill: "w2" });           // front face    g.line([3, 44], [44, 44], "w1");    g.line([6, 39], [11, 39], "w2"); g.line([27, 38], [31, 38], "w2"); g.line([19, 40], [24, 40], "w2");   // grain    g.line([13, 43], [18, 43], "w1"); g.pixel(34, 42, "w1"); g.line([38, 43], [41, 43], "w1");  });  g.part("feet", () => {    g.rect({ x: [4, 7], y: [45, 45] }, "w0", { fill: "w0" });    g.rect({ x: [40, 43], y: [45, 45] }, "w0", { fill: "w0" });  });}function lamp(g) {  const cx = 15.5, cy = 37.5, rx = 4.6, ry = 2.2;  g.part("lamp", () => {    g.field({ x: [10, 21], y: [35, 40] }, (x, y) => {      const i = litIndex(x, y, cx, cy, rx, ry, 4, -0.1);      return i < 0 ? null : ["g0", "g1", "g1", "g2"][i];    });    g.pixel(12, 36, "g3"); g.pixel(13, 36, "g3");                        // specular    g.line([14, 35], [16, 35], "n2");                                    // wick collar    g.pixel(14, 35, "n4");  });}const RING = { cy: 27, rx: 5.8, ry: 1.7 };                 // support ring under the bulbfunction ringStand(g) {  g.part("standLegs", () => {                                // splayed tripod legs (the third hides behind the lamp)    g.line([9, 28], [5, 39], "g2");    g.line([22, 28], [26, 39], "g1");  });}function ring(g) {  // front arc of the ring the flask rests in; its ends turn back behind the glass  g.part("ring", () => {    for (let x = Math.floor(BULB.cx - RING.rx); x <= Math.ceil(BULB.cx + RING.rx) - 1; x++) {      const u = (x + 0.5 - BULB.cx) / RING.rx, k = Math.sqrt(Math.max(0, 1 - u * u)) * RING.ry;      const y = Math.round(RING.cy + k);      g.pixel(x, y, u < -0.3 ? "g3" : u < 0.4 ? "g2" : "g1");      g.pixel(x, y + 1, "ink");    }  });}function collar(g) {  g.part("collar", () => {    g.rect({ x: [12, 18], y: [6, 8] }, "g1", { fill: "g1" });    g.line([12, 6], [18, 6], "g2"); g.line([12, 7], [13, 7], "g3"); g.pixel(18, 8, "g0");  });}function tube(g) {  g.part("tube", () => {    g.sweep([[17, 6], [22, 3], [29, 3], [34, 7], [36.5, 13], [37, 20]], { w0: 1.5, w1: 1.1 },      (u, v) => {        const w = u < 0.62 ? v : -v;                                     // light comes from the top-left        return w < -0.35 ? "w4" : w > 0.45 ? "w1" : "w3";      });    g.rect({ x: [36, 38], y: [20, 21] }, "g2", { fill: "g2" });          // brass spout    g.pixel(36, 20, "g3");  });}function fire(g, t) {  g.part("flame", () => flame(g, 15, 34, 5, 6, t, 0.3, ["fl0", "fl1", "fl2", "fl3"]));}const Alembic = sprite(pal, (g, t) => {  contactShadow(g, CX, GROUND, 20, 1);  g.outlined("ink", () => { board(g); ringStand(g); lamp(g); });  flask(g, t);  boil(g, t);  ring(g);  g.outlined("ink", () => { fire(g, t); collar(g); tube(g); });  vial(g, t);  drop(g, t);});submit(Alembic, {  size: [48, 48], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.6, loop: true },  anchor: { x: 24, y: 45 },  variants: [    { name: "idle-green" },    { name: "idle-violet", repalette: swap("brew", "v") },    { name: "idle-blue", repalette: swap("brew", "b") },  ],});

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

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

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