Tavern & Market · barrel.js
エールの樽
32×32 の小物で、見下ろし 3/4 視点です。台の上に寝かせたエールの樽を 25 度回し、真鍮の注ぎ口が付いた右の鏡板が少しこちらを向いています。樽は小さな立体の模型から画素ごとに光線を進めて描いています。ふくらんだ 16 枚の側板、4 枚板のくぼんだ鏡板、4 本の鉄のたが、4 本脚に 2 本の横木の台です。idle(待機、12 コマのループ)は注ぎ口に雫がふくらみ、落ちて床で跳ねます。pour(8 コマの一回再生)は赤いつまみのレバーが倒れ、琥珀色の流れが床まで弧を描き、レバーが戻ります。
6×
動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。