Tessarune
Tavern & Market · barrel.js

エールの樽

32×32 · JavaScript 259 行

32×32 の小物で、見下ろし 3/4 視点です。台の上に寝かせたエールの樽を 25 度回し、真鍮の注ぎ口が付いた右の鏡板が少しこちらを向いています。樽は小さな立体の模型から画素ごとに光線を進めて描いています。ふくらんだ 16 枚の側板、4 枚板のくぼんだ鏡板、4 本の鉄のたが、4 本脚に 2 本の横木の台です。idle(待機、12 コマのループ)は注ぎ口に雫がふくらみ、落ちて床で跳ねます。pour(8 コマの一回再生)は赤いつまみのレバーが倒れ、琥珀色の流れが床まで弧を描き、レバーが戻ります。

エールの樽 — 描画結果6×
このプログラムで描画(変種「pour」) · 32×32 · 8 コマ
Tavern & Market動くスプライト素材: Tavern & Market(itch.io)Vol.3・酒場と市場の小物 12 点・32 アニメ

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

barrel.js · 1–11
// Ale barrel on a stillage — 32x32, top-down 3/4, key light top-left.// The cask lies on its side, turned 25 degrees so its right head faces the viewer a little and carries the tap.// It is ray-marched per pixel from a small solid model (camera elevation: ellipse ratio 0.35, as in Vol.1/2):// a bulged cask of 16 staves, a recessed head of four boards inside the chime, four iron hoops, and a stillage of two// rails on four legs with a low stretcher. The brass tap is drawn on top in side profile: a flange on the head, a// short pipe, a valve with a lever and a red-lacquered knob (upright = shut, tipped forward = open), a spout pointing down.// Ale uses the brass ramp (g1 body, g2 core, g3 glint, g0 shade); the splash has one n5 fleck of foam.// Variants://   idle   loop 12f     — a drop swells at the spout, lets go, falls and splashes on the floor//   pour   one-shot 8f  — the lever tips, an amber stream arcs out and down to the floor, the lever returns;//                         ends on idle f0

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

