Tessarune
Witch's Hut · herb_rack.js

薬草の干し棚

48×48 · JavaScript 288 行

48×48 の壁用の小物で、接地影はありません。2 本の鉄帯で壁に留めた荒削りの梁から、セージの束、編んだにんにく、ラベンダーの束、血の玉を付けた干しトカゲのお守り、ナナカマドの実の小枝が麻ひもで逆さに吊られています。吊られたものはそれぞれ自分の支点を基準に描き、支点を中心にずらして揺らすので、全体がまとまって動き、ひもは外れません。変種はどれも 16 コマのループで、idle はそれぞれずれた拍子でゆっくり揺れ、breeze は突風で右へ傾いてセージの葉とラベンダーの花が落ち、dried は茶色く枯れてほとんど動きません。

薬草の干し棚 — 描画結果6×
このプログラムで描画(変種「breeze」) · 48×48 · 16 コマ
Witch's Hut動くスプライト素材: Witch's Hut(itch.io)Vol.5・ハロウィンの小物 12 点・42 アニメ

冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。

herb_rack.js · 1–9
// Herb drying rack — 48x48 WALL prop (no contact shadow), 3/4 view, key light top-left.// A rough-hewn beam held to the wall by two iron straps; six things hang from it on twine wrapped round the beam:// a sage bundle, a garlic braid, a lavender bunch, a dried lizard charm with a blood bead and a rowan-berry sprig. Every bundle hangs upside down: cut stem ends above the tie, sprigs fanning out below.// Each hanging thing is drawn as a pure function of local (pivot) coordinates and swung by a shear about its pivot,// so the whole mass moves and the twine stays attached.// Variants (16f loops @10fps)://   idle    bundles sway gently, each out of phase//   breeze  a gust leans them to the right and they swing harder; a sage leaf and a lavender floret break off and fall//   dried   the same bundles browned and withered (key remap inside the palette), thinner sprigs, barely moving

最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。

