Tessarune
Tavern & Market · merchant_scale.js

Merchant's scale

32×48 · 264 lines of JavaScript

A 32×48 floor prop in top-down 3/4: a brass beam balance on a small plank table, with a stepped round foot, a dial plate whose needle sits behind the pivot, and two pans on chains; a stack of coins and a violet-bound ledger lie on the table. The beam is drawn column by column from its tilt, so every pose comes from one drawing and the needle leans with it. “idle” (12 frames, loop) runs a glint along the beam; “weigh” (12 frames, once) drops a gem into the left pan, tips the beam, overshoots and settles tilted; “idle_tilted” (12 frames, loop) holds that pose.

Merchant's scale — Rendered output6×
Rendered by this program, variant “weigh” · 32×48 · 12 frames
Tavern & MarketGet the animated sprites: Tavern & Market on itch.ioVol.3 · 12 tavern and market props · 32 animations

The header. The comment at the top of the file is the brief: size, view, light direction and every variant.

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

The last lines. submit declares the canvas and lists the variants the renderer exports.

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"] },  ],});
Show the whole program (264 lines)
merchant_scale.js · 264 lines
// 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"] },  ],});

These excerpts omit the boilerplate prepended by the asset pack build script: a single-line runtime header, the shared palette, and common helper utilities (contactShadow, litIndex, flame, hash, chk, TAU). The displayed line numbers match the original source files. Each program was authored by an AI coding agent and adjusted after visual inspection of the rendered frames.

Have your agent write programs like this.

Tessarune gives it the drawing API reference, 25 production manuals and a renderer to check its work against.