Tavern & Market · merchant_scale.js
商人の天秤
32×48 の床置きの小物で、見下ろし 3/4 視点です。小さな板の台に真鍮の天秤があり、段のある丸い台座、支点の後ろに針のある目盛り板、鎖で吊った 2 枚の皿。台には硬貨の山と紫の表紙の帳簿があります。竿は傾きから一列ずつ描くので、どの姿勢も一つの描き方から出て、針も竿と一緒に傾きます。idle(待機、12 コマのループ)は竿を光が走ります。weigh(12 コマの一回再生)は左の皿に宝石が落ち、竿が傾き、行き過ぎて、傾いたまま止まります。idle_tilted(12 コマのループ)はその傾いた姿です。
6×
動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。