Tessarune
Tavern & Market · merchant_scale.js

商人の天秤

32×48 · JavaScript 264 行

32×48 の床置きの小物で、見下ろし 3/4 視点です。小さな板の台に真鍮の天秤があり、段のある丸い台座、支点の後ろに針のある目盛り板、鎖で吊った 2 枚の皿。台には硬貨の山と紫の表紙の帳簿があります。竿は傾きから一列ずつ描くので、どの姿勢も一つの描き方から出て、針も竿と一緒に傾きます。idle(待機、12 コマのループ)は竿を光が走ります。weigh(12 コマの一回再生)は左の皿に宝石が落ち、竿が傾き、行き過ぎて、傾いたまま止まります。idle_tilted(12 コマのループ)はその傾いた姿です。

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

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

merchant_scale.js · 1–10
// Merchant's balance scale — 32x48 floor prop, top-down 3/4, key light top-left.// A brass beam balance on a small plank table: stepped round foot, a post with a collar, a dial plate with a needle// behind the pivot, a beam with two pans hung on chains. On the table: a stack of coins and a violet-bound ledger.// The beam is rasterised per column from its tilt d (px the left end sits below the pivot), so every pose of the// weigh comes from one drawing; the pans hang plumb under the beam ends and the needle leans with the beam.// Variants://   idle          loop 12f — balanced; a glint runs along the brass beam and sparks off the right hook//   weigh         one-shot 12f — a gem drops into the left pan, the beam tips, overshoots, settles tilted;//                 its last frame == idle_tilted frame 0//   idle_tilted   loop 12f — tilted to the left, the gem in the pan; the same glint along the tilted beam

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

