Tessarune
Graveyard & Chapel · holy_font.js

聖水盤

48×48 · JavaScript 206 行

48×48 の床置きの小物で、溝を彫った台と段のある台座の上に、広い縁と縁取りのある鉢、正面に太陽の浮き彫りを付けた石の水盤があり、水が満ちています。変種はどれも 10 fps・12 コマのループです。idle は澄んだ青い水に、ループごとにゆっくりした波紋が一つ広がり、きらめきが瞬きます。glow は水が金色とクリーム色に光って縁を照らし、光の粒が昇ります。cursed は水が濁った緑になり、泡がふくらんで弾け、緑のぬめりが鉢を伝い落ちます。

聖水盤 — 描画結果6×
このプログラムで描画(変種「glow」) · 48×48 · 12 コマ
Graveyard & Chapel動くスプライト素材: Graveyard & Chapel(itch.io)Vol.4・小物 12 点・33 アニメ

冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。

holy_font.js · 1–7
// Holy font — 48x48 floor prop, top-down 3/4, key light top-left.// A carved stone basin (broad lip, moulded bowl, a sun boss on the front) on a fluted pedestal and a stepped// plinth, filled with water.// Variants (all 12f loops @ 10 fps)://   idle    — clear blue water, one slow ripple ring spreads per loop, glints twinkle//   glow    — the water shines gold/cream and lights the lip, motes of light rise (healing / save point)//   cursed  — murky green water, bubbles swell and pop, green slime runs down the bowl, the boss is dead

最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。

