Tavern & Market · grain_sack.js
穀物袋
32×32 の小物で、見下ろし 3/4 視点です。床にくたりと置いた麻袋で、口を紐で縛り、その上の布がひだになって広がっています。しわ、織り目、織り込んだ 2 本の青い縞、右下のほつれた穴があり、足元にはこぼれた小麦が小さく積もっています。重なる層ごとに輪郭線が付くように、毎コマを一つのバッファに一層ずつ重ねています。idle(待機、16 コマのループ)は 1 ループに一度、穴から粒が一つこぼれて山に落ちます。mouse(11 コマの一回再生)は袋の後ろからネズミが顔を出し、2 回においを嗅ぎ、耳を動かして引っ込みます。
6×
動くスプライト素材: Tavern & Market(itch.io)Vol.3・酒場と市場の小物 12 点・32 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
grain_sack.js · 1–11
// Grain sack — 32x32, top-down 3/4, key light top-left.// A burlap sack slumped on the floor, its neck tied with twine, the gathered cloth flaring above the tie in pleats.// The body is shaded from a pseudo-normal (profile half-width per row -> across and up components), then gets// countable marks: folds pulled down from the tie (dark crease + lit lip), a staggered weave stitch every 3 rows,// two woven blue stripes, a frayed hole low on the right. Spilled wheat lies in a little heap at its foot.// Every frame is composited into one keyed buffer here, layer by layer, so a layer that overlaps an earlier one gets// its own ink seam (outlined() only inks empty pixels).// Variants:// idle loop 16f — the sack is still; once a loop a grain slips out of the hole and drops onto the heap// mouse one-shot 11f — a mouse pokes its head out from behind the sack, sniffs twice, flicks an ear and ducks// back; ends on idle f0最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
grain_sack.js · 244–251
submit(Sack, { 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: "mouse", args: ["mouse"], animate: { fps: 10, duration: N_MOUSE / 10, loop: false } }, ],});プログラム全体を表示 (251 行)
grain_sack.js · 251 行
// Grain sack — 32x32, top-down 3/4, key light top-left.// A burlap sack slumped on the floor, its neck tied with twine, the gathered cloth flaring above the tie in pleats.// The body is shaded from a pseudo-normal (profile half-width per row -> across and up components), then gets// countable marks: folds pulled down from the tie (dark crease + lit lip), a staggered weave stitch every 3 rows,// two woven blue stripes, a frayed hole low on the right. Spilled wheat lies in a little heap at its foot.// Every frame is composited into one keyed buffer here, layer by layer, so a layer that overlaps an earlier one gets// its own ink seam (outlined() only inks empty pixels).// Variants:// idle loop 16f — the sack is still; once a loop a grain slips out of the hole and drops onto the heap// mouse one-shot 11f — a mouse pokes its head out from behind the sack, sniffs twice, flicks an ear and ducks// back; ends on idle f0const W = 32, H = 32, CX = 16, GROUND = 29;const N_IDLE = 16, N_MOUSE = 11;const BURLAP = ["w1", "w2", "w3", "w4"];// ---- sack shape --------------------------------------------------------------------------------------------------const NECK_Y = 9, BOT_Y = 28;const PROF = [[9, 3.6], [10, 5.6], [11, 7.0], [13, 8.0], [17, 8.3], [22, 8.7], [26, 9.2], [28, 9.1]];function hwAt(y) { for (let k = 1; k < PROF.length; k++) { const [y0, r0] = PROF[k - 1], [y1, r1] = PROF[k]; if (y <= y1) { const u = (y - y0) / (y1 - y0); return r0 + (r1 - r0) * u; } } return PROF[PROF.length - 1][1];}const cxAt = (y) => CX + (y < 16 ? 0.6 * (16 - y) / 7 : 0); // the neck leans a little to the right// the base: an ellipse footprint, so the bottom edge is a curveconst bottomAt = (x) => { const u = (x + 0.5 - CX) / 9.6; return u * u >= 1 ? -1 : Math.floor(25.8 + 3.0 * Math.sqrt(1 - u * u)); };// folds pulled down from the tie: [x offset at the neck, x offset at the end, end row]const FOLDS = [[-2.4, -6.2, 16], [-0.6, -2.4, 19], [1.3, 2.4, 17], [2.8, 6.4, 15]];function sackPixels() { const m = new Map(); const tone = new Map(); for (let y = NECK_Y; y <= BOT_Y; y++) { const hw = hwAt(y + 0.5), cx = cxAt(y); const slope = (hwAt(y + 1.5) - hwAt(y - 0.5)) / 2; // >0 on the shoulders: faces up for (let x = 0; x < W; x++) { const nx = (x + 0.5 - cx) / hw; if (Math.abs(nx) > 1) continue; const bot = bottomAt(x); if (bot < 0 || y > bot) continue; const nz = Math.sqrt(Math.max(0, 1 - nx * nx)); const up = Math.max(-0.6, Math.min(0.8, slope * 0.55)) - (y > 24 ? (y - 24) * 0.12 : 0); const d = -0.6 * nx + 0.55 * up + 0.62 * nz; let lv = d > 0.78 ? 3 : d > 0.45 ? 2 : d > 0.05 ? 1 : 0; if (y === bot && lv > 0) lv = Math.max(0, lv - 1); // the base rolls under tone.set(y * W + x, lv); } } // folds: dark crease with a lit lip on its left for (const [a, b, yEnd] of FOLDS) { for (let y = NECK_Y + 1; y <= yEnd; y++) { const s = (y - NECK_Y) / (yEnd - NECK_Y), e = Math.pow(s, 0.7); const x = Math.round(cxAt(y) + a + (b - a) * e); const i = y * W + x; if (!tone.has(i)) continue; if (s > 0.85 && (y & 1)) continue; // the crease thins out at its end tone.set(i, Math.max(0, tone.get(i) - 1)); if (tone.has(i - 1) && s < 0.8) tone.set(i - 1, Math.min(3, tone.get(i - 1) + 1)); } } for (const [i, lv] of tone) { const x = i % W, y = (i - x) / W; let k = lv; // weave stitch: a staggered dotted weft every third row on the lit and mid cloth (countable, never random) const row = y - NECK_Y - 2; if (row >= 0 && row % 3 === 0 && k >= 2) { const off = (row / 3) % 2 ? 2 : 0; if ((x + off) % 4 === 0) k -= 1; } m.set(i, BURLAP[k]); } // two woven stripes down the front (a miller's sack): blue on the lit cloth, deep blue where it turns away // they start under the shoulder and bow outward with the bulge of the sack for (let y = NECK_Y + 3; y <= BOT_Y; y++) { const cx = cxAt(y); for (const off of [-4.2, -2.2]) { const x = Math.round(cx + off * (hwAt(y + 0.5) / 8.4) - 0.5), i = y * W + x; if (!m.has(i) || y > bottomAt(x)) continue; const lv = tone.get(i); m.set(i, lv >= 2 ? "b1" : "b0"); } } // frayed hole low on the right: dark mouth, a pale torn lip above, a loose thread below m.set(HOLE_Y * W + HOLE_X, "w0"); m.set(HOLE_Y * W + HOLE_X + 1, "w0"); m.set((HOLE_Y - 1) * W + HOLE_X, "w4"); m.set((HOLE_Y - 1) * W + HOLE_X + 1, "w3"); m.set((HOLE_Y + 1) * W + HOLE_X + 1, "w3"); return m;}const HOLE_X = 21, HOLE_Y = 20;// gathered cloth above the tie: pleats 2px wide, alternately lit / shaded, a jagged top edgefunction ruffle() { const m = new Map(); const rows = [[2, 3.8], [3, 4.2], [4, 4.6], [5, 4.2], [6, 3.4]]; for (const [y, hw] of rows) { const cx = cxAt(y) + 0.3; for (let x = Math.floor(cx - hw); x <= Math.ceil(cx + hw - 1); x++) { const nx = (x + 0.5 - cx) / hw; if (Math.abs(nx) > 1.05) continue; const p = Math.floor((x - 9) / 2), left = (x - 9) % 2 === 0; const top = 1 + (p % 2); // pleat tips at alternating heights if (y < top + 1) continue; let lv = left ? 3 : 2; if (nx > 0.35) lv -= 1; // the right side turns from the light if (nx < -0.6 && !left) lv += 1; if (y === top + 1 && left) lv = Math.min(3, lv + 1); if (y >= 6) lv = Math.max(0, lv - 1); // pinched into the tie's shadow m.set(y * W + x, BURLAP[Math.max(0, Math.min(3, lv))]); } } return m;}// twine: two turns around the neck, a knot on the front right, two loose ends hanging over the clothfunction twine() { const m = new Map(); const cx = Math.round(cxAt(7)); for (let x = cx - 3; x <= cx + 3; x++) { m.set(7 * W + x, x < cx - 1 ? "n5" : x < cx + 2 ? "n4" : "n3"); m.set(8 * W + x, x < cx ? "n3" : "n2"); } m.set(7 * W + cx + 1, "n5"); m.set(8 * W + cx + 1, "n4"); // knot m.set(9 * W + cx + 1, "n4"); m.set(10 * W + cx + 1, "n3"); m.set(11 * W + cx + 1, "n2"); m.set(9 * W + cx + 2, "n3"); m.set(10 * W + cx + 3, "n2"); return m;}// spilled heap at the sack's foot (front right), grains as a regular stagger with a lit top-leftfunction heap() { // golden wheat: a mound with a jagged crest of single grains (every other one missing), lit on the left, a regular // sparse fleck of husks and shaded grains inside, the foot in shade const m = new Map(); const rows = [[25, 23, 24], [26, 21, 26], [27, 20, 27], [28, 20, 27], [29, 21, 26]]; const topOf = (x) => rows.find((r) => x >= r[1] && x <= r[2])[0]; for (const [y, x0, x1] of rows) for (let x = x0; x <= x1; x++) { const depth = y - topOf(x); if (depth === 0 && y > 25 && (x & 1)) continue; let k = depth === 0 ? (x < 24 ? "g3" : "g2") : "g2"; if (y >= 28 || (x === x1 && depth > 0)) k = "g1"; if (y === 29) k = x < 23 ? "g1" : "g0"; if (depth > 0 && y < 29 && (x * 2 + y * 3) % 5 === 0) k = y >= 28 ? "g0" : "w4"; else if (depth > 0 && y < 28 && (x * 2 + y * 3) % 5 === 2) k = "g1"; m.set(y * W + x, k); } return m;}const LOOSE = [[9, 30], [28, 30], [29, 27], [19, 30], [22, 24]];// mouse, facing left; (0,0) is the tip of the noseconst MOUSE = [ [3, -3, "n3"], [4, -3, "n2"], [3, -2, "v3"], [4, -2, "n3"], [5, -2, "n2"], [1, -1, "n3"], [2, -1, "n4"], [3, -1, "ink"], [4, -1, "n3"], [5, -1, "n2"], [6, -1, "n2"], [7, -1, "n2"], [0, 0, "v3"], [1, 0, "n4"], [2, 0, "n4"], [3, 0, "n3"], [4, 0, "n3"], [5, 0, "n2"], [6, 0, "n2"], [7, 0, "n1"], [1, 1, "n2"], [2, 1, "n3"], [3, 1, "n3"], [4, 1, "n2"], [5, 1, "n2"], [6, 1, "n1"], [7, 1, "n1"], [2, 2, "n4"], [3, 2, "n3"], [5, 2, "n1"], [6, 2, "n1"], [7, 2, "n1"],];function mouseMap(nx, ny, earDown) { const m = new Map(); for (const [dx, dy, k] of MOUSE) { if (earDown && dy <= -2) continue; const x = nx + dx, y = ny + dy; if (x >= 0 && x < W && y >= 0 && y < H) m.set(y * W + x, k); } if (earDown) { m.set((ny - 2) * W + nx + 4, "n3"); m.set((ny - 2) * W + nx + 5, "n2"); } // ear laid back return m;}// ---- compositor -------------------------------------------------------------------------------------------------function compose(layers) { const key = new Array(W * H).fill(null), part = new Array(W * H).fill(null); for (const { name, map, seam } of layers) { for (const [i, k] of map) { key[i] = k; part[i] = name; } if (!seam) continue; for (const i of map.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 j = ny * W + nx; if (!map.has(j) && key[j] !== null && part[j] !== name) { key[j] = "ink"; part[j] = name; } } } } return { key, part };}function emit(g, comp) { const names = []; for (const p of comp.part) if (p && !names.includes(p)) names.push(p); g.outlined("ink", () => { for (const n of names) g.part(n, () => g.field({ x: [0, W - 1], y: [0, H - 1] }, (x, y) => { const i = y * W + x; return comp.part[i] === n ? comp.key[i] : null; })); });}const CACHE = {};const SACK = () => CACHE.sack || (CACHE.sack = sackPixels());const RUF = () => CACHE.ruf || (CACHE.ruf = ruffle());const TWN = () => CACHE.twn || (CACHE.twn = twine());const HEAP = () => CACHE.heap || (CACHE.heap = heap());function scene(g, mouse) { const layers = []; if (mouse) layers.push({ name: "mouse", map: mouse, seam: false }); layers.push({ name: "ruffle", map: RUF(), seam: false }); layers.push({ name: "sack", map: SACK(), seam: true }); layers.push({ name: "twine", map: TWN(), seam: false }); layers.push({ name: "heap", map: HEAP(), seam: true }); emit(g, compose(layers)); g.detached("loose", () => { LOOSE.forEach(([x, y], k) => g.pixel(x, y, k % 2 ? "g1" : "g2")); });}const Sack = sprite(pal, (g, t, mode) => { contactShadow(g, CX, GROUND - 1, 11, 2); if (mode === "idle") { const f = Math.round(t * N_IDLE) % N_IDLE; scene(g, null); // one grain per loop: peeks from the hole, drops, lands on the heap and rolls into it const path = { 7: [HOLE_X + 1, HOLE_Y], 8: [HOLE_X + 1, HOLE_Y + 1], 9: [HOLE_X + 1, HOLE_Y + 3], 10: [HOLE_X + 1, HOLE_Y + 4], 11: [HOLE_X + 2, HOLE_Y + 5] }; if (path[f]) g.detached("grain", () => { const [x, y] = path[f]; g.pixel(x, y, f === 7 ? "g2" : "g3"); if (f >= 8 && f <= 9) g.pixel(x, y - 1, "g1"); }); return; } const f = Math.min(N_MOUSE - 1, Math.round(t * N_MOUSE)); const NOSE = [null, 5, 3, 1, 1, 1, 1, 1, 1, 4, null][f]; const BOB = [0, 0, 0, 0, -1, 0, -1, 0, 0, 0, 0][f]; const earDown = f === 7 || f === 9; scene(g, NOSE === null ? null : mouseMap(NOSE, 25 + BOB, earDown)); if (NOSE !== null && f >= 3 && f <= 8) { // whiskers: two pale hairs off the snout, fanned wider on the sniffs const y = 25 + BOB, wide = BOB !== 0; g.detached("whiskers", () => { g.pixel(NOSE - 1, y - 1 - (wide ? 1 : 0), "n4"); g.pixel(NOSE - 1, y + 1 + (wide ? 1 : 0), "n4"); if (wide && NOSE >= 2) g.pixel(NOSE - 2, y - 2, "n3"); }); }});submit(Sack, { 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: "mouse", args: ["mouse"], animate: { fps: 10, duration: N_MOUSE / 10, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。