Tessarune
Witch's Hut · mortar_table.js

乳鉢の作業台

48×48 · JavaScript 334 行

48×48 の床置きの小物で、3 枚の板を脚を開いた台に載せた粗い作業台に、重い石の乳鉢と白っぽい木の乳棒、真鍮の皿に立てたろうそく、毒キノコが 2 本、紋字を書いて丸まった羊皮紙の切れ端、散らばったセージの葉とラベンダーの小枝が載っています。どれも右下へ短い影を板の上に落とします。idle(16 コマ)はろうそくが揺れ、乳鉢からかすかな湯気が上がります。grind(12 コマ)は乳棒がひとりでに鉢の中を回り、緑の粉が吹き出し、紋字が脈打ちます。brewed(8 コマの一回再生)は中身が光り、紫の煙が弾けて火花になります。

乳鉢の作業台 — 描画結果6×
このプログラムで描画(変種「brewed」) · 48×48 · 8 コマ
Witch's Hut動くスプライト素材: Witch's Hut(itch.io)Vol.5・ハロウィンの小物 12 点・42 アニメ

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

mortar_table.js · 1–9
// Witch's mortar table — 48x48 floor prop, top-down 3/4, key light top-left.// A small rough worktable (three planks, a front apron, splayed legs on a stretcher) holding a heavy stone mortar// with a pale wooden pestle, a candle stub on a brass dish, a pair of toadstools, a curled parchment scrap with a// rune, and a few loose sage leaves and a lavender sprig. Objects cast short shadows down-right onto the planks.// Variants://   idle    16f loop: candle flickers, a faint steam curl rises from the mortar//   grind   12f loop: the pestle grinds round the bowl by itself, green dust puffs from the mouth, the rune pulses//   brewed  8f once : the paste flares, a violet puff bursts from the mortar, swells, breaks into sparks and settles;//                     the last frame is idle frame 0

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

