Dungeon Mechanisms · teleport_rune.js
転送の紋
48×48 の床置きの小物で、敷石を並べた低く丸い台座です。縁石の輪、光をはめ込んだ溝、12 個の紋字を彫った一段低い帯、内側の溝、内側 4 枚の敷石にまたがる羅針盤のような星形の印があります。dormant(12 コマ)は紋字がかすかに灯り、外の溝を淡い光がゆっくり一周します。activate(10 コマの一回再生)は火花が溝を走って紋字を灯し、紋字が回り、印が光って光の柱が立ち、active の最初のコマで終わります。active(12 コマ)は紋字の帯が回り、光の粒が昇ります。どれも紫の変種があります。
6×
動くスプライト素材: Dungeon Mechanisms(itch.io)Vol.2・罠と扉と壊れる物 12 点
冒頭のコメント。これが指示書です。サイズ、視点、光の向き、すべての変種が書かれています。
teleport_rune.js · 1–8
// Teleport rune — 48x48 floor prop, top-down 3/4, key light top-left.// A low round dais of flagstones: a ring of curb stones, a carved channel inlaid with light, a recessed band of// twelve runes, an inner channel, and a compass-star sigil cut through the four inner flagstones.// Variants (all light in glow0..3; violet = swap("glow","v")):// dormant 12f loop runes barely lit; a slow shimmer walks once round the outer channel// activate 10f once a spark races round the channel lighting every rune, the runes whirl up, the sigil flares// and a column of light bursts up; ends on active frame 0// active 12f loop bright channels, the rune band turns (4 runes per loop), motes rise through a light column最後の数行。submit でキャンバスの大きさと、描画エンジンが書き出す変種の一覧を指定します。
teleport_rune.js · 241–254
submit(Rune, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: CX, y: GROUND }, args: ["dormant"], variants: [ { name: "dormant" }, { name: "activate", args: ["activate"], animate: { fps: 10, duration: 1.0, loop: false } }, { name: "active", args: ["active"] }, { name: "dormant-violet", repalette: swap("glow", "v") }, { name: "activate-violet", args: ["activate"], animate: { fps: 10, duration: 1.0, loop: false }, repalette: swap("glow", "v") }, { name: "active-violet", args: ["active"], repalette: swap("glow", "v") }, ],});プログラム全体を表示 (254 行)
teleport_rune.js · 254 行
// Teleport rune — 48x48 floor prop, top-down 3/4, key light top-left.// A low round dais of flagstones: a ring of curb stones, a carved channel inlaid with light, a recessed band of// twelve runes, an inner channel, and a compass-star sigil cut through the four inner flagstones.// Variants (all light in glow0..3; violet = swap("glow","v")):// dormant 12f loop runes barely lit; a slow shimmer walks once round the outer channel// activate 10f once a spark races round the channel lighting every rune, the runes whirl up, the sigil flares// and a column of light bursts up; ends on active frame 0// active 12f loop bright channels, the rune band turns (4 runes per loop), motes rise through a light columnconst W = 48, H = 48, CX = 24, CY = 30, RX = 22, RY = 13, GROUND = 45;const S_G1 = 0.87, S_G2 = 0.46; // outer / inner channel (ellipse scale)const S_MID = (S_G1 + S_G2) / 2; // rune band centrelineconst DEG = Math.PI / 180;// ---- static per-pixel map -----------------------------------------------------------------------------const NONE = 0, FACE = 1, CURB = 2, G1 = 3, BAND = 4, G2 = 5, INNER = 6;const rOf = (x, y) => Math.hypot((x - CX) / RX, (y - CY) / RY);const inS = (x, y, s) => rOf(x, y) <= s - 0.01; // the margin trims the 1px nubs at the polesconst ringPx = (x, y, s) => inS(x, y, s) && [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([a, b]) => !inS(x + a, y + b, s));const code = new Uint8Array(W * H), ang = new Float32Array(W * H);for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const i = y * W + x; ang[i] = ((Math.atan2((y - CY) / RY, (x - CX) / RX) / DEG) + 360) % 360; // 0 = right, 90 = front if (!inS(x, y, 1)) { code[i] = y > CY && (inS(x, y - 1, 1) || inS(x, y - 2, 1)) ? FACE : NONE; continue; } code[i] = ringPx(x, y, S_G1) ? G1 : inS(x, y, S_G1) === false ? CURB : ringPx(x, y, S_G2) ? G2 : inS(x, y, S_G2) ? INNER : BAND;}const at = (x, y) => (x < 0 || y < 0 || x >= W || y >= H ? NONE : code[y * W + x]);// band pixels touching a channel: they catch spill light when active; the channel's upper-left lip shades the bandconst nearG = (x, y) => [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([a, b]) => { const c = at(x + a, y + b); return c === G1 || c === G2; });// angular distance (deg, 0..180)const adist = (a, b) => { const d = Math.abs(((a - b) % 360 + 360) % 360); return Math.min(d, 360 - d); };// swept(a, from, len): is angle a inside the arc that starts at `from` and runs `len` degrees clockwise?const swept = (a, from, len) => ((a - from) % 360 + 360) % 360 <= len;// ---- runes: twelve slots, four glyphs repeating, so turning by four slots (120 deg) closes the loop ------const GLYPHS = [ ["xx.", "x.x", "x.."], // thorn ["x.x", ".x.", ".x."], // elk [".x.", "x.x", ".x."], // ing ["x.x", "xxx", "x.x"], // hagal];const runeAt = (k, off) => { const a = (k * 30 + off) * DEG; return { x: Math.round(CX + Math.cos(a) * RX * S_MID), y: Math.round(CY + Math.sin(a) * RY * S_MID), a: ((k * 30 + off) % 360 + 360) % 360 };};// ---- stone -------------------------------------------------------------------------------------------const stoneHash = (a) => hash(Math.floor(((a + 15) % 360) / 30), 5); // one value per curb stonefunction dais(g, L) { g.part("dais", () => g.field({ x: [1, 46], y: [15, 46] }, (x, y) => { const c = at(x, y), i = y * W + x, a = ang[i]; const nx = (x - CX) / RX; if (c === NONE) return null; if (c === FACE) { // the dais' side: lit on the left, falling away on the right const top = !inS(x, y - 1, 1) ? false : true; if (nx < -0.62) return top ? "n2" : "n1"; if (nx > 0.55) return "n0"; return top ? "n1" : "n0"; } if (c === CURB) { const edge = !inS(x - 1, y, 1) || !inS(x, y - 1, 1) || !inS(x + 1, y, 1) || !inS(x, y + 1, 1); if (edge && a > 150 && a < 290) return "n4"; // outer lip catches the light (upper left) if (edge && a > 10 && a < 110) return "n2"; // lower right lip in shade return stoneHash(a) < 0.3 ? "n2" : "n3"; } if (c === G1 || c === G2) return L.groove(x, y, a, c); if (c === BAND) { if (L.spill && nearG(x, y)) return "glow0"; // carved band: the upper-left wall of the channel shades the band just inside it const shade = at(x, y - 1) === G1 || (at(x - 1, y) === G1 && a > 180); return shade ? "n0" : "n1"; } // INNER: four flagstones cut by a glowing cross; a diamond sigil at the centre const dx = x - CX, dy = y - CY, r = rOf(x, y); const ring = ringPx(x, y, 0.24) && !(dx === 0 && dy === 0); const spoke = r > 0.26 && Math.abs(Math.abs(dx) / RX - Math.abs(dy) / RY) < 0.024; // the flagstone seams if (dx === 0 && dy === 0) return L.sigil(x, y, true); if (ring || spoke) return L.sigil(x, y, false); if (L.pool) return r < 0.24 ? L.pool[1] : L.pool[0]; // flat flagstones; the lower-right lip of each cut seam faces the light const cut = (u, v) => ringPx(u, v, 0.24) || (rOf(u, v) > 0.26 && Math.abs(Math.abs(u - CX) / RX - Math.abs(v - CY) / RY) < 0.024) || at(u, v) === G2; return cut(x - 1, y) || cut(x, y - 1) ? "n3" : "n2"; }));}function curbMarks(g) { g.part("curbSeams", () => { for (let j = 0; j < 12; j++) { const a = (j * 30 + 15) * DEG, c = Math.cos(a), s = Math.sin(a); const p0 = [Math.round(CX + c * RX * 0.92), Math.round(CY + s * RY * 0.92)]; const p1 = [Math.round(CX + c * RX * 0.99), Math.round(CY + s * RY * 0.99)]; g.line(p0, p1, "n1"); if (s > 0.2) { const fx = Math.round(CX + c * RX); const fy = Math.round(CY + s * RY) + 1; g.line([fx, fy], [fx, fy + 1], "ink"); } } g.pixel(8, 23, "n4"); g.pixel(37, 20, "n2"); g.pixel(41, 38, "n1"); g.pixel(12, 40, "n4"); // chips });}// ---- light layers --------------------------------------------------------------------------------------function runes(g, off, lit) { g.part("runes", () => { for (let k = 0; k < 12; k++) { const r = runeAt(k, off), gl = GLYPHS[k % 4], key = lit(r.a, k); if (!key) continue; for (let j = 0; j < 3; j++) for (let i = 0; i < 3; i++) if (gl[j][i] === "x") g.pixel(r.x - 1 + i, r.y - 1 + j, key); } });}const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];function column(g, t, hgt, bright) { // an opaque shaft of light standing on the inner disc: hot core, softer body, dim edges; bright bands (seen as // near arcs of a ring) climb it, a few sparks streak up, and the top dissolves in a dither. Periodic in t. const RC = bright ? 9 : 7; const TONE = ["glow1", "glow2", "glow3", "n5"]; g.detached("column", () => g.field({ x: [CX - 10, CX + 10], y: [0, CY + 6] }, (x, y) => { const h0 = CY - y; // height above the disc centre const wob = 1 + 0.04 * Math.sin(TAU * (h0 / 9 - 2 * t)); // the shaft breathes as it rises const dx = Math.abs(x - CX) / (RC * wob); if (dx > 1) return null; const arc = Math.sqrt(Math.max(0, 1 - dx * dx)); const yb = CY + Math.round(4.8 * arc); // front edge of the base const h = yb - y; // height above the floor if (h < 0 || h > hgt) return null; const top = hgt - h; // distance below the column's top const fade = top / (hgt * 0.45); // ordered-dither dissolve over the top 45% if (fade < 1 && BAYER[(y & 3) * 4 + (x & 3)] / 16 >= fade * fade * 0.9 + 0.05) return null; let k = dx < 0.3 ? 2 : dx < 0.72 ? 1 : 0; if (bright) k = dx < 0.8 ? 2 : 1; if (h === 0) k = Math.max(k, 2); // hot seam where the beam meets the floor for (let j = 0; j < 3; j++) { // bright bands climbing, curved like the near arc of a ring const hr = ((t + j / 3) % 1) * (hgt - 2); const d = y - (CY - Math.round(hr) + Math.round(2 * arc)); if (d === 0 || d === 1) { k += 1; break; } } if (!bright && dx > 0.3 && dx < 0.72 && hash(x, 11) > 0.5) { // sparks streaking up the body const s = ((y + Math.round(t * 24) + Math.floor(hash(x, 13) * 12)) % 12 + 12) % 12; if (s < 2) k = Math.max(k, 2); } return TONE[Math.min(3, k)]; }));}function motes(g, t, maxAge) { g.detached("motes", () => { for (let i = 0; i < 9; i++) { const p = (t * (i % 3 === 0 ? 2 : 1) + hash(i, 21)) % 1; if (p > maxAge) continue; const a = (hash(i, 23) * 360) * DEG, rr = 0.55 + 0.35 * hash(i, 29); const x = Math.round(CX + Math.cos(a) * RX * rr + Math.sin(TAU * (p + hash(i, 31))) * 1.2); const y = Math.round(CY + Math.sin(a) * RY * rr - p * 22); if (y < 0) continue; const key = p < 0.35 ? "glow3" : p < 0.7 ? "glow2" : "glow1"; g.pixel(x, y, key); if (p < 0.15) { g.pixel(x, y + 1, "glow2"); } } });}// ---- states --------------------------------------------------------------------------------------------function dormantLight(t) { const head = (90 + 360 * t) % 360; // the shimmer walks once round per loop return { groove: (x, y, a, c) => { if (c === G2) return "glow0"; const d = ((head - a) % 360 + 360) % 360; // how far behind the shimmer's head return d < 6 ? "glow3" : d < 22 ? "glow2" : d < 45 ? "glow1" : "glow0"; }, sigil: (x, y, centre) => (centre ? "glow1" : "glow0"), rune: (a) => (adist(a, head - 12) < 16 ? "glow2" : "glow1"), spill: false, pool: null, };}function activeLight(t) { const p1 = (90 + 120 * t) % 360, p2 = (p1 + 180) % 360; // two pulses ride round with the runes return { groove: (x, y, a, c) => { if (c === G2) return adist(a, (270 - 120 * t + 360) % 360) < 20 ? "glow3" : "glow2"; return adist(a, p1) < 14 || adist(a, p2) < 14 ? "glow3" : "glow2"; }, sigil: () => "glow3", rune: () => "glow3", spill: true, pool: ["glow1", "glow2"], };}const ACT = 10;const SPIN = [0, 5, 15, 30, 50, 70, 88, 102, 112, 120]; // rune whirl during activation (ends = 4 slots)function activateLight(f) { const d = dormantLight(0); const len = Math.min(360, 72 * (f + 1)); // the racing spark's swept arc from the front const head = (90 + len) % 360; const lit = (a) => len >= 360 || swept(a, 90, len); return { groove: (x, y, a, c) => { if (c === G2) return f >= 5 ? "glow2" : f === 4 ? "glow1" : "glow0"; if (len < 360 && adist(a, head) < 9) return "glow3"; if (f === 4 && len >= 360) return "glow3"; // the ring closes with a flash return lit(a) ? "glow2" : d.groove(x, y, a, c); }, sigil: (x, y, centre) => (f >= 5 ? "glow3" : f === 4 ? "glow2" : d.sigil(x, y, centre)), rune: (a) => (len < 360 && adist(a, head) < 20 ? "glow3" : lit(a) ? (f >= 4 ? "glow3" : "glow2") : "glow1"), spill: f >= 5, pool: f >= 6 ? ["glow1", "glow2"] : f === 5 ? ["glow0", "glow1"] : null, };}function spark(g, f) { // the spark at the head of the racing light: a small star if (f > 3) return; const a = (90 + 72 * (f + 1)) * DEG; const x = Math.round(CX + Math.cos(a) * RX * S_G1), y = Math.round(CY + Math.sin(a) * RY * S_G1); g.detached("spark", () => { for (const [ox, oy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) g.pixel(x + ox, y + oy, "glow3"); g.pixel(x, y - 2, "glow2"); g.pixel(x, y, "n5"); });}const Rune = sprite(pal, (g, t, state) => { let L, off = 0, ct = t, colH = 0, bright = false, age = 1; if (state === "dormant") { L = dormantLight(t); age = -1; } else if (state === "active") { L = activeLight(t); off = 120 * t; colH = 34; } else { // activate: frame-indexed const f = Math.round(t * ACT); ct = ((f - (ACT - 1)) / 12 + 1) % 1; // same 10 fps clock as the active loop off = SPIN[f] % 360; if (f >= ACT - 1) L = activeLight(0); else L = activateLight(f); colH = [0, 0, 0, 0, 0, 22, 40, 36, 34, 34][f]; bright = f === 5 || f === 6; age = f >= 7 ? (f - 6) * 0.34 : -1; if (f < ACT - 1) spark(g, f); } contactShadow(g, CX, GROUND, 23, 2); g.outlined("ink", () => dais(g, L)); curbMarks(g); runes(g, off, (a) => L.rune(a)); if (colH > 0) column(g, ct, colH, bright); if (age > 0) motes(g, ct, age);});submit(Rune, { size: [W, H], origin: "top-left", kind: "icon", scale: 1, animate: { fps: 10, duration: 1.2, loop: true }, anchor: { x: CX, y: GROUND }, args: ["dormant"], variants: [ { name: "dormant" }, { name: "activate", args: ["activate"], animate: { fps: 10, duration: 1.0, loop: false } }, { name: "active", args: ["active"] }, { name: "dormant-violet", repalette: swap("glow", "v") }, { name: "activate-violet", args: ["activate"], animate: { fps: 10, duration: 1.0, loop: false }, repalette: swap("glow", "v") }, { name: "active-violet", args: ["active"], repalette: swap("glow", "v") }, ],});掲載されたコードでは、ビルドスクリプトが自動付与する共通部分を省略しています。具体的には 1 行のランタイムヘッダー、共有パレット、および汎用ヘルパー関数(contactShadow、litIndex、flame、hash、chk、TAU)です。表示されている行番号は実際のファイルと一致しています。これらのプログラムは AIエージェントが生成し、描画されたコマの仕上がりを確認しながら修正を加えたものです。
エージェントに、こうしたプログラムを書かせる。
Tessarune は描画 API のリファレンス、25 冊の制作マニュアル、結果を確かめるための描画エンジンをエージェントに渡します。