Witch's Hut · witch_broom.js
魔女の箒
32×48 の床置きの小物で、こぶと節と折れた小枝のある曲がったトネリコの柄に、藁を束ねたところへ紫の紐を巻き、その下にもう一か所縛った扇形の藁束を付けた箒です。箒全体を箒自身の座標の一つの立体として画素ごとに描くので、飛び立つ途中のどの角度も同じ光で描けます。lean(12 コマ)は穂を下にして立ち、光が柄を滑り降ります。hover(16 コマ)は影の上で水平に浮いて上下し、紫の光の粒が落ちます。takeoff(10 コマの一回再生)は後ろへ沈み、浮き上がって水平に振れます。
6×
動くスプライト素材: Witch's Hut(itch.io)Vol.5・ハロウィンの小物 12 点・42 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
witch_broom.js · 1–8
// Witch's broom — 32x48 floor prop, top-down 3/4, key light top-left.// A besom: a crooked ash handle with a knob, a knot and a snapped-off twig, a violet cord wrapped where the straw is// bound on, a second tie lower down, and a fanned bundle of straw. The whole broom is one model in broom space// (s along the axis from the knob, d across it) ray-cast per pixel, so every angle of the takeoff is lit the same way.// Variants:// lean 12f loop: stands on its bristles; a glint slides down the handle and twinkles on the cord// hover 16f loop: floats horizontally above its shadow, bobbing; the straw flutters, violet sparkles fall off// takeoff 10f once: lean -> crouch back -> lifts and swings level with an overshoot; ends on hover frame 0最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
witch_broom.js · 180–190
submit(WitchBroom, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LEAN / 10, loop: true }, anchor: { x: 15, y: GROUND }, args: ["lean"], variants: [ { name: "lean", args: ["lean"] }, { name: "hover", args: ["hover"], animate: { fps: 10, duration: N_HOVER / 10, loop: true } }, { name: "takeoff", args: ["takeoff"], animate: { fps: 10, duration: N_TAKE / 10, loop: false } }, ],});プログラム全体を表示 (190 行)
witch_broom.js · 190 行
// Witch's broom — 32x48 floor prop, top-down 3/4, key light top-left.// A besom: a crooked ash handle with a knob, a knot and a snapped-off twig, a violet cord wrapped where the straw is// bound on, a second tie lower down, and a fanned bundle of straw. The whole broom is one model in broom space// (s along the axis from the knob, d across it) ray-cast per pixel, so every angle of the takeoff is lit the same way.// Variants:// lean 12f loop: stands on its bristles; a glint slides down the handle and twinkles on the cord// hover 16f loop: floats horizontally above its shadow, bobbing; the straw flutters, violet sparkles fall off// takeoff 10f once: lean -> crouch back -> lifts and swings level with an overshoot; ends on hover frame 0const W = 32, H = 48, GROUND = 45, L = 30;const N_LEAN = 12, N_HOVER = 16, N_TAKE = 10;const BIND0 = 16.5, BIND1 = 19.3, TIE0 = 23.2, TIE1 = 24.3;const WOOD = ["w0", "w1", "w2", "w3"], STRAW = ["w1", "g0", "g1", "g2"], CORD = ["v0", "v1", "v2", "v3"];const LEAN = { th: -6, base: [15, 45.6] };const HOVER = { th: 78, c: [14.5, 26.5] };const rad = (deg) => (deg * Math.PI) / 180;// handle centre line wanders (a crooked branch); the bound part continues straight from where the binding startsconst crook = (s) => 0.7 * Math.sin(Math.min(s, BIND0) * 0.24 + 0.2) + 0.25 * Math.sin(Math.min(s, BIND0) * 0.7);function halfW(s) { if (s < 1.9) return 1.95; // knob if (s < BIND0) return 1.45 - s * 0.012; if (s < BIND1) return 2.5; // cord wrap const u = (s - BIND1) / (L - BIND1); return 2.3 + 4.1 * Math.pow(u, 0.7); // straw fans out}function centreFromBase(th, base, lift) { const dx = Math.sin(rad(th)), dy = Math.cos(rad(th)); return [base[0] - dx * L / 2, base[1] - lift - dy * L / 2];}// one tone function for the whole broom: pose = {th, c:[cx,cy]}, flutter = phase of the straw tips (or null)function broomTone(pose, flutter, glint) { const th = rad(pose.th), dx = Math.sin(th), dy = Math.cos(th), px = Math.cos(th), py = -Math.sin(th); return (x, y) => { const X = x + 0.5 - pose.c[0], Y = y + 0.5 - pose.c[1]; const s = X * dx + Y * dy + L / 2, d = X * px + Y * py; if (s < 0 || s > L + 1) return null; const dd = d - crook(s); let hw = halfW(s); // snapped twig on the shadow side of the handle const twig = s > 9.0 && s < 10.8 && dd > 1.2 && dd < 2.6 + (s - 9.0) * 0.5; if (s >= BIND1) { const dn = dd / hw; const sid = Math.floor((dn + 1) * 5.5); // individual strand tips of uneven length const rag = 0.3 + 2.0 * hash(sid, 3) + (flutter == null ? 0 : 1.1 * (0.5 + 0.5 * Math.sin(TAU * flutter + sid * 1.9))); if (s > L - rag) return null; } if (Math.abs(dd) > hw && !twig) return null; const dn = twig ? 0.2 : Math.max(-1, Math.min(1, dd / hw)); const nz = Math.sqrt(Math.max(0, 1 - dn * dn)); const lit = -0.72 * (px + py) * dn + 0.55 * nz - 0.25; // light from the top-left, a little from the front const lvl = lit > 0.42 ? 3 : lit > 0.05 ? 2 : lit > -0.35 ? 1 : 0; if (twig) return s < 9.7 ? "w2" : "w1"; if (s < BIND0) { if (glint != null && Math.abs(s - glint) < 1.1 && lvl >= 2) return "n5"; if (s > 6.0 && s < 7.0 && Math.abs(dd) < 0.7) return "w0"; // knot if (s > 13.0 && s < 13.9 && dd < -0.3) return "w0"; // a ring of bark if (s < 1.0) return lvl >= 2 ? "w3" : "w1"; // cut end of the knob if (lvl === 3 && ((s > 2 && s < 4.5) || (s > 7.8 && s < 11.5))) return "w4"; return WOOD[lvl]; } if (s < BIND1) { // cord: wraps read as ridges const ridge = Math.floor((s - BIND0) * 1.6) % 2 === 1; return CORD[Math.max(0, Math.min(3, lvl - (ridge ? 1 : 0) - (lvl === 3 && Math.abs(dn) > 0.6 ? 1 : 0)))]; } if (s > TIE0 && s < TIE1) return CORD[Math.max(0, lvl - 1)]; // straw: strands converge on the binding (stripes in normalised width), one step darker every other strand const strand = Math.floor((dn + 1) * 6.5 + 0.5); const sl = lit > 0.22 ? 3 : lit > -0.22 ? 2 : 1; let k = sl - (strand % 3 === 0 ? 1 : 0); const sid = Math.floor((dn + 1) * 5.5); if (s > L - 0.3 - 2.0 * hash(sid, 3) - 1.2 && lvl >= 2 && strand % 3 !== 0) return "w4"; // sunlit strand tips if (s < BIND1 + 1.2 || (s > TIE1 && s < TIE1 + 0.9)) k -= 1; // tucked under the ties return STRAW[Math.max(0, Math.min(3, k))]; };}function drawBroom(g, pose, flutter, glint) { g.outlined("ink", () => g.part("broom", () => g.field({ x: [0, W - 1], y: [0, H - 1] }, broomTone(pose, flutter, glint))));}function endPoint(pose) { // centre of the straw end, in screen space const th = rad(pose.th); return [pose.c[0] + Math.sin(th) * (L / 2 - 1), pose.c[1] + Math.cos(th) * (L / 2 - 1)];}function star(g, x, y, size) { // 4-point twinkle x = Math.round(x); y = Math.round(y); if (size <= 0) return; g.pixel(x, y, size > 1 ? "n5" : "v3"); if (size >= 2) for (const [a, b] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + a, y + b, "v3"); if (size >= 3) for (const [a, b] of [[2, 0], [-2, 0], [0, 2], [0, -2]]) g.pixel(x + a, y + b, "v2");}const bobAt = (t) => Math.round(2 * Math.sin(TAU * t));const hoverPose = (t) => ({ th: HOVER.th, c: [HOVER.c[0], HOVER.c[1] - bobAt(t)] });function trail(g, t, keep) { // sparkles shaken off the straw as it hovers g.detached("sparkles", () => { for (let i = 0; i < 9; i++) { const p = (t + i / 9) % 1; if (p > keep) continue; const e = endPoint(hoverPose(t - p)); // emitted where the straw was at the time const side = ((i * 4) % 9) / 8 - 0.5; const x = e[0] - 1 + side * 5 + p * 2 + Math.sin(TAU * (p + i * 0.37)) * 1.2; const y = e[1] + 2 + side * 3 + p * 14; if (y > GROUND) continue; if (p < 0.12) star(g, x, y, 1); else if (p < 0.3) star(g, x, y, 2); else if (p < 0.45) { g.pixel(Math.round(x), Math.round(y), "v3"); g.pixel(Math.round(x), Math.round(y) - 1, "v2"); } else if (p < 0.75) g.pixel(Math.round(x), Math.round(y), "v2"); else if (chk(Math.round(x), Math.round(y))) g.pixel(Math.round(x), Math.round(y), "v1"); } });}function leanFrame(g, t) { const pose = { th: LEAN.th, c: centreFromBase(LEAN.th, LEAN.base, 0) }; contactShadow(g, 15, GROUND, 5, 1); // a glint slides down the handle in the first half, then the cord twinkles, then a mote off the knob const glint = t < 0.5 ? 1.5 + (t / 0.5) * 13.5 : null; drawBroom(g, pose, null, glint); const th = rad(pose.th), at = (s, d) => [pose.c[0] + Math.sin(th) * (s - L / 2) + Math.cos(th) * d, pose.c[1] + Math.cos(th) * (s - L / 2) - Math.sin(th) * d]; g.detached("glints", () => { const k = Math.round(t * N_LEAN) % N_LEAN; const cord = [0, 0, 0, 0, 0, 0, 1, 2, 3, 2, 1, 0][k]; const tip = [1, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0][k]; const [cx, cy] = at(17.9, -3.4); star(g, cx, cy, cord); const [kx, ky] = at(0.2, -2.6); star(g, kx, ky, tip); // two slow motes rising off the straw for (let i = 0; i < 2; i++) { const p = (t + i / 2) % 1; const [mx, my] = at(26 + i * 2, (i ? 4 : -4) + Math.sin(TAU * (p + i * 0.3)) * 1.5); const y = Math.round(my - p * 10), x = Math.round(mx); if (p < 0.5) g.pixel(x, y, "v2"); else if (p < 0.8) g.pixel(x, y, "v1"); } });}function hoverFrame(g, t, keep) { const pose = hoverPose(t); const lift = bobAt(t); contactShadow(g, 15, GROUND, lift > 0 ? 8 : 9, 1); drawBroom(g, pose, (2 * t) % 1, null); trail(g, t, keep);}// takeoff keyframes: tilt (deg), lift off the base (px), blend from base-pivot to the hover centre, extra riseconst TAKE = [[-6, 0, 0, 0], [-13, 0, 0, 0], [2, 2, 0, 0], [26, 5, 0.2, 0], [52, 7, 0.5, 0], [70, 8, 0.8, 0], [84, 0, 1, 2], [80, 0, 1, 1], [77, 0, 1, 0]];function takeoffFrame(g, f) { if (f >= N_TAKE - 1) return hoverFrame(g, 0, 1); if (f === 0) return leanFrame(g, 0); const [th, lift, k, rise] = TAKE[f]; const cb = centreFromBase(th, LEAN.base, lift); const pose = { th, c: [cb[0] + (HOVER.c[0] - cb[0]) * k, cb[1] + (HOVER.c[1] - cb[1]) * k - rise] }; contactShadow(g, 15, GROUND, Math.round(5 + 4 * Math.min(1, f / 5)), 1); drawBroom(g, pose, f >= 3 ? (f * 0.2) % 1 : null, null); // a burst of sparkles where it pushed off, then the trail starts g.detached("burst", () => { if (f >= 2 && f <= 5) { const r = (f - 1) * 3; for (let i = 0; i < 8; i++) { const a = (i / 8) * TAU + 0.3; const x = Math.round(15 + Math.cos(a) * r), y = Math.round(GROUND - 1 + Math.sin(a) * r * 0.35 - (f - 1)); g.pixel(x, y, f < 4 ? "v3" : f < 5 ? "v2" : "v1"); } } }); if (f >= 6) trail(g, 0, (f - 5) / 4);}const WitchBroom = sprite(pal, (g, t, mode) => { if (mode === "takeoff") return takeoffFrame(g, Math.min(N_TAKE - 1, Math.round(t * N_TAKE))); t = ((t % 1) + 1) % 1; if (mode === "hover") return hoverFrame(g, t, 1); leanFrame(g, t);});submit(WitchBroom, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LEAN / 10, loop: true }, anchor: { x: 15, y: GROUND }, args: ["lean"], variants: [ { name: "lean", args: ["lean"] }, { name: "hover", args: ["hover"], animate: { fps: 10, duration: N_HOVER / 10, loop: true } }, { name: "takeoff", args: ["takeoff"], animate: { fps: 10, duration: N_TAKE / 10, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。