Tessarune
Harvest Festival · windmill.js

風車

64×64 · JavaScript 292 行

石造りの土台に木造の本体、赤い屋根、穀物袋の吊り上げ機、そして 4 枚の格子羽根を備えた 64×64 の風車です。16 コマの idle(待機)では羽根が四分の一回転して袋が揺れ、8 コマの fast では残像を伴って速く回転します。16 コマの still では羽根が X 字に止まり、屋根にスズメが飛来してつついたあとに飛び去ります。

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

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

windmill.js · 1–10
// Windmill — 64x64 floor prop (large), top-down 3/4, key light top-left.// A small post windmill for the harvest fair: a round fieldstone roundhouse with a wooden ladder up the front, a// timber-framed weatherboarded body (corner posts, braces, an arched door and a little window), a barrel cap of red// shingles, a sack hoist on the right with a rope and a grain sack, and four lattice sails covered in cloth.// Variants://   idle   16f loop: the sails turn slowly, a quarter turn per loop (4 identical sails, so the loop closes);//                    the hoisted sack sways a little//   fast    8f loop: the sails spin twice as fast with blur arcs at the tips, the sack swings hard on its rope//   still  16f loop: sails stopped in an X with one sail's cloth rolled up on its stock (bare lattice), the other//                    cloths ripple; a sparrow flies in, lands on the cap, hops and pecks, then flies off

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

