Tessarune
Dungeon Mechanisms · portcullis.js

落とし格子

48×48 · JavaScript 303 行

48×48 の正面向きの小物で、先の尖った鉄の格子が、地下牢の扉と同じ切石のアーチと壁の溝の中を上下します。すき間からは奥の暗い通路が見えます。格子は丸い鉄の棒として陰影がつき、アーチが格子の上部に影を落とし、棒の影が通路の床に縞を描きます。closed(8 コマ)は中央の横棒を光が走ります。raise(10 コマの一回再生)は鎖が 2 度滑りながら格子ががくがくと引き上げられます。raised(8 コマ)はアーチの下に歯先だけが残り、drop(6 コマの一回再生)は格子が落ちて跳ね、足元に土ぼこりが広がります。

落とし格子 — 描画結果6×
このプログラムで描画(変種「drop」) · 48×48 · 6 コマ
Dungeon Mechanisms動くスプライト素材: 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 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。