Tessarune
Graveyard & Chapel · bell_frame.js

鐘楼

48×64 · JavaScript 248 行

48×64 の床置きの小物で、石の土台に立つ 2 本の木の柱を方杖と横木でつなぎ、小さなスレート葺きの屋根を載せています。鉄の吊り金具に青銅の鐘が下がり、舌から麻縄が垂れています。鐘は一つの g.field で画素ごとに鐘の向きへ回して描くので、どの角度に揺れても光は左上のままです。idle(12 コマ)は鐘が止まったまま縄が揺れます。toll(12 コマの一回再生)は鐘が 2 回揺れ、振り切ったところで舌が当たって音の輪が広がり、idle の最初のコマで終わります。

鐘楼 — 描画結果6×
このプログラムで描画(変種「toll」) · 48×64 · 12 コマ
Graveyard & Chapel動くスプライト素材: Graveyard & Chapel(itch.io)Vol.4・小物 12 点・33 アニメ

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

bell_frame.js · 1–7
// Graveyard bell frame — 48x64 floor prop, top-down 3/4, key light top-left.// Two timber posts on stone footings, knee braces, a crossbeam under a small slate-shingled cap. A bronze bell hangs// from an iron hanger; a hemp rope hangs from the clapper.// The bell is drawn by ONE g.field that inverse-rotates each pixel into bell space, so it can swing by any angle// while the light stays top-left (the highlight stripe does not rotate with it).// Variants: idle (12f loop: bell still, rope sways), toll (12f one-shot: two swings with the clapper striking and// sound rings at the peaks; ends on idle frame 0).

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

