Tessarune
Graveyard & Chapel · bone_pile.js

骨の山

48×32 · JavaScript 313 行

48×32 の床置きの小物で、長い骨、肋骨、指の骨、2 つの頭蓋骨を積んだ山です。生々しさはなく、古く乾いた骨です。骨は一本ずつ距離で表した形として作り、それぞれの面の向きから骨の階調で陰影を付けるので、明るい面はいつも左上です。奥から手前へ重ね、下に潜り込むところには冷たい色の境目の線を入れています。idle(12 コマ)は上の頭蓋骨の眼窩が 2 回淡く緑に光り、小さな骨が揺れて戻ります。rise(10 コマの一回再生)は山がカタカタ鳴り、上の頭蓋骨があごを鳴らし目を緑に光らせて浮き上がり、ほこりを立てて山に戻ります。

骨の山 — 描画結果6×
このプログラムで描画(変種「rise」) · 48×32 · 10 コマ
Graveyard & Chapel動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。