Dungeon Mechanisms · spike_trap.js
棘の罠
32×32 の床タイルで、床にはめ込んだ鋲留めの鉄板です。光の当たる枠の内側に一段低い板があり、互い違いに 8 つの穴が開いています。棘は一つの仕組みで、穴から陰影のついた円錐がせり出す長さだけが状態ごとに変わります。idle(8 コマ)は棘が隠れ、穴から穴へかすかな光が瞬きます。trigger(8 コマの一回再生)は棘が勢いよく伸びて土ぼこりが上がり、落ち着きます。armed(8 コマ)は棘の先を光が走り、retract(6 コマの一回再生)は棘が穴へ沈みます。
6×
動くスプライト素材: Dungeon Mechanisms(itch.io)Vol.2・罠と扉と壊れる物 12 点
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
spike_trap.js · 1–9
// Spike trap — 32x32 floor tile, top-down 3/4, key light top-left.// A riveted iron plate set into the floor: a lit frame round a recessed panel pierced by 8 holes (3-2-3, staggered).// The spikes are one rig: each spike is a shaded cone whose visible length h slides out of its hole, so every// state is the same spikes at a different h, occluded back row to front row.// Variants:// idle loop 8f — spikes hidden; a faint glint winks from hole to hole// trigger one-shot 8f — spikes shoot up past full height, dust puffs out from the plate, spikes settle (== armed f0)// armed loop 8f — spikes up; a glint runs along the tips left to right// retract one-shot 6f — spikes sink back into the holes (== idle f0)最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
spike_trap.js · 197–207
submit(SpikeTrap, { size: [32, 32], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "trigger", args: ["trigger"], animate: { fps: 10, duration: N_TRIG / 10, loop: false } }, { name: "armed", args: ["armed"] }, { name: "retract", args: ["retract"], animate: { fps: 10, duration: N_RET / 10, loop: false } }, ],});プログラム全体を表示 (207 行)
spike_trap.js · 207 行
// Spike trap — 32x32 floor tile, top-down 3/4, key light top-left.// A riveted iron plate set into the floor: a lit frame round a recessed panel pierced by 8 holes (3-2-3, staggered).// The spikes are one rig: each spike is a shaded cone whose visible length h slides out of its hole, so every// state is the same spikes at a different h, occluded back row to front row.// Variants:// idle loop 8f — spikes hidden; a faint glint winks from hole to hole// trigger one-shot 8f — spikes shoot up past full height, dust puffs out from the plate, spikes settle (== armed f0)// armed loop 8f — spikes up; a glint runs along the tips left to right// retract one-shot 6f — spikes sink back into the holes (== idle f0)const CX = 16, GROUND = 29;const PLATE = { x0: 2, x1: 30, y0: 8, y1: 26 }; // top face; the front face is 2px below itconst FULL = 7; // full spike length (px above the hole)const N_LOOP = 8, N_TRIG = 8, N_RET = 6;// holes: [cx, top row y] back row first; 3-2-3 staggeredconst ROWS = [ { y: 11, xs: [8, 16, 24] }, { y: 16, xs: [12, 20] }, { y: 21, xs: [8, 16, 24] },];const HOLES = [];for (const r of ROWS) for (const x of r.xs) HOLES.push({ x, y: r.y });// rank of each hole in a diagonal sweep from the top-left (glint order)const RANK = new Array(HOLES.length);HOLES.map((h, i) => [h.x + 1.4 * h.y, i]).sort((a, b) => a[0] - b[0]).forEach((a, r) => { RANK[a[1]] = r; });// ---- plate --------------------------------------------------------------------------------------function plate(g) { const { x0, x1, y0, y1 } = PLATE; g.part("plate", () => { g.field({ x: [x0, x1], y: [y0, y1 + 2] }, (x, y) => { if (y > y1) { // front face (thickness): lit at the left end if (y === y1 + 2) return x < x0 + 3 ? "n1" : "n0"; return x < x0 + 3 ? "n2" : "n1"; } const dx0 = x - x0, dx1 = x1 - x, dy0 = y - y0, dy1 = y1 - y; if (dx0 < 2 || dx1 < 2 || dy0 < 2 || dy1 < 2) { // raised frame: top & left bands face the light, the right and front bands turn away if (dy0 === 0) return dx1 < 2 ? "n3" : "n4"; if (dx0 === 0) return dy1 < 2 ? "n3" : "n4"; if (dx1 === 0) return "n1"; if (dy1 === 0) return "n1"; if (dx0 === 1 || dy0 === 1) return dx1 < 2 ? "n2" : "n3"; return "n2"; } // recessed panel: the frame shades its top and left walls, the lower lip catches the light if (dy0 === 2) return "n0"; if (dx0 === 2) return "n0"; if (dy1 === 2) return "n3"; if (dx1 === 2) return "n2"; // a soft falloff: the panel is a touch brighter toward the top-left return dx0 + 1.4 * dy0 < 15 ? "n2" : "n1"; }); // rivets on the frame corners and mid-sides for (const [x, y] of [[x0 + 1, y0 + 1], [x1 - 2, y0 + 1], [x0 + 1, y1 - 1], [x1 - 2, y1 - 1], [CX - 1, y0 + 1], [CX - 1, y1 - 1]]) { g.pixel(x, y, x < CX && y < y1 - 1 ? "n5" : "n4"); g.pixel(x + 1, y, "n1"); } }); g.part("stain", () => { // old blood dried round one hole on the front row for (const [x, y, k] of [[17, 25, "r0"], [18, 25, "w0"], [14, 25, "w0"], [19, 22, "r0"], [19, 23, "w0"]]) g.pixel(x, y, k); });}function holes(g, glint) { g.part("holes", () => { HOLES.forEach((h, i) => { const { x, y } = h; g.pixel(x - 1, y, "ink"); g.pixel(x, y, "ink"); g.pixel(x + 1, y, "n0"); g.pixel(x - 1, y + 1, "n0"); g.pixel(x, y + 1, "ink"); g.pixel(x + 1, y + 1, "n0"); g.line([x - 1, y + 2], [x + 1, y + 2], "n3"); // lower lip catches the light const k = glint ? glint(i) : 0; // a hidden tip catching the light if (k > 0) g.pixel(x, y + 1, k > 1 ? "n4" : "n3"); }); });}// ---- spikes -------------------------------------------------------------------------------------// cone profile from the tip down: keys for columns cx-1, cx, cx+1 (null = empty); lit on the leftfunction spikeRow(k) { if (k === 0) return [null, "n5", null]; if (k === 1) return [null, "n4", null]; if (k < 3) return ["n4", "n2", null]; return ["n4", "n3", "n1"];}function spikePixels(cx, hy, h, tip, blood) { // h = visible length above the hole; hy = the hole's top row (the spike stands in its lower row) const base = hy + 1, n = Math.round(h), out = []; for (let r = 0; r < n; r++) { const y = base - (n - 1) + r, row = spikeRow(r); const collar = r === n - 1 && n > 2; // where it leaves the hole: a darker band for (let c = 0; c < 3; c++) { let key = row[c]; if (!key) continue; if (collar) key = c === 0 ? "n2" : c === 1 ? "n1" : "n0"; if (r <= 1 && tip) key = tip; if (blood && r >= 2 && r <= 3) key = c === 0 ? "r1" : "r0"; if (blood && r === 4 && c === 1) key = "r0"; out.push([cx + c - 1, y, key]); } } return { base, px: out };}function spikes(g, hs, tipAt) { // each spike carries its own ink outline, drawn back row to front row, so a front spike is cut out against the // ones behind it (g.outlined only rings empty pixels; here the spikes stand over the plate) g.part("spikes", () => HOLES.forEach((hole, i) => { const h = hs(i); if (Math.round(h) <= 0) return; const { base, px } = spikePixels(hole.x, hole.y, h, tipAt ? tipAt(i) : null, i === 6); const has = new Set(px.map(([x, y]) => x + "," + y)); for (const [x, y] of px) for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const qx = x + dx, qy = y + dy; if (qy > base || has.has(qx + "," + qy)) continue; g.pixel(qx, qy, "ink"); } for (const [x, y, k] of px) g.pixel(x, y, k); }));}function star(g, x, y, size) { g.pixel(x, y, "n5"); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + dx, y + dy, size > 1 ? "n5" : "n4"); if (size > 1) for (const [dx, dy] of [[2, 0], [-2, 0], [0, -2]]) g.pixel(x + dx, y + dy, "n4");}// ---- dust ---------------------------------------------------------------------------------------// puffs thrown out from under the plate's edges; stage 1..4: burst, billow, thin out, last motesconst PUFFS = [[3, 21, -1], [28, 19, 1], [7, 28, -1], [24, 28, 1]];const CLOUD = [ null, [" 54 ", " 4443"], [" 544 ", "544443", " 3443 "], [" 4 4 ", "4 4 43", " 3 3 "], [" 3 ", "3 3", " 3 "],];function dust(g, stage) { if (stage <= 0 || stage > 4) return; const rows = CLOUD[stage]; g.detached("dust", () => { for (const [px, py, s] of PUFFS) { const w = rows[0].length, x0 = Math.max(0, Math.min(31 - w, px + s * (stage - 1) - (w >> 1))); const y0 = py - rows.length + 1 - Math.floor((stage - 1) / 2); rows.forEach((row, j) => { for (let i = 0; i < row.length; i++) { const c = row[i]; if (c !== " ") g.pixel(x0 + i, y0 + j, "n" + c); } }); } });}// ---- states -------------------------------------------------------------------------------------// trigger: spikes (h), dust stage per frameconst TRIG = [[0, 0], [9, 1], [6, 2], [8, 3], [7, 4], [7, 0], [7, 0], [7, 0]];const RET = [7, 6, 4, 1, 0, 0];const sweep = (rank) => 1 + Math.floor((rank * 7) / HOLES.length); // frame (1..7) in which a rank is litfunction holeGlint(f) { // frame 0 rests (so the one-shots can join it); frames 1..7 the glint crosses the holes diagonally if (f < 1) return null; return (i) => (sweep(RANK[i]) === f ? 2 : sweep(RANK[i]) === f - 1 ? 1 : 0);}const SpikeTrap = sprite(pal, (g, t, state) => { contactShadow(g, CX, GROUND, 15, 2); g.outlined("ink", () => plate(g)); if (state === "idle") { const f = Math.round(t * N_LOOP) % N_LOOP; holes(g, holeGlint(f)); return; } holes(g, null); if (state === "armed") { const f = Math.round(t * N_LOOP) % N_LOOP; // frame 0 rests; frames 1..7 the glint walks the tips by rank const lit = (i) => f >= 1 && sweep(RANK[i]) === f; spikes(g, () => FULL, (i) => (lit(i) ? "n5" : null)); g.detached("glint", () => { HOLES.forEach((h, i) => { if (f >= 1 && sweep(RANK[i]) === f && (RANK[i] === 7 || sweep(RANK[i] + 1) !== f)) star(g, h.x, h.y + 1 - (FULL - 1), 1); }); }); return; } if (state === "trigger") { const f = Math.min(N_TRIG - 1, Math.round(t * N_TRIG)); const [h, d] = TRIG[f]; spikes(g, () => h); dust(g, d); return; } const f = Math.min(N_RET - 1, Math.round(t * N_RET)); spikes(g, () => RET[f]);});submit(SpikeTrap, { size: [32, 32], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: N_LOOP / 10, loop: true }, anchor: { x: CX, y: GROUND }, variants: [ { name: "idle", args: ["idle"] }, { name: "trigger", args: ["trigger"], animate: { fps: 10, duration: N_TRIG / 10, loop: false } }, { name: "armed", args: ["armed"] }, { name: "retract", args: ["retract"], animate: { fps: 10, duration: N_RET / 10, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。