Arcane Curiosities Vol.1 · brazier.js
火鉢
48×64 の鍛鉄の火鉢で、曲がった 3 本の脚の上に鋲留めの広い鉢があり、赤く光る炭の上で 3 つの炎が重なって燃え、火の粉が昇ります。12 コマのループで、炎が上へなめるように伸びて先がちぎれ、炭の割れ目が脈打ち、火の粉が昇ります。炎は階調を一つ差し替えて fire(炎)、soul(魂火)、witch(魔女火)、bile の 4 色になり、unlit(消灯)は灰と消えかけの火の粉だけの 1 枚の静止画です。
6×
動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。