Arcane Curiosities Vol.1 · alembic.js
蒸留器
48×48 の蒸留器で、小さな木の台の上、アルコールランプにかけた真鍮の輪に丸いガラスのフラスコが載り、銅の白鳥の首のような管が小さな円錐の受け瓶へ続いています。16 コマのループで、ランプが揺れ、中身が沸いて泡が弾け、湯気が首を昇り、1 ループに一滴が管の先でふくらんで受け瓶に落ち、液面に波紋が広がります。ガラスは輪郭線と強い光の筋で描き、空の部分からは床が透けて見えます。中身は階調の差し替えで緑・紫・青です。
6×
動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。