Graveyard & Chapel · bone_pile.js
骨の山
48×32 の床置きの小物で、長い骨、肋骨、指の骨、2 つの頭蓋骨を積んだ山です。生々しさはなく、古く乾いた骨です。骨は一本ずつ距離で表した形として作り、それぞれの面の向きから骨の階調で陰影を付けるので、明るい面はいつも左上です。奥から手前へ重ね、下に潜り込むところには冷たい色の境目の線を入れています。idle(12 コマ)は上の頭蓋骨の眼窩が 2 回淡く緑に光り、小さな骨が揺れて戻ります。rise(10 コマの一回再生)は山がカタカタ鳴り、上の頭蓋骨があごを鳴らし目を緑に光らせて浮き上がり、ほこりを立てて山に戻ります。
6×
動くスプライト素材: Graveyard & Chapel(itch.io)Vol.4・小物 12 点・33 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
bone_pile.js · 1–10
// Bone pile — 48x32 floor prop, top-down 3/4, key light top-left. A heap of long bones, ribs, knuckle bones and two// skulls; not gory, just old dry bone. Every bone is a signed-distance shape (a shaft with twin knobs at each end,// an arc for a rib, an oval for a vertebra) shaded from its own normal through the bone ramp n2 -> n5, so the lit// side is always top-left; the two skulls are hand-drawn masks. Pieces are stacked back to front: a piece that// sits behind another gets a cool n1 / n2 contact line where it tucks under, so the heap reads as separate bones.// Variants:// idle loop 12f — the top skull's eye sockets breathe a faint green glow twice; a small bone on the right// slope rocks and settles back with a click// rise once 10f — the heap rattles, the top skull lifts, jaw chattering, eyes flare green, then it drops// back into the pile with a puff of dust; ends on idle f0最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
bone_pile.js · 305–313
submit(BonePile, { size: [48, 32], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: 24, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "rise", args: ["rise"], animate: { fps: 10, duration: N_RISE / 10, loop: false } }, ],});プログラム全体を表示 (313 行)
bone_pile.js · 313 行
// Bone pile — 48x32 floor prop, top-down 3/4, key light top-left. A heap of long bones, ribs, knuckle bones and two// skulls; not gory, just old dry bone. Every bone is a signed-distance shape (a shaft with twin knobs at each end,// an arc for a rib, an oval for a vertebra) shaded from its own normal through the bone ramp n2 -> n5, so the lit// side is always top-left; the two skulls are hand-drawn masks. Pieces are stacked back to front: a piece that// sits behind another gets a cool n1 / n2 contact line where it tucks under, so the heap reads as separate bones.// Variants:// idle loop 12f — the top skull's eye sockets breathe a faint green glow twice; a small bone on the right// slope rocks and settles back with a click// rise once 10f — the heap rattles, the top skull lifts, jaw chattering, eyes flare green, then it drops// back into the pile with a puff of dust; ends on idle f0const GROUND = 29, N_IDLE = 12, N_RISE = 10;const LX = -0.62, LY = -0.5, LZ = 0.6; // toward the light in screen space (x right, y down, z out)// ---- shapes -------------------------------------------------------------------------------------------------// each returns { d: signed distance (px, < 0 inside), nx, ny, nz } for a point, or null when well outsidefunction circleSD(px, py, cx, cy, r) { const dx = px - cx, dy = py - cy, m = Math.hypot(dx, dy) || 1e-6; const e = r - m, nz = Math.max(0, Math.min(1, e / r + 0.25)); const s = Math.sqrt(Math.max(0, 1 - nz * nz)); return { d: m - r, nx: (dx / m) * s, ny: (dy / m) * s, nz };}function capsuleSD(px, py, ax, ay, bx, by, r) { const vx = bx - ax, vy = by - ay, L2 = vx * vx + vy * vy; const k = Math.max(0, Math.min(1, ((px - ax) * vx + (py - ay) * vy) / L2)); return circleSD(px, py, ax + vx * k, ay + vy * k, r);}// a long bone: shaft + two knobs at each end (knobs sit across the shaft)function boneSD(px, py, b) { const [ax, ay, bx, by] = b.p, r = b.r, kr = b.kr || r * 1.35; const L = Math.hypot(bx - ax, by - ay), ux = (bx - ax) / L, uy = (by - ay) / L, qx = -uy, qy = ux; let best = capsuleSD(px, py, ax + ux * kr, ay + uy * kr, bx - ux * kr, by - uy * kr, r); for (const [ex, ey, s] of [[ax, ay, 1], [bx, by, -1]]) for (const side of [-1, 1]) { const c = circleSD(px, py, ex + ux * kr * 0.6 * s + qx * side * kr * 0.62, ey + uy * kr * 0.6 * s + qy * side * kr * 0.62, kr); if (c.d < best.d) best = c; } return best;}// a rib: an arc of radius R about (cx, cy) from angle a0 to a1 (radians, screen y down), thickness rfunction ribSD(px, py, b) { const [cx, cy, R, a0, a1] = b.p, r = b.r; let a = Math.atan2(py - cy, px - cx); while (a < a0) a += TAU; if (a > a1) { const d0 = Math.abs(a - TAU - a0), d1 = Math.abs(a - a1); a = d0 < d1 ? a0 : a1; } return circleSD(px, py, cx + Math.cos(a) * R, cy + Math.sin(a) * R, r * (1 - 0.35 * Math.abs((a - a0) / (a1 - a0) - 0.3)));}function ovalSD(px, py, b) { const [cx, cy, rx, ry] = b.p; const s = circleSD((px - cx) / rx, (py - cy) / ry, 0, 0, 1); return { d: s.d * Math.min(rx, ry), nx: s.nx, ny: s.ny, nz: s.nz };}const SD = { bone: boneSD, rib: ribSD, oval: ovalSD };// ---- the heap, listed BACK to FRONT (layer 0 = back row, dimmer) ---------------------------------------------const PIECES = [ { k: "rib", p: [24, 21, 10, 3.75, 5.7], r: 0.95, layer: 0 }, { k: "bone", p: [8, 18, 22, 11], r: 1.2, layer: 0 }, { k: "bone", p: [26, 10, 41, 16], r: 1.2, layer: 0 }, { k: "oval", p: [14, 13, 1.7, 1.2], layer: 0 }, { k: "oval", p: [36, 12, 1.5, 1.1], layer: 0 }, { k: "bone", p: [4, 25, 18, 17], r: 1.3, layer: 1 }, { k: "bone", p: [29, 17, 44, 24], r: 1.3, layer: 1 }, { k: "rib", p: [15, 26, 6, 3.6, 5.3], r: 0.8, layer: 1 }, { k: "rib", p: [34, 27, 6, 4.0, 5.8], r: 0.8, layer: 1 }, { k: "bone", p: [37, 18, 41, 16], r: 0.75, kr: 1.0, layer: 1, id: "settle" }, { k: "oval", p: [24.5, 23.5, 2.2, 1.4], layer: 0 }, { k: "oval", p: [11, 21, 1.6, 1.2], layer: 0 }, { k: "oval", p: [37.5, 21, 1.6, 1.2], layer: 0 }, { k: "bone", p: [14, 24, 31, 15], r: 1.15, layer: 1 }, { k: "bone", p: [18, 15, 35, 24], r: 1.15, layer: 1 }, { k: "skull", id: "hero" }, { k: "skull", id: "side" }, { k: "bone", p: [15, 26, 36, 25], r: 1.4, kr: 1.8, layer: 2, id: "front" }, { k: "oval", p: [39, 26, 1.7, 1.2], layer: 2 }, { k: "bone", p: [40, 28, 45, 26], r: 0.75, kr: 1.0, layer: 2 }, { k: "bone", p: [3, 28, 9, 27], r: 0.75, kr: 1.0, layer: 2 },];// the heap's body: a dark mound of packed fragments behind every piece, so gaps read as crevices, not floorconst moundTop = (x) => 14 + ((x + 0.5 - 24) / 18) ** 2 * 12;const CHIPS = [[10, 21], [13, 18], [19, 16], [30, 15], [34, 19], [38, 21], [21, 24], [27, 24], [8, 25], [41, 25], [24, 20], [16, 21], [31, 21]];// ---- skulls: hand-drawn masks. 5..1 = n5..n1, 0 = n0, E = eye socket, N = nose, T/t = tooth / gapconst HERO = { ox: 19, oy: 6, cran: [ "...45543...", "..4555443..", ".455554432.", ".454444332.", "44EE434EE32", "43EE3N3EE21", ".3334N3322.", "..TtTtTtT..", ], jaw: [ "..tTtTtTt..", "..3443321..", "...3322....", ], eyes: [[2, 4], [7, 4]],};const SIDE = { ox: 5, oy: 18, // an older skull at the front left, turned a little to the right, jaw long gone cran: [ "..34553..", ".3455543.", "345554432", "3454EE4E2", "3443EE3E1", ".33332N1.", "..2t2t2..", ], jaw: [], eyes: [],};const TONE = { 5: "n5", 4: "n4", 3: "n3", 2: "n2", 1: "n1", 0: "n0", T: "n5", t: "n1", N: "n0", E: "ink" };function skullAt(sk, dx, dy, jawDrop, x, y) { const lx = x - sk.ox - dx, ly = y - sk.oy - dy; const rows = sk.cran.length; if (lx < 0 || lx > 11) return null; if (ly >= 0 && ly < rows) { const c = sk.cran[ly][lx]; if (c && c !== ".") return { c, jaw: false, lx, ly }; } const jy = ly - rows - jawDrop; if (jawDrop > 0 && ly >= rows && jy < 0 && lx >= 3 && lx <= 7) return { c: "0", jaw: false, mouth: true, lx, ly }; if (jy >= 0 && jy < sk.jaw.length) { const c = sk.jaw[jy][lx]; if (c && c !== ".") return { c, jaw: true, lx, ly }; } return null;}function boneKey(s, layer) { // bone reads as three clean steps: a lit top-left lip, the body, a cool underside const side = LX * s.nx + LY * s.ny; let lv = s.nz > 0.8 ? 2 : side > 0.12 ? 3 : side > -0.3 ? 2 : 1; if (s.nz < 0.45 && side < -0.3) lv = 0; if (layer === 0) lv = Math.max(0, lv - 1); return ["n2", "n3", "n4", "n5"][lv];}// ---- one frame: stack the pieces into an id buffer, then add the contact linesfunction buildHeap(st) { const W = 48, H = 32, buf = new Array(W * H).fill(null); for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { for (let i = PIECES.length - 1; i >= 0; i--) { const pc = PIECES[i]; const off = st.off[i] || [0, 0]; if (pc.k === "skull") { const sk = pc.id === "hero" ? HERO : SIDE; const lift = pc.id === "hero" ? st.lift : 0; const h = skullAt(sk, off[0], off[1] - lift, pc.id === "hero" ? st.jaw : 0, x, y); if (!h) continue; let key = TONE[h.c]; let eye = false; if (h.c === "E") { eye = true; const e = sk.eyes.find(([ex, ey]) => h.lx >= ex && h.lx <= ex + 1 && h.ly >= ey && h.ly <= ey + 1); if (e) key = eyeKey(h.lx - e[0], h.ly - e[1], st.eye); } buf[y * W + x] = { i, part: pc.id === "hero" ? (h.jaw ? "jaw" : "skull") : "skull2", key, eye, flat: true }; break; } let px = x + 0.5 - off[0], py = y + 0.5 - off[1]; if (pc.id === "settle" && st.tip) { // rock about the lower (right-hand) knob const a = st.tip, cx = pc.p[0], cy = pc.p[1], c = Math.cos(a), s = Math.sin(a); const qx = px - cx, qy = py - cy; px = cx + qx * c + qy * s; py = cy - qx * s + qy * c; } const s = SD[pc.k](px, py, pc); if (s.d > 0) continue; buf[y * W + x] = { i, part: "bones", key: boneKey(s, pc.layer), layer: pc.layer }; break; } if (!buf[y * W + x] && y >= moundTop(x) && y <= GROUND - 2 - (Math.abs(x + 0.5 - 24) > 15 ? 1 : 0)) { let key = y > GROUND - 3 ? "n0" : "n1"; for (const [cx, cy] of CHIPS) { const dx = x - cx, dy = y - cy; if (dx >= 0 && dx <= 1 && dy === 0) key = dx === 0 ? "n3" : "n2"; else if (dx === 0 && dy === 1) key = "n0"; } buf[y * W + x] = { i: -1, part: "bones", key, mound: true }; } } // contact lines: a piece tucked under a nearer one darkens where they meet (cast down-right = n1, else n2) const out = buf.map((b) => (b ? { ...b } : null)); for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const b = buf[y * W + x]; if (!b || b.eye) continue; const at = (a, c) => (a < 0 || c < 0 || a >= W || c >= H ? null : buf[c * W + a]); const nearer = (q) => q && q.i > b.i && q.part !== b.part || (q && q.i > b.i && q.part === "bones"); if (nearer(at(x, y - 1)) || nearer(at(x - 1, y))) out[y * W + x].key = "n1"; else if (nearer(at(x + 1, y)) || nearer(at(x, y + 1))) out[y * W + x].key = b.key === "n5" || b.key === "n4" ? "n3" : "n2"; } return out;}// eye socket pixel (sx, sy in 0..1) at glow level 0 (dark) .. 4 (flare)function eyeKey(sx, sy, lv) { if (lv <= 0) return sy === 0 ? "n0" : "ink"; if (lv === 1) return sy === 1 && sx === 1 ? "e0" : sy === 0 ? "n0" : "ink"; if (lv === 2) return sy === 1 ? (sx === 1 ? "e1" : "e0") : "ink"; if (lv === 3) return sy === 1 ? (sx === 1 ? "e3" : "e2") : sx === 1 ? "e1" : "e0"; return sy === 1 ? "e3" : "e2";}const PARTS = ["bones", "skull2", "skull", "jaw"];function drawHeap(g, st) { const buf = buildHeap(st); g.outlined("ink", () => { for (const part of PARTS) g.part(part, () => g.field({ x: [0, 47], y: [0, 31] }, (x, y) => { const b = buf[y * 48 + x]; return b && b.part === part ? b.key : null; })); }); return buf;}// ---- effects// green light spilling from the sockets: a dithered halo, and at a flare, sparks drifting upfunction eyeGlow(g, buf, st, t) { if (st.eye < 3) return; const lift = st.lift; g.detached("glow", () => { for (const [ex, ey] of HERO.eyes) { const cx = HERO.ox + ex + 0.5, cy = HERO.oy + ey + 1 - lift; const R = st.eye >= 4 ? 3.2 : 2.2; for (let y = Math.floor(cy - R); y <= Math.ceil(cy + R); y++) for (let x = Math.floor(cx - R); x <= Math.ceil(cx + R); x++) { if (x < 0 || y < 0 || x > 47 || y > 31) continue; const b = buf[y * 48 + x]; if (b && b.part !== "bones") continue; // only spill over the empty air and the bones around const r = Math.hypot(x + 0.5 - cx, y + 0.5 - cy); if (r < R && chk(x, y)) g.pixel(x, y, r < R * 0.55 ? "e2" : "e1"); } } }); if (st.eye >= 4) g.detached("sparks", () => { for (let k = 0; k < 3; k++) { const p = (t * 3 + k / 3) % 1; const x = HERO.ox + [1, 5, 9][k] + Math.round(Math.sin(TAU * (p + k * 0.3))), y = HERO.oy + 2 - lift - Math.round(p * 6); if (y >= 0 && p < 0.8) g.pixel(x, y, p < 0.4 ? "e3" : "e2"); } });}const PUFF = [ [[0, 0, "n4"], [1, 0, "n4"], [2, 0, "n3"], [1, -1, "n5"]], [[1, -2, "n4"], [0, -1, "n4"], [2, -1, "n5"], [3, -1, "n4"], [1, 0, "n3"], [3, 0, "n3"]], [[1, -3, "n3"], [3, -2, "n4"], [0, -1, "n3"], [4, -1, "n3"]],];function puff(g, x, y, st, dir) { if (st < 0 || st > 2) return; for (const [a, b, k] of PUFF[st]) g.pixel(x + a * dir + dir * st, y + b, k);}// ---- statesfunction idleState(t) { const f = Math.round(t * N_IDLE) % N_IDLE; // two soft pulses per loop, the second one brighter (a small halo at its peak) const eye = [0, 1, 2, 2, 1, 0, 1, 2, 3, 2, 1, 0][f]; const tip = { 4: -0.2, 5: -0.45, 6: -0.25 }[f] || 0; return { f, eye, tip, lift: 0, jaw: 0, off: {} };}// rise keys: lift, jaw drop, eye level, rattleconst RISE = [ [0, 0, 0, 0], [0, 0, 2, 1], // the heap shivers, the eyes wake [1, 1, 3, 2], [3, 2, 4, 1], // the skull lifts, jaw chattering, eyes flare [5, 0, 4, 2], [6, 2, 4, 1], [4, 0, 4, 0], // it drops [0, 1, 3, 2], // lands: the heap jolts, dust [1, 0, 1, 1], // a small bounce [0, 0, 0, 0], // == idle f0];const RATTLERS = [0, 2, 4, 7, 9, 12, 14];const BonePile = sprite(pal, (g, t, state) => { contactShadow(g, 24, GROUND, 21, 2); if (state === "idle") { const st = idleState(t); const buf = drawHeap(g, st); eyeGlow(g, buf, st, t); if (st.f === 7) g.detached("click", () => { g.pixel(42, 14, "n4"); g.pixel(43, 13, "n3"); g.pixel(36, 15, "n3"); }); return; } const f = Math.min(N_RISE - 1, Math.round(t * N_RISE)); const [lift, jaw, eye, rattle] = RISE[f]; const off = {}; if (rattle) RATTLERS.forEach((i, k) => { off[i] = [((k + f) % 2 ? 1 : -1) * (rattle > 1 || k % 2 ? 1 : 0), (k + f) % 3 === 0 ? -1 : 0]; }); const st = { eye, lift, jaw, tip: 0, off }; const buf = drawHeap(g, st); eyeGlow(g, buf, st, t); if (f === 7 || f === 8) { // the skull lands: dust kicks out on both sides (gone again by the last frame, which is idle f0) g.detached("dust", () => { puff(g, 16, 10, f - 7, -1); puff(g, 31, 10, f - 7, 1); }); }});submit(BonePile, { size: [48, 32], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: 24, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "rise", args: ["rise"], animate: { fps: 10, duration: N_RISE / 10, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。