Dungeon Mechanisms · portcullis.js
落とし格子
48×48 の正面向きの小物で、先の尖った鉄の格子が、地下牢の扉と同じ切石のアーチと壁の溝の中を上下します。すき間からは奥の暗い通路が見えます。格子は丸い鉄の棒として陰影がつき、アーチが格子の上部に影を落とし、棒の影が通路の床に縞を描きます。closed(8 コマ)は中央の横棒を光が走ります。raise(10 コマの一回再生)は鎖が 2 度滑りながら格子ががくがくと引き上げられます。raised(8 コマ)はアーチの下に歯先だけが残り、drop(6 コマの一回再生)は格子が落ちて跳ね、足元に土ぼこりが広がります。
6×
動くスプライト素材: Dungeon Mechanisms(itch.io)Vol.2・罠と扉と壊れる物 12 点
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
portcullis.js · 1–11
// Portcullis — 48x48, top-down 3/4, key light top-left. Camera-facing only.// An iron grate with pointed teeth, sliding in a slot of the same dressed-stone arch and ashlar wall stub as the// dungeon door (so the two drop into one wall). Built the same way: the wall, frame and keystone are solids, the grate// is a patterned plane in the slot behind the wall face; every pixel casts the view ray, a second ray toward the key// light casts the shadows (the arch shades the top of the grate, the bars stripe the passage floor). Through the gaps// you see the dark passage beyond. Bars are shaded as round iron (lit left / top edge, dark right edge / underside).// Variants:// closed loop, 8f — a glint runs along the middle cross bar// raise one-shot, 10f — the grate is hauled up into the arch in jerks (the chain slips twice), grit falls from the slot// raised loop, 8f — only the teeth hang below the arch crown; a glint walks the tips, a grain of grit drops// drop one-shot, 6f — the grate slams down, bounces, a dust puff rolls out at its foot; ends == closed最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
portcullis.js · 293–303
submit(Portcullis, { size: [48, 48], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 0.8, loop: true }, anchor: { x: 24, y: 45 }, variants: [ { name: "closed", args: ["closed"] }, { name: "raise", args: ["raise"], animate: { fps: 10, duration: 1.0, loop: false } }, { name: "raised", args: ["raised"] }, { name: "drop", args: ["drop"], animate: { fps: 10, duration: 0.6, loop: false } }, ],});プログラム全体を表示 (303 行)
portcullis.js · 303 行
// Portcullis — 48x48, top-down 3/4, key light top-left. Camera-facing only.// An iron grate with pointed teeth, sliding in a slot of the same dressed-stone arch and ashlar wall stub as the// dungeon door (so the two drop into one wall). Built the same way: the wall, frame and keystone are solids, the grate// is a patterned plane in the slot behind the wall face; every pixel casts the view ray, a second ray toward the key// light casts the shadows (the arch shades the top of the grate, the bars stripe the passage floor). Through the gaps// you see the dark passage beyond. Bars are shaded as round iron (lit left / top edge, dark right edge / underside).// Variants:// closed loop, 8f — a glint runs along the middle cross bar// raise one-shot, 10f — the grate is hauled up into the arch in jerks (the chain slips twice), grit falls from the slot// raised loop, 8f — only the teeth hang below the arch crown; a glint walks the tips, a grain of grit drops// drop one-shot, 6f — the grate slams down, bounces, a dust puff rolls out at its foot; ends == closedconst K = 0.4, CX = 24, BASE = 46; // screen rise per unit of depth (as chest.js), wall base rowconst UW = 24, WT = 10, WH = 38; // wall stub: half-length, thickness, heightconst SPR = 20, RO = 9, RF = 13, FP = 1.5; // arch: springing height, opening radius, frame outer radius, frame projectionconst CH = WH / 7; // ashlar course heightconst VG = 1.2; // the grate's plane (in the slot, just behind the wall face)const BARS_U = [-8, -4, 0, 4, 8], BAR_W = 0.85; // uprights (centres, half-width)const BARS_Z = [5.5, 11, 16.5, 22, 27.5, 33, 38.5, 44], BAR_H = 0.72; // cross bars, heights above the tipsconst TOOTH = 4; // tooth length (the uprights taper to points below the first cross bar)const LIFT = 17; // raised: tips hang at z = 17, the teeth and one cross bar under the arch crown (29)const RAISE_L = [0, 0.6, 2.4, 4.6, 3.9, 7.4, 11, 10.2, 14, LIFT]; // hauled in jerks; the chain slips at 4 and 7const DROP_L = [LIFT, 8, 0, 1.5, 0, 0]; // falls, slams, bounces, rests == closedconst LV = norm3([-0.6, -0.25, 0.76]); // toward the key light (world: X right, Y away, Z up)const LS = norm3([-0.45, -0.6, 0.66]); // cast-shadow direction: the same light, pulled forward so recess shadows stay shortconst STONE = ["n0", "n1", "n2", "n3", "n4"], IRON = ["n0", "n1", "n2", "n3", "n4"];const WALLB = [-UW, UW, 0, WT, 0, WH];const KEYB = [-2.3, 2.3, -FP - 0.8, 0, SPR + RO - 0.5, SPR + RF + 0.8];const SILLB = [-RO, RO, -FP, WT, -2, 0]; // the passage floor under the arch (seen only through it)function norm3(v) { const m = Math.hypot(v[0], v[1], v[2]); return [v[0] / m, v[1] / m, v[2] / m]; }const neg = (n) => [-n[0], -n[1], -n[2]];const clamp = (v, a, b) => Math.max(a, Math.min(b, v));const proj = (q) => [CX + q[0], BASE - q[2] - K * q[1]];// ---- ray vs solids. Intervals are {tin, tout, nin, nout} (outward normals) or null.function slab(o, d, i, lo, hi, st) { const di = d[i], oi = o[i]; if (di < 1e-9 && di > -1e-9) return oi >= lo && oi <= hi; let t1 = (lo - oi) / di, t2 = (hi - oi) / di, s1 = -1, s2 = 1; if (t1 > t2) { const k = t1; t1 = t2; t2 = k; s1 = 1; s2 = -1; } if (t1 > st.tin) { st.tin = t1; st.nin = [0, 0, 0]; st.nin[i] = s1; } if (t2 < st.tout) { st.tout = t2; st.nout = [0, 0, 0]; st.nout[i] = s2; } return st.tin <= st.tout;}function box(o, d, b) { const st = { tin: -1e9, tout: 1e9, nin: [0, 0, 0], nout: [0, 0, 0] }; for (let i = 0; i < 3; i++) if (!slab(o, d, i, b[2 * i], b[2 * i + 1], st)) return null; return st;}// D-shaped prism along v: |u| <= w, z >= 0, v0 <= v <= v1, and (z <= SPR or inside the circle r about (0, SPR))function inD(u, z, w, r) { return Math.abs(u) <= w && z >= 0 && (z <= SPR || u * u + (z - SPR) * (z - SPR) <= r * r); }function dshape(o, d, w, r, v0, v1) { const st = { tin: -1e9, tout: 1e9, nin: [0, 0, 0], nout: [0, 0, 0] }; if (!slab(o, d, 0, -w, w, st) || !slab(o, d, 1, v0, v1, st) || !slab(o, d, 2, 0, 1e9, st)) return null; let hA = -1e9, hB = 1e9, cA = 1, cB = 0; if (d[2] < 1e-9 && d[2] > -1e-9) { if (o[2] > SPR) { hA = 1; hB = 0; } } else { const th = (SPR - o[2]) / d[2]; if (d[2] > 0) hB = th; else hA = th; } const A = d[0] * d[0] + d[2] * d[2], ou = o[0], oz = o[2] - SPR; if (A < 1e-12) { if (ou * ou + oz * oz <= r * r) { cA = -1e9; cB = 1e9; } } else { const B = 2 * (ou * d[0] + oz * d[2]), C = ou * ou + oz * oz - r * r, D = B * B - 4 * A * C; if (D >= 0) { const s = Math.sqrt(D); cA = (-B - s) / (2 * A); cB = (-B + s) / (2 * A); } } const a1 = Math.max(st.tin, hA), b1 = Math.min(st.tout, hB), a2 = Math.max(st.tin, cA), b2 = Math.min(st.tout, cB); const ok1 = a1 <= b1, ok2 = a2 <= b2; if (!ok1 && !ok2) return null; const tin = ok1 && ok2 ? Math.min(a1, a2) : ok1 ? a1 : a2; const tout = ok1 && ok2 ? Math.max(b1, b2) : ok1 ? b1 : b2; const capN = (t) => { const u = o[0] + t * d[0], z = o[2] + t * d[2] - SPR, m = Math.hypot(u, z) || 1; return [u / m, 0, z / m]; }; return { tin, tout, nin: tin === st.tin ? st.nin : ok2 && tin === cA ? capN(tin) : [0, 0, 1], nout: tout === st.tout ? st.nout : ok2 && tout === cB ? capN(tout) : [0, 0, 1], };}// first surface of (A minus B) past epsfunction minus(a, b, eps) { if (!a || a.tout < eps) return null; let best = null; if (a.tin > eps && !(b && a.tin >= b.tin && a.tin <= b.tout)) best = { t: a.tin, n: a.nin, inner: false }; if (b && b.tout > eps && b.tout >= a.tin && b.tout < a.tout && (!best || b.tout < best.t)) best = { t: b.tout, n: neg(b.nout), inner: true }; return best;}// the grate pattern at (u, zr = height above the tooth tips): null, or {kind, k, n (surface normal)}function grateAt(u, zr) { if (zr < 0 || Math.abs(u) > RO + 0.5) return null; for (let i = 0; i < BARS_Z.length; i++) { const y = (zr - BARS_Z[i]) / BAR_H; if (Math.abs(y) <= 1) { let rivet = false; for (const uc of BARS_U) if (Math.abs(u - uc) < 0.5 && Math.abs(y) < 0.6) rivet = true; return { kind: "cross", k: i, rivet, n: [0, -Math.sqrt(1 - 0.6 * y * y), 0.75 * y] }; } } for (let i = 0; i < BARS_U.length; i++) { const hw = zr < TOOTH ? 0.12 + (BAR_W - 0.12) * (zr / TOOTH) : BAR_W, x = (u - BARS_U[i]) / hw; if (Math.abs(x) <= 1) return { kind: "bar", k: 10 + i, tooth: zr < TOOTH + 0.5, n: [0.75 * x, -Math.sqrt(1 - 0.6 * x * x), 0] }; } return null;}function grateHit(o, d, L, eps) { const t = (VG - o[1]) / d[1]; if (t <= eps) return null; const u = o[0] + t * d[0], z = o[2] + t * d[2], gr = grateAt(u, z - L); return gr ? { t, n: gr.n, gr, zr: z - L, u } : null;}function scene(o, d, L, eps, primary, skipGrate) { let best = null; const take = (h, id, extra) => { if (h && h.t > eps && (!best || h.t < best.t)) best = Object.assign({ t: h.t, n: h.n, id, inner: h.inner }, extra); }; take(minus(box(o, d, WALLB), dshape(o, d, RF, RF, -1e9, 1e9), eps), "wall"); take(minus(dshape(o, d, RF, RF, -FP, WT), dshape(o, d, RO, RO, -1e9, 1e9), eps), "frame"); const kb = box(o, d, KEYB); if (kb) take({ t: kb.tin, n: kb.nin }, "key"); let gate = true; // primary rays see the grate and the passage only through the arch if (primary) { const t0 = (-FP - o[1]) / d[1]; gate = inD(o[0] + t0 * d[0], o[2] + t0 * d[2], RO, RO); } if (gate && !skipGrate) { const g = grateHit(o, d, L, eps); if (g) take(g, "grate", { gr: g.gr, zr: g.zr, gu: g.u }); } if (primary && gate) { const sb = box(o, d, SILLB); if (sb) take({ t: sb.tin, n: sb.nin }, "frame", { inner: true }); const c = dshape(o, d, RO, RO, WT, 1e9); if (c) take({ t: c.tout, n: neg(c.nout) }, "corridor"); } return best;}function classify(h, p) { const u = p[0], v = p[1], z = p[2], n = h.n; if (h.id === "wall") { if (n[2] > 0.5) return { part: "wall", mat: "stone", s: "wt", lift: 0.45, k: Math.floor((u + UW + 3) / 9) + 50 * Math.floor(v / 5.01) }; const c = Math.floor(z / CH), j = Math.floor((u + UW + 7.5 + (c & 1) * 4.5) / 9); return { part: "wall", mat: "stone", s: "wf", k: c * 64 + j }; } if (h.id === "frame" && !h.inner) { const k = z <= SPR ? (u < 0 ? 0 : 20) + Math.floor(z / (SPR / 3)) : 50 + Math.floor(Math.atan2(z - SPR, u) / (Math.PI / 7)); return { part: "arch", mat: "stone", s: n[1] < -0.5 ? "ff" : "fr", k, lift: 0.95 }; } if (h.id === "key") return { part: "arch", mat: "stone", s: "key", k: 0, lift: 0.9, carve: n[1] < -0.5 && Math.abs(Math.abs(u) + Math.abs(z - 31.3) - 1.3) < 0.45 }; if (h.id === "frame") return { part: "passage", mat: "reveal", s: n[2] > 0.5 ? "floor" : "side", k: 0, v }; if (h.id === "corridor") return { part: "passage", mat: "void", s: "void", k: 0, v }; const gr = h.gr; return { part: "grate", mat: "iron", s: gr.kind, k: gr.k, rivet: gr.rivet, tooth: gr.tooth, u: h.gu, zr: h.zr };}function cast(L) { const buf = new Array(48 * 48).fill(null); for (let py = 0; py < 48; py++) for (let px = 0; px < 48; px++) { const o = [px + 0.5 - CX, -30, BASE - (py + 0.5) + 30 * K], d = [0, 1, -K]; const h = scene(o, d, L, 0, true, false); if (!h) continue; const p = [o[0] + h.t * d[0], o[1] + h.t * d[1], o[2] + h.t * d[2]]; const c = classify(h, p); c.d = h.n[0] * LV[0] + h.n[1] * LV[1] + h.n[2] * LV[2]; c.t = h.t; c.v = p[1]; if (c.d > 0 && c.mat !== "void") { // the grate is a thin plane: start its shadow ray from just in front of it const q = [p[0] + h.n[0] * 0.06, p[1] + h.n[1] * 0.06 - (h.id === "grate" ? 0.3 : 0), p[2] + h.n[2] * 0.06]; c.sh = !!scene(q, LS, L, 1e-4, false, h.id === "grate"); } buf[py * 48 + px] = c; } return { buf, proj };}function tone(buf, fx) { const keys = new Array(48 * 48).fill(null); for (let i = 0; i < 48 * 48; i++) { const b = buf[i]; if (!b) continue; const px = i % 48, py = (i - px) / 48; const up = py > 0 ? buf[i - 48] : null, dn = py < 47 ? buf[i + 48] : null; const lf = px > 0 ? buf[i - 1] : null, rt = px < 47 ? buf[i + 1] : null; const seam = (q) => q && q.s === b.s && q.k !== b.k; let key; if (b.mat === "stone") { let val = 1.3 + 1.25 * b.d + (b.lift || 0); const h = hash(b.k * 1.7 + b.s.charCodeAt(1), 3.1); if (h < 0.22) val -= 0.45; else if (h > 0.8) val += 0.4; if (b.sh) val -= 0.9; let lv = Math.floor(val); if (seam(rt) || seam(dn)) lv = -1; else if (seam(up) || seam(lf)) lv += 1; else if (b.part === "arch" && ((up && up.part !== "arch") || (lf && lf.part !== "arch"))) lv += 1; if (!up && lv >= 0) lv += 1; if (b.carve) lv = Math.min(lv, 1); key = lv < 0 ? (val < 1.2 ? "ink" : "n0") : STONE[clamp(lv, 0, 4)]; } else if (b.mat === "reveal") { const dv = b.v + FP; key = b.sh ? (dv < 4 ? "n0" : "ink") : dv < 2.5 ? "n1" : dv < 6 ? "n0" : "ink"; } else if (b.mat === "void") { const dv = b.v - WT; key = dv < 2 ? "n0" : dv < 6 && chk(px, py) ? "n0" : "ink"; } else if (b.mat === "iron") { let val = 1.1 + 3.0 * b.d; if (b.sh) val -= 0.7; let lv = Math.floor(val); const same = (q) => q && q.mat === "iron" && q.k === b.k; if (b.s === "bar" && !same(lf)) lv += 1; // round uprights: a lit left rim if (b.s === "cross" && !same(up)) lv += 1; // cross bars: a lit top rim if (b.rivet) lv = Math.max(lv + 1, 3); const g = fx.glint ? fx.glint(b.u, b.zr, b) : 0; lv += g; key = g >= 2 ? "n5" : IRON[clamp(lv, 0, 4)]; } keys[i] = key; } return keys;}const PARTS = ["wall", "arch", "passage", "grate"];function drawGate(g, L, fx) { const fr = cast(L), buf = fr.buf, keys = tone(buf, fx); let x0 = 47, x1 = 0, y0 = 47, y1 = 0; const seen = new Set(); for (let i = 0; i < 48 * 48; i++) if (buf[i]) { const x = i % 48, y = (i - x) / 48; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); seen.add(buf[i].part); } g.outlined("ink", () => { for (const part of PARTS) if (seen.has(part)) g.part(part, () => g.field({ x: [x0, x1], y: [y0, y1] }, (x, y) => { const b = buf[y * 48 + x]; return b && b.part === part ? keys[y * 48 + x] : null; })); }); return fr;}// a local point is drawn only where it is in front of what the ray already hit (rays never move in u)function put(g, fr, q, key) { const [sx, sy] = fr.proj(q), x = Math.floor(sx), y = Math.floor(sy); if (x < 0 || y < 0 || x > 47 || y > 47) return; const b = fr.buf[y * 48 + x]; if (b && b.v < q[1]) return; g.pixel(x, y, key);}// grit sifting down from the slot in the arch crown: [u, phase]const GRIT = [[-2.5, 0], [1.5, 0.35], [4, 0.7], [-5, 0.55]];const Portcullis = sprite(pal, (g, t, state) => { if (state === "closed") { // a glint runs along the top edge of the middle cross bar, left to right, then rests const f = Math.round(t * 8), gx = -11 + (f - 1) * 4; const glint = f >= 1 && f <= 6 ? (u, zr, b) => (b.s === "cross" && b.k === 2 && zr > BARS_Z[2] ? (Math.abs(u - gx) < 1 ? 2 : Math.abs(u - gx) < 2.6 ? 1 : 0) : 0) : null; drawGate(g, 0, { glint }); return; } if (state === "raise") { const f = Math.min(9, Math.round(t * 10)), L = RAISE_L[f]; const fr = drawGate(g, L, {}); g.detached("grit", () => { for (const [u0, ph] of GRIT) { const k = f - 2 - Math.round(ph * 4); if (k < 0 || k > 4) continue; const z = 27 - k * (2 + k * 1.2); if (z > 0.5) put(g, fr, [u0, -0.3, z], k < 2 ? "n4" : "n3"); } if (f === 4 || f === 7) for (const s of [-1, 1]) put(g, fr, [s * 7.8, -0.3, SPR + 5.2], "n3"); // the chain slips: a jolt of grit at the slot }); return; } if (state === "raised") { const f = Math.round(t * 8); // a glint walks along the tooth tips, and a grain of grit drops from between the teeth const glint = f >= 1 && f <= 5 ? (u, zr, b) => (b.tooth && Math.abs(u - BARS_U[f - 1]) < 1 ? 1 : 0) : null; const fr = drawGate(g, LIFT, { glint }); g.detached("grit", () => { const k = f - 2; if (k >= 0 && k <= 3) put(g, fr, [-2, -0.3, LIFT - 1 - k * (2 + k * 1.5)], k < 2 ? "n4" : "n3"); }); return; } // drop const f = Math.min(5, Math.round(t * 6)), L = DROP_L[f]; const fr = drawGate(g, L, {}); if (f === 1) g.detached("smear", () => { // motion trail above the falling teeth for (const u of BARS_U) for (let z = L + TOOTH + 1; z < L + TOOTH + 6; z += 1.4) put(g, fr, [u, VG - 0.2, z], "n2"); }); if (f >= 2 && f <= 4) g.detached("puff", () => { // a dust puff bursts out along the threshold from under the teeth, rolls outward and rises, then thins out const k = f - 2, half = [9, 12, 14][k], top = [3, 3, 3][k], lift = [0, 1, 2][k]; for (let row = 0; row < top; row++) { const w = half - row * 3 - Math.round(2 * hash(row, k)), y = 45 - lift - row; for (let x = 24 - w; x <= 24 + w; x++) { const r = Math.abs(x - 24) / w; // denser at the core, ragged toward the rolling ends if (!chk(x, y) || (k === 1 && r > 0.6 && (x + row) % 4 === 0) || (k === 2 && (x + row) % 4 !== 1)) continue; g.pixel(x, y, k === 0 ? (r < 0.5 ? "n4" : "n3") : k === 1 ? (r < 0.4 ? "n4" : "n3") : "n2"); } } });});submit(Portcullis, { size: [48, 48], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 0.8, loop: true }, anchor: { x: 24, y: 45 }, variants: [ { name: "closed", args: ["closed"] }, { name: "raise", args: ["raise"], animate: { fps: 10, duration: 1.0, loop: false } }, { name: "raised", args: ["raised"] }, { name: "drop", args: ["drop"], animate: { fps: 10, duration: 0.6, loop: false } }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。