Tessarune
Dungeon Mechanisms · wall_torch.js

壁の松明

32×48 · JavaScript 195 行

32×48 の壁掛けの小物で、3/4 視点、接地影はありません。鋲を打った鉄の壁板と輪の金具が木の松明を支え、布を巻いてタールを染ませた先端は三つ叉の鉄の受けに収まり、その上で炎が燃えています。lit-fire、lit-soul、lit-witch、lit-bile は炎の階調を差し替えただけの 12 コマのループです。unlit(消灯、8 コマ)は焦げた先端から細い煙が上がり、ignite(点火、8 コマの一回再生)は火花から炎が育って lit-fire の最初のコマで終わります。

壁の松明 — 描画結果6×
このプログラムで描画(変種「ignite」) · 32×48 · 8 コマ
Dungeon Mechanisms動くスプライト素材: Dungeon Mechanisms(itch.io)Vol.2・罠と扉と壊れる物 12 点

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

wall_torch.js · 1–5
// Wall torch — 32x48 WALL prop (no contact shadow), 3/4 view, key light top-left.// A riveted iron wall plate; a collar ring holds a wooden torch whose tarred, cloth-wrapped head sits in a// three-prong iron cup. A living flame (layered teardrops + side tongues + embers) burns on top.// Variants: lit-fire / lit-soul / lit-witch / lit-bile (12f loop, fl ramp swap), unlit (8f loop: a thin smoke// wisp from the charred head), ignite (8f one-shot: a spark catches, the flame grows; ends on lit-fire frame 0).

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

