Harvest Festival · apple_tub.js
りんごの樽
リベット留めされた 2 本の鉄の輪で締めた木樽に水を張り、赤と緑のリンゴを浮かべた見下ろし 3/4 視点の 48×48 の小物です。16 コマのループである idle(待機)では、リンゴがゆっくりと漂って波紋が広がり、水面がきらめきます。8 コマの一回再生である splash では中央のリンゴが沈んで水滴の冠が跳ね、16 コマの empty では残った 1 個のリンゴが水面を回ります。
6×
動くスプライト素材: Harvest Festival(itch.io)Vol.6・収穫祭の小物 12 点・37 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
apple_tub.js · 1–9
// Apple tub — 48x48 floor prop, top-down 3/4, key light top-left.// Bobbing for apples: a wide wooden half-barrel of tapered staves bound by two riveted iron hoops, its end-grain rim// jointed per stave, filled to just under the rim with water (dark under the far wall, lighter toward the near edge).// Red and green apples float in it, each with a stem, a lit shoulder and a bright meniscus ring at the waterline.// Variants:// idle 16f loop the apples bob and drift slowly and turn their stems, rings spread from each, a glint shimmers// splash 8f once the centre apple is ducked under, a crown of droplets rises and falls, rings spread wide,// the apple bobs back up and the other apples rock; ends on idle frame 0// empty 16f loop one apple left circling slowly, rings spreading over the open water, the glint shimmers最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
apple_tub.js · 260–270
submit(AppleTub, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "splash", args: ["splash"], animate: { fps: 10, duration: N_SPLASH / 10, loop: false } }, { name: "empty", args: ["empty"] }, ],});プログラム全体を表示 (270 行)
apple_tub.js · 270 行
// Apple tub — 48x48 floor prop, top-down 3/4, key light top-left.// Bobbing for apples: a wide wooden half-barrel of tapered staves bound by two riveted iron hoops, its end-grain rim// jointed per stave, filled to just under the rim with water (dark under the far wall, lighter toward the near edge).// Red and green apples float in it, each with a stem, a lit shoulder and a bright meniscus ring at the waterline.// Variants:// idle 16f loop the apples bob and drift slowly and turn their stems, rings spread from each, a glint shimmers// splash 8f once the centre apple is ducked under, a crown of droplets rises and falls, rings spread wide,// the apple bobs back up and the other apples rock; ends on idle frame 0// empty 16f loop one apple left circling slowly, rings spreading over the open water, the glint shimmersconst W = 48, H = 48, CX = 24, GROUND = 45;const RCY = 24, RRX = 20, RRY = 7; // rim (outer ellipse)const MRX = 17, MRY = 5; // mouth (inside of the rim)const WY = RCY + 2; // water surface centre (2px below the mouth)const BCY = 36, BRX = 18, BRY = 6; // bottom curve of the tapered wallconst N_LOOP = 16, N_SPLASH = 8;const e2 = (x, y, cx, cy, rx, ry) => ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2;const inRim = (x, y) => e2(x, y, CX, RCY, RRX, RRY) <= 0.99;const inMouth = (x, y) => e2(x, y, CX, RCY, MRX, MRY) <= 0.99;const inWater = (x, y) => inMouth(x, y) && e2(x, y, CX, WY, MRX, MRY) <= 0.99;const isRing = (x, y) => inRim(x, y) && !inMouth(x, y);const hwAt = (y) => RRX + (BRX - RRX) * Math.max(0, Math.min(1, (y - RCY) / (BCY - RCY)));const isWall = (x, y) => { if (y <= RCY || inRim(x, y)) return false; if (y <= BCY) return Math.abs(x + 0.5 - CX) <= hwAt(y); return e2(x + 0.5, y, CX, BCY, BRX, BRY) <= 1;};const LAST = []; // last rim row per columnfor (let x = 0; x < W; x++) { LAST[x] = -1; for (let y = 0; y < H; y++) if (inRim(x, y)) LAST[x] = y; }// ---- tub ------------------------------------------------------------------------------------------------------const WOOD = ["w0", "w1", "w2", "w3", "w4"], IRON = ["n0", "n1", "n2", "n3", "n4"];const cl = (i, a = 0, b = 4) => Math.max(a, Math.min(b, i));const staveOf = (x, y) => Math.floor(Math.asin(Math.max(-0.99, Math.min(0.99, (x + 0.5 - CX) / hwAt(y)))) / (Math.PI / 10));const HOOPS = [[3, 4], [9, 10]]; // rows below the rim's front edgefunction wallTone(x, y) { const dy = y - LAST[x], nx = (x + 0.5 - CX) / hwAt(y); const lv = nx < -0.62 ? 3 : nx < 0.05 ? 2 : nx < 0.7 ? 1 : 0; // cylinder lit from the left const hoop = HOOPS.findIndex(([a, b]) => dy >= a && dy <= b); if (hoop >= 0) { const top = dy === HOOPS[hoop][0]; const riv = top && [-0.75, -0.3, 0.2, 0.65].some((u) => Math.round(CX + u * hwAt(y) - 0.5) === x); if (riv) return lv >= 2 ? "n4" : "n3"; return IRON[cl(lv + (top ? 1 : 0) - (lv === 0 ? 0 : 0), 0, 4)]; } if (HOOPS.some(([a, b]) => dy === b + 1)) return WOOD[cl(lv - 1)]; // the hoop's shadow on the staves let s = lv; if (dy === 1) s -= 1; // under the rim's lip if (staveOf(x, y) !== staveOf(x + 1, y) && x + 1 < CX + hwAt(y)) s -= 1; // stave seam if (!isWall(x, y + 1) || !isWall(x, y + 2)) s -= 1; // the floor-facing bottom edge return WOOD[cl(s)];}function rimTone(x, y) { const nx = (x + 0.5 - CX) / RRX, ny = (y + 0.5 - RCY) / RRY; const ang = (xx, yy) => Math.floor(((Math.atan2((yy + 0.5 - RCY) / RRY, (xx + 0.5 - CX) / RRX) / TAU) * 20 + 20.5)) % 20; if (ang(x + 1, y) !== ang(x, y) && isRing(x + 1, y) && ny < 0.3) return "w1"; // stave joints (far half) const outer = !inRim(x - 1, y) || !inRim(x + 1, y) || !inRim(x, y - 1) || !inRim(x, y + 1); const inner = inMouth(x - 1, y) || inMouth(x + 1, y) || inMouth(x, y - 1) || inMouth(x, y + 1); const a = -0.75 * nx - 0.6 * ny; if (outer && a > 0.2) return "w4"; // lit outer arris, upper left if (inner && ny > 0.25 && nx < 0.35) return "w4"; // near inner arris catches the light if (inner && ny < 0) return "w2"; // far inner bevel drops into the tub if (outer && ny > 0.3 && nx > 0.35) return "w1"; return a > -0.35 ? "w3" : "w2";}function insideTone(x, y) { // far inner wall above the water: staves seen from inside; their right side faces the light if (inWater(x, y + 1)) return "w0"; // dark line where wall meets water const nx = (x + 0.5 - CX) / MRX; return nx > 0.25 ? "w2" : "w1";}function waterTone(x, y) { if (!inWater(x, y - 1) || !inWater(x, y - 2)) return "b0"; // the far wall's reflection const ny = (y + 0.5 - WY) / MRY; if (ny > 0.55 && inWater(x, y + 1)) return "b1"; return "b1";}function tub(g) { g.part("tub", () => g.field({ x: [CX - RRX - 1, CX + RRX + 1], y: [RCY - RRY - 1, H - 1] }, (x, y) => { if (isRing(x, y)) return rimTone(x, y); if (inMouth(x, y)) return inWater(x, y) ? waterTone(x, y) : insideTone(x, y); if (isWall(x, y)) return wallTone(x, y); return null; })); g.part("wear", () => { g.pixel(9, 37, "w1"); g.pixel(10, 38, "w1"); // a split in a stave g.pixel(37, 33, "w0"); // a knot g.pixel(30, 39, "w1"); g.pixel(6, 25, "e1"); g.pixel(7, 26, "e2"); // a fleck of moss on the rim });}// ---- water light -----------------------------------------------------------------------------------------------function glint(g, t) { g.part("glint", () => { const q = Math.floor(t * 8) % 4; const len = [3, 4, 3, 2][q], x0 = 10 + [0, 0, 1, 1][q]; g.line([x0, 26], [x0 + len - 1, 26], "b3"); g.line([x0 + 1, 27], [x0 + 1 + [1, 2, 1, 0][q], 27], "b2"); if (Math.sin(TAU * (t + 0.2)) > 0.3) g.pixel(38, 25, "b2"); if (Math.sin(TAU * (t * 2 + 0.6)) > 0.5) g.pixel(36, 27, "b3"); g.line([26, 29], [28, 29], "b2"); // near-edge sheen });}function rings(g, list) { // list: [[cx, cy, r, key]] — elliptical ripple rings, only on open water if (!list.length) return; g.part("ripples", () => g.field({ x: [CX - MRX, CX + MRX], y: [RCY - MRY, RCY + MRY] }, (x, y) => { if (!inWater(x, y) || !inWater(x, y - 1)) return null; for (const [cx, cy, r, key] of list) { const q = Math.hypot(x + 0.5 - cx, (y + 0.5 - cy) * 2.6); if (Math.abs(q - r) < 0.75) return key === "faint" ? (chk(x, y) ? "b2" : null) : key; } return null; }));}// ---- apples ----------------------------------------------------------------------------------------------------// 6 rows; a floating apple shows rows 0..4 above its waterline (row 5 is under water)const SHAPE = [[1, 4], [0, 5], [0, 5], [0, 5], [0, 5], [1, 4]];const SKIN = { red: ["r0", "r1", "r1", "r2"], green: ["e0", "e1", "e2", "e2"] };const SHINE = { red: "r3", green: "e3" };function apple(g, ax, wl, kind, sink, stem, name) { // ax: left column, wl: waterline row (the last visible row), sink: rows pushed under (negative = lifted) const top = wl - 4 + sink, m = new Map(); const P = (x, y, k) => m.set(y * W + x, k); const ramp = SKIN[kind]; for (let r = 0; r < 6; r++) { const y = top + r; if (y > wl) break; for (let c = SHAPE[r][0]; c <= SHAPE[r][1]; c++) { let i = litIndex(c, r, 3, 3, 3.2, 3.2, 4, -0.02); if (i < 0) i = 0; P(ax + c, y, ramp[i]); } } if (top <= wl) { P(ax + 1, top + 1, "n5"); // specular on the lit shoulder if (top + 2 <= wl) P(ax + 1, top + 2, SHINE[kind]); if (top + 1 <= wl) P(ax + 2, top + 1, SHINE[kind]); if (top >= 0) P(ax + 2 + stem, top, ramp[0]); // the dimple } const out = new Map(m); if (top - 1 <= wl && top >= 1) out.set((top - 1) * W + ax + 2 + stem, "w1"); // stem if (kind === "green" && top >= 1 && top <= wl) out.set((top - 1) * W + ax + 3 + stem, "e2"); // a leaf // ink around it (not below the waterline), meniscus ring under it const ink = new Set(); for (const i of out.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]]) { const k = ny * W + nx; if (!out.has(k) && ny <= wl) ink.add(k); } } const wr = SHAPE[Math.min(5, wl - top)] || [1, 4]; g.part(name, () => g.field({ x: [ax - 2, ax + 8], y: [wl - 7, wl + 1] }, (x, y) => { const i = y * W + x; if (out.has(i)) return out.get(i); if (ink.has(i)) return "ink"; if (y === wl + 1 && top <= wl && inWater(x, y)) { const c = x - ax; if (c >= wr[0] && c <= wr[1]) return "b3"; if (c === wr[0] - 1 || c === wr[1] + 1) return "b2"; } return null; }));}// apples: [left x, waterline, kind, phase]const APPLES = [ [9, 24, "red", 0.0], [30, 23, "green", 0.62], [19, 24, "red", 0.37], [24, 28, "green", 0.81],];const DUCK = 2; // the centre apple is the one ducked in the splashfunction applePose(ph, t) { return { dx: Math.round(Math.sin(TAU * (t + ph)) * 1.2), sink: Math.sin(TAU * (2 * t + ph * 1.7)) > 0.35 ? 1 : 0, stem: Math.round(Math.sin(TAU * (t + ph + 0.25)) * 1.1), };}function appleRing(ax, wl, ph, t) { const p = (t * 2 + ph) % 1; return [ax + 3, wl + 1, 3.5 + p * 4.5, p < 0.45 ? "b2" : "faint"];}// ---- splash ----------------------------------------------------------------------------------------------------// per splash frame: sink of the ducked apple, rock of the others, ring radii, droplet setconst DUCK_SINK = [0, 3, 6, 5, -1, 1, 0, 0];const ROCK = [0, 0, 0, 1, 1, 0, 0, 0];function droplets(g, f, cx, cy) { // the crown: a central column and side drops, each a 2px streak (bright head, darker tail along its motion) const up = f <= 4; const D = { 2: [[0, -1], [-2, 0], [2, 0]], 3: [[0, -4], [-2, -3], [2, -3], [-4, -1], [4, -1], [-1, -6]], 4: [[0, -8], [-3, -6], [3, -6], [-5, -3], [5, -4], [1, -10], [-1, -5]], 5: [[0, -6], [-4, -5], [4, -5], [-7, -1], [7, -2], [1, -8]], 6: [[-6, 1], [7, 1], [0, -2], [-9, 2], [9, 2]], }[f] || []; g.detached("droplets", () => { for (const [dx, dy] of D) { g.pixel(cx + dx, cy + dy, "b3"); if (f !== 6) g.pixel(cx + dx, cy + dy + (up ? 1 : -1), "b2"); } if (f === 3) g.rect({ x: [cx - 1, cx + 1], y: [cy - 1, cy + 1] }, "b2", { fill: "b2" }); // the water heaves up if (f === 3) g.pixel(cx, cy - 2, "b3"); });}function stateAt(state, t) { t = ((t % 1) + 1) % 1; if (state !== "splash") return { mode: state, t, f: -1 }; const f = Math.min(N_SPLASH - 1, Math.round(t * N_SPLASH)); if (f === 0 || f === N_SPLASH - 1) return { mode: "idle", t: 0, f: -1 }; return { mode: "splash", t: 0, f };}const AppleTub = sprite(pal, (g, t, state) => { const S = stateAt(state, t); contactShadow(g, CX, GROUND, 20, 2); g.outlined("ink", () => tub(g)); if (S.mode === "empty") { const [ax0, wl, kind] = [21, 25, "red"]; const ax = ax0 + Math.round(Math.cos(TAU * S.t) * 4), wlx = wl + Math.round(Math.sin(TAU * S.t) * 1.4); glint(g, S.t); rings(g, [0, 0.5].map((o) => { const p = (S.t + o) % 1; return [ax + 3, wlx + 1, 3.5 + p * 9, p < 0.4 ? "b2" : "faint"]; })); const sink = Math.sin(TAU * (2 * S.t)) > 0.35 ? 1 : 0; apple(g, ax, wlx, kind, sink, Math.round(Math.sin(TAU * (S.t + 0.25)) * 1.1), "apple0"); return; } glint(g, S.t); const L = []; const f = S.f; APPLES.forEach(([ax, wl, , ph], i) => { const P = applePose(ph, S.t); if (f < 0 || i !== DUCK) L.push(appleRing(ax + P.dx, wl, ph, S.t)); }); if (f > 0) { const [ax, wl] = APPLES[DUCK], cx = ax + 3, cy = wl + 1; const R = { 1: [[3, "b3"]], 2: [[2, "b3"], [5, "b2"]], 3: [[4, "b3"], [8, "b2"]], 4: [[6, "b2"], [11, "faint"]], 5: [[9, "b2"], [3, "b3"]], 6: [[12, "faint"], [6, "b2"]] }[f] || []; for (const [r, k] of R) L.push([cx, cy, r, k]); } rings(g, L); const order = APPLES.map((a, i) => i).sort((a, b) => APPLES[a][1] - APPLES[b][1]); for (const i of order) { const [ax, wl, kind, ph] = APPLES[i]; const P = applePose(ph, S.t); let sink = P.sink; if (f > 0) sink = i === DUCK ? DUCK_SINK[f] : Math.min(1, sink + ROCK[f]); if (sink >= 5) continue; // fully under apple(g, ax + P.dx, wl, kind, sink, P.stem, "apple" + i); } if (f > 0) { const [ax, wl] = APPLES[DUCK]; if (f === 2) g.detached("bubble", () => { g.pixel(ax + 3, wl + 1, "b3"); g.pixel(ax + 1, wl, "b2"); }); droplets(g, f, ax + 3, wl - 1); }});submit(AppleTub, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, args: ["idle"], variants: [ { name: "idle", args: ["idle"] }, { name: "splash", args: ["splash"], animate: { fps: 10, duration: N_SPLASH / 10, loop: false } }, { name: "empty", args: ["empty"] }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。