Arcane Curiosities Vol.1 · crystal_ball.js
水晶玉
32×48 の水晶玉で、真鍮の爪の台に載っています。見下ろし 3/4 視点、左上からの光です。16 コマのループで、ガラスの中の霧が 2 本の腕のように渦を巻いて半回転し、目が一つ開いてまた霧に溶け、光の点が一度きらめきます。光の色は階調を一つ差し替えて青・紫・緑になり、新しい色は足していません。
6×
動くスプライト素材: Arcane Curiosities Vol.1(itch.io)動く小物 12 点・52 アニメ
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
crystal_ball.js · 1–4
// Crystal ball on a brass claw stand — 32x48, top-down 3/4, key light top-left.// Loop: 16 frames @ 10 fps. Mist swirls inside the glass (two arms, half a turn per loop), an eye opens and// fades back into the mist, the specular twinkles once.// Variants: glow colour blue / violet / green (ramp swap of the glow0..3 aliases — no new colours).最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
crystal_ball.js · 175–184
submit(CrystalBall, { size: [32, 48], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.6, loop: true }, anchor: { x: 16, y: 45 }, variants: [ { name: "idle-blue" }, { name: "idle-violet", repalette: swap("glow", "v") }, { name: "idle-green", repalette: swap("glow", "e") }, ],});プログラム全体を表示 (184 行)
crystal_ball.js · 184 行
// Crystal ball on a brass claw stand — 32x48, top-down 3/4, key light top-left.// Loop: 16 frames @ 10 fps. Mist swirls inside the glass (two arms, half a turn per loop), an eye opens and// fades back into the mist, the specular twinkles once.// Variants: glow colour blue / violet / green (ramp swap of the glow0..3 aliases — no new colours).const CX = 16, GROUND = 45;const ORB = { cx: 16, cy: 19, r: 11 };const BASE = { cy: 37, rx: 10, ry: 3, h: 4 };function orbAt(x, y) { const nx = (x + 0.5 - ORB.cx) / ORB.r, ny = (y + 0.5 - ORB.cy) / ORB.r; return { nx, ny, r: Math.sqrt(nx * nx + ny * ny) };}// ---- glass -----------------------------------------------------------------------------------------------function glass(g, t) { const eyeOpen = 0.5 - 0.5 * Math.cos(TAU * t); g.field({ x: [ORB.cx - ORB.r, ORB.cx + ORB.r], y: [ORB.cy - ORB.r, ORB.cy + ORB.r] }, (x, y) => { const { nx, ny, r } = orbAt(x, y); if (r > 1) return null; const ux = nx / (r || 1), uy = ny / (r || 1); // specular: a crisp window highlight on the upper-left shoulder const sx = nx + 0.4, sy = ny + 0.42; if (sx * sx * 26 + sy * sy * 44 < 1) return "n5"; // dark rim — the far (upper) rim is thicker: the eye looks through the full depth of glass there if (r > 0.9) return "glow0"; if (r > 0.8 && uy < -0.35) return "glow0"; // reflection of the room: a thin bright arc just inside the upper-left rim, running on from the specular if (r > 0.72 && r <= 0.8 && ux < -0.2 && uy < -0.35) return "glow3"; // light passing through: a caustic crescent on the lower inner wall, opposite the key light const dir = ux * 0.42 + uy * 0.91; if (r > 0.64 && dir > 0.9) return r > 0.7 && r < 0.86 && dir > 0.97 ? "glow3" : "glow2"; // swirling mist: two arms, 2-fold symmetric, half a turn per loop (closes the loop) const inEye = Math.abs(nx) < 0.44 && Math.abs(ny) < 0.24 && eyeOpen > 0.3; if (r < 0.72 && r > 0.14 && !inEye) { const th = Math.atan2(ny, nx); const m = Math.cos(2 * th + 5.4 * r - TAU * t); if (m > 0.92 && r > 0.3 && r < 0.6) return "glow3"; if (m > 0.62) return "glow2"; } // body of the glass: a shade darker on the upper-left (reflects the dark room), lighter where light exits const d = 0.55 * nx + 0.83 * ny; if (d > 0.4) return "glow2"; if (d < -0.62) return "glow0"; return "glow1"; });}function eye(g, t) { // fades in with the mist parting, opens, and sinks back: periodic in t const e = 0.5 - 0.5 * Math.cos(TAU * t); if (e < 0.3) return; const cx = ORB.cx, cy = ORB.cy; g.part("eye", () => { if (e < 0.55) { // a slit of light g.line([cx - 3, cy], [cx + 2, cy], "glow2"); return; } const open = e > 0.86; const lid = open ? "glow3" : "glow2"; // almond lids g.line([cx - 4, cy], [cx - 3, cy], lid); g.line([cx + 2, cy], [cx + 3, cy], lid); g.line([cx - 2, cy - 1 - (open ? 1 : 0)], [cx + 1, cy - 1 - (open ? 1 : 0)], lid); g.line([cx - 2, cy + 1 + (open ? 1 : 0)], [cx + 1, cy + 1 + (open ? 1 : 0)], lid); if (open) { g.pixel(cx - 3, cy - 1, lid); g.pixel(cx + 2, cy - 1, lid); g.pixel(cx - 3, cy + 1, lid); g.pixel(cx + 2, cy + 1, lid); g.rect({ x: [cx - 2, cx + 1], y: [cy - 1, cy + 1] }, "glow2", { fill: "glow2" }); g.line([cx, cy - 1], [cx, cy + 1], "glow0"); // slit pupil g.line([cx - 1, cy - 1], [cx - 1, cy + 1], "glow0"); g.pixel(cx - 2, cy - 1, "glow3"); // catchlight } else { g.line([cx - 2, cy], [cx + 1, cy], "glow1"); g.pixel(cx - 1, cy, "glow0"); g.pixel(cx, cy, "glow0"); } });}// ---- brass claw stand ------------------------------------------------------------------------------------const brassV = (u, v) => (v < -0.45 ? "g2" : v > 0.45 ? "g0" : "g1");const TALONS = { L: [[13, 31], [9, 29], [6, 26], [5, 22], [7, 19]], R: [[19, 31], [23, 29], [26, 26], [27, 22], [25, 19]], F: [[16, 32], [16, 29], [15, 26]],};function talon(g, name, pts, w0, leftV) { // a jointed claw: a knuckle crease part-way up, a polished nail at the tip. // leftV = which sign of v (across the stroke) points screen-left, i.e. toward the key light. g.part(name, () => g.sweep(pts, { w0, w1: 0.3, profile: 1.3 }, (u, v) => { const s = v * leftV; if (u > 0.42 && u < 0.5) return s > 0.3 ? "g1" : "g0"; if (u > 0.8) return s > -0.2 ? "g3" : "g2"; return s > 0.35 ? "g2" : s < -0.45 ? "g0" : "g1"; }));}function talons(g) { talon(g, "talonL", TALONS.L, 1.5, 1); talon(g, "talonR", TALONS.R, 1.5, -1); talon(g, "talonF", TALONS.F, 1.6, -1);}function stem(g) { g.part("stem", () => { // domed foot on the wood, a slim neck, and the palm the talons grow from g.ellipse({ x: [12, 20], y: [34, 38] }, "g0", { fill: "g0" }); g.ellipse({ x: [12, 20], y: [33, 37] }, "g1", { fill: "g1" }); g.line([13, 35], [14, 34], "g2"); g.line([18, 36], [19, 35], "g0"); g.rect({ x: [15, 17], y: [31, 34] }, "g1", { fill: "g1" }); g.line([15, 31], [15, 34], "g2"); g.line([17, 31], [17, 34], "g0"); g.ellipse({ x: [12, 20], y: [29, 32] }, "g1", { fill: "g1" }); g.line([13, 30], [15, 29], "g3"); g.line([17, 31], [19, 31], "g0"); });}function caustic(g) { // light focused through the orb lands on the base, down-right of the stand (away from the key light) g.part("caustic", () => { g.line([21, 37], [23, 37], "glow2"); g.pixel(22, 37, "glow3"); g.pixel(22, 38, "glow1"); });}// ---- dark-wood base --------------------------------------------------------------------------------------function base(g) { const { cy, rx, ry, h } = BASE; g.part("base", () => { // side band (front of the round plinth), then the lit top face g.field({ x: [CX - rx, CX + rx], y: [cy, cy + h + ry] }, (x, y) => { const nx = (x + 0.5 - CX) / rx; if (Math.abs(nx) > 1) return null; const yb = cy + h + ry * Math.sqrt(1 - nx * nx); // bottom edge of the band if (y + 0.5 > yb) return null; const rel = y - (cy + ry * Math.sqrt(1 - nx * nx)); if (Math.abs(rel - 1.5) < 0.5) return nx < -0.3 ? "w1" : "w0"; // carved groove if (nx < -0.55) return "w2"; if (nx < 0.35) return "w1"; return "w0"; }); g.field({ x: [CX - rx, CX + rx], y: [cy - ry, cy + ry] }, (x, y) => { const nx = (x + 0.5 - CX) / rx, ny = (y + 0.5 - cy) / ry; const d = nx * nx + ny * ny; if (d > 1) return null; const a = -0.75 * nx - 0.4 * ny; if (d > 0.62) return a > 0.2 ? "w3" : a > -0.3 ? "w2" : "w1"; // bevelled rim return a > 0.35 ? "w2" : "w1"; }); });}function twinkle(g, t) { const p = (t + 0.3) % 1; if (p > 0.18) return; const x = ORB.cx - 5, y = ORB.cy - 6; g.detached("twinkle", () => { const k = p < 0.06 || p > 0.12 ? 1 : 2; for (let i = 1; i <= k; i++) { g.pixel(x - i - 1, y, "n5"); g.pixel(x + i + 1, y, "n5"); g.pixel(x, y - i - 1, "n5"); g.pixel(x, y + i + 1, "n5"); } });}const CrystalBall = sprite(pal, (g, t) => { contactShadow(g, CX, GROUND, 12, 2); g.outlined("ink", () => { base(g); caustic(g); stem(g); g.part("orb", () => glass(g, t)); talons(g); }); eye(g, t); twinkle(g, t);});submit(CrystalBall, { size: [32, 48], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.6, loop: true }, anchor: { x: 16, y: 45 }, variants: [ { name: "idle-blue" }, { name: "idle-violet", repalette: swap("glow", "v") }, { name: "idle-green", repalette: swap("glow", "e") }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。