holy_font.js · 197–206
submit(Font, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N / 10, loop: true },  anchor: { x: CX, y: GROUND },  variants: [    { name: "idle", args: ["idle"] },    { name: "glow", args: ["glow"] },    { name: "cursed", args: ["cursed"] },  ],});
プログラム全体を表示 (206 行)
holy_font.js · 206 行
// Holy font — 48x48 floor prop, top-down 3/4, key light top-left.// A carved stone basin (broad lip, moulded bowl, a sun boss on the front) on a fluted pedestal and a stepped// plinth, filled with water.// Variants (all 12f loops @ 10 fps)://   idle    — clear blue water, one slow ripple ring spreads per loop, glints twinkle//   glow    — the water shines gold/cream and lights the lip, motes of light rise (healing / save point)//   cursed  — murky green water, bubbles swell and pop, green slime runs down the bowl, the boss is deadconst W = 48, H = 48, CX = 24, GROUND = 45;const N = 12;const LIP = { cy: 15.5, rx: 16, ry: 6 };const HOLE = { cy: 15, rx: 13, ry: 4 };const WATER = { cy: 15.6, rx: 12, ry: 3.2 };const WR = { idle: ["b0", "b1", "b2", "b3"], glow: ["g1", "g2", "g3", "n5"], cursed: ["e0", "e1", "e2", "e3"] };const stone = (u) => (u < -0.6 ? "n4" : u < -0.1 ? "n3" : u < 0.5 ? "n2" : "n1");// ---- plinth & pedestal ------------------------------------------------------------------------------------function plinth(g) {  g.part("plinth", () => {    g.field({ x: [10, 37], y: [38, 44] }, (x, y) => {      if ((x === 10 || x === 37) && y === 38) return null;           // clipped corners of the octagon      if (y === 38) return x < 30 ? "n4" : "n3";      if (y === 39) return x >= 26 ? (x > 33 ? "n1" : "n2") : "n3";   // the pedestal's shadow falls down-right      if (y === 40) return x < 28 ? "n4" : "n3";      if (y === 44) return "n1";      if (x === 10) return "n3";      if (y === 42 && (x - 12) % 4 === 0 && x < 36) return "n1";        // carved notches      return x > 31 ? "n1" : "n2";    });    g.pixel(35, 41, "n1"); g.pixel(34, 42, "n1");                       // a chip    g.pixel(11, 43, "e1"); g.pixel(12, 43, "e2"); g.pixel(11, 42, "e1"); // moss in the corner  });}function pedestal(g) {  const R = (y) => (y === 29 ? 5.5 : y >= 36 ? 6.5 : y === 35 ? 5.5 : 4.5);  g.part("pedestal", () => g.field({ x: [16, 32], y: [28, 38] }, (x, y) => {    const r = R(y), u = (x + 0.5 - CX) / r;    if (Math.abs(u) > 1 || y === 28) return null;    if (y === 38) return u < 0.3 ? "n1" : "n0";    if (y === 30) return u < 0.2 ? "n1" : "n0";                       // shadow under the collar    if (y === 29 || y >= 35) return stone(u - 0.1);    if ((x === 22 || x === 26) ) return x === 22 ? "n2" : "n0";       // flutes    return stone(u);  }));}// ---- basin ------------------------------------------------------------------------------------------------const bowlR = (y) => { const k = (y - 16) / 13.5; return k < 0 ? 16 : Math.max(5.5, 16 * Math.sqrt(Math.max(0, 1 - k * k))); };function bowl(g, s) {  g.part("bowl", () => g.field({ x: [7, 41], y: [16, 29] }, (x, y) => {    const r = bowlR(y), u = (x + 0.5 - CX) / r;    if (Math.abs(u) > 1) return null;    const nx = (x + 0.5 - CX) / LIP.rx, ny = (y + 0.5 - LIP.cy) / LIP.ry;    if (nx * nx + ny * ny <= 1) return null;                         // lip top drawn separately    if (s.slime && slimeAt(x, y)) return slimeAt(x, y);    const ey = y - (16 + 6 * Math.sqrt(Math.max(0, 1 - nx * nx)));    // rows below the lip's front edge    if (ey < 1.2) return stone(u - 0.25);                            // the lip's thick edge, lit    if (ey < 2.2) return u < 0.3 ? "n1" : "n0";                       // undercut beneath the lip    const li = litIndex(x, y, CX, 13, 16.5, 16, 4, 0.12 - Math.max(0, y - 25) * 0.07);    const base = ["n1", "n2", "n3", "n4"][Math.max(0, li)];    const dn = { n4: "n3", n3: "n2", n2: "n1", n1: "n0", n0: "n0" };    // a carved moulding round the belly: a groove with a lit bead beneath it    if (y === 24 && Math.abs(u) < 0.97) return dn[base];    if (y === 25 && Math.abs(u) < 0.97) return u < 0.35 ? { n1: "n2", n2: "n3", n3: "n4", n4: "n4" }[base] : base;    return base;  }));}function slimeAt(x, y) {  // green runs down the bowl from the lip (cursed)  const runs = [[14, 24], [19, 27], [31, 22]];  for (const [rx, end] of runs) {    if (x === rx && y <= end) return y === end ? "e3" : y === end - 1 ? "e2" : "e1";    if (x === rx + 1 && y >= end - 1 && y <= end) return "e1";    // the drop swells at the tip  }  return null;}function lip(g, s) {  const lit = s.state === "glow";  g.part("lip", () => g.field({ x: [CX - LIP.rx, CX + LIP.rx], y: [9, 22] }, (x, y) => {    const nx = (x + 0.5 - CX) / LIP.rx, ny = (y + 0.5 - LIP.cy) / LIP.ry;    if (nx * nx + ny * ny > 1) return null;    const hx = (x + 0.5 - CX) / HOLE.rx, hy = (y + 0.5 - HOLE.cy) / HOLE.ry;    if (hx * hx + hy * hy <= 1) return null;                          // the hollow, drawn by basin()    if (lit && hy > 0.2 && hx * hx + hy * hy < 1.25) return "n5";              // the water's light on the near inner rim    if (s.slime && hy < 0 && hx * hx + hy * hy < 1.3 && hash(x, y) > 0.55) return "e1";    const a = -0.7 * nx - 0.3 * ny;    return a > 0.45 ? "n4" : a > 0.0 ? "n3" : a > -0.45 ? "n2" : "n1";  }));}function basin(g, s, t) {  const R = WR[s.state];  g.part("water", () => g.field({ x: [CX - HOLE.rx, CX + HOLE.rx], y: [10, 20] }, (x, y) => {    const hx = (x + 0.5 - CX) / HOLE.rx, hy = (y + 0.5 - HOLE.cy) / HOLE.ry;    if (hx * hx + hy * hy > 1) return null;    const wx = (x + 0.5 - CX) / WATER.rx, wy = (y + 0.5 - WATER.cy) / WATER.ry, wr = Math.sqrt(wx * wx + wy * wy);    if (wr > 1) {                                                      // inner wall above the water; right side lit      if (s.state === "glow") return hx > 0.3 ? "n4" : "n3";      return hx > 0.35 ? "n2" : hx > -0.3 ? "n1" : "n0";    }    if (s.state === "cursed") {      // murk: slow-turning blotches of scum on dark water      const a = Math.atan2(wy, wx) - TAU * t;      const m = Math.sin(2 * a + wr * 5) + (wy > 0.55 ? 0.6 : 0);      if (wy < -0.5) return "e0";      return m > 1.0 ? "e2" : m > 0.1 ? "e1" : "e0";    }    // ripple ring spreading from the centre, one per loop    const p = 0.1 + 0.9 * t;    if (Math.abs(wr - p) < 0.12 && p < 0.95 && (p < 0.6 || wy > 0)) return s.state === "glow" ? "n5" : p < 0.35 ? R[3] : R[2];    if (s.state === "glow") {      if (wy < -0.45) return R[0];      return wr < 0.25 ? R[3] : wr < 0.6 ? R[2] : R[1];    }    if (wy < -0.45) return R[0];                                       // far edge in the lip's shadow    if (wy > 0.6 && Math.abs(wx) < 0.8) return R[2];                   // near meniscus    return R[1];  }));  // glints of the key light, twinkling in turn  if (s.state !== "cursed") g.detached("glints", () => {    const f = Math.round(t * N) % N;    const G = [[17, 14, 0], [29, 16, 4], [21, 17, 8]];    for (const [x, y, ph] of G) {      const k = (f - ph + N) % N;      if (k === 0 || k === 2) g.pixel(x, y, R[3] === "n5" ? "n5" : R[3]);      if (k === 1) { g.pixel(x, y, "n5"); g.pixel(x - 1, y, R[3]); g.pixel(x + 1, y, R[3]); }    }  });}function boss(g, s, t) {  // a small sun boss on the front of the bowl  const cx = CX, cy = 21;  const hot = s.state === "glow" ? (Math.cos(TAU * t) > 0 ? "g3" : "g2") : null;  g.part("boss", () => {    const dead = s.state === "cursed";    for (const [dx, dy] of [[0, -2], [0, 2], [-2, 0], [2, 0]]) g.pixel(cx + dx, cy + dy, dead ? "n1" : hot ? "g1" : "n1");    g.rect({ x: [cx - 1, cx], y: [cy - 1, cy] }, "n3", { fill: "n3" });    g.pixel(cx - 1, cy - 1, "n4"); g.pixel(cx, cy, "n1");    if (hot) { g.pixel(cx - 1, cy - 1, "g3"); g.pixel(cx, cy - 1, hot); g.pixel(cx - 1, cy, hot); g.pixel(cx, cy, "g1"); }    if (dead) { g.pixel(cx, cy - 1, "e1"); g.pixel(cx, cy, "e0"); }  });}// ---- effects ----------------------------------------------------------------------------------------------function motes(g, t) {  g.detached("motes", () => {    for (let i = 0; i < 5; i++) {      const p = (t + i / 5) % 1;      const x = CX + [-8, -3, 2, 6, 9][i] + Math.round(Math.sin(TAU * (p * 0.9 + i * 0.37)) * 1.5);      const y = 15 - Math.round(p * 15);      if (p < 0.3) { g.pixel(x, y, "n5"); g.pixel(x, y + 1, "g3"); g.pixel(x, y + 2, "g2"); }      else if (p < 0.6) { g.pixel(x, y, "g3"); g.pixel(x, y + 1, "g2"); }      else if (p < 0.85) g.pixel(x, y, "g2");      else if (chk(x, y)) g.pixel(x, y, "g1");    }    // a sparkle blinking over the water    const f = Math.round(t * N) % N;    const [sx, sy] = [[20, 11], [27, 9], [24, 12], [30, 11]][Math.floor(f / 3)];    if (f % 3 === 1) { g.pixel(sx, sy, "n5"); for (const [dx, dy] of [[0, -1], [0, 1], [-1, 0], [1, 0]]) g.pixel(sx + dx, sy + dy, "g3"); }    else if (f % 3 === 0) g.pixel(sx, sy, "g3");  });}function bubbles(g, t) {  const spots = [[-6, 0, 0.0], [3, 1, 0.33], [7, -1, 0.66], [-1, -1, 0.5]];  g.detached("bubbles", () => {    for (const [dx, dy, ph] of spots) {      const p = (t + ph) % 1, x = CX + dx, y = Math.round(WATER.cy) + dy;      if (p < 0.2) g.pixel(x, y, "e2");      else if (p < 0.45) { g.rect({ x: [x - 1, x], y: [y - 1, y] }, "e2", { fill: "e2" }); g.pixel(x - 1, y - 1, "e3"); }      else if (p < 0.6) { g.ellipse({ x: [x - 2, x + 1], y: [y - 2, y] }, "e1", { fill: "e2" }); g.pixel(x - 1, y - 2, "e3"); }      else if (p < 0.72) for (const [ox, oy] of [[-2, 0], [2, 0], [0, -2], [-1, 1]]) g.pixel(x + ox, y + oy, "e3");    }    // a sickly fume rising off the water    for (let i = 0; i < 3; i++) {      const p = (t + i / 3) % 1;      const x = CX + [-4, 1, 6][i] + Math.round(Math.sin(TAU * (p + i * 0.4)) * 2), y = 12 - Math.round(p * 11);      if (p < 0.4) g.pixel(x, y, "e2"); else if (p < 0.75 && chk(x, y)) g.pixel(x, y, "e1");    }  });}// ---- sprite -----------------------------------------------------------------------------------------------const Font = sprite(pal, (g, t, state) => {  const s = { state, slime: state === "cursed" };  contactShadow(g, CX, GROUND, 15, 2);  g.outlined("ink", () => { plinth(g); pedestal(g); bowl(g, s); lip(g, s); });  basin(g, s, t);  boss(g, s, t);  if (state === "glow") motes(g, t);  if (state === "cursed") bubbles(g, t);});submit(Font, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N / 10, loop: true },  anchor: { x: CX, y: GROUND },  variants: [    { name: "idle", args: ["idle"] },    { name: "glow", args: ["glow"] },    { name: "cursed", args: ["cursed"] },  ],});

掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。

エージェントに、こうしたプログラムを書かせる。

Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。