bell_frame.js · 239–248
submit(BellFrame, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: IDLE_N / 10, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "toll", args: ["toll"], animate: { fps: 10, duration: TOLL.length / 10, loop: false } },  ],});
プログラム全体を表示 (248 行)
bell_frame.js · 248 行
// Graveyard bell frame — 48x64 floor prop, top-down 3/4, key light top-left.// Two timber posts on stone footings, knee braces, a crossbeam under a small slate-shingled cap. A bronze bell hangs// from an iron hanger; a hemp rope hangs from the clapper.// The bell is drawn by ONE g.field that inverse-rotates each pixel into bell space, so it can swing by any angle// while the light stays top-left (the highlight stripe does not rotate with it).// Variants: idle (12f loop: bell still, rope sways), toll (12f one-shot: two swings with the clapper striking and// sound rings at the peaks; ends on idle frame 0).const W = 48, H = 64, CX = 24, GROUND = 61;const PIV = { x: 24, y: 18 };                 // hanger pin (bell crown loop turns on it)const HB = 17;                                // bell height below the crown (local v 0..HB)// half-width of the bell per local row v (crown -> waist -> flare -> lip)const HW = [3.2, 4.4, 5.0, 5.3, 5.4, 5.5, 5.6, 5.7, 5.9, 6.1, 6.4, 6.8, 7.2, 7.6, 8.0, 8.3, 8.5, 8.5];const hwAt = (v) => { const i = Math.max(0, Math.min(HB, v)); const a = Math.floor(i), b = Math.min(HB, a + 1); return HW[a] + (HW[b] - HW[a]) * (i - a); };const RAD = Math.PI / 180;// ---- frame -------------------------------------------------------------------------------------------function footing(g, x0, lit) {  g.part(lit ? "footingL" : "footingR", () => {    g.rect({ x: [x0, x0 + 8], y: [55, 60] }, "n2", { fill: "n2" });    g.line([x0, 55], [x0 + 8, 55], "n4");                                   // top face edge    g.line([x0, 56], [x0 + 8, 56], "n3");    g.line([x0, 57], [x0, 60], lit ? "n3" : "n2");    g.line([x0 + 8, 57], [x0 + 8, 60], "n1");    g.line([x0 + 1, 60], [x0 + 8, 60], "n1");    g.pixel(x0 + 3, 58, "n1"); g.pixel(x0 + 6, 57, "n3"); g.pixel(x0 + 5, 59, "n1");   // chips  });}function post(g, x0, lit) {  g.part(lit ? "postL" : "postR", () => {    g.field({ x: [x0, x0 + 3], y: [16, 55] }, (x, y) => {      const u = x - x0;      if (u === 0) return lit ? "w3" : "w2";      if (u === 3) return lit ? "w1" : "w0";      // grain: a few countable streaks      if ((u === 1 && (y === 24 || y === 25 || y === 41)) || (u === 2 && (y === 33 || y === 34 || y === 48))) return "w1";      return u === 1 && lit ? "w3" : "w2";    });    g.pixel(x0 + 1, 29, "w0"); g.pixel(x0 + 2, 29, "w1");                     // a knot  });}function sill(g) {  g.part("sill", () => {    g.rect({ x: [9, 38], y: [52, 54] }, "w1", { fill: "w1" });    g.line([9, 52], [38, 52], "w3");    g.line([9, 54], [38, 54], "w0");    g.pixel(15, 53, "w0"); g.pixel(16, 53, "w0"); g.pixel(30, 53, "w2");  });}function braces(g) {  g.part("braces", () => {    for (let i = 0; i <= 5; i++) {      g.pixel(7 + i, 23 - i, "w3"); g.pixel(7 + i, 24 - i, "w1");          // left brace, lit top edge      g.pixel(40 - i, 23 - i, "w2"); g.pixel(40 - i, 24 - i, "w0");        // right brace, in shade    }  });}function beam(g) {  g.part("crossbeam", () => {    g.rect({ x: [1, 46], y: [13, 16] }, "w2", { fill: "w2" });    g.line([2, 13], [45, 13], "w1");                                        // under the eave shadow    g.line([2, 14], [20, 14], "w3");    g.line([1, 16], [46, 16], "w0");    // end grain: lit on the left, dark on the right    g.rect({ x: [1, 2], y: [13, 16] }, "w3", { fill: "w3" }); g.pixel(1, 14, "w4");    g.rect({ x: [45, 46], y: [13, 16] }, "w1", { fill: "w1" });    g.line([27, 15], [31, 15], "w1"); g.line([10, 15], [12, 15], "w1");     // grain    for (const x of [5, 42]) { g.pixel(x, 14, "n4"); g.pixel(x, 15, "n1"); }   // peg heads over the posts  });}function cap(g) {  // front slope of a gable cap seen from above: trapezoid, ridge at the top, eave at the bottom  const top = 4, eave = 11, l0 = 10, r0 = 37;  g.part("cap", () => {    g.field({ x: [0, 47], y: [top, eave + 1] }, (x, y) => {      const k = (y - top) / (eave - top);      const xl = l0 - k * l0, xr = r0 + k * (47 - r0);      if (x < Math.round(xl) || x > Math.round(xr)) return null;      if (y === eave + 1) return x < 2 || x > 45 ? null : "w1";              // fascia board      if (y === top) return x < 22 ? "n4" : "n3";                            // ridge tiles      const row = Math.floor((y - top - 1) / 2), sub = (y - top - 1) % 2;      if (x - xl < 1.2) return "n4";                                         // lit left hip      if (xr - x < 1.2) return "n1";                                         // right hip in shade      const joint = (x + row * 2) % 5 === 0;      if (sub === 1) return joint ? "n0" : x < 18 ? "n2" : "n1";             // slate butt edge      if (joint) return "n1";      return x < 16 ? "n3" : "n2";    });    g.pixel(24, 3, "n3"); g.pixel(24, 2, "n4"); g.pixel(25, 3, "n2");        // finial knob    g.pixel(13, 7, "n1"); g.pixel(33, 9, "e1");                              // a cracked slate, a tuft of moss  });}function hanger(g) {  g.part("hanger", () => {    g.rect({ x: [22, 26], y: [17, 17] }, "n1", { fill: "n1" });    g.pixel(22, 17, "n3"); g.pixel(23, 17, "n2");    g.pixel(24, 18, "n2");  });}// ---- bell --------------------------------------------------------------------------------------------function toLocal(x, y, th) {  const dx = x + 0.5 - PIV.x, dy = y + 0.5 - PIV.y, c = Math.cos(th), s = Math.sin(th);  return [dx * c - dy * s, dx * s + dy * c];}function toWorld(u, v, th) {  const c = Math.cos(th), s = Math.sin(th);  return [PIV.x + u * c + v * s, PIV.y - u * s + v * c];}// patina flecks in bell space (u, v) — sparse, in the crevices under the bandsconst PATINA = [[3.5, 5.5, "e1"], [3.5, 6.5, "e1"], [3.5, 7.5, "e0"], [4.5, 5.5, "e0"], [5.5, 13.5, "e0"], [5.5, 12.5, "e1"]];function bellKey(x, y, th) {  const [u, v0] = toLocal(x, y, th);  const v = v0 - 1;                                                          // v 0 = top of the crown  if (v < -2.2 || v > HB + 0.5) return null;  if (v < 0) {                                                               // crown loop (canon) on the pin    const r = Math.hypot(u, v + 1.1);    if (r > 1.9 || r < 0.7) return null;    return u < 0 ? "g2" : "g0";  }  const hw = hwAt(v);  if (Math.abs(u) > hw) return null;  const nu = u / hw;  // surface slope: the shoulder faces up, the flare faces down/out  const nyl = v < 2.5 ? -0.7 : v > 11 ? 0.35 : 0;  const nz = Math.sqrt(Math.max(0, 1 - nu * nu));  const c = Math.cos(th), s = Math.sin(th);  const nxw = nu * c + nyl * s, nyw = -nu * s + nyl * c;  const edge = hw - Math.abs(u);  for (const [pu, pv, k] of PATINA) if (Math.abs(u - pu) < 0.5 && Math.abs(v - pv) < 0.5) return k;  // lip band: a raised roll along the mouth  if (v > HB - 0.5) return nxw < -0.2 ? "g1" : "g0";                          // underside of the roll  if (v > HB - 1.5) return nxw < -0.35 ? "g3" : nxw < 0.3 ? "g2" : "g1";      // lit top of the roll  if (v > HB - 2.5 && v <= HB - 1.5) return nxw < 0 ? "g1" : "g0";            // groove above the roll  // shoulder band (two thin rings)  if (v > 3 && v <= 4) return "g0";  if (v > 4 && v <= 5) return nxw < -0.2 ? "g3" : nxw < 0.35 ? "g2" : "g1";  // sharp lit rim along the top-left silhouette, bounce light on the right  if (edge < 1 && nxw < -0.3) return "g3";  if (edge < 1 && nxw > 0.6) return "g1";  // vertical specular stripe (world-lit, not bell-lit)  // cylindrical bands keyed to the WORLD-space normal, so the light stays top-left as the bell swings  const b = nxw - (v < 2.5 ? 0.25 : 0) + (v > 12 ? 0.1 : 0);  if (b > -0.52 && b < -0.36 && v > 5 && v < HB - 2.5) return "g3";          // specular stripe  if (b < 0.02) return "g2";  if (b < 0.42) return "g1";  if (b < 0.66 || v < 2.5) return "g0";  return "w1";                                                               // core shadow: dark bronze}function bell(g, th) {  g.part("bell", () => g.field({ x: [10, 38], y: [14, 40] }, (x, y) => bellKey(x, y, th)));}// clapper: bell-space angle psi (relative to the bell axis); the ball hangs just below the lipfunction clapperPos(th, psi) {  const L = HB + 2.2;  return toWorld(Math.sin(psi) * L, 1 + Math.cos(psi) * L, th);}function clapper(g, th, psi) {  const [bx, by] = clapperPos(th, psi);  const x = Math.round(bx), y = Math.round(by);  g.part("clapper", () => {    g.rect({ x: [x - 1, x + 1], y: [y - 1, y + 1] }, "n1", { fill: "n1" });    g.pixel(x - 1, y - 1, "n3"); g.pixel(x, y - 1, "n2"); g.pixel(x - 1, y, "n2");    g.pixel(x + 1, y + 1, "n0");  });  return [x, y + 2];}// ---- rope --------------------------------------------------------------------------------------------const ROPE_LEN = 15;function rope(g, top, sway) {  const [tx, ty] = top;  g.part("rope", () => {    let px = tx;    for (let i = 0; i <= ROPE_LEN; i++) {      const k = i / ROPE_LEN;      const x = Math.round(tx + sway * Math.pow(k, 1.4)), y = ty + i;      if (Math.abs(x - px) > 1) g.pixel((x + px) >> 1, y, "w3");            // keep the strand connected      g.pixel(x, y, (i + Math.floor(y / 1)) % 3 === 0 ? "w2" : "w4");       // twisted hemp      px = x;    }    // knot and frayed tassel    const ex = Math.round(tx + sway), ey = ty + ROPE_LEN;    g.rect({ x: [ex - 1, ex + 1], y: [ey + 1, ey + 2] }, "w3", { fill: "w3" });    g.pixel(ex - 1, ey + 1, "w4"); g.pixel(ex + 1, ey + 2, "w1");    g.pixel(ex - 1, ey + 3, "w2"); g.pixel(ex + 1, ey + 3, "w2"); g.pixel(ex, ey + 4, "w3");  });}// ---- sound rings (toll peaks) ------------------------------------------------------------------------function rings(g, th, age) {  // two arc pairs around the bell's belly; age 0 = strike (tight, bright), 1 = spreading (dithered)  const [cx, cy] = toWorld(0, 10, th);  g.detached("sound", () => {    for (let n = 0; n < 2; n++) {      const r = 11 + n * 3 + age * 3;      for (let a = -34; a <= 34; a += 4) {        for (const side of [-1, 1]) {          const x = Math.round(cx + side * Math.cos(a * RAD) * r), y = Math.round(cy + Math.sin(a * RAD) * r * 0.9);          if (age > 0 && !chk(x, y)) continue;          g.pixel(x, y, n === 0 && age === 0 ? "n5" : "g3");        }      }    }  });}// ---- motion ------------------------------------------------------------------------------------------const IDLE_N = 12;const TOLL = [0, 11, 21, 12, -3, -15, -20, -10, 2, 7, 3, 0];               // bell angle per frame (deg, + = right)const PSI = [0, -3, 5, 2, 3, 3, -5, -2, -2, 1, 0, 0];                        // clapper vs bell (deg); strikes at peaksconst RING = { 2: 0, 3: 1, 6: 0, 7: 1 };const BellFrame = sprite(pal, (g, t, state) => {  let th = 0, psi = 0, sway, f = -1;  if (state === "toll") {    f = Math.min(TOLL.length - 1, Math.round(t * TOLL.length));    th = TOLL[f] * RAD; psi = PSI[f] * RAD;    const prev = TOLL[Math.max(0, f - 1)];    sway = Math.round(-(TOLL[f] - prev) * 0.18 - TOLL[f] * 0.12);           // rope lags behind the swing    if (f === TOLL.length - 1) sway = 0;  } else sway = Math.round(1.6 * Math.sin(TAU * t));  contactShadow(g, CX, GROUND, 22, 2);  g.outlined("ink", () => {    footing(g, 1, true); footing(g, 38, false);    sill(g);    post(g, 3, true); post(g, 41, false);    braces(g);    beam(g);    cap(g);    hanger(g);  });  let ropeTop;  g.outlined("ink", () => { ropeTop = clapper(g, th, psi); });  g.outlined("ink", () => { bell(g, th); });  g.outlined("ink", () => { rope(g, ropeTop, sway); });  if (f in RING) rings(g, th, RING[f]);});submit(BellFrame, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: IDLE_N / 10, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "toll", args: ["toll"], animate: { fps: 10, duration: TOLL.length / 10, loop: false } },  ],});

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

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

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