Witch's Hut · mortar_table.js
乳鉢の作業台
48×48 の床置きの小物で、3 枚の板を脚を開いた台に載せた粗い作業台に、重い石の乳鉢と白っぽい木の乳棒、真鍮の皿に立てたろうそく、毒キノコが 2 本、紋字を書いて丸まった羊皮紙の切れ端、散らばったセージの葉とラベンダーの小枝が載っています。どれも右下へ短い影を板の上に落とします。idle(16 コマ)はろうそくが揺れ、乳鉢からかすかな湯気が上がります。grind(12 コマ)は乳棒がひとりでに鉢の中を回り、緑の粉が吹き出し、紋字が脈打ちます。brewed(8 コマの一回再生)は中身が光り、紫の煙が弾けて火花になります。
6×
動くスプライト素材: Witch's Hut(itch.io)Vol.5・ハロウィンの小物 12 点・42 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
mortar_table.js · 1–9
// Witch's mortar table — 48x48 floor prop, top-down 3/4, key light top-left.// A small rough worktable (three planks, a front apron, splayed legs on a stretcher) holding a heavy stone mortar// with a pale wooden pestle, a candle stub on a brass dish, a pair of toadstools, a curled parchment scrap with a// rune, and a few loose sage leaves and a lavender sprig. Objects cast short shadows down-right onto the planks.// Variants:// idle 16f loop: candle flickers, a faint steam curl rises from the mortar// grind 12f loop: the pestle grinds round the bowl by itself, green dust puffs from the mouth, the rune pulses// brewed 8f once : the paste flares, a violet puff bursts from the mortar, swells, breaks into sparks and settles;// the last frame is idle frame 0最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
mortar_table.js · 324–334
submit(MortarTable, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle" }, { name: "grind", args: ["grind"], animate: { fps: 10, duration: N_GRIND / 10, loop: true } }, { name: "brewed", args: ["brewed"], animate: { fps: 10, duration: N_BREW / 10, loop: false } }, ],});プログラム全体を表示 (334 行)
mortar_table.js · 334 行
// Witch's mortar table — 48x48 floor prop, top-down 3/4, key light top-left.// A small rough worktable (three planks, a front apron, splayed legs on a stretcher) holding a heavy stone mortar// with a pale wooden pestle, a candle stub on a brass dish, a pair of toadstools, a curled parchment scrap with a// rune, and a few loose sage leaves and a lavender sprig. Objects cast short shadows down-right onto the planks.// Variants:// idle 16f loop: candle flickers, a faint steam curl rises from the mortar// grind 12f loop: the pestle grinds round the bowl by itself, green dust puffs from the mouth, the rune pulses// brewed 8f once : the paste flares, a violet puff bursts from the mortar, swells, breaks into sparks and settles;// the last frame is idle frame 0const W = 48, H = 48, CX = 24, GROUND = 45;const MX = 15, RIM_Y = 18; // mortar axis and rim centre rowconst RIM = { rx: 6.5, ry: 2.6 }, MOUTH = { rx: 4.6, ry: 1.6 };const CANDLE_X = 36;const N_IDLE = 16, N_GRIND = 12, N_BREW = 8;const grainAt = (x, y) => hash(x, y * 3.1) > 0.82;// ---- table ------------------------------------------------------------------------------------------------function table(g) { g.part("tabletop", () => g.field({ x: [5, 42], y: [22, 31] }, (x, y) => { if ((x === 5 || x === 42) && y === 22) return null; // knocked-off corners if (x === 42 && y === 29) return null; // a chip out of the edge if (y === 25 || y === 28) return x % 9 === 3 ? "w2" : "w1"; // plank seams const u = (x - 5) / 37; if (y === 22) return u < 0.4 ? "w4" : u < 0.8 ? "w3" : "w2"; // lit back edge if (x === 5) return "w4"; if (x === 42) return "w2"; if (x === 14 && y === 30) return "w1"; // knot if ((x === 13 || x === 15) && y === 30) return "w2"; // grain dashes, fixed per plank const plank = y < 25 ? 0 : y < 28 ? 1 : 2; const gx = (x + plank * 7) % 13; if (y === [23, 26, 29][plank] && gx > 2 && gx < 7) return "w2"; if (y === [24, 27, 30][plank] && gx > 8 && gx < 11) return u < 0.5 ? "w2" : "w1"; return u < 0.7 ? "w3" : "w2"; })); g.part("apron", () => g.field({ x: [5, 42], y: [32, 35] }, (x, y) => { if (y === 32) return x < 14 ? "w3" : "w2"; if (y === 35) return x < 7 || x > 40 ? null : "w0"; return x === 20 && y === 33 ? "w0" : "w1"; })); g.part("legs", () => { // back legs (farther: higher feet, darker), then the stretcher, then the front legs for (const lx of [11, 35]) { g.rect({ x: [lx, lx + 1], y: [36, 41] }, "w0", { fill: "w0" }); g.pixel(lx, 36, "w1"); } g.rect({ x: [9, 38], y: [40, 41] }, "w1", { fill: "w1" }); g.line([9, 40], [24, 40], "w2"); for (const lx of [6, 39]) { g.rect({ x: [lx, lx + 2], y: [36, 44] }, "w2", { fill: "w2" }); g.line([lx, 36], [lx, 44], "w3"); g.line([lx + 2, 36], [lx + 2, 44], "w1"); g.pixel(lx + 1, 38, "w1"); // peg through the joint } });}// cast shadows on the planks (down-right of each object), darker wood onlyfunction tableShadows(g) { g.part("castShadows", () => g.field({ x: [6, 41], y: [23, 31] }, (x, y) => { const m = ((x + 0.5 - (MX + 4)) / 7) ** 2 + ((y + 0.5 - 29.2) / 1.6) ** 2 < 1; const ts = ((x + 0.5 - 41) / 2.5) ** 2 + ((y + 0.5 - 31) / 0.9) ** 2 < 1; const c = ((x + 0.5 - (CANDLE_X + 3)) / 4.5) ** 2 + ((y + 0.5 - 25.5) / 1.2) ** 2 < 1; const p = x >= 24 && x <= 33 && y === 31; if (!(m || c || p || ts)) return null; if (y === 25 || y === 28) return "w0"; return "w1"; }));}// ---- mortar & pestle -------------------------------------------------------------------------------------const halfW = (y) => { // bowl silhouette half-width per row if (y < RIM_Y) return 0; if (y >= 27) return y === 28 ? 4 : 3.6; // foot const k = (y - RIM_Y) / 9; return 6.5 * Math.sqrt(Math.max(0, 1 - (k * 0.8) ** 2));};function mortar(g, paste) { g.part("mortarBody", () => g.field({ x: [MX - 8, MX + 8], y: [RIM_Y, 28] }, (x, y) => { const dx = x + 0.5 - (MX + 0.5), hw = halfW(y); if (Math.abs(dx) > hw) return null; if (y >= 27) return dx < -2 ? "n2" : dx < 2 ? "n1" : "n0"; // foot, in the bowl's shadow let i = litIndex(x, y, MX + 0.5, RIM_Y + 1, 6.6, 8.5, 4, 0.05); if (i < 0) i = 0; if ((y === 21 && (x === MX - 3 || x === MX + 3)) || (y === 24 && x === MX + 1)) i = Math.max(0, i - 1); // chisel pocks if (y === RIM_Y + 2 && dx < -3 && dx > -5.5) return "n5"; // shoulder highlight return ["n1", "n2", "n3", "n4"][i]; })); g.part("mortarRim", () => g.field({ x: [MX - 7, MX + 7], y: [RIM_Y - 3, RIM_Y + 3] }, (x, y) => { const ox = (x + 0.5 - (MX + 0.5)) / RIM.rx, oy = (y + 0.5 - (RIM_Y + 0.5)) / RIM.ry; if (ox * ox + oy * oy > 1) return null; const ix = (x + 0.5 - (MX + 0.5)) / MOUTH.rx, iy = (y + 0.5 - (RIM_Y + 0.5)) / MOUTH.ry; if (ix * ix + iy * iy <= 1) { // the hollow: far wall in shadow, paste pooled in the near half if (iy < -0.2) return ix > 0.3 ? "n1" : "n0"; return paste(x, y, ix); } const a = -0.75 * ox - 0.45 * oy; if (a > 0.55 && oy < 0) return "n5"; // one hard glint on the lit lip return a > 0.1 ? "n4" : a > -0.45 ? "n3" : "n2"; }));}// rim front edge of the mouth: pestle pixels below it are hidden inside the bowlconst mouthFront = (x) => { const ix = (x + 0.5 - (MX + 0.5)) / MOUTH.rx; return Math.abs(ix) >= 1 ? RIM_Y - 1 : RIM_Y + 0.5 + MOUTH.ry * Math.sqrt(1 - ix * ix) - 0.5;};function pestle(g, a, lift) { // the pestle leans out of the bowl; its top traces an ellipse as it grinds (a = lean direction) const B = [MX + 0.5 + Math.cos(a) * 0.8, RIM_Y + 4 - lift]; const T = [MX + 0.5 + Math.cos(a) * 7, RIM_Y - 9 + Math.sin(a) * 2.5 - lift]; const len = Math.hypot(T[0] - B[0], T[1] - B[1]); const nx = -(T[1] - B[1]) / len, ny = (T[0] - B[0]) / len; // across-direction const lit = nx * -0.7 + ny * -0.7 > 0 ? 1 : -1; // which side faces the top-left light g.part("pestle", () => g.field({ x: [MX - 9, MX + 10], y: [RIM_Y - 13, RIM_Y + 3] }, (x, y) => { if (y > mouthFront(x)) return null; const px = x + 0.5 - B[0], py = y + 0.5 - B[1]; const k = (px * (T[0] - B[0]) + py * (T[1] - B[1])) / (len * len); const v = (px * nx + py * ny) * lit; // + = lit side const kx = B[0] + (T[0] - B[0]) * 0.94, ky = B[1] + (T[1] - B[1]) * 0.94; const knob = Math.hypot(x + 0.5 - kx, y + 0.5 - ky) <= 1.6; // a rounded knob at the handle end if (!knob && (k < 0 || k > 0.9 || Math.abs(v) > 1.05)) return null; return v > 0.4 ? "w4" : v > -0.5 ? "w3" : "w2"; }));}// ---- candle, toadstools, parchment, loose herbs -----------------------------------------------------------function candle(g) { g.part("dish", () => { g.ellipse({ x: [CANDLE_X - 4, CANDLE_X + 4], y: [21, 25] }, "g1", { fill: "g1" }); g.line([CANDLE_X - 3, 21], [CANDLE_X + 1, 21], "g3"); g.line([CANDLE_X - 4, 22], [CANDLE_X - 4, 23], "g2"); g.line([CANDLE_X - 2, 25], [CANDLE_X + 3, 25], "g0"); g.pixel(CANDLE_X + 4, 23, "g0"); g.line([CANDLE_X + 5, 22], [CANDLE_X + 6, 21], "g1"); g.pixel(CANDLE_X + 6, 20, "g2"); // finger loop }); g.part("candle", () => { g.rect({ x: [CANDLE_X - 2, CANDLE_X + 1], y: [15, 23] }, "n4", { fill: "n4" }); g.line([CANDLE_X - 2, 16], [CANDLE_X - 2, 23], "n5"); g.line([CANDLE_X + 1, 16], [CANDLE_X + 1, 23], "n3"); g.line([CANDLE_X - 2, 15], [CANDLE_X + 1, 15], "n5"); // melted top g.pixel(CANDLE_X + 1, 15, "n4"); g.pixel(CANDLE_X - 1, 16, "n3"); g.pixel(CANDLE_X, 16, "n3"); // the well round the wick g.line([CANDLE_X + 2, 16], [CANDLE_X + 2, 19], "n4"); // a long drip g.pixel(CANDLE_X + 2, 20, "n3"); g.pixel(CANDLE_X - 3, 22, "n5"); g.pixel(CANDLE_X - 3, 23, "n4"); // wax pooled on the dish });}function candleFlame(g, t) { g.part("wick", () => g.pixel(CANDLE_X - 1, 14, "n0")); g.part("flame", () => flame(g, CANDLE_X - 1, 13, 3, 6, t, 0.2, ["fl0", "fl1", "fl2", "fl3"]));}function toadstools(g) { g.part("toadstools", () => { // big one, front right g.rect({ x: [39, 40], y: [28, 31] }, "n4", { fill: "n4" }); g.pixel(39, 29, "n5"); g.pixel(40, 31, "n3"); g.ellipse({ x: [36, 42], y: [25, 28] }, "r1", { fill: "r1" }); g.line([37, 25], [39, 25], "r2"); g.line([37, 26], [38, 26], "r2"); g.pixel(37, 25, "r3"); g.line([37, 28], [41, 28], "r0"); // gills in shadow under the cap g.pixel(38, 26, "n5"); g.pixel(40, 25, "n5"); g.pixel(41, 27, "n4"); // warts // small one, leaning in from the left g.line([35, 29], [35, 31], "n4"); g.pixel(34, 31, "n5"); g.rect({ x: [33, 36], y: [28, 29] }, "r1", { fill: "r1" }); g.pixel(33, 28, "r3"); g.pixel(34, 28, "r2"); g.pixel(36, 29, "r0"); g.pixel(35, 28, "n5"); });}// parchment: a slightly skewed sheet with a curled back corner; the rune is drawn by runeKeyconst SHEET = [[21, 27], [29, 25], [31, 30], [23, 32]];function parchment(g) { g.part("parchment", () => g.field({ x: [21, 31], y: [25, 32] }, (x, y) => { const P = [x + 0.5, y + 0.5]; let inside = true; for (let i = 0; i < 4; i++) { const [ax, ay] = SHEET[i], [bx, by] = SHEET[(i + 1) % 4]; if ((bx - ax) * (P[1] - ay) - (by - ay) * (P[0] - ax) < 0) { inside = false; break; } } if (!inside) return null; if (x >= 28 && y <= 26) return "n3"; // curled corner, its underside if (x === 27 && y === 26) return "n4"; const u = (x - 21) / 10 + (y - 25) / 7; return u > 1.2 ? "n4" : "n5"; }));}const RUNE = [[24, 27], [26, 27], [28, 27], [25, 28], [26, 28], [27, 28], [26, 29], [25, 30], [26, 30]];function rune(g, key) { g.part("rune", () => { for (const [x, y] of RUNE) g.pixel(x, y, key); }); }function looseHerbs(g) { g.part("looseHerbs", () => { // a sage sprig (leaves either side of a stem) and a lavender sprig on the front plank, with their shadows g.line([7, 30], [11, 28], "w1"); g.pixel(8, 30, "e3"); g.pixel(9, 30, "e2"); g.pixel(9, 29, "e3"); g.pixel(10, 29, "e2"); g.pixel(11, 28, "e2"); g.pixel(10, 28, "e3"); g.pixel(8, 29, "e2"); g.pixel(11, 29, "e1"); g.pixel(12, 28, "e1"); g.pixel(10, 30, "e1"); g.line([6, 26], [9, 24], "e1"); g.pixel(7, 25, "v2"); g.pixel(8, 24, "v3"); g.pixel(9, 23, "v2"); g.pixel(8, 25, "v1"); g.pixel(12, 31, "e2"); g.pixel(19, 30, "e2"); g.pixel(21, 31, "e1"); // crushed bits });}// ---- effects ----------------------------------------------------------------------------------------------function steam(g, t) { g.detached("steam", () => { for (let i = 0; i < 2; i++) { const p = (t + i / 2) % 1; const x = MX - 1 + i * 2 + Math.round(Math.sin(TAU * (p * 0.9 + i * 0.35)) * 1.5); const y = RIM_Y - 2 - Math.round(p * 11); if (p < 0.4) { g.pixel(x, y, "n4"); g.pixel(x + 1, y - 1, "n3"); } else if (p < 0.75) { if (chk(x, y)) g.pixel(x, y, "n3"); g.pixel(x + 1, y - 1, "n3"); } else if (chk(x, Math.floor(t * 16))) g.pixel(x, y, "n2"); } });}function dustPuffs(g, t, a) { // a puff is kicked out of the mouth on the side the pestle is sweeping past, twice a revolution g.detached("dust", () => { for (let i = 0; i < 4; i++) { const p = (t * 2 + i / 4) % 1; const side = i % 2 ? 1 : -1; const x = Math.round(MX + 0.5 + side * (3 + p * 6)); const y = Math.round(RIM_Y - 1 - p * 8 + p * p * 5); if (p < 0.3) { g.pixel(x, y, "e3"); g.pixel(x + side, y, "e2"); g.pixel(x, y - 1, "e2"); g.pixel(x + side, y - 1, "e3"); } else if (p < 0.6) { g.pixel(x, y, "e2"); g.pixel(x + side, y - 1, "e2"); if (chk(x, y)) g.pixel(x - side, y - 1, "e1"); } else if (p < 0.85) { if (chk(x, y + i)) g.pixel(x, y, "e1"); g.pixel(x + side, y - 1, "e1"); } } });}function runeGlow(g, t, k) { // at the peak of each pulse two motes lift off the rune g.detached("runeMotes", () => { const p = (t * 2) % 1; if (p > 0.45) return; const y = 26 - Math.round(p * 10); g.pixel(26, y, p < 0.2 ? "e3" : "e2"); g.pixel(28, y + 2, p < 0.25 ? "e2" : "e1"); });}// violet puff for the brewed one-shot: frame f in 1..6function puff(g, f) { g.detached("puff", () => { if (f === 1) { // a bright jet out of the mouth for (let y = RIM_Y - 7; y <= RIM_Y; y++) { const w = y > RIM_Y - 3 ? 1 : 0; for (let x = MX - w; x <= MX + 1 + w; x++) g.pixel(x, y, y < RIM_Y - 5 ? "v2" : x === MX || x === MX + 1 ? "v3" : "v2"); } for (const [x, y, k] of [[MX - 3, RIM_Y - 2, "v3"], [MX + 4, RIM_Y - 3, "v3"], [MX - 2, RIM_Y - 5, "v2"], [MX + 3, RIM_Y - 6, "v2"], [MX - 4, RIM_Y, "v1"], [MX + 5, RIM_Y - 1, "v1"], [MX, RIM_Y - 9, "v1"]]) g.pixel(x, y, k); return; } const R = [0, 0, 3.5, 5, 6, 6.5, 6.5][f], cy = [0, 0, RIM_Y - 5, RIM_Y - 7, RIM_Y - 8.5, RIM_Y - 9.5, RIM_Y - 10][f]; const fill = [0, 0, 1, 1, 0.97, 0.62, 0][f]; // cloud density (thins as it breaks up) // billows, back to front: [dx, dy, r] in units of R const BIL = [[0, -0.75, 0.62], [-0.8, -0.05, 0.55], [0.85, 0.05, 0.55], [0.05, 0.35, 0.62]]; if (fill > 0) g.field({ x: [MX - 10, MX + 11], y: [0, RIM_Y] }, (x, y) => { let best = -1, dmin = 9; for (const [bx, by, br] of BIL) { const cx = MX + 0.5 + bx * R * (1 + (f - 2) * 0.08), cyy = cy + by * R, r = br * R; const i = litIndex(x, y, cx, cyy, r, r * 0.9, 4, 0.15); if (i < 0) continue; if (best < 0 || i > best) best = i; dmin = Math.min(dmin, Math.hypot(x + 0.5 - cx, (y + 0.5 - cyy) / 0.9) / r); } if (best < 0) return null; const key = ["v1", "v2", "v2", "v3"][best]; if (fill < 1 && (dmin > fill || (dmin > fill - 0.3 && !chk(x, y)))) return null; // billows shrink to wisps return key; }); // sparks thrown out from the cloud for (let s = 0; s < 7; s++) { const a = (s / 7) * TAU + 0.4; const d = R + 1 + (f - 2) * 1.6; const x = Math.round(MX + 0.5 + Math.cos(a) * d * 1.1); const y = Math.round(cy + Math.sin(a) * d * 0.8 + (f >= 5 ? (f - 4) * 2 : 0)); if (y < 0 || y > GROUND) continue; g.pixel(x, y, f >= 5 ? (s % 2 ? "v2" : "v1") : s % 2 ? "v3" : "v2"); if (f <= 4) g.pixel(x, y + 1, "v1"); } });}// ---- assembly ---------------------------------------------------------------------------------------------const MortarTable = sprite(pal, (g, t, mode) => { t = ((t % 1) + 1) % 1; let ti = t, a = -0.6, lift = 0, runeK = "w1", glowK = 0, f = -1; if (mode === "grind") { a = -0.6 + TAU * t; // one revolution per loop glowK = 0.5 + 0.5 * Math.cos(TAU * 2 * t); runeK = glowK > 0.8 ? "e3" : glowK > 0.45 ? "e2" : glowK > 0.15 ? "e1" : "w1"; } else if (mode === "brewed") { f = Math.min(N_BREW - 1, Math.round(t * N_BREW)); ti = (((f - (N_BREW - 1)) / N_IDLE) % 1 + 1) % 1; // hands back to idle frame 0 lift = f === 1 ? 2 : f === 2 ? 1 : 0; // the pestle jumps in the blast } // paste in the bowl: green, flaring violet in the brewed blast const flare = f >= 0 && f <= 3; const paste = (x, y, ix) => { if (flare) return f === 1 ? "v3" : ix < 0 ? "v2" : "v1"; if (mode === "grind" && hash(x, Math.floor(t * N_GRIND)) > 0.7) return "e3"; return ix < -0.2 ? "e2" : "e1"; }; contactShadow(g, CX, GROUND, 20, 2); g.outlined("ink", () => table(g)); tableShadows(g); g.outlined("ink", () => { looseHerbs(g); parchment(g); candle(g); toadstools(g); mortar(g, paste); pestle(g, a, lift); }); rune(g, runeK); candleFlame(g, ti); if (mode === "grind") { dustPuffs(g, t, a); runeGlow(g, t, glowK); } if (mode === "idle" || (mode === "brewed" && (f === 0 || f >= 6))) steam(g, ti); if (mode === "brewed" && f >= 1 && f <= 6) puff(g, f);});submit(MortarTable, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle" }, { name: "grind", args: ["grind"], animate: { fps: 10, duration: N_GRIND / 10, loop: true } }, { name: "brewed", args: ["brewed"], animate: { fps: 10, duration: N_BREW / 10, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。