/* global AFRAME, THREE, Astro */ // A Thelemic temple of two rooms, each furnished as Crowley describes it. // // The hall is the temple of Liber XV, the Gnostic Mass ("Of the Furnishings of // the Temple"): the High Altar (7 ft by 3 ft by 44 in, crimson, with a gold // sunblaze) on a dais of three black-and-white steps between two countercharged // pillars; the super-altar with the Stele of Revealing between 4 + 4 candles, // The Book of the Law between 6 + 6, the Graal between roses with the Paten // before it and two great candles beyond; the Veil; and down the middle, on // three equal equilateral triangles from the line between the pillars, the // small black altar of superimposed cubes, the round font and the upright tomb. // Its East, like the Mass's, is the direction of Boleskine. // // North of it, through a short passage, is the working chamber of Book 4, // Part II. The room is a Vesica Piscis (ch. I). On the floor is the Circle of // ch. II as drawn in its figure: green, with the names of God in vermilion, the // vermilion Tau of ten squares, the three diamonds of the escribed triangle and // nine lamps on pentagrams outside it. Every size in it follows from one square // of the Tau, which is the base of the altar: a double cube as high as the // magician's navel, gold on top, with the weapons on it (ch. III-IX). The Lamp // hangs above it (ch. X) under an opening to the real sky, and the Magick Fire // burns on its open brass altar in the East (ch. XVI). Banners of the four // adorations of Liber Resh hang at the quarters; the one whose time it is now // is lit, and a small gold sun on the wall shows where the Sun really is. // // Ritual East is -z throughout, and ritual North is -x. Nothing is loaded: // the shapes are built here and the paintings are drawn on canvases. (function () { const FT = 0.3048; const IN = 0.0254; const SQRT3 = Math.sqrt(3); const RAD = Math.PI / 180; // Liber XV: "In the East, that is, in the direction of Boleskine". const BOLESKINE = { lat: 57.2430, lon: -4.4750 }; // ---- The hall ---- const HALL = { x: 5, east: -10, west: 4.2, height: 8 }; const DAIS = { x: 3, front: -6.9, tread: 0.35, rise: 0.18 }; const TOP = DAIS.rise * 3; // the dais floor const PLATFORM_FRONT = DAIS.front - 2 * DAIS.tread; const PILLAR = { x: 2.2, z: -8, r: 0.24, h: 4.4 }; // Each triangle is equilateral on the line between the pillars. const TRI = PILLAR.x * SQRT3; const SMALL_ALTAR = { z: PILLAR.z + TRI, size: 0.42 }; const FONT = { z: PILLAR.z + 2 * TRI, r: 0.3 }; const TOMB = { z: PILLAR.z + 3 * TRI, w: 0.9, d: 0.7, h: 2.3 }; const HIGH_ALTAR = { w: 7 * FT, d: 3 * FT, h: 44 * IN, z: -9 }; const ALTAR_TOP = TOP + HIGH_ALTAR.h; const ALTAR_BACK = HIGH_ALTAR.z - HIGH_ALTAR.d / 2; const VEIL = { z: PILLAR.z, half: PILLAR.x - PILLAR.r, top: 4.3 }; const BENCHES = [-3.6, -1.4]; // along both side walls, facing the middle // ---- The passage from the hall's north wall ---- const DOOR = { z0: 0.6, z1: 2.4, h: 2.8 }; // ---- The chamber ---- // A vesica: where two circles of radius r, r apart, overlap. Long axis E-W. const VESICA = { x: -13.5, z: 0, r: 11, height: 7 }; // One square of the Tau = the base of the altar (a double cube, navel high). const SQ = 0.525; // Radii of the figure in ch. II, in squares, measured from the plate. const CIRCLE = { x: VESICA.x, z: VESICA.z, inner: 6.22 * SQ, outer: 7.28 * SQ, lamps: 8.5 * SQ, diamonds: 4.9 * SQ }; const FIRE = { x: VESICA.x, z: VESICA.z - 6.3 }; const OCULUS = 1.2; const LECTERN = { x: CIRCLE.x + 2 * SQ, z: CIRCLE.z + 2 * SQ }; // How near a wall or a piece of furniture your head may come. const MARGIN = 0.22; // The four adorations, at the chamber's quarters (ritual East is -z). const RESH = [ { name: 'RA', time: 'sunrise', angle: 0, field: ['#f2e27a'], ink: '#6b4a00', glyph: 'rising' }, { name: 'AHATHOOR', time: 'noon', angle: 90, field: ['#e0461c'], ink: '#ffe9a8', glyph: 'horns' }, { name: 'TUM', time: 'sunset', angle: 180, field: ['#1d3c9c'], ink: '#f2d58a', glyph: 'setting' }, { name: 'KHEPHRA', time: 'midnight', angle: 270, field: ['#b8a23a', '#6b6b28', '#7a3d1d', '#141414'], ink: '#f2d58a', glyph: 'scarab' }, ]; // --------------------------------------------------------------------------- // Small helpers function mulberry32(seed) { return function () { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // Compass bearing from one place to another along the great circle (deg). function bearing(from, to) { const p1 = from.lat * RAD; const p2 = to.lat * RAD; const dl = (to.lon - from.lon) * RAD; const y = Math.sin(dl) * Math.cos(p2); const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl); return (Math.atan2(y, x) / RAD + 360) % 360; } // sky.js's `south` (an angle like campsite.js's polar()) for a scene whose // -z points along compass bearing `east`. South is 180 - east degrees // clockwise from -z, which polar() puts at 180 - (180 - east). function skySouth(east) { return ((east % 360) + 360) % 360; } function canvasTexture(w, h, draw, repeat) { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; draw(canvas.getContext('2d'), w, h); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = 4; if (repeat) texture.wrapS = texture.wrapT = THREE.RepeatWrapping; return texture; } function glowTexture(inner, outer) { return canvasTexture(64, 64, (ctx, w) => { const g = ctx.createRadialGradient(w / 2, w / 2, 0, w / 2, w / 2, w / 2); g.addColorStop(0, inner); g.addColorStop(0.35, outer); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, w); }); } // A box whose UVs are in metres, so a repeating texture keeps its scale. function meterBox(w, h, d) { const geometry = new THREE.BoxGeometry(w, h, d); const uv = geometry.attributes.uv; // Face order: +x, -x, +y, -y, +z, -z; four vertices each. const spans = [[d, h], [d, h], [w, d], [w, d], [w, h], [w, h]]; for (let f = 0; f < 6; f++) { for (let i = 0; i < 4; i++) { const k = f * 4 + i; uv.setXY(k, uv.getX(k) * spans[f][0], uv.getY(k) * spans[f][1]); } } return geometry; } // Upright quads with UVs in metres, all in one geometry. Each faces the // side its `inside` point is on. function QuadBatch() { const pos = []; const nor = []; const uv = []; const idx = []; return { add(a, b, y0, y1, inside, u0 = 0) { const dx = b.x - a.x; const dz = b.z - a.z; const len = Math.hypot(dx, dz); let nx = -dz / len; let nz = dx / len; const flip = (inside.x - a.x) * nx + (inside.z - a.z) * nz < 0; if (flip) { nx = -nx; nz = -nz; } const base = pos.length / 3; pos.push(a.x, y0, a.z, b.x, y0, b.z, b.x, y1, b.z, a.x, y1, a.z); for (let i = 0; i < 4; i++) nor.push(nx, 0, nz); uv.push(u0, y0, u0 + len, y0, u0 + len, y1, u0, y1); if (flip) idx.push(base, base + 2, base + 1, base, base + 3, base + 2); else idx.push(base, base + 1, base + 2, base, base + 2, base + 3); return len; }, geometry() { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3)); g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); g.setIndex(idx); return g; }, }; } // A horizontal rectangle at height y, facing up or down, UVs in metres. function flat(x0, z0, x1, z1, y, down) { const g = new THREE.PlaneGeometry(x1 - x0, z1 - z0); const uv = g.attributes.uv; for (let i = 0; i < uv.count; i++) uv.setXY(i, uv.getX(i) * (x1 - x0), uv.getY(i) * (z1 - z0)); g.rotateX(down ? Math.PI / 2 : -Math.PI / 2); g.translate((x0 + x1) / 2, y, (z0 + z1) / 2); return g; } // A tube along any function of t in [0, 1]. function tube(fn, radius, segments = 24, radial = 6) { const curve = new THREE.Curve(); curve.getPoint = (t, target = new THREE.Vector3()) => target.copy(fn(t)); return new THREE.TubeGeometry(curve, segments, radius, radial, false); } // Which side of the vesica's long axis a point is on, and the half width there. function vesicaHalf(z) { const dz = z - VESICA.z; return Math.sqrt(Math.max(0, VESICA.r * VESICA.r - dz * dz)) - VESICA.r / 2; } // --------------------------------------------------------------------------- // Paintings function stoneTexture(seed, base, joint) { const rand = mulberry32(seed); return canvasTexture(512, 512, (ctx, w) => { ctx.fillStyle = joint; ctx.fillRect(0, 0, w, w); // 2 m square: flagstones of 1 m by 0.5 m, staggered. const unit = w / 2; for (let row = 0; row < 4; row++) { const off = (row % 2) * unit / 2; for (let col = -1; col < 2; col++) { const shade = 0.85 + rand() * 0.3; const c = new THREE.Color(base).multiplyScalar(shade); ctx.fillStyle = `#${c.getHexString()}`; ctx.fillRect(col * unit + off + 2, row * unit / 2 + 2, unit - 4, unit / 2 - 4); } } for (let i = 0; i < 6000; i++) { ctx.fillStyle = `rgba(${rand() < 0.5 ? '0,0,0' : '255,255,255'},${rand() * 0.06})`; ctx.fillRect(rand() * w, rand() * w, 1 + rand() * 3, 1 + rand() * 3); } }, true); } function plasterTexture(seed) { const rand = mulberry32(seed); return canvasTexture(256, 256, (ctx, w) => { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, w); for (let i = 0; i < 400; i++) { const r = 4 + rand() * 30; const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r); g.addColorStop(0, `rgba(0,0,0,${0.03 + rand() * 0.05})`); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.save(); ctx.translate(rand() * w, rand() * w); ctx.fillStyle = g; ctx.fillRect(-r, -r, 2 * r, 2 * r); ctx.restore(); } }, true); } function checkerTexture(a, b) { return canvasTexture(64, 64, (ctx) => { ctx.fillStyle = a; ctx.fillRect(0, 0, 64, 64); ctx.fillStyle = b; ctx.fillRect(0, 0, 32, 32); ctx.fillRect(32, 32, 32, 32); }, true); } // Black and white, countercharged: a checker that wraps a pillar. function pillarTexture(invert) { const [a, b] = invert ? ['#f1ece0', '#111111'] : ['#111111', '#f1ece0']; return canvasTexture(128, 512, (ctx) => { for (let row = 0; row < 16; row++) { for (let col = 0; col < 4; col++) { ctx.fillStyle = (row + col) % 2 ? a : b; ctx.fillRect(col * 32, row * 32, 32, 32); } } }); } function drawSunblaze(ctx, x, y, r, rays) { ctx.save(); ctx.translate(x, y); ctx.fillStyle = '#e7b94a'; for (let i = 0; i < rays; i++) { const a = (i / rays) * Math.PI * 2; const long = i % 2 ? 1.55 : 2.1; ctx.beginPath(); ctx.moveTo(Math.cos(a - 0.09) * r * 0.9, Math.sin(a - 0.09) * r * 0.9); ctx.lineTo(Math.cos(a) * r * long, Math.sin(a) * r * long); ctx.lineTo(Math.cos(a + 0.09) * r * 0.9, Math.sin(a + 0.09) * r * 0.9); ctx.fill(); } const g = ctx.createRadialGradient(-r * 0.3, -r * 0.3, 0, 0, 0, r); g.addColorStop(0, '#fff0b0'); g.addColorStop(1, '#d19a2a'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } function drawFleur(ctx, x, y, s) { ctx.save(); ctx.translate(x, y); ctx.scale(s, s); ctx.fillStyle = '#e1b04a'; ctx.beginPath(); ctx.moveTo(0, -10); ctx.bezierCurveTo(5, -5, 5, 2, 0, 6); ctx.bezierCurveTo(-5, 2, -5, -5, 0, -10); ctx.fill(); for (const side of [-1, 1]) { ctx.beginPath(); ctx.moveTo(side * 1.5, 4); ctx.bezierCurveTo(side * 9, 2, side * 11, -6, side * 6, -7); ctx.bezierCurveTo(side * 8, -3, side * 6, 2, side * 1.5, 6); ctx.fill(); } ctx.fillRect(-6, 5, 12, 2.5); ctx.beginPath(); ctx.moveTo(-2, 7); ctx.lineTo(2, 7); ctx.lineTo(0, 12); ctx.fill(); ctx.restore(); } // The High Altar's crimson cloth, front face. function altarClothTexture() { return canvasTexture(1024, 540, (ctx, w, h) => { const g = ctx.createLinearGradient(0, 0, w, 0); for (let i = 0; i <= 16; i++) g.addColorStop(i / 16, i % 2 ? '#7d0d17' : '#9a1420'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = '#d9a640'; ctx.lineWidth = 6; ctx.strokeRect(22, 22, w - 44, h - 70); ctx.lineWidth = 2; ctx.strokeRect(34, 34, w - 68, h - 94); // Gold fringe along the hem. ctx.fillStyle = '#c99a3a'; for (let x = 0; x < w; x += 7) ctx.fillRect(x, h - 38, 3, 38); drawSunblaze(ctx, w / 2, h / 2 - 16, 72, 24); for (const [x, y] of [[110, 110], [w - 110, 110], [110, h - 140], [w - 110, h - 140]]) drawFleur(ctx, x, y, 3.2); }); } // The winged disk, as on the Stele and over doors in Egypt. function drawWingedDisk(ctx, x, y, span, h) { ctx.save(); ctx.translate(x, y); const colors = ['#2a55a8', '#2f8a5a', '#c43424', '#e3b54a']; for (const side of [-1, 1]) { for (let row = 0; row < 4; row++) { ctx.fillStyle = colors[row]; ctx.beginPath(); const top = -h * 0.35 + row * h * 0.17; const len = span / 2 * (1 - row * 0.12); ctx.moveTo(side * h * 0.3, top); ctx.quadraticCurveTo(side * len * 0.6, top - h * 0.25 + row * h * 0.05, side * len, top - h * 0.05); ctx.lineTo(side * len * 0.97, top + h * 0.16); ctx.quadraticCurveTo(side * len * 0.55, top + h * 0.02, side * h * 0.3, top + h * 0.2); ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.lineWidth = Math.max(1, h * 0.012); for (let f = 1; f < 9; f++) { const fx = side * (h * 0.3 + (len - h * 0.3) * f / 9); ctx.beginPath(); ctx.moveTo(fx, top - h * 0.1 * (f / 9)); ctx.lineTo(fx + side * h * 0.02, top + h * 0.15); ctx.stroke(); } } } // Two uraei hanging from the disk. ctx.strokeStyle = '#e3b54a'; ctx.lineWidth = h * 0.06; for (const side of [-1, 1]) { ctx.beginPath(); ctx.moveTo(side * h * 0.2, h * 0.15); ctx.quadraticCurveTo(side * h * 0.34, h * 0.35, side * h * 0.22, h * 0.48); ctx.stroke(); } const g = ctx.createRadialGradient(-h * 0.08, -h * 0.08, 0, 0, 0, h * 0.3); g.addColorStop(0, '#ff6a4a'); g.addColorStop(1, '#a8160e'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, h * 0.3, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } // A painted stand-in for the Stele of Revealing (Cairo 9422): Nuit arched // over the winged disk, Ra-Hoor-Khuit enthroned, the priest Ankh-af-na-khonsu // before an offering table, and the columns and lines of the inscription. function steleTexture() { const rand = mulberry32(718); return canvasTexture(512, 850, (ctx, w, h) => { const r = w / 2; ctx.beginPath(); ctx.moveTo(0, h); ctx.lineTo(0, r); ctx.arc(r, r, r, Math.PI, Math.PI * 2); ctx.lineTo(w, h); ctx.closePath(); ctx.save(); ctx.clip(); const bg = ctx.createLinearGradient(0, 0, 0, h); bg.addColorStop(0, '#e8d6a6'); bg.addColorStop(1, '#d9c08a'); ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); for (let i = 0; i < 2500; i++) { ctx.fillStyle = `rgba(90,60,20,${rand() * 0.08})`; ctx.fillRect(rand() * w, rand() * h, 2, 2); } // Nuit: arms down on the left, legs down on the right, star-spangled. ctx.strokeStyle = '#1f3f8f'; ctx.lineWidth = 30; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(34, 330); ctx.lineTo(34, r); ctx.arc(r, r, r - 34, Math.PI, Math.PI * 2); ctx.lineTo(w - 34, 330); ctx.stroke(); ctx.fillStyle = '#f0cf6a'; for (let i = 0; i < 70; i++) { const a = Math.PI + rand() * Math.PI; const rr = r - 34 + (rand() - 0.5) * 20; ctx.fillRect(r + Math.cos(a) * rr - 1.5, r + Math.sin(a) * rr - 1.5, 3, 3); } drawWingedDisk(ctx, r, 190, 330, 64); // Columns of text above the figures. const glyph = (x, y, s, color) => { ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineWidth = 2; const k = Math.floor(rand() * 6); if (k === 0) { ctx.beginPath(); ctx.arc(x, y, s * 0.35, 0, Math.PI * 2); ctx.stroke(); } else if (k === 1) ctx.fillRect(x - s * 0.4, y - s * 0.1, s * 0.8, s * 0.2); else if (k === 2) { ctx.beginPath(); ctx.moveTo(x - s * 0.4, y + s * 0.3); ctx.quadraticCurveTo(x, y - s * 0.5, x + s * 0.4, y + s * 0.3); ctx.stroke(); } else if (k === 3) ctx.fillRect(x - s * 0.08, y - s * 0.4, s * 0.16, s * 0.8); else if (k === 4) { ctx.beginPath(); ctx.moveTo(x - s * 0.35, y + s * 0.35); ctx.lineTo(x, y - s * 0.35); ctx.lineTo(x + s * 0.35, y + s * 0.35); ctx.stroke(); } else { ctx.beginPath(); ctx.ellipse(x, y, s * 0.4, s * 0.2, 0, 0, Math.PI * 2); ctx.fill(); } }; const inks = ['#1d1a18', '#1f3f8f', '#9b2a1c', '#2f6e4a']; for (let col = 0; col < 7; col++) { for (let row = 0; row < 4; row++) glyph(150 + col * 34, 262 + row * 22, 15, inks[(col + row) % 4]); } ctx.fillStyle = '#1d1a18'; ctx.fillRect(40, 352, w - 80, 3); // Ra-Hoor-Khuit, hawk-headed, on his throne, facing right. ctx.fillStyle = '#20201e'; ctx.fillRect(84, 440, 60, 92); ctx.fillStyle = '#e8d6a6'; ctx.fillRect(92, 448, 44, 40); ctx.fillStyle = '#2f7a55'; ctx.fillRect(112, 400, 34, 62); // torso ctx.fillRect(130, 452, 48, 22); // thighs ctx.fillRect(160, 470, 18, 62); // shins ctx.fillStyle = '#f2ead2'; ctx.fillRect(112, 448, 66, 20); // kilt ctx.fillStyle = '#2f5a8a'; ctx.beginPath(); ctx.arc(130, 386, 18, 0, Math.PI * 2); // the hawk's head ctx.fill(); ctx.beginPath(); ctx.moveTo(144, 380); ctx.lineTo(160, 392); ctx.lineTo(144, 394); ctx.fill(); ctx.fillStyle = '#c92b1c'; ctx.beginPath(); ctx.arc(130, 356, 15, 0, Math.PI * 2); // the sun on his head ctx.fill(); ctx.strokeStyle = '#1d1a18'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(176, 340); // the wand ctx.lineTo(176, 532); ctx.stroke(); ctx.beginPath(); ctx.moveTo(146, 420); ctx.lineTo(176, 420); ctx.stroke(); // The offering table. ctx.fillStyle = '#7a4a24'; ctx.fillRect(236, 470, 40, 62); ctx.fillStyle = '#b8862e'; for (let i = 0; i < 4; i++) ctx.fillRect(232 + i * 12, 450 - (i % 2) * 8, 10, 22); ctx.fillStyle = '#2f7a55'; ctx.beginPath(); ctx.moveTo(256, 446); ctx.lineTo(244, 418); ctx.lineTo(268, 418); ctx.fill(); // The priest, facing left, arms raised. ctx.fillStyle = '#f2ead2'; ctx.beginPath(); ctx.moveTo(352, 402); ctx.lineTo(380, 402); ctx.lineTo(392, 532); ctx.lineTo(338, 532); ctx.fill(); ctx.fillStyle = '#b8862e'; ctx.fillRect(344, 420, 44, 26); // the leopard skin ctx.fillStyle = '#7a4a24'; ctx.beginPath(); ctx.arc(364, 386, 16, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#1d1a18'; ctx.beginPath(); ctx.arc(368, 382, 17, Math.PI * 1.1, Math.PI * 2.4); ctx.fill(); ctx.strokeStyle = '#7a4a24'; ctx.lineWidth = 8; ctx.beginPath(); ctx.moveTo(356, 412); ctx.lineTo(318, 404); ctx.lineTo(312, 372); ctx.stroke(); ctx.fillStyle = '#1d1a18'; ctx.fillRect(40, 540, w - 80, 3); // The inscription below: eleven lines. for (let line = 0; line < 11; line++) { for (let x = 52; x < w - 52; x += 22) glyph(x, 566 + line * 24, 16, inks[Math.floor(rand() * 4)]); ctx.fillStyle = '#1d1a18'; ctx.fillRect(40, 578 + line * 24, w - 80, 1); } ctx.restore(); ctx.strokeStyle = '#5a3d1c'; ctx.lineWidth = 10; ctx.beginPath(); ctx.moveTo(5, h); ctx.lineTo(5, r); ctx.arc(r, r, r - 5, Math.PI, Math.PI * 2); ctx.lineTo(w - 5, h); ctx.stroke(); }); } function nuitTexture() { const rand = mulberry32(93); return canvasTexture(2048, 2048, (ctx, w, h) => { const g = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, w * 0.7); g.addColorStop(0, '#152a6a'); g.addColorStop(1, '#070d2a'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); const star = (x, y, r) => { ctx.beginPath(); for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + (i * Math.PI) / 5; const rr = i % 2 ? r * 0.4 : r; ctx.lineTo(x + Math.cos(a) * rr, y + Math.sin(a) * rr); } ctx.fill(); }; ctx.fillStyle = '#e8c46a'; for (let i = 0; i < 900; i++) { const r = rand() < 0.08 ? 10 + rand() * 12 : 2 + rand() * 4; if (r > 8) star(rand() * w, rand() * h, r); else ctx.fillRect(rand() * w, rand() * h, r * 0.6, r * 0.6); } ctx.strokeStyle = '#d9a640'; ctx.lineWidth = 14; ctx.strokeRect(10, 10, w - 20, h - 20); }); } // The unicursal hexagram, with a rose of five petals at its centre. function drawUnicursal(ctx, x, y, r, color, width) { const p = (deg) => [x + Math.cos(deg * RAD) * r, y - Math.sin(deg * RAD) * r]; const T = p(90); const B = p(270); const UL = p(150); const UR = p(30); const LL = p(210); const LR = p(330); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineJoin = 'miter'; ctx.beginPath(); for (const [i, pt] of [T, LL, UR, B, UL, LR, T].entries()) { if (i === 0) ctx.moveTo(pt[0], pt[1]); else ctx.lineTo(pt[0], pt[1]); } ctx.closePath(); ctx.stroke(); ctx.fillStyle = '#c4202a'; for (let i = 0; i < 5; i++) { const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5; ctx.beginPath(); ctx.arc(x + Math.cos(a) * r * 0.08, y + Math.sin(a) * r * 0.08, r * 0.08, 0, Math.PI * 2); ctx.fill(); } ctx.fillStyle = '#e8c46a'; ctx.beginPath(); ctx.arc(x, y, r * 0.05, 0, Math.PI * 2); ctx.fill(); } function hangingTexture() { return canvasTexture(256, 600, (ctx, w, h) => { const g = ctx.createLinearGradient(0, 0, w, 0); g.addColorStop(0, '#5a0a12'); g.addColorStop(0.5, '#8a1420'); g.addColorStop(1, '#5a0a12'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = '#d9a640'; ctx.lineWidth = 6; ctx.strokeRect(12, 12, w - 24, h - 24); drawUnicursal(ctx, w / 2, h * 0.42, w * 0.34, '#e1b04a', 7); ctx.fillStyle = '#d9a640'; for (let x = 0; x < w; x += 6) ctx.fillRect(x, h - 22, 2, 22); }); } function plaqueTexture() { return canvasTexture(512, 512, (ctx, w) => { ctx.fillStyle = '#0d0d12'; ctx.beginPath(); ctx.arc(w / 2, w / 2, w / 2 - 4, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#d9a640'; ctx.lineWidth = 8; ctx.stroke(); drawUnicursal(ctx, w / 2, w / 2, w * 0.38, '#e1b04a', 12); }); } function tombTexture() { return canvasTexture(256, 640, (ctx, w, h) => { ctx.fillStyle = '#0c0b0d'; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = '#b8862e'; ctx.lineWidth = 5; ctx.strokeRect(14, 14, w - 28, h - 28); // The ankh. ctx.strokeStyle = '#e1b04a'; ctx.lineWidth = 16; ctx.beginPath(); ctx.ellipse(w / 2, 190, 34, 50, 0, 0, Math.PI * 2); ctx.moveTo(w / 2, 240); ctx.lineTo(w / 2, 430); ctx.moveTo(w / 2 - 70, 262); ctx.lineTo(w / 2 + 70, 262); ctx.stroke(); }); } // The Circle of Book 4, ch. II, as a floor painting. The canvas's top is // ritual East and its right is South; `size` metres across. function circleTexture(size) { const px = 2048; const k = px / size; return canvasTexture(px, px, (ctx) => { const c = px / 2; ctx.translate(c, c); // The green Circle. ctx.fillStyle = '#1f6e3a'; ctx.beginPath(); ctx.arc(0, 0, CIRCLE.outer * k, 0, Math.PI * 2); ctx.arc(0, 0, CIRCLE.inner * k, 0, Math.PI * 2, true); ctx.fill(); ctx.strokeStyle = '#0f3d1f'; ctx.lineWidth = 4; for (const r of [CIRCLE.inner, CIRCLE.outer]) { ctx.beginPath(); ctx.arc(0, 0, r * k, 0, Math.PI * 2); ctx.stroke(); } // The names, as in the figure: clockwise, tops outward, a point between // NVIT and HADIT in the East. const text = '• HADIT • RA HOOR KHVIT • CHAOS • BABALON • LAYLAH • PERDVRABO • NVIT '; const mid = ((CIRCLE.inner + CIRCLE.outer) / 2) * k; const band = (CIRCLE.outer - CIRCLE.inner) * k; ctx.fillStyle = '#ff4b2a'; ctx.font = `bold ${Math.round(band * 0.62)}px Georgia, 'Times New Roman', serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.shadowColor = 'rgba(255,120,40,0.6)'; ctx.shadowBlur = 8; const chars = [...text]; chars.forEach((ch, i) => { const a = (i / chars.length) * Math.PI * 2; ctx.save(); ctx.rotate(a); ctx.translate(0, -mid); ctx.fillText(ch, 0, 0); ctx.restore(); }); ctx.shadowBlur = 0; // The Tau: a stem of five squares, east, on a bar of five; the centre of // the Circle is the centre of the fourth square of the stem. const s = SQ * k; ctx.fillStyle = '#e8402a'; ctx.strokeStyle = '#5a1208'; ctx.lineWidth = 4; for (const [col, row] of tauSquares()) { ctx.fillRect(col * s - s / 2, row * s - s / 2, s, s); ctx.strokeRect(col * s - s / 2, row * s - s / 2, s, s); } // The corners of the triangle escribed about the Tau: three diamonds. for (const deg of [0, 120, 240]) { ctx.save(); ctx.rotate(deg * RAD); ctx.translate(0, -CIRCLE.diamonds * k); ctx.beginPath(); ctx.moveTo(0, -0.75 * s); ctx.lineTo(0.38 * s, 0); ctx.lineTo(0, 0.75 * s); ctx.lineTo(-0.38 * s, 0); ctx.closePath(); ctx.fillStyle = 'rgba(232,64,42,0.35)'; ctx.fill(); ctx.strokeStyle = '#e8402a'; ctx.lineWidth = 6; ctx.stroke(); ctx.restore(); } // Nine pentagrams outside, one in the East and every 40 degrees. for (let i = 0; i < 9; i++) { ctx.save(); ctx.rotate(i * 40 * RAD); ctx.translate(0, -CIRCLE.lamps * k); const r = 0.3 * k; ctx.beginPath(); for (let j = 0; j <= 5; j++) { const a = -Math.PI / 2 + j * (4 * Math.PI) / 5; ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r); } ctx.strokeStyle = '#d6301c'; ctx.lineWidth = 7; ctx.stroke(); ctx.beginPath(); ctx.arc(0, 0, r * 1.08, 0, Math.PI * 2); ctx.lineWidth = 3; ctx.stroke(); ctx.restore(); } }); } // [column (south +), row (west +)] of each square, in squares from the // centre of the Circle. function tauSquares() { const out = []; for (let row = -3; row <= 1; row++) out.push([0, row]); for (let col = -2; col <= 2; col++) out.push([col, 2]); return out; } // The sides of the altar: on the upper cube a cross and the sun, on the // lower the letters of the great tablets of the elements. function altarSideTexture(face) { const rand = mulberry32(400 + face); return canvasTexture(256, 512, (ctx, w, h) => { ctx.fillStyle = '#efe6cf'; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = '#3a2a16'; ctx.lineWidth = 6; ctx.strokeRect(3, 3, w - 6, h / 2 - 6); ctx.strokeRect(3, h / 2 + 3, w - 6, h / 2 - 6); ctx.fillStyle = '#111'; ctx.strokeStyle = '#111'; if (face % 2 === 0) { ctx.lineWidth = 14; ctx.beginPath(); ctx.moveTo(w / 2, 40); ctx.lineTo(w / 2, h / 2 - 40); ctx.moveTo(40, h / 4); ctx.lineTo(w - 40, h / 4); ctx.stroke(); ctx.font = 'bold 44px Georgia, serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const letters = ['4', 'b', 'b', '6']; [[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([sx, sy], i) => ctx.fillText(letters[i], w / 2 + sx * 52, h / 4 + sy * 48)); } else { ctx.lineWidth = 6; ctx.beginPath(); ctx.arc(w / 2, h / 4, 38, 0, Math.PI * 2); ctx.stroke(); for (let i = 0; i < 8; i++) { const a = (i / 8) * Math.PI * 2; ctx.beginPath(); ctx.moveTo(w / 2 + Math.cos(a) * 52, h / 4 + Math.sin(a) * 52); ctx.lineTo(w / 2 + Math.cos(a) * 86, h / 4 + Math.sin(a) * 86); ctx.stroke(); } } // 12 x 13 letters, in a grid. ctx.font = '15px Georgia, serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const cw = (w - 20) / 12; const ch = (h / 2 - 20) / 13; ctx.lineWidth = 1; for (let row = 0; row < 13; row++) { for (let col = 0; col < 12; col++) { const x = 10 + col * cw; const y = h / 2 + 10 + row * ch; ctx.strokeRect(x, y, cw, ch); ctx.fillText(String.fromCharCode(65 + Math.floor(rand() * 26)), x + cw / 2, y + ch / 2 + 1); } } }); } // The gold top of the altar, engraved with the New Jerusalem: twelve gates. function altarTopTexture() { return canvasTexture(512, 512, (ctx, w) => { const g = ctx.createLinearGradient(0, 0, w, w); g.addColorStop(0, '#f3d27a'); g.addColorStop(0.5, '#c99a3a'); g.addColorStop(1, '#e8bf5c'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, w); ctx.strokeStyle = '#6a4a12'; ctx.lineWidth = 4; ctx.strokeRect(40, 40, w - 80, w - 80); ctx.strokeRect(70, 70, w - 140, w - 140); for (let side = 0; side < 4; side++) { for (let gate = 1; gate <= 3; gate++) { ctx.save(); ctx.translate(w / 2, w / 2); ctx.rotate((side * Math.PI) / 2); const x = -w / 2 + 70 + (gate * (w - 140)) / 4; ctx.strokeRect(x - 12, -w / 2 + 40, 24, 30); ctx.restore(); } } ctx.beginPath(); ctx.arc(w / 2, w / 2, w * 0.2, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(w / 2, 70); ctx.lineTo(w / 2, w - 70); ctx.moveTo(70, w / 2); ctx.lineTo(w - 70, w / 2); ctx.stroke(); }); } function reshTexture(station) { return canvasTexture(256, 560, (ctx, w, h) => { const fields = station.field; if (fields.length === 1) { ctx.fillStyle = fields[0]; ctx.fillRect(0, 0, w, h); } else { // Earth: citrine, olive, russet and black, quartered by the diagonals. const cx = w / 2; const cy = h / 2; const quads = [[[0, 0], [w, 0]], [[w, 0], [w, h]], [[w, h], [0, h]], [[0, h], [0, 0]]]; quads.forEach(([a, b], i) => { ctx.fillStyle = fields[i]; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.fill(); }); } ctx.strokeStyle = station.ink; ctx.fillStyle = station.ink; ctx.lineWidth = 6; ctx.strokeRect(10, 10, w - 20, h - 20); const x = w / 2; const y = h * 0.4; ctx.fillStyle = '#d4281c'; if (station.glyph === 'rising' || station.glyph === 'setting') { ctx.beginPath(); ctx.arc(x, y + 20, 56, Math.PI, Math.PI * 2); ctx.fill(); ctx.fillStyle = station.ink; ctx.fillRect(28, y + 22, w - 56, 6); const dir = station.glyph === 'rising' ? -1 : 1; for (let i = -2; i <= 2; i++) { ctx.beginPath(); ctx.moveTo(x + i * 26, y + 60); ctx.lineTo(x + i * 26 - 10, y + 60 - dir * 18); ctx.lineTo(x + i * 26 + 10, y + 60 - dir * 18); ctx.fill(); } } else if (station.glyph === 'horns') { ctx.beginPath(); ctx.arc(x, y, 44, 0, Math.PI * 2); ctx.fill(); ctx.lineWidth = 12; ctx.strokeStyle = station.ink; ctx.beginPath(); ctx.arc(x, y - 4, 66, Math.PI * 0.05, Math.PI * 0.95, true); ctx.stroke(); } else { // The scarab, rolling the sun before it. ctx.beginPath(); ctx.arc(x, y - 72, 30, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = station.ink; ctx.beginPath(); ctx.ellipse(x, y + 18, 40, 54, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(x, y - 42, 22, 16, 0, 0, Math.PI * 2); ctx.fill(); ctx.lineWidth = 6; for (const side of [-1, 1]) { for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.moveTo(x + side * 30, y - 8 + i * 28); ctx.lineTo(x + side * 64, y - 22 + i * 34); ctx.stroke(); } } } ctx.fillStyle = station.ink; ctx.textAlign = 'center'; ctx.font = `bold ${station.name.length > 5 ? 34 : 46}px Georgia, serif`; ctx.fillText(station.name, x, h * 0.7); ctx.font = 'italic 24px Georgia, serif'; ctx.fillText(station.time, x, h * 0.78); }); } // A dim warm room for the metals to reflect. function warmEnvironment() { const faces = []; for (let i = 0; i < 6; i++) { const canvas = document.createElement('canvas'); canvas.width = canvas.height = 64; const ctx = canvas.getContext('2d'); const up = i === 2; const down = i === 3; const g = ctx.createLinearGradient(0, 0, 0, 64); g.addColorStop(0, up ? '#1a2240' : '#2a1a10'); g.addColorStop(0.5, down ? '#0c0806' : '#6a3a18'); g.addColorStop(1, '#0c0806'); ctx.fillStyle = g; ctx.fillRect(0, 0, 64, 64); if (!up && !down) { const spot = ctx.createRadialGradient(32, 30, 0, 32, 30, 22); spot.addColorStop(0, 'rgba(255,200,120,0.9)'); spot.addColorStop(1, 'rgba(255,160,60,0)'); ctx.fillStyle = spot; ctx.fillRect(0, 0, 64, 64); } faces.push(canvas); } const cube = new THREE.CubeTexture(faces); cube.colorSpace = THREE.SRGBColorSpace; cube.needsUpdate = true; return cube; } // --------------------------------------------------------------------------- AFRAME.registerComponent('temple', { schema: { // Whether the Veil before the High Altar is drawn open. veil: { default: true }, // Optional crackle for the Magick Fire, e.g. 'assets/sounds/fire.ogg'. fireSound: { default: '' }, // A photograph of the Stele, cut out on a transparent ground. Until it // loads, or if it can't, the painted stand-in is shown. stele: { default: 'assets/temple/stele.webp' }, sky: { type: 'selector', default: '#sky' }, }, init() { this.group = new THREE.Group(); this.el.setObject3D('mesh', this.group); this.rand = mulberry32(777); this.flames = []; // [x, y, z, size class] of every candle flame this.smokes = []; this.obstacles = []; this.seats = []; // where people can sit; same shape as campsite.js's this.spawn = { x: 0, z: 2, yaw: 0 }; this.veilOpen = this.data.veil ? 1 : 0; const env = warmEnvironment(); this.mat = { stone: new THREE.MeshLambertMaterial({ color: '#8a8278', map: stoneTexture(11, '#5a554e', '#2a2724') }), hallWall: new THREE.MeshLambertMaterial({ color: '#6a5446', map: plasterTexture(5) }), dado: new THREE.MeshLambertMaterial({ color: '#2a1a14', map: plasterTexture(6) }), chamberWall: new THREE.MeshLambertMaterial({ color: '#5e5a80', map: plasterTexture(7) }), wood: new THREE.MeshLambertMaterial({ color: '#3a2416' }), oak: new THREE.MeshLambertMaterial({ color: '#7a5a34' }), crimson: new THREE.MeshLambertMaterial({ color: '#8a121c' }), black: new THREE.MeshStandardMaterial({ color: '#0b0b0d', roughness: 0.3, metalness: 0.1, envMap: env }), marble: new THREE.MeshStandardMaterial({ color: '#e8e4da', roughness: 0.35, envMap: env }), wax: new THREE.MeshLambertMaterial({ color: '#f3e7c4', emissive: '#2a1a08' }), beeswax: new THREE.MeshLambertMaterial({ color: '#d9a94a', emissive: '#2a1a08' }), gold: new THREE.MeshStandardMaterial({ color: '#e0b04c', metalness: 0.85, roughness: 0.3, envMap: env }), silver: new THREE.MeshStandardMaterial({ color: '#d8dce0', metalness: 0.9, roughness: 0.25, envMap: env }), steel: new THREE.MeshStandardMaterial({ color: '#b8c0c8', metalness: 0.9, roughness: 0.2, envMap: env }), copper: new THREE.MeshStandardMaterial({ color: '#c8744a', metalness: 0.85, roughness: 0.35, envMap: env }), brass: new THREE.MeshStandardMaterial({ color: '#c8a050', metalness: 0.8, roughness: 0.4, envMap: env }), iron: new THREE.MeshStandardMaterial({ color: '#4a4a4c', metalness: 0.7, roughness: 0.6, envMap: env }), crystal: new THREE.MeshStandardMaterial({ color: '#e8f4ff', metalness: 0, roughness: 0.05, transparent: true, opacity: 0.45, envMap: env }), ruby: new THREE.MeshStandardMaterial({ color: '#c0102a', emissive: '#300008', roughness: 0.1, envMap: env }), rose: new THREE.MeshLambertMaterial({ color: '#b0101c' }), leaf: new THREE.MeshLambertMaterial({ color: '#2a5a24' }), water: new THREE.MeshStandardMaterial({ color: '#1a2a38', roughness: 0.05, metalness: 0.2, envMap: env }), flame: new THREE.MeshBasicMaterial({ color: '#fff2c0' }), }; this.buildFloor(); this.buildHall(); this.buildPassage(); this.buildChamber(); this.buildFlames(); this.buildLights(); // The Mass faces Boleskine from wherever the sky says we are. const skyEl = this.data.sky; const where = skyEl && skyEl.getAttribute('sky'); const here = where && Number.isFinite(where.latitude) ? { lat: where.latitude, lon: where.longitude } : { lat: 47.6062, lon: -122.3321 }; this.east = bearing(here, BOLESKINE); this.here = here; if (skyEl && !skyEl.components.sky) skyEl.setAttribute('sky', 'south', skySouth(this.east)); this.lastSun = -Infinity; }, update(old) { if (old.veil !== undefined && old.veil !== this.data.veil) this.el.emit('veil', { open: this.data.veil }, false); }, remove() { this.el.removeObject3D('mesh'); }, toggleVeil() { this.el.setAttribute('temple', 'veil', !this.data.veil); }, add(geometry, material, x = 0, y = 0, z = 0, parent = this.group) { const mesh = new THREE.Mesh(geometry, material); mesh.position.set(x, y, z); parent.add(mesh); return mesh; }, box(x0, x1, z0, z1) { this.obstacles.push({ box: true, x0: Math.min(x0, x1), x1: Math.max(x0, x1), z0: Math.min(z0, z1), z1: Math.max(z0, z1) }); }, round(x, z, r) { this.obstacles.push({ box: false, x, z, r }); }, flame(x, y, z, big) { this.flames.push([x, y, z, big ? 1 : 0]); }, // ---- Floor --------------------------------------------------------------- buildFloor() { const x0 = VESICA.x - VESICA.r / 2 - 0.5; const x1 = HALL.x + 0.5; const z0 = -VESICA.r * SQRT3 / 2 - 0.5; const z1 = VESICA.r * SQRT3 / 2 + 0.5; const floor = flat(x0, z0, x1, z1, 0, false); this.mat.stone.map.repeat.set(0.5, 0.5); this.add(floor, this.mat.stone); }, // ---- The hall: Liber XV ---------------------------------------------------- buildHall() { const g = this.group; const inside = { x: 0, z: -3 }; const walls = QuadBatch(); const dado = QuadBatch(); const H = HALL.height; const E = HALL.east; const W = HALL.west; const X = HALL.x; const runs = [ [{ x: -X, z: E }, { x: X, z: E }], [{ x: X, z: E }, { x: X, z: W }], [{ x: X, z: W }, { x: -X, z: W }], [{ x: -X, z: W }, { x: -X, z: DOOR.z1 }], [{ x: -X, z: DOOR.z0 }, { x: -X, z: E }], ]; for (const [a, b] of runs) { walls.add(a, b, 1.1, H, inside); dado.add(a, b, 0, 1.1, inside); } walls.add({ x: -X, z: DOOR.z1 }, { x: -X, z: DOOR.z0 }, DOOR.h, H, inside); this.mat.hallWall.map.repeat.set(0.25, 0.25); this.add(walls.geometry(), this.mat.hallWall); this.add(dado.geometry(), this.mat.dado); // Gold bands along the walls, at the dado and at the cornice. for (const y of [1.1, 5.6]) { const band = (len, x, z, ry) => { const m = this.add(new THREE.BoxGeometry(len, 0.06, 0.03), this.mat.gold, x, y, z); m.rotation.y = ry; }; band(2 * X, 0, E + 0.015, 0); band(2 * X, 0, W - 0.015, 0); band(W - E, X - 0.015, (E + W) / 2, Math.PI / 2); band(DOOR.z0 - E, -X + 0.015, (E + DOOR.z0) / 2, Math.PI / 2); band(W - DOOR.z1, -X + 0.015, (W + DOOR.z1) / 2, Math.PI / 2); } // Nuit overhead. const ceiling = new THREE.PlaneGeometry(2 * X, W - E); ceiling.rotateX(Math.PI / 2); this.add(ceiling, new THREE.MeshLambertMaterial({ map: nuitTexture(), emissive: '#05081a' }), 0, H, (E + W) / 2); this.buildDais(); this.buildHighAltar(); this.buildVeil(); // The small black altar of superimposed cubes, with a censer on it. const s = SMALL_ALTAR.size; this.add(new THREE.BoxGeometry(s, s, s), this.mat.black, 0, s / 2, SMALL_ALTAR.z); this.add(new THREE.BoxGeometry(s, s, s), this.mat.black, 0, s * 1.5, SMALL_ALTAR.z); this.censer(0, 2 * s, SMALL_ALTAR.z, 0.8); this.box(-s / 2, s / 2, SMALL_ALTAR.z - s / 2, SMALL_ALTAR.z + s / 2); // The font. const font = new THREE.LatheGeometry([ new THREE.Vector2(0.22, 0), new THREE.Vector2(0.22, 0.06), new THREE.Vector2(0.09, 0.1), new THREE.Vector2(0.07, 0.7), new THREE.Vector2(0.12, 0.76), new THREE.Vector2(FONT.r, 0.86), new THREE.Vector2(FONT.r, 0.92), new THREE.Vector2(FONT.r - 0.04, 0.92), new THREE.Vector2(FONT.r - 0.05, 0.84), new THREE.Vector2(0, 0.82), ], 32); this.add(font, this.mat.marble, 0, 0, FONT.z); const water = new THREE.CircleGeometry(FONT.r - 0.05, 32); water.rotateX(-Math.PI / 2); this.add(water, this.mat.water, 0, 0.885, FONT.z); this.round(0, FONT.z, FONT.r); // The upright tomb in the West, open to the East behind its curtain. const t = TOMB; const tomb = new THREE.Group(); tomb.position.set(0, 0, t.z); g.add(tomb); const tombMats = [this.mat.black, this.mat.black, this.mat.black, this.mat.black, new THREE.MeshLambertMaterial({ map: tombTexture() }), this.mat.black]; this.add(new THREE.BoxGeometry(t.w, t.h, t.d), tombMats, 0, t.h / 2, 0, tomb); const cap = new THREE.CylinderGeometry(0.01, t.w * 0.62, 0.3, 4); cap.rotateY(Math.PI / 4); cap.scale(1, 1, t.d / t.w); this.add(cap, this.mat.gold, 0, t.h + 0.15, 0, tomb); this.box(-t.w / 2, t.w / 2, t.z - t.d / 2, HALL.west + 1); // The Unicursal Hexagram above the tomb, and the winged disk above the Stele. const plaque = new THREE.CircleGeometry(1.1, 48); plaque.rotateY(Math.PI); this.add(plaque, new THREE.MeshLambertMaterial({ map: plaqueTexture(), transparent: true, emissive: '#100804' }), 0, 5.4, W - 0.02); const disk = new THREE.PlaneGeometry(4.2, 1.5); this.add(disk, new THREE.MeshLambertMaterial({ map: canvasTexture(1024, 366, (ctx, w, h) => drawWingedDisk(ctx, w / 2, h * 0.45, w * 0.96, h * 0.7)), transparent: true, alphaTest: 0.1, emissive: '#100804', }), 0, 6.4, E + 0.02); // Hangings on the side walls. const hang = new THREE.MeshLambertMaterial({ map: hangingTexture() }); const hangGeo = new THREE.PlaneGeometry(1.2, 2.8); for (const z of [-5.6, -2, 2]) { const m = this.add(hangGeo, hang, X - 0.03, 3.9, z); m.rotation.y = -Math.PI / 2; } for (const z of [-5.6, -2]) { const m = this.add(hangGeo, hang, -X + 0.03, 3.9, z); m.rotation.y = Math.PI / 2; } // Sconces between the hangings. const cup = new THREE.CylinderGeometry(0.06, 0.03, 0.08, 12); const arm = new THREE.BoxGeometry(0.03, 0.03, 0.22); for (const side of [-1, 1]) { for (const z of [-7.6, -3.8, 0, 3.4]) { if (side < 0 && z > DOOR.z0 - 0.4 && z < DOOR.z1 + 0.4) continue; const x = side * (X - 0.14); this.add(cup, this.mat.brass, x, 2.4, z); const a = this.add(arm, this.mat.brass, side * (X - 0.06), 2.36, z); a.rotation.y = Math.PI / 2; this.candle(x, 2.44, z, 0.012, 0.12); } } // Benches along both side walls, facing the middle. Each seats two. for (const side of [-1, 1]) { for (const z of BENCHES) { const x = side * (X - 0.3); this.add(new THREE.BoxGeometry(0.45, 0.06, 1.6), this.mat.wood, x, 0.43, z); this.add(new THREE.BoxGeometry(0.06, 0.55, 1.6), this.mat.wood, side * (X - 0.06), 0.72, z); for (const dz of [-0.7, 0.7]) this.add(new THREE.BoxGeometry(0.4, 0.4, 0.06), this.mat.wood, x, 0.2, z + dz); this.box(side * (X - 0.52), side * (X + 1), z - 0.8, z + 0.8); for (const dz of [-0.4, 0.4]) { this.seats.push({ x: side * (X - 0.32), y: 0.46, z: z + dz, yaw: side * Math.PI / 2 }); } } } }, buildDais() { const tex = checkerTexture('#111111', '#d6d0c2'); tex.repeat.set(1 / 0.6, 1 / 0.6); const mat = new THREE.MeshLambertMaterial({ map: tex }); const depth = (front) => front - HALL.east; for (let step = 0; step < 3; step++) { const front = DAIS.front - step * DAIS.tread; const h = DAIS.rise * (step + 1); this.add(meterBox(2 * DAIS.x, h, depth(front)), mat, 0, h / 2, (front + HALL.east) / 2); } // The aisle runner from the dais to the tomb. const runTex = checkerTexture('#151515', '#b4ac9c'); runTex.repeat.set(1 / 0.8, 1 / 0.8); this.add(flat(-1.2, DAIS.front, 1.2, TOMB.z - TOMB.d / 2 - 0.2, 0.004, false), new THREE.MeshLambertMaterial({ map: runTex })); // The two pillars, countercharged. for (const side of [-1, 1]) { const x = side * PILLAR.x; const mat2 = new THREE.MeshLambertMaterial({ map: pillarTexture(side > 0) }); this.add(new THREE.CylinderGeometry(PILLAR.r, PILLAR.r, PILLAR.h, 24, 1, true), mat2, x, TOP + PILLAR.h / 2, PILLAR.z); this.add(new THREE.BoxGeometry(0.62, 0.16, 0.62), this.mat.black, x, TOP + 0.08, PILLAR.z); this.add(new THREE.BoxGeometry(0.62, 0.16, 0.62), this.mat.gold, x, TOP + PILLAR.h + 0.08, PILLAR.z); const point = new THREE.CylinderGeometry(0.02, 0.24, 0.7, 4); point.rotateY(Math.PI / 4); this.add(point, this.mat.gold, x, TOP + PILLAR.h + 0.51, PILLAR.z); this.round(x, PILLAR.z, 0.31); } }, buildHighAltar() { const a = HIGH_ALTAR; const cloth = altarClothTexture(); const front = new THREE.MeshLambertMaterial({ map: cloth }); const mats = [this.mat.crimson, this.mat.crimson, this.mat.crimson, this.mat.crimson, front, this.mat.crimson]; this.add(new THREE.BoxGeometry(a.w, a.h, a.d), mats, 0, TOP + a.h / 2, a.z); this.box(-a.w / 2, a.w / 2, HALL.east - 1, a.z + a.d / 2); // no squeezing behind it // The super-altar: two tiers at the back of the altar. const t2 = { w: 1.9, h: 0.26, d: 0.3 }; const t3 = { w: 1.8, h: 0.3, d: 0.15 }; const tier = new THREE.MeshLambertMaterial({ color: '#6a0e16' }); this.add(new THREE.BoxGeometry(t2.w, t2.h, t2.d), tier, 0, ALTAR_TOP + t2.h / 2, ALTAR_BACK + t2.d / 2); this.add(new THREE.BoxGeometry(t3.w, t3.h, t3.d), tier, 0, ALTAR_TOP + t2.h + t3.h / 2, ALTAR_BACK + t3.d / 2); for (const [w, y, z] of [[t2.w, ALTAR_TOP + t2.h, ALTAR_BACK + t2.d], [t3.w, ALTAR_TOP + t2.h + t3.h, ALTAR_BACK + t3.d]]) { this.add(new THREE.BoxGeometry(w + 0.02, 0.02, 0.02), this.mat.gold, 0, y, z); } const y2 = ALTAR_TOP + t2.h; const y3 = y2 + t3.h; // The Stele of Revealing, with four candles on each side. const steleMat = new THREE.MeshLambertMaterial({ map: steleTexture(), alphaTest: 0.5, emissive: '#1a1206' }); this.add(new THREE.PlaneGeometry(0.5, 0.8), steleMat, 0, y3 + 0.4, ALTAR_BACK + 0.07); if (this.data.stele) { new THREE.TextureLoader().load(this.data.stele, (photo) => { photo.colorSpace = THREE.SRGBColorSpace; photo.anisotropy = 4; steleMat.map.dispose(); steleMat.map = photo; steleMat.color.set('#bdb5a8'); // the altar candles are close; keep it from washing out steleMat.needsUpdate = true; }); } for (let i = 0; i < 4; i++) { for (const side of [-1, 1]) this.candle(side * (0.38 + i * 0.13), y3, ALTAR_BACK + 0.08, 0.016, 0.2); } // The Book of the Law on its stand, with six candles on each side. const zb = ALTAR_BACK + t3.d + (t2.d - t3.d) / 2; const stand = this.add(new THREE.BoxGeometry(0.22, 0.02, 0.14), this.mat.gold, 0, y2 + 0.05, zb); stand.rotation.x = -0.45; const book = this.add(new THREE.BoxGeometry(0.15, 0.025, 0.2), new THREE.MeshLambertMaterial({ color: '#7a0f16' }), 0, y2 + 0.08, zb + 0.005); book.rotation.x = -0.45; for (let i = 0; i < 6; i++) { for (const side of [-1, 1]) this.candle(side * (0.25 + i * 0.12), y2, zb, 0.014, 0.17); } // The Graal between the roses, the Paten before it, and the two great candles. const zg = ALTAR_BACK + t2.d + 0.2; const graal = new THREE.LatheGeometry([ new THREE.Vector2(0, 0), new THREE.Vector2(0.07, 0), new THREE.Vector2(0.07, 0.012), new THREE.Vector2(0.018, 0.03), new THREE.Vector2(0.014, 0.11), new THREE.Vector2(0.03, 0.125), new THREE.Vector2(0.018, 0.14), new THREE.Vector2(0.06, 0.17), new THREE.Vector2(0.075, 0.24), new THREE.Vector2(0.07, 0.24), new THREE.Vector2(0.055, 0.18), new THREE.Vector2(0, 0.16), ], 32); this.add(graal, this.mat.gold, 0, ALTAR_TOP, zg); const paten = new THREE.CylinderGeometry(0.085, 0.08, 0.008, 32); this.add(paten, this.mat.gold, 0, ALTAR_TOP + 0.004, zg + 0.22); for (const side of [-1, 1]) this.roses(side * 0.32, ALTAR_TOP, zg); for (const side of [-1, 1]) { const x = side * 0.9; this.add(new THREE.CylinderGeometry(0.07, 0.1, 0.12, 16), this.mat.gold, x, ALTAR_TOP + 0.06, zg + 0.05); this.candle(x, ALTAR_TOP + 0.12, zg + 0.05, 0.045, 0.62, true); } }, roses(x, y, z) { this.add(new THREE.CylinderGeometry(0.05, 0.035, 0.16, 16), this.mat.gold, x, y + 0.08, z); const head = new THREE.IcosahedronGeometry(0.035, 1); const leaf = new THREE.ConeGeometry(0.02, 0.07, 5); const rand = this.rand; for (let i = 0; i < 7; i++) { const a = (i / 7) * Math.PI * 2; const r = i === 0 ? 0 : 0.05; const hx = x + Math.cos(a) * r; const hz = z + Math.sin(a) * r; const hy = y + 0.22 + (i === 0 ? 0.05 : rand() * 0.03); this.add(head, this.mat.rose, hx, hy, hz).scale.set(1, 0.85, 1); const l = this.add(leaf, this.mat.leaf, x + Math.cos(a + 0.4) * 0.06, y + 0.18, z + Math.sin(a + 0.4) * 0.06); l.rotation.set(Math.sin(a) * 0.9, 0, -Math.cos(a) * 0.9); } }, // A veil of two halves hanging from a gold rod between the pillars; open, // each half is gathered to its pillar. buildVeil() { const h = VEIL.top - TOP; const rod = new THREE.CylinderGeometry(0.025, 0.025, 2 * VEIL.half, 12); rod.rotateZ(Math.PI / 2); this.add(rod, this.mat.gold, 0, VEIL.top + 0.03, VEIL.z); const mat = new THREE.MeshLambertMaterial({ color: '#1a1f5a', emissive: '#05061a', side: THREE.DoubleSide, transparent: true, opacity: 0.88, }); this.veilHalves = []; for (const side of [-1, 1]) { const geo = new THREE.PlaneGeometry(VEIL.half, h, 48, 1); geo.translate(-side * VEIL.half / 2, h / 2, 0); // hinge at the pillar const base = geo.attributes.position.array.slice(); const mesh = this.add(geo, mat, side * VEIL.half, TOP, VEIL.z); this.veilHalves.push({ mesh, base, side }); } this.shapeVeil(this.veilOpen); // Reach out and touch it to draw or open it (see wield.js). const local = new THREE.Vector3(); this.touchables = [{ name: 'veil', label: () => (this.data.veil ? 'close the Veil' : 'open the Veil'), // From a world position to the cloth as it hangs now: across the whole // gap when drawn, gathered against each pillar when open. distance: (p) => { this.group.worldToLocal(local.copy(p)); const reach = VEIL.half * (1 - this.veilOpen * 0.86); const ax = Math.abs(local.x); const dx = ax > VEIL.half ? ax - VEIL.half : Math.max(0, VEIL.half - reach - ax); const dy = Math.max(0, TOP - local.y, local.y - VEIL.top); return Math.hypot(dx, dy, local.z - VEIL.z); }, use: () => this.toggleVeil(), }]; }, // 0 = closed, 1 = open: folds deepen as each half gathers to its pillar. shapeVeil(open) { const gather = 1 - open * 0.86; for (const { mesh, base, side } of this.veilHalves) { const pos = mesh.geometry.attributes.position; for (let i = 0; i < pos.count; i++) { const x = base[i * 3]; const y = base[i * 3 + 1]; const u = Math.abs(x) / VEIL.half; // 0 at the pillar, 1 at the middle const fold = Math.sin(u * VEIL.half * 26) * (0.03 + open * 0.06); pos.setXYZ(i, x * gather, y, fold - (open * 0.12 * u)); } pos.needsUpdate = true; mesh.geometry.computeVertexNormals(); mesh.position.x = side * VEIL.half; } }, // ---- The passage ----------------------------------------------------------- buildPassage() { const walls = QuadBatch(); const inside = { x: -6.5, z: (DOOR.z0 + DOOR.z1) / 2 }; const xNear = -HALL.x; for (const z of [DOOR.z0, DOOR.z1]) { const xFar = VESICA.x + vesicaHalf(z); walls.add({ x: xNear, z }, { x: xFar, z }, 0, DOOR.h, inside); } this.add(walls.geometry(), this.mat.dado); const xFar = VESICA.x + vesicaHalf(DOOR.z1); this.add(flat(xFar, DOOR.z0, xNear, DOOR.z1, DOOR.h, true), this.mat.dado); // A lintel of gold on each side. for (const x of [xNear, VESICA.x + vesicaHalf((DOOR.z0 + DOOR.z1) / 2)]) { this.add(new THREE.BoxGeometry(0.08, 0.08, DOOR.z1 - DOOR.z0 + 0.1), this.mat.gold, x, DOOR.h + 0.04, (DOOR.z0 + DOOR.z1) / 2); } }, // ---- The chamber: Book 4 ----------------------------------------------------- buildChamber() { const v = VESICA; const half = v.r / 2; // The outline: the south arc (circle centred north) and the north arc. const doorA = Math.asin((DOOR.z0 - v.z) / v.r); const doorB = Math.asin((DOOR.z1 - v.z) / v.r); const southAngles = []; for (let i = 0; i <= 48; i++) southAngles.push(-Math.PI / 3 + (i / 48) * (2 * Math.PI / 3)); southAngles.push(doorA, doorB); southAngles.sort((p, q) => p - q); const south = southAngles.map((t) => ({ x: v.x - half + Math.cos(t) * v.r, z: v.z + Math.sin(t) * v.r, t })); const north = []; for (let i = 0; i <= 48; i++) { const t = (2 * Math.PI) / 3 + (i / 48) * (2 * Math.PI / 3); north.push({ x: v.x + half + Math.cos(t) * v.r, z: v.z + Math.sin(t) * v.r }); } const walls = QuadBatch(); const inside = { x: v.x, z: v.z }; let u = 0; for (let i = 0; i < south.length - 1; i++) { const a = south[i]; const b = south[i + 1]; const inDoor = a.t >= doorA - 1e-9 && b.t <= doorB + 1e-9; u += walls.add(a, b, inDoor ? DOOR.h : 0, v.height, inside, u); } for (let i = 0; i < north.length - 1; i++) u += walls.add(north[i], north[i + 1], 0, v.height, inside, u); this.mat.chamberWall.map.repeat.set(0.25, 0.25); this.add(walls.geometry(), this.mat.chamberWall); // The ceiling, open to the sky above the Lamp. const shape = new THREE.Shape(); const outline = [...south, ...north]; outline.forEach((p, i) => (i ? shape.lineTo(p.x, p.z) : shape.moveTo(p.x, p.z))); const hole = new THREE.Path(); hole.absarc(CIRCLE.x, CIRCLE.z, OCULUS, 0, Math.PI * 2, true); shape.holes.push(hole); const ceiling = new THREE.ShapeGeometry(shape, 24); ceiling.rotateX(Math.PI / 2); this.add(ceiling, new THREE.MeshLambertMaterial({ color: '#1c1a30', side: THREE.DoubleSide }), 0, v.height, 0); const rim = new THREE.TorusGeometry(OCULUS, 0.06, 8, 48); rim.rotateX(Math.PI / 2); this.add(rim, this.mat.gold, CIRCLE.x, v.height, CIRCLE.z); // A short shaft so the opening has depth. const shaft = new THREE.CylinderGeometry(OCULUS, OCULUS, 0.6, 48, 1, true); this.add(shaft, new THREE.MeshLambertMaterial({ color: '#141222', side: THREE.BackSide }), CIRCLE.x, v.height + 0.3, CIRCLE.z); // The Circle on the floor. const size = 2 * (CIRCLE.lamps + 0.6); const decal = new THREE.PlaneGeometry(size, size); decal.rotateX(-Math.PI / 2); this.add(decal, new THREE.MeshLambertMaterial({ map: circleTexture(size), transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, }), CIRCLE.x, 0.003, CIRCLE.z); // The nine lamps, beeswax, each on its pentagram. const holder = new THREE.CylinderGeometry(0.07, 0.09, 0.05, 16); for (let i = 0; i < 9; i++) { const a = i * 40 * RAD; const x = CIRCLE.x + Math.sin(a) * CIRCLE.lamps; const z = CIRCLE.z - Math.cos(a) * CIRCLE.lamps; this.add(holder, this.mat.brass, x, 0.025, z); this.candle(x, 0.05, z, 0.035, 0.24, false, this.mat.beeswax); this.round(x, z, 0.12); } this.buildAltar(); this.buildLamp(); this.buildMagickFire(); this.buildLectern(); this.buildBanners(); }, // The double cube on the centre of the Circle, with the weapons on it. buildAltar() { const x = CIRCLE.x; const z = CIRCLE.z; const sides = [0, 1, 2, 3].map((f) => { const map = altarSideTexture(f); // The Lamp is straight overhead, so the sides would be in shade. return new THREE.MeshLambertMaterial({ map, emissiveMap: map, emissive: '#ffffff', emissiveIntensity: 0.12 }); }); const top = new THREE.MeshStandardMaterial({ map: altarTopTexture(), metalness: 0.7, roughness: 0.35, envMap: this.mat.gold.envMap }); this.add(new THREE.BoxGeometry(SQ, 2 * SQ, SQ), [sides[0], sides[1], top, this.mat.oak, sides[2], sides[3]], x, SQ, z); this.box(x - SQ / 2, x + SQ / 2, z - SQ / 2, z + SQ / 2); const on = new THREE.Group(); on.position.set(x, 2 * SQ, z); this.group.add(on); const m = this.mat; // The Wand along the eastern edge. const wand = new THREE.Group(); wand.position.set(0, 0.008, -0.2); on.add(wand); this.add(new THREE.CylinderGeometry(0.006, 0.006, 0.44, 8), m.oak, 0, 0, 0, wand).rotation.z = Math.PI / 2; for (const end of [-1, 1]) { const tip = this.add(new THREE.SphereGeometry(0.01, 8, 6), m.gold, end * 0.22, 0, 0, wand); tip.scale.set(1.4, 1, 1); } // The Sword along the western edge: copper hilt, steel blade, a guard of // two crescents back to back with spheres between them that make an // equilateral triangle with the pommel. const sword = new THREE.Group(); sword.position.set(0, 0.006, 0.2); on.add(sword); const R = 0.032; const guardX = -0.1; this.add(new THREE.BoxGeometry(0.31, 0.004, 0.018), m.steel, guardX + 0.16, 0, 0, sword); const tipGeo = new THREE.ConeGeometry(0.009, 0.03, 4); tipGeo.rotateZ(-Math.PI / 2); tipGeo.scale(1, 0.25, 1); this.add(tipGeo, m.steel, guardX + 0.33, 0, 0, sword); for (const dir of [-1, 1]) { const arc = tube((t) => { const a = Math.PI / 2 + t * Math.PI; return new THREE.Vector3(guardX + dir * (R * 0.5 + Math.cos(a) * R * 0.5), 0, Math.sin(a) * R); }, 0.003, 16, 5); this.add(arc, m.silver, 0, 0, 0, sword); } const ball = new THREE.SphereGeometry(0.007, 10, 8); for (const side of [-1, 1]) this.add(ball, m.gold, guardX, 0, side * R, sword); const pommelX = guardX - R * SQRT3; this.add(ball, m.gold, pommelX, 0, 0, sword).scale.setScalar(1.3); const grip = this.add(new THREE.CylinderGeometry(0.006, 0.006, guardX - pommelX, 8), m.copper, (guardX + pommelX) / 2, 0, 0, sword); grip.rotation.z = Math.PI / 2; // The two that can be picked up and drawn with (see wield.js). Both lie // along their local +x, point first; `grip` is where the hand closes. const V = (px) => new THREE.Vector3(px, 0, 0); this.tools = [ { name: 'wand', label: 'the Wand', object: wand, grip: V(-0.12), tip: V(0.234), butt: V(-0.234), ink: '#ffb040' }, { name: 'sword', label: 'the Sword', object: sword, grip: V((guardX + pommelX) / 2), tip: V(guardX + 0.345), butt: V(pommelX - 0.009), ink: '#8fd0ff' }, ]; // The Cup to the north: a bowl like the moon on a sphere on a cone. const cup = new THREE.Group(); cup.position.set(-0.17, 0, 0); on.add(cup); this.add(new THREE.CylinderGeometry(0.004, 0.032, 0.045, 16), m.silver, 0, 0.0225, 0, cup); this.add(new THREE.SphereGeometry(0.014, 12, 8), m.silver, 0, 0.055, 0, cup); const bowl = new THREE.LatheGeometry([ new THREE.Vector2(0, 0), new THREE.Vector2(0.03, 0.006), new THREE.Vector2(0.045, 0.028), new THREE.Vector2(0.042, 0.03), new THREE.Vector2(0.028, 0.01), new THREE.Vector2(0, 0.006), ], 24); this.add(bowl, m.silver, 0, 0.066, 0, cup); // The Pantacle to the south: pure wax, eight inches across, half an inch thick. this.add(new THREE.CylinderGeometry(4 * IN * 0.8, 4 * IN * 0.8, 0.5 * IN * 0.8, 32), m.beeswax, 0.17, 0.005, 0, on); // In the middle the phial of Oil, rock crystal stoppered with a ruby, // with the Chain coiled about it and the Scourge and Dagger beside. this.add(new THREE.CylinderGeometry(0.014, 0.018, 0.04, 12), m.crystal, 0, 0.02, 0, on); this.add(new THREE.SphereGeometry(0.018, 12, 8), m.crystal, 0, 0.045, 0, on); this.add(new THREE.CylinderGeometry(0.004, 0.004, 0.02, 8), m.crystal, 0, 0.066, 0, on); this.add(new THREE.SphereGeometry(0.007, 10, 8), m.ruby, 0, 0.08, 0, on); const links = new THREE.InstancedMesh(new THREE.TorusGeometry(0.0055, 0.0014, 4, 10), m.iron, 333); const mtx = new THREE.Matrix4(); const q = new THREE.Quaternion(); const e = new THREE.Euler(); const p = new THREE.Vector3(); const sc = new THREE.Vector3(1.3, 1, 1); let n = 0; for (let layer = 0; n < 333; layer++) { for (let r = 0.03; r < 0.075 && n < 333; r += 0.0075) { const count = Math.floor((2 * Math.PI * r) / 0.0085); for (let i = 0; i < count && n < 333; i++, n++) { const a = (i / count) * Math.PI * 2 + layer * 0.3; p.set(Math.cos(a) * r, 0.003 + layer * 0.004, Math.sin(a) * r); e.set(n % 2 ? Math.PI / 2 : 0, -a + Math.PI / 2, 0, 'YXZ'); mtx.compose(p, q.setFromEuler(e), sc); links.setMatrixAt(n, mtx); } } } on.add(links); // The Scourge: iron handle, nine lashes of copper wire weighted with lead. const scourge = new THREE.Group(); scourge.position.set(0.05, 0.006, 0.11); on.add(scourge); const handle = this.add(new THREE.CylinderGeometry(0.006, 0.006, 0.08, 8), m.iron, -0.04, 0, 0, scourge); handle.rotation.z = Math.PI / 2; const lash = new THREE.CylinderGeometry(0.0012, 0.0012, 0.1, 4); lash.rotateZ(Math.PI / 2); lash.translate(0.05, 0, 0); for (let i = 0; i < 9; i++) this.add(lash, m.copper, 0, 0, 0, scourge).rotation.y = (i - 4) * 0.07; // The Dagger: steel inlaid with gold, hilt of gold. const dagger = new THREE.Group(); dagger.position.set(-0.04, 0.005, -0.11); on.add(dagger); this.add(new THREE.BoxGeometry(0.09, 0.003, 0.012), m.steel, 0.03, 0, 0, dagger); this.add(new THREE.BoxGeometry(0.08, 0.004, 0.002), m.gold, 0.03, 0.001, 0, dagger); this.add(new THREE.BoxGeometry(0.008, 0.008, 0.04), m.gold, -0.017, 0, 0, dagger); const dhilt = this.add(new THREE.CylinderGeometry(0.005, 0.005, 0.045, 8), m.gold, -0.043, 0, 0, dagger); dhilt.rotation.z = Math.PI / 2; }, // "This Lamp hangeth above the Altar, it hath no support from below." buildLamp() { const y = 2.5; const lamp = new THREE.LatheGeometry([ new THREE.Vector2(0, -0.1), new THREE.Vector2(0.03, -0.09), new THREE.Vector2(0.09, -0.04), new THREE.Vector2(0.12, 0.02), new THREE.Vector2(0.115, 0.03), new THREE.Vector2(0.085, -0.02), new THREE.Vector2(0, -0.03), ], 32); this.add(lamp, this.mat.gold, CIRCLE.x, y, CIRCLE.z); const ring = new THREE.TorusGeometry(0.12, 0.008, 6, 32); ring.rotateX(Math.PI / 2); this.add(ring, this.mat.gold, CIRCLE.x, y + 0.03, CIRCLE.z); // Three chains up to the rim of the opening. for (let i = 0; i < 3; i++) { const a = (i / 3) * Math.PI * 2; const from = new THREE.Vector3(CIRCLE.x + Math.cos(a) * 0.12, y + 0.03, CIRCLE.z + Math.sin(a) * 0.12); const to = new THREE.Vector3(CIRCLE.x + Math.cos(a) * OCULUS, VESICA.height, CIRCLE.z + Math.sin(a) * OCULUS); const chain = tube((t) => from.clone().lerp(to, t), 0.006, 2, 4); this.add(chain, this.mat.gold); } const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTexture('rgba(255,250,230,1)', 'rgba(255,210,140,0.45)'), blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, })); glow.scale.setScalar(0.7); glow.position.set(CIRCLE.x, y + 0.1, CIRCLE.z); this.group.add(glow); this.lampGlow = glow; }, // The Magick Fire in the East: an altar of open brass work, and on it the // three-legged silver censer with its pierced plate and burning charcoal. buildMagickFire() { const { x, z } = FIRE; const s = 0.5; const h = 0.8; const bar = (len) => new THREE.CylinderGeometry(0.012, 0.012, len, 6); for (const dx of [-1, 1]) { for (const dz of [-1, 1]) this.add(bar(h), this.mat.brass, x + dx * s / 2, h / 2, z + dz * s / 2); } for (const y of [0.04, h / 2, h]) { for (const [dx, dz, rot] of [[0, -1, 0], [0, 1, 0], [-1, 0, 1], [1, 0, 1]]) { const b = this.add(bar(s), this.mat.brass, x + dx * s / 2, y, z + dz * s / 2); b.rotation.set(rot ? Math.PI / 2 : 0, 0, rot ? 0 : Math.PI / 2); } } // Lattice on each side. for (const [dx, dz] of [[0, -1], [0, 1], [-1, 0], [1, 0]]) { for (const sign of [-1, 1]) { const len = Math.hypot(s, h / 2); for (const y0 of [0, h / 2]) { const b = this.add(bar(len), this.mat.brass, x + dx * s / 2, y0 + h / 4, z + dz * s / 2); const tilt = sign * Math.atan2(s, h / 2); if (dx === 0) b.rotation.z = tilt; else b.rotation.x = tilt; } } } this.add(new THREE.BoxGeometry(s, 0.02, s), this.mat.brass, x, h, z); this.censer(x, h + 0.01, z, 1.6); this.box(x - s / 2, x + s / 2, z - s / 2, z + s / 2); this.firePos = new THREE.Vector3(x, h + 0.3, z); if (this.data.fireSound) { const sound = document.createElement('a-entity'); sound.setAttribute('sound', { src: `url(${this.data.fireSound})`, autoplay: true, loop: true, volume: 0.25, refDistance: 1.5, rolloffFactor: 1.4, positional: true }); sound.setAttribute('position', `${x} ${h + 0.2} ${z}`); this.el.appendChild(sound); } }, censer(x, y, z, scale) { const c = new THREE.Group(); c.position.set(x, y, z); c.scale.setScalar(scale); this.group.add(c); for (let i = 0; i < 3; i++) { const a = (i / 3) * Math.PI * 2; const leg = this.add(new THREE.CylinderGeometry(0.006, 0.004, 0.08, 6), this.mat.silver, Math.cos(a) * 0.05, 0.04, Math.sin(a) * 0.05, c); leg.rotation.set(Math.sin(a) * 0.3, 0, -Math.cos(a) * 0.3); } const bowl = new THREE.SphereGeometry(0.075, 24, 10, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2); this.add(bowl, new THREE.MeshStandardMaterial({ color: '#d8dce0', metalness: 0.9, roughness: 0.25, envMap: this.mat.silver.envMap, side: THREE.DoubleSide }), 0, 0.15, 0, c); this.add(new THREE.CylinderGeometry(0.07, 0.07, 0.004, 24), this.mat.silver, 0, 0.14, 0, c); const coal = new THREE.CircleGeometry(0.055, 16); coal.rotateX(-Math.PI / 2); this.add(coal, new THREE.MeshBasicMaterial({ color: '#ff5a1a' }), 0, 0.144, 0, c); const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTexture('rgba(255,140,60,0.9)', 'rgba(255,80,20,0.35)'), blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, })); glow.scale.setScalar(0.25); glow.position.set(0, 0.16, 0); c.add(glow); this.smoke(x, y + 0.16 * scale, z); }, smoke(x, y, z) { const count = 36; const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(count * 3), 3)); geo.setAttribute('color', new THREE.Float32BufferAttribute(new Float32Array(count * 4), 4)); const points = new THREE.Points(geo, new THREE.PointsMaterial({ size: 0.22, map: glowTexture('rgba(220,220,230,0.5)', 'rgba(200,200,210,0.2)'), vertexColors: true, transparent: true, depthWrite: false, })); points.frustumCulled = false; this.group.add(points); const ages = Array.from({ length: count }, (_, i) => (i / count) * 6); const drift = Array.from({ length: count }, () => [this.rand() - 0.5, this.rand() - 0.5]); this.smokes.push({ x, y, z, points, ages, drift }); }, buildLectern() { const { x, z } = LECTERN; const l = new THREE.Group(); l.position.set(x, 0, z); // Face the magician, who stands just west of the altar: the desk tilts // toward local +z, so that is turned toward them. l.rotation.y = Math.atan2(CIRCLE.x - x, CIRCLE.z + SQ - z); this.group.add(l); this.add(new THREE.CylinderGeometry(0.14, 0.16, 0.04, 16), this.mat.wood, 0, 0.02, 0, l); this.add(new THREE.CylinderGeometry(0.025, 0.025, 1.0, 8), this.mat.wood, 0, 0.52, 0, l); const top = this.add(new THREE.BoxGeometry(0.4, 0.02, 0.3), this.mat.wood, 0, 1.06, 0, l); top.rotation.x = 0.35; // The Book of Conjurations, open on white vellum. for (const side of [-1, 1]) { const page = this.add(new THREE.BoxGeometry(0.15, 0.012, 0.21), new THREE.MeshLambertMaterial({ color: '#f2ead6' }), side * 0.078, 1.08, 0.0, l); page.rotation.set(0.35, 0, side * 0.06); } // The Bell: a little cymbal of electrum hanging on a chain from a gold // post at the lectern's side, with its striker resting on the desk. const post = 0.2; this.add(new THREE.CylinderGeometry(0.004, 0.004, 0.28, 6), this.mat.gold, post, 1.14, -0.05, l); this.add(new THREE.CylinderGeometry(0.003, 0.003, 0.05, 6), this.mat.gold, post - 0.025, 1.28, -0.05, l).rotation.z = Math.PI / 2; this.add(new THREE.CylinderGeometry(0.0012, 0.0012, 0.05, 4), this.mat.iron, post - 0.05, 1.255, -0.05, l); this.add(new THREE.CylinderGeometry(0.009, 0.035, 0.01, 24), this.mat.gold, post - 0.05, 1.225, -0.05, l); this.add(new THREE.SphereGeometry(0.008, 10, 6, 0, Math.PI * 2, 0, Math.PI / 2), this.mat.gold, post - 0.05, 1.229, -0.05, l); // The striker lies flat on the desk beside the book. const striker = this.add(new THREE.CylinderGeometry(0.003, 0.003, 0.08, 6), this.mat.wood, 0.178, 0.013, 0.07, top); striker.rotation.x = Math.PI / 2; this.add(new THREE.SphereGeometry(0.007, 8, 6), this.mat.gold, 0.178, 0.014, 0.03, top); this.round(x, z, 0.18); }, // Banners of the four adorations at the chamber's quarters. buildBanners() { this.banners = []; const rod = new THREE.CylinderGeometry(0.015, 0.015, 1.25, 8); rod.rotateZ(Math.PI / 2); for (const station of RESH) { const a = station.angle * RAD; const r = station.angle % 180 === 0 ? 8.2 : 4.9; const x = CIRCLE.x + Math.sin(a) * r; const z = CIRCLE.z - Math.cos(a) * r; const map = reshTexture(station); const mat = new THREE.MeshLambertMaterial({ map, emissiveMap: map, emissive: '#ffffff', emissiveIntensity: 0.04, side: THREE.DoubleSide }); const banner = new THREE.Group(); banner.position.set(x, 0, z); banner.rotation.y = -a; // face the centre this.group.add(banner); this.add(new THREE.PlaneGeometry(1.1, 2.4), mat, 0, 4.3, 0, banner); this.add(rod, this.mat.gold, 0, 5.52, 0, banner); for (const side of [-1, 1]) { const cord = tube((t) => new THREE.Vector3(side * 0.55 * (1 - t), 5.52 + t * (VESICA.height - 5.52), 0), 0.004, 2, 4); this.add(cord, this.mat.gold, 0, 0, 0, banner); } this.banners.push({ station, mat }); } // Where the Sun is now: a small gold disc on the wall. this.sunMarker = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTexture('rgba(255,236,170,1)', 'rgba(255,190,80,0.5)'), blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, })); this.sunMarker.scale.setScalar(0.35); this.group.add(this.sunMarker); }, // ---- Candles ------------------------------------------------------------- // Candles are gathered here and drawn as instances in buildFlames(). candle(x, y, z, r, h, big, material) { this.candles = this.candles || []; this.candles.push({ x, y, z, r, h, material: material || this.mat.wax }); this.flame(x, y + h + r * 1.2, z, big); }, buildFlames() { const m = new THREE.Matrix4(); const q = new THREE.Quaternion(); const draw = (geometry, material, list, place) => { const mesh = new THREE.InstancedMesh(geometry, material, list.length); list.forEach((c, i) => mesh.setMatrixAt(i, place(c))); this.group.add(mesh); }; for (const material of new Set(this.candles.map((c) => c.material))) { const list = this.candles.filter((c) => c.material === material); draw(new THREE.CylinderGeometry(1, 1, 1, 12), material, list, (c) => m.compose(new THREE.Vector3(c.x, c.y + c.h / 2, c.z), q, new THREE.Vector3(c.r, c.h, c.r))); } // The bright core of each flame. draw(new THREE.ConeGeometry(0.35, 1.6, 6), this.mat.flame, this.candles, (c) => m.compose(new THREE.Vector3(c.x, c.y + c.h + c.r * 0.8, c.z), q, new THREE.Vector3(c.r, c.r, c.r))); this.flameSets = []; for (const big of [0, 1]) { const list = this.flames.filter((f) => f[3] === big); if (!list.length) continue; const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(list.flatMap((f) => [f[0], f[1], f[2]]), 3)); const colors = new Float32Array(list.length * 3).fill(1); geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); const points = new THREE.Points(geo, new THREE.PointsMaterial({ size: big ? 0.2 : 0.11, map: glowTexture('rgba(255,236,190,1)', 'rgba(255,150,50,0.45)'), vertexColors: true, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false, })); this.group.add(points); this.flameSets.push({ points, phase: list.map(() => this.rand() * 100) }); } }, buildLights() { const light = (color, intensity, distance, x, y, z) => { const el = document.createElement('a-entity'); el.setAttribute('light', { type: 'point', color, intensity, distance, decay: 2 }); el.setAttribute('position', `${x} ${y} ${z}`); this.el.appendChild(el); return el; }; this.lights = { altar: light('#ffb46a', 14, 16, 0, ALTAR_TOP + 0.9, HIGH_ALTAR.z + 0.9), hall: light('#ffc68a', 7, 14, 0, 3.4, -1.5), lamp: light('#ffe6b8', 26, 15, CIRCLE.x, 2.3, CIRCLE.z), fire: light('#ff8a3d', 5, 7, FIRE.x, 1.2, FIRE.z), }; }, // ---- Movement ------------------------------------------------------------ // Keeps a head position (Vector3, world space) inside the rooms and out of // the furniture. Changes x and z in place. confine(p) { const m = MARGIN; const rects = [ { x0: -HALL.x + m, x1: HALL.x - m, z0: HALL.east + m, z1: HALL.west - m }, { x0: -9, x1: -HALL.x + m + 0.2, z0: DOOR.z0 + m, z1: DOOR.z1 - m }, ]; const inRect = (r, x, z) => x >= r.x0 && x <= r.x1 && z >= r.z0 && z <= r.z1; const rv = VESICA.r - m; const cA = { x: VESICA.x - VESICA.r / 2, z: VESICA.z }; const cB = { x: VESICA.x + VESICA.r / 2, z: VESICA.z }; const inDisc = (c, x, z) => Math.hypot(x - c.x, z - c.z) <= rv; const inVesica = (x, z) => inDisc(cA, x, z) && inDisc(cB, x, z); const intoRooms = () => { if (!rects.some((r) => inRect(r, p.x, p.z)) && !inVesica(p.x, p.z)) { const options = rects.map((r) => ({ x: Math.min(r.x1, Math.max(r.x0, p.x)), z: Math.min(r.z1, Math.max(r.z0, p.z)), })); const q = { x: p.x, z: p.z }; for (let i = 0; i < 4; i++) { for (const c of [cA, cB]) { const d = Math.hypot(q.x - c.x, q.z - c.z); if (d > rv) { q.x = c.x + ((q.x - c.x) / d) * rv; q.z = c.z + ((q.z - c.z) / d) * rv; } } } options.push(q); let best = options[0]; let bestD = Infinity; for (const o of options) { const d = Math.hypot(o.x - p.x, o.z - p.z); if (d < bestD) { bestD = d; best = o; } } p.x = best.x; p.z = best.z; } }; const outOfFurniture = () => { for (const o of this.obstacles) { if (o.box) { const x0 = o.x0 - m; const x1 = o.x1 + m; const z0 = o.z0 - m; const z1 = o.z1 + m; if (p.x > x0 && p.x < x1 && p.z > z0 && p.z < z1) { const pushes = [[p.x - x0, x0, null], [x1 - p.x, x1, null], [p.z - z0, null, z0], [z1 - p.z, null, z1]]; pushes.sort((a, b) => a[0] - b[0]); if (pushes[0][1] !== null) p.x = pushes[0][1]; else p.z = pushes[0][2]; } } else { const dx = p.x - o.x; const dz = p.z - o.z; const d = Math.hypot(dx, dz); const min = o.r + m; if (d < min) { const k = d > 1e-4 ? min / d : 0; p.x = o.x + (d > 1e-4 ? dx * k : min); p.z = o.z + dz * k; } } } }; // Twice, so that neither undoes the other. for (let pass = 0; pass < 2; pass++) { intoRooms(); outOfFurniture(); } return p; }, // Height of the floor here: the dais has three steps. floorAt(x, z) { if (Math.abs(x) > DAIS.x || z > DAIS.front || z < HALL.east) return 0; if (z <= PLATFORM_FRONT) return TOP; return z <= DAIS.front - DAIS.tread ? 2 * DAIS.rise : DAIS.rise; }, // ---- Every frame ----------------------------------------------------------- tick(time, delta) { const t = time / 1000; const dt = Math.min(delta, 100) / 1000; // Candles flicker each on their own. for (const { points, phase } of this.flameSets) { const c = points.geometry.attributes.color; for (let i = 0; i < c.count; i++) { const f = 0.82 + Math.sin(t * 9 + phase[i]) * 0.08 + Math.sin(t * 23.3 + phase[i] * 1.7) * 0.06 + (Math.random() - 0.5) * 0.06; c.setXYZ(i, f, f * 0.95, f * 0.9); } c.needsUpdate = true; } const flick = 1 + Math.sin(t * 7.1) * 0.04 + Math.sin(t * 17.9) * 0.03; const set = (el, base) => { const obj = el && el.getObject3D('light'); if (obj) obj.intensity = base * flick; }; set(this.lights.altar, 14); set(this.lights.hall, 7); set(this.lights.fire, 5 * (1 + Math.sin(t * 13) * 0.08)); if (this.lampGlow) this.lampGlow.material.opacity = 0.9 + Math.sin(t * 0.8) * 0.06; // Incense smoke rising and spreading. for (const s of this.smokes) { const pos = s.points.geometry.attributes.position; const col = s.points.geometry.attributes.color; for (let i = 0; i < s.ages.length; i++) { s.ages[i] += dt; if (s.ages[i] > 6) s.ages[i] -= 6; const age = s.ages[i]; const k = age / 6; const sway = Math.sin(t * 0.7 + i) * 0.05 * k; pos.setXYZ(i, s.x + s.drift[i][0] * k * 0.5 + sway, s.y + age * 0.28, s.z + s.drift[i][1] * k * 0.5); const a = Math.min(1, age * 2) * (1 - k) * 0.5; col.setXYZW(i, 0.8, 0.8, 0.85, a); } pos.needsUpdate = true; col.needsUpdate = true; } // The Veil opens and closes over about two seconds. const want = this.data.veil ? 1 : 0; if (Math.abs(this.veilOpen - want) > 1e-3) { this.veilOpen += Math.sign(want - this.veilOpen) * Math.min(Math.abs(want - this.veilOpen), dt / 2); const e = this.veilOpen < 0.5 ? 2 * this.veilOpen ** 2 : 1 - (-2 * this.veilOpen + 2) ** 2 / 2; this.shapeVeil(e); } if (time - this.lastSun > 5000) { this.lastSun = time; this.updateSun(); } }, // Light the banner of the adoration whose time it is, and put the gold sun // where the Sun really is (dim when it's below the horizon). updateSun() { if (typeof Astro === 'undefined') return; const sky = this.data.sky && this.data.sky.components.sky; const ms = sky && sky.skyStart !== undefined ? sky.skyStart + (Date.now() - sky.realStart) * sky.speed : Date.now(); const { lat, lon } = this.here; const dir = Astro.bodies(ms, lat, lon).sun; const { alt, az } = Astro.altAz(dir, Astro.horizonMatrix(ms, lat, lon)); // Hour angle: negative in the morning. const ra = Math.atan2(dir[1], dir[0]) / RAD; const hourAngle = ((Astro.siderealTime(ms) + lon - ra + 540) % 360) - 180; const up = alt > -0.83; const now = up ? (hourAngle < 0 ? 'RA' : 'AHATHOOR') : (hourAngle > 0 ? 'TUM' : 'KHEPHRA'); for (const b of this.banners) b.mat.emissiveIntensity = b.station.name === now ? 0.55 : 0.04; this.resh = now; const heading = (az - this.east) * RAD; const r = 4.2; this.sunMarker.position.set( CIRCLE.x + Math.sin(heading) * r, Math.max(0.3, 1.6 + Math.sin(alt * RAD) * 3), CIRCLE.z - Math.cos(heading) * r, ); this.sunMarker.material.opacity = up ? 1 : 0.25; }, }); // For whoever places people and the sky around the temple. window.TempleLayout = { BOLESKINE, bearing, skySouth, HALL, VESICA, CIRCLE, DOOR }; })();