Tessarune
Dungeon Mechanisms · teleport_rune.js

転送の紋

48×48 · JavaScript 254 行

48×48 の床置きの小物で、敷石を並べた低く丸い台座です。縁石の輪、光をはめ込んだ溝、12 個の紋字を彫った一段低い帯、内側の溝、内側 4 枚の敷石にまたがる羅針盤のような星形の印があります。dormant(12 コマ)は紋字がかすかに灯り、外の溝を淡い光がゆっくり一周します。activate(10 コマの一回再生)は火花が溝を走って紋字を灯し、紋字が回り、印が光って光の柱が立ち、active の最初のコマで終わります。active(12 コマ)は紋字の帯が回り、光の粒が昇ります。どれも紫の変種があります。

転送の紋 — 描画結果6×
このプログラムで描画(変種「activate」) · 48×48 · 10 コマ
Dungeon Mechanisms動くスプライト素材: Dungeon Mechanisms(itch.io)Vol.2・罠と扉と壊れる物 12 点

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

teleport_rune.js · 1–8
// Teleport rune — 48x48 floor prop, top-down 3/4, key light top-left.// A low round dais of flagstones: a ring of curb stones, a carved channel inlaid with light, a recessed band of// twelve runes, an inner channel, and a compass-star sigil cut through the four inner flagstones.// Variants (all light in glow0..3; violet = swap("glow","v"))://   dormant   12f loop  runes barely lit; a slow shimmer walks once round the outer channel//   activate  10f once  a spark races round the channel lighting every rune, the runes whirl up, the sigil flares//                       and a column of light bursts up; ends on active frame 0//   active    12f loop  bright channels, the rune band turns (4 runes per loop), motes rise through a light column

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

