Graveyard & Chapel · tombstone.js
墓石
48×48 の床置きの小物で、草の生えた古い墓の盛り土の上に、輪十字の紋字と墓碑銘を刻み、肩と根元に苔の付いた、風化した丸い墓石が立っています。idle(16 コマ)は草の房が揺れ、淡い青の魂の火が墓石の後ろを通りながら周りを回ります。cracked(16 コマ)は墓石が割れて傾き、割れ目と紋字から紫の光が脈打ちながらにじみます。rise(10 コマの一回再生)は土が震えて盛り上がり、弾けて、魂の火が地面から昇り、idle の最初のコマで終わります。
6×
動くスプライト素材: Graveyard & Chapel(itch.io)Vol.4・小物 12 点・33 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
tombstone.js · 1–8
// Tombstone — 48x48 floor prop, top-down 3/4, key light top-left.// A weathered round-topped headstone (you see its face, its lit top edge and its shaded right side) with an// engraved ring-cross rune and an epitaph, moss on the shoulder and the foot, over an old grassy grave mound.// Variants:// idle 16f loop grass tufts sway, a faint soul-wisp (blue, unoutlined) circles the stone, passing behind it// cracked 16f loop the stone is split and leaning; violet light seeps from the crack and the rune, pulsing// rise 10f once the mound shivers, bulges, bursts (clods fly), settles; the wisp climbs out of the earth// and the last frame is idle frame 0最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
tombstone.js · 259–269
submit(Tombstone, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.6, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle" }, { name: "cracked", args: ["cracked"] }, { name: "rise", args: ["rise"], animate: { fps: 10, duration: 1.0, loop: false } }, ],});プログラム全体を表示 (269 行)
tombstone.js · 269 行
// Tombstone — 48x48 floor prop, top-down 3/4, key light top-left.// A weathered round-topped headstone (you see its face, its lit top edge and its shaded right side) with an// engraved ring-cross rune and an epitaph, moss on the shoulder and the foot, over an old grassy grave mound.// Variants:// idle 16f loop grass tufts sway, a faint soul-wisp (blue, unoutlined) circles the stone, passing behind it// cracked 16f loop the stone is split and leaning; violet light seeps from the crack and the rune, pulsing// rise 10f once the mound shivers, bulges, bursts (clods fly), settles; the wisp climbs out of the earth// and the last frame is idle frame 0const W = 48, H = 48, CX = 24, GROUND = 45;const FX0 = 15, FX1 = 30, AY = 12.5, AR = 8, BASE = 34; // stone face: columns, arch centre row, arch radius, footconst ACX = 23; // arch centre x (pixel-centre coords)const DX = 2, DY = -2; // stone thickness, seen up and to the rightconst MND = { cx: 24, cy: 39, rx: 14, ry: 5 }; // grave moundconst inFace = (x, y) => { if (x < FX0 || x > FX1 || y > BASE) return false; if (y >= AY) return true; const dx = x + 0.5 - ACX, dy = y + 0.5 - AY; return dx * dx + dy * dy <= AR * AR;};const inSlab = (x, y) => inFace(x, y) || inFace(x - 1, y + 1) || inFace(x - DX, y - DY);// ---- stone -------------------------------------------------------------------------------------------------// crack: a zigzag down the face (cracked variant); returns the crack column for a rowconst CRACK = [[27, 3], [28, 7], [27, 10], [28, 14], [27, 18], [25, 22], [26, 26], [24, 30], [25, 35]];function crackX(y) { for (let i = 0; i + 1 < CRACK.length; i++) { const [x0, y0] = CRACK[i], [x1, y1] = CRACK[i + 1]; if (y >= y0 && y <= y1) return Math.round(x0 + (x1 - x0) * (y - y0) / (y1 - y0)); } return -99;}const RUNE = (() => { // ring-cross carved into the face: a ring of radius 3 round (23,16), a shaft and a crossbar const s = new Set(); for (let a = 0; a < 64; a++) { const x = Math.round(22.5 + 3.4 * Math.cos(TAU * a / 64)), y = Math.round(15.5 + 3.4 * Math.sin(TAU * a / 64)); s.add(x + "," + y); } for (let y = 10; y <= 23; y++) s.add("23," + y); for (let x = 18; x <= 27; x++) s.add(x + ",15"); for (const x of [20, 21, 22, 24, 26, 27]) s.add(x + ",26"); // epitaph: two lines of "words" for (const x of [19, 20, 22, 23, 24, 26]) s.add(x + ",28"); return s;})();const isRune = (x, y) => RUNE.has(x + "," + y);function faceTone(x, y, cracked, pulse) { const lEdge = !inFace(x - 1, y), tEdge = !inFace(x, y - 1), rEdge = !inFace(x + 1, y); if (cracked) { const c = crackX(y); if (x === c) return pulse > 0.66 ? "v3" : pulse > 0.3 ? "v2" : "v1"; // the glowing split if (x === c + 1) return "n1"; // right lip in the crack's shade if (x === c - 1 && !lEdge) return pulse > 0.4 ? "v1" : "n2"; // light spilling onto the left lip } if (isRune(x, y)) { if (cracked && y < 26) return pulse > 0.7 ? "v1" : "n0"; return y >= 26 ? "n1" : "n0"; } if (!cracked && isRune(x - 1, y - 1) && y < 30) return "n3"; // the carving's lit lower-right wall if (cracked && isRune(x - 1, y - 1) && y < 30) return "n3"; if (lEdge || (tEdge && x < ACX + 2)) return "n4"; // lit arris on the top-left if (rEdge) return "n1"; if (!inFace(x + 2, y) && chk(x, y)) return "n1"; // turning away from the light if (y >= BASE - 2) return "n1"; // damp foot if (y >= BASE - 5) return (x & 1) === (y & 1) && y === BASE - 3 ? "n1" : "n2"; if (y < 9 || x <= FX0 + 1) return "n3"; // upper arch and left strip catch more light return "n2";}function slabTone(x, y) { // top edge (faces the sky, lit) and right side (turned away) const dx = x + 0.5 - ACX - 1, dy = y + 0.5 - AY + 1; if (dy < 0 && x <= FX1 + 1) return dx < -3 ? "n4" : dx < 3 ? "n3" : "n2"; return y > BASE - 4 ? "n0" : "n1";}function stone(g, cracked, pulse) { g.part("stone", () => g.field({ x: [FX0 - 1, FX1 + DX + 1], y: [2, BASE] }, (x, y) => { if (inFace(x, y)) return faceTone(x, y, cracked, pulse); if (inSlab(x, y)) return slabTone(x, y); return null; })); g.part("chips", () => { // countable weathering: dents (dark top-left pixel, lit bottom-right pixel) for (const [x, y] of [[17, 12], [28, 20], [19, 31], [27, 7]]) { if (cracked && Math.abs(x - crackX(y)) < 2) continue; g.pixel(x, y, "n1"); g.pixel(x + 1, y + 1, "n4"); } }); g.part("moss", () => { // on the lit shoulder, dripping down the left arris, and a band at the damp foot for (const [x, y, k] of [[18, 5, "e1"], [19, 4, "e2"], [20, 4, "e2"], [21, 3, "e1"], [22, 3, "e1"], [17, 6, "e1"], [16, 7, "e1"], [16, 8, "e0"], [15, 10, "e1"], [15, 11, "e1"], [15, 12, "e0"], [18, 6, "e0"], [20, 3, "e3"], [17, 5, "e2"]]) g.pixel(x, y, k); for (let x = FX0; x <= FX1 + 1; x++) { const h = Math.floor(hash(x, 5) * 4) - (x > 25 ? 1 : 0); for (let i = 0; i < h; i++) g.pixel(x, BASE - i, i === h - 1 ? (x < 22 ? "e2" : "e1") : "e0"); } });}// ---- grave mound -------------------------------------------------------------------------------------------------const DIRT = ["w0", "w1", "w2", "w3"];function mound(g, bulge) { const { cx, cy, rx, ry } = MND; const B = { cx: 24, cy: 39 - bulge, rx: 5 + bulge * 0.6, ry: 2.5 + bulge * 0.6 }; g.part("mound", () => g.field({ x: [cx - rx, cx + rx], y: [cy - ry - 5, cy + ry] }, (x, y) => { if (bulge > 0) { const bi = litIndex(x, y, B.cx, B.cy, B.rx, B.ry, 4, 0.05); if (bi >= 0) return DIRT[bi]; } const i = litIndex(x, y, cx, cy, rx, ry, 4, 0.1); if (i < 0) return null; // old grave: a grass crown with a wavy edge; bare earth on the flanks and the near face const gx = (x + 0.5 - cx) / (rx - 2), gy = (y + 0.5 - (cy - 1.2)) / (ry - 1.3 + 0.6 * Math.sin(x * 1.3)); if (gx * gx + gy * gy <= 1) { const gi = litIndex(x, y, cx - 1, cy - 1, rx - 2, ry - 1, 4, 0.05); return ["e0", "e1", "e1", "e2"][Math.max(0, gi)]; } return DIRT[i]; })); g.part("blades", () => { // countable marks on the crown: a few blade strokes and lit tips for (const [x, y] of [[16, 37], [20, 36], [27, 36], [31, 38], [23, 40], [18, 40]]) { g.pixel(x, y, "e1"); g.pixel(x, y - 1, "e2"); } for (const [x, y] of [[17, 36], [19, 37], [22, 35], [15, 38]]) g.pixel(x, y, "e3"); }); g.part("pebbles", () => { for (const [x, y] of [[13, 41], [33, 40], [29, 43], [18, 43]]) { g.pixel(x, y, "n3"); g.pixel(x + 1, y, "n1"); } });}function cracks(g, f) { // shivering earth before the burst if (f < 1 || f > 4) return; g.part("fissures", () => { const L = f === 1 ? [[[22, 39], [24, 38], [26, 39]]] : [[[20, 38], [22, 37], [24, 38], [26, 37], [28, 38]], [[24, 38], [23, 40]]]; for (const path of L) for (let i = 0; i + 1 < path.length; i++) g.line(path[i], path[i + 1], "ink"); });}// ---- grass -------------------------------------------------------------------------------------------------------const TUFTS = [[12, 38, 0.0, 1], [14, 34, 0.3, 1], [33, 35, 0.55, 0], [36, 39, 0.8, 0], [20, 44, 0.15, 1], [30, 44, 0.65, 0], [10, 42, 0.4, 1]];function grass(g, t) { g.part("grass", () => { for (const [bx, by, ph, lit] of TUFTS) { const sw = Math.round(Math.sin(TAU * (t + ph)) * 1.2); for (const [dx, h, lean] of [[-1, 3, -1], [0, 4, 0], [1, 3, 1]]) { const x0 = bx + dx, tip = [x0 + lean + sw, by - h]; g.line([x0, by], tip, "e1"); g.pixel(x0, by, "e0"); g.pixel(tip[0], tip[1], lit ? "e3" : "e2"); } } });}// ---- soul wisp -----------------------------------------------------------------------------------------------------const wispAt = (t) => [23 + 14 * Math.cos(TAU * t + 2.3), 21 + 6 * Math.sin(TAU * t + 2.3) - 2 * Math.sin(2 * TAU * t)];function wispBlob(g, x, y, tail, flick) { // a soft glowing head (white core, blue body, dithered halo) and a tail of fading dots x = Math.round(x); y = Math.round(y); for (const [dx, dy] of [[-2, -1], [-1, -2], [3, 1], [1, 3], [-2, 2], [3, -1], [1, -2], [-1, 3], [0, -2], [-2, 0], [3, 0], [0, 3]]) if (chk(x + dx, y + dy + (flick ? 1 : 0))) g.pixel(x + dx, y + dy, "b1"); for (const [dx, dy] of [[0, -1], [1, -1], [-1, 0], [2, 0], [-1, 1], [2, 1], [0, 2], [1, 2]]) g.pixel(x + dx, y + dy, "b2"); g.pixel(x, y, "n5"); g.pixel(x + 1, y, flick ? "b3" : "n5"); g.pixel(x, y + 1, "b3"); g.pixel(x + 1, y + 1, "b2"); tail.forEach(([tx, ty], i) => g.pixel(Math.round(tx), Math.round(ty), i < 2 ? "b2" : "b1"));}function wisp(g, t) { const [x, y] = wispAt(t); const tail = [0.045, 0.07, 0.095, 0.12, 0.145].map((d) => wispAt(t - d)).map(([tx, ty], i) => [tx + 0.5, ty - 0.5 - i * 0.7]); const behind = Math.sin(TAU * t + 2.3) < -0.15; g.depth(behind ? 2 : -2, () => g.detached("wisp", () => wispBlob(g, x, y, tail, Math.sin(TAU * t * 4) > 0)));}// ---- rise -----------------------------------------------------------------------------------------------------------const RISE = 10;const BULGE = [0, 0, 1, 2, 3, 1, 1, 0, 0, 0];function clods(g, f) { // [x0, vx, peak height, landing y] const C = [[22, -7, 8, 38], [25, 7, 10, 39], [23, -2, 12, 36], [21, -11, 5, 42], [26, 10, 6, 42]]; if (f < 4 || f > 7) return; const p = (f - 3) / 4; // 0.25 .. 1 g.outlined("ink", () => g.part("clods", () => { C.forEach(([x0, vx, hp, ly], i) => { const x = Math.round(x0 + vx * p), y = Math.round(37 + (ly - 37) * p - hp * 4 * p * (1 - p)); if (f === 7) { g.pixel(x, y, "w2"); g.pixel(x + 1, y, "w1"); return; } // landed crumbs g.rect({ x: [x, x + 2], y: [y, y + 1] }, "w1", { fill: "w1" }); g.pixel(x, y, "w3"); g.pixel(x + 1, y, "w2"); g.pixel(x + 2, y + 1, "w0"); if (i < 3) { g.pixel(x + 1, y - 1, "w2"); g.pixel(x + 1, y + 2, "w0"); } if (i === 1) g.pixel(x + 2, y - 1, "e1"); // a clod still wearing its turf }); })); if (f <= 6) g.detached("dust", () => { const r = [0, 0, 0, 0, 4, 7, 9][f]; for (let a = 0; a < 12; a++) { const x = Math.round(24 + r * Math.cos(TAU * a / 12)), y = Math.round(38 + r * 0.45 * Math.sin(TAU * a / 12) - (f - 3)); if (chk(x, y) || f === 4) g.pixel(x, y, f === 6 ? "n2" : "n3"); } });}function stateAt(state, t) { t = ((t % 1) + 1) % 1; if (state === "rise") { const f = Math.min(RISE - 1, Math.round(t * RISE)); if (f === 0 || f === RISE - 1) return { state: "idle", t: 0, f: -1 }; return { state: "rise", t: 0, f }; } return { state, t, f: -1 };}const Tombstone = sprite(pal, (g, t, state) => { const S = stateAt(state, t); const cracked = S.state === "cracked"; const pulse = 0.5 + 0.5 * Math.cos(TAU * S.t); const f = S.f; contactShadow(g, CX, GROUND - 1, 17, 2); const shake = f === 2 || f === 4 ? 1 : f === 3 ? -1 : 0; g.outlined("ink", () => { if (cracked) g.at(23, BASE, () => g.rotate(8, () => g.at(-23, -BASE, () => stone(g, true, pulse)))); else g.at(shake, 0, () => stone(g, false, pulse)); }); g.outlined("ink", () => mound(g, f > 0 ? BULGE[f] : 0)); cracks(g, f); if (f === 4 || f === 5) g.part("burst", () => { // the earth tears open where the clods came from g.line([22, 37 - BULGE[f]], [26, 37 - BULGE[f]], "ink"); g.line([23, 38 - BULGE[f]], [25, 38 - BULGE[f]], "w0"); }); grass(g, f > 0 ? 0 : S.t); if (cracked) { g.part("fallenChip", () => { g.pixel(33, 33, "n3"); g.pixel(34, 33, "n2"); g.pixel(33, 34, "n2"); g.pixel(34, 34, "n1"); }); g.detached("motes", () => { for (let k = 0; k < 4; k++) { const p = (S.t * 2 + k / 4) % 1; const x = 28 + [-1, 1, 0, 2][k] + Math.round(Math.sin(TAU * (p + k * 0.3)) * 1.5); const y = 12 + k * 5 - Math.round(p * 8); if (p < 0.4) g.pixel(x, y, "v3"); else if (p < 0.8) g.pixel(x, y, "v2"); } }); g.part("seep", () => { // the glow reaches the ground at the stone's foot const k = pulse > 0.5 ? "v2" : "v1"; g.pixel(24, 35, k); g.pixel(23, 36, "v1"); g.pixel(25, 36, "v1"); if (pulse > 0.66) g.pixel(24, 36, "v3"); }); } else if (f < 0) { wisp(g, S.t); } if (f > 0) { clods(g, f); if (f >= 5) { // the wisp climbs out of the burst point towards its idle frame-0 spot const [ex, ey] = wispAt(0), k = (f - 4) / 5; const x = 24 + (ex - 24) * k, y = 37 + (ey - 37) * k; g.depth(-2, () => g.detached("wisp", () => wispBlob(g, x, y, [[x + (24 - ex) * 0.12, y + 2], [x + (24 - ex) * 0.2, y + 4]], true))); } }});submit(Tombstone, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.6, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle" }, { name: "cracked", args: ["cracked"] }, { name: "rise", args: ["rise"], animate: { fps: 10, duration: 1.0, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。