Graveyard & Chapel · chapel_altar.js
礼拝堂の祭壇
64×48 の床置きの小物で、広い段の上の石の祭壇に、金の縁飾りと太陽の印を付けた深紅の前掛け布、背の高い真鍮の燭台 2 本、木の台に開いた書物、金の杯が置かれ、ろうそくが足元の石を照らします。idle(12 コマ)は炎が揺れ、石板の上の暖かい光だまりが息づきます。blessing(10 コマの一回再生)はページが金色に光り、光の筋が昇り、光の粒の輪が浮かんで消えます。desecrated(12 コマ)はろうそくが消えて溶け、布は破れ、石板は割れ、杯はこぼれ、書物から紫の光の粒が出ます。
6×
動くスプライト素材: Graveyard & Chapel(itch.io)Vol.4・小物 12 点・33 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
chapel_altar.js · 1–9
// Chapel altar — 64x48 floor prop, top-down 3/4, key light top-left.// A stone altar (overhanging mensa slab on a recessed block, on a wide step) dressed with a deep-red frontal cloth// with gold trim, fringe and a sun sigil; two tall brass candlesticks, an open tome on a wooden rest in the centre,// a gold chalice. The candles light the stone around their feet.// Variants:// idle loop, 12f — flames flicker and sway, the warm light pool on the slab breathes// blessing one-shot, 10f — pages flare gold, rays rise, a ring of light motes lifts and fades; ends == idle f0// desecrated loop, 12f — candles snuffed and melted with thin smoke, frontal torn, slab cracked, chalice knocked// onto the step and spilled, the tome glows violet and sheds violet motes最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
chapel_altar.js · 355–364
submit(Altar, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "blessing", args: ["blessing"], animate: { fps: 10, duration: N_BLESS / 10, loop: false } }, { name: "desecrated", args: ["desecrated"], animate: { fps: 10, duration: N_DES / 10, loop: true } }, ],});プログラム全体を表示 (364 行)
chapel_altar.js · 364 行
// Chapel altar — 64x48 floor prop, top-down 3/4, key light top-left.// A stone altar (overhanging mensa slab on a recessed block, on a wide step) dressed with a deep-red frontal cloth// with gold trim, fringe and a sun sigil; two tall brass candlesticks, an open tome on a wooden rest in the centre,// a gold chalice. The candles light the stone around their feet.// Variants:// idle loop, 12f — flames flicker and sway, the warm light pool on the slab breathes// blessing one-shot, 10f — pages flare gold, rays rise, a ring of light motes lifts and fades; ends == idle f0// desecrated loop, 12f — candles snuffed and melted with thin smoke, frontal torn, slab cracked, chalice knocked// onto the step and spilled, the tome glows violet and sheds violet motesconst W = 64, H = 48, CX = 32, GROUND = 45;const N_IDLE = 12, N_BLESS = 10, N_DES = 12;const CL = ["w0", "r0", "r1", "r2"]; // cloth ramp: deep fold -> lit ridgeconst CANDLES = [13, 51];const CHAL = 44;const gs = (u) => (u < -0.6 ? "g3" : u < -0.1 ? "g2" : u < 0.5 ? "g1" : "g0"); // brass across a round form// ---- light & marks on the altar top -----------------------------------------------------------------------function castShadow(x, y, s) { // countable shadows thrown down-right by what stands on the slab (light is top-left) for (const cx of CANDLES) if (y >= 24 && y <= 25 && x >= cx + 3 && x <= cx + 4 + (y - 24)) return true; if (!s.fallen && ((x === CHAL + 3 && y >= 22 && y <= 25) || (y === 25 && x >= CHAL + 1 && x <= CHAL + 3))) return true; if (y === 26 && x >= 27 && x <= 41) return true; if (x === 41 && y >= 18 && y <= 25) return true; return false;}function warmAt(x, y, s) { // the candles' light pool on the stone: 2 = inside, 1 = dithered rim, 0 = none if (!s.lit) return 0; let best = 0; for (const [i, cx] of CANDLES.entries()) { const r = 6.5 + s.pool * 1.2 + (i ? -0.4 : 0); const dx = (x + 0.5 - cx - 0.5) / r, dy = (y + 0.5 - 23) / (r * 0.42), d = dx * dx + dy * dy; if (d < 0.55) best = 2; } return best;}function tomeLight(x, y, s) { // magic light around the tome (gold when blessing, violet when desecrated): 0..2 const L = s.goldLight || s.violet; if (!L) return 0; const r = 7 + L * 2.2; const dx = (x + 0.5 - CX) / r, dy = (y + 0.5 - 22) / (r * 0.45), d = dx * dx + dy * dy; return d < 0.35 ? 2 : d < 1 ? 1 : 0;}const CRACK = new Set(["20,19", "21,20", "21,21", "22,22", "23,22", "24,23", "50,30", "51,31", "51,32", "52,33", "52,34", "10,33", "11,34"]);function stoneTop(x, y, s, base) { if (castShadow(x, y, s)) return "n2"; const tl = tomeLight(x, y, s); if (tl === 2 && s.violet) return s.violet > 1 && chk(x, y) ? "v2" : "v1"; if (tl && s.goldLight) { if (tl === 2) return chk(x, y) || s.goldLight > 2 ? "n5" : "n4"; if (chk(x, y)) return "n4"; } const w = warmAt(x, y, s); if (w === 2) return base === "n4" ? "n5" : "n4"; return base;}// ---- the altar block --------------------------------------------------------------------------------------function plinth(g) { g.part("step", () => g.field({ x: [6, 57], y: [39, 44] }, (x, y) => { if (y === 39) return x >= 11 && x <= 56 ? (x > 46 ? "n1" : "n2") : "n3"; // the altar's shadow on the step if (y === 40) return x === 6 ? "n4" : x < 42 ? "n4" : "n3"; if (y === 44) return "n1"; if (x === 6) return "n3"; if (y >= 42 && (x - 8) % 5 === 0 && x < 56) return "n1"; // dentils if (y >= 42 && (x - 9) % 5 === 0 && x < 56) return x < 44 ? "n3" : "n2"; return x > 46 ? "n1" : "n2"; }));}function altarBlock(g, s) { g.part("altar", () => { // recessed body under the slab g.field({ x: [10, 53], y: [30, 38] }, (x, y) => { if (s.crack && CRACK.has(x + "," + y)) return "n0"; if (y === 30) return "n0"; // shadow under the overhang const right = x >= 47; const joint = y === 34 || ((x === 14 || x === 49) && y < 34) || ((x === 12 || x === 51) && y > 34); if (joint) return right ? "n0" : "n1"; if (x === 10) return "n3"; return right ? "n1" : "n2"; }); // mensa slab: lit top face, then its front edge g.field({ x: [8, 55], y: [19, 29] }, (x, y) => { if (s.crack && CRACK.has(x + "," + y)) return y < 28 ? "n1" : "n0"; if (y === 28) return x === 55 ? "n2" : x <= 33 ? "n4" : "n3"; if (y === 29) return x === 55 ? "n1" : x <= 47 ? "n2" : "n1"; let base = "n3"; if ((y === 19 && x < 52) || x === 8) base = "n4"; else if (x === 55) base = "n2"; else if (y === 27 && x < 40) base = "n4"; // bevel along the near edge return stoneTop(x, y, s, base); }); // chips on the slab corners (countable) g.pixel(55, 19, "n2"); g.pixel(9, 28, "n3"); });}// ---- the frontal cloth ------------------------------------------------------------------------------------const clothBottom = (x, s) => (s.torn ? 34 + Math.floor(hash(x, 7) * 3.2) : 37);const ripped = (x, y, s) => s.torn && y >= 30 && Math.abs(x + 0.5 - 37 + (y - 30) * 0.3) <= (y - 29) * 0.75;function cloth(g, s) { const dark = s.torn ? 1 : 0; const tone = (k) => CL[Math.max(0, k - dark)]; const trim = (x) => (s.torn ? (x < 32 ? "g1" : "g0") : x < 36 ? "g2" : "g1"); g.part("cloth", () => { // the part lying on the slab g.field({ x: [19, 44], y: [25, 27] }, (x, y) => { if (y === 25) return trim(x); if (castShadow(x, y, s)) return tone(1); const tl = tomeLight(x, y, s); if (tl === 2 && chk(x, y)) return s.violet ? "v1" : "r3"; if (y === 26) return tone(2); return x < 38 ? tone(3) : tone(2); // the fold over the edge catches the light }); // the hanging part: vertical folds lit from the left g.field({ x: [19, 44], y: [28, 38] }, (x, y) => { const bot = clothBottom(x, s); if (y > bot + 1 || ripped(x, y, s)) return null; if (y === bot + 1) return s.torn ? (hash(x, 3) > 0.55 ? tone(1) : null) : (x % 2 === 0 ? (x < 36 ? "g2" : "g1") : null); // fringe / threads if (x === 19 || x === 44) return x === 19 ? trim(x) : s.torn ? "g0" : "g1"; if (y === bot - 1) return s.torn ? tone(1) : trim(x); if (y === bot) return tone(1); if (s.torn && Math.abs(x + 0.5 - 37 + (y - 30) * 0.3) <= (y - 29) * 0.75 + 1) return tone(0); // frayed lip of the rip const u = (x - 19) / 25; const v = 0.62 * Math.sin(u * TAU * 3 + 0.5) + 0.28 * Math.sin(u * TAU * 1.3 + 1.1) - (u - 0.45) * 0.9 - (y - 28) * 0.03; return tone(v > 0.62 ? 3 : v > -0.05 ? 2 : v > -0.7 ? 1 : 0); }); });}function sigil(g, s) { // a sun disc with eight rays, embroidered in gold on the frontal const cx = 31, cy = 32, lvl = s.sigil || 0; const disc = s.torn ? (s.violet > 1 ? ["v3", "v2", "v1"] : ["v2", "v1", "v0"]) : lvl >= 2 ? ["g3", "g3", "g2"] : lvl ? ["g3", "g2", "g2"] : ["g3", "g2", "g1"]; const ray = s.torn ? "v1" : lvl >= 2 ? "g3" : "g1"; g.part("sigil", () => { for (const [dx, dy] of [[0, -3], [0, 3], [-3, 0], [3, 0], [-2, -2], [2, -2], [-2, 2], [2, 2]]) { if (s.torn && (dx > 0 || dy > 1)) continue; // half the rays unpicked g.pixel(cx + dx, cy + dy, ray); } g.rect({ x: [cx - 1, cx + 1], y: [cy - 1, cy + 1] }, disc[1], { fill: disc[1] }); g.pixel(cx - 1, cy - 1, disc[0]); g.pixel(cx + 1, cy + 1, disc[2]); g.pixel(cx, cy + 1, disc[2]); g.pixel(cx + 1, cy, disc[2]); if (lvl >= 2) for (const [dx, dy] of [[0, -4], [0, 4], [-4, 0], [4, 0]]) g.pixel(cx + dx, cy + dy, "g2"); });}// ---- candlesticks -----------------------------------------------------------------------------------------function candlestick(g, cx, s) { const top = s.lit ? 7 : 10; // snuffed candles have burned down g.part(cx < CX ? "candleL" : "candleR", () => { // foot: a stepped dome for (const [y, hw] of [[22, 1], [23, 2], [24, 3]]) for (let x = cx - hw; x <= cx + hw; x++) g.pixel(x, y, gs((x - cx) / (hw + 0.5))); g.pixel(cx - 1, 22, "g3"); // stem with a knop for (let y = 16; y <= 21; y++) { g.pixel(cx - 1, y, "g2"); g.pixel(cx, y, "g1"); g.pixel(cx + 1, y, "g0"); } for (let x = cx - 2; x <= cx + 2; x++) g.pixel(x, 19, gs((x - cx) / 2.5)); g.pixel(cx - 2, 19, "g3"); // drip pan for (let x = cx - 3; x <= cx + 3; x++) g.pixel(x, 15, gs((x - cx) / 3.5)); for (let x = cx - 2; x <= cx + 2; x++) g.pixel(x, 16, x < cx ? "g1" : "g0"); g.pixel(cx - 2, 14, "g2"); g.pixel(cx + 2, 14, "g1"); // the candle: cream wax, lit on the left, a drip on the right for (let y = top; y <= 14; y++) { g.pixel(cx - 1, y, "n5"); g.pixel(cx, y, "n4"); g.pixel(cx + 1, y, "n3"); } g.pixel(cx, top, "n5"); g.pixel(cx + 1, top + 1, "n4"); g.pixel(cx + 1, top + 2, "n4"); if (!s.lit) { g.pixel(cx + 2, 14, "n4"); g.pixel(cx - 2, 13, "n5"); } // melted wax pooled on the pan if (s.lit) { g.pixel(cx - 1, top, "g3"); g.pixel(cx, top, "g3"); } // the flame lights the rim of the wax });}function candleFlame(g, x, t, ph, extra) { // a teardrop that flickers (height) and sways (lean), periodic in t const s = Math.sin(TAU * (t * 2 + ph)), c = Math.sin(TAU * (t * 3 + ph * 1.7)); const h = Math.min(7, 5 + (c > 0.2 ? 1 : 0) + extra); const yb = 6; for (let i = 0; i < h; i++) { const k = i / (h - 1), y = yb - i, ox = x + Math.round(s * k * 1.4); if (i <= 2 + (extra > 1 ? 1 : 0)) { g.pixel(ox - 1, y, i === 0 ? "r1" : "r2"); g.pixel(ox + 1, y, i === 0 ? "r1" : "r2"); g.pixel(ox, y, i === 0 ? "r3" : i <= 2 ? "g3" : "r3"); } else g.pixel(ox, y, i === h - 1 ? "r1" : i === h - 2 ? "r2" : "r3"); }}function smoke(g, x, t, ph) { // a thin wisp curling up from the snuffed wick; an ember blinks on the wick now and then const wick = 9; g.pixel(x, wick, (t * 12 + ph * 12) % 12 < 3 ? "r1" : "ink"); for (let j = 0; j < 6; j++) { const p = (t + j / 6 + ph) % 1; const y = wick - 1 - Math.round(p * 11); const xx = x + Math.round(Math.sin(TAU * (p * 1.3 + ph)) * (0.5 + p * 1.8)); if (p < 0.4) g.pixel(xx, y, "n3"); else if (p < 0.8) g.pixel(xx, y, "n2"); }}// ---- the tome and the chalice ---------------------------------------------------------------------------function tome(g, s) { g.part("bookrest", () => { g.rect({ x: [26, 38], y: [24, 25] }, "w2", { fill: "w2" }); g.line([26, 24], [34, 24], "w3"); g.rect({ x: [36, 38], y: [24, 25] }, "w1", { fill: "w1" }); g.pixel(26, 25, "w3"); }); const P = s.pages; // page palette: base, shaded edge, gutter, text, initial const K = P === "violet" ? ["n4", "v3", "v1", "v2", "v3"] : P === 3 ? ["g3", "g3", "g2", "g2", "g2"] : P === 2 ? ["g3", "g2", "g1", "g1", "g1"] : P === 1 ? ["n5", "g3", "g1", "g1", "r1"] : ["n5", "n4", "n2", "n3", "r1"]; g.part("tome", () => { g.rect({ x: [24, 40], y: [17, 23] }, "r0", { fill: "r0" }); // leather boards g.line([24, 23], [40, 23], "w0"); g.pixel(24, 17, "g2"); g.pixel(40, 17, "g1"); g.pixel(24, 23, "g1"); g.pixel(40, 23, "g0"); // brass corners g.line([25, 22], [31, 22], "n3"); g.line([33, 22], [39, 22], "n3"); // page stacks g.pixel(25, 22, "n4"); g.pixel(33, 22, "n4"); g.field({ x: [25, 39], y: [16, 21] }, (x, y) => { if (x === 32) return y >= 17 ? K[2] : null; // gutter if (y === 16 && (x === 25 || x === 31 || x === 33 || x === 39)) return null; // rounded page heads const line = x < 32 ? (y === 17 || y === 19 || y === 21) : (y === 19 || y === 21 || (y === 17 && x > 36)); if (line && ((x >= 26 && x <= 30) || (x >= 34 && x <= 38)) && hash(x, y * 3) > 0.28) return K[3]; if (x >= 34 && x <= 35 && y >= 17 && y <= 18) return K[4]; // illuminated initial if (x === 31 || x === 33 || x === 39) return K[1]; return K[0]; }); g.line([32, 24], [32, 25], "r1"); // ribbon marker });}function chalice(g) { const R = { 15: [43, ["g2", "g2", "g1"]], 16: [42, ["g3", "r0", "r0", "r1", "g1"]], 17: [42, ["g3", "g2", "g2", "g1", "g0"]], 18: [42, ["g2", "g2", "g1", "g1", "g0"]], 19: [43, ["g2", "g1", "g0"]], 20: [44, ["g1"]], 21: [43, ["g3", "g1", "g0"]], 22: [44, ["g1"]], 23: [43, ["g2", "g1", "g0"]], 24: [42, ["g3", "g2", "g1", "g1", "g0"]], }; g.part("chalice", () => { for (const [y, [x0, ks]] of Object.entries(R)) ks.forEach((k, i) => g.pixel(x0 + i, +y, k)); });}function fallenChalice(g) { // knocked off the altar: lying on the step, mouth to the right g.part("chalice", () => { [["g3", 37], ["g2", 38], ["g1", 39], ["g0", 40]].forEach(([k, y]) => g.pixel(45, y, k)); g.pixel(46, 38, "g2"); g.pixel(46, 39, "g1"); g.pixel(47, 38, "g3"); g.pixel(47, 39, "g0"); const bowl = [["g3", "g3", "g2", "g2"], ["g2", "g2", "g2", "g1"], ["g1", "g1", "g1", "g0"], ["g0", "g0", "g0", "g0"]]; bowl.forEach((row, j) => row.forEach((k, i) => g.pixel(48 + i, 37 + j, k))); ["g3", "r0", "r0", "r1", "r0", "g0"].forEach((k, j) => g.pixel(52, 36 + j, k)); });}function spill(g) { g.part("spill", () => { g.line([53, 40], [57, 40], "r0"); g.line([54, 39], [55, 39], "r0"); g.pixel(54, 39, "r1"); g.pixel(57, 41, "r0"); g.pixel(57, 42, "r0"); });}// ---- effects ----------------------------------------------------------------------------------------------function rays(g, len) { g.detached("rays", () => { for (const [x, m] of [[27, 0.7], [29, 1], [32, 1.2], [35, 1], [37, 0.7]]) { const n = Math.round(len * m); for (let i = 0; i < n; i++) g.pixel(x, 15 - i, i < n * 0.5 ? "g3" : "g2"); } });}function moteRing(g, p) { // ten motes on a level ring that lifts off the tome, widening and fading g.detached("ring", () => { const rx = 6 + p * 12, ry = rx * 0.38, cy = 20 - p * 15; for (let i = 0; i < 10; i++) { const a = TAU * i / 10 + p * 1.6; const x = Math.round(CX + Math.cos(a) * rx), y = Math.round(cy + Math.sin(a) * ry); const near = Math.sin(a) > 0; const k = p > 0.8 ? "g1" : p > 0.55 ? "g2" : near ? "g3" : "g2"; g.pixel(x, y, k); if (p < 0.55 && near) g.pixel(x, y + 1, "g2"); if (p < 0.3) { g.pixel(x - 1, y, "g2"); g.pixel(x + 1, y, "g2"); } } });}function violetAura(g, lvl, t) { // a flickering corona hugging the tome: a dotted crown over the pages and short flames at the sides g.detached("aura", () => { const sh = Math.round(t * N_DES) % 2; for (let x = 25; x <= 39; x++) if (x !== 32 && (x + sh) % 2 === 0) g.pixel(x, 15, lvl > 1 ? "v2" : "v1"); if (lvl > 1) for (const x of [27, 30, 34, 37]) g.pixel(x + sh, 14, "v1"); for (let y = 17; y <= 22; y++) if ((y + sh) % 2 === 0) { g.pixel(23, y, "v1"); g.pixel(41, y, lvl > 1 ? "v2" : "v1"); } });}function violetMotes(g, t) { g.detached("motes", () => { for (let i = 0; i < 4; i++) { const p = (t + i / 4) % 1; const x = CX + [-5, 2, 6, -1][i] + Math.round(Math.sin(TAU * (p + i * 0.3)) * 1.5); const y = 16 - Math.round(p * 15); if (p < 0.35) { g.pixel(x, y, "v3"); g.pixel(x, y + 1, "v2"); } else if (p < 0.7) g.pixel(x, y, "v2"); else if (chk(x, y)) g.pixel(x, y, "v1"); } });}// ---- scene ------------------------------------------------------------------------------------------------function scene(g, s) { contactShadow(g, CX, GROUND, 29, 2); g.outlined("ink", () => { plinth(g); altarBlock(g, s); cloth(g, s); }); sigil(g, s); for (const cx of CANDLES) g.outlined("ink", () => candlestick(g, cx, s)); g.outlined("ink", () => tome(g, s)); if (s.fallen) { spill(g); g.outlined("ink", () => fallenChalice(g)); } else g.outlined("ink", () => chalice(g)); if (s.lit) g.detached("flames", () => CANDLES.forEach((cx, i) => candleFlame(g, cx, s.t, i * 0.37, s.flameExtra))); else g.detached("smoke", () => CANDLES.forEach((cx, i) => smoke(g, cx, s.t, i * 0.5))); if (s.rays) rays(g, s.rays); if (s.ring !== undefined) moteRing(g, s.ring); if (s.violet) { violetAura(g, s.violet, s.t); violetMotes(g, s.t); }}const base = (t) => ({ t, lit: true, pool: 0.5 + 0.5 * Math.sin(TAU * t * 2), flameExtra: 0, pages: 0, sigil: 0 });const Altar = sprite(pal, (g, t, state) => { if (state === "idle") { scene(g, base(t)); return; } if (state === "blessing") { const f = Math.min(N_BLESS - 1, Math.round(t * N_BLESS)); if (f === N_BLESS - 1) { scene(g, base(0)); return; } // back to idle frame 0 const s = base(f / N_BLESS); s.pages = [1, 2, 3, 3, 3, 2, 2, 1, 1][f]; s.goldLight = [0, 1, 2, 3, 3, 2, 1, 1, 0][f]; s.sigil = [0, 1, 2, 2, 2, 2, 1, 1, 0][f]; s.flameExtra = [1, 1, 2, 2, 2, 1, 1, 0, 0][f]; s.rays = [0, 3, 7, 8, 5, 2, 0, 0, 0][f]; if (f >= 2) s.ring = (f - 2) / 6.5; scene(g, s); return; } // desecrated const pulse = 0.5 + 0.5 * Math.cos(TAU * t); scene(g, { t, lit: false, pool: 0, pages: "violet", torn: true, fallen: true, crack: true, violet: pulse > 0.5 ? 2 : 1 });});submit(Altar, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "blessing", args: ["blessing"], animate: { fps: 10, duration: N_BLESS / 10, loop: false } }, { name: "desecrated", args: ["desecrated"], animate: { fps: 10, duration: N_DES / 10, loop: true } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。