teleport_rune.js · 241–254
submit(Rune, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: CX, y: GROUND },  args: ["dormant"],  variants: [    { name: "dormant" },    { name: "activate", args: ["activate"], animate: { fps: 10, duration: 1.0, loop: false } },    { name: "active", args: ["active"] },    { name: "dormant-violet", repalette: swap("glow", "v") },    { name: "activate-violet", args: ["activate"], animate: { fps: 10, duration: 1.0, loop: false }, repalette: swap("glow", "v") },    { name: "active-violet", args: ["active"], repalette: swap("glow", "v") },  ],});
プログラム全体を表示 (254 行)
teleport_rune.js · 254 行
// Teleport rune — 48x48 floor prop, top-down 3/4, key light top-left.// A low round dais of flagstones: a ring of curb stones, a carved channel inlaid with light, a recessed band of// twelve runes, an inner channel, and a compass-star sigil cut through the four inner flagstones.// Variants (all light in glow0..3; violet = swap("glow","v"))://   dormant   12f loop  runes barely lit; a slow shimmer walks once round the outer channel//   activate  10f once  a spark races round the channel lighting every rune, the runes whirl up, the sigil flares//                       and a column of light bursts up; ends on active frame 0//   active    12f loop  bright channels, the rune band turns (4 runes per loop), motes rise through a light columnconst W = 48, H = 48, CX = 24, CY = 30, RX = 22, RY = 13, GROUND = 45;const S_G1 = 0.87, S_G2 = 0.46;                    // outer / inner channel (ellipse scale)const S_MID = (S_G1 + S_G2) / 2;                   // rune band centrelineconst DEG = Math.PI / 180;// ---- static per-pixel map -----------------------------------------------------------------------------const NONE = 0, FACE = 1, CURB = 2, G1 = 3, BAND = 4, G2 = 5, INNER = 6;const rOf = (x, y) => Math.hypot((x - CX) / RX, (y - CY) / RY);const inS = (x, y, s) => rOf(x, y) <= s - 0.01;       // the margin trims the 1px nubs at the polesconst ringPx = (x, y, s) => inS(x, y, s) && [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([a, b]) => !inS(x + a, y + b, s));const code = new Uint8Array(W * H), ang = new Float32Array(W * H);for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {  const i = y * W + x;  ang[i] = ((Math.atan2((y - CY) / RY, (x - CX) / RX) / DEG) + 360) % 360;   // 0 = right, 90 = front  if (!inS(x, y, 1)) { code[i] = y > CY && (inS(x, y - 1, 1) || inS(x, y - 2, 1)) ? FACE : NONE; continue; }  code[i] = ringPx(x, y, S_G1) ? G1 : inS(x, y, S_G1) === false ? CURB          : ringPx(x, y, S_G2) ? G2 : inS(x, y, S_G2) ? INNER : BAND;}const at = (x, y) => (x < 0 || y < 0 || x >= W || y >= H ? NONE : code[y * W + x]);// band pixels touching a channel: they catch spill light when active; the channel's upper-left lip shades the bandconst nearG = (x, y) => [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([a, b]) => { const c = at(x + a, y + b); return c === G1 || c === G2; });// angular distance (deg, 0..180)const adist = (a, b) => { const d = Math.abs(((a - b) % 360 + 360) % 360); return Math.min(d, 360 - d); };// swept(a, from, len): is angle a inside the arc that starts at `from` and runs `len` degrees clockwise?const swept = (a, from, len) => ((a - from) % 360 + 360) % 360 <= len;// ---- runes: twelve slots, four glyphs repeating, so turning by four slots (120 deg) closes the loop ------const GLYPHS = [  ["xx.", "x.x", "x.."],     // thorn  ["x.x", ".x.", ".x."],     // elk  [".x.", "x.x", ".x."],     // ing  ["x.x", "xxx", "x.x"],     // hagal];const runeAt = (k, off) => {  const a = (k * 30 + off) * DEG;  return { x: Math.round(CX + Math.cos(a) * RX * S_MID), y: Math.round(CY + Math.sin(a) * RY * S_MID), a: ((k * 30 + off) % 360 + 360) % 360 };};// ---- stone -------------------------------------------------------------------------------------------const stoneHash = (a) => hash(Math.floor(((a + 15) % 360) / 30), 5);   // one value per curb stonefunction dais(g, L) {  g.part("dais", () => g.field({ x: [1, 46], y: [15, 46] }, (x, y) => {    const c = at(x, y), i = y * W + x, a = ang[i];    const nx = (x - CX) / RX;    if (c === NONE) return null;    if (c === FACE) {                                  // the dais' side: lit on the left, falling away on the right      const top = !inS(x, y - 1, 1) ? false : true;      if (nx < -0.62) return top ? "n2" : "n1";      if (nx > 0.55) return "n0";      return top ? "n1" : "n0";    }    if (c === CURB) {      const edge = !inS(x - 1, y, 1) || !inS(x, y - 1, 1) || !inS(x + 1, y, 1) || !inS(x, y + 1, 1);      if (edge && a > 150 && a < 290) return "n4";   // outer lip catches the light (upper left)      if (edge && a > 10 && a < 110) return "n2";    // lower right lip in shade      return stoneHash(a) < 0.3 ? "n2" : "n3";    }    if (c === G1 || c === G2) return L.groove(x, y, a, c);    if (c === BAND) {      if (L.spill && nearG(x, y)) return "glow0";      // carved band: the upper-left wall of the channel shades the band just inside it      const shade = at(x, y - 1) === G1 || (at(x - 1, y) === G1 && a > 180);      return shade ? "n0" : "n1";    }    // INNER: four flagstones cut by a glowing cross; a diamond sigil at the centre    const dx = x - CX, dy = y - CY, r = rOf(x, y);    const ring = ringPx(x, y, 0.24) && !(dx === 0 && dy === 0);    const spoke = r > 0.26 && Math.abs(Math.abs(dx) / RX - Math.abs(dy) / RY) < 0.024;   // the flagstone seams    if (dx === 0 && dy === 0) return L.sigil(x, y, true);    if (ring || spoke) return L.sigil(x, y, false);    if (L.pool) return r < 0.24 ? L.pool[1] : L.pool[0];    // flat flagstones; the lower-right lip of each cut seam faces the light    const cut = (u, v) => ringPx(u, v, 0.24) || (rOf(u, v) > 0.26 && Math.abs(Math.abs(u - CX) / RX - Math.abs(v - CY) / RY) < 0.024) || at(u, v) === G2;    return cut(x - 1, y) || cut(x, y - 1) ? "n3" : "n2";  }));}function curbMarks(g) {  g.part("curbSeams", () => {    for (let j = 0; j < 12; j++) {      const a = (j * 30 + 15) * DEG, c = Math.cos(a), s = Math.sin(a);      const p0 = [Math.round(CX + c * RX * 0.92), Math.round(CY + s * RY * 0.92)];      const p1 = [Math.round(CX + c * RX * 0.99), Math.round(CY + s * RY * 0.99)];      g.line(p0, p1, "n1");      if (s > 0.2) { const fx = Math.round(CX + c * RX); const fy = Math.round(CY + s * RY) + 1; g.line([fx, fy], [fx, fy + 1], "ink"); }    }    g.pixel(8, 23, "n4"); g.pixel(37, 20, "n2"); g.pixel(41, 38, "n1"); g.pixel(12, 40, "n4");   // chips  });}// ---- light layers --------------------------------------------------------------------------------------function runes(g, off, lit) {  g.part("runes", () => {    for (let k = 0; k < 12; k++) {      const r = runeAt(k, off), gl = GLYPHS[k % 4], key = lit(r.a, k);      if (!key) continue;      for (let j = 0; j < 3; j++) for (let i = 0; i < 3; i++) if (gl[j][i] === "x") g.pixel(r.x - 1 + i, r.y - 1 + j, key);    }  });}const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];function column(g, t, hgt, bright) {  // an opaque shaft of light standing on the inner disc: hot core, softer body, dim edges; bright bands (seen as  // near arcs of a ring) climb it, a few sparks streak up, and the top dissolves in a dither. Periodic in t.  const RC = bright ? 9 : 7;  const TONE = ["glow1", "glow2", "glow3", "n5"];  g.detached("column", () => g.field({ x: [CX - 10, CX + 10], y: [0, CY + 6] }, (x, y) => {    const h0 = CY - y;                                 // height above the disc centre    const wob = 1 + 0.04 * Math.sin(TAU * (h0 / 9 - 2 * t));      // the shaft breathes as it rises    const dx = Math.abs(x - CX) / (RC * wob);    if (dx > 1) return null;    const arc = Math.sqrt(Math.max(0, 1 - dx * dx));    const yb = CY + Math.round(4.8 * arc);             // front edge of the base    const h = yb - y;                                  // height above the floor    if (h < 0 || h > hgt) return null;    const top = hgt - h;                               // distance below the column's top    const fade = top / (hgt * 0.45);                   // ordered-dither dissolve over the top 45%    if (fade < 1 && BAYER[(y & 3) * 4 + (x & 3)] / 16 >= fade * fade * 0.9 + 0.05) return null;    let k = dx < 0.3 ? 2 : dx < 0.72 ? 1 : 0;    if (bright) k = dx < 0.8 ? 2 : 1;    if (h === 0) k = Math.max(k, 2);                   // hot seam where the beam meets the floor    for (let j = 0; j < 3; j++) {                      // bright bands climbing, curved like the near arc of a ring      const hr = ((t + j / 3) % 1) * (hgt - 2);      const d = y - (CY - Math.round(hr) + Math.round(2 * arc));      if (d === 0 || d === 1) { k += 1; break; }    }    if (!bright && dx > 0.3 && dx < 0.72 && hash(x, 11) > 0.5) {    // sparks streaking up the body      const s = ((y + Math.round(t * 24) + Math.floor(hash(x, 13) * 12)) % 12 + 12) % 12;      if (s < 2) k = Math.max(k, 2);    }    return TONE[Math.min(3, k)];  }));}function motes(g, t, maxAge) {  g.detached("motes", () => {    for (let i = 0; i < 9; i++) {      const p = (t * (i % 3 === 0 ? 2 : 1) + hash(i, 21)) % 1;      if (p > maxAge) continue;      const a = (hash(i, 23) * 360) * DEG, rr = 0.55 + 0.35 * hash(i, 29);      const x = Math.round(CX + Math.cos(a) * RX * rr + Math.sin(TAU * (p + hash(i, 31))) * 1.2);      const y = Math.round(CY + Math.sin(a) * RY * rr - p * 22);      if (y < 0) continue;      const key = p < 0.35 ? "glow3" : p < 0.7 ? "glow2" : "glow1";      g.pixel(x, y, key);      if (p < 0.15) { g.pixel(x, y + 1, "glow2"); }    }  });}// ---- states --------------------------------------------------------------------------------------------function dormantLight(t) {  const head = (90 + 360 * t) % 360;                   // the shimmer walks once round per loop  return {    groove: (x, y, a, c) => {      if (c === G2) return "glow0";      const d = ((head - a) % 360 + 360) % 360;       // how far behind the shimmer's head      return d < 6 ? "glow3" : d < 22 ? "glow2" : d < 45 ? "glow1" : "glow0";    },    sigil: (x, y, centre) => (centre ? "glow1" : "glow0"),    rune: (a) => (adist(a, head - 12) < 16 ? "glow2" : "glow1"),    spill: false, pool: null,  };}function activeLight(t) {  const p1 = (90 + 120 * t) % 360, p2 = (p1 + 180) % 360;    // two pulses ride round with the runes  return {    groove: (x, y, a, c) => {      if (c === G2) return adist(a, (270 - 120 * t + 360) % 360) < 20 ? "glow3" : "glow2";      return adist(a, p1) < 14 || adist(a, p2) < 14 ? "glow3" : "glow2";    },    sigil: () => "glow3",    rune: () => "glow3",    spill: true, pool: ["glow1", "glow2"],  };}const ACT = 10;const SPIN = [0, 5, 15, 30, 50, 70, 88, 102, 112, 120];      // rune whirl during activation (ends = 4 slots)function activateLight(f) {  const d = dormantLight(0);  const len = Math.min(360, 72 * (f + 1));                    // the racing spark's swept arc from the front  const head = (90 + len) % 360;  const lit = (a) => len >= 360 || swept(a, 90, len);  return {    groove: (x, y, a, c) => {      if (c === G2) return f >= 5 ? "glow2" : f === 4 ? "glow1" : "glow0";      if (len < 360 && adist(a, head) < 9) return "glow3";      if (f === 4 && len >= 360) return "glow3";            // the ring closes with a flash      return lit(a) ? "glow2" : d.groove(x, y, a, c);    },    sigil: (x, y, centre) => (f >= 5 ? "glow3" : f === 4 ? "glow2" : d.sigil(x, y, centre)),    rune: (a) => (len < 360 && adist(a, head) < 20 ? "glow3" : lit(a) ? (f >= 4 ? "glow3" : "glow2") : "glow1"),    spill: f >= 5, pool: f >= 6 ? ["glow1", "glow2"] : f === 5 ? ["glow0", "glow1"] : null,  };}function spark(g, f) {  // the spark at the head of the racing light: a small star  if (f > 3) return;  const a = (90 + 72 * (f + 1)) * DEG;  const x = Math.round(CX + Math.cos(a) * RX * S_G1), y = Math.round(CY + Math.sin(a) * RY * S_G1);  g.detached("spark", () => {    for (const [ox, oy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + ox, y + oy, "glow3");    g.pixel(x, y - 2, "glow2"); g.pixel(x, y, "n5");  });}const Rune = sprite(pal, (g, t, state) => {  let L, off = 0, ct = t, colH = 0, bright = false, age = 1;  if (state === "dormant") { L = dormantLight(t); age = -1; }  else if (state === "active") { L = activeLight(t); off = 120 * t; colH = 34; }  else {                                                      // activate: frame-indexed    const f = Math.round(t * ACT);    ct = ((f - (ACT - 1)) / 12 + 1) % 1;                      // same 10 fps clock as the active loop    off = SPIN[f] % 360;    if (f >= ACT - 1) L = activeLight(0);    else L = activateLight(f);    colH = [0, 0, 0, 0, 0, 22, 40, 36, 34, 34][f];    bright = f === 5 || f === 6;    age = f >= 7 ? (f - 6) * 0.34 : -1;    if (f < ACT - 1) spark(g, f);  }  contactShadow(g, CX, GROUND, 23, 2);  g.outlined("ink", () => dais(g, L));  curbMarks(g);  runes(g, off, (a) => L.rune(a));  if (colH > 0) column(g, ct, colH, bright);  if (age > 0) motes(g, ct, age);});submit(Rune, {  size: [W, H], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: 1.2, loop: true },  anchor: { x: CX, y: GROUND },  args: ["dormant"],  variants: [    { name: "dormant" },    { name: "activate", args: ["activate"], animate: { fps: 10, duration: 1.0, loop: false } },    { name: "active", args: ["active"] },    { name: "dormant-violet", repalette: swap("glow", "v") },    { name: "activate-violet", args: ["activate"], animate: { fps: 10, duration: 1.0, loop: false }, repalette: swap("glow", "v") },    { name: "active-violet", args: ["active"], repalette: swap("glow", "v") },  ],});

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

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

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