Tavern & Market · hearth.js
酒場の暖炉
64×64 の壁に付ける小物で、正面向き、接地影はありません。切石の暖炉で、石の持ち送りに載せた樫の炉棚には泡立つジョッキ、白目の杯、ろうそく。要石のあるアーチの奥で、鉄の薪載せに 2 本の薪がのり、手前に敷石の炉床があります。火の光が側面の石、アーチの下面、炉床を温め、炎と一緒に脈打ちます。idle(待機、12 コマのループ)は炎が揺れて火の粉が昇ります。low(12 コマのループ)は熾火だけで、焦げた薪のひびが明滅し、煙が一筋上がります。stoke(8 コマの一回再生)は low から燃え上がって元の火になります。
6×
動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。