Tessarune
Arcane Curiosities Vol.1 · brazier.js

火鉢

48×64 · JavaScript 190 行

48×64 の鍛鉄の火鉢で、曲がった 3 本の脚の上に鋲留めの広い鉢があり、赤く光る炭の上で 3 つの炎が重なって燃え、火の粉が昇ります。12 コマのループで、炎が上へなめるように伸びて先がちぎれ、炭の割れ目が脈打ち、火の粉が昇ります。炎は階調を一つ差し替えて fire(炎)、soul(魂火)、witch(魔女火)、bile の 4 色になり、unlit(消灯)は灰と消えかけの火の粉だけの 1 枚の静止画です。

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

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

brazier.js · 1–4
// Wrought-iron brazier — 48x64 (tall), top-down 3/4, key light top-left.// A wide riveted bowl of glowing coals on three curved legs, with a big fire (3 overlapping flames) and rising embers.// Loop: 12 frames @ 10 fps: flames lick upward and shed tongues, coal cracks pulse, embers rise.// Variants: flame colour fire / soul / witch / bile (fl ramp swap), plus a cold `unlit` still (ash, a few dying embers).

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

brazier.js · 178–190
submit(Brazier, {  size: [48, 64], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: 24, y: 61 },  args: ["lit"],  variants: [    { name: "idle-fire" },    { name: "idle-soul", repalette: swap("fl", "soul") },    { name: "idle-witch", repalette: swap("fl", "witch") },    { name: "idle-bile", repalette: swap("fl", "bile") },    { name: "unlit", args: ["unlit"], animate: false },  ],});
プログラム全体を表示 (190 行)
brazier.js · 190 行
// Wrought-iron brazier — 48x64 (tall), top-down 3/4, key light top-left.// A wide riveted bowl of glowing coals on three curved legs, with a big fire (3 overlapping flames) and rising embers.// Loop: 12 frames @ 10 fps: flames lick upward and shed tongues, coal cracks pulse, embers rise.// Variants: flame colour fire / soul / witch / bile (fl ramp swap), plus a cold `unlit` still (ash, a few dying embers).const CX = 24, GROUND = 61;const RIM = { cy: 33, rx: 18, ry: 5 };const MOUTH = { cy: 33, rx: 15, ry: 3.2 };const BOWL = { cy: 34, rx: 17, ry: 12 };const IRON = ["n0", "n1", "n2", "n3"];// ---- legs ---------------------------------------------------------------------------------------// three legs at 120 degrees: the front one drops from the boss under the bowl, the two back ones// splay outward from the bowl's flanks; each ends in a scroll where the bar curls up off the floorfunction backLeg(g, name, s) {  const X = (x) => CX + s * x;                       // s = -1 left, +1 right  const lit = s < 0;  const span = (a, b) => [Math.min(X(a), X(b)), Math.max(X(a), X(b))];  g.part(name, () => {    g.sweep([[X(11), 42], [X(15.5), 45], [X(17.5), 49.5], [X(19), 55]], { w0: 1.1, w1: 0.9 },      (u, v) => (v * s > 0.2 ? (lit ? "n3" : "n2") : v * s < -0.4 ? "n0" : "n1"));    g.strokeEllipse({ x: span(19, 21), y: [52, 55] }, "n1");    g.pixel(X(20), 52, lit ? "n3" : "n2");  });}function frontLeg(g) {  g.part("legF", () => {    g.sweep([[24, 45], [25, 49], [24, 54], [23, 58]], { w0: 1.3, w1: 1.0 }, (u, v) => (v < -0.25 ? "n3" : v > 0.4 ? "n0" : "n1"));    g.strokeEllipse({ x: [23, 26], y: [58, 61] }, "n1");                   // scroll foot facing us    g.pixel(23, 59, "n3"); g.pixel(24, 58, "n3");    g.line([25, 61], [26, 60], "n0");  });}// ---- bowl ---------------------------------------------------------------------------------------function bowl(g) {  g.part("bowl", () => {    g.field({ x: [CX - BOWL.rx, CX + BOWL.rx], y: [BOWL.cy, BOWL.cy + BOWL.ry] }, (x, y) => {      const i = litIndex(x, y, CX, BOWL.cy, BOWL.rx, BOWL.ry, 4, 0.15);      if (i < 0) return null;      const nx = (x + 0.5 - CX) / BOWL.rx, ny = (y + 0.5 - BOWL.cy) / BOWL.ry;      const sx = nx + 0.5, sy = ny - 0.5;      if (sx * sx * 3 + sy * sy * 12 < 0.08) return "n4";          // soft specular on the upper-left shoulder      return IRON[i];    });    // a boss under the bowl where the legs meet    g.ellipse({ x: [CX - 3, CX + 3], y: [44, 46] }, "n1", { fill: "n1" });    g.line([CX - 2, 44], [CX, 44], "n2");  });  // riveted strap round the belly, following the curve of the bowl  const yAt = (x) => { const u = (x + 0.5 - CX) / (BOWL.rx - 0.5); return Math.round(BOWL.cy + 6.5 - (1 - Math.sqrt(Math.max(0, 1 - u * u))) * 4); };  g.part("strap", () => {    for (let x = CX - BOWL.rx + 1; x <= CX + BOWL.rx - 1; x++) {      const u = (x - CX) / BOWL.rx, y = yAt(x);      g.pixel(x, y, u < -0.3 ? "n3" : u < 0.4 ? "n2" : "n1");      g.pixel(x, y + 1, "n0");    }    for (const u of [-0.8, -0.48, -0.16, 0.16, 0.48, 0.8]) {      const x = Math.round(CX + u * (BOWL.rx - 1)), y = yAt(x);      g.pixel(x, y, u < 0 ? "n5" : "n4");    }  });}function rim(g, lit) {  const { cy, rx, ry } = RIM;  g.part("rim", () => {    g.ellipse({ x: [CX - rx, CX + rx], y: [cy - ry + 1, cy + ry + 1] }, "n1", { fill: "n1" });   // rolled lip thickness    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 a = -0.75 * nx - 0.35 * ny;      if (lit && ny < -0.2 && ny > -0.75 && Math.abs(nx) > 0.72 && Math.abs(nx) < 0.86) return "fl0";  // coal glow on the inner lip      return a > 0.5 ? "n4" : a > 0.1 ? "n3" : a > -0.4 ? "n2" : "n1";    });  });}// ---- coals --------------------------------------------------------------------------------------const BACK_LUMPS = [[12, 31], [17, 30], [23, 29], [29, 30], [35, 31], [14, 33], [20, 32], [27, 32], [33, 33]];const FRONT_LUMPS = [[11, 34], [16, 35], [21, 35], [27, 35], [32, 35], [37, 34]];function lumps(g, t, lit, list, k0) {  for (let i = 0; i < list.length; i++) {    const [lx, ly] = list[i], id = i + k0;    g.ellipse({ x: [lx - 2, lx + 2], y: [ly - 1, ly + 1] }, "n0", { fill: "n0" });    g.line([lx - 1, ly - 1], [lx + 1, ly - 1], "n1");                              // lit top    if (lit) {      g.pixel(lx + 2, ly, "fl0");                                                   // hot underside edge      const p = 0.5 + 0.5 * Math.sin(TAU * (t + hash(id, 3)));                      // crack pulses on its own phase      if (hash(id, 7) > 0.3) g.line([lx - 1, ly], [lx, ly + (hash(id, 9) > 0.5 ? 1 : 0)], p > 0.72 ? "fl3" : p > 0.36 ? "fl2" : "fl1");    } else {      g.pixel(lx - 1, ly - 1, "n2");                                                // dull charcoal sheen      if (hash(id, 7) > 0.72) g.pixel(lx + 2, ly, "r0");                            // a dying ember in the gap    }  }}function coalBed(g, lit) {  const { cy, rx, ry } = MOUTH;  g.part("coals", () => {    g.field({ x: [CX - rx, CX + rx], y: [cy - ry - 4, cy + ry] }, (x, y) => {      const nx = (x + 0.5 - CX) / rx, ny = (y + 0.5 - cy) / ry;      const top = -1 - 1.3 * Math.max(0, 1 - nx * nx * 2.2);          // the heap bulges above the rim      if (!(nx * nx + ny * ny <= 1 || (ny < 0 && ny >= top && Math.abs(nx) < 0.68))) return null;      if (!lit) {                                                      // grey ash settled between cold lumps        const ax = nx / 0.5, ay = (y + 0.5 - (cy - 1.5)) / 2.8;        return ax * ax + ay * ay <= 1 ? (ax < -0.2 && ay < -0.1 ? "n3" : "n2") : ny < 0 ? "n2" : "n1";      }      return Math.abs(nx) < 0.3 ? "fl2" : Math.abs(nx) < 0.65 ? "fl1" : "fl0";    });  });}// ---- fire ---------------------------------------------------------------------------------------// each flame: a teardrop with nested inner teardrops (body, bright, core) that are shorter and narrower,// so the inner layers are themselves flame-shaped. Side flames lean outward; the centre one is in front.const FLAMES = [  { name: "flameL", x: 17, base: 32, h: 18, hw: 4.6, lean: -2.5, seed: 0.31 },  { name: "flameR", x: 31, base: 32, h: 20, hw: 4.6, lean: 2.5, seed: 0.64 },  { name: "flameC", x: 24, base: 33, h: 28, hw: 7, lean: 0, seed: 0.05 },];const LAYERS = [["fl0", 1, 1], ["fl1", 0.88, 0.72], ["fl2", 0.62, 0.48], ["fl3", 0.36, 0.26]];   // key, height, widthconst profile = (k) => (k < 0.22 ? 0.7 + 0.3 * Math.sin((k / 0.22) * Math.PI / 2) : Math.pow((1 - k) / 0.78, 0.9));function flameHeight(f, t) {  return f.h * (1 + 0.08 * Math.sin(TAU * (2 * t + f.seed)) + 0.05 * Math.sin(TAU * (3 * t + 2 * f.seed)));}function flame(g, f, t) {  const hh = flameHeight(f, t);  g.part(f.name, () => g.field({ x: [2, 45], y: [f.base - 32, f.base] }, (x, y) => {    const k = (f.base + 1 - (y + 0.5)) / hh;    if (k < 0 || k > 1) return null;    const ax = f.x + 0.5 + (f.lean + 1.4 * Math.sin(TAU * (t + f.seed))) * k * k             + 1.0 * Math.sin(TAU * (1.2 * k - 2 * t + f.seed)) * k;    const lick = 1 + 0.2 * Math.sin(TAU * (2.3 * k - 2 * t + 1.7 * f.seed));        // bulges travel upward    const dx = Math.abs(x + 0.5 - ax);    let key = null;    for (const [c, hf, wf] of LAYERS) {      const kj = k / hf;      if (kj > 1) break;      if (dx <= f.hw * wf * profile(kj) * lick) key = c; else break;    }    return key;  }));}function fire(g, t) {  for (const f of FLAMES) flame(g, f, t);  // tongues tear off the tips and rise; embers drift up and cool  g.detached("tongues", () => {    for (const f of FLAMES) {      const p = (2 * t + f.seed) % 1, tip = f.base - flameHeight(f, t);      const x = f.x + Math.round(f.lean + 1.4 * Math.sin(TAU * (t + f.seed))), y = Math.round(tip - 1 - p * 7);      if (y < 1) continue;      if (p < 0.4) { g.rect({ x: [x, x + 1], y: [y, y + 1] }, "fl1", { fill: "fl1" }); g.pixel(x + 1, y, "fl0"); g.pixel(x, y + 1, "fl0"); }      else if (p < 0.7) g.pixel(x, y, "fl0");    }  });  g.detached("embers", () => {    for (let i = 0; i < 6; i++) {      const p = (t + i / 6) % 1;      const x = CX + Math.round((i - 2.5) * 4 + Math.sin(TAU * (p * 1.5 + hash(i, 1))) * 2.5);      const y = 26 - Math.round(p * 24);      if (p < 0.92 && y >= 0) g.pixel(x, y, p < 0.3 ? "fl3" : p < 0.6 ? "fl2" : p < 0.8 ? "fl1" : "fl0");    }  });}// ---- sprite -------------------------------------------------------------------------------------const Brazier = sprite(pal, (g, t, state) => {  const lit = state !== "unlit";  contactShadow(g, CX, 58, 21, 3);  g.outlined("ink", () => { backLeg(g, "legL", -1); backLeg(g, "legR", 1); });  g.outlined("ink", () => { bowl(g); rim(g, lit); });  g.outlined("ink", () => { coalBed(g, lit); g.part("coal lumps", () => lumps(g, t, lit, BACK_LUMPS, 0)); });  if (lit) fire(g, t);  g.part("front coals", () => lumps(g, t, lit, FRONT_LUMPS, 20));  g.outlined("ink", () => frontLeg(g));});submit(Brazier, {  size: [48, 64], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: 24, y: 61 },  args: ["lit"],  variants: [    { name: "idle-fire" },    { name: "idle-soul", repalette: swap("fl", "soul") },    { name: "idle-witch", repalette: swap("fl", "witch") },    { name: "idle-bile", repalette: swap("fl", "bile") },    { name: "unlit", args: ["unlit"], animate: false },  ],});

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

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

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