Witch's Hut · scarecrow.js
かかし
32×64 の縦長の床置きの小物で、木の柱と横木に組んだ藁人形です。首元を縛った麻袋の頭、赤い帯と真鍮の留め金が付いた先の垂れたとんがり帽子、革と紫の当て布をした色あせた緑の上着、縄のベルトを身に着け、袖口や襟、裾から藁がはみ出しています。柱は草の生えた小さな土盛りに立っています。idle(16 コマ)は風に揺れ、帽子の先が遅れてついてきます。haunted(16 コマ)は彫った目と口が橙色に燃え、火の粉が昇ります。turn(8 コマの一回再生)はゆっくりこちらへ顔を向け、haunted の最初のコマで終わります。
6×
動くスプライト素材: Witch's Hut(itch.io)Vol.5・ハロウィンの小物 12 点・42 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
scarecrow.js · 1–10
// Scarecrow — 32x64 floor prop (tall), top-down 3/4, key light top-left.// A straw man on a wooden post and crossbar: a burlap sack head tied off at the neck with straw bursting out under// the cord, a floppy pointed witch hat (bent tip, red band, brass buckle), a faded green coat with a leather patch// and a violet sleeve patch, a rope belt, straw at the cuffs, the collar, the coat's open front and under the hem.// The post stands in a little mound of earth with grass tufts. No crow.// Variants:// idle 16f loop: sways in the wind — the head rocks, the hat tip lags behind, coat tatters, straw and rope flap// haunted 16f loop: carved eyes and mouth burn orange (fl family), head held at a tilt, faint glow, embers rise// turn 8f once: the head slowly turns from looking aside to facing the viewer, eyes catch and flare;// starts on idle frame 0, ends on haunted frame 0最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
scarecrow.js · 301–311
submit(Scarecrow, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "haunted", args: ["haunted"] }, { name: "turn", args: ["turn"], animate: { fps: 10, duration: N_TURN / 10, loop: false } }, ],});プログラム全体を表示 (311 行)
scarecrow.js · 311 行
// Scarecrow — 32x64 floor prop (tall), top-down 3/4, key light top-left.// A straw man on a wooden post and crossbar: a burlap sack head tied off at the neck with straw bursting out under// the cord, a floppy pointed witch hat (bent tip, red band, brass buckle), a faded green coat with a leather patch// and a violet sleeve patch, a rope belt, straw at the cuffs, the collar, the coat's open front and under the hem.// The post stands in a little mound of earth with grass tufts. No crow.// Variants:// idle 16f loop: sways in the wind — the head rocks, the hat tip lags behind, coat tatters, straw and rope flap// haunted 16f loop: carved eyes and mouth burn orange (fl family), head held at a tilt, faint glow, embers rise// turn 8f once: the head slowly turns from looking aside to facing the viewer, eyes catch and flare;// starts on idle frame 0, ends on haunted frame 0const W = 32, H = 64, GROUND = 61, CX = 16;const N_LOOP = 16, N_TURN = 8;const NECK = [16, 24];const COAT = ["e0", "e1", "e2", "e3"], SACK = ["w1", "w2", "w3", "w4"], HAT = ["n0", "v0", "v1", "v2"];const WOOD = ["w0", "w1", "w2", "w3"];// ---- post, crossbar, mound --------------------------------------------------------------------------------------function frame(g) { g.part("post", () => { for (let y = 20; y <= 59; y++) { g.pixel(15, y, "w3"); g.pixel(16, y, "w2"); g.pixel(17, y, "w1"); } for (const y of [48, 49, 53]) g.pixel(16, y, "w1"); // grain g.pixel(15, 55, "w2"); g.pixel(17, 51, "w0"); }); g.part("crossbar", () => { for (let x = 1; x <= 30; x++) { g.pixel(x, 27, x < 16 ? "w3" : "w2"); g.pixel(x, 28, "w1"); } g.pixel(1, 27, "w4"); g.pixel(1, 28, "w2"); g.pixel(30, 28, "w0"); // cut ends g.pixel(2, 27, "w4"); });}function mound(g, wind) { g.part("mound", () => { g.field({ x: [9, 23], y: [57, 61] }, (x, y) => { const nx = (x + 0.5 - 16) / 7, ny = (y + 0.5 - 60.5) / 3.5; if (nx * nx + ny * ny > 1 || y > 61) return null; const a = -0.8 * nx - 0.6 * ny; return a > 0.55 ? "w2" : a > -0.1 ? "w1" : "w0"; }); g.pixel(12, 59, "n3"); g.pixel(13, 59, "n2"); // a pebble g.pixel(20, 60, "w2"); }); const sw = Math.round(wind * 0.8); g.part("grass", () => { for (const [x, h, k] of [[10, 3, "e2"], [11, 4, "e1"], [21, 4, "e2"], [22, 2, "e1"], [19, 2, "e1"]]) { g.line([x, 60], [x + sw, 60 - h], k); g.pixel(x + sw, 60 - h, k === "e2" ? "e3" : "e2"); } });}// ---- coat --------------------------------------------------------------------------------------------------------const torsoL = (y) => 9.5 - (y - 26) * 0.09, torsoR = (y) => 22.5 + (y - 26) * 0.09;function coatAt(x, y, sh) { // returns "sleeve" | "torso" | "gap" | null const tip = y >= 44 ? x - sh : x; // hem tatters swing in the wind if (y >= 26 && y <= 31 && x >= 4 && x <= 28) { const bottom = x < 7 || x > 25 ? 30 : 31; if (y <= bottom && (x < 10 || x > 22)) return "sleeve"; } if (y === 25 && x >= 11 && x <= 21) return "torso"; if (y < 26) return null; if (x + 0.5 < torsoL(y) || x + 0.5 > torsoR(y) + 1) return null; const v = ((tip % 4) + 4) % 4, tat = v < 2 ? v : 4 - v; // 0..2 if (y > 43 + tat + (tip % 7 === 3 ? 1 : 0)) return null; if (y >= 37 && Math.abs(x + 0.5 - 16.5) < (y - 36) * 0.38) return "gap"; // the coat falls open return "torso";}function coat(g, sh, flap) { g.part("coat", () => g.field({ x: [3, 29], y: [25, 47] }, (x, y) => { const r = coatAt(x, y, sh); if (!r || r === "gap") return null; if (r === "sleeve") { const left = x < 16; let k = y <= 26 ? 3 : y <= 28 ? 2 : y <= 29 ? 1 : 0; if (!left) k = Math.max(0, k - 1); if (k === 3 && (x > 8 || !left)) k = 2; if ((x === 4 || x === 28) && y >= 28) k = 0; // inside of the cuff if ((x === 5 || x === 27) && flap > 0.3) k = Math.max(0, k - 1); // cuffs flutter return COAT[k]; } const nx = (x + 0.5 - 16) / 7.5; const fold = 0.32 * Math.sin(x * 1.35 + 0.6) * (y > 33 ? 1 : 0.4); let v = -0.95 * nx + fold - (y - 26) / 45; if (y <= 33 && (x <= 10 || x >= 22)) v -= 0.5; // under the arms if (y === 25 || (y === 26 && x > 10 && x < 15)) v += 0.6; // shoulders catch the light const k = v > 0.55 ? 3 : v > -0.05 ? 2 : v > -0.6 ? 1 : 0; return COAT[Math.min(k, y <= 27 ? 3 : 2)]; })); g.part("coatDetail", () => { for (let y = 26; y <= 36; y++) g.pixel(16, y, y < 30 ? "e1" : "e0"); // the front edge g.pixel(15, 26, "e0"); g.pixel(17, 26, "e0"); for (const y of [29, 32, 35]) { g.pixel(18, y, "g1"); } // buttons g.pixel(18, 29, "g2"); // leather patch, stitched g.rect({ x: [11, 13], y: [38, 40] }, "w2", { fill: "w2" }); g.line([11, 38], [12, 38], "w3"); g.line([13, 39], [13, 40], "w1"); g.pixel(12, 40, "w1"); g.pixel(10, 38, "n4"); g.pixel(14, 41, "n4"); g.pixel(14, 38, "n4"); g.pixel(10, 41, "n3"); // violet patch on the right sleeve g.rect({ x: [23, 25], y: [27, 29] }, "v1", { fill: "v1" }); g.line([23, 27], [24, 27], "v2"); g.pixel(25, 29, "v0"); g.pixel(22, 27, "n4"); g.pixel(26, 30, "n3"); g.pixel(24, 30, "n3"); // a tear on the left sleeve g.pixel(7, 29, "e0"); g.pixel(8, 30, "e0"); });}function belt(g, wind) { g.part("belt", () => { for (let x = 10; x <= 23; x++) g.pixel(x, 36, x % 2 ? "w3" : "w2"); g.pixel(10, 36, "w4"); g.pixel(19, 36, "w4"); g.pixel(20, 36, "w3"); g.pixel(19, 37, "w2"); // knot const d = Math.round(wind * 0.9); g.line([19, 38], [19 + d, 40], "w2"); g.line([20, 37], [21 + d, 39], "w3"); });}// ---- straw ------------------------------------------------------------------------------------------------------// a fan of strands from a root point; tips: [[dx,dy],...]; each strand a line, alternating straw tonesfunction tuft(g, name, root, tips, sway) { g.part(name, () => { tips.forEach(([dx, dy], i) => { const k = i % 3 === 0 ? "g1" : i % 3 === 1 ? "g2" : "g0"; const tx = root[0] + dx + Math.round(sway * (0.6 + 0.2 * (i % 3))), ty = root[1] + dy; g.line(root, [tx, ty], k); if (k === "g2") g.pixel(tx, ty, "g3"); }); });}function straw(g, sway) { // cuffs: splay outward and down below the bar ends tuft(g, "strawCuffL", [5, 29], [[-2, 0], [-3, 2], [-2, 4], [-4, 3], [-1, 4], [-3, 1]], sway); tuft(g, "strawCuffR", [27, 29], [[2, 0], [2, 2], [2, 4], [3, 3], [1, 4], [3, 1]], sway); // hanging under the hem around the post tuft(g, "strawHem", [16, 43], [[-4, 4], [-2, 5], [-3, 3], [2, 5], [4, 4], [3, 3], [0, 5], [-5, 3], [5, 3]], sway);}function strawGap(g) { // straw stuffing seen through the open front g.part("strawFront", () => g.field({ x: [12, 21], y: [37, 46] }, (x, y) => { if (coatAt(x, y, 0) !== "gap") return null; return x === 16 ? "g2" : x === 17 ? "g1" : (x + y) % 3 === 0 ? "g0" : "g1"; }));}// ---- head + hat (one joint, so the hat rides the head) ----------------------------------------------------------// face: yaw 1 = looking off to the viewer's left, 0 = facing the viewer. glow 0 = dead carved holes .. 1 = burningfunction head(g, angle, yaw, glow, hatSway, flick) { g.joint("head", { at: NECK, angle }, () => { g.outlined("ink", () => { g.part("neckStraw", () => { for (const [dx, k] of [[-3, "g1"], [-2, "g2"], [-1, "g1"], [0, "g2"], [1, "g0"], [2, "g1"], [3, "g0"]]) { g.line([Math.sign(dx) * 0.5, 0], [dx * 1.3, 2 + (Math.abs(dx) === 2 ? 1 : 0)], k); } }); g.part("sack", () => g.field({ x: [-8, 8], y: [-14, 0] }, (x, y) => { const inBall = litIndex(x, y, 0, -7, 6.6, 6.2, 4, 0.1); const inNeck = y >= -3 && y <= -1 && Math.abs(x + 0.5) <= 3 - (y + 3) * 0.6; if (inBall < 0 && !inNeck) return null; let k = inBall < 0 ? 1 : inBall; const seamX = Math.round(4.5 - 2.5 * yaw); // stitched back seam turns with the head if (x === seamX && y > -12 && y < -2) return (y % 2) ? "w1" : SACK[Math.max(0, k - 1)]; if (k === 3 && (y === -9 || y === -6) && x < -2) k = 2; // a couple of weave lines on the lit cheek return SACK[k]; })); g.part("tie", () => { for (let x = -2; x <= 2; x++) g.pixel(x, -1, x < 0 ? "w3" : x === 0 ? "w2" : "w1"); g.pixel(-2, 0, "w2"); g.pixel(1, 0, "w1"); }); hat(g, hatSway); }); face(g, yaw, glow, flick); });}function hat(g, sway) { g.part("hatCone", () => { const tip = [6.5 + sway * 1.6, -21.5 + Math.abs(sway) * 0.6]; g.sweep([[0, -15], [0.4, -19], [2.0, -22.5], tip], { w0: 4.6, w1: 0.6, profile: 0.9 }, (u, v) => { const s = -v * (u < 0.62 ? -1 : 1); if (u > 0.6 && u < 0.68) return "n0"; // crease where the tip folds over return HAT[s < -0.4 ? 2 : s < 0.25 ? 1 : 0]; }); }); g.part("hatBand", () => { for (let x = -4; x <= 3; x++) { g.pixel(x, -17, x < -1 ? "r2" : x < 2 ? "r1" : "r0"); g.pixel(x, -16, x < 0 ? "r1" : "r0"); } g.rect({ x: [-2, -1], y: [-17, -16] }, "g1", { fill: "g1" }); g.pixel(-2, -17, "g3"); }); g.part("hatBrim", () => g.field({ x: [-11, 11], y: [-18, -10] }, (x, y) => { const ax = Math.abs(x + 0.5); const droop = (ax > 6 ? 1 : 0) + (ax > 8.5 ? 1 : 0); const nx = (x + 0.5) / 10, ny = (y + 0.5 - (-14.5 + droop)) / 2.3; if (nx * nx + ny * ny > 1) return null; if (ny > 0.15) return x < -4 ? "v0" : "n0"; // the brim's front edge, in shade if (y === -16 && x < -2) return "v2"; return x < -1 ? "v1" : x < 5 ? "v0" : "n0"; }));}function eyeAt(cx, cy, w) { // carved triangle pointing up const px = [[0, -2], [-1, -1], [0, -1], [1, -1], [-1, 0], [0, 0], [1, 0]]; return px.filter(([dx]) => w >= 3 || dx <= 0).map(([dx, dy]) => [Math.round(cx) + dx, cy + dy]);}function face(g, yaw, glow, flick) { const lx = -2.5 - 1.5 * yaw, rx = 2.5 - 3.3 * yaw, mx = -1.6 * yaw; const hot = glow > 0.85 ? (flick > 0.5 ? "fl3" : "fl2") : glow > 0.6 ? "fl2" : glow > 0.3 ? "fl1" : glow > 0.05 ? "fl0" : "w0"; const rim = glow > 0.85 ? "fl2" : glow > 0.6 ? "fl1" : glow > 0.3 ? "fl0" : "w0"; g.part("eyes", () => { for (const [cx, w] of [[lx, 3], [rx, yaw > 0.5 ? 2 : 3]]) { const px = eyeAt(cx, -7, w); const core = Math.round(cx); px.forEach(([x, y]) => g.pixel(x, y, glow <= 0.05 ? (y === -7 ? "w0" : "ink") : (x === core && y > -9) ? hot : rim)); if (glow > 0.6) px.filter(([, y]) => y === -7).forEach(([x]) => g.pixel(x, -6, "w4")); // glow spills on the burlap } }); g.part("mouth", () => { const m = Math.round(mx), n = yaw > 0.5 ? 2 : 3; for (let x = m - n; x <= m + n; x++) g.pixel(x, -4, glow > 0.3 ? (Math.abs(x - m) <= 1 ? hot : rim) : "w0"); for (const x of [m - 2, m, m + 2]) if (x >= m - n && x <= m + n) { g.pixel(x, -5, "n4"); g.pixel(x, -3, "n3"); } });}// ---- haunting effects --------------------------------------------------------------------------------------------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;function haloAt(g, t, strength) { const C = { x: 16, y: 18 }, R = (10.5 + 0.7 * Math.sin(TAU * 2 * t)) * strength; if (R < 2) return; g.detached("halo", () => g.field({ x: [2, 30], y: [4, 32] }, (x, y) => { const r = Math.hypot(x + 0.5 - C.x, (y + 0.5 - C.y) * 0.9); if (r > R) return null; return dith(x, y, 0.3 * (1 - Math.max(0, r - 6.5) / (R - 6.5))) ? "fl0" : null; }));}function embers(g, t, amount) { g.detached("embers", () => { for (let i = 0; i < 5; i++) { const p = (t + i / 5) % 1; if (p > amount) continue; const x = Math.round(16 + (i % 2 ? 9 : -9) + (i - 2) * 0.8 + Math.sin(TAU * (p + i * 0.31)) * 1.5 - p * 2); const y = Math.round(14 - p * 14); if (y < 0) continue; if (p < 0.3) { g.pixel(x, y, "fl3"); g.pixel(x, y + 1, "fl1"); } else if (p < 0.65) g.pixel(x, y, "fl2"); else if (chk(x, y)) g.pixel(x, y, "fl0"); } });}// ---- frames ------------------------------------------------------------------------------------------------------function body(g, wt) { const wind = Math.sin(TAU * wt), lag = Math.sin(TAU * (wt - 0.1)); const sh = Math.round(wind * 1.1), flap = 0.5 + 0.5 * Math.sin(TAU * 2 * wt); contactShadow(g, CX, GROUND, 8, 1); g.outlined("ink", () => frame(g)); g.outlined("ink", () => { straw(g, lag * 1.2); }); g.outlined("ink", () => { coat(g, sh, flap); }); strawGap(g); belt(g, wind); g.outlined("ink", () => tuft(g, "strawCollar", [16, 25], [[-5, -1], [-4, -2], [5, -1], [4, -2], [-3, -1], [3, -1]], lag * 0.6)); g.outlined("ink", () => mound(g, wind)); return { wind, lag };}function idleFrame(g, t) { const { lag } = body(g, t); head(g, 3 * Math.sin(TAU * t), 1, 0, lag, 0);}const H_TILT = -9;function hauntedFrame(g, t) { haloAt(g, t, 1); const { lag } = body(g, t); const flick = 0.5 + 0.5 * Math.sin(TAU * 3 * t + 0.4); head(g, H_TILT + 3 * Math.sin(TAU * t), 0, 1, lag, flick); embers(g, t, 1);}function turnFrame(g, f) { if (f >= N_TURN - 1) return hauntedFrame(g, 0); const k = f / (N_TURN - 1), e = k * k * (3 - 2 * k); // eased turn const wt = f / (N_TURN - 1); if (f >= 5) haloAt(g, 0, f === 5 ? 1.25 : 1.1); // a flare as the eyes catch const { lag } = body(g, wt); const glow = [0, 0, 0.1, 0.35, 0.65, 1, 1][f]; head(g, 3 * Math.sin(TAU * wt) * (1 - e) + H_TILT * e, 1 - e, glow, lag, f === 5 ? 1 : 0.6); if (f >= 5) embers(g, 0, (f - 4) / 3);}const Scarecrow = sprite(pal, (g, t, mode) => { if (mode === "turn") return turnFrame(g, Math.min(N_TURN - 1, Math.round(t * N_TURN))); t = ((t % 1) + 1) % 1; if (mode === "haunted") return hauntedFrame(g, t); idleFrame(g, t);});submit(Scarecrow, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "haunted", args: ["haunted"] }, { name: "turn", args: ["turn"], animate: { fps: 10, duration: N_TURN / 10, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。