Arcane Curiosities Vol.1 · cauldron.js
大釜
48×48 の床置きの小物で、薪の火にかけた大釜です。見下ろし 3/4 視点、左上からの光です。10 fps・12 コマのループで、泡がふくらんで弾け、中身が渦を巻き、紋が脈打ち、湯気が漂い、火が揺れます。緑・紫・青の中身は、同じループの階調を一つ差し替えただけで、新しい色は足していません。
6×
動くスプライト素材: Arcane Curiosities Vol.1(itch.io)動く小物 12 点・52 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
cauldron.js · 1–3
// Cauldron over a wood fire — 48x48, top-down 3/4, key light top-left.// Loop: 12 frames @ 10 fps. Bubbles swell and pop, the brew swirls, the sigil pulses, steam drifts, fire flickers.// Variants: brew colour green / violet / blue (ramp swap — no new colours).最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
cauldron.js · 193–202
submit(Cauldron, { size: [48, 48], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, 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") }, ],});プログラム全体を表示 (202 行)
cauldron.js · 202 行
// Cauldron over a wood fire — 48x48, top-down 3/4, key light top-left.// Loop: 12 frames @ 10 fps. Bubbles swell and pop, the brew swirls, the sigil pulses, steam drifts, fire flickers.// Variants: brew colour green / violet / blue (ramp swap — no new colours).const CX = 24, GROUND = 45;const BODY = { cx: 24, cy: 29, rx: 16, ry: 12 };const LIP = { cy: 16, rx: 17, ry: 6 };const MOUTH = { cy: 16, rx: 14, ry: 4 };const IRON = ["n0", "n1", "n2", "n3"];function edgeDist(x, y, cx, cy, rx, ry) { // approximate distance (px) from the ellipse boundary, inside only const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry; const r = Math.sqrt(nx * nx + ny * ny); return (1 - r) * Math.min(rx, ry) * 1.15;}function body(g, t) { const { cx, cy, rx, ry } = BODY; g.field({ x: [cx - rx, cx + rx], y: [cy - ry, cy + ry] }, (x, y) => { const i = litIndex(x, y, cx, cy, rx, ry, 4, -0.05); if (i < 0) return null; const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry; // warm rim light from the fire along the underside if (ny > 0.5 && Math.abs(nx) < 0.55 && edgeDist(x, y, cx, cy, rx, ry) < 1.2) return Math.abs(nx) < 0.3 ? "r1" : "r0"; // soft specular: a short curved highlight on the upper-left shoulder const sx = nx + 0.5, sy = ny + 0.28; if (sx * sx * 3 + sy * sy * 9 < 0.07) return "n4"; return IRON[i]; });}function band(g, t) { // riveted iron band following the belly's curvature, with a pulsing sigil plate at the front const { cx, cy, rx } = BODY; const yAt = (x) => { const u = (x + 0.5 - cx) / (rx - 0.5); return Math.round(cy - 3 + (1 - Math.sqrt(Math.max(0, 1 - u * u))) * -3); }; g.part("band", () => { for (let x = cx - rx + 1; x <= cx + rx - 1; x++) { const y = yAt(x), u = (x - cx) / rx; g.pixel(x, y, u < -0.3 ? "n3" : u < 0.5 ? "n2" : "n1"); g.pixel(x, y + 1, "n0"); } for (const u of [-0.8, -0.45, 0.45, 0.8]) { const x = Math.round(cx + u * (rx - 1)); g.pixel(x, yAt(x) + 1, u < 0 ? "n4" : "n3"); } }); const pulse = 0.5 + 0.5 * Math.cos(TAU * t); const hot = pulse > 0.66 ? "brew3" : pulse > 0.33 ? "brew2" : "brew1"; const y0 = yAt(cx) - 1; g.part("sigil", () => { g.fillPoly([[cx, y0 - 1], [cx + 3, y0 + 2], [cx, y0 + 5], [cx - 3, y0 + 2]], "n1"); g.poly([[cx, y0 - 1], [cx + 3, y0 + 2], [cx, y0 + 5], [cx - 3, y0 + 2], [cx, y0 - 1]], "g1"); g.pixel(cx - 1, y0, "g2"); g.pixel(cx - 2, y0 + 1, "g2"); g.pixel(cx, y0 + 1, "brew1"); g.pixel(cx - 1, y0 + 2, "brew1"); g.pixel(cx + 1, y0 + 2, "brew1"); g.pixel(cx, y0 + 3, "brew1"); g.pixel(cx, y0 + 2, hot); if (pulse > 0.5) { g.pixel(cx, y0 + 1, "brew2"); g.pixel(cx, y0 + 3, "brew2"); } });}function lip(g) { const { cy, rx, ry } = LIP; g.part("lip", () => { // outer thickness (seen on the near side) then the lit top face g.ellipse({ x: [CX - rx, CX + rx], y: [cy - ry + 1, cy + ry] }, "n1", { fill: "n1" }); g.field({ x: [CX - rx, CX + rx], y: [cy - ry, cy + ry - 1] }, (x, y) => { const nx = (x + 0.5 - CX) / rx, ny = (y + 0.5 - (cy - 0.5)) / (ry - 0.5); if (nx * nx + ny * ny > 1) return null; const a = -0.7 * nx - 0.3 * ny; // top-left of the ring catches the light return a > 0.45 ? "n4" : a > 0.05 ? "n3" : a > -0.45 ? "n2" : "n1"; }); }); for (const s of [-1, 1]) { const hx = CX + s * (rx + 1); g.part(s < 0 ? "handleL" : "handleR", () => { g.strokeEllipse({ x: [hx - 2, hx + 2], y: [cy + 4, cy + 9] }, s < 0 ? "n2" : "n1"); g.pixel(hx - 1, cy + 4, s < 0 ? "n4" : "n2"); }); }}function brew(g, t) { const { cy, rx, ry } = MOUTH; const L = { cx: CX, cy: cy + 1, rx: rx - 1, ry: ry - 1 }; g.part("brew", () => { // inner wall of the far side, then the liquid surface g.field({ x: [CX - rx, CX + rx], y: [cy - ry, cy + ry] }, (x, y) => { const nx = (x + 0.5 - CX) / rx, ny = (y + 0.5 - cy) / ry; if (nx * nx + ny * ny > 1) return null; const lx = (x + 0.5 - L.cx) / L.rx, ly = (y + 0.5 - L.cy) / L.ry; if (lx * lx + ly * ly > 1) return nx > 0.35 ? "n1" : "n0"; // inner wall: its right side catches light if (ly > 0.55 && Math.abs(lx) < 0.8) return "brew2"; // meniscus along the near edge if (ly < -0.3) return "brew0"; return "brew1"; }); // swirl: two arms, 2-fold symmetric so a half turn per loop closes the loop for (let arm = 0; arm < 2; arm++) { for (let s = 0.25; s < 0.9; s += 0.09) { const a = Math.PI * t + arm * Math.PI + s * 2.8; const x = Math.round(L.cx + Math.cos(a) * s * L.rx); const y = Math.round(L.cy + Math.sin(a) * s * L.ry); g.pixel(x, y, s > 0.6 ? "brew2" : "brew0"); } } g.line([CX - 9, cy], [CX - 6, cy], "brew3"); // glint of the key light });}function bubbles(g, t) { const spots = [[-6, 1, 0.0], [3, 2, 0.34], [8, 0, 0.68]]; g.detached("bubbles", () => { for (const [dx, dy, ph] of spots) { const p = (t + ph) % 1; const x = CX + dx, y = MOUTH.cy + dy; if (p < 0.2) g.pixel(x, y, "brew2"); else if (p < 0.45) { g.rect({ x: [x - 1, x], y: [y - 1, y] }, "brew2", { fill: "brew2" }); g.pixel(x - 1, y - 1, "brew3"); } else if (p < 0.62) { g.ellipse({ x: [x - 2, x + 1], y: [y - 2, y] }, "brew2", { fill: "brew2" }); g.pixel(x - 1, y - 2, "brew3"); g.pixel(x - 2, y - 1, "brew3"); g.pixel(x + 1, y, "brew0"); } else if (p < 0.75) { for (const [ox, oy] of [[-3, 0], [2, 0], [-1, -3], [0, 1]]) g.pixel(x + ox, y + oy, "brew3"); } } });}function steam(g, t) { g.detached("steam", () => { for (let i = 0; i < 4; i++) { const p = (t + i / 4) % 1; const x0 = CX + [-6, -1, 4, 8][i] + Math.round(Math.sin(TAU * (p * 0.8 + i * 0.3)) * 2); const y0 = LIP.cy - 4 - Math.round(p * 12); if (p < 0.35) { g.rect({ x: [x0, x0 + 1], y: [y0, y0 + 1] }, "brew3", { fill: "brew3" }); g.pixel(x0 + 1, y0 + 1, "brew2"); } else if (p < 0.7) { g.pixel(x0, y0, "brew2"); g.pixel(x0 + 1, y0 - 1, "brew2"); } else if (chk(x0, Math.floor(t * 12))) g.pixel(x0, y0, "brew1"); } });}function logs(g) { g.part("logs", () => { // back log (angled), front log (level) with end grain facing the viewer g.rect({ x: [18, 31], y: [39, 41] }, "w1", { fill: "w1" }); g.pixel(31, 39, "w3"); g.rect({ x: [15, 34], y: [41, 44] }, "w2", { fill: "w2" }); g.line([16, 41], [33, 41], "w3"); g.line([16, 44], [33, 44], "w1"); g.ellipse({ x: [12, 16], y: [40, 45] }, "w3", { fill: "w3" }); g.strokeEllipse({ x: [12, 16], y: [40, 45] }, "w1"); g.pixel(14, 42, "w1"); g.pixel(13, 41, "w4"); g.line([22, 42], [26, 42], "w1"); g.pixel(30, 43, "w1"); });}function fire(g, t) { g.part("fire", () => { flame(g, 19, 40, 5, 8, t, 0.1, ["r1", "r2", "r3", "g3"]); flame(g, 29, 40, 5, 7, t, 0.55, ["r1", "r2", "r3", "g3"]); flame(g, 24, 41, 7, 11, t, 0.3, ["r1", "r2", "r3", "g3"]); }); g.detached("embers", () => { for (let k = 0; k < 3; k++) { const p = (t * 2 + k / 3) % 1; const x = 24 + Math.round(Math.sin(TAU * (p + k * 0.4)) * 7), y = 37 - Math.round(p * 5); if (p < 0.7) g.pixel(x, y, p < 0.35 ? "g2" : "r2"); } });}function foot(g, fx, lit) { g.part(lit ? "footL" : "footR", () => { g.fillPoly([[fx - 2, 37], [fx + 2, 37], [fx + 3, 43], [fx - 3, 43]], "n1"); g.line([fx - 2, 38], [fx - 3, 42], lit ? "n3" : "n2"); g.line([fx - 3, 43], [fx + 3, 43], "n0"); g.pixel(fx - 1, 43, "n2"); g.pixel(fx + 1, 43, "n2"); // toes });}const Cauldron = sprite(pal, (g, t) => { contactShadow(g, CX, GROUND, 18, 2); g.outlined("ink", () => { foot(g, 12, true); foot(g, 36, false); g.part("body", () => body(g, t)); lip(g); }); band(g, t); brew(g, t); bubbles(g, t); g.outlined("ink", () => { logs(g); fire(g, t); }); steam(g, t);});submit(Cauldron, { size: [48, 48], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。