Tessarune
Tavern & Market · well.js

村の井戸

64×64 · JavaScript 339 行

64×64 の床置きの小物で、見下ろし 3/4 視点です。石を段に積んだ丸い胴に厚い板石の縁があり、2 本の木の柱がこけら葺きの切妻屋根と、ロープを巻いた巻き上げの軸を支え、右に鉄の取っ手があります。穴の奥で水が光ります。idle(待機、16 コマのループ)は桶が穴の底にあり、ロープから波紋が広がります。draw(12 コマの一回再生)は取っ手が 2 回まわり、桶が雫を垂らしながら水から上がって手前の縁に置かれます。idle_up(16 コマのループ)は縁の桶の水が光り、石の胴を雫が伝います。

村の井戸 — 描画結果6×
このプログラムで描画(変種「draw」) · 64×64 · 12 コマ
Tavern & Market動くスプライト素材: Tavern & Market(itch.io)Vol.3・酒場と市場の小物 12 点・32 アニメ

冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。

well.js · 1–8
// Village well — 64x64 floor prop, top-down 3/4, key light top-left.// A round drum of coursed stone under a thick slab rim; two timber posts carry a shingled gable roof and a// windlass axle with a rope coil; an iron crank on the right. Water glints deep in the shaft.// Variants://   idle     16f loop  the bucket is down the shaft: ripples spread from the rope, the water glint shimmers//   draw     12f once  the crank turns twice, the bucket climbs out of the water dripping, then is set on the//                      near rim; ends on idle_up frame 0//   idle_up  16f loop  the bucket rests on the rim: its water glints, a drip runs down the stone drum

最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。

