Witch's Hut · spinning_wheel.js
糸車
48×48 の床置きの小物で、脚を開いた三本脚の台に 8 本スポークの大きな車輪を載せた、古いサクソニー型の木の糸車です。糸を巻いた太いボビン、針のように尖った鉄の錘、赤いリボンで結んだ羊毛の糸巻き棒、踏み板が付いています。変種はどれも 12 コマのループで、idle は止まったまま錘を光が走ります。spin は車輪がループごとに半回転し(スポークが 8 本なのでループがつながります)、踏み板が上下します。cursed はひとりでに回り、紫の糸と紫の火花、錘の先が光ります。
6×
動くスプライト素材: Witch's Hut(itch.io)Vol.5・ハロウィンの小物 12 点・42 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
spinning_wheel.js · 1–9
// Spinning wheel — 48x48 floor prop, top-down 3/4, key light top-left.// An old Saxony-style wooden spinning wheel: a big eight-spoked wheel on a splayed three-legged bench, the// mother-of-all with two finialed maidens holding the flyer and a fat bobbin of yarn, a long iron spindle with a// needle-sharp tip, a distaff of fleece tied with a red ribbon, and a treadle linked to the crank by a footman.// Variants (12f loops @ 10 fps):// idle still; a glint runs along the spindle and flashes on its tip// spin the wheel turns (half a turn per loop, 8 spokes, so the loop closes), the flyer whirls, the yarn runs// from the fleece to the spindle, the treadle pumps// cursed spinning by itself: violet yarn, violet sparks, the spindle tip glows最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
spinning_wheel.js · 322–332
submit(SpinningWheel, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: 24, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "spin", args: ["spin"] }, { name: "cursed", args: ["cursed"] }, ],});プログラム全体を表示 (332 行)
spinning_wheel.js · 332 行
// Spinning wheel — 48x48 floor prop, top-down 3/4, key light top-left.// An old Saxony-style wooden spinning wheel: a big eight-spoked wheel on a splayed three-legged bench, the// mother-of-all with two finialed maidens holding the flyer and a fat bobbin of yarn, a long iron spindle with a// needle-sharp tip, a distaff of fleece tied with a red ribbon, and a treadle linked to the crank by a footman.// Variants (12f loops @ 10 fps):// idle still; a glint runs along the spindle and flashes on its tip// spin the wheel turns (half a turn per loop, 8 spokes, so the loop closes), the flyer whirls, the yarn runs// from the fleece to the spindle, the treadle pumps// cursed spinning by itself: violet yarn, violet sparks, the spindle tip glowsconst W = 48, H = 48, GROUND = 44;const WC = { x: 14, y: 20 }, R = 11;const L = [-0.74, -0.67];const WOOD = ["w0", "w1", "w2", "w3", "w4"];const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];const dith = (x, y, d) => BAYER[(y & 3) * 4 + (x & 3)] / 16 < d;// a wooden limb swept along a path, shaded as a cylinder lit from the top-leftfunction limb(g, path, w0, w1, opt = {}) { const a = path[0], b = path[path.length - 1]; const dx = b[0] - a[0], dy = b[1] - a[1], len = Math.hypot(dx, dy) || 1; const nl = (-dy / len) * L[0] + (dx / len) * L[1]; g.sweep(path, { w0, w1, wobble: opt.wobble ?? 0, wobblePhase: opt.ph ?? 0.5 }, (u, v) => { const l = v * nl * 1.3 + 0.1 - (opt.dark || 0); return WOOD[l > 0.85 ? 4 : l > 0.15 ? 3 : l > -0.4 ? 2 : l > -0.85 ? 1 : 0]; });}// ---- the wheel ----------------------------------------------------------------------------------------------function rim(g, ang) { g.part("wheelRim", () => g.field({ x: [WC.x - R - 1, WC.x + R + 1], y: [WC.y - R - 1, WC.y + R + 1] }, (x, y) => { const dx = x - WC.x, dy = y - WC.y, r = Math.hypot(dx, dy); if (r > R + 0.45 || r < R - 2.5) return null; const nx = dx / r, ny = dy / r, lit = L[0] * nx + L[1] * ny; // felloe joints, between the spokes, turn with the wheel const a = Math.atan2(dy, dx) - ang - TAU / 16; const s = a / (TAU / 8), d = (s - Math.round(s)) * (TAU / 8); if (r < R - 0.5 && Math.abs(Math.sin(d)) * r < 0.6) return lit > 0.2 ? "w1" : "w0"; if (r > R - 0.55) return lit > 0.55 ? "w4" : lit > 0 ? "w3" : lit > -0.55 ? "w2" : "w1"; if (r > R - 1.55) return lit > 0.3 ? "w3" : lit > -0.45 ? "w2" : "w1"; return -lit > 0.3 ? "w3" : -lit > -0.4 ? "w2" : "w1"; // inner face: lit at the bottom of the ring }));}function spokes(g, ang) { g.part("spokes", () => { for (let k = 0; k < 8; k++) { const a = ang + (k * TAU) / 8, c = Math.cos(a), s = Math.sin(a); const lit = L[0] * c + L[1] * s; const p0 = [Math.round(WC.x + 2.4 * c), Math.round(WC.y + 2.4 * s)]; const pm = [Math.round(WC.x + 5.2 * c), Math.round(WC.y + 5.2 * s)]; const p1 = [Math.round(WC.x + (R - 2.3) * c), Math.round(WC.y + (R - 2.3) * s)]; g.line(p0, p1, lit > -0.2 ? "w3" : "w2"); g.line(p0, pm, lit > -0.2 ? "w4" : "w3"); // turned bead near the hub } });}function hub(g) { g.part("hub", () => { g.field({ x: [WC.x - 3, WC.x + 3], y: [WC.y - 3, WC.y + 3] }, (x, y) => { const i = litIndex(x, y, WC.x + 0.5, WC.y + 0.5, 3, 3, 4, 0.05); return i < 0 ? null : WOOD[i + 1]; }); });}// ---- bench, legs, treadle ---------------------------------------------------------------------------------function bench(g) { g.part("legs", () => { limb(g, [[23, 35], [24, 39], [24, 41]], 1.0, 0.9, { dark: 0.5 }); // far leg, in shadow limb(g, [[6, 35], [5, 40], [3, 44]], 1.3, 1.1); limb(g, [[40, 35], [42, 40], [43, 44]], 1.3, 1.1, { dark: 0.2 }); }); g.part("bench", () => { // lit top face, then the front face; the left end shows its end grain g.rect({ x: [2, 44], y: [31, 32] }, "w3", { fill: "w3" }); g.line([3, 31], [20, 31], "w4"); g.rect({ x: [2, 44], y: [33, 35] }, "w2", { fill: "w2" }); g.line([2, 35], [44, 35], "w1"); g.line([3, 33], [44, 33], "w1"); // shadow under the lip g.rect({ x: [2, 3], y: [33, 35] }, "w3", { fill: "w3" }); // end grain g.pixel(2, 34, "w2"); // grain and a couple of old nicks g.line([8, 34], [12, 34], "w1"); g.line([27, 34], [31, 34], "w1"); g.pixel(37, 32, "w2"); g.pixel(24, 32, "w2"); g.pixel(17, 34, "w3"); g.pixel(34, 34, "w3"); });}function treadle(g, yT) { g.part("treadle", () => { // a broad plank hinged at the back-left; its free end rides up and down with the crank g.fillPoly([[8, 40], [27, yT - 2], [27, yT], [8, 42]], "w3"); g.line([8, 42], [27, yT], "w2"); g.line([8, 43], [27, yT + 1], "w1"); g.line([8, 40], [16, 40 + Math.round(((yT - 42) * 8) / 19)], "w4"); g.pixel(8, 41, "n3"); g.pixel(8, 42, "n1"); // iron hinge pin g.pixel(20, 41 + Math.round(((yT - 42) * 12) / 19), "w2"); // a worn foot mark });}// ---- mother-of-all, flyer, spindle --------------------------------------------------------------------------function motherOfAll(g) { g.part("tension", () => { g.rect({ x: [32, 34], y: [29, 30] }, "w2", { fill: "w2" }); g.pixel(32, 29, "w3"); g.pixel(34, 30, "w1"); }); g.part("mother", () => { g.rect({ x: [26, 40], y: [26, 26] }, "w3", { fill: "w3" }); g.rect({ x: [26, 40], y: [27, 28] }, "w2", { fill: "w2" }); g.line([26, 28], [40, 28], "w1"); g.line([26, 26], [31, 26], "w4"); g.pixel(26, 27, "w3"); }); for (const [x, nm] of [[28, "maidenL"], [37, "maidenR"]]) { g.part(nm, () => { g.rect({ x: [x, x + 1], y: [18, 25] }, "w2", { fill: "w2" }); g.line([x, 18], [x, 25], "w3"); g.pixel(x + 1, 21, "w1"); g.pixel(x + 1, 23, "w1"); g.rect({ x: [x - 1, x + 2], y: [21, 23] }, "w0", { fill: "w0" }); // leather bearing g.pixel(x - 1, 21, "w1"); // ball finial g.ellipse({ x: [x - 1, x + 2], y: [14, 17] }, "w2", { fill: "w2" }); g.pixel(x - 1, 15, "w3"); g.pixel(x, 14, "w4"); g.pixel(x, 15, "w3"); g.pixel(x + 2, 16, "w1"); g.pixel(x + 1, 17, "w1"); }); }}function flyer(g, phi) { g.part("whorl", () => { g.ellipse({ x: [25, 27], y: [19, 25] }, "w2", { fill: "w2" }); g.line([25, 20], [25, 23], "w3"); g.pixel(26, 19, "w3"); g.line([27, 21], [27, 24], "w1"); g.pixel(26, 22, "w0"); // the groove the drive band rides in }); g.part("spindle", () => { g.line([24, 22], [38, 22], "n2"); // the long needle-sharp tip past the right maiden g.line([39, 21], [41, 21], "n3"); g.pixel(39, 21, "n4"); g.line([39, 22], [41, 22], "n2"); g.line([42, 22], [44, 22], "n3"); g.pixel(42, 22, "n4"); g.pixel(45, 22, "n5"); }); g.part("bobbin", () => { g.rect({ x: [31, 31], y: [18, 26] }, "w3", { fill: "w3" }); g.rect({ x: [35, 35], y: [18, 26] }, "w2", { fill: "w2" }); g.pixel(31, 18, "w4"); g.pixel(35, 26, "w1"); // the yarn wound on it: a soft fat roll, lit top-left, with a few wraps marked g.field({ x: [32, 34], y: [19, 25] }, (x, y) => { const i = litIndex(x, y, 33.5, 22.5, 2.2, 3.8, 3, 0.15); return ["w2", "w4", "n5"][Math.max(0, i)]; }); g.pixel(33, 20, "w4"); g.pixel(34, 22, "w3"); g.pixel(33, 24, "w3"); }); // flyer arms whirl round the spindle: seen side-on they swing above and below it const off = Math.round(4 * Math.cos(phi)); g.part("flyer", () => { for (const s of [1, -1]) { const y = 22 + s * off; g.line([30, y], [36, y], s * off <= 0 ? "w4" : "w2"); g.pixel(33, y, "g2"); g.pixel(35, y, "g1"); // brass hooks } g.line([30, 22 - Math.abs(off)], [30, 22 + Math.abs(off)], "w1"); });}// ---- distaff and fleece -------------------------------------------------------------------------------------function distaff(g) { g.part("distaff", () => { g.line([33, 0], [33, 25], "w2"); g.pixel(33, 0, "w3"); g.pixel(33, 16, "w3"); g.pixel(33, 18, "w3"); }); g.part("fleece", () => { g.field({ x: [25, 42], y: [2, 15] }, (x, y) => { // a soft cone of carded wool wound round the distaff: narrow at the top, full at the ribbon, rounded below, // its edge scalloped where the locks bulge const yc = y + 0.5; let hw = yc < 10.5 ? 1.2 + 5.4 * Math.pow((yc - 2) / 8.5, 0.75) : 6.6 * Math.sqrt(Math.max(0, 1 - Math.pow((yc - 10.5) / 4.6, 2))); const dx = x + 0.5 - 33.5; hw += 0.8 * Math.abs(Math.sin(y * (dx < 0 ? 1.15 : 0.95) + (dx < 0 ? 0.4 : 1.9))); const d = dx / Math.max(0.5, hw); if (Math.abs(d) > 1 || yc < 2) return null; const yy = (yc - 9) / 7; // wrapped locks of wool: soft diagonal bands, lit from the top-left const lit = -0.75 * d - 0.45 * yy + 0.22 * Math.sin(y * 1.4 - d * 2.4) + 0.05; return lit > 0.5 ? "n5" : lit > -0.05 ? "n4" : lit > -0.55 ? "n3" : "n2"; }); g.pixel(26, 9, "n5"); g.pixel(41, 7, "n3"); g.pixel(30, 4, "n4"); // loose tufts g.pixel(36, 13, "w4"); g.pixel(31, 12, "w4"); // a warmer lock or two }); g.part("ribbon", () => { // a red ribbon binds the fleece g.line([28, 9], [38, 10], "r1"); g.line([28, 9], [31, 9], "r2"); g.pixel(32, 8, "r2"); g.pixel(33, 8, "r1"); // the bow g.pixel(39, 11, "r1"); g.pixel(39, 12, "r0"); g.pixel(40, 13, "r1"); g.pixel(40, 14, "r0"); });}// ---- yarn, drive band, sparks, glow ------------------------------------------------------------------------const YARN = [[37, 13], [38, 15], [39, 17], [40, 19], [41, 21]];function yarnPath() { const pts = []; for (let i = 0; i < YARN.length - 1; i++) { const [x0, y0] = YARN[i], [x1, y1] = YARN[i + 1]; const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)); for (let s = 0; s < n; s++) pts.push([Math.round(x0 + ((x1 - x0) * s) / n), Math.round(y0 + ((y1 - y0) * s) / n)]); } return pts;}function yarn(g, t, mode) { const pts = yarnPath(); const step = Math.floor(t * 12 + 1e-6); g.detached("yarn", () => { pts.forEach(([x, y], i) => { if (mode === "idle") { g.pixel(x, y, i % 3 === 0 ? "n3" : "n4"); return; } const m = (((i - step) % 4) + 4) % 4; // twists run down the yarn toward the spindle if (mode === "cursed") g.pixel(x, y, m === 0 ? "n5" : m === 1 ? "v3" : "v2"); else g.pixel(x, y, m === 0 ? "n5" : m === 1 ? "n3" : "n4"); }); });}function driveBand(g, mode) { const k = mode === "cursed" ? "v2" : "n4"; g.detached("driveBand", () => { g.line([17, 9], [25, 19], k); g.line([22, 28], [25, 25], k); });}function glint(g, t) { // idle: a glint slides out along the spindle and winks on the needle tip const p = t; g.detached("glint", () => { if (p < 0.5) { const x = 38 + Math.round((p / 0.5) * 6); g.pixel(x, 22, "n5"); if (x <= 40) g.pixel(x, 21, "n5"); } else if (p < 0.7) { g.pixel(44, 22, "n5"); g.pixel(45, 22, "n5"); g.pixel(44, 21, "n5"); g.pixel(44, 23, "n5"); if (p < 0.6) { g.pixel(46, 22, "n4"); g.pixel(44, 20, "n4"); g.pixel(44, 24, "n4"); } } });}function curse(g, t) { const pulse = 0.5 + 0.5 * Math.sin(TAU * 2 * t); g.detached("tipGlow", () => { g.field({ x: [38, 47], y: [16, 28] }, (x, y) => { const r = Math.hypot(x - 44, (y - 22) * 1.1); const R0 = 4.3 + pulse; if (r > R0 || (y === 22 && x >= 39 && x <= 44)) return null; if (!dith(x, y, 0.75 * (1 - r / R0) + 0.1)) return null; return r < 2 ? "v2" : "v1"; }); }); g.detached("tip", () => { g.line([42, 22], [43, 22], "v3"); g.pixel(44, 22, "n5"); if (pulse > 0.5) { g.pixel(45, 22, "v3"); g.pixel(44, 21, "v3"); g.pixel(44, 23, "v3"); } }); g.detached("sparks", () => { const src = [[44, 21], [24, 12], [41, 18], [6, 14], [36, 13], [44, 23]]; for (let i = 0; i < 6; i++) { const p = (t * (i % 2 ? 1 : 2) + i / 6) % 1; const [sx, sy] = src[i]; const x = sx + Math.round(Math.sin(TAU * (p + i * 0.37)) * 2), y = sy - Math.round(p * 9); if (y < 0) continue; if (p < 0.3) { g.pixel(x, y, "n5"); g.pixel(x, y + 1, "v3"); } else if (p < 0.65) g.pixel(x, y, "v3"); else if (chk(x, y)) g.pixel(x, y, "v2"); } });}const SpinningWheel = sprite(pal, (g, t, mode) => { t = ((t % 1) + 1) % 1; const turning = mode !== "idle"; const ang = turning ? Math.PI * t : 0; // half a turn per loop; 8 spokes repeat every 45 degrees const crank = turning ? TAU * t : 0; const phi = turning ? TAU * t : 0.6; const pin = [Math.round(WC.x + 3 * Math.cos(crank + 1.2)), Math.round(WC.y + 3 * Math.sin(crank + 1.2))]; const yT = 41 + Math.round(1.4 * Math.sin(crank + 1.2)); contactShadow(g, 23, GROUND, 21, 2); g.outlined("ink", () => distaff(g)); g.outlined("ink", () => rim(g, ang)); spokes(g, ang); driveBand(g, mode); g.outlined("ink", () => { treadle(g, yT); bench(g); g.part("post", () => { // the front upright holding the axle g.rect({ x: [13, 15], y: [21, 30] }, "w2", { fill: "w2" }); g.line([13, 21], [13, 30], "w3"); g.line([15, 22], [15, 30], "w1"); g.pixel(13, 25, "w4"); g.line([13, 26], [15, 26], "w1"); // a turned ring }); hub(g); motherOfAll(g); flyer(g, phi); }); g.part("footman", () => { // the rod from the crank down to the treadle, in front of the bench const foot = [25, yT - 2]; g.line([pin[0] - 1, pin[1]], [foot[0] - 1, foot[1]], "ink"); g.line([pin[0] + 1, pin[1]], [foot[0] + 1, foot[1]], "ink"); g.line(pin, foot, "w4"); g.pixel(foot[0], foot[1] + 1, "ink"); }); g.part("crank", () => { g.line([WC.x, WC.y], pin, "n2"); g.pixel(WC.x, WC.y, "n3"); g.pixel(pin[0], pin[1], "n4"); }); yarn(g, t, mode); if (mode === "idle") glint(g, t); if (mode === "cursed") curse(g, t);});submit(SpinningWheel, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: 24, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "spin", args: ["spin"] }, { name: "cursed", args: ["cursed"] }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。