Tessarune
Arcane Curiosities Vol.1 · orrery.js

天球儀

48×64 · JavaScript 230 行

48×64 の床置きの天球儀で、ろくろで挽いた濃い色の木の台に真鍮の柱が立ち、その上に光る太陽があります。3 つの惑星(鉄色、輪のある紫、青)が傾いた真鍮の輪に乗って 1 ループに 3 周、2 周、1 周し、それぞれ柱の自分の留め輪から伸びる真鍮の腕に支えられ、回っている太陽から光を受けます。輪や腕や惑星の手前の半分は太陽と柱の前を、奥の半分は後ろを通ります。ループは 24 コマで、idle は炎の太陽、idle-soul は魂火に差し替えたものです。

天球儀 — 描画結果6×
このプログラムで描画(変種「idle」) · 48×64 · 24 コマ
Arcane Curiosities Vol.1動くスプライト素材: Arcane Curiosities Vol.1(itch.io)動く小物 12 点・52 アニメ

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

orrery.js · 1–5
// Orrery — 48x64 floor prop, top-down 3/4, key light top-left (the planets are lit by the sun they circle).// A glowing sun on a brass column over a turned dark-wood pedestal; three planets ride tilted brass rings at// 3, 2 and 1 turns per loop (iron, ringed violet, blue), each held by a brass arm from its own hub collar on the// column. Near halves of rings/arms/planets pass in front of the sun and column, far halves behind (g.depth).// Loop: 24 frames @ 10 fps. Variants: idle (fire sun), idle-soul (fl ramp swapped to soulfire).

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

