Tessarune
Arcane Curiosities Vol.1 · mana_crystal.js

魔力の結晶

48×48 · JavaScript 195 行

48×48 の魔力の結晶の群れで、ごつごつした石の割れ目から 5 本の角ばった柱が扇のように伸びています。柱はどれも明るい面・中間の面・影の面を持つ角柱で、先端にはより明るい面、明るい面と中間の面の境には光る稜線があります。12 コマのループで、内側の光の脈が岩からどの柱も同じ速さで昇るので、短い柱が先に、高い柱が最後に光り、届いたところで 3 つの先端にきらめきが出ます。idle-blue、idle-violet、idle-green、idle-red は光の階調をまるごと差し替えたものです。

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

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

mana_crystal.js · 1–6
// Mana crystal cluster — 48x48, top-down 3/4, key light top-left.// Five faceted spires fan out of a cleft in a rough low-poly stone. Each spire is a prism (lit / mid / shadow face,// hard planes) with brighter termination facets at the tip and a bright ridge line where the lit face meets the mid.// Loop: 12 frames @ 10 fps. One pulse of inner light climbs every spire at the same speed from the rock, so the// short spires flare first and the tall one last; a sparkle twinkles off three tips as the pulse arrives.// Variants: idle-blue / idle-violet / idle-green / idle-red (whole glow ramp swapped — no new colours).

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

