Arcane Curiosities Vol.1 · orrery.js
天球儀
48×64 の床置きの天球儀で、ろくろで挽いた濃い色の木の台に真鍮の柱が立ち、その上に光る太陽があります。3 つの惑星(鉄色、輪のある紫、青)が傾いた真鍮の輪に乗って 1 ループに 3 周、2 周、1 周し、それぞれ柱の自分の留め輪から伸びる真鍮の腕に支えられ、回っている太陽から光を受けます。輪や腕や惑星の手前の半分は太陽と柱の前を、奥の半分は後ろを通ります。ループは 24 コマで、idle は炎の太陽、idle-soul は魂火に差し替えたものです。
6×
動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。