well.js · 329–339
submit(Well, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.6, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "draw", args: ["draw"], animate: { fps: 10, duration: 1.2, loop: false } },    { name: "idle_up", args: ["idle_up"] },  ],});
プログラム全体を表示 (339 行)
well.js · 339 行
// Village well — 64x64 floor prop, top-down 3/4, key light top-left.// A round drum of coursed stone under a thick slab rim; two timber posts carry a shingled gable roof and a// windlass axle with a rope coil; an iron crank on the right. Water glints deep in the shaft.// Variants://   idle     16f loop  the bucket is down the shaft: ripples spread from the rope, the water glint shimmers//   draw     12f once  the crank turns twice, the bucket climbs out of the water dripping, then is set on the//                      near rim; ends on idle_up frame 0//   idle_up  16f loop  the bucket rests on the rim: its water glints, a drip runs down the stone drumconst W = 64, H = 64, CX = 32, GROUND = 61;const RCY = 40, RRX = 20, RRY = 8;          // rim slab (outer ellipse)const MRX = 15, MRY = 5;                    // shaft mouthconst WALL_H = 13;                          // drum height below the rim edgeconst WD = 4;                               // how far the water sits below the mouth (screen px)const WY = RCY + WD;                        // water centre rowconst POSTS = [14, 48];                     // left x of each 3px postconst AXLE = { y0: 24, y1: 27, x0: 17, x1: 47 };const PIV = [53, 26], CRANK_L = 5;         // crank pivot and arm lengthconst REST = 47;                            // bucket bottom when it sits on the near rimconst e2 = (x, y, cx, cy, rx, ry) => ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2;// the 0.99 margin trims the lone 1px nubs a pixel ellipse grows at its polesconst 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 LAST = [], FIRSTM = [];               // last rim row / first mouth row per columnfor (let x = 0; x < W; x++) {  LAST[x] = -1; FIRSTM[x] = -1;  for (let y = 0; y < H; y++) { if (inRim(x, y)) LAST[x] = y; if (FIRSTM[x] < 0 && inMouth(x, y)) FIRSTM[x] = y; }}const isWall = (x, y) => LAST[x] >= 0 && y > LAST[x] && y <= LAST[x] + WALL_H && y > RCY;const isRing = (x, y) => inRim(x, y) && !inMouth(x, y);// ---- stone -------------------------------------------------------------------------------------------const STONE = ["n0", "n1", "n2", "n3", "n4"];const clampI = (i) => Math.max(0, Math.min(4, i));function wallTone(x, y) {  const dy = y - LAST[x], nx = (x - CX) / RRX;  const lv = nx < -0.55 ? 3 : nx < 0.3 ? 2 : 1;           // cylinder lit from the left  if (dy <= 2) return STONE[clampI(lv + (dy === 1 ? 1 : 0))]; // the slab's front edge  if (dy === 3) return nx > 0.3 ? "ink" : "n0";           // shadow line under the overhanging slab  const c = Math.floor((dy - 4) / 4), k = (dy - 4) % 4;  const A = (xx) => Math.asin(Math.max(-0.99, Math.min(0.99, (xx - CX) / RRX))) / (Math.PI / 8) + c * 0.5;  const mortar = STONE[clampI(lv - 1)];  if (k === 3) return mortar;  if (Math.floor(A(x)) !== Math.floor(A(x + 1)) && x < CX + RRX) return mortar;  let s = lv;  if (hash(Math.floor(A(x)), c) < 0.24) s -= 1;             // a darker stone here and there  if (k === 0) s += 1;                                     // each stone's lit top edge  if (dy >= WALL_H) s -= 1;                                // ground contact  return STONE[clampI(s)];}function rimTone(x, y) {  const nx = (x - CX) / RRX, ny = (y - RCY) / RRY;  const a = ((Math.atan2(ny, nx) * 180 / Math.PI) + 360) % 360;       // 0 = right, 90 = front  const slab = (xx, yy) => Math.floor(((((Math.atan2((yy - RCY) / RRY, (xx - CX) / RRX) * 180 / Math.PI) + 360) % 360) + 20) / 40);  const sb = slab(x, y);  if (slab(x + 1, y) !== sb && isRing(x + 1, y)) return "n1";           // slab joints  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);  if (outer && a > 140 && a < 300) return "n4";                         // lit outer arris (upper left)  if (inner && ny < 0.2) return "n2";                                   // bevel dropping into the shaft  if (inner && ny > 0.2 && nx < 0.2) return "n4";                       // near inner arris catches light  if (outer && a < 100) return "n2";  return hash(sb, 3) < 0.3 ? "n2" : "n3";}function shaftTone(x, y) {  // far inner wall above the water: coursed, the right side faces the light  const dw = y - FIRSTM[x], nx = (x - CX) / MRX;  let lv = nx > 0.35 ? 2 : nx < -0.3 ? 0 : 1;  if (dw >= 3) lv -= 1;  if (dw % 3 === 2) lv -= 1;  if (inWater(x, y + 1)) return "ink";                                  // dark water line  return ["n0", "n1", "n2"][Math.max(0, lv)];}function waterTone(x, y) {  return inWater(x, y - 2) ? "b1" : "b0";                               // far strip reflects the dark wall}function drum(g, near) {  // near = ring pixels on the front half + the drum wall (drawn nearer, so the bucket can sink behind them)  g.part(near ? "drumFront" : "rimBack", () => g.field({ x: [CX - RRX - 1, CX + RRX + 1], y: [RCY - RRY, RCY + RRY + WALL_H + 1] }, (x, y) => {    if (isRing(x, y)) return (y > RCY) === near ? rimTone(x, y) : null;    if (near && isWall(x, y)) return wallTone(x, y);    return null;  }));  if (near) g.part("moss", () => {    for (const [x, h] of [[15, 2], [16, 3], [17, 1], [21, 1], [38, 2], [39, 1], [46, 2], [47, 1]]) {      const b = LAST[x] + WALL_H;      for (let i = 0; i < h; i++) g.pixel(x, b - i, i === h - 1 ? "e2" : "e1");    }    g.pixel(19, LAST[19] + 7, "e1"); g.pixel(20, LAST[20] + 7, "e1");   // moss in a mortar joint    g.pixel(27, LAST[27] + 9, "n1"); g.pixel(28, LAST[28] + 10, "n1");  // a crack    g.pixel(43, LAST[43] + 5, "n3");                                      // a chip  });}function shaft(g) {  g.part("shaft", () => g.field({ x: [CX - MRX, CX + MRX], y: [RCY - MRY, RCY + MRY] }, (x, y) => {    if (!inMouth(x, y)) return null;    return inWater(x, y) ? waterTone(x, y) : shaftTone(x, y);  }));}// ---- water light -----------------------------------------------------------------------------------------function glint(g, t) {  g.part("glint", () => {    const q = Math.floor(t * 4) % 4;    const len = [4, 3, 4, 2][q], x0 = 22 + [0, 1, 0, 1][q];    g.line([x0, WY - 2], [x0 + len - 1, WY - 2], "b3");    if (q === 3) g.pixel(x0 + 3, WY - 2, "b2");    if (Math.sin(TAU * (t + 0.3)) > 0.4) g.pixel(38, WY - 1, "b2");    if (Math.sin(TAU * (t + 0.75)) > 0.5) { g.pixel(40, WY, "b3"); }  });}function rings(g, cx, cy, list) {  // list: [[radius, key]] — elliptical ripple rings on the water surface (only on water pixels)  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)) return null;    const q = Math.hypot(x - cx, (y - cy) * 3);    for (const [r, key] of list) if (Math.abs(q - r) < 0.9) return key;    return null;  }));}// ---- timber and iron -------------------------------------------------------------------------------------function posts(g) {  for (const px of POSTS) g.part(px < CX ? "postL" : "postR", () => {    g.rect({ x: [px, px], y: [20, RCY] }, "w3", { fill: "w3" });    g.rect({ x: [px + 1, px + 1], y: [20, RCY] }, "w2", { fill: "w2" });    g.rect({ x: [px + 2, px + 2], y: [20, RCY] }, "w1", { fill: "w1" });    g.pixel(px + 1, 31, "w1"); g.pixel(px + 1, 32, "w1"); g.pixel(px, 35, "w4");    // grain, a lit knot    g.pixel(px + 1, 37, "w1");    g.line([px, RCY], [px + 2, RCY], "w0");                                           // foot on the slab    g.line([px, 22], [px + 2, 22], "w1"); g.pixel(px + 2, 22, "w0");                  // the eave's shadow    g.line([px, 29], [px + 2, 29], "n2"); g.pixel(px, 29, "n3");                      // iron strap below the axle  });}function axle(g, th, wind) {  const { y0, y1, x0, x1 } = AXLE;  g.part("axle", () => {    g.line([x0, y0], [x1, y0], "w3");    g.line([x0, y0 + 1], [x1, y0 + 1], "w2");    g.line([x0, y0 + 2], [x1, y0 + 2], "w2");    g.line([x0, y1], [x1, y1], "w1");    // two grain marks turn with the axle (visible on the front half of the log)    for (const mx of [20, 44]) for (const off of [0, 180]) {      const c = Math.cos((th + off) * Math.PI / 180);      if (c < -0.2) continue;      g.pixel(mx, Math.round(25.5 + 1.6 * Math.sin((th + off) * Math.PI / 180)), "w1");    }    g.pixel(12, 25, "w3"); g.pixel(13, 25, "w2"); g.pixel(12, 26, "w2"); g.pixel(13, 26, "w1");   // axle end left    g.pixel(51, 25, "w2"); g.pixel(51, 26, "w1"); g.pixel(52, 25, "n3"); g.pixel(52, 26, "n1");    // stub right, iron boss  });  // rope coil: grows as the bucket is wound up; stripes step with the turning axle  const hw = 1 + Math.round(wind * 5), ph = Math.round(th / 90) & 1;  g.part("coil", () => {    for (let x = CX - hw; x <= CX + hw; x++) for (let y = y0 - 1; y <= y1 + 1; y++) {      if ((y === y0 - 1 || y === y1 + 1) && (x === CX - hw || x === CX + hw)) continue;      const stripe = ((x + y + ph) % 3) === 0;                             // helix of the wound rope      const key = y === y0 - 1 ? (stripe ? "w3" : "w4") : y >= y1 ? (stripe ? "w1" : "w2") : (stripe ? "w2" : "w3");      g.pixel(x, y, key);    }  });}function crank(g, th) {  const a = th * Math.PI / 180, [px, py] = PIV;  const tx = px, ty = Math.round(py - CRANK_L * 0.85 * Math.cos(a) + CRANK_L * 0.55 * Math.sin(a));  const front = Math.sin(a) > 0.3, back = Math.sin(a) < -0.3;  g.part("crank", () => {    g.line([px, py], [tx, ty], back ? "n1" : "n2");    g.pixel(px, py, "n3");    const gy = ty;    g.line([tx + 1, gy], [tx + 3, gy], front ? "w3" : back ? "w1" : "w2");    g.pixel(tx + 1, gy, front ? "w4" : back ? "w2" : "w3");  });}function roof(g) {  g.part("roof", () => g.field({ x: [RX0 - 1, RX1 + 1], y: [7, 21] }, (x, y) => {    if (y <= 10) {                                                                        // back slope, faces the sky      if (x < RX0 + 1 || x > RX1 - 1) return null;      if (y === 7) return x < 20 ? "w4" : "w3";      if (y === 10) return "w3";      if (y === 9 && ((x * 7 + 3) % 11) < 1) return "w3";                                 // a few shingle joints      return "w4";    }    if (y === 11) return x === RX0 - 1 ? "w3" : x === RX1 + 1 ? "w0" : x === RX0 ? "w2" : "w1";   // ridge beam, ends proud    if (x < RX0 || x > RX1) return null;    if (y === 21) return x === RX0 ? "w2" : "w1";                                         // fascia board    if (x === RX0) return "w3";                                                            // barge boards    if (x === RX1) return "w0";    // three shingle courses of 3 rows: [shadow under the course above, face, lit butt]; shingles of uneven    // width, a few short ones whose butt stays in shadow    const r = Math.floor((y - 12) / 3), k = (y - 12) % 3;    if (k === 0) return r === 0 ? "w0" : "w1";    const cuts = SHINGLE[r];    let si = 0;    while (si + 1 < cuts.length && cuts[si + 1] <= x) si++;    if (cuts[si] === x && k === 1) return "w1";    const light = hash(si, r + 7) < 0.4 ? 0 : 1;    if (k === 1) return light ? "w3" : "w2";    if (hash(si, r + 3) < 0.22) return "w2";                                               // short shingle    return light ? "w4" : "w3";  }));  g.part("roofMoss", () => {    g.pixel(11, 20, "e1"); g.pixel(12, 20, "e2"); g.pixel(13, 20, "e1"); g.pixel(12, 19, "e1");    g.pixel(44, 17, "e1"); g.pixel(45, 17, "e2");    g.pixel(52, 14, "e1");  });}const RX0 = 8, RX1 = 56;                     // front slope extent (eaves)const SHINGLE = [0, 1, 2].map((r) => {       // shingle cut positions per course, uneven widths, staggered  const out = [];  for (let x = RX0 + 1 + r * 2 - 4, i = 0; x <= RX1; i++) { out.push(x); x += 4 + Math.floor(hash(i, r + 11) * 4); }  return out;});// ---- bucket and rope -------------------------------------------------------------------------------------function bucket(g, bx, by) {  const top = by - 8;  g.part("bucket", () => {    // iron bail    for (const [dx, dy, k] of [[-4, -1, "n3"], [-3, -2, "n3"], [-2, -3, "n3"], [-1, -3, "n4"], [0, -3, "n3"], [1, -3, "n2"], [2, -3, "n2"], [3, -2, "n2"], [4, -1, "n1"]])      g.pixel(bx + dx, top + dy, k);    g.line([bx - 3, top], [bx + 3, top], "w2");                            // far rim (inside face)    g.pixel(bx - 4, top + 1, "w4"); g.pixel(bx + 4, top + 1, "w2");    g.line([bx - 3, top + 1], [bx + 3, top + 1], "b1");                     // water in the bucket    g.pixel(bx - 2, top + 1, "b3"); g.pixel(bx - 1, top + 1, "b2");    g.line([bx - 4, top + 2], [bx + 4, top + 2], "w3");                     // near lip    g.pixel(bx - 4, top + 2, "w4"); g.line([bx + 2, top + 2], [bx + 4, top + 2], "w2");    for (let j = 3; j <= 8; j++) {      const hw = j <= 5 ? 4 : 3, y = top + j, band = j === 4 || j === 7;      for (let dx = -hw; dx <= hw; dx++) {        const u = dx / hw;        let k;        if (band) k = u < -0.5 ? "n3" : u < 0.4 ? "n2" : "n1";        else {          k = u < -0.6 ? "w3" : u < 0.3 ? "w2" : u < 0.8 ? "w1" : "w0";          if ((dx === -1 || dx === 2) && k === "w2") k = "w1";             // stave seams          if (j === 8) k = k === "w3" ? "w2" : k === "w2" ? "w1" : "w0";        }        g.pixel(bx + dx, y, k);      }    }  });}function rope(g, yTop, yEnd) {  g.part("rope", () => {    for (let y = yTop; y <= yEnd; y++) {      g.pixel(CX, y, (y & 1) ? "w3" : "w2");      if (inRim(CX + 1, y) && !inWater(CX + 1, y)) g.pixel(CX + 1, y, inMouth(CX + 1, y) ? "ink" : "n1");   // its shadow    }  });}// ---- states ------------------------------------------------------------------------------------------------const DRAW = 12;const BB = [null, null, 55, 51, 47, 44, 42, 41, 40, 43, 46, REST];   // bucket bottom per draw framefunction stateAt(state, t) {  t = ((t % 1) + 1) % 1;                                    // hosts probe t = 1 and t < 0 too  if (state === "idle") return { th: 0, bb: null, wind: 0, wt: t, rt: t, front: false, f: -1 };  if (state === "idle_up") return { th: 0, bb: REST, wind: 1, wt: t, rt: -1, front: true, f: -1, dt: t };  const f = Math.min(DRAW - 1, Math.round(t * DRAW));  if (f === 0) return stateAt("idle", 0);  if (f === DRAW - 1) return stateAt("idle_up", 0);  return { th: Math.min(8, f) * 90, bb: BB[f], wind: Math.min(1, f / 8), wt: 0, rt: -1, front: f >= 9, f };}function splash(g, S) {  const f = S.f;  if (f < 1) return;  const L = [];  if (f === 1) L.push([3, "b2"], [7, "b1"]);                 // the rope jerks taut  if (f === 2) L.push([5, "b3"], [9, "b2"]);                 // the bucket breaks the surface  if (f === 3) L.push([8, "b2"], [12, "b1"]);  if (f === 4) L.push([11, "b1"]);  if (f >= 5 && f <= 9) L.push([5 + (f % 2) * 3, "b2"]);    // drips landing  rings(g, CX, WY, L);  g.detached("drops", () => {    if (f === 2) for (const [x, y] of [[28, 41], [36, 40], [26, 39], [38, 42]]) g.pixel(x, y, "b3");    if (f === 3) for (const [x, y] of [[26, 37], [38, 36], [24, 40]]) g.pixel(x, y, "b2");    if (f >= 3 && f <= 10 && S.bb != null) {                 // drips falling from the bucket's bottom      const by = S.bb, ph = f % 3;      g.pixel(CX - 2, by + 1 + ph, "b2");      g.pixel(CX + 2, by + 2 + ((ph + 1) % 3) * 2, "b3");      if (f >= 5) g.pixel(CX, by + 3 + ((ph + 2) % 3), "b2");    }  });}function wallDrip(g, t) {  // idle_up: a drop runs from the bucket over the slab edge and down the drum, then splashes at the foot  const x = CX + 3, top = REST + 1, bot = LAST[x] + WALL_H;  const p = t / 0.8;  g.detached("drip", () => {    if (p < 1) {      const y = top + Math.floor(p * (bot - top + 1));      g.pixel(x, y, "b3");      if (y - 1 >= top) g.pixel(x, y - 1, "b2");      if (y - 2 >= top && p > 0.3) g.pixel(x, y - 2, "b1");    } else {      const k = (t - 0.8) / 0.2;      if (k < 0.5) { g.pixel(x - 1, bot + 1, "b2"); g.pixel(x + 1, bot + 1, "b2"); g.pixel(x, bot, "b1"); }      else { g.pixel(x - 2, bot + 1, "b1"); g.pixel(x + 2, bot + 1, "b1"); }    }  });}const Well = sprite(pal, (g, t, state) => {  const S = stateAt(state, t);  contactShadow(g, CX, GROUND, 22, 2);  // the bucket first, so its own ink ring lands on empty pixels; its depth decides what hides it  if (S.bb != null) g.depth(S.front ? -3 : -1, () => g.outlined("ink", () => bucket(g, CX, S.bb)));  g.outlined("ink", () => {    roof(g);    crank(g, S.th);    posts(g);    axle(g, S.th, S.wind);    drum(g, false);    shaft(g);    g.depth(-2, () => drum(g, true));    const ropeEnd = S.bb != null ? S.bb - 12 : WY - 1;    rope(g, AXLE.y1 + 2, ropeEnd);  });  glint(g, S.wt);  if (S.rt >= 0) rings(g, CX, WY, [0, 0.5].map((o) => { const p = (S.rt + o) % 1; return [2 + p * 11, p < 0.55 ? "b2" : "b1"]; }));  g.depth(-1, () => splash(g, S));  if (S.dt !== undefined) g.depth(-4, () => wallDrip(g, S.dt));});submit(Well, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.6, loop: true },  anchor: { x: CX, y: GROUND },  args: ["idle"],  variants: [    { name: "idle" },    { name: "draw", args: ["draw"], animate: { fps: 10, duration: 1.2, loop: false } },    { name: "idle_up", args: ["idle_up"] },  ],});

掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。

エージェントに、こうしたプログラムを書かせる。

Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。