orrery.js · 222–230
submit(Orrery, {  size: [48, 64], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 2.4, loop: true },  anchor: { x: 24, y: GROUND },  variants: [    { name: "idle" },    { name: "idle-soul", repalette: swap("fl", "soul") },  ],});
プログラム全体を表示 (230 行)
orrery.js · 230 行
// Orrery — 48x64 floor prop, top-down 3/4, key light top-left (the planets are lit by the sun they circle).// A glowing sun on a brass column over a turned dark-wood pedestal; three planets ride tilted brass rings at// 3, 2 and 1 turns per loop (iron, ringed violet, blue), each held by a brass arm from its own hub collar on the// column. Near halves of rings/arms/planets pass in front of the sun and column, far halves behind (g.depth).// Loop: 24 frames @ 10 fps. Variants: idle (fire sun), idle-soul (fl ramp swapped to soulfire).const SX = 24, SY = 20, GROUND = 61;        // sun centre (continuous coords), ground rowconst SUN_R = 7;const HUB = { top: 32, bot: 36 };           // brass hub drum on the column; the arms leave from itconst RINGS = [  { name: "iron", r: 10.5, k: 0.46, roll: 13, speed: 3, ph: 0.15, hub: 34, rad: 2.5 },  { name: "violet", r: 14.5, k: 0.46, roll: -8, speed: 2, ph: 0.62, hub: 34, rad: 2.5 },  { name: "blue", r: 18.5, k: 0.46, roll: 4, speed: 1, ph: 0.9, hub: 34, rad: 3.5 },];// a point on a ring (angle a); front = sin(a): +1 nearest the camera (bottom of the ellipse on screen)function orbitPt(R, a) {  const c = Math.cos(a), s = Math.sin(a), q = (R.roll * Math.PI) / 180;  const ex = R.r * c, ey = R.r * R.k * s;  return { x: SX + ex * Math.cos(q) - ey * Math.sin(q), y: SY + ex * Math.sin(q) + ey * Math.cos(q), front: s };}// ring as a clean 1px pixel loop (sampled densely, deduplicated, L-corners removed), each pixel tagged near/farfunction ringPixels(R) {  const pts = [];  for (let i = 0; i < 480; i++) {    const a = (i / 480) * TAU, p = orbitPt(R, a);    const x = Math.floor(p.x), y = Math.floor(p.y), last = pts[pts.length - 1];    if (!last || last.x !== x || last.y !== y) pts.push({ x, y, front: p.front, u: Math.cos(a) });  }  while (pts.length > 1 && pts[0].x === pts[pts.length - 1].x && pts[0].y === pts[pts.length - 1].y) pts.pop();  const out = [];  for (let i = 0; i < pts.length; i++) {    const p = out.length ? out[out.length - 1] : pts[pts.length - 1], c = pts[i], q = pts[(i + 1) % pts.length];    const corner = (p.x === c.x || p.y === c.y) && (q.x === c.x || q.y === c.y) && p.x !== q.x && p.y !== q.y;    if (!corner) out.push(c);  }  return out;}const RING_PX = RINGS.map(ringPixels);// ---- pedestal ------------------------------------------------------------------------------------------// dark wood: the body lives in w0..w2, w3 only on the lit edgeconst stemR = (y) => (y <= 44 ? 4 : y <= 47 ? 2.5 : y <= 51 ? 4.5 - Math.abs(y - 49.5) * 0.6 : 3);function pedestal(g) {  g.part("stem", () => {    g.field({ x: [SX - 6, SX + 5], y: [42, 53] }, (x, y) => {      const R = stemR(y), u = (x + 0.5 - SX) / R;      if (Math.abs(u) > 1) return null;      if (y === 42) return u < -0.2 ? "g3" : u < 0.5 ? "g2" : "g1";   // brass ring capping the stem      if (y === 43) return u < -0.2 ? "g1" : "g0";      return u < -0.65 ? "w3" : u < -0.1 ? "w2" : u < 0.55 ? "w1" : "w0";    });    g.line([SX - 3, 45], [SX + 2, 45], "w0");                       // shadow under the cap's lip  });  // base: a short cylinder, top ellipse centred at yt, seen from above  const yt = 55, rx = 11.5, ry = 3.2, h = 3;  g.part("base", () => {    g.field({ x: [SX - 13, SX + 12], y: [51, GROUND] }, (x, y) => {      const u = (x + 0.5 - SX) / rx;      if (Math.abs(u) > 1) return null;      const e = ry * Math.sqrt(1 - u * u), yc = y + 0.5;      if (yc >= yt - e && yc <= yt + e) {                            // top face: lit toward the top-left        const v = (yc - yt) / ry;        const a = -0.6 * u - 0.5 * v;        if (yc < yt - e + 1 && u < 0.2) return "w3";                   // lit far rim        return a > 0.3 ? "w2" : a > -0.35 ? "w1" : "w0";      }      if (yc > yt && yc <= yt + h + e) {                             // side        if (yc > yt + h + e - 1) return "w0";        if (yc <= yt + e + 1) return u < -0.3 ? "g2" : u < 0.4 ? "g1" : "g0";   // brass trim band        return u < -0.55 ? "w1" : "w0";      }      return null;    });    // brass inlay ring set into the top face (near arc bright, far arc dull), then countable grain    for (let i = 0; i < 48; i++) {      const a = (i / 48) * TAU, x = Math.floor(SX + Math.cos(a) * 7), y = Math.floor(yt - 0.5 + Math.sin(a) * 1.7);      g.pixel(x, y, Math.sin(a) > 0 && Math.cos(a) < 0 ? "g1" : "g0");    }    g.line([SX - 7, 54], [SX - 4, 54], "w2"); g.line([SX + 3, 56], [SX + 6, 56], "w0");  });}function column(g) {  g.part("column", () => {    g.rect({ x: [SX - 1, SX], y: [SY + SUN_R - 1, 42] }, "g1", { fill: "g1" });    g.line([SX - 1, SY + SUN_R - 1], [SX - 1, 42], "g2");  });  g.part("hub", () => {    // clockwork drum: lit top face, toothed side, one arm per tier    g.rect({ x: [SX - 3, SX + 2], y: [HUB.top + 1, HUB.bot] }, "g1", { fill: "g1" });    g.line([SX - 3, HUB.top + 1], [SX - 3, HUB.bot], "g2");    g.line([SX + 2, HUB.top + 1], [SX + 2, HUB.bot], "g0");    g.line([SX - 2, HUB.top], [SX + 1, HUB.top], "g3"); g.pixel(SX + 1, HUB.top, "g2");    for (let x = SX - 3; x <= SX + 2; x += 2) g.pixel(x, HUB.bot, "g0");     // gear teeth    g.line([SX - 2, HUB.top + 2], [SX + 1, HUB.top + 2], "g0");            // seam between the tiers  });}// ---- sun -----------------------------------------------------------------------------------------------function sun(g, t) {  g.part("sun", () => {    g.field({ x: [SX - SUN_R, SX + SUN_R - 1], y: [SY - SUN_R, SY + SUN_R - 1] }, (x, y) => {      const nx = (x + 0.5 - SX) / SUN_R, ny = (y + 0.5 - SY) / SUN_R, r = Math.hypot(nx, ny);      if (r > 1) return null;      if (r > 0.86) return "fl0";                                     // cooler limb      let v = 1 - Math.hypot(nx + 0.25, ny + 0.25) * 0.9;             // hot core, offset toward the key light      v += 0.05 * Math.sin(TAU * 2 * t);                               // the core breathes      let i = v > 0.6 ? 3 : v > 0.28 ? 2 : 1;      // two sunspots ride the surface as the sun turns (one turn per loop, half a turn apart)      for (const [lat, ph] of [[-0.3, 0], [0.28, 0.5]]) {        const lon = ((t + ph) % 1) * 2 - 1;                             // -1..1 across the face, then round the back        const sx = Math.sin((lon * Math.PI) / 2) * Math.sqrt(1 - lat * lat) * 0.8;        if (i === 2 && Math.abs(nx - sx) < 0.12 && Math.abs(ny - lat) < 0.1 && r < 0.8) i = 1;   // never on the core      }      return "fl" + i;    });  });  g.detached("corona", () => {    for (let k = 0; k < 8; k++) {      const a = (k / 8) * TAU + TAU / 16;      const len = Math.sin(TAU * (2 * t) + k * 1.9) > 0.1 ? 2 : 1;      for (let i = 0; i < len; i++) {        const d = SUN_R + 0.7 + i;        g.pixel(Math.floor(SX + Math.cos(a) * d), Math.floor(SY + Math.sin(a) * d), i === 0 ? "fl1" : "fl0");      }    }  });}// ---- rings, arms, planets ------------------------------------------------------------------------------function rings(g) {  RINGS.forEach((R, i) => {    g.part("ring" + (i + 1), () => {      for (const p of RING_PX[i]) {        const col = p.front > 0 ? (p.u < -0.35 ? "g3" : "g2") : (p.u < 0 ? "g1" : "g0");        g.depth(p.front > 0 ? -1 : 1, () => g.pixel(p.x, p.y, col));      }    });  });}// planet sprite as a pixel map: sun-lit sphere + optional flat ring, ink outline around the unionfunction planetMap(cx, cy, rad, ramp, extra) {  const m = new Map(), key = (x, y) => x + "," + y;  const lx = SX - cx, ly = SY - cy, L = Math.hypot(lx, ly) || 1;  const ux = (0.75 * lx) / L - 0.2, uy = (0.75 * ly) / L - 0.2, uz = 0.62;   // sun light + a little key light  const x0 = Math.floor(cx - rad - 6), x1 = Math.ceil(cx + rad + 6);  const y0 = Math.floor(cy - rad - 3), y1 = Math.ceil(cy + rad + 3);  const body = (x, y) => { const nx = (x + 0.5 - cx) / rad, ny = (y + 0.5 - cy) / rad; return nx * nx + ny * ny <= 1; };  if (extra && extra.back) extra.back(m, key);  for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {    if (!body(x, y)) continue;    const nx = (x + 0.5 - cx) / rad, ny = (y + 0.5 - cy) / rad, nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny));    const d = nx * ux + ny * uy + nz * uz;    m.set(key(x, y), ramp[clamp(Math.floor(((d + 0.1) / 1.1) * 4), 0, 3)]);  }  if (extra && extra.front) extra.front(m, key, body);  const ring = [];  for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) {    if (m.has(key(x, y))) continue;    if (m.has(key(x - 1, y)) || m.has(key(x + 1, y)) || m.has(key(x, y - 1)) || m.has(key(x, y + 1))) ring.push(key(x, y));  }  for (const k of ring) m.set(k, "ink");  return { m, box: { x: [x0, x1], y: [y0, y1] } };}const clamp = (v, a, b) => Math.max(a, Math.min(b, v));function planetExtras(name, cx, cy, t) {  if (name === "iron") return {    front: (m, key) => { const x = Math.floor(cx), y = Math.floor(cy); m.set(key(x - 1, y), "n1"); m.set(key(x + 1, y + 1), "n2"); },  };  if (name === "blue") return {    // a small green continent that drifts round as the planet spins (2 turns per loop)    front: (m, key, body) => {      const x = Math.floor(cx), y = Math.floor(cy), s = Math.round(Math.sin(TAU * 2 * t) * 1.5);      for (const [dx, dy, c] of [[-1, -1, "e2"], [0, -1, "e1"], [-1, 0, "e1"], [1, 1, "e1"]]) {        const px = x + dx + s;        if (body(px, y + dy) && Math.abs(px + 0.5 - cx) < 3) m.set(key(px, y + dy), c);      }    },  };  // violet: a flat ring (11x3), far arc behind the ball, near arc in front  const pts = [];  for (let i = 0; i < 64; i++) {    const a = (i / 64) * TAU;    pts.push([Math.floor(cx + Math.cos(a) * 5.4), Math.floor(cy + Math.sin(a) * 1.4 - Math.cos(a) * 0.9), Math.sin(a)]);  }  return {    back: (m, key) => { for (const [x, y, s] of pts) if (s <= 0) m.set(key(x, y), x < cx ? "n4" : "n3"); },    front: (m, key) => { for (const [x, y, s] of pts) if (s > 0) m.set(key(x, y), x < cx ? "n5" : "n4"); },  };}function orbiters(g, t) {  RINGS.forEach((R) => {    const p = orbitPt(R, TAU * (R.ph + R.speed * t));    const cx = Math.floor(p.x) + 0.5, cy = Math.floor(p.y) + 0.5;    const near = p.front > 0;    // arm: from the hub drum on the column out to the planet (all arms share the drum's pivot, so they fan out    // from one point instead of running side by side)    g.depth(near ? -1 : 1, () => g.part("arm-" + R.name, () => {      g.line([SX, R.hub], [Math.floor(cx), Math.floor(cy)], near ? "g1" : "g0");    }));    const { m, box } = planetMap(cx, cy, R.rad, R.name === "iron" ? ["n1", "n2", "n3", "n4"] : R.name === "blue" ? ["b0", "b1", "b2", "b3"] : ["v0", "v1", "v2", "v3"], planetExtras(R.name, cx, cy, t));    g.depth(near ? -2 : 1, () => g.part(R.name, () => g.field(box, (x, y) => m.get(x + "," + y) ?? null)));  });}const Orrery = sprite(pal, (g, t) => {  contactShadow(g, SX, GROUND, 14, 2);  g.outlined("ink", () => {    pedestal(g);    column(g);  });  sun(g, t);  rings(g);  orbiters(g, t);});submit(Orrery, {  size: [48, 64], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 2.4, loop: true },  anchor: { x: 24, y: GROUND },  variants: [    { name: "idle" },    { name: "idle-soul", repalette: swap("fl", "soul") },  ],});

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

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

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