Harvest Festival · windmill.js
風車
石造りの土台に木造の本体、赤い屋根、穀物袋の吊り上げ機、そして 4 枚の格子羽根を備えた 64×64 の風車です。16 コマの idle(待機)では羽根が四分の一回転して袋が揺れ、8 コマの fast では残像を伴って速く回転します。16 コマの still では羽根が X 字に止まり、屋根にスズメが飛来してつついたあとに飛び去ります。
6×
動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。