wall_torch.js · 182–195
submit(WallTorch, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: 16, y: 46 },  args: ["lit"],  variants: [    { name: "lit-fire" },    { name: "lit-soul", repalette: swap("fl", "soul") },    { name: "lit-witch", repalette: swap("fl", "witch") },    { name: "lit-bile", repalette: swap("fl", "bile") },    { name: "unlit", args: ["unlit"], animate: { fps: 10, duration: 0.8, loop: true } },    { name: "ignite", args: ["ignite"], animate: { fps: 10, duration: 0.8, loop: false } },  ],});
プログラム全体を表示 (195 行)
wall_torch.js · 195 行
// Wall torch — 32x48 WALL prop (no contact shadow), 3/4 view, key light top-left.// A riveted iron wall plate; a collar ring holds a wooden torch whose tarred, cloth-wrapped head sits in a// three-prong iron cup. A living flame (layered teardrops + side tongues + embers) burns on top.// Variants: lit-fire / lit-soul / lit-witch / lit-bile (12f loop, fl ramp swap), unlit (8f loop: a thin smoke// wisp from the charred head), ignite (8f one-shot: a spark catches, the flame grows; ends on lit-fire frame 0).const W = 32, H = 48, CX = 16;const HEAD = { top: 19, bot: 27, hw0: 4.9, hw1: 2.5 };   // cloth head flares upward: half-width at top / bottomconst BASE = 19;                                          // flame root (head top)// ---- iron wall plate (a small shield-shaped escutcheon behind the collar) --------------------------const PLATE = [[11, 29], [21, 29], [22, 30], [22, 39], [16, 45], [10, 39], [10, 30]];function plate(g) {  g.part("plate", () => {    g.fillPoly(PLATE, "n2");    g.line([11, 29], [20, 29], "n4"); g.line([10, 30], [10, 38], "n3");       // lit bevel, top and left    g.line([22, 30], [22, 38], "n1"); g.line([21, 40], [17, 44], "n1");       // shaded bevel, right and lower right    g.line([11, 40], [15, 44], "n3");    g.line([18, 34], [18, 40], "n1");                                         // the handle's cast shadow    for (const [x, y] of [[12, 31], [20, 31], [16, 42]]) { g.pixel(x, y, "n4"); g.pixel(x + 1, y + 1, "n0"); }    g.pixel(13, 37, "n1"); g.pixel(20, 36, "n3");                            // a pit and a scuff  });}// ---- torch ----------------------------------------------------------------------------------------function handle(g) {  g.part("handle", () => {    g.sweep([[16, 27], [16, 43]], { w0: 1.9, w1: 1.2 }, (u, v) => (v < -0.35 ? "w3" : v > 0.35 ? "w1" : "w2"));    g.pixel(16, 38, "w1"); g.pixel(16, 39, "w1"); g.pixel(15, 36, "w4");      // grain, a lit knot  });}function ringBack(g) {  g.part("collarBack", () => { g.line([13, 31], [19, 31], "n1"); g.pixel(12, 32, "n2"); g.pixel(20, 32, "n1"); });}function ringFront(g) {  g.part("collar", () => {    g.line([12, 33], [20, 33], "n2");    g.line([13, 34], [19, 34], "n1");    g.line([12, 33], [14, 33], "n4"); g.pixel(15, 34, "n3");                   // lit shoulder    g.pixel(19, 34, "n0"); g.pixel(20, 33, "n1");  });}function head(g, lit, t) {  const hwAt = (y) => HEAD.hw0 + (HEAD.hw1 - HEAD.hw0) * Math.pow((y - HEAD.top) / (HEAD.bot - HEAD.top), 0.8);  const TWINE = [22, 25];  g.part("head", () => g.field({ x: [10, 22], y: [HEAD.top, HEAD.bot] }, (x, y) => {    const hw = hwAt(y), u = (x + 0.5 - CX) / hw;    if (Math.abs(u) > 1) return null;    if (y === HEAD.top) {                                       // charred crown where the fire sits      if (!lit) return Math.abs(u) < 0.6 ? "n0" : "w0";      return Math.abs(u) < 0.7 ? "fl1" : "fl0";    }    if (TWINE.includes(y)) return u < -0.4 ? "w4" : u < 0.35 ? "w3" : "w2";  // twine bindings    // tarred cloth wrapped in slanted strips, the cone lit from the left    const s = (y - HEAD.top + 1.3 * u) / 1.6;    const seam = s - Math.floor(s) < 0.34;    let k = u < -0.4 ? 2 : u < 0.45 ? 1 : 0;    if (seam) k = Math.max(0, k - 1);    if (y === HEAD.top + 1 && lit && Math.abs(u) < 0.95) k = Math.min(3, k + 1);   // underglow from the flame    return ["w0", "w1", "w2", "w3"][k];  }));  if (lit) g.part("headEmbers", () => {                        // embers smoulder in the top wrap    for (const [x, ph] of [[13, 0.1], [16, 0.45], [19, 0.8]]) {      const p = 0.5 + 0.5 * Math.sin(TAU * (t + ph));      g.pixel(x, HEAD.top + 1, p > 0.55 ? "fl2" : "fl0");    }  });  else g.part("ash", () => { g.pixel(14, HEAD.top, "n2"); g.pixel(18, HEAD.top, "n1"); g.pixel(17, HEAD.top + 1, "n0"); });}// ---- flame (layered teardrops, like the brazier) ---------------------------------------------------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: "tongueL", x: 13.2, base: BASE, h: 7, hw: 1.9, lean: -1.6, seed: 0.37 },  { name: "tongueR", x: 18.8, base: BASE, h: 8, hw: 1.9, lean: 1.6, seed: 0.71 },  { name: "flame", x: 16, base: BASE + 1, h: 18, hw: 4.8, lean: 0, seed: 0.08 },];const flameH = (f, t, s) => s * f.h * (1 + 0.09 * Math.sin(TAU * (2 * t + f.seed)) + 0.05 * Math.sin(TAU * (3 * t + 2 * f.seed)));function drawFlame(g, f, t, s) {  const hh = flameH(f, t, s), hwS = f.hw * Math.min(1, 0.45 + 0.55 * s);  if (hh < 1.5) return;  g.part(f.name, () => g.field({ x: [2, 29], y: [Math.max(0, f.base - 24), 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.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 (const [c, hf, wf] of LAYERS) {      const 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) if (s > 0.55 || f.name === "flame") drawFlame(g, f, t, f.name === "flame" ? s : (s - 0.55) / 0.45);  const main = FLAMES[2];  g.detached("flicks", () => {                                  // tongues tear off the tip and rise    if (s < 0.8) return;    const p = (2 * t + 0.3) % 1, tip = main.base - flameH(main, t, s);    const x = main.x + Math.round(1.2 * Math.sin(TAU * (t + main.seed))), y = Math.round(tip - 1 - p * 5);    if (y < 0) return;    if (p < 0.4) { g.pixel(x, y, "fl1"); g.pixel(x, y - 1, "fl0"); }    else if (p < 0.7) g.pixel(x, y, "fl0");  });  g.detached("embers", () => {    if (s < 0.6) return;    for (let i = 0; i < 3; i++) {      const p = (t + i / 3) % 1;      const x = CX + Math.round((i - 1) * 3 + Math.sin(TAU * (p * 1.3 + hash(i, 4))) * 2);      const y = BASE - 6 - Math.round(p * 14);      if (p < 0.85 && y >= 0) g.pixel(x, y, p < 0.3 ? "fl3" : p < 0.6 ? "fl2" : "fl1");    }  });}// ---- smoke wisp (unlit) -----------------------------------------------------------------------------function smoke(g, t, fade) {  g.detached("smoke", () => {    for (let y = BASE - 1; y >= 3; y--) {      const k = (BASE - 1 - y) / 15;                            // 0 at the head, 1 at the top      if (k > fade) continue;      const x = CX + Math.round(Math.sin(TAU * (k * 1.4 - t)) * (0.4 + 2.2 * k) + 1.5 * k);      const puff = (k * 2 - t + 2) % 1;                        // puffs travel upward; gaps between them      if (puff > 0.78 && k > 0.15) continue;      if (k > 0.55 && chk(x, y)) continue;                     // the upper wisp thins to a dither      g.pixel(x, y, k < 0.3 ? "n3" : "n2");    }  });}// ---- ignite: spark -> ember -> flame grows (frame-indexed; the last frame is lit-fire t=0) -----------const IGN = 8;const GROW = [0, 0.14, 0.3, 0.5, 0.72, 1.12, 1.04, 1];function spark(g, f) {  g.detached("spark", () => {    if (f === 0) {      const [x, y] = [18, 16];      for (const [ox, oy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + ox, y + oy, "fl2");      g.pixel(x + 2, y, "fl0"); g.pixel(x - 2, y, "fl0"); g.pixel(x, y - 2, "fl0");      g.pixel(x, y, "fl3");      g.pixel(20, 14, "fl1"); g.pixel(22, 12, "fl0");           // the trail it came in on    } else if (f <= 3) {                                       // kindling sparks spit upward      for (let i = 0; i < 3; i++) {        const x = CX + [-3, 1, 3][i] + (f - 1) * [-1, 0, 1][i], y = BASE - 3 - f * 3 - i * 2;        g.pixel(x, y, f < 3 ? "fl2" : "fl1");      }    }  });}const WallTorch = sprite(pal, (g, t, state) => {  let lit = state === "lit", grow = 1, ft = t, f = 0;  if (state === "ignite") {    f = Math.round(t * IGN);    grow = GROW[f];    lit = f >= 1;    ft = ((f - (IGN - 1)) / 12 + 1) % 1;                        // same 10 fps clock as the lit loop  }  // nearer parts first at depth -1, so their ink outline survives the plate drawn behind them  g.depth(-1, () => {    g.outlined("ink", () => {      g.depth(1, () => ringBack(g));      handle(g);      ringFront(g);      head(g, lit, ft);    });  });  g.depth(1, () => g.outlined("ink", () => plate(g)));  if (lit) g.depth(-2, () => fire(g, ft, grow));  if (state === "unlit") smoke(g, t, 1);  if (state === "ignite") {    if (f <= 2) smoke(g, 0, [1, 0.6, 0.3][f]);    spark(g, f);  }});submit(WallTorch, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: 16, y: 46 },  args: ["lit"],  variants: [    { name: "lit-fire" },    { name: "lit-soul", repalette: swap("fl", "soul") },    { name: "lit-witch", repalette: swap("fl", "witch") },    { name: "lit-bile", repalette: swap("fl", "bile") },    { name: "unlit", args: ["unlit"], animate: { fps: 10, duration: 0.8, loop: true } },    { name: "ignite", args: ["ignite"], animate: { fps: 10, duration: 0.8, loop: false } },  ],});

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

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

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