Tessarune
Dungeon Mechanisms · saw_trap.js

回転のこぎりの罠

48×32 · JavaScript 195 行

48×32 の床置きの小物で、敷石を左右に割る鉄張りの溝に、歯のついた鋼の円盤が半分沈んで立ち、時計回りに回っています。磨いた縁は左上の弧に光が当たり、8 つの歯は回る向きに傾き、面の短い光の筋も一緒に回るので、原寸でも回転が読めます。spin(8 コマ)は四分の一回転し、歯が溝から出るところで火花が散ります。travel(16 コマ)は回りながら溝の端から端まで往復します。hidden(8 コマ)は空の溝、emerge(6 コマの一回再生)は刃が回りながらせり上がり、spin の最初のコマで終わります。

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

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

saw_trap.js · 1–10
// Saw trap — 48x32 floor prop, top-down 3/4, key light top-left.// A patch of flagstones split by an iron-lined slot running left-right; a toothed steel disc stands in the slot,// half sunk, spinning clockwise (its top runs right). The disc faces the camera, so it reads as a full circle cut by// the near lip of the slot. Polished rim ring lit on its top-left arc, darker inner disc, 8 sawtooth teeth all leaning// the spin direction, and short bright arc streaks on the face that turn with it, so the spin reads at 1x.// Variants://   spin    loop, 8f   — blade turns 90° (2 teeth) per loop, sparks spray where the teeth leave the slot//   travel  loop, 16f  — blade runs from the left end of the slot to the right end and back, still spinning//   hidden  loop, 8f   — empty slot, a glint runs along the far iron lip//   emerge  one-shot, 6f — blade rises spinning out of the slot, overshoots 1px; ends == spin f0

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