mortar_table.js · 324–334
submit(MortarTable, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N_IDLE / 10, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "grind", args: ["grind"], animate: { fps: 10, duration: N_GRIND / 10, loop: true } },    { name: "brewed", args: ["brewed"], animate: { fps: 10, duration: N_BREW / 10, loop: false } },  ],});
プログラム全体を表示 (334 行)
mortar_table.js · 334 行
// Witch's mortar table — 48x48 floor prop, top-down 3/4, key light top-left.// A small rough worktable (three planks, a front apron, splayed legs on a stretcher) holding a heavy stone mortar// with a pale wooden pestle, a candle stub on a brass dish, a pair of toadstools, a curled parchment scrap with a// rune, and a few loose sage leaves and a lavender sprig. Objects cast short shadows down-right onto the planks.// Variants://   idle    16f loop: candle flickers, a faint steam curl rises from the mortar//   grind   12f loop: the pestle grinds round the bowl by itself, green dust puffs from the mouth, the rune pulses//   brewed  8f once : the paste flares, a violet puff bursts from the mortar, swells, breaks into sparks and settles;//                     the last frame is idle frame 0const W = 48, H = 48, CX = 24, GROUND = 45;const MX = 15, RIM_Y = 18;                          // mortar axis and rim centre rowconst RIM = { rx: 6.5, ry: 2.6 }, MOUTH = { rx: 4.6, ry: 1.6 };const CANDLE_X = 36;const N_IDLE = 16, N_GRIND = 12, N_BREW = 8;const grainAt = (x, y) => hash(x, y * 3.1) > 0.82;// ---- table ------------------------------------------------------------------------------------------------function table(g) {  g.part("tabletop", () => g.field({ x: [5, 42], y: [22, 31] }, (x, y) => {    if ((x === 5 || x === 42) && y === 22) return null;                     // knocked-off corners    if (x === 42 && y === 29) return null;                                  // a chip out of the edge    if (y === 25 || y === 28) return x % 9 === 3 ? "w2" : "w1";              // plank seams    const u = (x - 5) / 37;    if (y === 22) return u < 0.4 ? "w4" : u < 0.8 ? "w3" : "w2";            // lit back edge    if (x === 5) return "w4";    if (x === 42) return "w2";    if (x === 14 && y === 30) return "w1";                                  // knot    if ((x === 13 || x === 15) && y === 30) return "w2";    // grain dashes, fixed per plank    const plank = y < 25 ? 0 : y < 28 ? 1 : 2;    const gx = (x + plank * 7) % 13;    if (y === [23, 26, 29][plank] && gx > 2 && gx < 7) return "w2";    if (y === [24, 27, 30][plank] && gx > 8 && gx < 11) return u < 0.5 ? "w2" : "w1";    return u < 0.7 ? "w3" : "w2";  }));  g.part("apron", () => g.field({ x: [5, 42], y: [32, 35] }, (x, y) => {    if (y === 32) return x < 14 ? "w3" : "w2";    if (y === 35) return x < 7 || x > 40 ? null : "w0";    return x === 20 && y === 33 ? "w0" : "w1";  }));  g.part("legs", () => {    // back legs (farther: higher feet, darker), then the stretcher, then the front legs    for (const lx of [11, 35]) { g.rect({ x: [lx, lx + 1], y: [36, 41] }, "w0", { fill: "w0" }); g.pixel(lx, 36, "w1"); }    g.rect({ x: [9, 38], y: [40, 41] }, "w1", { fill: "w1" });    g.line([9, 40], [24, 40], "w2");    for (const lx of [6, 39]) {      g.rect({ x: [lx, lx + 2], y: [36, 44] }, "w2", { fill: "w2" });      g.line([lx, 36], [lx, 44], "w3");      g.line([lx + 2, 36], [lx + 2, 44], "w1");      g.pixel(lx + 1, 38, "w1");                                             // peg through the joint    }  });}// cast shadows on the planks (down-right of each object), darker wood onlyfunction tableShadows(g) {  g.part("castShadows", () => g.field({ x: [6, 41], y: [23, 31] }, (x, y) => {    const m = ((x + 0.5 - (MX + 4)) / 7) ** 2 + ((y + 0.5 - 29.2) / 1.6) ** 2 < 1;    const ts = ((x + 0.5 - 41) / 2.5) ** 2 + ((y + 0.5 - 31) / 0.9) ** 2 < 1;    const c = ((x + 0.5 - (CANDLE_X + 3)) / 4.5) ** 2 + ((y + 0.5 - 25.5) / 1.2) ** 2 < 1;    const p = x >= 24 && x <= 33 && y === 31;    if (!(m || c || p || ts)) return null;    if (y === 25 || y === 28) return "w0";    return "w1";  }));}// ---- mortar & pestle -------------------------------------------------------------------------------------const halfW = (y) => {                                                      // bowl silhouette half-width per row  if (y < RIM_Y) return 0;  if (y >= 27) return y === 28 ? 4 : 3.6;                                   // foot  const k = (y - RIM_Y) / 9;  return 6.5 * Math.sqrt(Math.max(0, 1 - (k * 0.8) ** 2));};function mortar(g, paste) {  g.part("mortarBody", () => g.field({ x: [MX - 8, MX + 8], y: [RIM_Y, 28] }, (x, y) => {    const dx = x + 0.5 - (MX + 0.5), hw = halfW(y);    if (Math.abs(dx) > hw) return null;    if (y >= 27) return dx < -2 ? "n2" : dx < 2 ? "n1" : "n0";              // foot, in the bowl's shadow    let i = litIndex(x, y, MX + 0.5, RIM_Y + 1, 6.6, 8.5, 4, 0.05);    if (i < 0) i = 0;    if ((y === 21 && (x === MX - 3 || x === MX + 3)) || (y === 24 && x === MX + 1)) i = Math.max(0, i - 1);   // chisel pocks    if (y === RIM_Y + 2 && dx < -3 && dx > -5.5) return "n5";               // shoulder highlight    return ["n1", "n2", "n3", "n4"][i];  }));  g.part("mortarRim", () => g.field({ x: [MX - 7, MX + 7], y: [RIM_Y - 3, RIM_Y + 3] }, (x, y) => {    const ox = (x + 0.5 - (MX + 0.5)) / RIM.rx, oy = (y + 0.5 - (RIM_Y + 0.5)) / RIM.ry;    if (ox * ox + oy * oy > 1) return null;    const ix = (x + 0.5 - (MX + 0.5)) / MOUTH.rx, iy = (y + 0.5 - (RIM_Y + 0.5)) / MOUTH.ry;    if (ix * ix + iy * iy <= 1) {      // the hollow: far wall in shadow, paste pooled in the near half      if (iy < -0.2) return ix > 0.3 ? "n1" : "n0";      return paste(x, y, ix);    }    const a = -0.75 * ox - 0.45 * oy;    if (a > 0.55 && oy < 0) return "n5";                                   // one hard glint on the lit lip    return a > 0.1 ? "n4" : a > -0.45 ? "n3" : "n2";  }));}// rim front edge of the mouth: pestle pixels below it are hidden inside the bowlconst mouthFront = (x) => {  const ix = (x + 0.5 - (MX + 0.5)) / MOUTH.rx;  return Math.abs(ix) >= 1 ? RIM_Y - 1 : RIM_Y + 0.5 + MOUTH.ry * Math.sqrt(1 - ix * ix) - 0.5;};function pestle(g, a, lift) {  // the pestle leans out of the bowl; its top traces an ellipse as it grinds (a = lean direction)  const B = [MX + 0.5 + Math.cos(a) * 0.8, RIM_Y + 4 - lift];  const T = [MX + 0.5 + Math.cos(a) * 7, RIM_Y - 9 + Math.sin(a) * 2.5 - lift];  const len = Math.hypot(T[0] - B[0], T[1] - B[1]);  const nx = -(T[1] - B[1]) / len, ny = (T[0] - B[0]) / len;               // across-direction  const lit = nx * -0.7 + ny * -0.7 > 0 ? 1 : -1;                           // which side faces the top-left light  g.part("pestle", () => g.field({ x: [MX - 9, MX + 10], y: [RIM_Y - 13, RIM_Y + 3] }, (x, y) => {    if (y > mouthFront(x)) return null;    const px = x + 0.5 - B[0], py = y + 0.5 - B[1];    const k = (px * (T[0] - B[0]) + py * (T[1] - B[1])) / (len * len);    const v = (px * nx + py * ny) * lit;                                     // + = lit side    const kx = B[0] + (T[0] - B[0]) * 0.94, ky = B[1] + (T[1] - B[1]) * 0.94;    const knob = Math.hypot(x + 0.5 - kx, y + 0.5 - ky) <= 1.6;              // a rounded knob at the handle end    if (!knob && (k < 0 || k > 0.9 || Math.abs(v) > 1.05)) return null;    return v > 0.4 ? "w4" : v > -0.5 ? "w3" : "w2";  }));}// ---- candle, toadstools, parchment, loose herbs -----------------------------------------------------------function candle(g) {  g.part("dish", () => {    g.ellipse({ x: [CANDLE_X - 4, CANDLE_X + 4], y: [21, 25] }, "g1", { fill: "g1" });    g.line([CANDLE_X - 3, 21], [CANDLE_X + 1, 21], "g3");    g.line([CANDLE_X - 4, 22], [CANDLE_X - 4, 23], "g2");    g.line([CANDLE_X - 2, 25], [CANDLE_X + 3, 25], "g0");    g.pixel(CANDLE_X + 4, 23, "g0");    g.line([CANDLE_X + 5, 22], [CANDLE_X + 6, 21], "g1"); g.pixel(CANDLE_X + 6, 20, "g2");   // finger loop  });  g.part("candle", () => {    g.rect({ x: [CANDLE_X - 2, CANDLE_X + 1], y: [15, 23] }, "n4", { fill: "n4" });    g.line([CANDLE_X - 2, 16], [CANDLE_X - 2, 23], "n5");    g.line([CANDLE_X + 1, 16], [CANDLE_X + 1, 23], "n3");    g.line([CANDLE_X - 2, 15], [CANDLE_X + 1, 15], "n5");                  // melted top    g.pixel(CANDLE_X + 1, 15, "n4");    g.pixel(CANDLE_X - 1, 16, "n3"); g.pixel(CANDLE_X, 16, "n3");           // the well round the wick    g.line([CANDLE_X + 2, 16], [CANDLE_X + 2, 19], "n4");                   // a long drip    g.pixel(CANDLE_X + 2, 20, "n3");    g.pixel(CANDLE_X - 3, 22, "n5"); g.pixel(CANDLE_X - 3, 23, "n4");       // wax pooled on the dish  });}function candleFlame(g, t) {  g.part("wick", () => g.pixel(CANDLE_X - 1, 14, "n0"));  g.part("flame", () => flame(g, CANDLE_X - 1, 13, 3, 6, t, 0.2, ["fl0", "fl1", "fl2", "fl3"]));}function toadstools(g) {  g.part("toadstools", () => {    // big one, front right    g.rect({ x: [39, 40], y: [28, 31] }, "n4", { fill: "n4" });    g.pixel(39, 29, "n5"); g.pixel(40, 31, "n3");    g.ellipse({ x: [36, 42], y: [25, 28] }, "r1", { fill: "r1" });    g.line([37, 25], [39, 25], "r2"); g.line([37, 26], [38, 26], "r2");    g.pixel(37, 25, "r3");    g.line([37, 28], [41, 28], "r0");                                       // gills in shadow under the cap    g.pixel(38, 26, "n5"); g.pixel(40, 25, "n5"); g.pixel(41, 27, "n4");    // warts    // small one, leaning in from the left    g.line([35, 29], [35, 31], "n4"); g.pixel(34, 31, "n5");    g.rect({ x: [33, 36], y: [28, 29] }, "r1", { fill: "r1" });    g.pixel(33, 28, "r3"); g.pixel(34, 28, "r2"); g.pixel(36, 29, "r0"); g.pixel(35, 28, "n5");  });}// parchment: a slightly skewed sheet with a curled back corner; the rune is drawn by runeKeyconst SHEET = [[21, 27], [29, 25], [31, 30], [23, 32]];function parchment(g) {  g.part("parchment", () => g.field({ x: [21, 31], y: [25, 32] }, (x, y) => {    const P = [x + 0.5, y + 0.5];    let inside = true;    for (let i = 0; i < 4; i++) {      const [ax, ay] = SHEET[i], [bx, by] = SHEET[(i + 1) % 4];      if ((bx - ax) * (P[1] - ay) - (by - ay) * (P[0] - ax) < 0) { inside = false; break; }    }    if (!inside) return null;    if (x >= 28 && y <= 26) return "n3";                                    // curled corner, its underside    if (x === 27 && y === 26) return "n4";    const u = (x - 21) / 10 + (y - 25) / 7;    return u > 1.2 ? "n4" : "n5";  }));}const RUNE = [[24, 27], [26, 27], [28, 27], [25, 28], [26, 28], [27, 28], [26, 29], [25, 30], [26, 30]];function rune(g, key) { g.part("rune", () => { for (const [x, y] of RUNE) g.pixel(x, y, key); }); }function looseHerbs(g) {  g.part("looseHerbs", () => {    // a sage sprig (leaves either side of a stem) and a lavender sprig on the front plank, with their shadows    g.line([7, 30], [11, 28], "w1");    g.pixel(8, 30, "e3"); g.pixel(9, 30, "e2"); g.pixel(9, 29, "e3"); g.pixel(10, 29, "e2"); g.pixel(11, 28, "e2");    g.pixel(10, 28, "e3"); g.pixel(8, 29, "e2"); g.pixel(11, 29, "e1"); g.pixel(12, 28, "e1"); g.pixel(10, 30, "e1");    g.line([6, 26], [9, 24], "e1");    g.pixel(7, 25, "v2"); g.pixel(8, 24, "v3"); g.pixel(9, 23, "v2"); g.pixel(8, 25, "v1");    g.pixel(12, 31, "e2"); g.pixel(19, 30, "e2"); g.pixel(21, 31, "e1");     // crushed bits  });}// ---- effects ----------------------------------------------------------------------------------------------function steam(g, t) {  g.detached("steam", () => {    for (let i = 0; i < 2; i++) {      const p = (t + i / 2) % 1;      const x = MX - 1 + i * 2 + Math.round(Math.sin(TAU * (p * 0.9 + i * 0.35)) * 1.5);      const y = RIM_Y - 2 - Math.round(p * 11);      if (p < 0.4) { g.pixel(x, y, "n4"); g.pixel(x + 1, y - 1, "n3"); }      else if (p < 0.75) { if (chk(x, y)) g.pixel(x, y, "n3"); g.pixel(x + 1, y - 1, "n3"); }      else if (chk(x, Math.floor(t * 16))) g.pixel(x, y, "n2");    }  });}function dustPuffs(g, t, a) {  // a puff is kicked out of the mouth on the side the pestle is sweeping past, twice a revolution  g.detached("dust", () => {    for (let i = 0; i < 4; i++) {      const p = (t * 2 + i / 4) % 1;      const side = i % 2 ? 1 : -1;      const x = Math.round(MX + 0.5 + side * (3 + p * 6));      const y = Math.round(RIM_Y - 1 - p * 8 + p * p * 5);      if (p < 0.3) {        g.pixel(x, y, "e3"); g.pixel(x + side, y, "e2"); g.pixel(x, y - 1, "e2"); g.pixel(x + side, y - 1, "e3");      } else if (p < 0.6) {        g.pixel(x, y, "e2"); g.pixel(x + side, y - 1, "e2"); if (chk(x, y)) g.pixel(x - side, y - 1, "e1");      } else if (p < 0.85) { if (chk(x, y + i)) g.pixel(x, y, "e1"); g.pixel(x + side, y - 1, "e1"); }    }  });}function runeGlow(g, t, k) {  // at the peak of each pulse two motes lift off the rune  g.detached("runeMotes", () => {    const p = (t * 2) % 1;    if (p > 0.45) return;    const y = 26 - Math.round(p * 10);    g.pixel(26, y, p < 0.2 ? "e3" : "e2");    g.pixel(28, y + 2, p < 0.25 ? "e2" : "e1");  });}// violet puff for the brewed one-shot: frame f in 1..6function puff(g, f) {  g.detached("puff", () => {    if (f === 1) {                                                           // a bright jet out of the mouth      for (let y = RIM_Y - 7; y <= RIM_Y; y++) {        const w = y > RIM_Y - 3 ? 1 : 0;        for (let x = MX - w; x <= MX + 1 + w; x++) g.pixel(x, y, y < RIM_Y - 5 ? "v2" : x === MX || x === MX + 1 ? "v3" : "v2");      }      for (const [x, y, k] of [[MX - 3, RIM_Y - 2, "v3"], [MX + 4, RIM_Y - 3, "v3"], [MX - 2, RIM_Y - 5, "v2"],        [MX + 3, RIM_Y - 6, "v2"], [MX - 4, RIM_Y, "v1"], [MX + 5, RIM_Y - 1, "v1"], [MX, RIM_Y - 9, "v1"]]) g.pixel(x, y, k);      return;    }    const R = [0, 0, 3.5, 5, 6, 6.5, 6.5][f], cy = [0, 0, RIM_Y - 5, RIM_Y - 7, RIM_Y - 8.5, RIM_Y - 9.5, RIM_Y - 10][f];    const fill = [0, 0, 1, 1, 0.97, 0.62, 0][f];                              // cloud density (thins as it breaks up)    // billows, back to front: [dx, dy, r] in units of R    const BIL = [[0, -0.75, 0.62], [-0.8, -0.05, 0.55], [0.85, 0.05, 0.55], [0.05, 0.35, 0.62]];    if (fill > 0) g.field({ x: [MX - 10, MX + 11], y: [0, RIM_Y] }, (x, y) => {      let best = -1, dmin = 9;      for (const [bx, by, br] of BIL) {        const cx = MX + 0.5 + bx * R * (1 + (f - 2) * 0.08), cyy = cy + by * R, r = br * R;        const i = litIndex(x, y, cx, cyy, r, r * 0.9, 4, 0.15);        if (i < 0) continue;        if (best < 0 || i > best) best = i;        dmin = Math.min(dmin, Math.hypot(x + 0.5 - cx, (y + 0.5 - cyy) / 0.9) / r);      }      if (best < 0) return null;      const key = ["v1", "v2", "v2", "v3"][best];      if (fill < 1 && (dmin > fill || (dmin > fill - 0.3 && !chk(x, y)))) return null;   // billows shrink to wisps      return key;    });    // sparks thrown out from the cloud    for (let s = 0; s < 7; s++) {      const a = (s / 7) * TAU + 0.4;      const d = R + 1 + (f - 2) * 1.6;      const x = Math.round(MX + 0.5 + Math.cos(a) * d * 1.1);      const y = Math.round(cy + Math.sin(a) * d * 0.8 + (f >= 5 ? (f - 4) * 2 : 0));      if (y < 0 || y > GROUND) continue;      g.pixel(x, y, f >= 5 ? (s % 2 ? "v2" : "v1") : s % 2 ? "v3" : "v2");      if (f <= 4) g.pixel(x, y + 1, "v1");    }  });}// ---- assembly ---------------------------------------------------------------------------------------------const MortarTable = sprite(pal, (g, t, mode) => {  t = ((t % 1) + 1) % 1;  let ti = t, a = -0.6, lift = 0, runeK = "w1", glowK = 0, f = -1;  if (mode === "grind") {    a = -0.6 + TAU * t;                                                     // one revolution per loop    glowK = 0.5 + 0.5 * Math.cos(TAU * 2 * t);    runeK = glowK > 0.8 ? "e3" : glowK > 0.45 ? "e2" : glowK > 0.15 ? "e1" : "w1";  } else if (mode === "brewed") {    f = Math.min(N_BREW - 1, Math.round(t * N_BREW));    ti = (((f - (N_BREW - 1)) / N_IDLE) % 1 + 1) % 1;                        // hands back to idle frame 0    lift = f === 1 ? 2 : f === 2 ? 1 : 0;                                    // the pestle jumps in the blast  }  // paste in the bowl: green, flaring violet in the brewed blast  const flare = f >= 0 && f <= 3;  const paste = (x, y, ix) => {    if (flare) return f === 1 ? "v3" : ix < 0 ? "v2" : "v1";    if (mode === "grind" && hash(x, Math.floor(t * N_GRIND)) > 0.7) return "e3";    return ix < -0.2 ? "e2" : "e1";  };  contactShadow(g, CX, GROUND, 20, 2);  g.outlined("ink", () => table(g));  tableShadows(g);  g.outlined("ink", () => {    looseHerbs(g);    parchment(g);    candle(g);    toadstools(g);    mortar(g, paste);    pestle(g, a, lift);  });  rune(g, runeK);  candleFlame(g, ti);  if (mode === "grind") { dustPuffs(g, t, a); runeGlow(g, t, glowK); }  if (mode === "idle" || (mode === "brewed" && (f === 0 || f >= 6))) steam(g, ti);  if (mode === "brewed" && f >= 1 && f <= 6) puff(g, f);});submit(MortarTable, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N_IDLE / 10, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "grind", args: ["grind"], animate: { fps: 10, duration: N_GRIND / 10, loop: true } },    { name: "brewed", args: ["brewed"], animate: { fps: 10, duration: N_BREW / 10, loop: false } },  ],});

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

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

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