barrel.js · 252–259
submit(Barrel, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  anchor: { x: 16, y: GROUND },  variants: [    { name: "idle", args: ["idle"], animate: { fps: 10, duration: N_IDLE / 10, loop: true } },    { name: "pour", args: ["pour"], animate: { fps: 10, duration: N_POUR / 10, loop: false } },  ],});
プログラム全体を表示 (259 行)
barrel.js · 259 行
// Ale barrel on a stillage — 32x32, top-down 3/4, key light top-left.// The cask lies on its side, turned 25 degrees so its right head faces the viewer a little and carries the tap.// It is ray-marched per pixel from a small solid model (camera elevation: ellipse ratio 0.35, as in Vol.1/2):// a bulged cask of 16 staves, a recessed head of four boards inside the chime, four iron hoops, and a stillage of two// rails on four legs with a low stretcher. The brass tap is drawn on top in side profile: a flange on the head, a// short pipe, a valve with a lever and a red-lacquered knob (upright = shut, tipped forward = open), a spout pointing down.// Ale uses the brass ramp (g1 body, g2 core, g3 glint, g0 shade); the splash has one n5 fleck of foam.// Variants://   idle   loop 12f     — a drop swells at the spout, lets go, falls and splashes on the floor//   pour   one-shot 8f  — the lever tips, an amber stream arcs out and down to the floor, the lever returns;//                         ends on idle f0const W = 32, H = 32, CX = 15, GROUND = 29, REFY = 25.6;const SE = 0.35, CE = Math.sqrt(1 - SE * SE);const LIGHT = (() => { const v = [-0.55, 0.5, 0.68], m = Math.hypot(...v); return v.map((c) => c / m); })();const toView = (n) => [n[0], n[1] * CE - n[2] * SE, n[1] * SE + n[2] * CE];const dotL = (n) => { const m = Math.hypot(...n) || 1, v = toView(n.map((c) => c / m)); return v[0] * LIGHT[0] + v[1] * LIGHT[1] + v[2] * LIGHT[2]; };const WOOD = ["w0", "w1", "w2", "w3", "w4"], IRON = ["n0", "n1", "n2", "n3", "n4"];const N_IDLE = 12, N_POUR = 8;// ---- model (barrel frame: a = along the axis toward the tapped head, c = across toward the camera, h = up) -------const YAW = (25 * Math.PI) / 180, CA = Math.cos(YAW), SA = Math.sin(YAW);const toLocal = (x, z) => [x * CA + z * SA, -x * SA + z * CA];const toWorld = (a, c) => [a * CA - c * SA, a * SA + c * CA];const HC = 14.2, HALF = 9.4, RB = 8.6, RE = 7.3, HEADA = 8.7, CHW = 0.9, HOOPT = 0.35, RI = RE - CHW;const HOOPS = [[6.9, 8.6], [2.5, 4.1]];const STAVES = 16;const R = (a) => RE + (RB - RE) * (1 - (a / HALF) ** 2);const dR = (a) => (-(RB - RE) * 2 * a) / (HALF * HALF);// stillageconst RAIL_C = 5.0, RAIL_W = 0.85, RAIL_H = [5.0, 7.3], RAIL_A = 9.9;const LEG_A = 8.3, LEG_W = 0.85;const STR_H = [1.6, 2.7];function box(a, c, h, a0, a1, c0, c1, h0, h1) {  if (a < a0 || a > a1 || c < c0 || c > c1 || h < h0 || h > h1) return null;  // nearest face -> normal in barrel frame  const faces = [[a - a0, [-1, 0, 0]], [a1 - a, [1, 0, 0]], [c - c0, [0, 0, -1]], [c1 - c, [0, 0, 1]], [h1 - h, [0, 1, 0]], [h - h0, [0, -1, 0]]];  let best = faces[0];  for (const f of faces) if (f[0] < best[0]) best = f;  return best[1];}function solid(x, h, z) {  const [a, c] = toLocal(x, z), aa = Math.abs(a);  // barrel  if (aa <= HALF) {    const rho = Math.hypot(c, h - HC), r = R(a);    const hoop = HOOPS.findIndex(([p, q]) => aa >= p && aa <= q);    const ro = hoop >= 0 ? r + HOOPT : r;    if (rho <= ro && !(aa > HEADA && rho < RI)) {      let s = hoop >= 0 ? "hoop" : "stave", best = ro - rho;      if (rho >= RI && HALF - aa < best) { s = "chime"; best = HALF - aa; }      if (aa <= HEADA && rho < RI && HEADA - aa < best) { s = "head"; best = HEADA - aa; }      if (aa > HEADA && rho - RI < best) { s = "chimein"; best = rho - RI; }      const nl = s === "stave" || s === "hoop" ? [-dR(a), (h - HC) / rho, c / rho]        : s === "chimein" ? [0, -(h - HC) / rho, -c / rho] : [Math.sign(a), 0, 0];      const [nx, nz] = toWorld(nl[0], nl[2]);      return { o: "barrel", s, a, c, h, rho, hoop, n: [nx, nl[1], nz] };    }  }  // stillage: rails, legs, front stretcher  const parts = [];  for (const sc of [1, -1]) {    parts.push(["rail", sc, box(a, c, h, -RAIL_A, RAIL_A, sc * RAIL_C - RAIL_W, sc * RAIL_C + RAIL_W, RAIL_H[0], RAIL_H[1])]);    for (const sa of [1, -1]) parts.push(["leg", sc, box(a, c, h, sa * LEG_A - LEG_W, sa * LEG_A + LEG_W, sc * RAIL_C - LEG_W, sc * RAIL_C + LEG_W, 0, RAIL_H[0])]);  }  parts.push(["stretcher", 1, box(a, c, h, -LEG_A, LEG_A, RAIL_C - 0.6, RAIL_C + 0.4, STR_H[0], STR_H[1])]);  for (const [kind, side, nl] of parts) {    if (!nl) continue;    const [nx, nz] = toWorld(nl[0], nl[2]);    return { o: "stand", s: kind, side, a, c, h, face: nl, n: [nx, nl[1], nz] };  }  return null;}function cast() {  const buf = new Array(W * H).fill(null);  for (let py = 0; py < H; py++) for (let px = 0; px < W; px++) {    const X = px + 0.5 - CX, Y = py + 0.5 - REFY;    for (let d = 22; d >= -22; d -= 0.12) {      const h = -Y * CE + d * SE, z = Y * SE + d * CE;      const hit = solid(X, h, z);      if (hit) { hit.d = dotL(hit.n); buf[py * W + px] = hit; break; }    }  }  return tone(buf);}const lvWood = (d) => (d > 0.9 ? 4 : d > 0.56 ? 3 : d > 0.2 ? 2 : d > -0.15 ? 1 : 0);const staveOf = (b) => (b && b.o === "barrel" && (b.s === "stave" || b.s === "hoop") ? Math.floor(((Math.atan2(b.h - HC, b.c) + Math.PI) / TAU) * STAVES + 0.5) % STAVES : -1);const boardOf = (b) => (b && b.o === "barrel" && b.s === "head" ? Math.min(3, Math.max(0, Math.floor((b.h - HC + RI) / (RI / 2)))) : -1);function tone(buf) {  const out = new Array(W * H).fill(null);  for (let i = 0; i < W * H; i++) {    const b = buf[i]; if (!b) continue;    const px = i % W, py = (i - px) / W;    const up = py > 0 ? buf[i - W] : null, dn = py < H - 1 ? buf[i + W] : null;    const lf = px > 0 ? buf[i - 1] : null, rt = px < W - 1 ? buf[i + 1] : null;    if (b.o === "stand") {      let lv = b.face[1] > 0 ? 3 : b.face[2] > 0 ? 2 : b.face[0] > 0 ? 1 : 1;   // top / front / end      if (b.side < 0) lv = Math.max(0, lv - 1);                               // back rail & legs in shadow      if (b.s === "leg" && b.face[2] > 0 && (!lf || lf.o !== "stand")) lv = Math.min(3, lv + 1);   // lit left edge      if (up && up.o === "barrel" && b.face[1] <= 0) lv = Math.max(0, Math.min(lv, 1));             // under the cask      out[i] = { key: WOOD[lv], part: "stillage" };      continue;    }    if (b.s === "hoop") {      let lv = b.d > 0.8 ? 3 : b.d > 0.4 ? 2 : b.d > 0.0 ? 1 : 0;      const same = (o) => o && o.o === "barrel" && o.s === "hoop" && o.hoop === b.hoop && Math.sign(o.a) === Math.sign(b.a);      if (!same(lf) && lv > 0) lv = Math.min(4, lv + 1);                       // the hoop's left edge catches the light      else if (!same(rt) && lv > 0) lv -= 1;      const rivet = Math.abs(Math.abs(b.a) - (HOOPS[b.hoop][0] + HOOPS[b.hoop][1]) / 2) < 0.5 && Math.abs(((Math.atan2(b.h - HC, b.c) / TAU) * 5 + 20.35) % 1 - 0.5) < 0.06;      out[i] = { key: rivet ? "n4" : IRON[lv], part: "hoops" };      continue;    }    if (b.s === "stave") {      let lv = lvWood(b.d);      const k = staveOf(b), ku = staveOf(up);      // stave seams: a darker line where the next stave begins (above, on screen)      // (only on the side facing the camera: on the foreshortened top the seams would crowd into stripes)      if (ku >= 0 && ku !== k && Math.abs(b.h - HC) < 5.2) lv = Math.max(0, Math.min(lv, 3) - 1);      out[i] = { key: WOOD[lv], part: "staves" };      continue;    }    if (b.s === "chime" || b.s === "chimein") {      // end grain of the staves: pale, lit on the upper side      const lv = b.h - HC > 3 ? 4 : b.h - HC > -3 ? 3 : 2;      out[i] = { key: WOOD[lv], part: "staves" };      continue;    }    // head: faces right-front, away from the key light. Only ~4px wide on screen, so: flat boards, the chime's    // shadow down its left rim, one seam across the middle    let lv = b.h - HC > 3.5 ? 3 : 2;    const rim = (o) => o && o.o === "barrel" && o.s !== "head";    if (rim(lf)) lv = 1;                                                   // the chime shades the head's left rim    else if (boardOf(up) >= 0 && boardOf(up) !== boardOf(b) && boardOf(b) === 2) lv = 1;   // one board seam    out[i] = { key: WOOD[lv], part: "head" };  }  return out;}// ---- compositing ---------------------------------------------------------------------------------------------------function drawBody(g, buf) {  const parts = [];  for (const b of buf) if (b && !parts.includes(b.part)) parts.push(b.part);  g.outlined("ink", () => {    for (const p of parts) g.part(p, () => g.field({ x: [0, W - 1], y: [0, H - 1] }, (x, y) => {      const b = buf[y * W + x];      return b && b.part === p ? b.key : null;    }));  });}// tap: attached to the head a little below its centreconst HEAD_PT = (() => { const [x, z] = toWorld(HEADA, 0); return [x, HC - 2.2, z]; })();const TX = Math.round(CX + HEAD_PT[0]) - 1, TY = Math.round(REFY - HEAD_PT[1] * CE + HEAD_PT[2] * SE);const SX = TX + 3;                                       // left column of the 2px spoutconst SPOUT_Y = TY + 3;                                  // nozzle rowconst FLOOR_Y = Math.round(REFY + (HEAD_PT[2] + 1.5) * SE) + 1;   // floor under the spoutfunction tap(g, lever) {  // lever: 0 upright (shut), 1 tipped 45 degrees, 2 tipped forward level (open).  // Built as a pixel map and ringed with ink by hand: outlined() only inks empty pixels, and most of the tap sits  // over the cask.  const m = new Map();  const P = (x, y, k) => m.set((TY + y) * W + TX + x, k);  P(0, -1, "g1"); P(0, 0, "g1"); P(0, 1, "g0"); P(0, 2, "g0");            // flange on the head  P(1, 0, "g2"); P(2, 0, "g2"); P(1, 1, "g1"); P(2, 1, "g1");            // pipe  P(3, -1, "g3"); P(4, -1, "g2"); P(3, 0, "g2"); P(4, 0, "g1"); P(3, 1, "g1"); P(4, 1, "g0");   // valve  P(3, 2, "g2"); P(4, 2, "g0"); P(3, 3, "g1"); P(4, 3, "g0");            // spout  const knob = (x, y) => { P(x, y, "r3"); P(x + 1, y, "r2"); P(x, y + 1, "r2"); P(x + 1, y + 1, "r1"); };   // red-lacquered knob  if (lever === 0) { P(3, -2, "g2"); P(3, -3, "g1"); knob(3, -5); }  else if (lever === 1) { P(4, -2, "g2"); knob(5, -4); }  else { P(5, -1, "g2"); knob(6, -2); }  const ink = new Set();  for (const i of m.keys()) {    const x = i % W, y = (i - x) / W;    for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {      if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;      const k = ny * W + nx;      if (!m.has(k) && !(nx < TX)) ink.add(k);                            // no ink between the flange and the head    }  }  g.outlined("ink", () => g.part("tap", () => g.field({ x: [TX - 1, W - 1], y: [TY - 7, TY + 5] }, (x, y) => {    const i = y * W + x;    return m.has(i) ? m.get(i) : ink.has(i) ? "ink" : null;  })));}function splash(g, k) {  // k: 0 hit (a crown with a fleck of foam), 1 droplets thrown wide, 2 last fleck  g.detached("splash", () => {    const x = SX, y = FLOOR_Y;    if (k === 0) { g.pixel(x - 1, y, "g2"); g.pixel(x + 2, y, "g2"); g.pixel(x, y, "n5"); g.pixel(x + 1, y, "g3"); g.pixel(x - 1, y - 1, "g1"); g.pixel(x + 2, y - 1, "g1"); }    else if (k === 1) { g.pixel(x - 2, y - 1, "g2"); g.pixel(x + 3, y - 1, "g2"); g.pixel(x, y, "g1"); g.pixel(x + 1, y, "g2"); }    else { g.pixel(x - 2, y, "g1"); g.pixel(x + 3, y, "g1"); }  });}function drop(g, x, y, size) {  // size 0: bead at the nozzle, 1: swollen bead, 2: falling drop (teardrop)  g.detached("drip", () => {    if (size === 0) { g.pixel(x, y, "g2"); }    else if (size === 1) { g.pixel(x, y, "g1"); g.pixel(x, y + 1, "g3"); g.pixel(x + 1, y + 1, "g1"); }    else { g.pixel(x, y, "g1"); g.pixel(x, y + 1, "g3"); g.pixel(x, y + 2, "g2"); }  });}function stream(g, y0, y1, phase, arc) {  // a 2px amber rope under the spout; the first rows bow out forward (to the right) when arc is set; highlights  // drift down with phase so the flow reads  g.detached("ale", () => {    for (let y = y0; y <= y1; y++) {      const k = y - y0, dx = arc && k >= 1 && k <= 3 ? 1 : 0;      const band = ((y - phase) % 3 + 3) % 3;      g.pixel(SX + dx, y, band === 0 ? "g3" : "g2");      g.pixel(SX + 1 + dx, y, band === 1 ? "g2" : "g1");    }  });}const CACHE = {};const body = () => CACHE.body || (CACHE.body = cast());const Barrel = sprite(pal, (g, t, mode) => {  contactShadow(g, CX + 1, GROUND - 2, 12, 2);  drawBody(g, body());  if (mode === "idle") {    const f = Math.round(t * N_IDLE) % N_IDLE;    tap(g, 0);    const ny = SPOUT_Y + 1;    if (f >= 3 && f <= 4) drop(g, SX, ny, 0);    else if (f >= 5 && f <= 6) drop(g, SX, ny, 1);    else if (f === 7) drop(g, SX, ny + 2, 2);    else if (f === 8) drop(g, SX, ny + 5, 2);    else if (f === 9) splash(g, 0);    else if (f === 10) splash(g, 2);    return;  }  // pour  const f = Math.min(N_POUR - 1, Math.round(t * N_POUR));  tap(g, [0, 1, 2, 2, 2, 1, 0, 0][f]);  const y0 = SPOUT_Y + 1;  if (f === 2) stream(g, y0, y0 + 3, 0, true);  else if (f === 3) { stream(g, y0, FLOOR_Y - 1, 1, true); splash(g, 0); }  else if (f === 4) { stream(g, y0, FLOOR_Y - 1, 2, true); splash(g, 1); }  else if (f === 5) { stream(g, y0 + 3, FLOOR_Y - 1, 0, false); splash(g, 0); drop(g, SX, y0, 0); }  else if (f === 6) { splash(g, 2); }});submit(Barrel, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  anchor: { x: 16, y: GROUND },  variants: [    { name: "idle", args: ["idle"], animate: { fps: 10, duration: N_IDLE / 10, loop: true } },    { name: "pour", args: ["pour"], animate: { fps: 10, duration: N_POUR / 10, loop: false } },  ],});

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

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

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