Tessarune
Tavern & Market · roast_spit.js

焼き串

48×48 · JavaScript 215 行

48×48 の床置きの小物で、見下ろし 3/4 視点です。石で囲んだ焚き火、二股の鉄の支え 2 本、取っ手の付いた鉄の串に、紐で縛ったハムが刺さっています。ハムは回ります。切れ目を入れた皮、紐の結び目、脂の粒が胴を回っていき、光は動かないので、脂の粒はハイライトを通るときに光ります。idle(待機、16 コマのループ)でちょうど 1 回転し、取っ手が円を描き、脂が火に落ちてジュッと鳴ります。empty(12 コマのループ)は何も刺さっていない串と火だけです。

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

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

roast_spit.js · 1–5
// Roast spit — 48x48 floor prop, top-down 3/4, key light top-left.// An open cookfire in a ring of stones, two forked iron stands, and an iron spit with a crank; a twine-trussed ham// roasts on it. The ham TURNS: its scored crackling, twine knots and fat beads travel round the body (lighting stays// fixed, so the beads flash as they pass through the highlight), the crank handle circles, and fat drips fall into// the fire and sizzle. Variants: idle (16f loop, one full turn), empty (12f loop: bare spit, fire only).

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

roast_spit.js · 206–215
submit(Roast, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.6, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "empty", args: ["empty"], animate: { fps: 10, duration: 1.2, loop: true } },  ],});
プログラム全体を表示 (215 行)
roast_spit.js · 215 行
// Roast spit — 48x48 floor prop, top-down 3/4, key light top-left.// An open cookfire in a ring of stones, two forked iron stands, and an iron spit with a crank; a twine-trussed ham// roasts on it. The ham TURNS: its scored crackling, twine knots and fat beads travel round the body (lighting stays// fixed, so the beads flash as they pass through the highlight), the crank handle circles, and fat drips fall into// the fire and sizzle. Variants: idle (16f loop, one full turn), empty (12f loop: bare spit, fire only).const W = 48, H = 48, CX = 24, GROUND = 44;const AXIS = 20.5;                                  // spit axis (screen y)const HAM = { x0: 10, x1: 33, r: 7.2, shank: 2.6 };const PIT = { cx: 24, cy: 40, rx: 13, ry: 4 };const MEAT = ["w0", "w1", "w2", "w3", "w4"];// ham half-height along the spit: a round rump on the left tapering to the shank on the rightfunction hamR(x) {  const u = (x + 0.5 - HAM.x0) / (HAM.x1 - HAM.x0);  if (u < 0 || u > 1) return -1;  if (u < 0.42) return HAM.r * Math.sqrt(Math.max(0, 1 - ((0.42 - u) / 0.42) ** 2));  return HAM.shank + (HAM.r - HAM.shank) * (1 - Math.pow((u - 0.42) / 0.58, 1.7));}// ---- fire pit -------------------------------------------------------------------------------------------const STONES = [];for (let i = 0; i < 11; i++) {  const a = (i / 11) * TAU + 0.2;  STONES.push({ x: Math.round(PIT.cx + Math.cos(a) * PIT.rx), y: Math.round(PIT.cy + Math.sin(a) * PIT.ry), front: Math.sin(a) > 0.05, id: i });}function stone(g, s) {  const w = s.front ? 3 : 2, hgt = s.front ? 2 : 1.5;  g.field({ x: [s.x - w, s.x + w], y: [Math.floor(s.y - hgt - 1), Math.ceil(s.y + hgt)] }, (x, y) => {    const i = litIndex(x, y, s.x + 0.5, s.y, w + 0.5, hgt + 0.6, 4, 0.1 + (hash(s.id, 2) - 0.5) * 0.3);    return i < 0 ? null : ["n1", "n2", "n2", "n3"][i];  });}function pitBed(g, lit, t) {  g.part("ashBed", () => {    g.ellipse({ x: [PIT.cx - 11, PIT.cx + 11], y: [PIT.cy - 3, PIT.cy + 3] }, "n1", { fill: "n1" });    g.ellipse({ x: [PIT.cx - 8, PIT.cx + 8], y: [PIT.cy - 2, PIT.cy + 2] }, lit ? "r0" : "n0", { fill: lit ? "r0" : "n0" });    if (lit) for (let i = 0; i < 7; i++) {      const x = PIT.cx - 6 + i * 2, y = PIT.cy - 1 + (i % 3 === 1 ? 1 : 0);      const p = 0.5 + 0.5 * Math.sin(TAU * (t + hash(i, 5)));      g.pixel(x, y, p > 0.65 ? "r2" : "r1");    }  });}function logs(g, t) {  const crack = (id) => (0.5 + 0.5 * Math.sin(TAU * (t + hash(id, 3))) > 0.5 ? "fl2" : "fl1");  g.part("logs", () => {    g.sweep([[14, 42], [31, 37]], { w0: 1.6, w1: 1.3 }, (u, v) => (v < -0.3 ? "w2" : v > 0.45 ? "w0" : "w1"));    g.sweep([[34, 42], [17, 37]], { w0: 1.6, w1: 1.3 }, (u, v) => (v < -0.3 ? "w2" : v > 0.45 ? "w0" : "w1"));    for (const [x, y] of [[14, 42], [34, 42]]) { g.ellipse({ x: [x - 1, x + 1], y: [y - 1, y + 1] }, "w3", { fill: "w3" }); g.pixel(x, y, "w1"); }    g.pixel(19, 40, crack(1)); g.pixel(20, 40, crack(1)); g.pixel(29, 40, crack(2)); g.pixel(24, 38, "n0"); g.pixel(26, 39, "n0");  });}// ---- flames (layered teardrops) ----------------------------------------------------------------------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: "flameL", x: 18.5, base: 40, h: 9, hw: 3.4, lean: -1.2, seed: 0.37 },  { name: "flameR", x: 29.5, base: 40, h: 10, hw: 3.4, lean: 1.2, seed: 0.68 },  { name: "flameC", x: 24, base: 41, h: 12, hw: 5, lean: 0, seed: 0.05 },];function drawFlame(g, f, t, boost) {  const hh = f.h * (1 + 0.1 * Math.sin(TAU * (2 * t + f.seed)) + 0.06 * Math.sin(TAU * (3 * t + 2 * f.seed)) + boost);  g.part(f.name, () => g.field({ x: [6, 42], y: [Math.max(0, f.base - 22), 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.1 * 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 <= f.hw * wf * profile(kj) * lick) key = c; else break;    }    return key;  }));}// ---- stands, spit, crank -------------------------------------------------------------------------------function stand(g, x, name) {  // x = left column of the 2px pole  g.part(name, () => {    g.line([x, 22], [x, 38], "n3"); g.line([x + 1, 22], [x + 1, 38], "n1");    g.line([x, 21], [x - 2, 17], "n2"); g.line([x + 1, 21], [x + 3, 17], "n1");   // fork    g.pixel(x - 2, 17, "n3"); g.pixel(x + 3, 17, "n2");    g.line([x, 36], [x - 3, 43], "n2"); g.line([x + 1, 36], [x + 4, 43], "n1");    // splayed legs    g.pixel(x - 3, 43, "n3"); g.pixel(x + 4, 43, "n2");    g.pixel(x, 30, "n4");                                                          // a glint on the lit side  });}function spit(g) {  g.part("spit", () => {    g.line([3, 20], [42, 20], "n3"); g.line([3, 21], [42, 21], "n1");    g.pixel(2, 20, "n2"); g.pixel(2, 21, "n1"); g.pixel(3, 20, "n4");  });}function crank(g, th) {  const ps = th + Math.PI / 2;                        // the arm's angle round the axis  const ey = Math.round(AXIS + 4 * Math.sin(ps)), near = Math.cos(ps) > -0.2;  g.part("crank", () => {    g.line([43, 20], [43, 21], "n2");    const y0 = Math.min(ey, 21), y1 = Math.max(ey, 20);    g.line([43, y0], [43, y1], near ? "n2" : "n1");    g.line([44, ey], [46, ey], near ? "w2" : "w1");                                // wooden grip    g.pixel(44, ey, near ? "w3" : "w2");  });}// ---- the ham ---------------------------------------------------------------------------------------------// every visible pixel maps to a point on the turning surface: a = its screen angle round the axis, phi = a - thconst TWINE = [15, 21, 27];function ham(g, th) {  g.part("ham", () => g.field({ x: [HAM.x0, HAM.x1], y: [12, 29] }, (x, y) => {    const r = hamR(x);    if (r < 0) return null;    const v = (y + 0.5 - AXIS) / r;    if (Math.abs(v) > 1) return null;    const a = Math.asin(v), phi = a - th;    // fixed key light from the top-left; the rump end is rounded so it turns toward the light    const u = (x + 0.5 - HAM.x0) / (HAM.x1 - HAM.x0);    const nx = u < 0.42 ? -Math.sqrt(Math.max(0, 1 - (r / HAM.r) ** 2)) : 0.18;    const ny = v * Math.sqrt(1 - nx * nx), nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny));    const d = -0.5 * nx - 0.55 * ny + 0.67 * nz;    let lv = d > 0.86 ? 4 : d > 0.58 ? 3 : d > 0.18 ? 2 : d > -0.25 ? 1 : 0;    if (v > 0.8 && Math.abs(x + 0.5 - CX) < 8) return v > 0.93 && Math.abs(x + 0.5 - CX) < 4 ? "r2" : "r1";        // firelight on the underside    if (TWINE.includes(x)) return v < -0.5 ? "n4" : v < 0.4 ? "n3" : "n2";    // scored crackling: a diamond lattice that travels round the ham as it turns    const s = (phi * 4) / TAU;    const l1 = (((x / 7 + s) % 1) + 1) % 1, l2 = (((x / 7 - s) % 1) + 1) % 1;    if ((l1 < 0.12 || l2 < 0.12) && lv >= 2) lv -= 1;    return MEAT[Math.max(0, Math.min(4, lv))];  }));  // twine knots and fat beads riding the surface: drawn only on the half facing us  g.part("glaze", () => {    const onSurface = (x, ph, cb) => {      const psi = ph + th, c = Math.cos(psi);      const wrapped = Math.atan2(Math.sin(psi), c);      if (c < 0.15) return;      const r = hamR(x);      cb(Math.round(AXIS - 0.5 + Math.sin(wrapped) * r), wrapped);    };    for (const [i, x] of TWINE.entries()) onSurface(x, i * 2.1, (y) => { g.pixel(x, y, "n5"); g.pixel(x + 1, y, "n4"); });    const BEADS = [[13, 0.3], [18, 2.2], [24, 4.1], [29, 1.2], [16, 5.2], [23, 3.0]];    for (const [x, ph] of BEADS) onSurface(x, ph, (y, a) => {      const inHi = a < -0.25 && a > -1.1;                                  // passing through the highlight: it flashes      g.pixel(x, y, inHi ? "g3" : "g2");      if (inHi) g.pixel(x - 1, y, "g2");    });  });}function bone(g) {  g.part("bone", () => {    g.rect({ x: [33, 36], y: [19, 21] }, "n4", { fill: "n4" });    g.line([33, 19], [36, 19], "n5"); g.line([33, 21], [36, 21], "n3");    g.ellipse({ x: [36, 39], y: [17, 22] }, "n4", { fill: "n4" });           // the knuckle    g.pixel(37, 18, "n5"); g.pixel(38, 21, "n3"); g.pixel(39, 20, "n3");  });}// ---- drips, sparks --------------------------------------------------------------------------------------const DRIPS = [[14, 0.0], [21, 0.5], [27, 0.25], [18, 0.75]];function drips(g, t) {  g.detached("drips", () => {    for (const [x, ph] of DRIPS) {      const p = (t * 2 + ph) % 1;      const y0 = Math.round(AXIS + hamR(x));      if (p < 0.3) g.pixel(x, y0 + 1, p < 0.15 ? "g1" : "g2");                    // a bead swells on the underside      else if (p < 0.6) { const y = y0 + 2 + Math.round(((p - 0.3) / 0.3) ** 2 * 12); g.pixel(x, y, "g2"); g.pixel(x, y - 1, "g1"); }      else if (p < 0.72) {                                                       // it hits the fire: a sizzle pop        const y = 35;        g.pixel(x, y, "fl3"); g.pixel(x - 1, y - 1, "fl2"); g.pixel(x + 1, y - 1, "fl2"); g.pixel(x, y - 2, "fl1");      }    }  });}function embers(g, t, full) {  g.detached("embers", () => {    for (let i = 0; i < 4; i++) {      const p = (t * 2 + i / 4) % 1;      const x = CX + Math.round((i - 1.5) * 5 + Math.sin(TAU * (p + hash(i, 1))) * 2);      const y = 30 - Math.round(p * 18);      if (p < 0.8 && (!full || y < 12 || y > 29 || x < HAM.x0 - 1 || x > 40)) g.pixel(x, y, p < 0.3 ? "fl3" : p < 0.55 ? "fl2" : "fl1");    }  });}// ---- sprite ---------------------------------------------------------------------------------------------const Roast = sprite(pal, (g, t, state) => {  const full = state !== "empty";  const th = full ? TAU * t : 0;  contactShadow(g, CX, GROUND - 1, 21, 3);  g.outlined("ink", () => { pitBed(g, true, t); for (const s of STONES) if (!s.front) g.part("stones", () => stone(g, s)); });  g.outlined("ink", () => logs(g, t));  g.outlined("ink", () => { stand(g, 6, "standL"); stand(g, 40, "standR"); });  let boost = 0;  if (full) for (const [, ph] of DRIPS) { const p = (t * 2 + ph) % 1; if (p >= 0.6 && p < 0.8) boost = 0.12; }  for (const f of FLAMES) drawFlame(g, f, t, f.name === "flameC" ? boost : 0);  g.outlined("ink", () => { spit(g); crank(g, th); });  if (full) g.outlined("ink", () => { ham(g, th); bone(g); });  g.outlined("ink", () => { for (const s of STONES) if (s.front) g.part("stones", () => stone(g, s)); });  if (full) drips(g, t);  embers(g, t, full);});submit(Roast, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.6, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "empty", args: ["empty"], animate: { fps: 10, duration: 1.2, loop: true } },  ],});

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

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

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