Witch's Hut · herb_rack.js
薬草の干し棚
48×48 の壁用の小物で、接地影はありません。2 本の鉄帯で壁に留めた荒削りの梁から、セージの束、編んだにんにく、ラベンダーの束、血の玉を付けた干しトカゲのお守り、ナナカマドの実の小枝が麻ひもで逆さに吊られています。吊られたものはそれぞれ自分の支点を基準に描き、支点を中心にずらして揺らすので、全体がまとまって動き、ひもは外れません。変種はどれも 16 コマのループで、idle はそれぞれずれた拍子でゆっくり揺れ、breeze は突風で右へ傾いてセージの葉とラベンダーの花が落ち、dried は茶色く枯れてほとんど動きません。
6×
動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。