mana_crystal.js · 185–195
submit(ManaCrystal, {  size: [48, 48], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: 24, y: 45 },  variants: [    { name: "idle-blue" },    { name: "idle-violet", repalette: swap("glow", "v") },    { name: "idle-green", repalette: swap("glow", "e") },    { name: "idle-red", repalette: swap("glow", "r") },  ],});
プログラム全体を表示 (195 行)
mana_crystal.js · 195 行
// Mana crystal cluster — 48x48, top-down 3/4, key light top-left.// Five faceted spires fan out of a cleft in a rough low-poly stone. Each spire is a prism (lit / mid / shadow face,// hard planes) with brighter termination facets at the tip and a bright ridge line where the lit face meets the mid.// Loop: 12 frames @ 10 fps. One pulse of inner light climbs every spire at the same speed from the rock, so the// short spires flare first and the tall one last; a sparkle twinkles off three tips as the pulse arrives.// Variants: idle-blue / idle-violet / idle-green / idle-red (whole glow ramp swapped — no new colours).const W = 48, H = 48, CX = 24, GROUND = 45;const SPEED = 50;                        // px per loop the pulse climbsconst GLOW = ["glow0", "glow1", "glow2", "glow3"];function pip(px, py, poly) {  let c = false;  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {    const [xi, yi] = poly[i], [xj, yj] = poly[j];    if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) c = !c;  }  return c;}// ---- crystal spires ---------------------------------------------------------------------------------------function spire(name, b, a, hw, tip, spark) {  const dx = a[0] - b[0], dy = a[1] - b[1], L = Math.hypot(dx, dy);  const d = [dx / L, dy / L], p = [-d[1], d[0]];            // d: up the axis, p: to the right of it  const local = (x, y) => {    const rx = x + 0.5 - b[0], ry = y + 0.5 - b[1];    return [rx * d[0] + ry * d[1], rx * p[0] + ry * p[1]];  };  const halfW = (u) => (u <= L - tip ? hw : hw * Math.max(0, (L - u) / tip));  const inside = (x, y) => {    const [u, v] = local(x, y);    return u <= L && u >= -1 && Math.abs(v) <= halfW(u) + 0.3;  };  // static facet data per pixel; only the pulse and the flare depend on t  const facet = (x, y) => {    const [u, v] = local(x, y);    const hwu = Math.max(0.7, halfW(u)), s = v / hwu;    const tipFacet = u > L - tip * (1 - 0.3 * s);           // termination facets; the ridge dips toward the lit side    const face = s < -0.3 ? 0 : s < 0.38 ? 1 : 2;             // lit / mid / shadow plane    let k = (tipFacet ? [3, 2, 1] : [2, 1, 0])[face];    if (hw >= 4 && !tipFacet && face === 2 && Math.abs(s - 0.78) * hwu < 0.5) k = 1;   // inner reflection (wide spire)    const edge = Math.abs(v + 0.3 * hwu) < 0.5 && u < L - 1;  // bright facet edge (lit | mid)    return { u, k, tipFacet, edge };  };  const sp = { name, kind: "crystal", group: name, b, a, hw, tip, spark, L, d, inside, facet };  sp.tone = (x, y, t) => {    const f = sp.data[y * W + x];    if (f.edge) return "glow3";    let k = f.k;    const du = f.u - pulseCentre(t);    if (du > -3 && du < 2) k = Math.min(3, k + 1);          // the rising light band    if (f.tipFacet && (t - arrival(sp) + 1) % 1 < 0.12) k = Math.min(3, k + 1);   // tip flares as it arrives    return GLOW[k];  };  return sp;}const pulseCentre = (t) => t * SPEED - 4;                 // u (px up the axis) where the pulse is nowconst arrival = (sp) => (sp.L + 4) / SPEED;               // t at which the pulse reaches the apexconst SPIRES = {                         // a fan from one cleft: feet close together, tips spread  bl: spire("spireBackL", [20, 39], [13, 13], 3.1, 6, [-3, -1]),  br: spire("spireBackR", [28, 39], [35, 17], 3.2, 6, null),  c: spire("spireTall", [24, 41], [24, 5], 4.5, 8, [-4, 0]),  fl: spire("spireFrontL", [17, 42], [9, 28], 2.7, 4.5, [-3, 0]),  fr: spire("spireFrontR", [32, 43], [40, 29], 2.8, 4.5, null),};// ---- stone: a crown behind the cleft, a front face below it, two raised shoulders in front -----------------// Each rock is a list of hand-cut planes (first hit wins); rim:true lets the plane's top-left edge catch the light.function rock(name, group, planes, glowRim) {  const at = (x, y) => planes.find((f) => pip(x + 0.5, y + 0.5, f.poly));  return { name, kind: "rock", group, planes, at, inside: (x, y) => !!at(x, y), glowRim };}const CROWN = rock("crown", "mound", [  { tone: "n3", rim: true, poly: [[13, 41], [15, 36], [19, 33], [29, 33], [33, 36], [35, 41]] },], true);const FACE = rock("frontFace", "mound", [  { tone: "n2", poly: [[14, 40.5], [21, 41], [27, 41], [34, 40.5], [35, 46], [13, 46]] },]);const SHOULDER_L = rock("rockLeft", "left", [  { tone: "n4", poly: [[4, 41], [5, 37.5], [8, 35], [9.5, 37], [8, 40.5]] },                               // lit bevel  { tone: "n3", rim: true, poly: [[5, 37.5], [8, 35], [12, 33.5], [15.5, 35], [17.5, 38.5], [13, 40], [8, 40.5]] }, // crown  { tone: "n3", poly: [[4, 41], [8, 40.5], [11, 40.2], [10, 46], [6, 45]] },                               // front, lit  { tone: "n2", poly: [[11, 40.2], [13, 40], [17.5, 38.5], [18, 42], [16.5, 45], [13, 46.2], [10, 46]] },    // front, turning]);const SHOULDER_R = rock("rockRight", "right", [  { tone: "n3", rim: true, poly: [[30.5, 40.5], [32, 37.5], [36, 35.5], [40.5, 36], [43.5, 39], [38, 40.5], [34, 41]] }, // crown  { tone: "n1", poly: [[30.5, 40.5], [34, 41], [38, 40.5], [37, 46.2], [32, 46], [30, 43.5]] },            // front  { tone: "n0", poly: [[38, 40.5], [43.5, 39], [44.5, 42], [42, 45.2], [37, 46.2]] },                      // side, in shadow]);// painter's order, back to frontconst SHAPES = [CROWN, SPIRES.bl, SPIRES.br, SPIRES.c, SPIRES.fl, SPIRES.fr, FACE, SHOULDER_R, SHOULDER_L];// ---- precompute everything static once: masks, seams, facet data --------------------------------------------const INK = 1, CREVICE = 2, BODY = 3;for (const sh of SHAPES) {  sh.mask = new Uint8Array(W * H);  for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (sh.inside(x, y)) sh.mask[y * W + x] = 1;}const inS = (sh, x, y) => x >= 0 && y >= 0 && x < W && y < H && sh.mask[y * W + x] === 1;SHAPES.forEach((sh, k) => {  sh.code = new Uint8Array(W * H);  if (sh.kind === "crystal") sh.data = [];  else sh.plane = [];  let x0 = W, x1 = 0, y0 = H, y1 = 0;  for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {    if (!inS(sh, x, y)) continue;    x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y);    let code = BODY;    // seam where this shape's edge lies over what is already visible there (the nearer shape owns the line)    for (const [ax, ay] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {      if (inS(sh, ax, ay)) continue;      let j = k - 1;      while (j >= 0 && !inS(SHAPES[j], ax, ay)) j--;      if (j < 0 || SHAPES[j].group === sh.group) continue;      code = SHAPES[j].kind === "rock" && sh.kind === "rock" ? CREVICE : INK;      break;    }    sh.code[y * W + x] = code;    if (sh.kind === "crystal") sh.data[y * W + x] = sh.facet(x, y);    else {      const f = sh.at(x, y);      const rimLit = f.rim && (!inS(sh, x - 1, y) || !inS(sh, x, y - 1)) && x < 30;      const halo = sh.glowRim && !Object.values(SPIRES).some((sp) => inS(sp, x, y)) && [[1, 0], [-1, 0], [0, 1]].some(([ox, oy]) => Object.values(SPIRES).some((sp) => inS(sp, x + ox, y + oy)));      sh.plane[y * W + x] = halo ? "halo" : rimLit ? "n4" : f.tone;    }  }  sh.box = { x: [x0, x1], y: [y0, y1] };  if (sh.kind === "rock") sh.tone = (x, y, t) => {    const c = sh.plane[y * W + x];    return c === "halo" ? ((t + 0.98) % 1 < 0.2 ? "glow2" : "glow1") : c;   // crystal light spilled on the stone  };});function drawShape(g, sh, t) {  g.part(sh.name, () => g.field(sh.box, (x, y) => {    const c = sh.code[y * W + x];    return c === 0 ? null : c === INK ? "ink" : c === CREVICE ? "n0" : sh.tone(x, y, t);  }));}function rockMarks(g, t) {  g.part("rockMarks", () => {    g.line([22, 43], [23, 44], "n1"); g.pixel(23, 45, "n1");                 // crack down the front face    g.pixel(28, 42, "n3"); g.pixel(19, 44, "n1");    g.pixel(11, 36, "n2"); g.pixel(12, 37, "n4"); g.pixel(38, 38, "n2");     // pits on the shoulders    g.pixel(7, 43, "n2"); g.pixel(34, 44, "n0");  });  g.part("shard", () => {                                                     // a small shard in the left shoulder    const hot = t % 1 < 0.2;    g.fillPoly([[9, 41], [11, 43], [9, 45], [7, 43]], "ink");    g.pixel(9, 42, hot ? "glow3" : "glow2"); g.pixel(8, 43, "glow3"); g.pixel(9, 43, hot ? "glow2" : "glow1"); g.pixel(10, 43, "glow0");    g.pixel(9, 44, "glow1");  });}function sparkles(g, t) {  g.detached("sparkles", () => {    for (const sp of Object.values(SPIRES)) {      if (!sp.spark) continue;      const p = (t - arrival(sp) + 1) % 1;      const x = Math.round(sp.a[0]) + sp.spark[0], y = Math.round(sp.a[1]) + sp.spark[1];      const star = (n, c) => {        for (let i = 1; i <= n; i++) for (const [ox, oy] of [[i, 0], [-i, 0], [0, i], [0, -i]]) g.pixel(x + ox, y + oy, i === n && n > 1 ? "glow2" : "glow3");        g.pixel(x, y, c);      };      if (p < 0.08) g.pixel(x, y, "glow3");                 // dot -> cross -> big star (held) -> cross -> dot      else if (p < 0.16) star(1, "n5");      else if (p < 0.33) star(2, "n5");      else if (p < 0.42) star(1, "glow3");      else if (p < 0.5) g.pixel(x, y, "glow2");    }  });}const ManaCrystal = sprite(pal, (g, t) => {  contactShadow(g, CX, GROUND, 17, 2);  g.outlined("ink", () => {    for (const sh of SHAPES) drawShape(g, sh, t);    rockMarks(g, t);  });  sparkles(g, t);});submit(ManaCrystal, {  size: [48, 48], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: 24, y: 45 },  variants: [    { name: "idle-blue" },    { name: "idle-violet", repalette: swap("glow", "v") },    { name: "idle-green", repalette: swap("glow", "e") },    { name: "idle-red", repalette: swap("glow", "r") },  ],});

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

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

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