Witch's Hut · toadstool_ring.js
毒キノコの輪
48×32 の床置きの小物で、苔むした小山の上に、白い斑点の赤い傘と淡い紫に光る傘が高さを変えて輪に並びます。輪に沿った草は濃く、内側の空き地は青々としています。idle(12 コマ)では光る傘がずれた拍子で脈打ち、その光が苔の上で息づきます。spores(12 コマ)では傘から胞子が吹き出して昇り、消えていきます。awaken(10 コマの一回再生)では光が輪を一周し、通るたびにキノコを一つずつ灯します。キノコは g.depth の中で手前から描くので、一つずつ輪郭線が保たれます。
6×
動くスプライト素材: Witch's Hut(itch.io)Vol.5・ハロウィンの小物 12 点・42 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
toadstool_ring.js · 1–9
// Toadstool ring — 48x32 floor prop, top-down 3/4, key light top-left.// A fairy/witch ring on a mossy mound: red caps with white spots and pale violet glowing caps of varied heights stand// in a circle; the grass along the ring is darker (the "fairy ring"), the clearing inside is lush.// Variants:// idle 12f loop: the glowing caps pulse softly, each out of phase; their light breathes on the moss// spores 12f loop: the glowing caps puff spores that drift up, sway and fade// awaken 10f once: a spark of light runs around the ring, lighting each toadstool as it passes, then every glow// flares and settles; ends on spores f0// Toadstools are drawn nearest-first inside g.depth so each keeps its own ink outline against the ones behind it.最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
toadstool_ring.js · 214–224
submit(ToadstoolRing, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "spores", args: ["spores"] }, { name: "awaken", args: ["awaken"], animate: { fps: 10, duration: N_AWAKE / 10, loop: false } }, ],});プログラム全体を表示 (224 行)
toadstool_ring.js · 224 行
// Toadstool ring — 48x32 floor prop, top-down 3/4, key light top-left.// A fairy/witch ring on a mossy mound: red caps with white spots and pale violet glowing caps of varied heights stand// in a circle; the grass along the ring is darker (the "fairy ring"), the clearing inside is lush.// Variants:// idle 12f loop: the glowing caps pulse softly, each out of phase; their light breathes on the moss// spores 12f loop: the glowing caps puff spores that drift up, sway and fade// awaken 10f once: a spark of light runs around the ring, lighting each toadstool as it passes, then every glow// flares and settles; ends on spores f0// Toadstools are drawn nearest-first inside g.depth so each keeps its own ink outline against the ones behind it.const W = 48, H = 32, GROUND = 28, CX = 24;const N_LOOP = 12, N_AWAKE = 10;const MOSS = { cx: 24, cy: 20, rx: 22, ry: 7.5 };const RING = { cx: 24, cy: 19.5, rx: 17, ry: 6.5 };const LIGHT = { cx: 24, cy: 19.5, rx: 13, ry: 4.6 }; // the path the spark runs, just inside the ring// [angle deg (0 = right, 90 = front), type, stem height, cap radius, pulse phase]const SHROOMS = [ [196, "glow", 6, 3.0, 0.0], [236, "red", 8, 3.8, 0], [274, "glow", 4, 2.4, 0.42], [312, "red", 6, 3.0, 0], [350, "glow", 5, 3.0, 0.71], [33, "red", 3, 2.4, 0], [82, "glow", 2, 2.4, 0.27], [122, "red", 3, 2.8, 0], [158, "glow", 3, 2.2, 0.86],].map(([a, type, h, cr, ph], i) => { const r = (a * Math.PI) / 180; return { i, a: r, type, h, cr, ph, bx: Math.round(RING.cx + RING.rx * Math.cos(r)), by: Math.round(RING.cy + RING.ry * Math.sin(r)) };});const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];const dith = (x, y, dens) => BAYER[(y & 3) * 4 + (x & 3)] / 16 < dens;// ---- moss mound -----------------------------------------------------------------------------------------------function moss(g) { const { cx, cy, rx, ry } = MOSS; g.part("moss", () => { // earth lip under the moss, seen on the near side g.field({ x: [cx - rx, cx + rx], y: [cy - ry, cy + ry + 2] }, (x, y) => { const nx = (x + 0.5 - cx) / rx; for (let d = 1; d <= 2; d++) { const ny = (y + 0.5 - d - cy) / ry; const ny0 = (y + 0.5 - cy) / ry; if (nx * nx + ny0 * ny0 <= 1) return null; if (nx * nx + ny * ny <= 1) return d === 1 ? (nx < -0.5 ? "e1" : "e0") : (nx < -0.5 ? "w1" : "w0"); } return null; }); g.field({ x: [cx - rx, cx + rx], y: [cy - ry, cy + ry] }, (x, y) => { const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry; if (nx * nx + ny * ny > 1) return null; // distance from the ring line (in ring-normalised units) const rxn = (x + 0.5 - RING.cx) / RING.rx, ryn = (y + 0.5 - RING.cy) / RING.ry; const rr = Math.sqrt(rxn * rxn + ryn * ryn); const lit = -0.6 * nx - 0.5 * ny; if (Math.abs(rr - 1) < 0.16) return chk(x, y) ? "e0" : "e1"; // the darker fairy-ring band if (rr < 0.84) { // lush clearing inside the ring if (lit > 0.32 && !chk(x, y)) return "e3"; if (lit > 0.45) return "e3"; return lit > -0.35 ? "e2" : "e1"; } return lit > 0.35 ? "e2" : lit > -0.45 ? "e1" : "e0"; // outer moss }); // tufts and pebbles: countable marks for (const [x, y, k] of [[6, 17, "e3"], [7, 16, "e2"], [41, 23, "e2"], [12, 25, "e2"], [35, 26, "n2"], [4, 22, "n3"], [44, 18, "e2"], [20, 20, "e1"], [28, 18, "e1"], [24, 22, "e1"]]) g.pixel(x, y, k); });}// ---- a toadstool -----------------------------------------------------------------------------------------------function toadstool(g, s, glow) { const { bx, by, h, cr, type, i } = s; const capY = by - h; // cap rim row const crY = cr * 0.78; const sw = cr >= 4 ? 3 : 2; // stem width const x0 = bx - (sw === 3 ? 1 : 1); g.part("stem" + i, () => { for (let y = capY + 1; y <= by; y++) { for (let k = 0; k < sw; k++) { const x = x0 + k; const key = k === 0 ? "n5" : k === sw - 1 ? "n3" : "n4"; g.pixel(x, y, y === by ? (k === 0 ? "n3" : "n2") : key); } } if (h >= 5) g.pixel(x0 + sw - 1, capY + 2, "n2"); // shadow under the cap if (type === "red" && h >= 5) { g.line([x0 - 1, capY + 3], [x0 + sw, capY + 3], "n4"); g.pixel(x0 + sw, capY + 3, "n3"); } // the ring (annulus) }); const RED = ["r0", "r1", "r1", "r2"]; const VIO = ["v1", "v2", "v2", "v3"]; g.part("cap" + i, () => g.field({ x: [Math.floor(bx - cr - 1), Math.ceil(bx + cr + 1)], y: [Math.floor(capY - crY - 1), capY + 1] }, (x, y) => { const nx = (x + 0.5 - (bx + 0.5)) / (cr + 0.5), ny = (y + 0.5 - (capY + 0.5)) / (crY + 0.5); if (ny > 0.55) return null; if (nx * nx + ny * ny > 1) return null; // underside / gills: the bottom row of the cap if (ny > 0.05) return type === "red" ? (nx < -0.3 ? "n4" : "n3") : (glow > 0.5 ? "v3" : "v2"); if (type === "red") { let k = litIndex(x, y, bx + 0.5, capY + 0.5, cr + 0.5, crY + 0.5, 4, 0.1); return RED[Math.max(0, Math.min(3, k))]; } let k = litIndex(x, y, bx + 0.5, capY + 0.5, cr + 0.5, crY + 0.5, 4, -0.15 + 0.4 * glow); return VIO[Math.max(0, Math.min(3, k))]; })); // spots on the red caps (fixed, countable) if (type === "red") g.part("spots" + i, () => { const pts = cr >= 4 ? [[-2, -2], [1, -3], [2, -1], [-3, 0], [0, -1]] : cr >= 3 ? [[-1, -2], [1, -1], [-2, 0]] : [[-1, -1], [1, 0]]; for (const [dx, dy] of pts) g.pixel(bx + dx, capY + dy, dx < 1 ? "n5" : "n4"); }); else g.part("glint" + i, () => { g.pixel(bx - 1, capY - Math.round(crY) + 1, "n5"); });}// soft dithered light around a glowing cap and on the moss under itfunction halo(g, s, glow) { const { bx, by, h, cr } = s; const cy = by - h - 1; const R = cr + 1 + 2 * glow; g.field({ x: [Math.floor(bx - R - 1), Math.ceil(bx + R + 1)], y: [Math.floor(cy - R), Math.ceil(by + 2)] }, (x, y) => { const d = Math.hypot(x + 0.5 - (bx + 0.5), (y + 0.5 - cy) * 1.1); const dg = Math.hypot((x + 0.5 - (bx + 0.5)) / (cr + 2 + 2 * glow), (y + 0.5 - by) / (1.6 + glow)); if (dg < 1 && dith(x, y, 0.1 + 0.4 * glow * (1 - dg))) return dg < 0.5 && glow > 0.6 ? "v2" : "v1"; // light pool on the moss if (d > R || d < cr) return null; if (!dith(x, y, Math.max(0, 0.3 * glow - 0.05) * (1 - (d - cr) / (R - cr)))) return null; return d < cr + 1.2 ? "v2" : "v1"; });}function sporesOf(g, s, t) { const { bx, by, h, cr, ph } = s; const top = by - h - Math.round(cr * 0.78); for (let k = 0; k < 3; k++) { const p = (t + ph + k / 3) % 1; const x = Math.round(bx + (k - 1) * 1.2 + Math.sin(TAU * (p * 0.9 + ph + k * 0.37)) * (1 + p * 2.5)); const y = Math.round(top - 1 - p * 15); if (y < 0) continue; if (p < 0.12) { g.pixel(x, y, "v3"); g.pixel(x + 1, y, "v2"); g.pixel(x, y + 1, "v2"); } // the puff else if (p < 0.5) { g.pixel(x, y, "v3"); } else if (p < 0.78) { g.pixel(x, y, "v2"); } else if (chk(x, y + Math.floor(p * 12))) g.pixel(x, y, "v1"); }}// ---- scene ----------------------------------------------------------------------------------------------------// glowAt(s) -> 0..1.6 glow of each glowing capfunction scene(g, glowAt, extra) { contactShadow(g, CX, GROUND, 21, 1); const order = SHROOMS.slice().sort((a, b) => b.by - a.by); // nearest first for (const s of order) { const gl = s.type === "glow" ? glowAt(s) : 0; g.depth(-s.by * 2, () => g.outlined("ink", () => toadstool(g, s, Math.min(1, gl)))); } g.depth(0, () => g.outlined("ink", () => moss(g))); // light over everything solid but behind nothing important: halos sit in front of the moss and behind the caps g.depth(-10, () => g.detached("glow", () => { for (const s of SHROOMS) if (s.type === "glow") halo(g, s, Math.min(1.1, glowAt(s))); })); if (extra) g.depth(-10, () => g.detached("fx", extra));}const pulse = (s, t) => 0.5 + 0.5 * Math.sin(TAU * (t + s.ph));const ToadstoolRing = sprite(pal, (g, t, mode) => { t = ((t % 1) + 1) % 1; if (mode === "awaken") { const f = Math.min(N_AWAKE - 1, Math.round(t * N_AWAKE)); if (f === N_AWAKE - 1) { // settle: exactly spores f0 scene(g, (s) => pulse(s, 0), () => { for (const s of SHROOMS) if (s.type === "glow") sporesOf(g, s, 0); }); return; } // the spark runs once around the ring in frames 0..5 (from the back-left, clockwise as seen from above) const run = Math.min(1, f / 5); const head = (200 * Math.PI) / 180 + run * TAU; const flare = f >= 6 ? [1.6, 1.25, 0.95][f - 6] : 0; const lit = (s) => { const passed = run * TAU >= (((s.a - (200 * Math.PI) / 180) % TAU) + TAU) % TAU; return passed ? 0.85 : 0.15; }; scene(g, (s) => (f >= 6 ? flare : lit(s)), () => { if (f <= 5) { // trail of light along the ring line for (let k = 0; k < 14; k++) { const a = head - k * 0.13; if (a < (200 * Math.PI) / 180) break; const x = Math.round(LIGHT.cx + LIGHT.rx * Math.cos(a)), y = Math.round(LIGHT.cy + LIGHT.ry * Math.sin(a)); g.pixel(x, y, k < 3 ? "v3" : k < 8 ? "v2" : "v1"); if (k === 0) { g.pixel(x + 1, y, "v3"); g.pixel(x - 1, y, "v3"); g.pixel(x, y - 1, "v3"); g.pixel(x, y + 1, "v2"); g.pixel(x, y - 2, "v2"); g.pixel(x + 2, y, "v1"); g.pixel(x - 2, y, "v1"); } } } if (f >= 5) { // the whole ring line lights up, then fades const key = f === 6 ? "v3" : f === 7 || f === 5 ? "v2" : "v1"; for (let k = 0; k < 140; k++) { const a = (k / 140) * TAU; const x = Math.round(LIGHT.cx + LIGHT.rx * Math.cos(a)), y = Math.round(LIGHT.cy + LIGHT.ry * Math.sin(a)); if (f === 8 && !chk(x, y)) continue; g.pixel(x, y, key); if (f === 6 && Math.sin(a) > 0) g.pixel(x, y + (Math.sin(a) > 0.5 ? 1 : 0), "v2"); } } if (f >= 6) { // a burst of motes rising from the centre of the ring for (let k = 0; k < 6; k++) { const p = (f - 6 + 1) / 3; const x = Math.round(LIGHT.cx + Math.cos(k * 1.05) * (3 + p * 6)), y = Math.round(LIGHT.cy - 2 - p * (4 + (k % 3) * 2) + Math.sin(k * 1.05) * 2); g.pixel(x, y, f === 6 ? "v3" : f === 7 ? "v2" : "v1"); } } }); return; } const f = Math.round(t * N_LOOP) % N_LOOP; const tt = f / N_LOOP; scene(g, (s) => pulse(s, tt), mode === "spores" ? () => { for (const s of SHROOMS) if (s.type === "glow") sporesOf(g, s, tt); } : null);});submit(ToadstoolRing, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "spores", args: ["spores"] }, { name: "awaken", args: ["awaken"], animate: { fps: 10, duration: N_AWAKE / 10, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。