herb_rack.js · 278–288
submit(HerbRack, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.6, loop: true },  anchor: { x: 24, y: 10 },  args: ["idle"],  variants: [    { name: "idle" },    { name: "breeze", args: ["breeze"] },    { name: "dried", args: ["dried"] },  ],});
プログラム全体を表示 (288 行)
herb_rack.js · 288 行
// Herb drying rack — 48x48 WALL prop (no contact shadow), 3/4 view, key light top-left.// A rough-hewn beam held to the wall by two iron straps; six things hang from it on twine wrapped round the beam:// a sage bundle, a garlic braid, a lavender bunch, a dried lizard charm with a blood bead and a rowan-berry sprig. Every bundle hangs upside down: cut stem ends above the tie, sprigs fanning out below.// Each hanging thing is drawn as a pure function of local (pivot) coordinates and swung by a shear about its pivot,// so the whole mass moves and the twine stays attached.// Variants (16f loops @10fps)://   idle    bundles sway gently, each out of phase//   breeze  a gust leans them to the right and they swing harder; a sage leaf and a lavender floret break off and fall//   dried   the same bundles browned and withered (key remap inside the palette), thinner sprigs, barely movingconst W = 48, H = 48, PY = 11;                       // PY: first row under the beam (twine leaves the beam here)const BX0 = 2, BX1 = 45;                             // beam spanlet MODE = "idle";// withered remap: greens go to wood browns, violet greys out, berries shrivel darkconst DRY = { e0: "w0", e1: "w1", e2: "w2", e3: "w3", v0: "w0", v1: "v0", v2: "v1", v3: "n3",  r1: "r0", r2: "r1", r3: "w2" };const K = (k) => (MODE === "dried" && DRY[k]) || k;const ramp4 = (l, a, b, c, d) => K(l > 0.55 ? d : l > 0.05 ? c : l > -0.45 ? b : a);// ---- shared shape helpers (local coords: lx right, ly down from the pivot) ------------------------------------// a fan of sprigs hanging from a tie at ly=T; returns the sprig hit with the smallest |across| (front sprigs win)function fan(lx, ly, F) {  const dy = ly - F.T + 0.5;  if (dy < 0) return null;  const r = Math.hypot(lx, dy), a = Math.atan2(lx, dy);  let best = null;  for (let j = 0; j < F.n; j++) {    const aj = -F.spread + (2 * F.spread * j) / (F.n - 1);    const along = r * Math.cos(a - aj), perp = r * Math.sin(a - aj);    const L = F.len[j] * (MODE === "dried" ? 0.92 : 1);    if (along < 0 || along > L) continue;    const w = F.width(along, L, j) * (MODE === "dried" ? 0.8 : 1);    if (w <= 0.2) continue;    const f = perp / w;    if (Math.abs(f) > 1) continue;    const score = Math.abs(f) + Math.abs(aj) * 0.6;    if (!best || score < best.score) best = { j, d: along, L, f, score, aj };  }  return best;}const sprigPos = (F, j, d) => {  const aj = -F.spread + (2 * F.spread * j) / (F.n - 1);  return [Math.sin(aj) * d, F.T - 0.5 + Math.cos(aj) * d];};// cut stem ends above the tie, and the twine wrap of the tiefunction tieAndStems(lx, ly, T, hw, stemA, stemB) {  if (ly >= T && ly <= T + 1 && Math.abs(lx) <= hw) return K(ly === T ? (lx < 0 ? "w4" : "w3") : "w2");  if (ly >= T - 2 && ly < T && Math.abs(lx) <= hw - 1) {    if (ly === T - 2) return K(lx < 0 ? stemB : lx > 0 ? stemA : "e2");    // cut ends catch the light    return K((lx + hw) % 2 ? "e0" : lx < 0 ? "e2" : stemA);                 // separate stem stubs  }  return null;}function inPoly(x, y, P) {  let c = false;  for (let i = 0, j = P.length - 1; i < P.length; j = i++) {    const [xi, yi] = P[i], [xj, yj] = P[j];    if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c;  }  return c;}function segDist(x, y, [ax, ay], [bx, by]) {  const dx = bx - ax, dy = by - ay, k = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy)));  return Math.hypot(x - ax - k * dx, y - ay - k * dy);}function ball(lx, ly, cx, cy, r, keys) {  const i = litIndex(lx, ly, cx - 0.5, cy - 0.5, r, r, 4, 0.05);  return i < 0 ? null : K(keys[i]);}// ---- the six hanging things ---------------------------------------------------------------------------------// each: { px pivot column, str twine length, ph sway phase, amp, fn(lx,ly) }const SAGE = { T: 3, n: 5, spread: 0.24, len: [18, 22, 25, 21, 17],  width: (d, L, j) => {    const leaf = 0.6 + 0.4 * Math.abs(Math.sin((Math.PI * (d + j * 1.3)) / 3.4));   // leaf pairs scallop the edge    return Math.min(1.7, 0.8 + d * 0.35) * Math.min(1, (L - d) / 3.5 + 0.1) * leaf;  } };function sage(lx, ly) {  const tie = tieAndStems(lx, ly, SAGE.T, 2, "e1", "e3");  if (tie) return tie;  const s = fan(lx, ly, SAGE);  if (!s) return null;  if (s.f > 0.55 && s.d > 2.5) return K("e0");                              // each sprig's shaded flank separates it  let l = -0.6 * s.f - 2 * s.aj + 0.05;  if (s.f < -0.55 && s.d > 2.5) l += 0.5;                                   // lit flank  if (s.d < 3) l -= 0.6;                                                    // shadow under the tie  if (s.d > s.L - 2.5) l += 0.3;                                            // soft grey-green tips  return ramp4(l, "e0", "e1", "e2", "e3");}const BULBS = [[-1, 6], [2, 10], [-1, 14], [2, 18], [0, 22], [1, 26]];function garlic(lx, ly) {  for (let i = BULBS.length - 1; i >= 0; i--) {    const [cx, cy] = BULBS[i];    const nx = (lx - cx) / 2.5, ny = (ly - cy) / 2.3;    if (nx * nx + ny * ny <= 1) {      let k = litIndex(lx + 0.5, ly + 0.5, cx + 0.5, cy + 0.5, 2.5, 2.3, 4, 0.1);      if (Math.abs(Math.abs(lx - cx) - 1) < 0.5 && ny > -0.6) k = Math.max(0, k - 1);   // clove seams      if (i % 2 === 1 && lx - cx === 1 && ny > -0.3 && ny < 0.7) return K("v1");          // purple streak      if (i % 2 === 0 && lx - cx === 0 && ny > 0 && ny < 0.6) return K("v2");      return ["n2", "n3", "n4", "n5"][k];    }    if (ly >= cy - 4 && ly < cy - 2 && Math.abs(lx - cx * 0.6) < 0.8) return ly === cy - 4 ? "w4" : "w3";   // neck into braid    if (i === BULBS.length - 1 && ly > cy + 2 && ly <= cy + 4 && Math.abs(lx - cx) <= 1 && (lx + ly) % 2 === 0) return "w3";  // roots  }  if (ly >= 2 && ly <= 25 && Math.abs(lx) <= 1) {                           // plaited stalks    const k = (ly + (lx < 0 ? 0 : 1) + (lx > 0 ? 1 : 0)) % 3;    return lx > 0 ? (k === 0 ? "w1" : "w2") : (k === 0 ? "w2" : k === 1 ? "w4" : "w3");  }  if (ly >= 0 && ly < 2 && Math.abs(lx) <= 1) return lx < 0 ? "w4" : "w2";  // braid knot at the twine  return null;}const LAV = { T: 4, n: 5, spread: 0.26, len: [15, 18, 21, 19, 16],  width: (d, L) => (d < L * 0.42 ? 0.55 + (d < 2 ? 1 : 0) : (Math.floor(d) % 2 ? 1.05 : 0.65) * Math.min(1, (L - d) / 2 + 0.3)) };function lavender(lx, ly) {  const tie = tieAndStems(lx, ly, LAV.T, 2, "e1", "e2");  if (tie) return tie;  const s = fan(lx, ly, LAV);  if (!s) return null;  const l = -0.7 * s.f - 2 * s.aj + (Math.floor(s.d) % 2 ? 0.2 : -0.2);  if (s.d < s.L * 0.42) return K(l > 0 ? "e2" : "e1");                      // bare stems  if (s.d > s.L - 1.5) return K(l > 0 ? "v2" : "v1");                       // closed bud at the tip  if (s.f > 0.5) return K("v0");  return ramp4(l, "v0", "v1", "v2", "v3");}// a dried lizard hung by its tail as a charm, legs splayed, a blood bead threaded on the twine above itconst LIMBS = [  [[-1, 13], [-3.5, 11.5], [-4, 9.5]], [[1, 13], [3.5, 11.5], [4, 9.5]],      // hind legs reach up  [[-1, 19], [-3.5, 20.5], [-4, 22.5]], [[1, 19], [3.5, 20.5], [4, 22.5]],    // fore legs reach down];function lizard(lx, ly) {  if (ly <= 2 && Math.abs(lx) <= 1) {                                      // bead    if (ly === 0 && lx === -1) return K("r3");    return ly === 2 || lx === 1 ? K("r1") : K("r2");  }  const tx = Math.round(Math.sin((ly - 3) * 0.55) * 1.2);                  // curled, stiff dried tail  if (ly >= 3 && ly <= 11 && Math.abs(lx - tx) <= (ly > 8 ? 1 : 0)) return lx - tx < 0 || ly < 9 ? "w2" : "w1";  // body  const bi = litIndex(lx + 0.5, ly + 0.5, 0.5, 16.5, 2.0, 4.6, 4, 0);  if (bi >= 0) {    if (lx === 0 && ly % 2 === 0 && ly > 12 && ly < 20) return "n4";        // dorsal ridge scales    return ["w1", "w2", "w3", "w4"][bi];  }  // head: pointing down, a dull bead eye  const hi = litIndex(lx + 0.5, ly + 0.5, 0.5, 23, 1.7, 2.4, 3, 0);  if (hi >= 0) {    if (lx === -1 && ly === 22) return K("g2");    return ["w1", "w2", "w3"][hi];  }  for (const [a, b, c] of LIMBS) {    if (segDist(lx, ly, a, b) < 0.55 || segDist(lx, ly, b, c) < 0.55) return lx < 0 ? "w3" : "w2";    if (Math.abs(lx - c[0]) <= 1 && Math.abs(ly - c[1]) <= 0 && (lx + ly) % 2 === 0) return "w1";  // toes  }  return null;}const ROWAN = { T: 4, n: 3, spread: 0.22, len: [11, 14, 12], width: (d) => (d < 2 ? 1.2 : 0.55) };function rowan(lx, ly) {  const tie = tieAndStems(lx, ly, ROWAN.T, 1, "w2", "w3");  if (tie) return tie;  // berries: 2x2 drupes (lit corner, shaded corner) clustered at each sprig tip, front ones first  const CL = [[[0, 0], [-2, -2], [1, -3]], [[0, 0], [2, -2], [-1, -3], [1, 2]], [[0, 0], [-2, -1], [1, -3]]];  for (let j = 0; j < ROWAN.n; j++) {    const L = ROWAN.len[j] * (MODE === "dried" ? 0.92 : 1);    const [bx, by] = sprigPos(ROWAN, j, L);    for (const [ox, oy] of CL[j]) {      const cx = Math.round(bx + ox) - 1, cy = Math.round(by + oy) - 1;      const u = lx - cx, v = ly - cy;      if (u < 0 || u > 1 || v < 0 || v > 1) continue;      return K(u + v === 0 ? "r3" : u + v === 1 ? "r2" : "r1");    }  }  // two leaflets either side of the tie  for (const [cx, cy, s] of [[-2.6, ROWAN.T + 2, -1], [2.8, ROWAN.T + 3, 1]]) {    const u = (lx - cx) / 2.2, v = (ly - cy - u * s * 0.8) / 1.1;    if (u * u + v * v <= 1) return K(v < 0 && s < 0 ? "e3" : v < 0 ? "e2" : "e1");  }  const s = fan(lx, ly, ROWAN);  if (!s) return null;  return K(s.f < 0 ? "w2" : "w1");}const ITEMS = [  { name: "sage", px: 8, str: 2, ph: 0.0, fn: sage, bot: 28 },  { name: "garlicBraid", px: 16, str: 1, ph: 0.37, fn: garlic, bot: 31 },  { name: "lavender", px: 24, str: 6, ph: 0.71, fn: lavender, bot: 26 },  { name: "lizardCharm", px: 31, str: 3, ph: 0.18, fn: lizard, bot: 26 },  { name: "rowan", px: 40, str: 4, ph: 0.55, fn: rowan, bot: 18 },];// shear per item (px of sideways travel per px of drop): periodic in t so the loop closesfunction sway(it, t) {  const k = 1.4 / (it.bot + it.str);                                        // longer things swing through a smaller angle  if (MODE === "breeze") return 2.2 * k + 1.9 * k * Math.sin(TAU * (2 * t - it.ph * 0.5));  if (MODE === "dried") return 0.6 * k * Math.sin(TAU * (t + it.ph));  return 1.1 * k * Math.sin(TAU * (t + it.ph));}function hanging(g, it, s, twine) {  const x0 = Math.max(0, it.px - 14), x1 = Math.min(W - 1, it.px + 14);  g.field({ x: [x0, x1], y: [PY, Math.min(H - 1, PY + it.str + it.bot + 3)] }, (x, y) => {    const ly = y - PY, lx = x - it.px - Math.round(s * ly);    if (twine) return ly < it.str && lx === 0 ? (ly % 2 ? "w3" : "w4") : null;    if (ly < it.str) return null;    return it.fn(lx, ly - it.str);  });}// ---- the beam ----------------------------------------------------------------------------------------------function beam(g) {  g.part("beam", () => g.field({ x: [BX0, BX1], y: [4, 10] }, (x, y) => {    // hewn ends: the top corners are knocked off    if ((x === BX0 || x === BX1) && y === 4) return null;    if (y <= 5) return x < 16 && y === 4 ? "w4" : "w3";                     // top face, brighter toward the light    if (y === 10) return "w1";    if (x === BX0) return "w3";                                             // lit left end    if (x === BX1) return "w1";    // adze marks and grain    if (y === 7 && ((x > 9 && x < 14) || (x > 26 && x < 30))) return "w1";    if (y === 8 && ((x > 18 && x < 24) || (x > 36 && x < 41))) return "w1";    if (y === 6) return "w3";    return "w2";  }));  g.part("straps", () => {    for (const sx of [4, 43]) {      g.line([sx, 1], [sx, 10], "n2");      g.line([sx + 1, 1], [sx + 1, 10], "n1");      g.pixel(sx, 4, "n4"); g.pixel(sx, 5, "n3"); g.pixel(sx + 1, 4, "n3");      g.pixel(sx, 1, "n4");                                                  // nail head into the wall      g.pixel(sx, 8, "n3"); g.pixel(sx + 1, 8, "n0");                        // rivet    }  });  g.part("twineWraps", () => {    for (const it of ITEMS) {      for (let y = 4; y <= 10; y++) g.pixel(it.px, y, y <= 5 ? "w4" : y % 2 ? "w4" : "w3");      g.pixel(it.px + 1, 6, "w1");                                          // wrap's shadow on the beam    }  });}// ---- breeze: things that break off ---------------------------------------------------------------------------function fallers(g, t) {  g.detached("fallers", () => g.outlined("ink", () => {    const drops = [      { it: ITEMS[0], ly: 2 + 25, ph: 0, keys: ["e3", "e2", "e1"] },      { it: ITEMS[2], ly: 6 + 23, ph: 0.5, keys: ["v3", "v2", "v1"] },    ];    for (const d of drops) {      const p = (t - d.ph + 1) % 1;      if (p > 0.85) continue;      const q = p / 0.85;      const s0 = sway(d.it, t);      const x0 = d.it.px + s0 * d.ly, y0 = PY + d.ly;      const x = Math.round(x0 + q * 15 + 2 * Math.sin(TAU * q * 2));      const y = Math.round(y0 + q * (H + 2 - y0));      if (y > H - 3) continue;      const flip = Math.floor(q * 8) % 4;                                    // tumbling leaf: flat, tilted, edge-on, tilted back      const [k0, k1, k2] = d.keys;      if (flip === 0) { g.pixel(x - 1, y, k0); g.pixel(x, y, k1); g.pixel(x + 1, y, k1); g.pixel(x, y + 1, k2); g.pixel(x + 1, y + 1, k2); }      else if (flip === 1) { g.pixel(x - 1, y - 1, k0); g.pixel(x, y - 1, k1); g.pixel(x, y, k1); g.pixel(x + 1, y, k2); g.pixel(x + 1, y + 1, k2); }      else if (flip === 2) { g.pixel(x, y - 1, k0); g.pixel(x, y, k1); g.pixel(x, y + 1, k2); }      else { g.pixel(x + 1, y - 1, k0); g.pixel(x, y - 1, k1); g.pixel(x, y, k1); g.pixel(x - 1, y, k2); g.pixel(x - 1, y + 1, k2); }    }  }));}const HerbRack = sprite(pal, (g, t, mode) => {  t = ((t % 1) + 1) % 1;  MODE = mode;  g.outlined("ink", () => beam(g));  const S = ITEMS.map((it) => sway(it, t));  g.outlined("ink", () => ITEMS.forEach((it, i) => g.part(it.name, () => hanging(g, it, S[i], false))));  g.link("twine", () => ITEMS.forEach((it, i) => hanging(g, it, S[i], true)));  if (mode === "breeze") fallers(g, t);});submit(HerbRack, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.6, loop: true },  anchor: { x: 24, y: 10 },  args: ["idle"],  variants: [    { name: "idle" },    { name: "breeze", args: ["breeze"] },    { name: "dried", args: ["dried"] },  ],});

掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。

エージェントに、こうしたプログラムを書かせる。

Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。