Tessarune
Witch's Hut · witch_broom.js

魔女の箒

32×48 · JavaScript 190 行

32×48 の床置きの小物で、こぶと節と折れた小枝のある曲がったトネリコの柄に、藁を束ねたところへ紫の紐を巻き、その下にもう一か所縛った扇形の藁束を付けた箒です。箒全体を箒自身の座標の一つの立体として画素ごとに描くので、飛び立つ途中のどの角度も同じ光で描けます。lean(12 コマ)は穂を下にして立ち、光が柄を滑り降ります。hover(16 コマ)は影の上で水平に浮いて上下し、紫の光の粒が落ちます。takeoff(10 コマの一回再生)は後ろへ沈み、浮き上がって水平に振れます。

魔女の箒 — 描画結果6×
このプログラムで描画(変種「takeoff」) · 32×48 · 10 コマ
Witch's Hut動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。