summaryrefslogtreecommitdiff
path: root/public/js/components/temple.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/js/components/temple.js')
-rw-r--r--public/js/components/temple.js1999
1 files changed, 1999 insertions, 0 deletions
diff --git a/public/js/components/temple.js b/public/js/components/temple.js
new file mode 100644
index 0000000..6233751
--- /dev/null
+++ b/public/js/components/temple.js
@@ -0,0 +1,1999 @@
+/* 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 };
+})();