Tessarune
Tavern & Market · hearth.js

酒場の暖炉

64×64 · JavaScript 404 行

64×64 の壁に付ける小物で、正面向き、接地影はありません。切石の暖炉で、石の持ち送りに載せた樫の炉棚には泡立つジョッキ、白目の杯、ろうそく。要石のあるアーチの奥で、鉄の薪載せに 2 本の薪がのり、手前に敷石の炉床があります。火の光が側面の石、アーチの下面、炉床を温め、炎と一緒に脈打ちます。idle(待機、12 コマのループ)は炎が揺れて火の粉が昇ります。low(12 コマのループ)は熾火だけで、焦げた薪のひびが明滅し、煙が一筋上がります。stoke(8 コマの一回再生)は low から燃え上がって元の火になります。

酒場の暖炉 — 描画結果6×
このプログラムで描画(変種「idle」) · 64×64 · 12 コマ
Tavern & Market動くスプライト素材: Tavern & Market(itch.io)Vol.3・酒場と市場の小物 12 点・32 アニメ

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

hearth.js · 1–8
// Tavern hearth — 64x64 WALL prop (no contact shadow), top-down 3/4, camera-facing, key light top-left.// A dressed-stone fireplace: chimney breast running up out of frame, an oak mantel beam on stone corbels (a frothy// tankard, a pewter mug and a candle on it), an ashlar surround with a segmental voussoir arch and keystone, a// firebox with splayed firebrick covings, two logs on iron andirons, and a flagstone hearth slab in front.// The fire lights the room: the jamb reveals, the arch soffit and the hearth slab warm up and pulse with the flames.// Variants: idle (12f loop: flames lick, sparks rise, glow on the stones breathes), low (12f loop: embers only —// charred logs with pulsing cracks, a dim glow, a smoke wisp), stoke (8f one-shot: low -> flare -> full; its last// frame == idle frame 0).

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

