From e69ad01e700e80fa959dfee08cd556ca0d777ffd Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 1 Nov 2026 05:43:24 -0800 Subject: Yesod: self-hosted WebXR rooms Start a room in a world and hang out in VR from the headset's browser: A-Frame + Networked-A-Frame over EasyRTC, with a coturn relay that can hide everyone's IP address. Ships with the Campfire, Temple, Cabin, Balcony and Test room worlds. Co-Authored-By: Claude Opus 5.5 --- public/js/components/temple.js | 1999 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1999 insertions(+) create mode 100644 public/js/components/temple.js (limited to 'public/js/components/temple.js') 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 }; +})(); -- cgit v1.2.3