Dungeon Mechanisms · saw_trap.js
回転のこぎりの罠
48×32 の床置きの小物で、敷石を左右に割る鉄張りの溝に、歯のついた鋼の円盤が半分沈んで立ち、時計回りに回っています。磨いた縁は左上の弧に光が当たり、8 つの歯は回る向きに傾き、面の短い光の筋も一緒に回るので、原寸でも回転が読めます。spin(8 コマ)は四分の一回転し、歯が溝から出るところで火花が散ります。travel(16 コマ)は回りながら溝の端から端まで往復します。hidden(8 コマ)は空の溝、emerge(6 コマの一回再生)は刃が回りながらせり上がり、spin の最初のコマで終わります。
6×
動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。