Tessarune
Harvest Festival · festival_lanterns.js

祭りの提灯

64×48 · JavaScript 197 行

2 本の木柱の間に渡したロープに、4 つの提灯と 5 枚の小旗を吊るした 64×48 の装飾小物です。16 コマの idle(待機)では光の輪を伴って蝋燭の火が揺らめき、12 コマの windy では左からの風を受けて提灯と旗が大きく揺れ動きます。16 コマの unlit(消灯)では明かりが消えた昼間の提灯が穏やかに揺れます。

祭りの提灯 — 描画結果6×
このプログラムで描画(変種「idle」) · 64×48 · 16 コマ
Harvest Festival動くスプライト素材: Harvest Festival(itch.io)Vol.6・収穫祭の小物 12 点・37 アニメ

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

festival_lanterns.js · 1–12
// Festival lanterns — 64x48 floor prop, top-down 3/4, key light top-left.// Two wooden posts (pointed tops, a sprig of wheat tucked into each rope knot, a little mound of earth and grass at// the foot) carry a sagging rope strung with four round paper lanterns — orange, red, violet and gold, each with a// dark cap, ribbed paper, a bottom ring and a tassel — and five small cloth bunting flags (green, cream, blue)// hung between them. Lantern glows are detached dithered halos; posts have contact shadows.// Variants://   idle    16f loop: the candles flicker (each lantern on its own phase), the halos breathe, the lanterns sway on//                     their cords with the tassels trailing, the rope bobs, the flags flutter//   windy   12f loop: a steady breeze from the left — the lanterns swing hard to the right and back, the tassels//                     whip, the rope bellies, the flags snap out sideways//   unlit   16f loop: daytime — the paper is unlit (lit from the top-left like any other surface), gentle sway,//                     flags flutter

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