hearth.js · 394–404
submit(Hearth, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "low", args: ["low"] },    { name: "stoke", args: ["stoke"], animate: { fps: 10, duration: N / 10, loop: false } },  ],});
プログラム全体を表示 (404 行)
hearth.js · 404 行
// Tavern hearth — 64x64 WALL prop (no contact shadow), top-down 3/4, camera-facing, key light top-left.// A dressed-stone fireplace: chimney breast running up out of frame, an oak mantel beam on stone corbels (a frothy// tankard, a pewter mug and a candle on it), an ashlar surround with a segmental voussoir arch and keystone, a// firebox with splayed firebrick covings, two logs on iron andirons, and a flagstone hearth slab in front.// The fire lights the room: the jamb reveals, the arch soffit and the hearth slab warm up and pulse with the flames.// Variants: idle (12f loop: flames lick, sparks rise, glow on the stones breathes), low (12f loop: embers only —// charred logs with pulsing cracks, a dim glow, a smoke wisp), stoke (8f one-shot: low -> flare -> full; its last// frame == idle frame 0).const W = 64, H = 64, CX = 32, GROUND = 61;const OP = { x0: 19, x1: 44, bot: 53, spring: 36, bx0: 24, bx1: 39, floorY: 46 };   // firebox opening + interiorconst AR = 23.1, AYC = 32 + AR;                                                      // arch circle: crown at y=32const RING = 4.3, KEY_UP = 6.4, KEY_HW = 0.115;                                      // voussoir depth, keystoneconst archTop = (x) => AYC - Math.sqrt(Math.max(0, AR * AR - (x + 0.5 - CX) ** 2));const inOpening = (x, y) => x >= OP.x0 && x <= OP.x1 && y <= OP.bot && y + 0.5 >= archTop(x);const covL = (y) => OP.x0 + (OP.bot - y) * (OP.bx0 - OP.x0) / (OP.bot - OP.floorY);  // floor's left edge per rowconst covR = (y) => OP.x1 - (OP.bot - y) * (OP.x1 - OP.bx1) / (OP.bot - OP.floorY);const STONE = ["n0", "n1", "n2", "n3", "n4"];const WARM = { n0: "r0", n1: "w1", n2: "w2", n3: "w3", n4: "w4", ink: "ink" };const clamp = (v, a, b) => Math.max(a, Math.min(b, v));// ---- static geometry: one classification per pixel ---------------------------------------------------// surround courses: [y0, y1, block starts across the width] (hand-set so no block is a 1px sliver)const COURSES = [  [26, 30, [8, 15, 23, 36, 42, 49]],  [31, 35, [8, 12, 52]],  [36, 40, [8, 14, 45, 50]],  [41, 45, [8, 12, 45, 52]],  [46, 50, [8, 14, 45, 50]],  [51, 53, [8, 45]],];const CORBEL = [[9, 13], [9, 13], [10, 12], [10, 12], [10, 12]];  // rows 26..30: a stepped bracket (right is mirrored)const SLAB_J = [4, 14, 25, 38, 50];                               // flagstone startsconst idxOf = (starts, x) => { let j = -1; for (const s of starts) if (x >= s) j++; return j; };function classify(x, y) {  if (y >= 54 && y <= GROUND && x >= 4 && x <= 59) return { part: "slab", mat: y <= 58 ? "top" : "front", k: idxOf(SLAB_J, x) };  if (y >= 19 && y <= 25 && x >= 5 && x <= 58) return { part: "mantel", mat: y <= 21 ? "mtop" : "mfront" };  if (y <= 18 && x >= 14 && x <= 49) {    const c = Math.floor((18 - y) / 4);    return { part: "chimney", mat: "stone", k: 500 + c * 16 + Math.floor((x - 14 + (c & 1) * 5) / 9), lift: -0.35 };  }  if (y < 26 || y > 53 || x < 8 || x > 55) return null;  if (inOpening(x, y)) {    if (y >= OP.floorY && x >= covL(y) - 0.01 && x <= covR(y) + 0.01) return { part: "firebox", mat: "floor" };    if (y < OP.floorY && x >= OP.bx0 && x <= OP.bx1) return { part: "firebox", mat: "back" };    return { part: "firebox", mat: "coving", side: x < CX ? -1 : 1 };  }  const cr = CORBEL[y - 26];  if (cr && ((x >= cr[0] && x <= cr[1]) || (x >= 63 - cr[1] && x <= 63 - cr[0]))) return { part: "corbels", mat: "stone", k: x < CX ? 900 : 901, lift: 1, corbel: true };  const dx = x + 0.5 - CX, dy = AYC - (y + 0.5), r = Math.hypot(dx, dy), a = Math.atan2(dx, dy);  if (y < OP.spring && r >= AR - 0.6) {    if (Math.abs(a) < KEY_HW && r <= AR + KEY_UP) return { part: "arch", mat: "stone", k: 700, lift: 1.1, key: true };    if (r <= AR + RING) {      const s = Math.sign(a), n = Math.floor((Math.abs(a) - KEY_HW) / 0.205);      return { part: "arch", mat: "stone", k: 710 + s * (n + 1), lift: 0.6 };    }  }  if (y <= 35 && x >= 14 && x <= 49) return { part: "surround", mat: "stone", k: x < CX ? 40 : 41 };   // plain spandrels over the arch  for (let c = 0; c < COURSES.length; c++) {    const [y0, y1, starts] = COURSES[c];    if (y >= y0 && y <= y1) return { part: "surround", mat: "stone", k: c * 16 + idxOf(starts, x), plinth: c === COURSES.length - 1 };  }  return null;}const MAP = [];for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) MAP.push(classify(x, y));const at = (x, y) => (x < 0 || y < 0 || x >= W || y >= H ? null : MAP[y * W + x]);// distance (Chebyshev, capped) from each pixel to the nearest firebox pixel: how exposed a stone is to the fireconst DIST = MAP.map((m, i) => {  if (!m || m.part === "firebox") return 0;  const x = i % W, y = (i - x) / W;  for (let d = 1; d <= 6; d++) for (let v = -d; v <= d; v++) for (let u = -d; u <= d; u++) {    const q = at(x + u, y + v); if (q && q.part === "firebox") return d;  }  return 9;});// ---- lighting -------------------------------------------------------------------------------------------// fire glow: its reach follows the heat and breathes with the flamesconst pulse = (t) => 1 + 0.12 * Math.sin(TAU * t) + 0.05 * Math.sin(TAU * (3 * t) + 1);function glowAt(x, y, L) {                      // inside the firebox  const d = Math.hypot((x + 0.5 - CX) / 1.35, (y + 0.5 - 47) / 1.0);  const R = L.R;  return d < R * 0.42 ? 3 : d < R * 0.68 ? 2 : d < R ? 1 : 0;}function faceGlow(x, y, L) {                    // on the surround: only the stones hugging the opening  const d = DIST[y * W + x] + (y < 38 ? 1 : 0);  return d <= L.face - 1.5 ? 2 : d <= L.face ? 1 : 0;}const WARM1 = { n0: "n0", n1: "w0", n2: "w1", n3: "w2", n4: "w3", ink: "ink" };function warm(key, lvl) {  if (lvl >= 2) return WARM[key] || key;  if (lvl === 1) return WARM1[key] || key;  return key;}function stoneKey(x, y, b, L) {  const up = at(x, y - 1), dn = at(x, y + 1), lf = at(x - 1, y), rt = at(x + 1, y);  const other = (q) => !q || q.k !== b.k;  const fire = (q) => q && q.part === "firebox";  if (fire(rt) || fire(lf) || fire(dn)) {    // the reveal facing the fire    const gl = glowAt(x, y, L);    return gl >= 3 ? "w3" : gl === 2 ? "w2" : gl === 1 ? "w1" : "n1";  }  let lv = 2 + (b.lift || 0);  const h = hash(b.k * 1.7, 3.1);  if (h < 0.22) lv -= 0.6; else if (h > 0.78) lv += 0.6;  let key;  if (b.part === "arch") {    // voussoirs: a band of paler dressed stone, dark radial joints; the keystone stands proud    const inward = x < CX ? rt : lf;    if (b.key) key = !rt || rt.k !== b.k ? "n1" : !up || up.k !== b.k || !lf || lf.k !== b.k ? "n4" : "n3";    else if (inward && inward.part === "arch" && inward.k !== b.k) key = "n1";    else key = "n3";    return warm(key, faceGlow(x, y, L));  }  if (b.corbel) {    key = y === 26 ? "n2" : !dn || dn.k !== b.k ? "n1" : !rt || rt.k !== b.k ? "n1" : !lf || lf.k !== b.k ? "n4" : "n3";    return warm(key, faceGlow(x, y, L));  }  if ((up && up.corbel) || (lf && lf.corbel)) return "n0";                 // the bracket's cast shadow  if ((other(rt) && rt && rt.part !== "arch") || (other(dn) && dn && dn.part !== "mantel")) key = "n0";  else {    if ((up && other(up) && up.part !== "mantel") || (lf && other(lf))) lv += 1;    if (!lf) lv += 0.5;    if (!rt) lv -= 1;    if (up && up.part === "mantel" && !b.corbel) lv -= 1;                  // the beam's cast shadow    if (b.corbel) { lv = up && up.part === "mantel" ? 2 : lf && lf.k !== b.k ? 4 : 3; if (!dn || dn.k !== b.k) lv = 1; if (!rt || rt.k !== b.k) lv = Math.min(lv, 1); }    key = STONE[clamp(Math.floor(lv), 0, 4)];  }  if (b.part === "chimney") return key;  return warm(key, faceGlow(x, y, L));}function slabKey(x, y, b, L) {  const lf = at(x - 1, y);  const seam = lf && lf.part === "slab" && lf.k !== b.k;  let key;  if (b.mat === "top") {    if (seam) key = "n1";    else if (y === 54) key = inOpening(x, 53) ? "n2" : "n1";          // the surround's foot shades the slab    else if (y === 58) key = "n4";                                    // lit front lip    else key = x === 4 ? "n4" : "n3";    if (y > 54 && y < 58 && !seam && hash(x * 3.1 + y, 1.7) > 0.93 && x > 6) key = "n2";   // pits in the stone    // firelight spilling out of the opening: a warm pool with a dithered edge    const e = Math.hypot((x + 0.5 - CX) / L.sx, (y + 0.5 - 53) / L.sy);    const lvl = e < 0.55 ? 3 : e < 1 ? 2 : 0;    if (lvl >= 3) return key === "n1" ? "w2" : key === "n2" ? "w3" : "w4";    if (lvl === 2) return key === "n1" ? "w1" : key === "n2" ? "w2" : key === "n4" ? "w4" : "w3";    return key;  }  if (seam) return "n0";  key = y === 59 ? "n2" : "n1";  if (x === 4) key = y === 59 ? "n3" : "n2";  return key;}function mantelKey(x, y, b) {  if (b.mat === "mtop") return y === 19 ? "w2" : y === 20 ? "w3" : "w4";  if (y === 25) return "w1";  if (x === 5) return "w3";  if (x === 58) return "w1";  // adzed oak: a few long grain lines and two knots  const g1 = y === 23 && ((x >= 9 && x <= 17) || (x >= 34 && x <= 45));  const g2 = y === 24 && ((x >= 21 && x <= 30) || (x >= 48 && x <= 55));  if (g1 || g2) return "w1";  if ((x === 27 && y === 23) || (x === 13 && y === 24)) return "w0";  if (y === 22) return "w3";  return "w2";}function fireboxKey(x, y, b, L) {  const up = at(x, y - 1), lf = at(x - 1, y), rt = at(x + 1, y);  const edge = (q) => !q || q.part !== "firebox";  if (edge(up) || edge(lf) || edge(rt)) return y >= OP.bot - 1 && !edge(up) ? "n1" : "ink";   // the opening's shadowed lip  const g = glowAt(x, y, L);  if (b.mat === "back") {    if (y < 38) return "ink";                                                  // the flue throat    const brick = (y - 38) % 3 === 2 || ((x + (Math.floor((y - 38) / 3) & 1) * 2) % 5 === 0);    if (brick) return g >= 3 ? "r0" : "ink";    return g >= 3 ? "r1" : g >= 2 ? "r0" : "n0";  }  if (b.mat === "coving") {    if (y < 37) return "ink";    const brick = (y % 3 === 0);    if (brick) return g >= 2 ? "r0" : "ink";    return g >= 3 ? "r1" : g >= 2 ? "w1" : g >= 1 ? "n1" : "n0";  }  // floor: ash with a glowing ember bed under the logs  if (y === OP.bot) return g >= 2 ? "w2" : "n2";  const hot = Math.abs(x + 0.5 - CX) < 9 && y >= 47 && y <= 51;  if (hot) {    const p = 0.5 + 0.5 * Math.sin(TAU * (L.t + hash(x, y)));    return p > 0.7 && L.heat > 0.15 ? "r2" : "r1";  }  return g >= 2 ? "n2" : "n1";}function drawStructure(g, L) {  const parts = ["firebox", "slab", "surround", "arch", "corbels", "chimney", "mantel"];  for (const part of parts) {    g.part(part, () => g.field({ x: [0, W - 1], y: [0, H - 1] }, (x, y) => {      const b = MAP[y * W + x];      if (!b || b.part !== part) return null;      if (b.mat === "stone") return stoneKey(x, y, b, L);      if (part === "slab") return slabKey(x, y, b, L);      if (part === "mantel") return mantelKey(x, y, b);      return fireboxKey(x, y, b, L);    }));  }}// ---- logs and andirons -----------------------------------------------------------------------------------function logs(g, L, which) {  const hot = L.heat;  const crack = (id) => {    const p = 0.5 + 0.5 * Math.sin(TAU * (L.t + hash(id, 2)));    return p > 0.66 ? "fl2" : p > 0.3 ? "fl1" : "fl0";  };  if (which === "back") g.part("backLog", () => {    g.sweep([[24, 45.5], [40, 45]], { w0: 1.8, w1: 1.7 }, (u, v) => (v < -0.35 ? (hot > 0.5 ? "r1" : "w0") : v > 0.45 ? "w0" : hot > 0.5 ? "w1" : "w0"));    g.pixel(29, 45, crack(1)); g.pixel(30, 45, crack(1)); g.pixel(36, 46, crack(2));  });  else g.part("frontLog", () => {    g.sweep([[22.5, 50], [43, 48.5]], { w0: 2.2, w1: 2.0 }, (u, v) => (v < -0.45 ? (hot > 0.5 ? "r1" : "n0") : v > 0.5 ? "w0" : hot > 0.5 ? "w2" : "w1"));    // end grain toward the viewer's left    g.ellipse({ x: [20, 24], y: [48, 52] }, "w3", { fill: "w3" });    g.strokeEllipse({ x: [20, 24], y: [48, 52] }, "w2");    g.pixel(22, 50, "w1"); g.pixel(21, 49, "w4");    // charred patches and glowing cracks    g.line([27, 49], [29, 49], "n0"); g.line([35, 48], [37, 48], "n0");    g.line([31, 50], [32, 50], crack(3)); g.pixel(39, 49, crack(4)); g.pixel(26, 50, crack(5));    if (hot < 0.5) { g.line([28, 48], [33, 48], "n0"); g.line([36, 47], [41, 47], "n0"); }  });}function andiron(g, x0, s, hot) {  // x0 = post's left column; s = -1 left andiron, +1 right (the fire rims the side facing the centre)  g.part(s < 0 ? "andironL" : "andironR", () => {    g.rect({ x: [x0, x0 + 1], y: [45, 52] }, "n1", { fill: "n1" });    g.pixel(x0, 46, "n2"); g.pixel(x0, 47, "n2");    g.line([x0 + (s < 0 ? 1 : 0), 47], [x0 + (s < 0 ? 1 : 0), 51], hot ? "r1" : "r0");   // firelight on the inner edge    g.ellipse({ x: [x0 - 1, x0 + 2], y: [42, 45] }, "n2", { fill: "n2" });  // ball finial    g.pixel(x0 + 1, 45, "n1"); g.pixel(x0 + 2, 44, "n1"); g.pixel(x0, 43, "n4"); g.pixel(x0 - 1 + (s < 0 ? 3 : 0), 44, hot ? "r1" : "n1");    g.line([x0 - 1, 53], [x0 + 2, 53], "n1"); g.pixel(x0 - 1, 53, "n2");    // splayed foot  });}// ---- flames (layered teardrops, clipped to the opening) --------------------------------------------------const LAYERS = [["fl0", 1, 1], ["fl1", 0.86, 0.72], ["fl2", 0.6, 0.48], ["fl3", 0.34, 0.26]];const profile = (k) => (k < 0.22 ? 0.72 + 0.28 * Math.sin((k / 0.22) * Math.PI / 2) : Math.pow((1 - k) / 0.78, 0.9));const FLAMES = [  { name: "flameBL", x: 25, base: 47, h: 8, hw: 2.6, lean: -1, seed: 0.83 },  { name: "flameBR", x: 39, base: 47, h: 9, hw: 2.6, lean: 1, seed: 0.21 },  { name: "flameL", x: 27, base: 48, h: 12, hw: 4, lean: -1.4, seed: 0.37 },  { name: "flameR", x: 36.5, base: 48, h: 13, hw: 4.2, lean: 1.3, seed: 0.66 },  { name: "flameC", x: 31.5, base: 49, h: 17, hw: 6, lean: 0, seed: 0.05 },];const flameH = (f, t, s) => s * f.h * (1 + 0.09 * Math.sin(TAU * (2 * t + f.seed)) + 0.06 * Math.sin(TAU * (3 * t + 2 * f.seed)));function drawFlame(g, f, t, s, maxLayer) {  const hh = flameH(f, t, s), hwS = f.hw * Math.min(1.15, 0.4 + 0.6 * s);  if (hh < 1.5) return;  g.part(f.name, () => g.field({ x: [OP.x0, OP.x1], y: [Math.max(0, f.base - 26), f.base] }, (x, y) => {    if (!inOpening(x, y)) return null;    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.2 * Math.sin(TAU * (t + f.seed))) * k * k             + 0.9 * 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));    const dx = Math.abs(x + 0.5 - ax);    let key = null;    for (let j = 0; j <= maxLayer; j++) {      const [c, hf, wf] = LAYERS[j], kj = k / hf;      if (kj > 1) break;      if (dx <= hwS * wf * profile(kj) * lick) key = c; else break;    }    return key;  }));}function fire(g, t, s) {  for (const f of FLAMES) {    const small = f.name.startsWith("flameB");    const sf = small ? Math.min(1, s * 1.4) : s;    if (sf > 0.05) drawFlame(g, f, t, sf, s < 0.3 ? 1 : s < 0.55 ? 2 : 3);  }}// low fire: small dim tongues that come and go on the logsfunction licks(g, t) {  for (const [x, ph, h] of [[28, 0, 4], [34, 0.4, 3], [38, 0.7, 3]]) {    const p = (t + ph) % 1;    if (p > 0.6) continue;    const hh = h * Math.sin(Math.PI * p / 0.6);    drawFlame(g, { name: "lick", x, base: 48, h: hh, hw: 1.4, lean: 0.3, seed: ph }, t, 1, 1);  }}// ---- sparks, smoke ------------------------------------------------------------------------------------function put(g, x, y, key) { if (at(x, y) && at(x, y).part === "firebox") g.pixel(x, y, key); }function sparks(g, t, n, burst) {  g.detached("sparks", () => {    for (let i = 0; i < n; i++) {      const p = (t + i / n) % 1;      const x = CX + Math.round((i - (n - 1) / 2) * 3 + Math.sin(TAU * (p * 1.5 + hash(i, 1))) * 2);      const y = 42 - Math.round(p * 14);      if (p < 0.85) put(g, x, y, p < 0.3 ? "fl3" : p < 0.6 ? "fl2" : "fl1");    }    if (burst) for (let i = 0; i < 7; i++) {           // stoke: a spray of sparks thrown up and out      const k = burst - 1;      const x = CX + Math.round((i - 3) * (1.2 + k * 1.1) + hash(i, 4) * 2 - 1);      const y = 44 - Math.round(3 + k * 3 + hash(i, 6) * 3) + (k > 1 ? Math.round((k - 1) * (i & 1)) : 0);      if (y > 31 && x > 15 && x < 48) g.pixel(x, y, k < 1 ? "fl3" : k < 2 ? "fl2" : "fl1");    }  });}function smoke(g, t) {  g.detached("smoke", () => {    for (let y = 46; y >= 33; y--) {      const k = (46 - y) / 13;      const x = CX + Math.round(Math.sin(TAU * (k * 1.2 - t)) * (0.5 + 2 * k));      const puff = (k * 2 - t + 2) % 1;      if (puff > 0.7) continue;      if (k > 0.6 && chk(x, y)) continue;      put(g, x, y, k < 0.35 ? "n3" : "n2");    }  });}// ---- mantel dressing -------------------------------------------------------------------------------------function tankard(g) {  g.part("tankard", () => {    g.rect({ x: [8, 13], y: [15, 21] }, "w2", { fill: "w2" });    g.line([8, 15], [8, 21], "w3"); g.line([13, 15], [13, 21], "w1");    g.line([11, 16], [11, 20], "w1");                                         // stave joint    for (const y of [16, 20]) { g.line([8, y], [13, y], "n2"); g.pixel(8, y, "n3"); g.pixel(13, y, "n1"); }    g.strokeRect({ x: [14, 15], y: [16, 19] }, "w1");                        // handle    g.pixel(14, 16, "w2");    g.ellipse({ x: [7, 14], y: [12, 15] }, "n4", { fill: "n4" });             // froth heaped over the rim    g.line([8, 12], [11, 12], "n5"); g.pixel(8, 13, "n5"); g.pixel(13, 14, "n3"); g.pixel(12, 15, "n3");  });}function pewter(g) {  g.part("pewter", () => {    g.rect({ x: [17, 21], y: [16, 21] }, "n3", { fill: "n3" });    g.line([17, 16], [17, 21], "n4"); g.line([21, 16], [21, 21], "n2"); g.pixel(18, 18, "n5");    g.line([17, 16], [21, 16], "n2"); g.line([18, 16], [20, 16], "n0");       // rim + dark mouth    g.line([17, 20], [21, 20], "n2");                                          // turned foot ring    g.strokeRect({ x: [22, 23], y: [17, 19] }, "n2");  });}function candle(g, t) {  g.part("candle", () => {    g.ellipse({ x: [48, 55], y: [19, 21] }, "g1", { fill: "g1" });           // brass dish    g.line([49, 19], [52, 19], "g3"); g.line([50, 21], [54, 21], "g0");    g.rect({ x: [50, 53], y: [12, 19] }, "n4", { fill: "n4" });    g.line([50, 12], [50, 19], "n5"); g.line([53, 13], [53, 19], "n3");    g.pixel(51, 13, "n5"); g.line([50, 14], [50, 16], "n5");                 // wax drip    g.pixel(53, 12, "n3"); g.pixel(52, 11, "n0");                             // melted crown + wick  });}function candleFlame(g, t) {  g.detached("candleFlame", () => {    const f = Math.floor(t * 12) % 4, lean = [0, 0, 1, 0][f], tall = [3, 4, 3, 4][f];    for (let i = 0; i < tall; i++) g.pixel(52 + (i >= 2 ? lean : 0), 10 - i, i === 0 ? "fl3" : i === tall - 1 ? "fl0" : "fl1");    g.pixel(51, 9, f & 1 ? "fl0" : "fl1");  });}// ---- sprite ---------------------------------------------------------------------------------------------const N = 8;const HEAT = [0.25, 0.4, 0.62, 0.85, 1, 1, 1, 1];const GROW = [0.12, 0.3, 0.6, 0.95, 1.22, 1.12, 1.04, 1];const Hearth = sprite(pal, (g, t, state) => {  let heat = 1, s = 1, ft = t, burst = 0;  if (state === "low") { heat = 0.2; s = 0; }  if (state === "stoke") {    const f = Math.min(N - 1, Math.round(t * N));    heat = HEAT[f]; s = GROW[f];    ft = ((f - (N - 1)) / 12 + 1) % 1;                    // same 10 fps clock as idle: the last frame is idle t=0    burst = f >= 3 && f <= 5 ? f - 2 : 0;  }  const flare = s > 1 ? 1 + (s - 1) * 0.8 : 1, P = pulse(ft) * flare;  const L = { heat, t: ft, R: (7 + 10 * heat) * P, face: heat * 3.4 * P, sx: (3 + 13 * heat) * P, sy: (1.5 + 3.5 * heat) * P };  g.outlined("ink", () => drawStructure(g, L));  g.outlined("ink", () => logs(g, L, "back"));  if (s > 0) fire(g, ft, s); else licks(g, ft);  g.outlined("ink", () => { logs(g, L, "front"); andiron(g, 22, -1, heat > 0.5); andiron(g, 40, 1, heat > 0.5); });  if (state === "low") smoke(g, t);  else sparks(g, ft, s >= 0.9 ? 5 : 0, burst);  g.outlined("ink", () => { tankard(g); pewter(g); candle(g, ft); });  candleFlame(g, ft);});submit(Hearth, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "low", args: ["low"] },    { name: "stoke", args: ["stoke"], animate: { fps: 10, duration: N / 10, loop: false } },  ],});

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

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

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