saw_trap.js · 185–195
submit(SawTrap, {  size: [W, 32], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N_SPIN / 10, loop: true },  anchor: { x: 24, y: GROUND },  variants: [    { name: "spin", args: ["spin"] },    { name: "travel", args: ["travel"], animate: { fps: 10, duration: N_TRAVEL / 10, loop: true } },    { name: "hidden", args: ["hidden"] },    { name: "emerge", args: ["emerge"], animate: { fps: 10, duration: N_EMERGE / 10, loop: false } },  ],});
プログラム全体を表示 (195 行)
saw_trap.js · 195 行
// Saw trap — 48x32 floor prop, top-down 3/4, key light top-left.// A patch of flagstones split by an iron-lined slot running left-right; a toothed steel disc stands in the slot,// half sunk, spinning clockwise (its top runs right). The disc faces the camera, so it reads as a full circle cut by// the near lip of the slot. Polished rim ring lit on its top-left arc, darker inner disc, 8 sawtooth teeth all leaning// the spin direction, and short bright arc streaks on the face that turn with it, so the spin reads at 1x.// Variants://   spin    loop, 8f   — blade turns 90° (2 teeth) per loop, sparks spray where the teeth leave the slot//   travel  loop, 16f  — blade runs from the left end of the slot to the right end and back, still spinning//   hidden  loop, 8f   — empty slot, a glint runs along the far iron lip//   emerge  one-shot, 6f — blade rises spinning out of the slot, overshoots 1px; ends == spin f0const W = 48, GROUND = 30;const N_SPIN = 8, N_TRAVEL = 16, N_HIDDEN = 8, N_EMERGE = 6;const STEP = TAU / 32;                         // blade turn per frame (11.25°): 1/4 of a tooth, reads as forwardconst SLOT = { x0: 3, x1: 44, lip: 15, wall: [16, 19], void: 20, near: 21 };const BLADE = { cy: 20.5, rb: 10, th: 2.8, teeth: 8 };const TRAVEL = { mid: 24, amp: 6 };// ---- flagstones ------------------------------------------------------------------------------------------// Irregular flagstones: cells around hand-placed seeds, measured with y stretched (the floor is foreshortened),// mortar where two cells meet. The patch edge follows the cells, so it is ragged and sits on any floor.const SEEDS = [  [3, 9], [9, 5], [14, 11], [21, 6], [27, 11], [33, 5], [39, 11], [45, 7],  [3, 26], [9, 30], [13, 25], [20, 29], [26, 24], [32, 29], [38, 25], [44, 29],];const PALE = new Set([0, 7, 9, 15]);const FY = 1.9;function cellAt(x, y) {  let a = -1, d1 = 1e9, d2 = 1e9;  for (let i = 0; i < SEEDS.length; i++) {    const dx = x + 0.5 - SEEDS[i][0], dy = (y + 0.5 - SEEDS[i][1]) * FY, d = Math.hypot(dx, dy);    if (d < d1) { d2 = d1; d1 = d; a = i; } else if (d < d2) d2 = d;  }  return { id: a, gap: d2 - d1 };}function inPatch(x, y, id) {  // a rounded-rectangle patch whose edge steps in and out stone by stone  const k = 0.86 + 0.14 * hash(id, 3.7);  const nx = Math.abs(x + 0.5 - 24) / (24 * (0.93 + 0.07 * k)), ny = Math.abs(y + 0.5 - 16.5) / (14.5 * k);  return nx ** 4 + ny ** 4 <= 1;}function stoneTone(x, y) {  const c = cellAt(x, y);  if (!inPatch(x, y, c.id)) return null;  if (c.gap < 1.1) return "n0";                                            // mortar  const other = (xx, yy) => { const o = cellAt(xx, yy); return o.id !== c.id || o.gap < 1.1 || !inPatch(xx, yy, o.id); };  const pale = PALE.has(c.id);  const [hi, base, lo] = pale ? ["n3", "n2", "n1"] : ["n2", "n1", "n0"];  if (other(x, y - 1) || other(x - 1, y)) return hi;                       // far and left bevels face the light  if (other(x, y + 1) || other(x + 1, y)) return pale ? lo : "n1";         // near and right bevels in shade  return base;}function flagstones(g) {  g.part("flagstones", () => {    g.field({ x: [0, W - 1], y: [2, 31] }, stoneTone);    // countable marks: a crack, a chip, moss in two joints    g.line([27, 26], [29, 27], "n0"); g.pixel(30, 27, "n0");    g.pixel(8, 24, "e1"); g.pixel(7, 24, "e2"); g.pixel(36, 13, "e1"); g.pixel(35, 13, "e2");  });}function slot(g) {  const { x0, x1, lip, wall } = SLOT;  g.part("slot", () => {    // far lip: top of the iron angle that lines the slot, lit toward the left    g.line([x0, lip], [x1, lip], "n3");    g.line([x0, lip], [x0 + 14, lip], "n4");    // far inner wall: iron plate facing the camera, turned away from the light; plate seams and rivets    g.rect({ x: [x0 + 1, x1 - 1], y: [wall[0], wall[1]] }, "n0", { fill: "n0" });    g.line([x0 + 1, wall[0]], [x1 - 1, wall[0]], "n1");    for (let x = x0 + 7; x < x1 - 2; x += 9) { g.line([x, wall[0] + 1], [x, wall[1]], "ink"); g.pixel(x - 2, wall[0] + 1, "n2"); g.pixel(x + 2, wall[0] + 1, "n1"); }    g.line([x0 + 1, SLOT.void], [x1 - 1, SLOT.void], "ink");    for (const x of [x0 + 5, x1 - 11]) { g.pixel(x, wall[0] + 1, "w1"); g.pixel(x, wall[0] + 2, "w0"); }   // rust weeping from the rivets    // end walls: the left one faces right (shade), the right one faces left (lit)    g.line([x0, lip + 1], [x0, SLOT.void], "n1");    g.line([x1, lip + 1], [x1, SLOT.void], "n2");  });}function nearLip(g, glintX) {  const { x0, x1, near } = SLOT;  g.part("nearLip", () => {    g.line([x0, near], [x1, near], "n3");                      // edge of the near angle, catching the light    g.line([x0, near], [x0 + 18, near], "n4");    g.line([x0, near + 1], [x1, near + 1], "n1");    for (let x = x0 + 3; x < x1; x += 6) g.pixel(x, near + 1, "n3");   // rivet heads  });  if (glintX != null) g.detached("glint", () => {    // a glint slides along the far lip; at its brightest it flares into a small star    for (let dx = -1; dx <= 1; dx++) {      const x = glintX + dx;      if (x >= x0 + 1 && x <= x1 - 1) g.pixel(x, SLOT.lip, "n5");    }    if (glintX > x0 + 10 && glintX < x1 - 10) { g.pixel(glintX, SLOT.lip - 1, "n5"); g.pixel(glintX, SLOT.lip + 1, "n4"); }  });}// ---- blade ------------------------------------------------------------------------------------------------function bladeTone(x, y, cx, cy, rot) {  const dx = x + 0.5 - cx, dy = y + 0.5 - cy, r = Math.hypot(dx, dy);  const { rb, th, teeth } = BLADE;  if (r > rb + th + 0.3) return null;  const a = Math.atan2(dy, dx) - rot;                         // pattern angle (turns with the blade)  const nx = dx / (r || 1), ny = dy / (r || 1);  const lit = -0.6 * nx - 0.8 * ny;                           // how much this part of the rim faces the key light  if (r > rb) {    // sawtooth: each tooth rises along the spin direction and drops sheer at its tip; a flat gullet between    const s = (((a / TAU) * teeth) % 1 + 1) % 1;    const h = s < 0.45 ? 0 : (th * (s - 0.45)) / 0.55;    if (r > rb + h + 0.3) return null;    if (s > 0.8) return lit > -0.3 ? "n5" : "n4";            // sheer front face, honed    return lit > -0.3 ? "n3" : "n2";                          // back slope  }  // polished rim ring: bright on the top-left arc, falling into shade on the bottom-right  if (r > rb - 1.6) return lit > 0.7 ? "n5" : lit > 0.15 ? "n4" : lit > -0.45 ? "n3" : "n1";  if (r > rb - 2.4) return "n1";                              // shadowed step down to the inner disc  // brass arbor nut, 2x2, at the axle (sits right on the slot line)  if (Math.abs(dx) < 1 && dy > -1.5 && dy < 0.5) return dx < 0 && dy < -0.5 ? "g3" : dx > 0 && dy > -0.5 ? "g0" : "g1";  if (Math.abs(dx) < 2 && dy > -2.5 && dy < 0.5) return "n1";   // its washer  // motion streaks: four bright arcs riding the face (only the upper ones are ever out of the slot)  for (let k = 0; k < 4; k++) {    let d = a - (k * TAU) / 4;    d = Math.atan2(Math.sin(d), Math.cos(d));    if (r > 4.4 && r < 6.1 && d > -0.95 && d < 0) return d > -0.28 ? "n5" : d > -0.6 ? "n4" : "n3";  }  return "n2";}function blade(g, cx, cy, rot, clipY) {  // own 1px ink outline (g.outlined only rims empty pixels, and this blade stands over stone and iron)  const R = BLADE.rb + BLADE.th + 1;  const tone = (x, y) => bladeTone(x, y, cx, cy, rot);  g.part("blade", () =>    g.field({ x: [Math.floor(cx - R), Math.ceil(cx + R)], y: [Math.floor(cy - R), clipY] }, (x, y) => {      const k = tone(x, y);      if (k) return k;      return tone(x - 1, y) || tone(x + 1, y) || tone(x, y - 1) || tone(x, y + 1) ? "ink" : null;    }));}function sparks(g, cx, t, rate, n) {  // hot chips thrown up-left where the teeth rise out of the slot (and a few down-right where they bite in)  g.detached("sparks", () => {    for (let i = 0; i < n; i++) {      const p = (t * rate + i / n) % 1;      const left = i % 3 !== 2;      const ox = left ? cx - 11 : cx + 11, oy = SLOT.void - 1;      const vx = left ? -(5 + (i % 2) * 3) : 4, vy = left ? -(6 + (i % 2) * 2) : -3;      const x = Math.round(ox + vx * p), y = Math.round(oy + vy * p + 9 * p * p);      if (p > 0.7) continue;      g.pixel(x, y, p < 0.25 ? "g3" : p < 0.5 ? "r3" : "r2");      if (p < 0.35) g.pixel(x - Math.sign(vx), y + 1, "r3");    }  });}const SawTrap = sprite(pal, (g, t, state) => {  g.outlined("ink", () => { flagstones(g); slot(g); });  if (state === "hidden") {    const f = Math.round(t * N_HIDDEN) % N_HIDDEN;    nearLip(g, f < 6 ? SLOT.x0 + 2 + f * 7 : null);    return;  }  let cx = TRAVEL.mid, cy = BLADE.cy, rot = 0, sp = null;  if (state === "spin") { const f = Math.round(t * N_SPIN) % N_SPIN; rot = f * STEP; sp = [t, 2, 6]; }  if (state === "travel") {    const f = Math.round(t * N_TRAVEL) % N_TRAVEL;    rot = f * STEP;    cx = Math.round(TRAVEL.mid - TRAVEL.amp * Math.cos(TAU * t));    sp = [t, 4, 6];  }  if (state === "emerge") {    const f = Math.min(N_EMERGE - 1, Math.round(t * N_EMERGE));    const sink = [12, 8, 4, 1, -1, 0][f];    cy = BLADE.cy + sink;    rot = (f - (N_EMERGE - 1)) * STEP;    if (f === 0) { nearLip(g, SLOT.x0 + 2); return; }    sp = f < 5 ? [f / 8, 1, 6] : [0, 2, 6];            // last frame == spin f0, sparks included  }  blade(g, cx, cy, rot, SLOT.void);  nearLip(g, null);  if (sp) sparks(g, cx, sp[0], sp[1], sp[2]);});submit(SawTrap, {  size: [W, 32], origin: "top-left", kind: "icon", scale: 1,  animate: { fps: 10, duration: N_SPIN / 10, loop: true },  anchor: { x: 24, y: GROUND },  variants: [    { name: "spin", args: ["spin"] },    { name: "travel", args: ["travel"], animate: { fps: 10, duration: N_TRAVEL / 10, loop: true } },    { name: "hidden", args: ["hidden"] },    { name: "emerge", args: ["emerge"], animate: { fps: 10, duration: N_EMERGE / 10, loop: false } },  ],});

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

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

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