windmill.js · 282–292
submit(Windmill, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N_IDLE / 10, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle", args: ["idle"] },    { name: "fast", args: ["fast"], animate: { fps: 10, duration: N_FAST / 10, loop: true } },    { name: "still", args: ["still"] },  ],});
プログラム全体を表示 (292 行)
windmill.js · 292 行
// Windmill — 64x64 floor prop (large), top-down 3/4, key light top-left.// A small post windmill for the harvest fair: a round fieldstone roundhouse with a wooden ladder up the front, a// timber-framed weatherboarded body (corner posts, braces, an arched door and a little window), a barrel cap of red// shingles, a sack hoist on the right with a rope and a grain sack, and four lattice sails covered in cloth.// Variants://   idle   16f loop: the sails turn slowly, a quarter turn per loop (4 identical sails, so the loop closes);//                    the hoisted sack sways a little//   fast    8f loop: the sails spin twice as fast with blur arcs at the tips, the sack swings hard on its rope//   still  16f loop: sails stopped in an X with one sail's cloth rolled up on its stock (bare lattice), the other//                    cloths ripple; a sparrow flies in, lands on the cap, hops and pecks, then flies offconst W = 64, H = 64, GROUND = 60, CX = 32;const HUB = { x: 32, y: 22 };const R = 21, R0 = 6, SW = 6;                 // sail reach, where the lattice starts, lattice widthconst L = [-0.74, -0.67];                     // direction toward the key light (screen space, y down)const STONE = ["n0", "n1", "n2", "n3", "n4"];const WOOD = ["w0", "w1", "w2", "w3", "w4"];const CAP = ["n0", "r0", "r1", "r2", "r3"];const N_IDLE = 16, N_FAST = 8;// ---- fieldstone roundhouse ------------------------------------------------------------------------------------const BASE = { rx: 13, top: 47, bot: 57, ry: 3 };function base(g) {  const { rx, top, bot, ry } = BASE;  g.part("base", () => g.field({ x: [CX - rx, CX + rx - 1], y: [top - ry, bot + ry] }, (x, y) => {    const nx = (x + 0.5 - CX) / rx;    if (Math.abs(nx) > 1) return null;    const c = Math.sqrt(1 - nx * nx), yc = y + 0.5;    if (yc < top - ry * c || yc > bot + ry * c) return null;    if (yc < top + ry * c) {      // capstone ring on top, lit from the top-left      const a = -0.8 * nx - 0.4 * ((yc - top) / ry);      return a > 0.35 ? "n4" : a > -0.25 ? "n3" : "n2";    }    // courses curve with the wall; stones staggered row by row    const yy = yc - (top + ry * c);    const row = Math.floor(yy / 3), within = yy - row * 3;    const arc = Math.asin(nx) * rx + (row % 2) * 2.5;    const col = Math.floor(arc / 5), fc = arc / 5 - col;    const light = -0.85 * nx + 0.25 * c - 0.04 * yy;    let k = light > 0.45 ? 4 : light > 0.0 ? 3 : light > -0.45 ? 2 : 1;    if (within < 1 || fc < 0.16) return k >= 3 ? "n1" : "n0";         // mortar    const h = hash(col, row);    if (h > 0.78) k = Math.max(1, k - 1); else if (h < 0.12) k = Math.min(4, k + 1);    if (within > 2 && k > 1) k--;                                        // each stone's lower lip in shade    if (yy > bot - top + ry * c - 1.6) k = Math.max(0, k - 2);          // damp footing    return STONE[k];  }));}function ladder(g) {  g.part("ladder", () => {    for (const [x0, x1, lit] of [[30, 29, true], [34, 35, false]]) {      g.line([x0, 47], [x1, 59], lit ? "w3" : "w1");    }    for (let y = 49; y <= 58; y += 3) {      const xl = 30 - Math.round((y - 47) / 12), xr = 34 + Math.round((y - 47) / 12);      g.line([xl + 1, y], [xr - 1, y], "w3");      g.pixel(xl + 1, y, "w4");    }  });}// ---- timber body ----------------------------------------------------------------------------------------------const BODY = { x0: 21, x1: 43, y0: 25, y1: 46 };function body(g) {  const { x0, x1, y0, y1 } = BODY;  g.part("sideWall", () => {    // the right side in shade, receding up-right under the cap    g.fillPoly([[x1 + 1, y0], [x1 + 4, y0 - 4], [x1 + 4, y1 - 5], [x1 + 1, y1]], "w1");    for (const x of [x1 + 2, x1 + 4]) g.line([x, y0 - 1], [x, y1 - 2 - (x - x1)], "w0");    g.line([x1 + 1, y0 + 1], [x1 + 1, y1], "w2");    g.rect({ x: [x1 + 2, x1 + 3], y: [30, 32] }, "b0", { fill: "b0" });   // small side window    g.pixel(x1 + 2, 30, "b1");  });  g.part("frontWall", () => g.field({ x: [x0, x1], y: [y0, y1] }, (x, y) => {    // vertical weatherboards, a darker frame of posts/rails/braces, and the cap's shadow along the top    if (y <= y0 + 1) return y === y0 ? "w0" : "w1";    if (x <= x0 + 1) return x === x0 ? "w3" : "w2";                     // corner post, lit edge    if (x >= x1 - 1) return x === x1 ? "w1" : "w2";    if (y === 35 || y === 36) return y === 35 ? "w3" : "w1";             // mid rail    if (y >= y1 - 1) return y === y1 ? "w0" : "w1";                      // sole plate    const bL = y < 35 ? (x - x0) - (y - y0) * 0.75 : 99, bR = y < 35 ? (x1 - x) - (y - y0) * 0.75 : 99;    if (y < 35 && (Math.abs(bL - 2) < 0.8 || Math.abs(bR - 2) < 0.8)) return Math.abs(bL - 2) < 0.8 ? "w2" : "w1";    const plank = (x - x0 - 2) % 3;    if (plank === 2) return "w2";                                         // board seams    const lit = 0.55 - (x - x0) / (x1 - x0) * 0.6 - (y - y0) / (y1 - y0) * 0.3;    return lit > 0.3 ? "w4" : lit > -0.05 ? "w3" : "w2";  }));  g.part("window", () => {    g.rect({ x: [24, 27], y: [28, 31] }, "w0", { fill: "w0" });    g.rect({ x: [25, 26], y: [29, 30] }, "b0", { fill: "b0" });    g.pixel(25, 29, "b2");    g.line([24, 32], [27, 32], "w4");                                     // sill catching the light  });  g.part("door", () => {    g.fillPoly([[29, 46], [29, 40], [30, 38], [34, 38], [35, 40], [35, 46]], "w1");    g.line([30, 39], [30, 46], "w2"); g.line([32, 38], [32, 46], "w0"); g.line([34, 40], [34, 46], "w0");    g.line([29, 40], [30, 38], "w0"); g.line([30, 38], [34, 38], "w0"); g.line([34, 38], [35, 40], "w0");    g.line([30, 41], [33, 41], "n1"); g.line([30, 44], [33, 44], "n1");  // iron straps    g.pixel(30, 41, "n3"); g.pixel(34, 43, "g2");                         // hinge glint, brass latch  });}// ---- barrel cap of shingles ----------------------------------------------------------------------------------const ARCH = { cx: 32, cy: 25, rx: 14, ry: 10 }, BACK = [3, -6];function capSample(x, y) {  // first arch (front -> back) whose upper boundary has reached the pixel; s = 0 is the front gable  for (let i = 0; i <= 24; i++) {    const s = i / 24, cx = ARCH.cx + BACK[0] * s, cy = ARCH.cy + BACK[1] * s;    const nx = (x + 0.5 - cx) / ARCH.rx, ny = (y + 0.5 - cy) / ARCH.ry;    if (ny <= 0.05 && nx * nx + ny * ny <= 1) return { s, nx, ny, r: Math.sqrt(nx * nx + ny * ny) };  }  return null;}function cap(g) {  g.part("cap", () => g.field({ x: [17, 50], y: [8, 26] }, (x, y) => {    const c = capSample(x, y);    if (!c) return null;    if (c.s === 0 && c.r < 0.9) return null;                             // gable face drawn separately    if (c.s === 0) return Math.atan2(-c.ny, c.nx) > 1.2 ? "r2" : "r1";   // the front eave of the shingles    const ang = Math.atan2(-c.ny, c.nx);                                  // 0 right .. PI left    const lit = 0.9 * Math.cos(ang - 2.2) - 0.05;    let k = lit > 0.55 ? 3 : lit > 0.0 ? 2 : lit > -0.45 ? 1 : 0;    // shingle courses run along the cap; joints staggered course by course    const arcLen = ang * ARCH.rx, course = Math.floor(arcLen / 3);    if (arcLen - course * 3 < 0.9) k = Math.max(0, k - 1);    else if (((Math.floor(c.s * 3) + course) % 2 === 0) && c.s > 0.08 && Math.abs(c.s * 3 - Math.round(c.s * 3)) < 0.12) k = Math.max(0, k - 1);    if (k === 3 && arcLen > 30 && arcLen < 33) return "r3";              // sheen on the lit shoulder    return CAP[k];  }));  g.part("gable", () => g.field({ x: [18, 46], y: [14, 25] }, (x, y) => {    const c = capSample(x, y);    if (!c || c.s !== 0 || c.r >= 0.9) return null;    if (c.r > 0.78) return "w0";                                          // shadow under the eave    if (y % 2 === 1) return "w1";                                         // horizontal boards    return x < 30 ? "w3" : "w2";  }));}// ---- sack hoist ---------------------------------------------------------------------------------------------function hoist(g, swing) {  const P = [53, 22], len = 14, a = (swing * Math.PI) / 180;  const end = [Math.round(P[0] + Math.sin(a) * len), Math.round(P[1] + Math.cos(a) * len)];  g.outlined("ink", () => {    g.part("hoistBeam", () => {      g.line([47, 22], [54, 20], "w2"); g.line([47, 21], [53, 19], "w3");      g.rect({ x: [52, 54], y: [21, 22] }, "n1", { fill: "n1" }); g.pixel(52, 21, "n3");   // pulley block    });    g.part("rope", () => g.line([P[0], P[1] + 1], [end[0], end[1] - 1], "w4"));    g.part("sack", () => {      const [sx, sy] = end;      g.field({ x: [sx - 3, sx + 3], y: [sy, sy + 7] }, (x, y) => {        const i = litIndex(x, y, sx + 0.5, sy + 4.5, 3.4, 3.8, 4, 0.05);        if (i < 0) return y <= sy + 1 && Math.abs(x - sx) <= 1 ? "w2" : null;   // the gathered neck        return ["w1", "w2", "w3", "w4"][i];      });      g.pixel(sx, sy + 1, "w0"); g.pixel(sx - 1, sy + 2, "w1");         // tie      g.pixel(sx + 1, sy + 5, "w2"); g.pixel(sx - 1, sy + 4, "w4");       // a fold and a stencil mark    });  });}// ---- sails ------------------------------------------------------------------------------------------------------// one field over the whole canvas; every pixel is inverse-rotated into each sail's own frame (u along, v across),// so the sails turn as solid geometry, never as moving single pixels.function sailAt(x, y, ang, rolledK, ripple) {  const dx = x + 0.5 - HUB.x, dy = y + 0.5 - HUB.y;  const r = Math.hypot(dx, dy);  if (r > R + 0.6 || r < 2) return null;  for (let k = 0; k < 4; k++) {    const th = ang + (k * Math.PI) / 2, c = Math.cos(th), s = Math.sin(th);    const u = dx * c + dy * s, v = -dx * s + dy * c;    if (u < 0) continue;    const nl = -s * L[0] + c * L[1];                                      // how much +v faces the light    if (v >= -1.7 && v < 0 && u <= R + 0.5) {                             // the stock (whip)      const side = v < -0.85 ? -nl : nl;      return side > 0.2 ? "w3" : side > -0.4 ? "w2" : "w1";    }    if (v >= 0 && v < SW && u >= R0 && u <= R + 0.5) {      const rail = u > R - 0.6 || u < R0 + 0.9 || v > SW - 1;      const bar = Math.abs(u - R0 - 3 * Math.round((u - R0) / 3)) < 0.5;      if (k === rolledK) {        if (v < 2.4) return Math.abs(u - 10) < 0.6 || Math.abs(u - 17) < 0.6 ? "r1" : (v < 1.2 ? (nl < 0 ? "n5" : "n4") : (nl < 0 ? "n4" : "n3"));        if (rail || bar) return nl > 0 ? "w2" : "w3";        return "ink";                                                     // the open lattice: dark gaps between bars      }      if (rail) return (v > SW - 1 ? nl : -nl) > 0 ? "w2" : "w1";      // cloth bellied by the wind: one edge turns toward the light, the other away      const vc = (v - SW / 2) / (SW / 2);      let lit = 0.5 + 0.55 * vc * nl + ripple(u);      if (bar) lit -= 0.5;      return lit > 0.72 ? "n5" : lit > 0.32 ? "n4" : "n3";    }  }  return null;}function sails(g, ang, rolledK, ripple) {  g.outlined("ink", () => {    g.part("sails", () => g.field({ x: [HUB.x - R - 1, HUB.x + R + 1], y: [HUB.y - R - 1, HUB.y + R + 1] },      (x, y) => sailAt(x, y, ang, rolledK, ripple)));    g.part("hub", () => g.field({ x: [HUB.x - 3, HUB.x + 3], y: [HUB.y - 3, HUB.y + 3] }, (x, y) => {      const i = litIndex(x, y, HUB.x + 0.5, HUB.y + 0.5, 3, 3, 4, 0.05);      return i < 0 ? null : ["n0", "n1", "n2", "n4"][i];    }));  });  g.part("hubBolt", () => { g.pixel(HUB.x, HUB.y, "n1"); g.pixel(HUB.x - 1, HUB.y - 1, "n5"); });}function blur(g, ang) {  // fast: a dithered ghost of each sail trailing behind it  g.detached("blur", () => g.field({ x: [HUB.x - R, HUB.x + R], y: [HUB.y - R, HUB.y + R] }, (x, y) => {    const dx = x + 0.5 - HUB.x, dy = y + 0.5 - HUB.y, r = Math.hypot(dx, dy);    if (r > R || r < R0 + 2) return null;    let a = Math.atan2(dy, dx) - ang;    a = ((a % (Math.PI / 2)) + Math.PI / 2) % (Math.PI / 2);          // angle behind the nearest sail stock    const d = a - SW / r;                                               // past the cloth's far edge    if (d < 0) return null;    if (r > R - 1.3) return d < 0.16 ? "n4" : d < 0.36 && chk(x, y) ? "n3" : null;   // tip streak    if (r > R - 8 && d < 0.1) return chk(x, y) ? "n3" : null;           // ghost of the cloth    return null;  }));}// ---- sparrow (still) -----------------------------------------------------------------------------------------const capTop = (x) => Math.ceil(ARCH.cy + BACK[1] - ARCH.ry * Math.sqrt(Math.max(0, 1 - ((x + 0.5 - ARCH.cx - BACK[0]) / ARCH.rx) ** 2)));function bird(g, f) {  // [x, y(feet), pose, facing]  pose: fly-up, fly-down, stand, hop, peck  const ky = (x) => capTop(x) - 1;  const K = [    null,    [60, 2, "up", -1], [55, 2, "down", -1], [48, 4, "up", -1], [39, ky(39) - 2, "down", -1],    [36, ky(36), "stand", -1], [36, ky(36), "stand", -1], [35, ky(35) - 2, "hop", -1], [34, ky(34), "stand", -1],    [34, ky(34), "peck", -1], [34, ky(34), "stand", -1], [33, ky(33) - 2, "hop", -1], [32, ky(32), "stand", 1],    [32, ky(32), "peck", 1], [29, ky(29) - 3, "up", -1], [22, 2, "down", -1],  ][f];  if (!K) return;  const [bx, by, p, dir] = K;  const px = (dx, dy, k) => g.pixel(bx + dx * dir, by + dy, k);  g.outlined("ink", () => g.part("bird", () => {    if (p === "up" || p === "down") {      px(-2, -1, "g2"); px(-1, -1, "n0"); px(0, -1, "n1"); px(1, -1, "n1"); px(2, -1, "n0"); px(3, -1, "n0"); px(4, -2, "n0");      px(0, 0, "n5"); px(1, 0, "n4");      if (p === "up") { px(0, -2, "n3"); px(1, -2, "n2"); px(1, -3, "n2"); px(2, -3, "n1"); }      else { px(0, 1, "n2"); px(1, 1, "n1"); px(1, 2, "n1"); }      return;    }    const dy = p === "peck" ? 1 : 0;    px(-2, -3 + dy, "g2"); px(-1, -3 + dy, "n0"); px(0, -3 + dy, "n1"); px(-1, -4 + dy, "n1");    px(-1, -2, "n1"); px(0, -2, "n5"); px(1, -2, "n2"); px(2, -2, "n3"); px(3, -2, "n0"); px(4, -3, "n0");    px(0, -1, "n4"); px(1, -1, "n5"); px(2, -1, "n1");    if (p !== "hop") { px(0, 0, "g0"); px(2, 0, "g0"); }  }));}// ---- assembly --------------------------------------------------------------------------------------------------function scene(g, ang, swing, rolledK, ripple) {  contactShadow(g, CX, GROUND, 17, 2);  g.outlined("ink", () => { base(g); body(g); cap(g); });  g.outlined("ink", () => ladder(g));  hoist(g, swing);}const Windmill = sprite(pal, (g, t, mode) => {  t = ((t % 1) + 1) % 1;  const still = () => 0;  if (mode === "still") {    const f = Math.round(t * N_IDLE) % N_IDLE;    const ang = Math.PI / 4 + 0.08;    scene(g, ang, 0, 0, (u) => 0.13 * Math.sin(TAU * 2 * t - u * 0.7));    sails(g, ang, 0, (u) => 0.13 * Math.sin(TAU * 2 * t - u * 0.7));    bird(g, f);    return;  }  const fast = mode === "fast";  const ang = -(Math.PI / 2) * t + 0.3;                                   // a quarter turn per loop, anticlockwise  scene(g, ang, fast ? 20 * Math.sin(TAU * t) : 4 * Math.sin(TAU * t), -1, still);  if (fast) blur(g, ang);  sails(g, ang, -1, still);});submit(Windmill, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N_IDLE / 10, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle", args: ["idle"] },    { name: "fast", args: ["fast"], animate: { fps: 10, duration: N_FAST / 10, loop: true } },    { name: "still", args: ["still"] },  ],});

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

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

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