merchant_scale.js · 255–264
submit(Scale, {  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: "weigh", args: ["weigh"], animate: { fps: 10, duration: N_WEIGH / 10, loop: false } },    { name: "idle_tilted", args: ["tilted"] },  ],});
プログラム全体を表示 (264 行)
merchant_scale.js · 264 行
// Merchant's balance scale — 32x48 floor prop, top-down 3/4, key light top-left.// A brass beam balance on a small plank table: stepped round foot, a post with a collar, a dial plate with a needle// behind the pivot, a beam with two pans hung on chains. On the table: a stack of coins and a violet-bound ledger.// The beam is rasterised per column from its tilt d (px the left end sits below the pivot), so every pose of the// weigh comes from one drawing; the pans hang plumb under the beam ends and the needle leans with the beam.// Variants://   idle          loop 12f — balanced; a glint runs along the brass beam and sparks off the right hook//   weigh         one-shot 12f — a gem drops into the left pan, the beam tips, overshoots, settles tilted;//                 its last frame == idle_tilted frame 0//   idle_tilted   loop 12f — tilted to the left, the gem in the pan; the same glint along the tilted beamconst W = 32, H = 48, GROUND = 45, CX = 16;const PX = 15.5, PY = 10, ARM = 10;                    // pivot (continuous coords) and arm lengthconst N_IDLE = 12, N_WEIGH = 12, TILT = 3;const WEIGH_D = [0, 0, 0, 0, 2, 5, 4, 2, 3, 4, 3, 3];  // beam tilt per frame of the weigh (damped around TILT)// ---- a tiny raster: paint keys per pixel with a part tag, then emit one field per part ---------------------------function Canvas() {  const key = new Array(W * H).fill(null), part = new Array(W * H).fill(null), order = [];  return {    key, part, order,    set(x, y, k, p) {      x = Math.round(x); y = Math.round(y);      if (x < 0 || y < 0 || x >= W || y >= H || !k) return;      key[y * W + x] = k; part[y * W + x] = p;      if (!order.includes(p)) order.push(p);    },  };}function emit(g, cv) {  for (const p of cv.order) {    let x0 = W, x1 = -1, y0 = H, y1 = -1;    for (let i = 0; i < W * H; i++) if (cv.part[i] === p) { const x = i % W, y = (i - x) / W; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }    if (x1 < 0) continue;    g.part(p, () => g.field({ x: [x0, x1], y: [y0, y1] }, (x, y) => (cv.part[y * W + x] === p ? cv.key[y * W + x] : null)));  }}// ---- table and stand (static) -------------------------------------------------------------------------------------function table(cv) {  for (let x = 3; x <= 28; x++) {    cv.set(x, 31, "w2", "table");    cv.set(x, 32, x < 22 ? "w3" : "w2", "table");    cv.set(x, 33, (x * 5) % 9 === 2 ? "w2" : "w3", "table");     // plank seam, broken    cv.set(x, 34, "w3", "table");    cv.set(x, 35, x < 20 ? "w4" : "w3", "table");                // lit front edge    cv.set(x, 36, "w1", "table");                                // shadow under the lip    cv.set(x, 37, x === 3 ? "w3" : "w2", "table");    cv.set(x, 38, "w1", "table");  }  for (let y = 39; y <= 45; y++) {    cv.set(4, y, "w3", "legs"); cv.set(5, y, "w2", "legs");    cv.set(26, y, "w2", "legs"); cv.set(27, y, "w1", "legs");    if (y <= 42) { cv.set(8, y, "w1", "legs"); cv.set(23, y, "w0", "legs"); }   // back legs, in shade  }  cv.set(4, 45, "w2", "legs"); cv.set(27, 45, "w0", "legs");}function ledger(cv) {  // a closed ledger lying on the left of the table: violet cover, cream page edges on the front  for (let x = 4; x <= 10; x++) {    cv.set(x, 31, x < 7 ? "v2" : "v1", "ledger");    cv.set(x, 32, x < 9 ? "v1" : "v0", "ledger");    cv.set(x, 33, x === 4 ? "v1" : "n5", "ledger");    cv.set(x, 34, x === 4 ? "v0" : "n4", "ledger");  }  cv.set(5, 31, "v3", "ledger");  cv.set(8, 31, "g2", "ledger"); cv.set(8, 32, "g1", "ledger");  // clasp}function coins(cv) {  // a stack of four coins on the front right, the top one catching the light  for (let i = 0; i < 4; i++) {    const y = 33 - i, x0 = 21 + (i === 2 ? 1 : 0);    for (let x = x0; x <= x0 + 3; x++) cv.set(x, y, x === x0 ? "g3" : x === x0 + 3 ? "g1" : "g2", "coins");  }  cv.set(22, 29, "g3", "coins"); cv.set(23, 29, "g2", "coins"); cv.set(24, 29, "g2", "coins");  cv.set(21, 34, "g0", "coins"); cv.set(22, 34, "g0", "coins"); cv.set(23, 34, "g1", "coins"); cv.set(24, 34, "g0", "coins");}function stand(cv) {  // stepped round foot  for (let y = 28; y <= 33; y++) for (let x = 9; x <= 22; x++) {    const low = y >= 31, rx = low ? 5.5 : 3.5, cy = low ? 31.5 : 29;    const u = (x + 0.5 - PX) / rx;    if (Math.abs(u) > 1) continue;    if (low && y === 31 && Math.abs(u) < 0.62) continue;          // the upper step sits on the lower one    let k = u < -0.5 ? "g3" : u < 0.1 ? "g2" : u < 0.6 ? "g1" : "g0";    if (y === 33 || y === 30) k = u < 0 ? "g1" : "g0";              // under-edges in shade    if ((y === 28 || y === 31) && u < 0.4) k = "g3";                // lit top rims    cv.set(x, y, k, "foot");  }  // post with a collar  for (let y = 11; y <= 27; y++) { cv.set(15, y, "g2", "post"); cv.set(16, y, "g1", "post"); }  cv.set(15, 12, "g3", "post"); cv.set(15, 13, "g3", "post");  for (const [x, k] of [[14, "g3"], [15, "g2"], [16, "g1"], [17, "g0"]]) cv.set(x, 20, k, "post");  cv.set(15, 21, "g1", "post"); cv.set(16, 21, "g0", "post");}function dial(cv, d) {  // half-disc plate above the pivot, tick marks, and a needle that leans towards the heavy side  const cy = 9.5, r = 4.6;  for (let y = 4; y <= 9; y++) for (let x = 10; x <= 21; x++) {    const nx = x + 0.5 - PX, ny = y + 0.5 - cy;    if (nx * nx + ny * ny > r * r) continue;    const rim = nx * nx + ny * ny > (r - 1) * (r - 1);    let k = rim ? (nx < 0.5 ? "g2" : "g1") : nx < -1 ? "n5" : "n4";    if (rim && ny < -3 && nx < 0) k = "g3";    cv.set(x, y, k, "dial");  }  for (const [x, y] of [[13, 6], [15, 5], [16, 5], [18, 6]]) cv.set(x, y, "n2", "dial");   // ticks (centre tick is 2 wide)  // needle: perpendicular to the beam, 4px long  const L = Math.hypot(2 * ARM, 2 * d), vx = (-2 * d) / L, vy = (-2 * ARM) / L;  for (let s = 1; s <= 4; s++) cv.set(Math.floor(PX + vx * s), Math.floor(PY - 0.5 + vy * s), s === 4 ? "r1" : "n0", "dial");}// ---- beam, pans, chains (posed by d) ----------------------------------------------------------------------------const beamY = (x, d) => PY + ((PX - (x + 0.5)) / ARM) * d;       // centre line of the beam at column xconst endL = (d) => [PX - ARM, PY + d], endR = (d) => [PX + ARM, PY - d];function beam(cv, d) {  const x0 = Math.round(PX - ARM) - 1, x1 = Math.round(PX + ARM);  const tops = [];  for (let x = x0; x <= x1; x++) {    const y = Math.round(beamY(x, d) - 1);    const u = (x - x0) / (x1 - x0);    cv.set(x, y, u < 0.55 ? "g3" : "g2", "beam");    cv.set(x, y + 1, u < 0.5 ? "g1" : "g0", "beam");    tops.push([x, y]);  }  // end knobs  for (const [ex, ey] of [endL(d), endR(d)]) {    const x = Math.floor(ex), y = Math.round(ey);    cv.set(x, y + 1, "g1", "beam");  }  // pivot boss  cv.set(15, Math.round(PY - 1), "g3", "beam"); cv.set(16, Math.round(PY - 1), "g2", "beam");  cv.set(15, Math.round(PY), "g2", "beam"); cv.set(16, Math.round(PY), "g1", "beam");  return tops;}const PAN_DROP = 8;                                              // hook to rimfunction pan(cv, ex, ey, name) {  const cx = ex, cy = ey + PAN_DROP + 1, rx = 4.6, ry = 1.7;  for (let y = Math.floor(cy - 2); y <= Math.ceil(cy + 3); y++) for (let x = Math.floor(cx - 5); x <= Math.ceil(cx + 5); x++) {    const nx = (x + 0.5 - cx) / rx, ny = (y + 0.5 - cy) / ry;    const inRim = nx * nx + ny * ny <= 1;    // bowl under the rim: a half ellipse, deeper in the middle    const bowl = !inRim && y + 0.5 > cy && Math.abs(nx) <= 1 && (y + 0.5 - cy) <= 1.2 + 2.2 * Math.sqrt(Math.max(0, 1 - nx * nx));    if (!inRim && !bowl) continue;    let k;    if (inRim) {      const ix = (x + 0.5 - cx) / (rx - 1.2), iy = (y + 0.5 - cy) / (ry - 0.8);      if (ix * ix + iy * iy <= 1) k = ix > 0.2 && iy > -0.4 ? "g2" : ix > -0.4 ? "g1" : "g0";   // concave: lit on the far right      else k = nx < -0.3 && ny < 0.3 ? "g3" : nx < 0.5 ? "g2" : "g1";                          // rim    } else {      const bot = (y + 1.5 - cy) > 1.2 + 2.2 * Math.sqrt(Math.max(0, 1 - nx * nx));      k = bot ? (nx < -0.2 ? "g1" : "g0") : nx < -0.45 ? "g2" : nx < 0.35 ? "g1" : "g0";    }    cv.set(x, y, k, name);  }}function chains(cv, ex, ey) {  // two chains from the hook to the rim, links alternating bright/dark  const hx = ex, hy = ey + 1.5, ry = ey + PAN_DROP + 0.5;  for (const side of [-1, 1]) {    const tx = ex + side * 3.6;    for (let s = 1; s <= 6; s++) {      const u = s / 7;      cv.set(Math.floor(hx + (tx - hx) * u), Math.floor(hy + (ry - hy) * u), s % 2 ? "g2" : "g0", "chains");    }  }}function gem(cv, x, y, name) {  // a 3x3 faceted blue gem, x,y = top-left  const P = [[null, "b3", "b2"], ["b3", "b2", "b1"], [null, "b1", "b0"]];  P.forEach((row, j) => row.forEach((k, i) => k && cv.set(x + i, y + j, k, name)));}function sparkle(g, x, y, s, c) {  g.pixel(x, y, s > 0 ? "n5" : c);  for (let i = 1; i <= s; i++) {    const k = i === s ? c : "n5";    g.pixel(x - i, y, k); g.pixel(x + i, y, k); g.pixel(x, y - i, k); g.pixel(x, y + i, k);  }}// ---- the whole scale in one pose ----------------------------------------------------------------------------------// o: { d, gemInPan, glint (frame index of the travelling glint or -1), fall: [x,y] | null, impact }function drawScale(g, o) {  const d = o.d;  contactShadow(g, CX, GROUND, 13, 2);  const base = Canvas();  table(base); ledger(base); coins(base); stand(base); dial(base, d);  g.outlined("ink", () => emit(g, base));  const [lx, ly] = endL(d), [rx, ry] = endR(d);  const top = Canvas();  const tops = beam(top, d);  pan(top, lx, ly, "panL");  pan(top, rx, ry, "panR");  if (o.gemInPan) gem(top, Math.round(lx) - 1, Math.round(ly) + PAN_DROP - 1, "gem");  g.outlined("ink", () => emit(g, top));  const ch = Canvas();  chains(ch, lx, ly); chains(ch, rx, ry);  emit(g, ch);  g.detached("glint", () => {    const f = o.glint;    if (f >= 2 && f <= 8) {      const i = Math.min(tops.length - 1, Math.round(((f - 2) / 6) * (tops.length - 1)));      const [x, y] = tops[i];      g.pixel(x, y, "n5");      if (i + 1 < tops.length) g.pixel(tops[i + 1][0], tops[i + 1][1], "g3");      if (i > 0) g.pixel(tops[i - 1][0], tops[i - 1][1], "g3");    }    if (f === 9) sparkle(g, Math.round(rx) + 1, Math.round(ry) - 1, 1, "g3");    if (f === 10) g.pixel(Math.round(rx) + 1, Math.round(ry) - 1, "n5");  });  if (o.fall) g.detached("falling", () => g.outlined("ink", () => {    const [x, y, edge] = o.fall;    if (edge) { g.pixel(x + 1, y, "b3"); g.pixel(x + 1, y + 1, "b2"); g.pixel(x + 1, y + 2, "b1"); }    else { g.pixel(x + 1, y, "b3"); g.pixel(x, y + 1, "b3"); g.pixel(x + 1, y + 1, "b2"); g.pixel(x + 2, y + 1, "b1"); g.pixel(x + 1, y + 2, "b0"); }  }));  if (o.impact) g.detached("impact", () => {    const cx = Math.round(lx), cy = Math.round(ly) + PAN_DROP;    const k = o.impact;    if (k === 1) { sparkle(g, cx - 4, cy - 2, 1, "n4"); sparkle(g, cx + 4, cy - 3, 1, "n4"); }    else { g.pixel(cx - 6, cy - 3, "n4"); g.pixel(cx + 5, cy - 5, "n4"); }  });}const Scale = sprite(pal, (g, t, mode) => {  if (mode === "idle" || mode === "tilted") {    const f = Math.round(t * N_IDLE) % N_IDLE;    drawScale(g, { d: mode === "idle" ? 0 : TILT, gemInPan: mode === "tilted", glint: f, fall: null });    return;  }  // weigh: 0 == idle f0 | 1-3 the gem falls (spinning) | 4 lands, beam starts to tip | 5 overshoot | 6-10 settle  const f = Math.min(N_WEIGH - 1, Math.round(t * N_WEIGH));  const d = WEIGH_D[f];  if (f === N_WEIGH - 1) { drawScale(g, { d: TILT, gemInPan: true, glint: 0, fall: null }); return; }  const FALL = [null, [6, 0, false], [6, 5, true], [6, 11, false]];  drawScale(g, {    d, gemInPan: f >= 4, glint: f === 0 ? 0 : -1,    fall: FALL[f] || null,    impact: f === 4 ? 1 : f === 5 ? 2 : 0,  });  if (f === 1) g.detached("toss", () => sparkle(g, 7, 1, 1, "b3"));});submit(Scale, {  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: "weigh", args: ["weigh"], animate: { fps: 10, duration: N_WEIGH / 10, loop: false } },    { name: "idle_tilted", args: ["tilted"] },  ],});

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

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

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