Tavern & Market · roast_spit.js
焼き串
48×48 の床置きの小物で、見下ろし 3/4 視点です。石で囲んだ焚き火、二股の鉄の支え 2 本、取っ手の付いた鉄の串に、紐で縛ったハムが刺さっています。ハムは回ります。切れ目を入れた皮、紐の結び目、脂の粒が胴を回っていき、光は動かないので、脂の粒はハイライトを通るときに光ります。idle(待機、16 コマのループ)でちょうど 1 回転し、取っ手が円を描き、脂が火に落ちてジュッと鳴ります。empty(12 コマのループ)は何も刺さっていない串と火だけです。
6×
動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。