Graveyard & Chapel · bell_frame.js
鐘楼
48×64 の床置きの小物で、石の土台に立つ 2 本の木の柱を方杖と横木でつなぎ、小さなスレート葺きの屋根を載せています。鉄の吊り金具に青銅の鐘が下がり、舌から麻縄が垂れています。鐘は一つの g.field で画素ごとに鐘の向きへ回して描くので、どの角度に揺れても光は左上のままです。idle(12 コマ)は鐘が止まったまま縄が揺れます。toll(12 コマの一回再生)は鐘が 2 回揺れ、振り切ったところで舌が当たって音の輪が広がり、idle の最初のコマで終わります。
6×
動くスプライト素材: Graveyard & Chapel(itch.io)Vol.4・小物 12 点・33 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
bell_frame.js · 1–7
// Graveyard bell frame — 48x64 floor prop, top-down 3/4, key light top-left.// Two timber posts on stone footings, knee braces, a crossbeam under a small slate-shingled cap. A bronze bell hangs// from an iron hanger; a hemp rope hangs from the clapper.// The bell is drawn by ONE g.field that inverse-rotates each pixel into bell space, so it can swing by any angle// while the light stays top-left (the highlight stripe does not rotate with it).// Variants: idle (12f loop: bell still, rope sways), toll (12f one-shot: two swings with the clapper striking and// sound rings at the peaks; ends on idle frame 0).最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
bell_frame.js · 239–248
submit(BellFrame, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: IDLE_N / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle" }, { name: "toll", args: ["toll"], animate: { fps: 10, duration: TOLL.length / 10, loop: false } }, ],});プログラム全体を表示 (248 行)
bell_frame.js · 248 行
// Graveyard bell frame — 48x64 floor prop, top-down 3/4, key light top-left.// Two timber posts on stone footings, knee braces, a crossbeam under a small slate-shingled cap. A bronze bell hangs// from an iron hanger; a hemp rope hangs from the clapper.// The bell is drawn by ONE g.field that inverse-rotates each pixel into bell space, so it can swing by any angle// while the light stays top-left (the highlight stripe does not rotate with it).// Variants: idle (12f loop: bell still, rope sways), toll (12f one-shot: two swings with the clapper striking and// sound rings at the peaks; ends on idle frame 0).const W = 48, H = 64, CX = 24, GROUND = 61;const PIV = { x: 24, y: 18 }; // hanger pin (bell crown loop turns on it)const HB = 17; // bell height below the crown (local v 0..HB)// half-width of the bell per local row v (crown -> waist -> flare -> lip)const HW = [3.2, 4.4, 5.0, 5.3, 5.4, 5.5, 5.6, 5.7, 5.9, 6.1, 6.4, 6.8, 7.2, 7.6, 8.0, 8.3, 8.5, 8.5];const hwAt = (v) => { const i = Math.max(0, Math.min(HB, v)); const a = Math.floor(i), b = Math.min(HB, a + 1); return HW[a] + (HW[b] - HW[a]) * (i - a); };const RAD = Math.PI / 180;// ---- frame -------------------------------------------------------------------------------------------function footing(g, x0, lit) { g.part(lit ? "footingL" : "footingR", () => { g.rect({ x: [x0, x0 + 8], y: [55, 60] }, "n2", { fill: "n2" }); g.line([x0, 55], [x0 + 8, 55], "n4"); // top face edge g.line([x0, 56], [x0 + 8, 56], "n3"); g.line([x0, 57], [x0, 60], lit ? "n3" : "n2"); g.line([x0 + 8, 57], [x0 + 8, 60], "n1"); g.line([x0 + 1, 60], [x0 + 8, 60], "n1"); g.pixel(x0 + 3, 58, "n1"); g.pixel(x0 + 6, 57, "n3"); g.pixel(x0 + 5, 59, "n1"); // chips });}function post(g, x0, lit) { g.part(lit ? "postL" : "postR", () => { g.field({ x: [x0, x0 + 3], y: [16, 55] }, (x, y) => { const u = x - x0; if (u === 0) return lit ? "w3" : "w2"; if (u === 3) return lit ? "w1" : "w0"; // grain: a few countable streaks if ((u === 1 && (y === 24 || y === 25 || y === 41)) || (u === 2 && (y === 33 || y === 34 || y === 48))) return "w1"; return u === 1 && lit ? "w3" : "w2"; }); g.pixel(x0 + 1, 29, "w0"); g.pixel(x0 + 2, 29, "w1"); // a knot });}function sill(g) { g.part("sill", () => { g.rect({ x: [9, 38], y: [52, 54] }, "w1", { fill: "w1" }); g.line([9, 52], [38, 52], "w3"); g.line([9, 54], [38, 54], "w0"); g.pixel(15, 53, "w0"); g.pixel(16, 53, "w0"); g.pixel(30, 53, "w2"); });}function braces(g) { g.part("braces", () => { for (let i = 0; i <= 5; i++) { g.pixel(7 + i, 23 - i, "w3"); g.pixel(7 + i, 24 - i, "w1"); // left brace, lit top edge g.pixel(40 - i, 23 - i, "w2"); g.pixel(40 - i, 24 - i, "w0"); // right brace, in shade } });}function beam(g) { g.part("crossbeam", () => { g.rect({ x: [1, 46], y: [13, 16] }, "w2", { fill: "w2" }); g.line([2, 13], [45, 13], "w1"); // under the eave shadow g.line([2, 14], [20, 14], "w3"); g.line([1, 16], [46, 16], "w0"); // end grain: lit on the left, dark on the right g.rect({ x: [1, 2], y: [13, 16] }, "w3", { fill: "w3" }); g.pixel(1, 14, "w4"); g.rect({ x: [45, 46], y: [13, 16] }, "w1", { fill: "w1" }); g.line([27, 15], [31, 15], "w1"); g.line([10, 15], [12, 15], "w1"); // grain for (const x of [5, 42]) { g.pixel(x, 14, "n4"); g.pixel(x, 15, "n1"); } // peg heads over the posts });}function cap(g) { // front slope of a gable cap seen from above: trapezoid, ridge at the top, eave at the bottom const top = 4, eave = 11, l0 = 10, r0 = 37; g.part("cap", () => { g.field({ x: [0, 47], y: [top, eave + 1] }, (x, y) => { const k = (y - top) / (eave - top); const xl = l0 - k * l0, xr = r0 + k * (47 - r0); if (x < Math.round(xl) || x > Math.round(xr)) return null; if (y === eave + 1) return x < 2 || x > 45 ? null : "w1"; // fascia board if (y === top) return x < 22 ? "n4" : "n3"; // ridge tiles const row = Math.floor((y - top - 1) / 2), sub = (y - top - 1) % 2; if (x - xl < 1.2) return "n4"; // lit left hip if (xr - x < 1.2) return "n1"; // right hip in shade const joint = (x + row * 2) % 5 === 0; if (sub === 1) return joint ? "n0" : x < 18 ? "n2" : "n1"; // slate butt edge if (joint) return "n1"; return x < 16 ? "n3" : "n2"; }); g.pixel(24, 3, "n3"); g.pixel(24, 2, "n4"); g.pixel(25, 3, "n2"); // finial knob g.pixel(13, 7, "n1"); g.pixel(33, 9, "e1"); // a cracked slate, a tuft of moss });}function hanger(g) { g.part("hanger", () => { g.rect({ x: [22, 26], y: [17, 17] }, "n1", { fill: "n1" }); g.pixel(22, 17, "n3"); g.pixel(23, 17, "n2"); g.pixel(24, 18, "n2"); });}// ---- bell --------------------------------------------------------------------------------------------function toLocal(x, y, th) { const dx = x + 0.5 - PIV.x, dy = y + 0.5 - PIV.y, c = Math.cos(th), s = Math.sin(th); return [dx * c - dy * s, dx * s + dy * c];}function toWorld(u, v, th) { const c = Math.cos(th), s = Math.sin(th); return [PIV.x + u * c + v * s, PIV.y - u * s + v * c];}// patina flecks in bell space (u, v) — sparse, in the crevices under the bandsconst PATINA = [[3.5, 5.5, "e1"], [3.5, 6.5, "e1"], [3.5, 7.5, "e0"], [4.5, 5.5, "e0"], [5.5, 13.5, "e0"], [5.5, 12.5, "e1"]];function bellKey(x, y, th) { const [u, v0] = toLocal(x, y, th); const v = v0 - 1; // v 0 = top of the crown if (v < -2.2 || v > HB + 0.5) return null; if (v < 0) { // crown loop (canon) on the pin const r = Math.hypot(u, v + 1.1); if (r > 1.9 || r < 0.7) return null; return u < 0 ? "g2" : "g0"; } const hw = hwAt(v); if (Math.abs(u) > hw) return null; const nu = u / hw; // surface slope: the shoulder faces up, the flare faces down/out const nyl = v < 2.5 ? -0.7 : v > 11 ? 0.35 : 0; const nz = Math.sqrt(Math.max(0, 1 - nu * nu)); const c = Math.cos(th), s = Math.sin(th); const nxw = nu * c + nyl * s, nyw = -nu * s + nyl * c; const edge = hw - Math.abs(u); for (const [pu, pv, k] of PATINA) if (Math.abs(u - pu) < 0.5 && Math.abs(v - pv) < 0.5) return k; // lip band: a raised roll along the mouth if (v > HB - 0.5) return nxw < -0.2 ? "g1" : "g0"; // underside of the roll if (v > HB - 1.5) return nxw < -0.35 ? "g3" : nxw < 0.3 ? "g2" : "g1"; // lit top of the roll if (v > HB - 2.5 && v <= HB - 1.5) return nxw < 0 ? "g1" : "g0"; // groove above the roll // shoulder band (two thin rings) if (v > 3 && v <= 4) return "g0"; if (v > 4 && v <= 5) return nxw < -0.2 ? "g3" : nxw < 0.35 ? "g2" : "g1"; // sharp lit rim along the top-left silhouette, bounce light on the right if (edge < 1 && nxw < -0.3) return "g3"; if (edge < 1 && nxw > 0.6) return "g1"; // vertical specular stripe (world-lit, not bell-lit) // cylindrical bands keyed to the WORLD-space normal, so the light stays top-left as the bell swings const b = nxw - (v < 2.5 ? 0.25 : 0) + (v > 12 ? 0.1 : 0); if (b > -0.52 && b < -0.36 && v > 5 && v < HB - 2.5) return "g3"; // specular stripe if (b < 0.02) return "g2"; if (b < 0.42) return "g1"; if (b < 0.66 || v < 2.5) return "g0"; return "w1"; // core shadow: dark bronze}function bell(g, th) { g.part("bell", () => g.field({ x: [10, 38], y: [14, 40] }, (x, y) => bellKey(x, y, th)));}// clapper: bell-space angle psi (relative to the bell axis); the ball hangs just below the lipfunction clapperPos(th, psi) { const L = HB + 2.2; return toWorld(Math.sin(psi) * L, 1 + Math.cos(psi) * L, th);}function clapper(g, th, psi) { const [bx, by] = clapperPos(th, psi); const x = Math.round(bx), y = Math.round(by); g.part("clapper", () => { g.rect({ x: [x - 1, x + 1], y: [y - 1, y + 1] }, "n1", { fill: "n1" }); g.pixel(x - 1, y - 1, "n3"); g.pixel(x, y - 1, "n2"); g.pixel(x - 1, y, "n2"); g.pixel(x + 1, y + 1, "n0"); }); return [x, y + 2];}// ---- rope --------------------------------------------------------------------------------------------const ROPE_LEN = 15;function rope(g, top, sway) { const [tx, ty] = top; g.part("rope", () => { let px = tx; for (let i = 0; i <= ROPE_LEN; i++) { const k = i / ROPE_LEN; const x = Math.round(tx + sway * Math.pow(k, 1.4)), y = ty + i; if (Math.abs(x - px) > 1) g.pixel((x + px) >> 1, y, "w3"); // keep the strand connected g.pixel(x, y, (i + Math.floor(y / 1)) % 3 === 0 ? "w2" : "w4"); // twisted hemp px = x; } // knot and frayed tassel const ex = Math.round(tx + sway), ey = ty + ROPE_LEN; g.rect({ x: [ex - 1, ex + 1], y: [ey + 1, ey + 2] }, "w3", { fill: "w3" }); g.pixel(ex - 1, ey + 1, "w4"); g.pixel(ex + 1, ey + 2, "w1"); g.pixel(ex - 1, ey + 3, "w2"); g.pixel(ex + 1, ey + 3, "w2"); g.pixel(ex, ey + 4, "w3"); });}// ---- sound rings (toll peaks) ------------------------------------------------------------------------function rings(g, th, age) { // two arc pairs around the bell's belly; age 0 = strike (tight, bright), 1 = spreading (dithered) const [cx, cy] = toWorld(0, 10, th); g.detached("sound", () => { for (let n = 0; n < 2; n++) { const r = 11 + n * 3 + age * 3; for (let a = -34; a <= 34; a += 4) { for (const side of [-1, 1]) { const x = Math.round(cx + side * Math.cos(a * RAD) * r), y = Math.round(cy + Math.sin(a * RAD) * r * 0.9); if (age > 0 && !chk(x, y)) continue; g.pixel(x, y, n === 0 && age === 0 ? "n5" : "g3"); } } } });}// ---- motion ------------------------------------------------------------------------------------------const IDLE_N = 12;const TOLL = [0, 11, 21, 12, -3, -15, -20, -10, 2, 7, 3, 0]; // bell angle per frame (deg, + = right)const PSI = [0, -3, 5, 2, 3, 3, -5, -2, -2, 1, 0, 0]; // clapper vs bell (deg); strikes at peaksconst RING = { 2: 0, 3: 1, 6: 0, 7: 1 };const BellFrame = sprite(pal, (g, t, state) => { let th = 0, psi = 0, sway, f = -1; if (state === "toll") { f = Math.min(TOLL.length - 1, Math.round(t * TOLL.length)); th = TOLL[f] * RAD; psi = PSI[f] * RAD; const prev = TOLL[Math.max(0, f - 1)]; sway = Math.round(-(TOLL[f] - prev) * 0.18 - TOLL[f] * 0.12); // rope lags behind the swing if (f === TOLL.length - 1) sway = 0; } else sway = Math.round(1.6 * Math.sin(TAU * t)); contactShadow(g, CX, GROUND, 22, 2); g.outlined("ink", () => { footing(g, 1, true); footing(g, 38, false); sill(g); post(g, 3, true); post(g, 41, false); braces(g); beam(g); cap(g); hanger(g); }); let ropeTop; g.outlined("ink", () => { ropeTop = clapper(g, th, psi); }); g.outlined("ink", () => { bell(g, th); }); g.outlined("ink", () => { rope(g, ropeTop, sway); }); if (f in RING) rings(g, th, RING[f]);});submit(BellFrame, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: IDLE_N / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle" }, { name: "toll", args: ["toll"], animate: { fps: 10, duration: TOLL.length / 10, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。