Harvest Festival · festival_lanterns.js
祭りの提灯
2 本の木柱の間に渡したロープに、4 つの提灯と 5 枚の小旗を吊るした 64×48 の装飾小物です。16 コマの idle(待機)では光の輪を伴って蝋燭の火が揺らめき、12 コマの windy では左からの風を受けて提灯と旗が大きく揺れ動きます。16 コマの unlit(消灯)では明かりが消えた昼間の提灯が穏やかに揺れます。
6×
動くスプライト素材: Harvest Festival(itch.io)Vol.6・収穫祭の小物 12 点・37 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
festival_lanterns.js · 1–12
// Festival lanterns — 64x48 floor prop, top-down 3/4, key light top-left.// Two wooden posts (pointed tops, a sprig of wheat tucked into each rope knot, a little mound of earth and grass at// the foot) carry a sagging rope strung with four round paper lanterns — orange, red, violet and gold, each with a// dark cap, ribbed paper, a bottom ring and a tassel — and five small cloth bunting flags (green, cream, blue)// hung between them. Lantern glows are detached dithered halos; posts have contact shadows.// Variants:// idle 16f loop: the candles flicker (each lantern on its own phase), the halos breathe, the lanterns sway on// their cords with the tassels trailing, the rope bobs, the flags flutter// windy 12f loop: a steady breeze from the left — the lanterns swing hard to the right and back, the tassels// whip, the rope bellies, the flags snap out sideways// unlit 16f loop: daytime — the paper is unlit (lit from the top-left like any other surface), gentle sway,// flags flutter最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
festival_lanterns.js · 187–197
submit(Lanterns, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: 32, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "windy", args: ["windy"], animate: { fps: 10, duration: N_WIND / 10, loop: true } }, { name: "unlit", args: ["unlit"] }, ],});プログラム全体を表示 (197 行)
festival_lanterns.js · 197 行
// Festival lanterns — 64x48 floor prop, top-down 3/4, key light top-left.// Two wooden posts (pointed tops, a sprig of wheat tucked into each rope knot, a little mound of earth and grass at// the foot) carry a sagging rope strung with four round paper lanterns — orange, red, violet and gold, each with a// dark cap, ribbed paper, a bottom ring and a tassel — and five small cloth bunting flags (green, cream, blue)// hung between them. Lantern glows are detached dithered halos; posts have contact shadows.// Variants:// idle 16f loop: the candles flicker (each lantern on its own phase), the halos breathe, the lanterns sway on// their cords with the tassels trailing, the rope bobs, the flags flutter// windy 12f loop: a steady breeze from the left — the lanterns swing hard to the right and back, the tassels// whip, the rope bellies, the flags snap out sideways// unlit 16f loop: daytime — the paper is unlit (lit from the top-left like any other surface), gentle sway,// flags flutterconst W = 64, H = 48, GROUND = 45;const N_IDLE = 16, N_WIND = 12;const POSTS = [5, 58];const ROPE = { x0: 7, x1: 56, y: 9 };const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];const dith = (x, y, dens) => BAYER[(y & 3) * 4 + (x & 3)] / 16 < dens;// [x on the rope, cord length, colour ramps, sway phase, flicker phase]// lit: [edge, mid, bright, core] unlit: [shadow, base, light, highlight]const LANTERNS = [ { x: 13, cord: 3, lit: ["r1", "r2", "r3", "g3"], day: ["r0", "r1", "r2", "r3"], ph: 0.0, fph: 0.13 }, { x: 25, cord: 4, lit: ["r0", "r1", "r2", "r3"], day: ["r0", "r0", "r1", "r2"], ph: 0.27, fph: 0.61 }, { x: 38, cord: 4, lit: ["v1", "v2", "v3", "n5"], day: ["v0", "v1", "v2", "v3"], ph: 0.55, fph: 0.37 }, { x: 49, cord: 3, lit: ["g1", "g2", "g3", "n5"], day: ["g0", "g1", "g2", "g3"], ph: 0.8, fph: 0.85 },];const FLAGS = [ { x: 9, k: ["e2", "e1"], ph: 0.1 }, { x: 19, k: ["n5", "n4"], ph: 0.42 }, { x: 31, k: ["b2", "b1"], ph: 0.74 }, { x: 44, k: ["n5", "n4"], ph: 0.2 }, { x: 54, k: ["e2", "e1"], ph: 0.57 },];// ---- rope ------------------------------------------------------------------------------------------------------function ropeY(x, sag) { const u = (x - (ROPE.x0 + ROPE.x1) / 2) / ((ROPE.x1 - ROPE.x0) / 2); return ROPE.y + sag * (1 - u * u);}function rope(g, sag) { g.part("rope", () => { for (let x = ROPE.x0; x <= ROPE.x1; x++) { const y = Math.round(ropeY(x, sag)); g.pixel(x, y, x % 3 === 0 ? "w2" : "w3"); // twisted strands const y2 = Math.round(ropeY(x + 1, sag)); if (x < ROPE.x1 && Math.abs(y2 - y) > 1) g.line([x, y], [x, y2 + (y2 > y ? -1 : 1)], "w2"); // keep it unbroken } });}// ---- posts -----------------------------------------------------------------------------------------------------function post(g, cx, lit) { g.outlined("ink", () => g.part(lit ? "postL" : "postR", () => { for (let y = 6; y <= 44; y++) { g.pixel(cx - 1, y, "w2"); g.pixel(cx, y, "w1"); g.pixel(cx + 1, y, "w0"); } g.pixel(cx, 4, "w2"); g.pixel(cx - 1, 5, "w3"); g.pixel(cx, 5, "w2"); g.pixel(cx + 1, 5, "w1"); // pointed top for (const y of [13, 14, 27, 28, 29, 38]) g.pixel(cx - 1, y, "w3"); // lit streaks g.pixel(cx, 21, "w0"); g.pixel(cx, 33, "w0"); g.pixel(cx + 1, 17, "ink"); // knots, a split }));}function knot(g, cx, side) { g.part(side < 0 ? "knotL" : "knotR", () => { g.line([cx - 1, 8], [cx + 1, 8], "w4"); g.line([cx - 1, 10], [cx + 1, 10], "w3"); // rope wraps g.pixel(cx + 1, 9, "w3"); g.pixel(cx - 1, 9, "w2"); g.line([cx - side, 11], [cx - side, 13], "w2"); // loose end }); g.part(side < 0 ? "wheatL" : "wheatR", () => { // a sprig of wheat tucked into the knot const d = -side; g.line([cx, 9], [cx + d * 3, 4], "g0"); g.pixel(cx + d * 3, 4, "g2"); g.pixel(cx + d * 3, 3, "g3"); g.pixel(cx + d * 2, 5, "g1"); g.pixel(cx + d * 4, 5, "g2"); g.pixel(cx + d * 4, 4, "g1"); });}function footing(g, cx, lit) { g.outlined("ink", () => g.part(lit ? "moundL" : "moundR", () => g.field({ x: [cx - 4, cx + 4], y: [42, 45] }, (x, y) => { const nx = (x + 0.5 - (cx + 0.5)) / 4.2, ny = (y + 0.5 - 45) / 2.6; if (nx * nx + ny * ny > 1 || y > 45) return null; return -0.8 * nx - 0.6 * ny > 0.45 ? "w2" : nx < 0.3 ? "w1" : "w0"; }))); g.part(lit ? "grassL" : "grassR", () => { for (const [dx, h, k] of [[-3, 3, "e2"], [-2, 4, "e1"], [2, 3, "e1"], [3, 2, "e2"]]) { g.line([cx + dx, 44], [cx + dx + (dx < 0 ? -1 : 1), 44 - h], k); g.pixel(cx + dx + (dx < 0 ? -1 : 1), 44 - h, k === "e2" ? "e3" : "e2"); } });}// ---- flags -----------------------------------------------------------------------------------------------------// a cloth triangle hung from the rope: top edge follows the rope, the tip flutters (and snaps out in the wind)function flag(g, f, sag, t, wind) { const a = Math.sin(TAU * (2 * t + f.ph)), b = Math.sin(TAU * (3 * t + f.ph * 1.7)); const tipDx = wind ? Math.round(2.2 + 1.3 * a) : Math.round(0.9 * a); const tipH = wind ? 5 - Math.round(1 + 0.8 * (0.5 + 0.5 * b)) : 5; const yl = ropeY(f.x - 2, sag), yr = ropeY(f.x + 2, sag), yc = ropeY(f.x, sag); const P = [[f.x - 2.5, yl + 0.5], [f.x + 2.5, yr + 0.5], [f.x + tipDx, yc + tipH + 0.5]]; const side = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax); g.outlined("ink", () => g.part("flag", () => g.field({ x: [f.x - 4, f.x + 5], y: [Math.floor(Math.min(yl, yr)), Math.ceil(yc + tipH + 1)] }, (x, y) => { const px = x + 0.5, py = y + 0.5; const s1 = side(...P[0], ...P[1], px, py), s2 = side(...P[1], ...P[2], px, py), s3 = side(...P[2], ...P[0], px, py); if (!((s1 >= 0 && s2 >= 0 && s3 >= 0) || (s1 <= 0 && s2 <= 0 && s3 <= 0))) return null; const mid = f.x + (tipDx * (py - yc) / tipH) * 0.6; // the fold runs from the top centre to the tip return px < mid + 0.2 ? f.k[0] : f.k[1]; })));}// ---- lanterns --------------------------------------------------------------------------------------------------const BODY_RX = 4.2, BODY_RY = 4.1;function lanternGeom(L, sag, swing) { const ax = L.x, ay = Math.round(ropeY(L.x, sag)); const len = L.cord + 5; const off = Math.round(len * Math.sin(swing)); const top = ay + L.cord; // cap row return { ax, ay, cx: L.x + off, top, cy: top + 6, off };}function halo(g, L, G, amt) { g.detached("halo", () => g.field({ x: [G.cx - 9, G.cx + 9], y: [G.cy - 9, G.cy + 9] }, (x, y) => { const r = Math.hypot(x + 0.5 - (G.cx + 0.5), (y + 0.5 - G.cy) * 1.1); const R = 7.5 * amt; if (r > R || r < 4.6) return null; return dith(x, y, 0.34 * (1 - (r - 4.6) / (R - 4.6))) ? L.lit[1] : null; }));}function lantern(g, i, L, G, lit, flick, tassel) { const { cx, cy, top } = G; g.part("cord" + i, () => g.line([G.ax, G.ay + 1], [cx, top - 1], "n1")); g.outlined("ink", () => g.part("lantern" + i, () => { g.line([cx - 1, top], [cx + 1, top], "n1"); g.pixel(cx - 1, top, "n3"); // cap g.line([cx - 2, top + 1], [cx + 2, top + 1], "n0"); g.pixel(cx - 2, top + 1, "n2"); g.field({ x: [cx - 5, cx + 5], y: [top + 2, top + 9] }, (x, y) => { const nx = (x + 0.5 - (cx + 0.5)) / BODY_RX, ny = (y + 0.5 - cy) / BODY_RY; if (nx * nx + ny * ny > 1) return null; const rib = y === cy - 2 || y === cy + 1; if (lit) { // light from the candle near the bottom: brightest low in the middle, falling off to the rim const d = Math.hypot(nx, (ny - 0.25) * 1.1) / flick; let k = d > 0.85 ? 0 : d > 0.6 ? 1 : d > 0.3 ? 2 : 3; if (rib && k < 3) k = Math.max(0, k - 1); // ribs show against the paper, not the flame return L.lit[k]; } let k = litIndex(x, y, cx + 0.5, cy, BODY_RX, BODY_RY, 4, 0.1); if (k === 3 && !(nx < -0.2 && ny < -0.15)) k = 2; // keep the highlight a small top-left patch if (rib) k = Math.max(0, k - 1); return L.day[k]; }); g.line([cx - 2, top + 10], [cx + 2, top + 10], "n0"); g.pixel(cx - 2, top + 10, "n2"); // bottom ring })); g.part("tassel" + i, () => { const tb = Math.round(tassel); g.pixel(cx, top + 11, "n1"); g.line([cx, top + 12], [cx + tb, top + 14], lit ? L.lit[1] : L.day[1]); g.pixel(cx + tb, top + 14, lit ? L.lit[2] : L.day[2]); });}// ---- scene -----------------------------------------------------------------------------------------------------function scene(g, t, mode) { const wind = mode === "windy", lit = mode !== "unlit"; const sag = wind ? 11.6 + 1.3 * Math.sin(TAU * t) : 11 + 0.6 * Math.sin(TAU * t); for (const cx of POSTS) contactShadow(g, cx, GROUND, 5, 1); const G = LANTERNS.map((L) => { const swing = wind ? (0.22 + 0.16 * Math.sin(TAU * (t + L.ph * 0.5))) + 0.06 * Math.sin(TAU * (2 * t + L.ph)) : 0.13 * Math.sin(TAU * (t + L.ph)); return lanternGeom(L, sag, swing); }); const flicks = LANTERNS.map((L) => 1 + 0.07 * Math.sin(TAU * (2 * t + L.fph)) + 0.05 * Math.sin(TAU * (3 * t + 2 * L.fph))); if (lit) LANTERNS.forEach((L, i) => halo(g, L, G[i], 0.95 + 0.6 * (flicks[i] - 1))); rope(g, sag); for (const f of FLAGS) flag(g, f, sag, t, wind); LANTERNS.forEach((L, i) => { const lag = wind ? Math.sin(TAU * (t + L.ph * 0.5 - 0.15)) : Math.sin(TAU * (t + L.ph - 0.12)); const tassel = wind ? 1.6 + 1.2 * lag : 0.9 * lag; lantern(g, i, L, G[i], lit, flicks[i], tassel); }); post(g, POSTS[0], true); post(g, POSTS[1], false); knot(g, POSTS[0], -1); knot(g, POSTS[1], 1); footing(g, POSTS[0], true); footing(g, POSTS[1], false);}const Lanterns = sprite(pal, (g, t, mode) => { t = ((t % 1) + 1) % 1; scene(g, t, mode);});submit(Lanterns, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_IDLE / 10, loop: true }, anchor: { x: 32, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "windy", args: ["windy"], animate: { fps: 10, duration: N_WIND / 10, loop: true } }, { name: "unlit", args: ["unlit"] }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。