festival_lanterns.js · 187–197
submit(Lanterns, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N_IDLE / 10, loop: true },  anchor: { x: 32, y: GROUND },  args: ["idle"],  variants: [    { name: "idle", args: ["idle"] },    { name: "windy", args: ["windy"], animate: { fps: 10, duration: N_WIND / 10, loop: true } },    { name: "unlit", args: ["unlit"] },  ],});
プログラム全体を表示 (197 行)
festival_lanterns.js · 197 行
// Festival lanterns — 64x48 floor prop, top-down 3/4, key light top-left.// Two wooden posts (pointed tops, a sprig of wheat tucked into each rope knot, a little mound of earth and grass at// the foot) carry a sagging rope strung with four round paper lanterns — orange, red, violet and gold, each with a// dark cap, ribbed paper, a bottom ring and a tassel — and five small cloth bunting flags (green, cream, blue)// hung between them. Lantern glows are detached dithered halos; posts have contact shadows.// Variants://   idle    16f loop: the candles flicker (each lantern on its own phase), the halos breathe, the lanterns sway on//                     their cords with the tassels trailing, the rope bobs, the flags flutter//   windy   12f loop: a steady breeze from the left — the lanterns swing hard to the right and back, the tassels//                     whip, the rope bellies, the flags snap out sideways//   unlit   16f loop: daytime — the paper is unlit (lit from the top-left like any other surface), gentle sway,//                     flags flutterconst W = 64, H = 48, GROUND = 45;const N_IDLE = 16, N_WIND = 12;const POSTS = [5, 58];const ROPE = { x0: 7, x1: 56, y: 9 };const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];const dith = (x, y, dens) => BAYER[(y & 3) * 4 + (x & 3)] / 16 < dens;// [x on the rope, cord length, colour ramps, sway phase, flicker phase]// lit: [edge, mid, bright, core]   unlit: [shadow, base, light, highlight]const LANTERNS = [  { x: 13, cord: 3, lit: ["r1", "r2", "r3", "g3"], day: ["r0", "r1", "r2", "r3"], ph: 0.0, fph: 0.13 },  { x: 25, cord: 4, lit: ["r0", "r1", "r2", "r3"], day: ["r0", "r0", "r1", "r2"], ph: 0.27, fph: 0.61 },  { x: 38, cord: 4, lit: ["v1", "v2", "v3", "n5"], day: ["v0", "v1", "v2", "v3"], ph: 0.55, fph: 0.37 },  { x: 49, cord: 3, lit: ["g1", "g2", "g3", "n5"], day: ["g0", "g1", "g2", "g3"], ph: 0.8, fph: 0.85 },];const FLAGS = [  { x: 9, k: ["e2", "e1"], ph: 0.1 },  { x: 19, k: ["n5", "n4"], ph: 0.42 },  { x: 31, k: ["b2", "b1"], ph: 0.74 },  { x: 44, k: ["n5", "n4"], ph: 0.2 },  { x: 54, k: ["e2", "e1"], ph: 0.57 },];// ---- rope ------------------------------------------------------------------------------------------------------function ropeY(x, sag) {  const u = (x - (ROPE.x0 + ROPE.x1) / 2) / ((ROPE.x1 - ROPE.x0) / 2);  return ROPE.y + sag * (1 - u * u);}function rope(g, sag) {  g.part("rope", () => {    for (let x = ROPE.x0; x <= ROPE.x1; x++) {      const y = Math.round(ropeY(x, sag));      g.pixel(x, y, x % 3 === 0 ? "w2" : "w3");                       // twisted strands      const y2 = Math.round(ropeY(x + 1, sag));      if (x < ROPE.x1 && Math.abs(y2 - y) > 1) g.line([x, y], [x, y2 + (y2 > y ? -1 : 1)], "w2");   // keep it unbroken    }  });}// ---- posts -----------------------------------------------------------------------------------------------------function post(g, cx, lit) {  g.outlined("ink", () => g.part(lit ? "postL" : "postR", () => {    for (let y = 6; y <= 44; y++) {      g.pixel(cx - 1, y, "w2"); g.pixel(cx, y, "w1"); g.pixel(cx + 1, y, "w0");    }    g.pixel(cx, 4, "w2"); g.pixel(cx - 1, 5, "w3"); g.pixel(cx, 5, "w2"); g.pixel(cx + 1, 5, "w1");   // pointed top    for (const y of [13, 14, 27, 28, 29, 38]) g.pixel(cx - 1, y, "w3");                              // lit streaks    g.pixel(cx, 21, "w0"); g.pixel(cx, 33, "w0"); g.pixel(cx + 1, 17, "ink");                          // knots, a split  }));}function knot(g, cx, side) {  g.part(side < 0 ? "knotL" : "knotR", () => {    g.line([cx - 1, 8], [cx + 1, 8], "w4"); g.line([cx - 1, 10], [cx + 1, 10], "w3");               // rope wraps    g.pixel(cx + 1, 9, "w3"); g.pixel(cx - 1, 9, "w2");    g.line([cx - side, 11], [cx - side, 13], "w2");                                                    // loose end  });  g.part(side < 0 ? "wheatL" : "wheatR", () => {                    // a sprig of wheat tucked into the knot    const d = -side;    g.line([cx, 9], [cx + d * 3, 4], "g0");    g.pixel(cx + d * 3, 4, "g2"); g.pixel(cx + d * 3, 3, "g3"); g.pixel(cx + d * 2, 5, "g1");    g.pixel(cx + d * 4, 5, "g2"); g.pixel(cx + d * 4, 4, "g1");  });}function footing(g, cx, lit) {  g.outlined("ink", () => g.part(lit ? "moundL" : "moundR", () => g.field({ x: [cx - 4, cx + 4], y: [42, 45] }, (x, y) => {    const nx = (x + 0.5 - (cx + 0.5)) / 4.2, ny = (y + 0.5 - 45) / 2.6;    if (nx * nx + ny * ny > 1 || y > 45) return null;    return -0.8 * nx - 0.6 * ny > 0.45 ? "w2" : nx < 0.3 ? "w1" : "w0";  })));  g.part(lit ? "grassL" : "grassR", () => {    for (const [dx, h, k] of [[-3, 3, "e2"], [-2, 4, "e1"], [2, 3, "e1"], [3, 2, "e2"]]) {      g.line([cx + dx, 44], [cx + dx + (dx < 0 ? -1 : 1), 44 - h], k);      g.pixel(cx + dx + (dx < 0 ? -1 : 1), 44 - h, k === "e2" ? "e3" : "e2");    }  });}// ---- flags -----------------------------------------------------------------------------------------------------// a cloth triangle hung from the rope: top edge follows the rope, the tip flutters (and snaps out in the wind)function flag(g, f, sag, t, wind) {  const a = Math.sin(TAU * (2 * t + f.ph)), b = Math.sin(TAU * (3 * t + f.ph * 1.7));  const tipDx = wind ? Math.round(2.2 + 1.3 * a) : Math.round(0.9 * a);  const tipH = wind ? 5 - Math.round(1 + 0.8 * (0.5 + 0.5 * b)) : 5;  const yl = ropeY(f.x - 2, sag), yr = ropeY(f.x + 2, sag), yc = ropeY(f.x, sag);  const P = [[f.x - 2.5, yl + 0.5], [f.x + 2.5, yr + 0.5], [f.x + tipDx, yc + tipH + 0.5]];  const side = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax);  g.outlined("ink", () => g.part("flag", () => g.field({ x: [f.x - 4, f.x + 5], y: [Math.floor(Math.min(yl, yr)), Math.ceil(yc + tipH + 1)] }, (x, y) => {    const px = x + 0.5, py = y + 0.5;    const s1 = side(...P[0], ...P[1], px, py), s2 = side(...P[1], ...P[2], px, py), s3 = side(...P[2], ...P[0], px, py);    if (!((s1 >= 0 && s2 >= 0 && s3 >= 0) || (s1 <= 0 && s2 <= 0 && s3 <= 0))) return null;    const mid = f.x + (tipDx * (py - yc) / tipH) * 0.6;                 // the fold runs from the top centre to the tip    return px < mid + 0.2 ? f.k[0] : f.k[1];  })));}// ---- lanterns --------------------------------------------------------------------------------------------------const BODY_RX = 4.2, BODY_RY = 4.1;function lanternGeom(L, sag, swing) {  const ax = L.x, ay = Math.round(ropeY(L.x, sag));  const len = L.cord + 5;  const off = Math.round(len * Math.sin(swing));  const top = ay + L.cord;                                           // cap row  return { ax, ay, cx: L.x + off, top, cy: top + 6, off };}function halo(g, L, G, amt) {  g.detached("halo", () => g.field({ x: [G.cx - 9, G.cx + 9], y: [G.cy - 9, G.cy + 9] }, (x, y) => {    const r = Math.hypot(x + 0.5 - (G.cx + 0.5), (y + 0.5 - G.cy) * 1.1);    const R = 7.5 * amt;    if (r > R || r < 4.6) return null;    return dith(x, y, 0.34 * (1 - (r - 4.6) / (R - 4.6))) ? L.lit[1] : null;  }));}function lantern(g, i, L, G, lit, flick, tassel) {  const { cx, cy, top } = G;  g.part("cord" + i, () => g.line([G.ax, G.ay + 1], [cx, top - 1], "n1"));  g.outlined("ink", () => g.part("lantern" + i, () => {    g.line([cx - 1, top], [cx + 1, top], "n1"); g.pixel(cx - 1, top, "n3");          // cap    g.line([cx - 2, top + 1], [cx + 2, top + 1], "n0"); g.pixel(cx - 2, top + 1, "n2");    g.field({ x: [cx - 5, cx + 5], y: [top + 2, top + 9] }, (x, y) => {      const nx = (x + 0.5 - (cx + 0.5)) / BODY_RX, ny = (y + 0.5 - cy) / BODY_RY;      if (nx * nx + ny * ny > 1) return null;      const rib = y === cy - 2 || y === cy + 1;      if (lit) {        // light from the candle near the bottom: brightest low in the middle, falling off to the rim        const d = Math.hypot(nx, (ny - 0.25) * 1.1) / flick;        let k = d > 0.85 ? 0 : d > 0.6 ? 1 : d > 0.3 ? 2 : 3;        if (rib && k < 3) k = Math.max(0, k - 1);                    // ribs show against the paper, not the flame        return L.lit[k];      }      let k = litIndex(x, y, cx + 0.5, cy, BODY_RX, BODY_RY, 4, 0.1);      if (k === 3 && !(nx < -0.2 && ny < -0.15)) k = 2;              // keep the highlight a small top-left patch      if (rib) k = Math.max(0, k - 1);      return L.day[k];    });    g.line([cx - 2, top + 10], [cx + 2, top + 10], "n0"); g.pixel(cx - 2, top + 10, "n2");   // bottom ring  }));  g.part("tassel" + i, () => {    const tb = Math.round(tassel);    g.pixel(cx, top + 11, "n1");    g.line([cx, top + 12], [cx + tb, top + 14], lit ? L.lit[1] : L.day[1]);    g.pixel(cx + tb, top + 14, lit ? L.lit[2] : L.day[2]);  });}// ---- scene -----------------------------------------------------------------------------------------------------function scene(g, t, mode) {  const wind = mode === "windy", lit = mode !== "unlit";  const sag = wind ? 11.6 + 1.3 * Math.sin(TAU * t) : 11 + 0.6 * Math.sin(TAU * t);  for (const cx of POSTS) contactShadow(g, cx, GROUND, 5, 1);  const G = LANTERNS.map((L) => {    const swing = wind      ? (0.22 + 0.16 * Math.sin(TAU * (t + L.ph * 0.5))) + 0.06 * Math.sin(TAU * (2 * t + L.ph))      : 0.13 * Math.sin(TAU * (t + L.ph));    return lanternGeom(L, sag, swing);  });  const flicks = LANTERNS.map((L) => 1 + 0.07 * Math.sin(TAU * (2 * t + L.fph)) + 0.05 * Math.sin(TAU * (3 * t + 2 * L.fph)));  if (lit) LANTERNS.forEach((L, i) => halo(g, L, G[i], 0.95 + 0.6 * (flicks[i] - 1)));  rope(g, sag);  for (const f of FLAGS) flag(g, f, sag, t, wind);  LANTERNS.forEach((L, i) => {    const lag = wind ? Math.sin(TAU * (t + L.ph * 0.5 - 0.15)) : Math.sin(TAU * (t + L.ph - 0.12));    const tassel = wind ? 1.6 + 1.2 * lag : 0.9 * lag;    lantern(g, i, L, G[i], lit, flicks[i], tassel);  });  post(g, POSTS[0], true); post(g, POSTS[1], false);  knot(g, POSTS[0], -1); knot(g, POSTS[1], 1);  footing(g, POSTS[0], true); footing(g, POSTS[1], false);}const Lanterns = sprite(pal, (g, t, mode) => {  t = ((t % 1) + 1) % 1;  scene(g, t, mode);});submit(Lanterns, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N_IDLE / 10, loop: true },  anchor: { x: 32, y: GROUND },  args: ["idle"],  variants: [    { name: "idle", args: ["idle"] },    { name: "windy", args: ["windy"], animate: { fps: 10, duration: N_WIND / 10, loop: true } },    { name: "unlit", args: ["unlit"] },  ],});

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

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

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