Tavern & Market · anvil.js
金床
48×48 の床置きの小物で、見下ろし 3/4 視点、左上からの光です。裾の広がった樫の切り株の上に、左に角、右のかかとに角穴のあるロンドン型の金床があり、やっとこが立てかけてあります。面の上の鉄の棒は左端が冷えた灰色、右の先が白熱していて、その暖かい光が鋼の面にたまります。idle(待機、12 コマのループ)は熱い芯が伸び縮みして陽炎が立ちます。strike(8 コマの一回再生)は上から大槌が落ちて当たる瞬間に光り、火花が飛び散って、槌が持ち上がって去ります。
6×
動くスプライト素材: Tavern & Market(itch.io)Vol.3・酒場と市場の小物 12 点・32 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
anvil.js · 1–7
// Blacksmith's anvil on a tree stump — 48x48 floor prop, top-down 3/4, key light top-left.// A London-pattern anvil (horn left, heel with a hardy hole right) stands on a flared oak stump; a bar lies across// the face, cold grey at the left end and white-hot at the right tip. Tongs lean on the stump. Warm light from the bar pools on the steel face around it.// Variants:// idle loop 12f — the bar breathes (the hot core grows and shrinks), the glow pool follows, heat shimmer rises// strike one-shot 8f — a sledge drops in from the top, hits (flash), a burst of sparks flies out and falls,// the sledge lifts away; the last frame == idle frame 0最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
anvil.js · 256–263
submit(Anvil, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"], animate: { fps: 10, duration: N_IDLE / 10, loop: true } }, { name: "strike", args: ["strike"], animate: { fps: 10, duration: N_STRIKE / 10, loop: false } }, ],});プログラム全体を表示 (263 行)
anvil.js · 263 行
// Blacksmith's anvil on a tree stump — 48x48 floor prop, top-down 3/4, key light top-left.// A London-pattern anvil (horn left, heel with a hardy hole right) stands on a flared oak stump; a bar lies across// the face, cold grey at the left end and white-hot at the right tip. Tongs lean on the stump. Warm light from the bar pools on the steel face around it.// Variants:// idle loop 12f — the bar breathes (the hot core grows and shrinks), the glow pool follows, heat shimmer rises// strike one-shot 8f — a sledge drops in from the top, hits (flash), a burst of sparks flies out and falls,// the sledge lifts away; the last frame == idle frame 0const W = 48, H = 48, CX = 24, GROUND = 45;const N_IDLE = 12, N_STRIKE = 8;const ST = { cx: 24, rx: 12, top: 31, ry: 4, bot: 42, bry: 2.5 }; // stump: top ellipse, bottom ellipseconst FACE = { y0: 15, y1: 19, x0: 13, x1: 37 }; // anvil working face (top)const BAR = { y: 17, x0: 14, x1: 31 }; // bar: top row y, front row y + 1const HIT = [27, 17]; // where the sledge lands on the bar// ---- stump -------------------------------------------------------------------------------------------------function stump(g) { const WOOD = ["w0", "w1", "w2", "w3", "w4"]; g.part("stump", () => g.field({ x: [8, 40], y: [ST.top - ST.ry, GROUND] }, (x, y) => { const flare = y > 38 ? (y - 38) * 0.45 : 0; // the base flares into roots const rx = ST.rx + flare, dx = x + 0.5 - ST.cx, nx = dx / rx; if (Math.abs(nx) > 1) return null; const s = Math.sqrt(1 - nx * nx); if (y + 0.5 < ST.top - ST.ry * s) return null; if (y + 0.5 > ST.bot + ST.bry * s + 0.6) return null; // three root lobes break the bottom outline if (y >= 42 && !(Math.abs(dx + 9) < 3.2 || Math.abs(dx - 2) < 2.6 || Math.abs(dx - 10.5) < 2.8) && Math.abs(nx) > 0.55) return null; // bark: vertical ridges and grooves; sampled on the cylinder's angle so they crowd together at the sides let k = nx < -0.55 ? 2 : nx < 0.3 ? 1 : 0; const a = Math.asin(nx) * 3.0 + 0.4, plate = Math.floor(a), fr = a - plate; if (fr < 0.25) k = Math.min(4, k + 1); // each bark plate's lit left edge else if (fr > 0.8) k = Math.max(0, k - 1); // groove between plates else if (y === 34 + ((plate * 5 + 20) % 6)) k = Math.max(0, k - 1); // one cross-crack per plate if (nx < -0.78 && fr < 0.25 && y > 32 && y < 41) k = 4; // lit ridges on the left flank if (y + 0.5 > ST.bot + ST.bry * s - 1.2) k = Math.max(0, k - 1); // foot of the stump in shadow return WOOD[k]; })); // end grain on top: rings, a lit cut edge top-left, a split running to the front g.part("stumpTop", () => g.field({ x: [ST.cx - ST.rx, ST.cx + ST.rx], y: [ST.top - ST.ry, ST.top + ST.ry] }, (x, y) => { const nx = (x + 0.5 - ST.cx) / ST.rx, ny = (y + 0.5 - ST.top) / ST.ry; const r = Math.sqrt(nx * nx + ny * ny); if (r > 1) return null; if (r > 0.9) return -nx - 0.6 * ny > 0.3 ? "w2" : "w1"; // bark rim if (r > 0.76) return -nx - ny > -0.2 ? "w4" : "w3"; // pale sapwood ring, brightest top-left if (Math.abs(r - 0.5) < 0.07 || Math.abs(r - 0.25) < 0.08) return "w2"; // growth rings if (ny > 0.2 && Math.abs(nx - 0.3 - ny * 0.15) < 0.06) return "w1"; // a drying split return "w3"; }));}// ---- anvil -------------------------------------------------------------------------------------------------// row spans [x0, x1] per screen row for the body below the faceconst SPANS = { 20: [13, 37], 21: [13, 37], 22: [15, 35], 23: [17, 33], // body front, underside slopes in at both ends 24: [19, 29], 25: [20, 28], 26: [20, 28], 27: [19, 29], // waist 28: [17, 31], 29: [16, 32], 30: [15, 33], 31: [15, 33], 32: [14, 34], // foot (top row + front)};function hornEdges(x) { const u = (FACE.x0 - x) / 10; // 0 at the body, 1 at the tip return [FACE.y0 + 2.3 * Math.pow(u, 1.4), 22 - 4.6 * Math.pow(u, 0.8)];}function anvilKey(x, y, heat) { // horn: a cone tapering left, lit along its upper flank if (x < FACE.x0 && x >= 3) { const [yt, yb] = hornEdges(x + 0.5); if (y + 0.5 < yt || y + 0.5 > yb + 0.9) return null; const v = (y + 0.5 - yt) / (yb + 0.9 - yt); if (x <= 4) return v < 0.5 ? "n3" : "n2"; return v < 0.22 ? "n4" : v < 0.45 ? "n3" : v < 0.78 ? "n2" : "n1"; } if (y >= FACE.y0 && y <= FACE.y1) { if (x < FACE.x0 || x > FACE.x1) return null; if ((x === 34 || x === 35) && y === 16) return "ink"; // hardy hole if ((x === 34 || x === 35) && y === 17) return x === 34 ? "ink" : "n1"; if (x === 31 && y === 16) return "n1"; // pritchel hole if (y === FACE.y0) return x < 31 ? "n4" : "n3"; // lit back edge if (x === FACE.x0) return "n4"; if (x === FACE.x1) return "n2"; // the bar's glow pools on the steel just in front of / behind it const T = barTemp(x, heat); if (y === BAR.y + 2 && x >= BAR.x0 && x <= BAR.x1 + 1) return T > 0.85 ? "r2" : T > 0.55 ? "r1" : T > 0.2 ? "n2" : "n1"; if (y === BAR.y - 1 && x >= BAR.x0 && x <= BAR.x1 && T > 0.8) return "r1"; if (y === 16 && x >= 15 && x <= 18) return "n4"; // polished wear on the face return "n3"; } const sp = SPANS[y]; if (!sp || x < sp[0] || x > sp[1]) return null; if (y >= 31 && x >= 21 - (y - 31) && x <= 27 + (y - 31)) return null; // arch cut out of the foot if (y === 20) { // front bevel catches light (and the glow) const T = barTemp(x, heat); return x > 33 ? "n2" : T > 0.88 && heat > 0.5 ? "r1" : "n3"; } if (y <= 23) { if (x >= sp[1] - (y === 23 ? 1 : 0) || y === 23) return x > 28 ? "n0" : "n1"; return x < 20 ? "n3" : x < 29 ? "n2" : "n1"; } if (y === 24) return x < 22 ? "n1" : "n0"; // waist: in the body's shadow if (y <= 27) return x <= sp[0] ? "n2" : x < 26 ? "n1" : "n0"; if (y === 28) return x < 21 ? "n4" : x < 29 ? "n3" : "n2"; // foot top if (y === 32) return x < 22 ? "n1" : "n0"; return x <= sp[0] + 1 ? "n3" : x < 22 ? "n2" : x < 30 ? "n1" : "n0";}function anvil(g, heat) { g.part("anvil", () => g.field({ x: [3, 37], y: [14, 32] }, (x, y) => anvilKey(x, y, heat)));}// ---- the bar -----------------------------------------------------------------------------------------------// temperature 0..1 along the bar (hot at the right tip); heat (0..1) breathes itfunction barTemp(x, heat) { const T = Math.max(0, Math.min(1, (x - 17) / 10)); return T * (0.84 + 0.26 * heat);}const TOP_RAMP = ["n2", "r0", "r1", "r2", "r3", "g3"];const FRONT_RAMP = ["n1", "r0", "r0", "r1", "r2", "r3"];function tempIdx(T) { return T < 0.12 ? 0 : T < 0.25 ? 1 : T < 0.42 ? 2 : T < 0.62 ? 3 : T < 0.85 ? 4 : 5; }function bar(g, heat, flash) { g.part("bar", () => { for (let x = BAR.x0; x <= BAR.x1; x++) { const i = tempIdx(barTemp(x, heat)); let top = TOP_RAMP[i], front = FRONT_RAMP[i]; if (flash && i >= 3) { top = i >= 4 ? "n5" : "g3"; front = "r3"; } if (x === BAR.x0) top = "n3"; // lit cut end of the cold stock g.pixel(x, BAR.y, top); g.pixel(x, BAR.y + 1, front); } g.pixel(BAR.x1 + 1, BAR.y + 1, flash ? "r3" : "r2"); // the tip's end face });}// ---- tongs ----------------------------------------------------------------------------function tongs(g) { g.part("tongs", () => { // two reins splaying to the floor from the pivot, lit / shaded g.line([36, 31], [37, 44], "n3"); g.line([37, 31], [42, 44], "n1"); g.pixel(37, 38, "n2"); g.pixel(40, 39, "n0"); g.rect({ x: [36, 37], y: [29, 30] }, "n2", { fill: "n2" }); g.pixel(36, 29, "n4"); // rivet g.line([36, 28], [35, 25], "n3"); g.line([37, 28], [38, 25], "n1"); // jaws g.pixel(36, 24, "n3"); g.pixel(37, 24, "n1"); });}// ---- effects -----------------------------------------------------------------------------------------------function shimmer(g, t) { g.detached("shimmer", () => { for (let i = 0; i < 4; i++) { const p = (t + i / 4) % 1; const x0 = [25, 28, 30, 26][i] + Math.round(Math.sin(TAU * (p * 1.5 + i * 0.37)) * 1.2); const y0 = BAR.y - 3 - Math.round(p * 11); if (p < 0.3) { g.pixel(x0, y0, "r2"); g.pixel(x0, y0 + 1, "r1"); } else if (p < 0.6) { g.pixel(x0, y0, "r1"); if (chk(x0, y0)) g.pixel(x0 + 1, y0 + 1, "r0"); } else if (p < 0.85) g.pixel(x0, y0, "r0"); } });}function embers(g, t) { g.detached("embers", () => { for (let k = 0; k < 2; k++) { const p = (t + k * 0.5) % 1; if (p > 0.55) continue; const x = 28 + k * 2 + Math.round(Math.sin(TAU * (p + k * 0.3)) * 2), y = BAR.y - 2 - Math.round(p * 14); g.pixel(x, y, p < 0.25 ? "g3" : "g2"); } });}// the smith's sledge, entering from the top of the frame; yb = bottom row of the striking facefunction sledge(g, yb) { // a double-faced sledge held head-down; its haft leaves the head's right cheek and runs up-right out of frame // (the smith stands behind and to the right) const hx0 = 23, hx1 = 31, HH = 10; g.detached("sledge", () => g.outlined("ink", () => { const hy = yb - 5.5; // eye height if (hy > 2) g.sweep([[30.5, hy], [30.5 + 0.62 * (hy - 1.5), 1.5]], { w0: 1.5, w1: 1.5 }, (u, v) => (Math.abs(v) < 0.34 ? "w2" : (v < 0) ? "w3" : "w1")); g.field({ x: [hx0, hx1], y: [yb - HH, yb] }, (x, y) => { if (y < 0) return null; const r = yb - y; // 0 = striking face const flare = r <= 1 || r >= HH - 1; const x0 = flare ? hx0 : hx0 + 1, x1 = flare ? hx1 : hx1 - 1; if (x < x0 || x > x1) return null; if (r === HH) return x < 30 ? "n4" : "n3"; // top face if (r === HH - 1 || r === 1) return x < 25 ? "n4" : x < 29 ? "n3" : "n2"; // flared chamfers if (r === 0) return x < 28 ? "n2" : "n1"; if (r === 2 || r === HH - 2) return x < 25 ? "n2" : "n1"; // neck under each flare return x <= x0 ? "n4" : x < 27 ? "n3" : x < 29 ? "n2" : "n1"; // cheek, lit left }); if (yb - 6 >= 0) { g.pixel(25, yb - 6, "n5"); g.pixel(25, yb - 5, "n4"); } // specular on the cheek }));}function smear(g, yb) { g.detached("smear", () => { for (let y = Math.max(0, yb - 17); y < yb - 11; y++) { if (chk(22, y)) g.pixel(22, y, "n3"); if (chk(25, y)) g.pixel(25, y, "n4"); } });}function flashStar(g) { // the flash squirts out sideways from under the sledge's face, along the bar const [x, y] = HIT; g.detached("flash", () => { for (const [dx, dy, k] of [[-5, -1, "n5"], [5, -1, "n5"], [-6, -1, "g3"], [6, -1, "g3"], [-7, -1, "g3"], [7, -1, "g3"], [-9, -1, "g2"], [9, -1, "g2"], [-6, -3, "g3"], [6, -3, "g3"], [-7, -4, "g2"], [7, -4, "g2"], [-5, 1, "g3"], [5, 1, "g3"], [-7, 2, "r3"], [7, 2, "r3"]]) g.pixel(x + dx, y + dy, k); });}const SPARKS = [ [-3.2, -2.0], [-2.3, -3.1], [-1.0, -3.8], [0.5, -4.1], [1.8, -3.4], [3.1, -2.6], [3.9, -1.1], [-3.9, -0.7], [-2.7, 0.3], [2.9, 0.2], [1.1, -2.2], [-1.6, -1.7], [4.4, -2.0], [-4.3, -1.8],];function sparks(g, k) { g.detached("sparks", () => { SPARKS.forEach(([vx, vy], i) => { if (k === 3 && i % 3 === 0) return; // some burn out const sp = 1.55 + (i % 3) * 0.25; const x = Math.round(HIT[0] + vx * sp * k), y = Math.round(HIT[1] - 1 + vy * sp * k + 0.75 * k * k); const tx = Math.round(HIT[0] + vx * sp * (k - 0.45)), ty = Math.round(HIT[1] - 1 + vy * sp * (k - 0.45) + 0.75 * (k - 0.45) * (k - 0.45)); const head = k === 1 ? "g3" : k === 2 ? (i % 2 ? "g3" : "g2") : i % 2 ? "r3" : "r2"; const tail = k === 1 ? "g2" : k === 2 ? "r3" : "r1"; const put = (px, py, key) => { if (px >= 0 && py >= 0 && px < W && py < H) g.pixel(px, py, key); }; if (tx !== x || ty !== y) put(tx, ty, tail); put(x, y, head); }); });}// ---- sprite ------------------------------------------------------------------------------------------------function drawAnvil(g, heat, flash) { contactShadow(g, CX, GROUND, 16, 2); g.outlined("ink", () => stump(g)); g.outlined("ink", () => anvil(g, heat)); bar(g, heat, flash); g.outlined("ink", () => tongs(g));}const heatAt = (t) => 0.5 + 0.5 * Math.cos(TAU * t);const Anvil = sprite(pal, (g, t, mode) => { if (mode === "idle") { drawAnvil(g, heatAt(t), false); shimmer(g, t); embers(g, t); return; } const f = Math.min(N_STRIKE - 1, Math.round(t * N_STRIKE)); if (f === 0 || f === N_STRIKE - 1) { drawAnvil(g, heatAt(0), false); shimmer(g, 0); embers(g, 0); return; } // 1 raised · 2 falling · 3 impact · 4 rebound · 5 lifting · 6 gone const YB = [null, 3, 11, 16, 13, 5, null][f]; drawAnvil(g, 1, f === 3); if (f === 6) { shimmer(g, 0); } if (f === 2) smear(g, YB); if (f >= 4) sparks(g, f - 3); if (YB !== null) sledge(g, YB); if (f === 3) flashStar(g);});submit(Anvil, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"], animate: { fps: 10, duration: N_IDLE / 10, loop: true } }, { name: "strike", args: ["strike"], animate: { fps: 10, duration: N_STRIKE / 10, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。