Witch's Hut · bat_roost.js
蝙蝠のねぐら
48×32 の天井・壁用の小物で、接地影はありません。苔むした荒削りの樫の梁に、翼をたたみ赤い目を光らせた小さな蝙蝠が 3 匹逆さにぶら下がり、左端の下には蜘蛛の巣と吊り下がった蜘蛛がいます。idle(16 コマ)は蝙蝠がずれた拍子で揺れ、右の 1 匹が翼を伸ばします。flutter(12 コマの一回再生)は 1 匹ずつ足を離して画面の外へ飛び去ります。empty(16 コマ)は何もいない梁で、巣が揺れます。return(12 コマの一回再生)は蝙蝠が戻ってぶら下がり、idle の最初のコマで終わります。
6×
動くスプライト素材: Witch's Hut(itch.io)Vol.5・ハロウィンの小物 12 点・42 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
bat_roost.js · 1–10
// Bat roost — 48x32 ceiling/wall prop (no contact shadow), key light top-left.// A rough-hewn, mossy oak beam with three little bats hanging upside down from its underside, wings wrapped,// red eyes glinting. A cobweb fills the corner under the left end, where a moss strand hangs; a spider dangles.// Variants:// idle 16f loop the bats sway on their toes out of step, the right one stretches a wing and wraps it// again, ears twitch; the web stirs and the spider bobs// flutter 12f once one after another the bats tense, let go, open their wings and flap off out of frame;// ends on empty frame 0// empty 16f loop the bare beam; the cobweb sways, the spider bobs// return 12f once the bats flap in from the edges, swing up and hang; ends on idle frame 0最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
bat_roost.js · 247–258
submit(Roost, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: 24, y: TOP }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "flutter", args: ["flutter"], animate: { fps: 10, duration: N_ONCE / 10, loop: false } }, { name: "empty", args: ["empty"] }, { name: "return", args: ["return"], animate: { fps: 10, duration: N_ONCE / 10, loop: false } }, ],});プログラム全体を表示 (258 行)
bat_roost.js · 258 行
// Bat roost — 48x32 ceiling/wall prop (no contact shadow), key light top-left.// A rough-hewn, mossy oak beam with three little bats hanging upside down from its underside, wings wrapped,// red eyes glinting. A cobweb fills the corner under the left end, where a moss strand hangs; a spider dangles.// Variants:// idle 16f loop the bats sway on their toes out of step, the right one stretches a wing and wraps it// again, ears twitch; the web stirs and the spider bobs// flutter 12f once one after another the bats tense, let go, open their wings and flap off out of frame;// ends on empty frame 0// empty 16f loop the bare beam; the cobweb sways, the spider bobs// return 12f once the bats flap in from the edges, swing up and hang; ends on idle frame 0const W = 48, H = 32;const BX0 = 2, BX1 = 45; // beam endsconst TOP = 3, FRONT = 6, BOT = 10; // top face rows 3..5, front face rows 6..10const FOOT = 11; // bats grip the underside hereconst N_IDLE = 16, N_ONCE = 12;const BATS = [{ x: 17, ph: 0.0 }, { x: 27, ph: 0.37 }, { x: 37, ph: 0.68 }];// ---- beam ---------------------------------------------------------------------------------------------------function beam(g) { g.part("beam", () => g.field({ x: [BX0, BX1], y: [TOP, BOT] }, (x, y) => { // hewn ends: the top-left corner is chamfered, the right end has a split if (x === BX0 && y === TOP) return null; if (x === BX1 && (y === TOP || y === BOT)) return null; if (y < FRONT) { // top face, lit; brightest at the left if (y === TOP) return x < 20 ? "w4" : "w3"; if (y === FRONT - 1) return "w2"; // the arris rounds over return (x + 2 * y) % 13 === 0 ? "w2" : "w3"; // adze marks } if (y === FRONT) return "w3"; // lit upper edge of the front face if (y === BOT) return "w0"; // underside in shadow // front face: long grain lines, a knot, darker toward the right if (y === 8 && ((x > 6 && x < 15) || (x > 27 && x < 38))) return "w1"; if (y === 9 && x > 17 && x < 25) return "w1"; if (x === BX1 || x === BX1 - 1 && y > 7) return "w1"; if ((x === 30 || x === 31) && y === 7) return "w1"; // knot return x > 34 ? "w1" : "w2"; })); g.part("beamEnd", () => { g.line([BX0, TOP + 1], [BX0, BOT], "w3"); // the cut end catches the light g.pixel(BX0 + 1, FRONT + 2, "w4"); g.pixel(30, 8, "w0"); g.pixel(31, 8, "w3"); // knot heart and its rim g.line([BX1 - 1, 7], [BX1 - 3, 8], "w0"); // split at the right end g.pixel(BX1, 6, "w0"); }); g.part("nails", () => { // iron spikes holding the beam for (const x of [8, 40]) { g.pixel(x, 7, "n4"); g.pixel(x, 8, "n1"); } }); g.part("moss", () => { // cushions on the top face, tufts lapping over the front edge, a strand hanging off the left end const pads = [[3, 9], [16, 21], [33, 37], [42, 44]]; for (const [a, b] of pads) for (let x = a; x <= b; x++) { const u = (x - a) / Math.max(1, b - a); g.pixel(x, TOP, u < 0.4 ? "e3" : "e2"); g.pixel(x, TOP + 1, u < 0.5 ? "e2" : "e1"); if (u > 0.15 && u < 0.85) g.pixel(x, TOP - 1, u < 0.5 ? "e2" : "e1"); } for (const [x, n] of [[4, 2], [6, 3], [8, 1], [17, 2], [19, 3], [34, 2], [36, 1], [43, 2]]) for (let i = 0; i < n; i++) g.pixel(x, FRONT + i, i === 0 ? "e2" : "e1"); for (const x of [5, 7, 18, 35]) g.pixel(x, FRONT, "e1"); for (let y = FOOT; y <= 21; y++) g.pixel(3, y, y < 16 ? "e1" : "e0"); // hanging strand g.pixel(2, 12, "e2"); g.pixel(4, 16, "e1"); g.pixel(2, 19, "e0"); g.pixel(3, 22, "e0"); });}// ---- cobweb + spider (not outlined: they are threads) ----------------------------------------------------------function web(g, wt) { const s = Math.sin(TAU * wt), c = Math.cos(TAU * wt); g.detached("cobweb", () => { const C = [3, FOOT]; // corner under the beam by the strand const ends = [[13, FOOT], [11 + 0.6 * s, 17 + 0.5 * c], [3, 21]]; const P = (e, k) => [C[0] + (e[0] - C[0]) * k, C[1] + (e[1] - C[1]) * k]; const R = (p) => [Math.round(p[0]), Math.round(p[1])]; for (const e of ends) g.line(R(C), R(e), "n3"); for (const [k, key] of [[0.4, "n3"], [0.8, "n3"]]) { for (let j = 0; j < ends.length - 1; j++) { const a = P(ends[j], k), b = P(ends[j + 1], k); const m = [(a[0] + b[0]) / 2 - 0.4, (a[1] + b[1]) / 2 + 0.6 + 0.4 * s]; // the thread sags g.line(R(a), R(m), key); g.line(R(m), R(b), key); } } g.pixel(Math.round(P(ends[1], 0.8)[0]), Math.round(P(ends[1], 0.8)[1]), "n5"); // a dew drop }); g.detached("spider", () => { const sx = 8, top = 17, y = 23 + Math.round(1.5 + 1.5 * Math.sin(TAU * wt + 1.2)); g.line([sx, top], [sx, y - 1], "n3"); g.pixel(sx, y, "n0"); g.pixel(sx, y + 1, "ink"); g.pixel(sx + 1, y, "ink"); g.pixel(sx - 1, y - 1, "ink"); g.pixel(sx + 2, y - 1, "ink"); g.pixel(sx - 1, y + 2, "ink"); g.pixel(sx + 2, y + 2, "ink"); g.pixel(sx, y, "r1"); // red hourglass glint on the back });}// ---- bats ----------------------------------------------------------------------------------------------------// hanging, wings wrapped. col 4 = centre, row 0 = toes on the beam. Upside down: chin up, ears at the bottom.const HANG = [ "...k.k...", "...l.l...", "..AMMMB..", ".AMMFMMB.", ".AMMFMMB.", ".AMMFMMB.", ".AAMFMBB.", "..cAFBc..", "..GHnHH..", "..GeHeH..", "..GHHHH..", "..EE.EE..", "...E.E...",];const HKEY = { k: "n4", l: "n1", A: "n3", M: "n2", B: "n1", F: "n0", c: "n4", H: "w0", G: "w1", n: "ink", e: "r2", E: "n1" };// flying, front view, three flap poses. col 6 = centre, row 3 = centre row.const FLY = { up: [ "M...........M", "MM..E...E..MM", "MMM.GHHHH.MMM", ".MMMGeHeHMMM.", "..MMGHHHHMM..", "...M.HHH.M...", "......H......", ], mid: [ ".............", "....E...E....", "....GHHHH....", "MMMMGeHeHMMMM", ".MMMGHHHHMMM.", "..M.MHHHM.M..", "......H......", ], down: [ ".............", "....E...E....", "....GHHHH....", "...MGeHeHM...", "..MMGHHHHMM..", ".MMM.HHH.MMM.", "MM....H....MM", ],};function grid(g, rows, ox, oy, keyOf) { rows.forEach((row, j) => [...row].forEach((ch, i) => { if (ch === ".") return; const k = keyOf(ch, i, j); if (k) g.pixel(ox + i, oy + j, k); }));}// a hanging bat. sway shears the body about the toes; twitch flicks an ear; wing = 0..1 stretch of one wingfunction hangingBat(g, bx, oy, sway, opt = {}) { const dxAt = (j) => Math.round(sway * j / 12); HANG.forEach((row, j) => [...row].forEach((ch, i) => { if (ch === ".") return; let x = bx - 4 + i + dxAt(j), y = oy + j; if (ch === "E" && opt.twitch && j === 12) { x += i < 4 ? -1 : 1; y -= 1; } // ear tips flick outward if (ch === "e" && opt.blink) { g.pixel(x, y, "w0"); return; } if (ch === "k" && opt.release) return; // toes let go g.pixel(x, y, HKEY[ch]); })); for (const [side, s] of [[1, opt.wingR || 0], [-1, opt.wingL || 0]]) { if (s <= 0) continue; // the wing unfurls from the shoulder: forearm out to the wrist, two fingers fanning down, scalloped edge const x0 = bx + side * 3 + dxAt(3), y0 = oy + 2; const X = (dx) => x0 + side * Math.round(dx * s); const lit = side < 0, Y = (dy) => y0 + Math.round(dy * (0.45 + 0.55 * s)); // forearm to the wrist, three finger tips with deep scallops between them, back to the body const P = [[0, 0], [5, -1], [8, 2], [6, 3], [6, 6], [4, 4], [3, 7], [1, 5], [0, 7]].map(([dx, dy]) => [X(dx), Y(dy)]); g.fillPoly(P, lit ? "n2" : "n1"); g.line(P[0], P[1], lit ? "n4" : "n3"); for (const tip of [P[2], P[4], P[6]]) g.line(P[1], tip, lit ? "n1" : "n0"); if (s > 0.6) g.pixel(P[1][0], P[1][1] - 1, "n4"); // thumb claw }}function flyingBat(g, cx, cy, pose) { grid(g, FLY[pose], cx - 6, cy - 3, (ch, i, j) => { if (ch === "M") { // leading edge (topmost membrane pixel in its column) lighter; the left wing faces the light const above = j > 0 && FLY[pose][j - 1][i] === "M"; if (i < 6) return above ? "n2" : "n3"; return above ? "n1" : "n2"; } return HKEY[ch]; });}// per-bat flight: k = 0 hanging, 1 tense, 2 drop, 3..6 flying along the path, >= 7 goneconst PATHS = [ [[15, 22], [9, 24], [2, 23], [-6, 21]], [[27, 24], [30, 27], [33, 32], [36, 39]], [[40, 22], [45, 23], [51, 21], [58, 19]],];const POSES = ["up", "mid", "down"];function bat(g, i, k, sway, f, opt) { const B = BATS[i]; if (k <= 0) return hangingBat(g, B.x, FOOT, sway, opt); if (k === 1) return hangingBat(g, B.x, FOOT, 0, { twitch: true }); if (k === 2) return hangingBat(g, B.x, FOOT + 3, 0, { twitch: true, release: true, wingL: 0.55, wingR: 0.55 }); if (k >= 7) return; const [x, y] = PATHS[i][k - 3]; flyingBat(g, x, y, POSES[(f + i) % 3]);}// ---- states ----------------------------------------------------------------------------------------------------const tri = (u) => Math.max(0, 1 - Math.abs(u));function idleBats(g, tt) { BATS.forEach((B, i) => { const sway = 1.6 * Math.sin(TAU * (tt + B.ph)); const opt = {}; if (i === 0) opt.twitch = tt >= 0.25 && tt < 0.32; if (i === 1) { opt.twitch = (tt >= 0.75 && tt < 0.82); opt.blink = tt >= 0.5 && tt < 0.57; } if (i === 2) { // the right bat stretches its right wing out, holds, and wraps it again const u = (tt - 0.3) / 0.45; opt.wingR = u > 0 && u < 1 ? [0.4, 0.7, 0.9, 0.9, 0.9, 0.7, 0.4][Math.min(6, Math.floor(u * 7))] : 0; opt.twitch = tt >= 0.06 && tt < 0.13; } bat(g, i, 0, opt.wingR ? sway * 0.4 : sway, 0, opt); });}const START = [2, 1, 4]; // flutter: frame each bat tensesconst LAND = [10, 11, 8]; // return: frame each bat is hanging againconst Roost = sprite(pal, (g, t, state) => { t = ((t % 1) + 1) % 1; let wt, draw; if (state === "idle" || state === "empty") { const f = Math.round(t * N_IDLE) % N_IDLE, tt = f / N_IDLE; wt = tt; draw = state === "idle" ? () => idleBats(g, tt) : () => {}; } else { const f = Math.min(N_ONCE - 1, Math.round(t * N_ONCE)); wt = f / (N_ONCE - 1); // the web shivers once through the whole action and comes to rest const ks = state === "flutter" ? START.map((s) => Math.max(0, f - s + 1)) : LAND.map((e) => Math.max(0, e - f)); draw = () => ks.forEach((k, i) => bat(g, i, k, k === 0 ? 1.6 * Math.sin(TAU * BATS[i].ph) : 0, f, {})); } web(g, wt); g.outlined("ink", () => { beam(g); g.detached("bats", draw); });});submit(Roost, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: 24, y: TOP }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "flutter", args: ["flutter"], animate: { fps: 10, duration: N_ONCE / 10, loop: false } }, { name: "empty", args: ["empty"] }, { name: "return", args: ["return"], animate: { fps: 10, duration: N_ONCE / 10, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。