summaryrefslogtreecommitdiff
path: root/public/js/components
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-01 05:43:24 -0800
committerskylar <me@casper>2026-11-01 05:43:24 -0800
commite69ad01e700e80fa959dfee08cd556ca0d777ffd (patch)
tree645fc9db2142e87a5ef302733a77ba2fec848d88 /public/js/components
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 <noreply@anthropic.com>
Diffstat (limited to 'public/js/components')
-rw-r--r--public/js/components/ambience.js130
-rw-r--r--public/js/components/balcony.js592
-rw-r--r--public/js/components/cabin.js1064
-rw-r--r--public/js/components/campsite.js479
-rw-r--r--public/js/components/chess.js742
-rw-r--r--public/js/components/hand-tilt.js37
-rw-r--r--public/js/components/locomotion.js312
-rw-r--r--public/js/components/nametag.js69
-rw-r--r--public/js/components/player-info.js16
-rw-r--r--public/js/components/screens.js483
-rw-r--r--public/js/components/sky.js498
-rw-r--r--public/js/components/temple.js1999
-rw-r--r--public/js/components/testroom.js72
-rw-r--r--public/js/components/voice-level.js39
-rw-r--r--public/js/components/vrm-avatar.js486
-rw-r--r--public/js/components/wield.js629
16 files changed, 7647 insertions, 0 deletions
diff --git a/public/js/components/ambience.js b/public/js/components/ambience.js
new file mode 100644
index 0000000..e7454ae
--- /dev/null
+++ b/public/js/components/ambience.js
@@ -0,0 +1,130 @@
+/* global AFRAME, THREE */
+// Quiet night sounds, from real recordings (public/assets/sounds, made by
+// tools/sounds/build-sounds.py): the fire crackling at the fire ring, wind in
+// the trees all around, and now and then something out in the dark, from a
+// random spot among the trees: an owl calling (often again, from the same
+// tree), a twig snapping, or footsteps that wander past.
+//
+// They play through the same listener as the voices (networked-audio-source
+// creates it the same way), so they come from where they happen. Nothing is
+// heard until app.js resumes the audio context on Join.
+(function () {
+ const SOUND_DIR = 'assets/sounds/';
+ const FIRE = { file: 'fire', volume: 0.7, refDistance: 1.2, rolloff: 1.5 };
+ const WIND = { file: 'wind', volume: 0.12 };
+ // What can be heard in the forest: recordings, loudness, how often (relative
+ // to each other), and how high up it happens.
+ const CRITTERS = {
+ owl: { files: ['owl-great-horned-1', 'owl-great-horned-2', 'owl-barred'], volume: 0.55, weight: 2, height: [5, 9] },
+ twig: { files: ['twig-1', 'twig-2'], volume: 0.6, weight: 3, height: [0, 0.3] },
+ steps: { files: ['steps-twigs', 'steps-leaves'], volume: 0.5, weight: 3, height: [0, 0.3] },
+ };
+ const POOL = 4;
+
+ const pick = (list) => list[Math.floor(Math.random() * list.length)];
+
+ AFRAME.registerComponent('ambience', {
+ init() {
+ const sceneEl = this.el.sceneEl;
+ // Share the voices' listener; whichever starts first makes it.
+ if (!sceneEl.audioListener) {
+ sceneEl.audioListener = new THREE.AudioListener();
+ if (sceneEl.camera) sceneEl.camera.add(sceneEl.audioListener);
+ sceneEl.addEventListener('camera-set-active', (e) => e.detail.cameraEl.getObject3D('camera').add(sceneEl.audioListener));
+ }
+ this.listener = sceneEl.audioListener;
+ this.buffers = {};
+ this.pool = [];
+
+ const loader = new THREE.AudioLoader();
+ const load = (name) => new Promise((resolve, reject) => loader.load(`${SOUND_DIR}${name}.ogg`, resolve, undefined, reject))
+ .then((buffer) => { this.buffers[name] = buffer; });
+
+ Promise.all([load(FIRE.file), load(WIND.file)])
+ .then(() => this.startLoops())
+ .catch((err) => console.warn("Couldn't load the fire and wind sounds", err));
+ Promise.all(Object.values(CRITTERS).flatMap((c) => c.files.map(load)))
+ .then(() => this.scheduleCritter(5000))
+ .catch((err) => console.warn("Couldn't load the forest sounds", err));
+ },
+
+ remove() {
+ clearTimeout(this.timer);
+ clearTimeout(this.owlTimer);
+ for (const s of [this.fire, this.wind, ...this.pool]) {
+ if (!s) continue;
+ if (s.isPlaying) s.stop();
+ s.removeFromParent();
+ }
+ },
+
+ startLoops() {
+ this.fire = new THREE.PositionalAudio(this.listener);
+ this.fire.setBuffer(this.buffers[FIRE.file]);
+ this.fire.setLoop(true);
+ this.fire.setVolume(FIRE.volume);
+ this.fire.setRefDistance(FIRE.refDistance);
+ this.fire.setRolloffFactor(FIRE.rolloff);
+ this.fire.position.set(0, 0.5, 0);
+ this.el.sceneEl.object3D.add(this.fire);
+ this.fire.play();
+
+ this.wind = new THREE.Audio(this.listener);
+ this.wind.setBuffer(this.buffers[WIND.file]);
+ this.wind.setLoop(true);
+ this.wind.setVolume(WIND.volume);
+ this.wind.play();
+ },
+
+ scheduleCritter(delay) {
+ this.timer = setTimeout(() => {
+ // Only while someone can hear it, so they don't pile up while suspended.
+ if (this.listener.context.state === 'running') this.critter();
+ this.scheduleCritter(5000 + Math.random() * 12000);
+ }, delay);
+ },
+
+ critter(kind, file, where) {
+ if (!kind) {
+ const names = Object.keys(CRITTERS);
+ let n = Math.random() * names.reduce((sum, k) => sum + CRITTERS[k].weight, 0);
+ kind = names.find((k) => (n -= CRITTERS[k].weight) < 0) || names[0];
+ }
+ const spec = CRITTERS[kind];
+ file = file || pick(spec.files);
+ const angle = Math.random() * Math.PI * 2;
+ if (!where) {
+ const radius = 14 + Math.random() * 16;
+ const height = spec.height[0] + Math.random() * (spec.height[1] - spec.height[0]);
+ where = new THREE.Vector3(Math.sin(angle) * radius, height, Math.cos(angle) * radius);
+ }
+ let sound = this.pool.find((s) => !s.isPlaying);
+ if (!sound) {
+ if (this.pool.length >= POOL) return;
+ sound = new THREE.PositionalAudio(this.listener);
+ sound.setRefDistance(6);
+ sound.setRolloffFactor(1);
+ this.el.sceneEl.object3D.add(sound);
+ this.pool.push(sound);
+ }
+ sound.setBuffer(this.buffers[file]);
+ sound.setVolume(spec.volume * (0.6 + 0.4 * Math.random()));
+ sound.position.copy(where);
+ // Footsteps walk past, about 1 m/s, along the edge of the trees.
+ sound.userData.velocity = kind === 'steps'
+ ? new THREE.Vector3(Math.cos(angle), 0, -Math.sin(angle)).multiplyScalar(Math.random() < 0.5 ? 1 : -1)
+ : null;
+ sound.play();
+ if (kind === 'owl' && Math.random() < 0.6) {
+ this.owlTimer = setTimeout(() => this.critter('owl', file, where), 7000 + Math.random() * 8000);
+ }
+ },
+
+ tick(time, delta) {
+ const dt = Math.min(delta, 100) / 1000;
+ for (const s of this.pool) {
+ if (s.isPlaying && s.userData.velocity) s.position.addScaledVector(s.userData.velocity, dt);
+ }
+ },
+ });
+})();
diff --git a/public/js/components/balcony.js b/public/js/components/balcony.js
new file mode 100644
index 0000000..9538f4b
--- /dev/null
+++ b/public/js/components/balcony.js
@@ -0,0 +1,592 @@
+/* global AFRAME, THREE */
+// The world: a balcony high up a glass tower, over a big city on a grey,
+// foggy day. The city is plain boxes, snapped to 3.5 m floors and bays and
+// painted with two window textures (concrete and glass), merged into one mesh
+// per texture; every balcony up and down the tower is one instanced unit, and
+// only ours has furniture. The fog (worlds.json) and two drifting layers of
+// cloud, one well below the balcony and one not far above, hide how simple it
+// all is: the street is out of sight, the tallest towers vanish into the
+// overcast, and red aircraft lights blink on the roofs around.
+//
+// The balcony floor is at y = 0 and its middle at the origin; -z is out over
+// the city, +z the glass doors back into the flat.
+(function () {
+ const SEED = 2112;
+
+ const BALCONY = { halfX: 3.4, front: -1.4, back: 1.3, slab: 0.25, rail: 1.08 };
+ const STOREY = 3.5; // floor to floor, and the width of a bay
+ const GROUND = -140; // the street, 40 storeys down
+ const TILE = STOREY * 8; // the window textures are 8 bays × 8 storeys
+ // Our tower: its front is the balcony's back wall; its top is in the clouds.
+ const TOWER = { halfX: 16, depth: 32, top: 70 };
+ const COLUMNS = [-11, 0, 11]; // balconies across the tower's face
+ const FLOORS = { below: 38, above: 10 };
+ const BLOCK = 45; // city blocks, with streets between
+ const STREET = 15;
+ const REACH = 6; // blocks out from ours in each direction
+ const CLOUDS = { below: -58, above: 38, size: 1800 };
+ const BLINK = 1.6; // seconds per aircraft-light flash cycle
+ const MARGIN = 0.25; // how close your head gets to the glass
+
+ 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;
+ };
+ }
+
+ // --- painted textures --------------------------------------------------------
+
+ function canvasTexture(width, height, paint, { color = true } = {}) {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ paint(canvas.getContext('2d'), width, height);
+ const texture = new THREE.CanvasTexture(canvas);
+ if (color) texture.colorSpace = THREE.SRGBColorSpace;
+ texture.anisotropy = 8;
+ texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
+ return texture;
+ }
+
+ // A facade texture and its glow: 8 × 8 cells, one per bay and storey, with
+ // some windows lit. paintCell(ctx, x, y, size, lit) paints one cell; the
+ // glow gets the lit windows only. The top-left pixels are always plain wall,
+ // which is what roofs use.
+ function facade(rand, litShare, paintCell, paintGlow) {
+ const size = 64;
+ const lit = [];
+ for (let i = 0; i < 64; i++) lit.push(rand() < litShare);
+ const map = canvasTexture(512, 512, (ctx) => {
+ for (let i = 0; i < 64; i++) paintCell(ctx, (i % 8) * size, Math.floor(i / 8) * size, size, lit[i]);
+ });
+ const glow = canvasTexture(512, 512, (ctx) => {
+ ctx.fillStyle = '#000';
+ ctx.fillRect(0, 0, 512, 512);
+ for (let i = 0; i < 64; i++) if (lit[i]) paintGlow(ctx, (i % 8) * size, Math.floor(i / 8) * size, size);
+ });
+ return { map, glow };
+ }
+
+ // Punched windows in a pale wall (tinted per building).
+ function concreteFacade(rand) {
+ const win = (x, y) => [x + 12, y + 14, 40, 38];
+ return facade(rand, 0.16, (ctx, x, y, s, lit) => {
+ ctx.fillStyle = '#fff';
+ ctx.fillRect(x, y, s, s);
+ ctx.fillStyle = 'rgba(0,0,0,0.06)';
+ ctx.fillRect(x, y + s - 6, s, 6); // a ledge under each storey
+ const [wx, wy, ww, wh] = win(x, y);
+ if (lit) {
+ ctx.fillStyle = '#ead9a6';
+ } else {
+ const g = ctx.createLinearGradient(0, wy, 0, wy + wh);
+ g.addColorStop(0, '#5d6770');
+ g.addColorStop(1, '#3c444b');
+ ctx.fillStyle = g;
+ }
+ ctx.fillRect(wx, wy, ww, wh);
+ ctx.fillStyle = '#d9d9d9';
+ ctx.fillRect(wx + ww / 2 - 1, wy, 2, wh); // the middle frame
+ }, (ctx, x, y) => {
+ const [wx, wy, ww, wh] = win(x, y);
+ ctx.fillStyle = '#8a7650';
+ ctx.fillRect(wx, wy, ww, wh);
+ });
+ }
+
+ // A curtain wall: panes reflecting the grey sky, thin mullions, a dark band
+ // at each floor.
+ function glassFacade(rand) {
+ return facade(rand, 0.1, (ctx, x, y, s, lit) => {
+ const l = 52 + rand() * 10;
+ ctx.fillStyle = lit ? '#d9cba0' : `hsl(205,12%,${l}%)`;
+ ctx.fillRect(x, y, s, s);
+ if (!lit) {
+ ctx.fillStyle = 'rgba(255,255,255,0.08)';
+ ctx.fillRect(x, y, s, s * 0.45);
+ }
+ ctx.fillStyle = '#4c555c';
+ ctx.fillRect(x, y + s - 9, s, 9);
+ ctx.fillStyle = '#c9ced1';
+ ctx.fillRect(x, y, 4, s);
+ ctx.fillRect(x + s / 2 - 1, y, 2, s - 9);
+ }, (ctx, x, y, s) => {
+ ctx.fillStyle = '#6e6244';
+ ctx.fillRect(x + 4, y, s - 4, s - 9);
+ });
+ }
+
+ // Grey concrete pavers, 60 cm square, four to a tile.
+ function pavers(rand) {
+ return canvasTexture(256, 256, (ctx, w, h) => {
+ const s = w / 2;
+ for (let i = 0; i < 4; i++) {
+ const x = (i % 2) * s;
+ const y = Math.floor(i / 2) * s;
+ ctx.fillStyle = `hsl(35,4%,${62 + rand() * 7}%)`;
+ ctx.fillRect(x, y, s, s);
+ for (let k = 0; k < 220; k++) {
+ ctx.fillStyle = `rgba(${rand() < 0.5 ? '0,0,0' : '255,255,255'},${0.03 + rand() * 0.05})`;
+ ctx.fillRect(x + rand() * s, y + rand() * s, 1 + rand() * 3, 1 + rand() * 3);
+ }
+ ctx.fillStyle = 'rgba(30,30,30,0.55)';
+ ctx.fillRect(x, y, s, 2);
+ ctx.fillRect(x, y, 2, s);
+ }
+ });
+ }
+
+ // Soft cloud, tileable: blobs drawn again across each edge.
+ function cloudTexture(rand, base) {
+ return canvasTexture(256, 256, (ctx, w, h) => {
+ ctx.fillStyle = `rgba(255,255,255,${base})`;
+ ctx.fillRect(0, 0, w, h);
+ for (let i = 0; i < 70; i++) {
+ const x = rand() * w;
+ const y = rand() * h;
+ const r = 18 + rand() * 60;
+ const a = 0.08 + rand() * 0.22;
+ for (const dx of [-w, 0, w]) {
+ for (const dy of [-h, 0, h]) {
+ const g = ctx.createRadialGradient(x + dx, y + dy, 0, x + dx, y + dy, r);
+ g.addColorStop(0, `rgba(255,255,255,${a})`);
+ g.addColorStop(1, 'rgba(255,255,255,0)');
+ ctx.fillStyle = g;
+ ctx.fillRect(x + dx - r, y + dy - r, r * 2, r * 2);
+ }
+ }
+ }
+ });
+ }
+
+ // --- merged boxes --------------------------------------------------------------
+
+ // Collects boxes into one geometry. Their sides take facade UVs in metres
+ // (÷ TILE) from a corner of the building, so windows line up with storeys;
+ // their tops all sample the textures' plain top-left corner. Bottoms are
+ // left out unless asked for, since nobody sees under a building.
+ class Boxes {
+ constructor() {
+ this.position = [];
+ this.normal = [];
+ this.uv = [];
+ this.color = [];
+ this.index = [];
+ }
+
+ quad(corner, u, v, n, uv, color) {
+ const i = this.position.length / 3;
+ const pts = [corner, corner.clone().add(u), corner.clone().add(u).add(v), corner.clone().add(v)];
+ for (let k = 0; k < 4; k++) {
+ this.position.push(pts[k].x, pts[k].y, pts[k].z);
+ this.normal.push(n.x, n.y, n.z);
+ this.uv.push(uv[k * 2], uv[k * 2 + 1]);
+ this.color.push(color.r, color.g, color.b);
+ }
+ this.index.push(i, i + 1, i + 2, i, i + 2, i + 3);
+ }
+
+ // b: { x0, x1, y0, y1, z0, z1 }; u0/v0: texture offset of the building's
+ // corner (in tiles), or null for plain wall all round; base: the
+ // building's ground, where v starts; bottom: whether to close it underneath.
+ box(b, color, roofColor, u0 = 0, v0 = 0, base = b.y0, bottom = false) {
+ const V = THREE.Vector3;
+ const w = b.x1 - b.x0;
+ const d = b.z1 - b.z0;
+ const h = b.y1 - b.y0;
+ const p = 0.004;
+ const plain = [p, 1 - p, p, 1 - p, p, 1 - p, p, 1 - p];
+ const vb = v0 + (b.y0 - base) / TILE;
+ const vt = vb + h / TILE;
+ const side = (corner, u, n, len, offset) => {
+ const ua = u0 + offset / TILE;
+ const ub = ua + len / TILE;
+ this.quad(corner, u, new V(0, h, 0), n, u0 === null ? plain : [ua, vb, ub, vb, ub, vt, ua, vt], color);
+ };
+ // Round the building: front (-z), right (+x), back (+z), left (-x).
+ side(new V(b.x1, b.y0, b.z0), new V(-w, 0, 0), new V(0, 0, -1), w, 0);
+ side(new V(b.x0, b.y0, b.z0), new V(0, 0, d), new V(-1, 0, 0), d, w);
+ side(new V(b.x0, b.y0, b.z1), new V(w, 0, 0), new V(0, 0, 1), w, w + d);
+ side(new V(b.x1, b.y0, b.z1), new V(0, 0, -d), new V(1, 0, 0), d, 2 * w + d);
+ this.quad(new V(b.x0, b.y1, b.z1), new V(w, 0, 0), new V(0, 0, -d), new V(0, 1, 0), plain, roofColor);
+ if (bottom) this.quad(new V(b.x0, b.y0, b.z0), new V(w, 0, 0), new V(0, 0, d), new V(0, -1, 0), plain, roofColor);
+ }
+
+ geometry() {
+ const g = new THREE.BufferGeometry();
+ g.setAttribute('position', new THREE.Float32BufferAttribute(this.position, 3));
+ g.setAttribute('normal', new THREE.Float32BufferAttribute(this.normal, 3));
+ g.setAttribute('uv', new THREE.Float32BufferAttribute(this.uv, 2));
+ g.setAttribute('color', new THREE.Float32BufferAttribute(this.color, 3));
+ g.setIndex(this.index);
+ g.computeBoundingSphere();
+ return g;
+ }
+ }
+
+ // An untextured box, for the balconies and furniture, closed all round:
+ // you see the undersides of the balconies above.
+ function plainBox(boxes, x0, x1, y0, y1, z0, z1, color) {
+ boxes.box({ x0, x1, y0, y1, z0, z1 }, color, color, null, 0, y0, true);
+ }
+
+ AFRAME.registerComponent('balcony', {
+ schema: {
+ windSound: { default: '' },
+ },
+
+ init() {
+ this.rand = mulberry32(SEED);
+ this.group = new THREE.Group();
+ this.el.setObject3D('mesh', this.group);
+ this.disposables = [];
+ this.spawn = { x: 0.6, z: 0.5, yaw: 0 }; // facing the city
+ this.screen = { x: 0, y: 1.9, z: BALCONY.front - 4, yaw: 0, width: 3.2 }; // out over the street
+
+ // Two chairs at a little table on the left, a bench on the right.
+ this.seats = [
+ { x: -2.55, y: 0.45, z: -0.35, yaw: -0.25 },
+ { x: -0.95, y: 0.45, z: -0.35, yaw: 0.25 },
+ { x: 1.45, y: 0.45, z: 0.6, yaw: 0 },
+ { x: 2.45, y: 0.45, z: 0.6, yaw: 0 },
+ ];
+
+ this.buildSky();
+ this.buildCity();
+ this.buildBalconies();
+ this.buildFurniture();
+ this.buildClouds();
+
+ if (this.data.windSound) {
+ const sound = document.createElement('a-entity');
+ sound.setAttribute('sound', { src: `url(${this.data.windSound})`, autoplay: true, loop: true, volume: 0.35, positional: false });
+ this.el.appendChild(sound);
+ }
+ },
+
+ track(...things) {
+ this.disposables.push(...things);
+ return things[0];
+ },
+
+ // A grey dome, a little brighter overhead, the fog's colour from the
+ // horizon down so the fogged city melts into it.
+ buildSky() {
+ const fog = this.el.sceneEl.object3D.fog;
+ const horizon = fog ? fog.color.clone() : new THREE.Color('#a3a9ae');
+ const zenith = new THREE.Color('#c4c8cc');
+ const geometry = this.track(new THREE.SphereGeometry(1500, 16, 10));
+ const pos = geometry.attributes.position;
+ const colors = [];
+ const c = new THREE.Color();
+ for (let i = 0; i < pos.count; i++) {
+ const up = Math.max(0, pos.getY(i) / 1500);
+ c.copy(horizon).lerp(zenith, Math.pow(up, 0.6));
+ colors.push(c.r, c.g, c.b);
+ }
+ geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
+ const material = this.track(new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide, fog: false, depthWrite: false }));
+ const dome = new THREE.Mesh(geometry, material);
+ dome.renderOrder = -1;
+ this.group.add(dome);
+ },
+
+ // Blocks on a grid round ours, cut into one to four lots, each a tower:
+ // a box, sometimes a narrower box on top, and a plant room on the roof.
+ buildCity() {
+ const r = this.rand;
+ const concrete = new Boxes();
+ const glass = new Boxes();
+ const lights = [];
+ const pitch = BLOCK + STREET;
+ // Our block's front edge is the balcony's back wall.
+ const originZ = BALCONY.back + BLOCK / 2;
+ const snap = (v) => Math.max(STOREY, Math.round(v / STOREY) * STOREY);
+ const tints = {
+ concrete: ['#dcd7ce', '#cdc7bb', '#bcc0c3', '#aea79d', '#d2cabf', '#a3a8ac', '#c2b6a6'],
+ glass: ['#e4ecf2', '#d3dce2', '#c4ced6', '#eef0f0', '#b9c6cf'],
+ };
+ const tint = (list, k) => new THREE.Color(list[Math.floor(r() * list.length)]).multiplyScalar(k);
+
+ const tower = (x0, x1, z0, z1, height) => {
+ const isGlass = r() < 0.38;
+ const boxes = isGlass ? glass : concrete;
+ const color = tint(isGlass ? tints.glass : tints.concrete, 0.92 + r() * 0.08);
+ const roof = color.clone().multiplyScalar(0.62);
+ const u0 = Math.floor(r() * 8) / 8;
+ const v0 = Math.floor(r() * 8) / 8;
+ const top = GROUND + height;
+ // A setback: the top third or so stands back from the edges.
+ const w = x1 - x0;
+ const d = z1 - z0;
+ if (height > 60 && w > 14 && d > 14 && r() < 0.5) {
+ const lower = GROUND + snap(height * (0.55 + r() * 0.2));
+ boxes.box({ x0, x1, y0: GROUND, y1: lower, z0, z1 }, color, roof, u0, v0);
+ const ix = snap(w * 0.18);
+ const iz = snap(d * 0.18);
+ x0 += ix; x1 -= ix; z0 += iz; z1 -= iz;
+ boxes.box({ x0, x1, y0: lower, y1: top, z0, z1 }, color, roof, u0, v0, GROUND);
+ } else {
+ boxes.box({ x0, x1, y0: GROUND, y1: top, z0, z1 }, color, roof, u0, v0);
+ }
+ // The plant room, plain wall all round.
+ const pw = Math.min(x1 - x0, 8) * (0.4 + r() * 0.3);
+ const pd = Math.min(z1 - z0, 8) * (0.4 + r() * 0.3);
+ const px = x0 + (x1 - x0 - pw) * r();
+ const pz = z0 + (z1 - z0 - pd) * r();
+ const ph = 2.5 + r() * 3;
+ concrete.box({ x0: px, x1: px + pw, y0: top, y1: top + ph, z0: pz, z1: pz + pd }, roof, roof, null);
+ // Aircraft lights on the corners of the tall ones still under the cloud.
+ if (top > CLOUDS.below + 4 && top < CLOUDS.above - 4) {
+ for (const [x, z] of [[x0, z0], [x1, z0], [x0, z1], [x1, z1]]) lights.push(new THREE.Vector3(x, top + 0.6, z));
+ }
+ };
+
+ for (let i = -REACH; i <= REACH; i++) {
+ for (let j = -REACH; j <= REACH; j++) {
+ const cx = i * pitch;
+ const cz = originZ + j * pitch;
+ if (i === 0 && j === 0) continue; // ours
+ const distance = Math.hypot(cx, cz);
+ if (distance > (REACH + 0.5) * pitch) continue; // round the city off
+ // Mostly below us, so there's a view; some up to our height, a few
+ // up into the cloud. In front, close by, keep them low.
+ const roll = r();
+ let height = roll < 0.55 ? 30 + r() * 80 : roll < 0.88 ? 110 + r() * 70 : 190 + r() * 150;
+ const ahead = cz < 0 && Math.abs(cx) < 100;
+ if (ahead && distance < 150) height = Math.min(height, -GROUND - 25 - r() * 30);
+ // Cut the block into lots.
+ const split = r();
+ const lots = [];
+ const h = BLOCK / 2;
+ if (split < 0.3) lots.push([cx - h, cx + h, cz - h, cz + h]);
+ else if (split < 0.55) lots.push([cx - h, cx, cz - h, cz + h], [cx, cx + h, cz - h, cz + h]);
+ else if (split < 0.75) lots.push([cx - h, cx + h, cz - h, cz], [cx - h, cx + h, cz, cz + h]);
+ else for (const [a, b] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) lots.push([cx + Math.min(0, a * h), cx + Math.max(0, a * h), cz + Math.min(0, b * h), cz + Math.max(0, b * h)]);
+ for (const [x0, x1, z0, z1] of lots) {
+ // Each lot's tower stands in from its edges by whole bays (or
+ // more), and varies around the block's height.
+ const w = snap((x1 - x0) - 2 - r() * 8);
+ const d = snap((z1 - z0) - 2 - r() * 8);
+ const mx = (x0 + x1) / 2 + (r() - 0.5) * ((x1 - x0) - w);
+ const mz = (z0 + z1) / 2 + (r() - 0.5) * ((z1 - z0) - d);
+ const lotHeight = snap(Math.max(20, height * (0.6 + r() * 0.5)));
+ const capped = ahead && distance < 150 ? Math.min(lotHeight, snap(height)) : lotHeight;
+ tower(mx - w / 2, mx + w / 2, mz - d / 2, mz + d / 2, capped);
+ }
+ }
+ }
+
+ // Our own tower: glass, with the balconies on its face.
+ glass.box({ x0: -TOWER.halfX, x1: TOWER.halfX, y0: GROUND, y1: TOWER.top, z0: BALCONY.back, z1: BALCONY.back + TOWER.depth },
+ new THREE.Color('#dde5ea'), new THREE.Color('#888'), 0, 0);
+
+ const concreteTex = concreteFacade(r);
+ const glassTex = glassFacade(r);
+ for (const [boxes, tex] of [[concrete, concreteTex], [glass, glassTex]]) {
+ const material = this.track(new THREE.MeshLambertMaterial({
+ map: tex.map, emissiveMap: tex.glow, emissive: '#ffffff', emissiveIntensity: 0.8, vertexColors: true,
+ }), tex.map, tex.glow);
+ this.group.add(new THREE.Mesh(this.track(boxes.geometry()), material));
+ }
+
+ // The street, far down in the murk.
+ const ground = new THREE.Mesh(
+ this.track(new THREE.PlaneGeometry(3000, 3000)),
+ this.track(new THREE.MeshLambertMaterial({ color: '#55595d' })),
+ );
+ ground.rotation.x = -Math.PI / 2;
+ ground.position.y = GROUND;
+ this.group.add(ground);
+
+ // The aircraft lights all flash together.
+ this.lightMaterial = this.track(new THREE.MeshBasicMaterial({ color: '#ff2a1a' }));
+ const lamp = new THREE.InstancedMesh(this.track(new THREE.OctahedronGeometry(0.7, 0)), this.lightMaterial, lights.length);
+ const m = new THREE.Matrix4();
+ lights.forEach((p, k) => lamp.setMatrixAt(k, m.makeTranslation(p.x, p.y, p.z)));
+ lamp.computeBoundingSphere();
+ this.group.add(lamp);
+ },
+
+ // One balcony (slab, posts and rails, then its glass) instanced up and
+ // down the tower's face; ours is the one at floor 0 in the middle column.
+ buildBalconies() {
+ const B = BALCONY;
+ const steel = new THREE.Color('#3b4045');
+ const slab = new THREE.Color('#c9c7c2');
+ const unit = new Boxes();
+ plainBox(unit, -B.halfX, B.halfX, -B.slab, 0, B.front, B.back, slab);
+ const post = 0.05;
+ for (const x of [-B.halfX, B.halfX]) {
+ plainBox(unit, x - post / 2, x + post / 2, 0, B.rail, B.front, B.front + post, steel);
+ plainBox(unit, x - post / 2, x + post / 2, B.rail - 0.05, B.rail, B.front, B.back, steel); // side rails
+ }
+ for (const x of [-1.15, 1.15]) plainBox(unit, x - post / 2, x + post / 2, 0, B.rail, B.front, B.front + post, steel);
+ plainBox(unit, -B.halfX, B.halfX, B.rail - 0.05, B.rail + 0.01, B.front - 0.01, B.front + 0.06, steel); // top rail
+ // The glass doors' frame across the back.
+ for (const x of [-B.halfX, -1.15, 1.15, B.halfX]) plainBox(unit, x - 0.04, x + 0.04, 0, STOREY - B.slab, B.back - 0.08, B.back, steel);
+ plainBox(unit, -B.halfX, B.halfX, STOREY - B.slab - 0.08, STOREY - B.slab, B.back - 0.08, B.back, steel);
+ plainBox(unit, -B.halfX, B.halfX, 0, 0.06, B.back - 0.08, B.back, steel);
+
+ const panes = new Boxes();
+ const white = new THREE.Color('#ffffff');
+ const g = 0.012;
+ plainBox(panes, -B.halfX, B.halfX, 0.04, B.rail - 0.06, B.front + 0.02, B.front + 0.02 + g, white);
+ for (const x of [-B.halfX, B.halfX]) plainBox(panes, x - g / 2, x + g / 2, 0.04, B.rail - 0.06, B.front + post, B.back - 0.08, white);
+
+ const opaque = this.track(new THREE.MeshLambertMaterial({ vertexColors: true }));
+ const glassMat = this.track(new THREE.MeshLambertMaterial({ color: '#a9c2c9', transparent: true, opacity: 0.22, depthWrite: false }));
+ const count = COLUMNS.length * (FLOORS.below + FLOORS.above + 1);
+ const units = new THREE.InstancedMesh(this.track(unit.geometry()), opaque, count);
+ const glass = new THREE.InstancedMesh(this.track(panes.geometry()), glassMat, count);
+ glass.renderOrder = 1;
+ const m = new THREE.Matrix4();
+ let k = 0;
+ for (const x of COLUMNS) {
+ for (let f = -FLOORS.below; f <= FLOORS.above; f++) {
+ m.makeTranslation(x, f * STOREY, 0);
+ units.setMatrixAt(k, m);
+ glass.setMatrixAt(k, m);
+ k++;
+ }
+ }
+ units.computeBoundingSphere();
+ glass.computeBoundingSphere();
+ this.group.add(units, glass);
+
+ // Pavers on our own floor.
+ const floorMap = this.track(pavers(this.rand));
+ floorMap.repeat.set(B.halfX * 2 / 1.2, (B.back - B.front) / 1.2);
+ const floor = new THREE.Mesh(
+ this.track(new THREE.PlaneGeometry(B.halfX * 2, B.back - B.front)),
+ this.track(new THREE.MeshLambertMaterial({ map: floorMap })),
+ );
+ floor.rotation.x = -Math.PI / 2;
+ floor.position.set(0, 0.002, (B.front + B.back) / 2);
+ this.group.add(floor);
+
+ // Behind our own doors, a dim room rather than the tower's facade.
+ const inside = new THREE.Mesh(
+ this.track(new THREE.PlaneGeometry(B.halfX * 2, STOREY - B.slab)),
+ this.track(new THREE.MeshLambertMaterial({ color: '#2e3439', emissive: '#3a2f22', emissiveIntensity: 0.35 })),
+ );
+ inside.position.set(0, (STOREY - B.slab) / 2, B.back - 0.01);
+ inside.rotation.y = Math.PI;
+ this.group.add(inside);
+ },
+
+ // Two chairs at a round table, a bench, and planters in the front corners.
+ buildFurniture() {
+ const r = this.rand;
+ const parts = new Boxes();
+ const wood = new THREE.Color('#9c7650');
+ const steel = new THREE.Color('#33383c');
+ const leg = 0.035;
+ const chair = (x, z, yaw) => {
+ const o = new THREE.Group();
+ o.position.set(x, 0, z);
+ o.rotation.y = yaw;
+ const b = new Boxes();
+ plainBox(b, -0.24, 0.24, 0.42, 0.46, -0.22, 0.22, wood); // seat
+ plainBox(b, -0.24, 0.24, 0.5, 0.9, 0.2, 0.24, wood); // back
+ for (const [lx, lz] of [[-0.21, -0.19], [0.21, -0.19], [-0.21, 0.21], [0.21, 0.21]]) {
+ plainBox(b, lx - leg / 2, lx + leg / 2, 0, lz > 0 ? 0.9 : 0.42, lz - leg / 2, lz + leg / 2, steel);
+ }
+ o.add(new THREE.Mesh(this.track(b.geometry()), this.furnitureMat));
+ this.group.add(o);
+ };
+ this.furnitureMat = this.track(new THREE.MeshLambertMaterial({ vertexColors: true }));
+ for (const seat of this.seats.slice(0, 2)) chair(seat.x, seat.z + 0.05, seat.yaw);
+
+ // The table between them.
+ const tableTop = new THREE.Mesh(this.track(new THREE.CylinderGeometry(0.34, 0.34, 0.03, 12)), this.track(new THREE.MeshLambertMaterial({ color: '#8a6a48' })));
+ tableTop.position.set(-1.75, 0.68, -0.55);
+ const stem = new THREE.Mesh(this.track(new THREE.CylinderGeometry(0.03, 0.03, 0.67, 6)), this.track(new THREE.MeshLambertMaterial({ color: '#33383c' })));
+ stem.position.set(-1.75, 0.335, -0.55);
+ const foot = new THREE.Mesh(this.track(new THREE.CylinderGeometry(0.2, 0.22, 0.03, 10)), stem.material);
+ foot.position.set(-1.75, 0.015, -0.55);
+ this.group.add(tableTop, stem, foot);
+ // A mug on it.
+ const mug = new THREE.Mesh(this.track(new THREE.CylinderGeometry(0.04, 0.035, 0.09, 8)), this.track(new THREE.MeshLambertMaterial({ color: '#e8e2d6' })));
+ mug.position.set(-1.65, 0.74, -0.6);
+ this.group.add(mug);
+
+ // The bench, against the doors.
+ plainBox(parts, 0.95, 2.95, 0.41, 0.46, 0.38, 0.82, wood);
+ plainBox(parts, 0.95, 2.95, 0.55, 0.9, 0.86, 0.9, wood);
+ for (const x of [1.02, 2.88]) {
+ plainBox(parts, x - 0.03, x + 0.03, 0, 0.41, 0.42, 0.48, steel);
+ plainBox(parts, x - 0.03, x + 0.03, 0, 0.9, 0.82, 0.88, steel);
+ }
+ // Planters: concrete troughs in the two front corners.
+ const trough = new THREE.Color('#8e8c88');
+ const planters = [[-BALCONY.halfX + 0.3, BALCONY.front + 0.32], [BALCONY.halfX - 0.3, BALCONY.front + 0.32]];
+ for (const [x, z] of planters) plainBox(parts, x - 0.25, x + 0.25, 0, 0.5, z - 0.25, z + 0.25, trough);
+ this.group.add(new THREE.Mesh(this.track(parts.geometry()), this.furnitureMat));
+
+ // Low-poly shrubs in them.
+ const leaves = this.track(new THREE.MeshLambertMaterial({ color: '#4f6a45', flatShading: true }));
+ const bush = this.track(new THREE.IcosahedronGeometry(0.28, 0));
+ for (const [x, z] of planters) {
+ for (let i = 0; i < 3; i++) {
+ const b = new THREE.Mesh(bush, leaves);
+ b.position.set(x + (r() - 0.5) * 0.2, 0.68 + i * 0.16, z + (r() - 0.5) * 0.2);
+ b.scale.setScalar(1 - i * 0.22);
+ b.rotation.set(r() * 3, r() * 3, r() * 3);
+ this.group.add(b);
+ }
+ }
+ },
+
+ // A thin layer far below that hides the street, and the overcast just
+ // above, both drifting slowly.
+ buildClouds() {
+ const r = this.rand;
+ this.clouds = [];
+ for (const [y, base, opacity, repeat, drift] of [
+ [CLOUDS.below, 0.5, 0.9, 5, [0.0009, 0.0004]],
+ [CLOUDS.above, 0.45, 0.95, 4, [-0.0006, 0.0005]],
+ ]) {
+ const map = this.track(cloudTexture(r, base));
+ map.repeat.set(repeat, repeat);
+ const mesh = new THREE.Mesh(
+ this.track(new THREE.PlaneGeometry(CLOUDS.size, CLOUDS.size)),
+ this.track(new THREE.MeshBasicMaterial({ map, color: '#dde1e4', transparent: true, opacity, depthWrite: false, side: THREE.DoubleSide })),
+ );
+ mesh.rotation.x = -Math.PI / 2;
+ mesh.position.y = y;
+ mesh.renderOrder = 2;
+ this.group.add(mesh);
+ this.clouds.push({ map, drift });
+ }
+ },
+
+ tick(time, delta) {
+ const dt = Math.min(delta || 0, 100) / 1000;
+ for (const { map, drift } of this.clouds) {
+ map.offset.x = (map.offset.x + drift[0] * dt) % 1;
+ map.offset.y = (map.offset.y + drift[1] * dt) % 1;
+ }
+ // A short bright flash, then dark.
+ const phase = (time / 1000 / BLINK) % 1;
+ this.lightMaterial.color.setRGB(phase < 0.35 ? 1 : 0.06, phase < 0.35 ? 0.1 : 0.01, phase < 0.35 ? 0.06 : 0.005);
+ },
+
+ remove() {
+ for (const d of this.disposables) d.dispose();
+ this.el.removeObject3D('mesh');
+ },
+
+ // Keeps a head position on the balcony, back from the glass.
+ confine(p) {
+ const B = BALCONY;
+ p.x = Math.min(B.halfX - MARGIN, Math.max(-B.halfX + MARGIN, p.x));
+ p.z = Math.min(B.back - MARGIN, Math.max(B.front + MARGIN, p.z));
+ return p;
+ },
+ });
+})();
diff --git a/public/js/components/cabin.js b/public/js/components/cabin.js
new file mode 100644
index 0000000..e9519dd
--- /dev/null
+++ b/public/js/components/cabin.js
@@ -0,0 +1,1064 @@
+/* global AFRAME, THREE */
+// The world: a log cabin at night. A stone fireplace with a chess table and
+// three armchairs, a bookshelf, a door, a magick circle and triangle painted
+// on the floor under a hanging lantern, two wands on the windowsill to draw
+// with, and a big window over a lake, where the sky (sky.js, pinMoon) holds the
+// real Moon. Everything is built here from boxes and cylinders with painted
+// canvas textures; only the pines outside are models (Kenney's Nature Kit,
+// like the campfire's). The chess board is its own entity (chess.js) in the
+// world's template, and the table is built under wherever it is.
+//
+// The room's floor is at y = 0 and its middle at the origin; -z is the window
+// (and the lake), +x the fireplace, -x the bookshelf, +z the door.
+(function () {
+ const ENV_DIR = 'assets/env/';
+ const SEED = 1904;
+
+ const ROOM = { halfX: 3, halfZ: 2.5 }; // inside faces of the walls
+ const LOG = 0.13; // log radius
+ const COURSES = 9;
+ const EAVE = COURSES * LOG * 2; // 2.34 m, where the roof starts
+ const RISE = 1.4; // ridge height above the eaves
+ const OVERHANG = 0.22; // logs past the corners
+ const CHINK = 0.07; // mortar this far in from the log centres
+ const WINDOW = { half: 0.65, sill: LOG * 2 * 3, top: EAVE }; // a hole of whole courses
+ const DOOR = { x: -1.7, half: 0.48, top: LOG * 2 * 8 };
+ const HEARTH = { front: 2.35, half: 0.85, opening: 0.43, openTop: 0.75, top: 1.25 };
+ const SHELF = { front: -2.62, z: 1.25, half: 0.8, height: 2.0 };
+ const LAKE = { x: 0, z: -88, r: 72, y: -0.22 };
+ const MARGIN = 0.25; // how close your head gets to a wall
+ const LEAN = 0.08; // and to the chess table, so you can reach across it
+
+ // Where the chess table goes if the template has no board; an armchair
+ // stands at each side of it, and a third in the corner by the hearth.
+ const TABLE = { x: 0.6, z: 0, half: 0.4, height: 0.62 };
+ const FIRESIDE = { x: 1.65, z: -1.7 };
+ const RUG_R = 1.1;
+
+ // The magick circle: its middle, radius, and the triangle's gap from it and
+ // side. East, toward the triangle, points into the corner by the window,
+ // where the lantern hangs over the triangle. (The book's are 9 ft across,
+ // 2 ft off and 3 ft; these are smaller to fit the room.)
+ const CIRCLE = { x: -1.45, z: -0.95, r: 0.85, gap: 0.42, side: 0.75 };
+ const RING = 0.875; // the inner edge of the names' band, × r
+ const LAMPS = 1.16; // how far out the nine pentagrams are, × r
+ const PAINT = 1.34; // half the circle's painted square, × r
+ const TRI_PAINT = 1.1; // the triangle's painted square (m)
+
+ 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;
+ };
+ }
+
+ // --- painted textures --------------------------------------------------------
+
+ function canvasTexture(width, height, paint, { repeat = true } = {}) {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ paint(canvas.getContext('2d'), width, height);
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.anisotropy = 8;
+ if (repeat) texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
+ return texture;
+ }
+
+ function hsl(h, s, l, a = 1) {
+ return `hsla(${h},${s}%,${l}%,${a})`;
+ }
+
+ // Boards running along u, eight to a tile, with seams, butt joints and grain.
+ function planks(rand, { hue = 28, light = 34 } = {}) {
+ return canvasTexture(512, 512, (ctx, w, h) => {
+ const rows = 8;
+ const rh = h / rows;
+ for (let r = 0; r < rows; r++) {
+ let x = -rand() * w;
+ while (x < w) {
+ const len = w * (0.45 + rand() * 0.6);
+ const l = light + (rand() - 0.5) * 9;
+ ctx.fillStyle = hsl(hue + (rand() - 0.5) * 6, 38, l);
+ ctx.fillRect(x, r * rh, len, rh);
+ for (let g = 0; g < 9; g++) {
+ ctx.strokeStyle = hsl(hue, 40, l - 8 - rand() * 6, 0.35);
+ ctx.lineWidth = 0.6 + rand();
+ ctx.beginPath();
+ const y = r * rh + rand() * rh;
+ ctx.moveTo(x, y);
+ ctx.bezierCurveTo(x + len / 3, y + (rand() - 0.5) * 6, x + (2 * len) / 3, y + (rand() - 0.5) * 6, x + len, y + (rand() - 0.5) * 4);
+ ctx.stroke();
+ }
+ if (rand() < 0.25) {
+ ctx.fillStyle = hsl(hue, 40, l - 14, 0.6);
+ ctx.beginPath();
+ ctx.ellipse(x + rand() * len, r * rh + rh * (0.3 + rand() * 0.4), 3 + rand() * 4, 2 + rand() * 2, 0, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.fillStyle = 'rgba(12,6,2,0.85)';
+ ctx.fillRect(x, r * rh, 2, rh);
+ x += len;
+ }
+ ctx.fillStyle = 'rgba(12,6,2,0.9)';
+ ctx.fillRect(0, r * rh, w, 2.5);
+ }
+ });
+ }
+
+ // Bark-less logs: streaks along their length (v), which stretching can't spoil.
+ function logGrain(rand) {
+ return canvasTexture(128, 16, (ctx, w, h) => {
+ ctx.fillStyle = '#fff';
+ ctx.fillRect(0, 0, w, h);
+ for (let i = 0; i < 70; i++) {
+ ctx.fillStyle = `rgba(60,35,15,${0.05 + rand() * 0.18})`;
+ ctx.fillRect(rand() * w, 0, 0.5 + rand() * 2.5, h);
+ }
+ });
+ }
+
+ // Fieldstones in dark mortar, about 16 to a metre-wide tile.
+ function stones(rand) {
+ return canvasTexture(512, 512, (ctx, w, h) => {
+ ctx.fillStyle = '#2e2a26';
+ ctx.fillRect(0, 0, w, h);
+ const rows = 7;
+ const rh = h / rows;
+ for (let r = 0; r < rows; r++) {
+ let x = -rand() * 60;
+ while (x < w) {
+ const sw = 60 + rand() * 70;
+ const sh = rh * (0.78 + rand() * 0.16);
+ const cx = x + sw / 2;
+ const cy = r * rh + rh / 2 + (rand() - 0.5) * 6;
+ const l = 34 + rand() * 20;
+ const g = ctx.createRadialGradient(cx - sw * 0.15, cy - sh * 0.2, 2, cx, cy, sw * 0.6);
+ g.addColorStop(0, hsl(25 + rand() * 20, 8 + rand() * 10, l + 8));
+ g.addColorStop(1, hsl(25 + rand() * 20, 8 + rand() * 8, l - 12));
+ ctx.fillStyle = g;
+ ctx.beginPath();
+ ctx.roundRect(cx - sw / 2 + 3, cy - sh / 2, sw - 6, sh, Math.min(sw, sh) * 0.35);
+ ctx.fill();
+ x += sw;
+ }
+ }
+ // Wrap the edge stones round, so the tile repeats without a seam.
+ ctx.drawImage(ctx.canvas, w - 4, 0, 4, h, 0, 0, 4, h);
+ });
+ }
+
+ // A round rug: wine red, a gold border, and a unicursal hexagram in a circle.
+ function rug() {
+ return canvasTexture(1024, 1024, (ctx, w) => {
+ const c = w / 2;
+ const ring = (r, color, width) => {
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.beginPath();
+ ctx.arc(c, c, r, 0, Math.PI * 2);
+ ctx.stroke();
+ };
+ ctx.fillStyle = '#5b1a1c';
+ ctx.beginPath();
+ ctx.arc(c, c, c - 4, 0, Math.PI * 2);
+ ctx.fill();
+ ring(c - 30, '#b88a3e', 14);
+ ring(c - 58, '#b88a3e', 4);
+ for (let i = 0; i < 48; i++) {
+ const a = (i / 48) * Math.PI * 2;
+ ctx.fillStyle = '#c39a52';
+ ctx.beginPath();
+ ctx.arc(c + Math.cos(a) * (c - 44), c + Math.sin(a) * (c - 44), 5, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.fillStyle = '#2a1626';
+ ctx.beginPath();
+ ctx.arc(c, c, c - 120, 0, Math.PI * 2);
+ ctx.fill();
+ ring(c - 120, '#b88a3e', 6);
+
+ // The unicursal hexagram in one stroke: from the top point down to the
+ // lower right, through the middle to the upper left, down to the bottom,
+ // up to the upper right, through the middle to the lower left and back
+ // up to the top. A five-petalled rose covers the crossing.
+ const R = c - 150;
+ const pts = [90, 330, 150, 270, 30, 210].map((d) => {
+ const a = (d * Math.PI) / 180;
+ return [c + Math.cos(a) * R, c - Math.sin(a) * R];
+ });
+ ctx.strokeStyle = '#c9a457';
+ ctx.lineWidth = 12;
+ ctx.lineJoin = 'miter';
+ ctx.beginPath();
+ ctx.moveTo(pts[0][0], pts[0][1]);
+ for (const [x, y] of pts.slice(1)) ctx.lineTo(x, y);
+ ctx.closePath();
+ ctx.stroke();
+ for (let i = 0; i < 5; i++) {
+ const a = (i / 5) * Math.PI * 2 - Math.PI / 2;
+ ctx.fillStyle = '#9c2a30';
+ ctx.strokeStyle = '#c9a457';
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.ellipse(c + Math.cos(a) * 30, c + Math.sin(a) * 30, 30, 21, a, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+ }
+ ctx.fillStyle = '#c9a457';
+ ctx.beginPath();
+ ctx.arc(c, c, 13, 0, Math.PI * 2);
+ ctx.fill();
+ }, { repeat: false });
+ }
+
+ const VERMILION = '#e2452f';
+ const PAINT_GOLD = '#d2ad52';
+ const LETTERS = "Georgia, 'Times New Roman', serif";
+
+ // Scuffs in floor paint: specks rubbed away.
+ function wear(ctx, size, rand) {
+ ctx.save();
+ ctx.globalCompositeOperation = 'destination-out';
+ for (let i = 0; i < size * 1.2; i++) {
+ ctx.fillStyle = `rgba(0,0,0,${0.15 + rand() * 0.5})`;
+ ctx.beginPath();
+ ctx.ellipse(rand() * size, rand() * size, 1 + rand() * 4, 0.5 + rand() * 1.5, rand() * Math.PI, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+
+ // The circle of Book 4, Part II, ch. II, as DuQuette draws it ("Modern
+ // Circle", Aleister Crowley's Illustrated Goetia): the circle green with the
+ // names in vermilion, the Tau of ten squares (the Sephiroth) in the midst,
+ // the three diamonds where the sides of a triangle escribed about the Tau
+ // would cross, and nine pentagrams without, each with its lamp. East is up.
+ function magickCircle(rand) {
+ return canvasTexture(2048, 2048, (ctx, w) => {
+ const c = w / 2;
+ const R = c / PAINT;
+ const inner = R * RING;
+ ctx.fillStyle = '#2d6a3e';
+ ctx.beginPath();
+ ctx.arc(c, c, R, 0, Math.PI * 2);
+ ctx.arc(c, c, inner, 0, Math.PI * 2, true);
+ ctx.fill();
+ ctx.strokeStyle = PAINT_GOLD;
+ ctx.lineWidth = 5;
+ for (const r of [R, inner]) {
+ ctx.beginPath();
+ ctx.arc(c, c, r, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+
+ // The names, clockwise from a point in the East, tops outward.
+ const names = ['HADIT', 'RA HOOR KHVIT', 'CHAOS', 'BABALON', 'LAYLAH', 'PERDVRABO', 'NVIT'];
+ const glyphs = names.flatMap((name) => ['·', ...name]);
+ const weight = (g) => (g === '·' ? 1.8 : g === ' ' ? 0.6 : 1);
+ const total = glyphs.reduce((sum, g) => sum + weight(g), 0);
+ const band = R - inner;
+ const mid = (R + inner) / 2;
+ ctx.fillStyle = VERMILION;
+ ctx.font = `bold ${Math.round(band * 0.62)}px ${LETTERS}`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ let at = -weight(glyphs[0]) / 2;
+ for (const g of glyphs) {
+ const a = ((at + weight(g) / 2) / total) * Math.PI * 2;
+ at += weight(g);
+ ctx.save();
+ ctx.translate(c + Math.sin(a) * mid, c - Math.cos(a) * mid);
+ ctx.rotate(a);
+ if (g === '·') {
+ ctx.beginPath();
+ ctx.arc(0, 0, band * 0.08, 0, Math.PI * 2);
+ ctx.fill();
+ } else {
+ ctx.fillText(g, 0, band * 0.04);
+ }
+ ctx.restore();
+ }
+
+ // The Tau: five squares above a bar of five, its middle on the centre.
+ const s = R * 0.125;
+ ctx.fillStyle = VERMILION;
+ ctx.strokeStyle = '#4a120c';
+ ctx.lineWidth = 4;
+ for (let i = 0; i < 10; i++) {
+ const x = i < 5 ? c - s / 2 : c - 2.5 * s + (i - 5) * s;
+ const y = i < 5 ? c - 3 * s + i * s : c + 2 * s;
+ ctx.fillRect(x, y, s, s);
+ ctx.strokeRect(x, y, s, s);
+ }
+
+ // Only the corners of the triangle about the Tau are marked: diamonds
+ // just inside the circle, where two of its sides would overlap.
+ const tip = inner - R * 0.03;
+ const long = R * 0.2;
+ const wide = long * Math.tan(Math.PI / 6);
+ ctx.fillStyle = PAINT_GOLD;
+ for (let k = 0; k < 3; k++) {
+ ctx.save();
+ ctx.translate(c, c);
+ ctx.rotate((k * 2 * Math.PI) / 3);
+ ctx.beginPath();
+ ctx.moveTo(0, -tip);
+ ctx.lineTo(wide / 2, -tip + long / 2);
+ ctx.lineTo(0, -tip + long);
+ ctx.lineTo(-wide / 2, -tip + long / 2);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ }
+
+ // Nine pentagrams without, equidistant, the first in the East, each
+ // pointing outward. Their lamps stand in the middles (buildCircle).
+ ctx.strokeStyle = PAINT_GOLD;
+ ctx.lineWidth = 6;
+ ctx.lineJoin = 'miter';
+ for (let k = 0; k < 9; k++) {
+ const a = (k / 9) * Math.PI * 2;
+ const pr = R * 0.1;
+ ctx.save();
+ ctx.translate(c + Math.sin(a) * R * LAMPS, c - Math.cos(a) * R * LAMPS);
+ ctx.rotate(a);
+ ctx.beginPath();
+ for (let i = 0; i < 5; i++) {
+ const b = -Math.PI / 2 + (i * 4 * Math.PI) / 5;
+ ctx[i ? 'lineTo' : 'moveTo'](Math.cos(b) * pr, Math.sin(b) * pr);
+ }
+ ctx.closePath();
+ ctx.stroke();
+ ctx.restore();
+ }
+ wear(ctx, w, rand);
+ }, { repeat: false });
+ }
+
+ // DuQuette's triangle: AGAPE, THELEMA and ABRAHADABRA about it in
+ // vermilion like the circle's names, the inside white and its circle green
+ // as in the Goetia's own. The apex (up) points away from the circle, and the
+ // triangle's centroid is the middle of the texture.
+ function triangleOfArt(rand) {
+ return canvasTexture(1024, 1024, (ctx, w) => {
+ const c = w / 2;
+ const px = w / TRI_PAINT;
+ const side = CIRCLE.side * px;
+ const h = (side * Math.sqrt(3)) / 2;
+ const apex = [c, c - (2 * h) / 3];
+ const left = [c - side / 2, c + h / 3];
+ const right = [c + side / 2, c + h / 3];
+ ctx.fillStyle = '#e4dbc6';
+ ctx.strokeStyle = '#17110d';
+ ctx.lineWidth = 8;
+ ctx.lineJoin = 'miter';
+ ctx.beginPath();
+ ctx.moveTo(...apex);
+ ctx.lineTo(...left);
+ ctx.lineTo(...right);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = '#1f4b2b';
+ ctx.lineWidth = 5;
+ ctx.beginPath();
+ ctx.arc(c, c, h / 3 - 8, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+
+ // The names outside the sides, tops outward, and under the base.
+ const size = Math.round(px * 0.07);
+ ctx.fillStyle = VERMILION;
+ ctx.font = `bold ${size}px ${LETTERS}`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ const along = (text, [x0, y0], [x1, y1]) => {
+ const dx = x1 - x0;
+ const dy = y1 - y0;
+ const len = Math.hypot(dx, dy);
+ ctx.save();
+ ctx.translate((x0 + x1) / 2 + (dy / len) * size * 0.85, (y0 + y1) / 2 - (dx / len) * size * 0.85);
+ ctx.rotate(Math.atan2(dy, dx));
+ ctx.fillText(text, 0, 0);
+ ctx.restore();
+ };
+ along('AGAPE', left, apex);
+ along('THELEMA', apex, right);
+ ctx.fillText('ABRAHADABRA', c, c + h / 3 + size * 0.95);
+ wear(ctx, w, rand);
+ }, { repeat: false });
+ }
+
+ function glow(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);
+ }, { repeat: false });
+ }
+
+ // Broken glints for the Moon's path on the water; it scrolls toward you.
+ function glints(rand) {
+ return canvasTexture(64, 256, (ctx, w, h) => {
+ for (let i = 0; i < 260; i++) {
+ const y = rand() * h;
+ const x = w / 2 + (rand() - 0.5) * w * Math.pow(rand(), 0.6);
+ ctx.fillStyle = `rgba(255,255,255,${0.25 + rand() * 0.75})`;
+ ctx.fillRect(x, y, 1 + rand() * 6, 1);
+ }
+ });
+ }
+
+ // Box texture coordinates in metres / tile, so a texture keeps its scale on boxes of any size.
+ function boxUV(geometry, w, h, d, tile) {
+ const uv = geometry.attributes.uv;
+ const faces = [[d, h], [d, h], [w, d], [w, d], [w, h], [w, h]]; // px nx py ny pz nz
+ for (let f = 0; f < 6; f++) {
+ for (let i = f * 4; i < f * 4 + 4; i++) uv.setXY(i, uv.getX(i) * faces[f][0] / tile, uv.getY(i) * faces[f][1] / tile);
+ }
+ return geometry;
+ }
+
+ AFRAME.registerComponent('cabin', {
+ schema: {
+ fireSound: { default: '' },
+ sky: { type: 'selector', default: '#sky' },
+ },
+
+ init() {
+ this.rand = mulberry32(SEED);
+ this.group = new THREE.Group();
+ this.el.setObject3D('mesh', this.group);
+ this.animated = [];
+ this.spawn = { x: -0.5, z: 1.45, yaw: 0 }; // facing the window
+ this.screen = { x: 0.9, y: 1.45, z: ROOM.halfZ - 0.06, yaw: Math.PI, width: 1.8 }; // beside the door
+
+ // Child entities start first, so the board is there already; its
+ // table goes under it.
+ const chess = this.chess();
+ const board = chess && chess.el.object3D.position;
+ this.table = chess ? { x: board.x, z: board.z, half: chess.data.table, height: chess.data.height } : TABLE;
+ this.touchables = chess ? chess.touchables : [];
+ // [x, z, yaw] of the armchairs: white's and black's facing across the
+ // board (white is at +z, see chess.js), and one by the hearth facing the fire.
+ const t = this.table;
+ const across = t.half + 0.4;
+ const fire = { x: HEARTH.front + 0.28, z: 0 };
+ this.chairs = [
+ [t.x, t.z + across, 0],
+ [t.x, t.z - across, Math.PI],
+ [FIRESIDE.x, FIRESIDE.z, Math.atan2(-(fire.x - FIRESIDE.x), -(fire.z - FIRESIDE.z))],
+ ];
+ this.seats = this.chairs.map(([x, z, yaw]) => ({ x, y: 0.45, z, yaw }));
+
+ const r = this.rand;
+ this.mat = {
+ log: new THREE.MeshLambertMaterial({ map: logGrain(r) }),
+ chink: new THREE.MeshLambertMaterial({ color: '#6e6150', side: THREE.DoubleSide }),
+ floor: new THREE.MeshLambertMaterial({ map: planks(r, { hue: 26, light: 30 }) }),
+ boards: new THREE.MeshLambertMaterial({ map: planks(r, { hue: 30, light: 36 }) }),
+ wood: new THREE.MeshLambertMaterial({ color: '#5a3a22' }),
+ darkWood: new THREE.MeshLambertMaterial({ color: '#2f1e12' }),
+ stone: new THREE.MeshLambertMaterial({ map: stones(r) }),
+ soot: new THREE.MeshLambertMaterial({ color: '#0d0a08', side: THREE.BackSide }),
+ iron: new THREE.MeshLambertMaterial({ color: '#1c1a19' }),
+ fabric: new THREE.MeshLambertMaterial({ color: '#6a2428' }),
+ wax: new THREE.MeshLambertMaterial({ color: '#efe3c4', emissive: '#3a2a12' }),
+ glass: new THREE.MeshBasicMaterial({ color: '#9fb4d8', transparent: true, opacity: 0.07, depthWrite: false }),
+ };
+ this.flameTexture = glow('rgba(255,240,200,1)', 'rgba(255,150,50,0.5)');
+
+ this.buildWalls();
+ this.buildRoof();
+ this.buildWindow();
+ this.buildDoor();
+ this.buildFireplace();
+ this.buildFurniture();
+ this.buildBookshelf();
+ this.buildCircle();
+ this.buildWands();
+ this.buildOutside();
+ },
+
+ tick(time, delta) {
+ const t = time / 1000;
+ for (const fn of this.animated) fn(t, Math.min(delta, 100) / 1000);
+ },
+
+ remove() {
+ this.group.traverse((o) => {
+ if (o.geometry) o.geometry.dispose();
+ if (o.material) [].concat(o.material).forEach((m) => { if (m.map) m.map.dispose(); m.dispose(); });
+ });
+ this.el.removeObject3D('mesh');
+ },
+
+ chess() {
+ const el = this.el.querySelector('[chess]');
+ return el && el.components.chess;
+ },
+
+ resetChess() {
+ const chess = this.chess();
+ if (chess) chess.resetChess();
+ },
+
+ add(geometry, material, x, y, z, parent = this.group) {
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.position.set(x, y, z);
+ parent.add(mesh);
+ return mesh;
+ },
+
+ // A box from its min and max corners, its texture in metres / tile if it has one.
+ box(material, x0, y0, z0, x1, y1, z1, { tile = 1, parent } = {}) {
+ const w = x1 - x0;
+ const h = y1 - y0;
+ const d = z1 - z0;
+ const geometry = new THREE.BoxGeometry(w, h, d);
+ if (material.map) boxUV(geometry, w, h, d, tile);
+ return this.add(geometry, material, (x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2, parent);
+ },
+
+ // --- walls and roof ----------------------------------------------------------
+
+ buildWalls() {
+ const logs = []; // [x, y, z, length, along z?]
+ const wx = ROOM.halfX + LOG;
+ const wz = ROOM.halfZ + LOG;
+ const endX = wx + LOG + OVERHANG;
+ const endZ = wz + LOG + OVERHANG;
+ // A course of logs along a wall, leaving out [cut0, cut1] when it crosses a hole.
+ const course = (y, alongZ, at, end, holes) => {
+ let pieces = [[-end, end]];
+ for (const [a, b, bottom, top] of holes) {
+ if (y + LOG <= bottom + 1e-6 || y - LOG >= top - 1e-6) continue;
+ pieces = pieces.flatMap(([s, e]) => [[s, Math.min(e, a)], [Math.max(s, b), e]].filter(([p, q]) => q - p > 0.05));
+ }
+ for (const [s, e] of pieces) logs.push(alongZ ? [at, y, (s + e) / 2, e - s, true] : [(s + e) / 2, y, at, e - s, false]);
+ };
+ for (let i = 0; i < COURSES; i++) {
+ const y = LOG + i * LOG * 2;
+ course(y, false, -wz, endX, [[-WINDOW.half, WINDOW.half, WINDOW.sill, WINDOW.top]]);
+ course(y, false, wz, endX, [[DOOR.x - DOOR.half, DOOR.x + DOOR.half, 0, DOOR.top]]);
+ course(y, true, wx, endZ, []);
+ course(y, true, -wx, endZ, []);
+ }
+ // The gables, shortening up to the ridge.
+ const span = wz + LOG;
+ for (let y = EAVE + LOG; ; y += LOG * 2) {
+ const half = span * (1 - (y + LOG - EAVE) / RISE);
+ if (half < 0.15) break;
+ for (const x of [wx, -wx]) logs.push([x, y, 0, half * 2, true]);
+ }
+
+ const geometry = new THREE.CylinderGeometry(LOG, LOG, 1, 14, 1).rotateZ(Math.PI / 2);
+ const mesh = new THREE.InstancedMesh(geometry, this.mat.log, logs.length);
+ const m = new THREE.Matrix4();
+ const q = new THREE.Quaternion();
+ const turn = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2);
+ const color = new THREE.Color();
+ logs.forEach(([x, y, z, len, alongZ], i) => {
+ m.compose(new THREE.Vector3(x, y, z), alongZ ? turn : q, new THREE.Vector3(len, 1, 1));
+ mesh.setMatrixAt(i, m);
+ mesh.setColorAt(i, color.setHSL(0.075 + (this.rand() - 0.5) * 0.02, 0.42, 0.3 + this.rand() * 0.08, THREE.SRGBColorSpace));
+ });
+ this.group.add(mesh);
+
+ // Chinking: a mortar wall a little inside the log centres, which shows
+ // between the logs, with the window and door cut out of it.
+ const shape = (halfU, holes, gable) => {
+ const s = new THREE.Shape();
+ s.moveTo(-halfU, 0);
+ s.lineTo(halfU, 0);
+ s.lineTo(halfU, EAVE);
+ if (gable) s.lineTo(0, EAVE + RISE * (1 - 0.0));
+ s.lineTo(-halfU, EAVE);
+ s.closePath();
+ for (const [u0, u1, v0, v1] of holes) {
+ const hole = new THREE.Path();
+ hole.moveTo(u0, v0);
+ hole.lineTo(u0, v1);
+ hole.lineTo(u1, v1);
+ hole.lineTo(u1, v0);
+ hole.closePath();
+ s.holes.push(hole);
+ }
+ return new THREE.ShapeGeometry(s);
+ };
+ const cx = wx - CHINK;
+ const cz = wz - CHINK;
+ const front = new THREE.Mesh(shape(cx, [[-WINDOW.half, WINDOW.half, WINDOW.sill, WINDOW.top]]), this.mat.chink);
+ front.position.z = -cz;
+ const back = new THREE.Mesh(shape(cx, [[-(DOOR.x + DOOR.half), -(DOOR.x - DOOR.half), 0, DOOR.top]]), this.mat.chink);
+ back.position.z = cz;
+ back.rotation.y = Math.PI;
+ const right = new THREE.Mesh(shape(cz, [], true), this.mat.chink);
+ right.position.x = cx;
+ right.rotation.y = -Math.PI / 2;
+ const left = new THREE.Mesh(shape(cz, [], true), this.mat.chink);
+ left.position.x = -cx;
+ left.rotation.y = Math.PI / 2;
+ this.group.add(front, back, right, left);
+
+ // The floor, which is also the cabin's base outside.
+ const fl = this.box(this.mat.floor, -wx - LOG, -0.3, -wz - LOG, wx + LOG, 0, wz + LOG, { tile: 1.2 });
+ fl.geometry.attributes.uv.needsUpdate = true;
+ },
+
+ buildRoof() {
+ const span = ROOM.halfZ + LOG * 2; // outer face of the walls
+ const pitch = Math.atan2(RISE, span);
+ const eaves = 0.35; // past the walls
+ const slope = (span + eaves) / Math.cos(pitch);
+ const halfX = ROOM.halfX + LOG * 2 + eaves;
+ for (const side of [-1, 1]) {
+ const geometry = boxUV(new THREE.BoxGeometry(halfX * 2, 0.06, slope), halfX * 2, 0.06, slope, 1.2);
+ const roof = new THREE.Mesh(geometry, this.mat.boards);
+ // Its underside runs from the ridge out over the eaves.
+ const mid = (slope / 2) * Math.cos(pitch);
+ roof.position.set(0, EAVE + RISE - (slope / 2) * Math.sin(pitch) + 0.03 / Math.cos(pitch), side * mid);
+ roof.rotation.x = side * pitch;
+ this.group.add(roof);
+
+ // Rafters under it.
+ for (let x = -ROOM.halfX + 0.25; x <= ROOM.halfX - 0.2; x += 0.95) {
+ const len = span / Math.cos(pitch);
+ const rafter = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.15, len), this.mat.darkWood);
+ rafter.position.set(x, EAVE + RISE / 2 - 0.075 / Math.cos(pitch), side * span / 2);
+ rafter.rotation.x = side * pitch;
+ this.group.add(rafter);
+ }
+ }
+ this.box(this.mat.darkWood, -ROOM.halfX - 0.3, EAVE + RISE - 0.28, -0.09, ROOM.halfX + 0.3, EAVE + RISE - 0.02, 0.09);
+ for (const x of [-1.25, 1.25]) {
+ this.box(this.mat.darkWood, x - 0.09, EAVE - 0.1, -ROOM.halfZ - LOG, x + 0.09, EAVE + 0.1, ROOM.halfZ + LOG);
+ }
+ },
+
+ // --- the window and door ----------------------------------------------------
+
+ buildWindow() {
+ const { half, sill, top } = WINDOW;
+ const z0 = -ROOM.halfZ - LOG * 2 - 0.02;
+ const z1 = -ROOM.halfZ + 0.02;
+ const t = 0.07;
+ const w = this.mat.wood;
+ this.box(w, -half, sill, z0, half, sill + t, z1);
+ this.box(w, -half, top - t, z0, half, top, z1);
+ this.box(w, -half, sill, z0, -half + t, top, z1);
+ this.box(w, half - t, sill, z0, half, top, z1);
+ const zm = -ROOM.halfZ - LOG;
+ const mid = (sill + top) / 2;
+ this.box(w, -0.025, sill, zm - 0.03, 0.025, top, zm + 0.03);
+ this.box(w, -half, mid - 0.025, zm - 0.03, half, mid + 0.025, zm + 0.03);
+ const glass = this.add(new THREE.PlaneGeometry(half * 2, top - sill), this.mat.glass, 0, mid, zm - 0.01);
+ glass.renderOrder = 3;
+
+ // A deep sill inside, with candles on it.
+ this.box(this.mat.boards, -half - 0.15, sill - 0.04, z1 - 0.04, half + 0.15, sill + t, z1 + 0.16, { tile: 1.2 });
+ const y = sill + t;
+ this.candle(-0.5, y, -ROOM.halfZ + 0.08, 0.16, 0.026);
+ this.candle(-0.4, y, -ROOM.halfZ + 0.1, 0.1, 0.022);
+ this.candle(0.48, y, -ROOM.halfZ + 0.09, 0.13, 0.024);
+ this.candleLight(0, y + 0.3, -ROOM.halfZ + 0.3, 1.1);
+ },
+
+ buildDoor() {
+ const x0 = DOOR.x - DOOR.half;
+ const x1 = DOOR.x + DOOR.half;
+ const z0 = ROOM.halfZ - 0.02;
+ const z1 = ROOM.halfZ + LOG * 2 + 0.02;
+ const t = 0.07;
+ this.box(this.mat.wood, x0, DOOR.top - t, z0, x1, DOOR.top, z1);
+ this.box(this.mat.wood, x0, 0, z0, x0 + t, DOOR.top, z1);
+ this.box(this.mat.wood, x1 - t, 0, z0, x1, DOOR.top, z1);
+ // Vertical boards with two battens and an iron ring.
+ const door = new THREE.Group();
+ door.position.set(DOOR.x, 0, ROOM.halfZ + LOG);
+ this.group.add(door);
+ const boards = this.mat.boards.clone();
+ boards.map = this.mat.boards.map.clone();
+ boards.map.rotation = Math.PI / 2;
+ boards.map.needsUpdate = true;
+ this.box(boards, x0 + t - DOOR.x, 0.01, -0.03, x1 - t - DOOR.x, DOOR.top - t, 0.03, { tile: 1.2, parent: door });
+ for (const y of [0.35, 1.7]) this.box(this.mat.darkWood, -DOOR.half + t + 0.04, y, -0.06, DOOR.half - t - 0.04, y + 0.12, -0.03, { parent: door });
+ const ring = this.add(new THREE.TorusGeometry(0.05, 0.008, 6, 16), this.mat.iron, DOOR.half - 0.22, 1.05, -0.07, door);
+ ring.rotation.y = 0;
+ this.add(new THREE.CylinderGeometry(0.018, 0.018, 0.03, 8).rotateX(Math.PI / 2), this.mat.iron, DOOR.half - 0.22, 1.1, -0.045, door);
+ },
+
+ // --- the fireplace -----------------------------------------------------------
+
+ buildFireplace() {
+ const { front, half, opening, openTop, top } = HEARTH;
+ const back = ROOM.halfX + 0.02;
+ const s = this.mat.stone;
+ this.box(s, front, 0, -half, back, top, -opening);
+ this.box(s, front, 0, opening, back, top, half);
+ this.box(s, front, openTop, -opening, back, top, opening);
+ this.box(this.mat.soot, front + 0.01, 0.0, -opening, back - 0.02, openTop, opening);
+ // Hearthstone, mantel and chimney breast up through the roof.
+ this.box(s, front - 0.45, 0, -half - 0.15, back, 0.07, half + 0.15, { tile: 0.8 });
+ this.box(this.mat.darkWood, front - 0.12, top, -half - 0.12, back, top + 0.13, half + 0.12);
+ this.box(s, front + 0.15, top + 0.13, -0.55, back, EAVE + RISE + 0.9, 0.55);
+
+ // Candles and a little brass bowl on the mantel.
+ const y = top + 0.13;
+ this.candle(front + 0.1, y, -0.75, 0.22, 0.03);
+ this.candle(front + 0.12, y, -0.62, 0.15, 0.026);
+ this.candle(front + 0.1, y, 0.72, 0.18, 0.03);
+ this.add(new THREE.SphereGeometry(0.07, 16, 8, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2), new THREE.MeshLambertMaterial({ color: '#8a6a2c', emissive: '#1a1004', side: THREE.DoubleSide }), front + 0.12, y + 0.07, 0.35);
+
+ this.buildFire(front + 0.28, 0.07, 0);
+ },
+
+ buildFire(x, y, z) {
+ const fire = new THREE.Group();
+ fire.position.set(x, y, z);
+ this.group.add(fire);
+ // Andiron logs, crossed.
+ for (const [dz, yaw, ly] of [[-0.06, 0.25, 0.05], [0.06, -0.25, 0.05], [0, Math.PI / 2, 0.13]]) {
+ const l = this.add(new THREE.CylinderGeometry(0.05, 0.055, 0.55, 8).rotateZ(Math.PI / 2), this.mat.darkWood, 0.02, ly, dz, fire);
+ l.rotation.y = yaw + Math.PI / 2;
+ }
+ const coals = this.add(new THREE.CircleGeometry(0.24, 16).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: '#ff5a1a', transparent: true, opacity: 0.8 }), 0, 0.012, 0, fire);
+ const colors = ['#ff5a14', '#ff8c1a', '#ffb23b', '#ffe08a'];
+ const flames = colors.map((color, i) => {
+ const h = 0.5 - i * 0.08;
+ const mesh = new THREE.Mesh(
+ new THREE.ConeGeometry(0.16 - i * 0.03, h, 8, 1, true).translate(0, h / 2, 0),
+ new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.55 + i * 0.1, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, fog: false }),
+ );
+ mesh.position.set(i % 2 ? 0.03 : -0.03, 0.06, i % 3 ? -0.03 : 0.03);
+ fire.add(mesh);
+ return { mesh, phase: i * 1.7 };
+ });
+ const halo = new THREE.Sprite(new THREE.SpriteMaterial({
+ map: glow('rgba(255,190,110,0.9)', 'rgba(255,110,30,0.35)'),
+ blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, fog: false,
+ }));
+ halo.position.y = 0.25;
+ halo.scale.set(1.3, 1.3, 1);
+ fire.add(halo);
+
+ // The light sits a little in front of the opening so it fills the room.
+ const light = document.createElement('a-entity');
+ light.setAttribute('light', { type: 'point', color: '#ff9446', intensity: 7, distance: 11, decay: 1.6 });
+ light.setAttribute('position', `${HEARTH.front - 0.35} 0.95 0`);
+ this.el.appendChild(light);
+
+ this.animated.push((t) => {
+ for (const f of flames) {
+ const n = Math.sin(t * 9 + f.phase) * 0.12 + Math.sin(t * 15.3 + f.phase * 2) * 0.08;
+ f.mesh.scale.set(1 + n * 0.4, 1 + n, 1 + n * 0.4);
+ f.mesh.rotation.y = t * 0.7 + f.phase;
+ }
+ const flicker = 1 + Math.sin(t * 11) * 0.06 + Math.sin(t * 23.7) * 0.05 + (Math.random() - 0.5) * 0.05;
+ halo.material.opacity = 0.75 * flicker;
+ coals.material.opacity = 0.65 + 0.15 * Math.sin(t * 2.3);
+ const obj = light.getObject3D('light');
+ if (obj) obj.intensity = 7 * flicker;
+ });
+
+ if (this.data.fireSound) {
+ const sound = document.createElement('a-entity');
+ sound.setAttribute('sound', { src: `url(${this.data.fireSound})`, autoplay: true, loop: true, volume: 0.2, refDistance: 1.5, rolloffFactor: 1.4, positional: true });
+ sound.setAttribute('position', `${x} 0.4 ${z}`);
+ this.el.appendChild(sound);
+ }
+ },
+
+ // --- small things ------------------------------------------------------------
+
+ candle(x, y, z, height, radius, parent = this.group) {
+ this.add(new THREE.CylinderGeometry(radius, radius * 1.05, height, 12), this.mat.wax, x, y + height / 2, z, parent);
+ const flame = new THREE.Sprite(new THREE.SpriteMaterial({ map: this.flameTexture, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, fog: false }));
+ flame.position.set(x, y + height + 0.025, z);
+ flame.scale.set(0.03, 0.06, 1);
+ parent.add(flame);
+ const halo = new THREE.Sprite(new THREE.SpriteMaterial({ map: this.flameTexture, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, opacity: 0.25, fog: false }));
+ halo.position.copy(flame.position);
+ halo.scale.set(0.22, 0.22, 1);
+ parent.add(halo);
+ const phase = this.rand() * 10;
+ this.animated.push((t) => {
+ const f = 1 + Math.sin(t * 13 + phase) * 0.08 + Math.sin(t * 31 + phase) * 0.05;
+ flame.scale.set(0.03 * (2 - f), 0.06 * f, 1);
+ });
+ },
+
+ candleLight(x, y, z, intensity, distance = 4) {
+ const light = document.createElement('a-entity');
+ light.setAttribute('light', { type: 'point', color: '#ffb36b', intensity, distance, decay: 2 });
+ light.setAttribute('position', `${x} ${y} ${z}`);
+ this.el.appendChild(light);
+ const phase = this.rand() * 10;
+ this.animated.push((t) => {
+ const obj = light.getObject3D('light');
+ if (obj) obj.intensity = intensity * (1 + Math.sin(t * 9 + phase) * 0.05 + Math.sin(t * 23 + phase) * 0.03);
+ });
+ },
+
+ armchair(x, z, yaw) {
+ const chair = new THREE.Group();
+ chair.position.set(x, 0, z);
+ chair.rotation.y = yaw;
+ this.group.add(chair);
+ const f = this.mat.fabric;
+ const p = { parent: chair };
+ this.box(f, -0.36, 0.1, -0.34, 0.36, 0.4, 0.34, p); // base
+ this.box(f, -0.28, 0.4, -0.32, 0.28, 0.47, 0.28, p); // cushion
+ const back = this.box(f, -0.36, 0.1, 0.24, 0.36, 1.0, 0.4, p);
+ back.rotation.x = -0.12;
+ for (const s of [-1, 1]) this.box(f, s * 0.36 - 0.08, 0.1, -0.34, s * 0.36 + 0.08, 0.62, 0.36, p);
+ for (const [lx, lz] of [[-0.3, -0.28], [0.3, -0.28], [-0.3, 0.3], [0.3, 0.3]]) {
+ this.box(this.mat.darkWood, lx - 0.03, 0, lz - 0.03, lx + 0.03, 0.1, lz + 0.03, p);
+ }
+ },
+
+ buildFurniture() {
+ for (const [x, z, yaw] of this.chairs) this.armchair(x, z, yaw);
+ const { x, z, half, height } = this.table;
+
+ const rugMesh = this.add(new THREE.CircleGeometry(RUG_R, 64).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ map: rug(), polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }), x, 0.004, z);
+ rugMesh.rotation.y = Math.PI / 2;
+
+ // The chess table: a square top on an apron and four turned legs.
+ this.box(this.mat.wood, x - half, height - 0.04, z - half, x + half, height, z + half);
+ const apron = half - 0.05;
+ this.box(this.mat.darkWood, x - apron, height - 0.13, z - apron, x + apron, height - 0.04, z + apron);
+ const leg = new THREE.CylinderGeometry(0.03, 0.022, height - 0.04, 10);
+ const inset = half - 0.08;
+ for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
+ this.add(leg, this.mat.darkWood, x + sx * inset, (height - 0.04) / 2, z + sz * inset);
+ }
+ },
+
+ buildBookshelf() {
+ const { front, half, height } = SHELF;
+ const back = -ROOM.halfX - 0.02;
+ const w = this.mat.wood;
+ const shelf = new THREE.Group();
+ shelf.position.z = SHELF.z;
+ this.group.add(shelf);
+ const p = { parent: shelf };
+ this.box(w, back, 0, -half, front, height, -half + 0.04, p);
+ this.box(w, back, 0, half - 0.04, front, height, half, p);
+ this.box(w, back, height - 0.04, -half, front, height, half, p);
+ this.box(this.mat.darkWood, back, 0, -half, back + 0.02, height, half, p);
+ const shelves = [0.06, 0.45, 0.84, 1.22, 1.6];
+ for (const y of shelves) this.box(w, back, y - 0.03, -half + 0.04, front, y, half - 0.04, p);
+
+ const colors = ['#4c1a1d', '#24341f', '#1f2740', '#6b4a1e', '#151313', '#4a2c18', '#3b2340', '#5c5a3a'];
+ const books = [];
+ for (const y of shelves) {
+ let z = -half + 0.06;
+ while (z < half - 0.1) {
+ if (this.rand() < 0.08) { z += 0.06 + this.rand() * 0.12; continue; }
+ const t = 0.025 + this.rand() * 0.035;
+ const h = 0.2 + this.rand() * 0.13;
+ const d = 0.18 + this.rand() * 0.07;
+ if (z + t > half - 0.06) break;
+ books.push([z + t / 2, y, t, h, d, colors[Math.floor(this.rand() * colors.length)]]);
+ z += t + 0.003;
+ }
+ }
+ const mesh = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0), new THREE.MeshLambertMaterial(), books.length);
+ const m = new THREE.Matrix4();
+ const q = new THREE.Quaternion();
+ const c = new THREE.Color();
+ books.forEach(([z, y, t, h, d, color], i) => {
+ m.compose(new THREE.Vector3(back + 0.03 + d / 2, y, z), q, new THREE.Vector3(d, h, t));
+ mesh.setMatrixAt(i, m);
+ mesh.setColorAt(i, c.set(color).offsetHSL(0, 0, (this.rand() - 0.5) * 0.05));
+ });
+ shelf.add(mesh);
+ },
+
+ // --- the circle, the triangle and the lantern ------------------------------
+
+ buildCircle() {
+ const { x, z, r, gap, side } = CIRCLE;
+ // East points from the middle into the corner; south is to its right.
+ const east = new THREE.Vector2(-ROOM.halfX - x, -ROOM.halfZ - z).normalize();
+ const at = (u, v) => [x - u * east.y + v * east.x, z + u * east.x + v * east.y];
+ const yaw = Math.atan2(-east.x, -east.y);
+ const paint = (texture, size, [px, pz]) => {
+ const mesh = this.add(new THREE.PlaneGeometry(size, size).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({
+ map: texture, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1,
+ }), px, 0.003, pz);
+ mesh.rotation.y = yaw;
+ };
+ paint(magickCircle(this.rand), 2 * PAINT * r, [x, z]);
+ const centroid = r + gap + (side * Math.sqrt(3)) / 6;
+ paint(triangleOfArt(this.rand), TRI_PAINT, at(0, centroid));
+
+ // A beeswax lamp in each of the nine pentagrams.
+ for (let k = 0; k < 9; k++) {
+ const a = (k / 9) * Math.PI * 2;
+ const [lx, lz] = at(Math.sin(a) * r * LAMPS, Math.cos(a) * r * LAMPS);
+ this.candle(lx, 0, lz, 0.07 + this.rand() * 0.03, 0.018);
+ }
+ this.buildLantern(...at(0, centroid));
+ },
+
+ // An iron lantern with a candle in it, hanging by a chain from a bracket on
+ // the wall, over the triangle. It sways a little.
+ buildLantern(x, z) {
+ const iron = this.mat.iron;
+ const wall = -ROOM.halfX;
+ const armY = 2.28;
+ this.box(iron, wall, armY - 0.3, z - 0.03, wall + 0.02, armY + 0.04, z + 0.03);
+ this.box(iron, wall, armY - 0.012, z - 0.012, x + 0.02, armY + 0.012, z + 0.012);
+ const [bx, by] = [0.3, 0.26];
+ const brace = this.add(new THREE.BoxGeometry(Math.hypot(bx, by), 0.012, 0.012), iron, wall + bx / 2, armY - by / 2 - 0.01, z);
+ brace.rotation.z = Math.atan2(by, bx);
+
+ const pivot = new THREE.Group();
+ pivot.position.set(x, armY - 0.012, z);
+ this.group.add(pivot);
+ const link = new THREE.TorusGeometry(0.011, 0.0025, 6, 12);
+ for (let i = 0; i < 8; i++) {
+ this.add(link, iron, 0, -0.011 - i * 0.022, 0, pivot).rotation.y = (i % 2) * (Math.PI / 2);
+ }
+ const body = new THREE.Group();
+ body.position.y = -0.4;
+ pivot.add(body);
+ this.add(new THREE.TorusGeometry(0.022, 0.004, 6, 14), iron, 0, 0.228, 0, body);
+ this.add(new THREE.CylinderGeometry(0.018, 0.024, 0.04, 8), iron, 0, 0.2, 0, body);
+ this.add(new THREE.ConeGeometry(0.115, 0.09, 4).rotateY(Math.PI / 4), iron, 0, 0.155, 0, body);
+ this.box(iron, -0.085, -0.125, -0.085, 0.085, -0.1, 0.085, { parent: body });
+ for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
+ this.box(iron, sx * 0.075 - 0.007, -0.1, sz * 0.075 - 0.007, sx * 0.075 + 0.007, 0.11, sz * 0.075 + 0.007, { parent: body });
+ }
+ const glass = this.add(new THREE.BoxGeometry(0.14, 0.21, 0.14), new THREE.MeshBasicMaterial({
+ color: '#ffb35a', transparent: true, opacity: 0.22, depthWrite: false, side: THREE.DoubleSide,
+ }), 0, 0.005, 0, body);
+ glass.renderOrder = 3;
+ this.candle(0, -0.1, 0, 0.08, 0.02, body);
+ const glowSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: this.flameTexture, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, opacity: 0.3, fog: false }));
+ glowSprite.scale.set(0.6, 0.6, 1);
+ body.add(glowSprite);
+ this.candleLight(x, armY - 0.4, z, 2.4, 5);
+
+ this.animated.push((t) => {
+ pivot.rotation.x = Math.sin(t * 0.9) * 0.02;
+ pivot.rotation.z = Math.sin(t * 0.67 + 1) * 0.025;
+ });
+ },
+
+ // Two wands on the windowsill, a black and a white, to pick up and draw in
+ // the air with (wield.js). Each lies along its +x, point first.
+ buildWands() {
+ const y = WINDOW.sill + 0.07 + 0.01;
+ const wands = [
+ { name: 'black-wand', label: 'the black Wand', at: [-0.04, -ROOM.halfZ + 0.04], shaft: '#141110', ends: '#c9a24a', ink: '#ffc04a' },
+ { name: 'white-wand', label: 'the white Wand', at: [0.1, -ROOM.halfZ + 0.13], shaft: '#e6dfcf', ends: '#c4c9d2', ink: '#bfe0ff' },
+ ];
+ const V = (px) => new THREE.Vector3(px, 0, 0);
+ this.tools = wands.map(({ name, label, at: [x, z], shaft, ends, ink }, i) => {
+ const wand = new THREE.Group();
+ wand.position.set(x, y, z);
+ wand.rotation.y = i * 0.06;
+ this.group.add(wand);
+ this.add(new THREE.CylinderGeometry(0.006, 0.006, 0.44, 8), new THREE.MeshLambertMaterial({ color: shaft }), 0, 0, 0, wand).rotation.z = Math.PI / 2;
+ const tipMaterial = new THREE.MeshLambertMaterial({ color: ends, emissive: ends, emissiveIntensity: 0.15 });
+ for (const end of [-1, 1]) this.add(new THREE.SphereGeometry(0.01, 8, 6), tipMaterial, end * 0.22, 0, 0, wand).scale.set(1.4, 1, 1);
+ return { name, label, object: wand, grip: V(-0.12), tip: V(0.234), butt: V(-0.234), ink };
+ });
+ },
+
+ // --- outside -----------------------------------------------------------------
+
+ buildOutside() {
+ this.add(new THREE.CircleGeometry(170, 48).rotateX(-Math.PI / 2), new THREE.MeshLambertMaterial({ color: '#16211a' }), 0, -0.25, 0);
+ this.add(new THREE.CircleGeometry(LAKE.r, 64).rotateX(-Math.PI / 2), new THREE.MeshPhongMaterial({ color: '#070d18', specular: '#5a6c99', shininess: 80, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }), LAKE.x, LAKE.y, LAKE.z);
+
+ // The Moon's path on the water, out from the shore toward where the sky holds it.
+ const skyEl = this.data.sky;
+ const bearing = skyEl && skyEl.components.sky ? skyEl.components.sky.data.moonBearing : 180;
+ const near = Math.abs(LAKE.z) - LAKE.r + 1;
+ const far = 160;
+ const path = new THREE.BufferGeometry();
+ path.setAttribute('position', new THREE.Float32BufferAttribute([-1.5, 0, -near, 1.5, 0, -near, -14, 0, -far, 14, 0, -far], 3));
+ path.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 0, 6, 1, 6], 2));
+ path.setIndex([0, 1, 2, 1, 3, 2]);
+ const glade = new THREE.Mesh(path, new THREE.MeshBasicMaterial({
+ map: glints(this.rand), color: '#cfd8ff', transparent: true, opacity: 0.3, blending: THREE.AdditiveBlending, depthWrite: false,
+ }));
+ glade.position.y = LAKE.y + 0.01;
+ glade.rotation.y = THREE.MathUtils.degToRad(bearing - 180);
+ glade.renderOrder = 2;
+ this.group.add(glade);
+ this.animated.push((t) => {
+ glade.material.map.offset.y = -t * 0.04;
+ const sky = skyEl && skyEl.components.sky;
+ glade.material.opacity = 0.08 + 0.45 * (sky ? sky.moonLit : 0.5);
+ });
+
+ // Pines all round, but not on the lake or between the window and it.
+ const models = ['tree_pineTallA', 'tree_pineTallB', 'tree_pineTallC', 'tree_pineDefaultA', 'tree_pineDefaultB', 'tree_pineRoundA', 'tree_pineRoundC'];
+ const placements = new Map();
+ for (let n = 0, tries = 0; n < 170 && tries < 3000; tries++) {
+ const r = 7 + Math.sqrt(this.rand()) * 75;
+ const a = this.rand() * Math.PI * 2;
+ const x = Math.sin(a) * r;
+ const z = Math.cos(a) * r;
+ if (Math.hypot(x - LAKE.x, z - LAKE.z) < LAKE.r + 2) continue;
+ if (z < 0 && Math.abs(x) < 1.5 - z * 0.35) continue;
+ const name = models[Math.floor(this.rand() * models.length)];
+ const s = 3.2 + this.rand() * 2.6;
+ const m = new THREE.Matrix4().compose(new THREE.Vector3(x, -0.25, z), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.rand() * 6.3), new THREE.Vector3(s, s, s));
+ if (!placements.has(name)) placements.set(name, []);
+ placements.get(name).push(m);
+ n++;
+ }
+ const loader = new THREE.GLTFLoader();
+ const out = new THREE.Matrix4();
+ for (const [name, matrices] of placements) {
+ loader.load(`${ENV_DIR}${name}.glb`, (gltf) => {
+ gltf.scene.updateMatrixWorld(true);
+ gltf.scene.traverse((obj) => {
+ if (!obj.isMesh) return;
+ const mats = [].concat(obj.material).map((mt) => new THREE.MeshLambertMaterial({ color: mt.color ? mt.color.clone() : 0xffffff, map: mt.map || null }));
+ const mesh = new THREE.InstancedMesh(obj.geometry, Array.isArray(obj.material) ? mats : mats[0], matrices.length);
+ matrices.forEach((mm, i) => mesh.setMatrixAt(i, out.multiplyMatrices(mm, obj.matrixWorld)));
+ mesh.computeBoundingSphere();
+ this.group.add(mesh);
+ });
+ }, undefined, (err) => console.warn(`Couldn't load ${name}`, err));
+ }
+ },
+
+ // Keeps a head position inside the walls and out of the fireplace,
+ // bookshelf and chess table.
+ confine(p) {
+ const wx = ROOM.halfX - MARGIN;
+ const wz = ROOM.halfZ - MARGIN;
+ p.x = Math.min(wx, Math.max(-wx, p.x));
+ p.z = Math.min(wz, Math.max(-wz, p.z));
+ const hearth = HEARTH.front - 0.15;
+ if (p.x > hearth && Math.abs(p.z) < HEARTH.half + 0.1) p.x = hearth;
+ const shelf = SHELF.front + 0.15;
+ if (p.x < shelf && Math.abs(p.z - SHELF.z) < SHELF.half + 0.1) p.x = shelf;
+ const t = this.table;
+ const tx = p.x - t.x;
+ const tz = p.z - t.z;
+ const reach = t.half + LEAN;
+ if (Math.abs(tx) < reach && Math.abs(tz) < reach) {
+ if (Math.abs(tx) > Math.abs(tz)) p.x = t.x + Math.sign(tx) * reach;
+ else p.z = t.z + (Math.sign(tz) || 1) * reach;
+ }
+ return p;
+ },
+ });
+})();
diff --git a/public/js/components/campsite.js b/public/js/components/campsite.js
new file mode 100644
index 0000000..0a227a1
--- /dev/null
+++ b/public/js/components/campsite.js
@@ -0,0 +1,479 @@
+/* global AFRAME, THREE */
+// The world: a clearing in a pine forest at night, with a crackling campfire,
+// log seats, tents, a little pond and fireflies. Models are Kenney's Nature Kit
+// (CC0). Everything is laid out from a fixed seed so everyone sees the same
+// place, and repeated models are drawn as instanced meshes to stay light on
+// standalone headsets.
+(function () {
+ const ENV_DIR = 'assets/env/';
+ const SEED = 20261030;
+
+ const PINES = [
+ 'tree_pineTallA', 'tree_pineTallB', 'tree_pineTallC', 'tree_pineTallD',
+ 'tree_pineRoundA', 'tree_pineRoundC', 'tree_pineRoundE',
+ 'tree_pineDefaultA', 'tree_pineDefaultB',
+ ];
+ const UNDERGROWTH = [
+ 'plant_bush', 'plant_bushLarge', 'plant_bushDetailed', 'grass_large', 'grass',
+ 'flower_redA', 'flower_yellowA', 'flower_purpleA', 'mushroom_redGroup', 'mushroom_tanGroup',
+ ];
+ const ROCKS = ['rock_largeA', 'rock_largeC', 'rock_tallB', 'rock_tallE'];
+
+ const POND = { x: 9.5, z: -8.5, r: 3.2 };
+
+ // Seat heights: the models' tops (less their -0.05 node offset) at the scales placed below.
+ const LOG_TOP = (0.173 - 0.05) * 3.2;
+ const STUMP_TOP = (0.206 - 0.05) * 2.6;
+
+ 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;
+ };
+ }
+
+ function polar(radius, degrees) {
+ const a = THREE.MathUtils.degToRad(degrees);
+ return { x: Math.sin(a) * radius, z: Math.cos(a) * radius, angle: a };
+ }
+
+ function glowTexture(inner, outer) {
+ const size = 64;
+ const canvas = document.createElement('canvas');
+ canvas.width = canvas.height = size;
+ const ctx = canvas.getContext('2d');
+ const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 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, size, size);
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ return texture;
+ }
+
+ // A pumpkin of radius 1: eight lobes, squashed, dimpled where the stem
+ // grows. Its face (texture u = 0.25) points along +z.
+ function pumpkinGeometry() {
+ const geometry = new THREE.SphereGeometry(1, 40, 24);
+ const p = geometry.attributes.position;
+ for (let i = 0; i < p.count; i++) {
+ const x = p.getX(i);
+ const y = p.getY(i);
+ const z = p.getZ(i);
+ const around = Math.hypot(x, z);
+ const groove = Math.pow(1 - Math.abs(Math.sin(Math.atan2(z, x) * 4)), 3);
+ const r = 1 - 0.09 * groove * around;
+ p.setXYZ(i, x * r, y * 0.7 - Math.sign(y) * 0.14 * (1 - around) ** 2, z * r);
+ }
+ geometry.computeVertexNormals();
+ return geometry;
+ }
+
+ // Skin and carved face, painted onto the sphere's texture: the holes are
+ // dark in the colour map and bright in the glow map. Two faces: a classic
+ // triangle-eyed grin, and a meaner one.
+ function pumpkinTextures(face) {
+ const W = 256;
+ const H = 128;
+ const paint = (draw) => {
+ const canvas = document.createElement('canvas');
+ canvas.width = W;
+ canvas.height = H;
+ draw(canvas.getContext('2d'));
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ return texture;
+ };
+ // Face centre at (64, 64); offsets are stretched vertically to undo the squash.
+ const pt = (x, y) => [64 + x, 64 + y * 1.35];
+ const smile = (w, top, bottom, teeth) => {
+ const upper = [];
+ const lower = [];
+ for (let i = 0; i <= teeth * 2; i++) {
+ const x = -w + (i * w) / teeth;
+ const bow = 1 - (x / w) ** 2;
+ upper.push(pt(x, top + 6 * bow + (i % 2 && i < teeth * 2 ? 4 : 0)));
+ lower.unshift(pt(x, bottom + 12 * bow));
+ }
+ return upper.concat(lower);
+ };
+ const holes = face === 0 ? [
+ [pt(-25, -6), pt(-9, -6), pt(-17, -22)],
+ [pt(9, -6), pt(25, -6), pt(17, -22)],
+ [pt(-5, 3), pt(5, 3), pt(0, -5)],
+ smile(30, 8, 12, 3),
+ ] : [
+ [pt(-27, -18), pt(-8, -9), pt(-10, -3), pt(-25, -9)],
+ [pt(27, -18), pt(8, -9), pt(10, -3), pt(25, -9)],
+ [pt(-32, 0), pt(-18, 6), pt(-12, 4), pt(-12, 12), pt(-4, 8), pt(4, 8), pt(12, 12), pt(12, 4),
+ pt(18, 6), pt(32, 0), pt(22, 17), pt(0, 22), pt(-22, 17)],
+ ];
+ const fill = (ctx, style, blur) => {
+ ctx.fillStyle = style;
+ ctx.filter = blur;
+ for (const hole of holes) {
+ ctx.beginPath();
+ hole.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
+ ctx.closePath();
+ ctx.fill();
+ }
+ };
+ return {
+ map: paint((ctx) => {
+ ctx.fillStyle = '#d9661c';
+ ctx.fillRect(0, 0, W, H);
+ fill(ctx, '#2a1204', 'none');
+ }),
+ glow: paint((ctx) => {
+ ctx.fillStyle = '#000';
+ ctx.fillRect(0, 0, W, H);
+ fill(ctx, '#ffb347', 'blur(1px)');
+ }),
+ };
+ }
+
+ function lambert(material) {
+ const m = new THREE.MeshLambertMaterial({
+ color: material.color ? material.color.clone() : new THREE.Color(0xffffff),
+ map: material.map || null,
+ vertexColors: material.vertexColors,
+ side: material.side,
+ transparent: material.transparent,
+ opacity: material.opacity,
+ });
+ material.dispose();
+ return m;
+ }
+
+ AFRAME.registerComponent('campsite', {
+ init() {
+ this.rand = mulberry32(SEED);
+ this.placements = new Map(); // model name -> [Matrix4]
+ this.blocked = []; // circles kept clear of scattered stuff
+ this.seats = []; // where people can sit; indexes are synced
+ this.screen = { x: 0, y: 1.9, z: 6.2, yaw: Math.PI, width: 3.2 }; // at the clearing's edge, facing the fire
+ this.animated = [];
+
+ this.buildGround();
+ this.buildFire();
+ this.buildFireflies();
+ this.buildPumpkins();
+ this.layout();
+ this.loadModels();
+ },
+
+ tick(time, delta) {
+ const t = time / 1000;
+ for (const fn of this.animated) fn(t, Math.min(delta, 100) / 1000);
+ },
+
+ // --- placement helpers ---------------------------------------------------
+
+ place(model, x, z, { scale = 3, yaw = 0, y = 0 } = {}) {
+ const m = new THREE.Matrix4().compose(
+ new THREE.Vector3(x, y, z),
+ new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yaw),
+ new THREE.Vector3(scale, scale, scale),
+ );
+ if (!this.placements.has(model)) this.placements.set(model, []);
+ this.placements.get(model).push(m);
+ },
+
+ block(x, z, r) {
+ this.blocked.push({ x, z, r });
+ },
+
+ // A place to sit, facing the fire. `yaw` turns -Z (where people face) toward it.
+ addSeat(x, z, y) {
+ this.seats.push({ x, y, z, yaw: Math.atan2(x, z) });
+ },
+
+ isClear(x, z, r) {
+ return this.blocked.every((b) => Math.hypot(x - b.x, z - b.z) > b.r + r);
+ },
+
+ scatter(models, count, minR, maxR, { minScale, maxScale, keepOut = 0 }) {
+ let placed = 0;
+ for (let tries = 0; placed < count && tries < count * 20; tries++) {
+ const r = minR + Math.sqrt(this.rand()) * (maxR - minR);
+ const p = polar(r, this.rand() * 360);
+ if (!this.isClear(p.x, p.z, keepOut)) continue;
+ const model = models[Math.floor(this.rand() * models.length)];
+ const scale = minScale + this.rand() * (maxScale - minScale);
+ this.place(model, p.x, p.z, { scale, yaw: this.rand() * Math.PI * 2 });
+ if (keepOut) this.block(p.x, p.z, keepOut * 0.8);
+ placed++;
+ }
+ },
+
+ // --- the scene -----------------------------------------------------------
+
+ buildGround() {
+ const group = this.el.object3D;
+
+ const forestFloor = new THREE.Mesh(
+ new THREE.CircleGeometry(120, 48).rotateX(-Math.PI / 2),
+ new THREE.MeshLambertMaterial({ color: '#203320' }),
+ );
+ group.add(forestFloor);
+
+ const clearing = new THREE.Mesh(
+ new THREE.CircleGeometry(6.5, 40).rotateX(-Math.PI / 2),
+ new THREE.MeshLambertMaterial({ color: '#4b3d2c', polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }),
+ );
+ clearing.position.y = 0.005;
+ group.add(clearing);
+
+ const pond = new THREE.Mesh(
+ new THREE.CircleGeometry(POND.r, 32).rotateX(-Math.PI / 2),
+ new THREE.MeshPhongMaterial({ color: '#0b1626', specular: '#7f93c9', shininess: 90, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }),
+ );
+ pond.position.set(POND.x, 0.01, POND.z);
+ group.add(pond);
+ },
+
+ buildFire() {
+ const fire = new THREE.Group();
+ fire.position.set(0, 0.12, 0);
+ this.el.object3D.add(fire);
+
+ const flameColors = ['#ff5a14', '#ff8c1a', '#ffb23b', '#ffe08a'];
+ const flames = flameColors.map((color, i) => {
+ const height = 1.1 - i * 0.18;
+ const mesh = new THREE.Mesh(
+ new THREE.ConeGeometry(0.32 - i * 0.06, height, 8, 1, true).translate(0, height / 2, 0),
+ new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.55 + i * 0.1, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, fog: false }),
+ );
+ mesh.position.set((i % 2 ? 0.05 : -0.05), 0, (i % 3 ? -0.04 : 0.04));
+ fire.add(mesh);
+ return { mesh, phase: i * 1.7, base: 1 };
+ });
+
+ const glow = new THREE.Sprite(new THREE.SpriteMaterial({
+ map: glowTexture('rgba(255,190,110,0.9)', 'rgba(255,110,30,0.35)'),
+ blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, fog: false,
+ }));
+ glow.position.y = 0.45;
+ glow.scale.set(3.2, 3.2, 1);
+ fire.add(glow);
+
+ // Embers drifting up.
+ const EMBERS = 60;
+ const positions = new Float32Array(EMBERS * 3);
+ const speeds = [];
+ for (let i = 0; i < EMBERS; i++) {
+ positions[i * 3 + 1] = Math.random() * 3;
+ speeds.push(0.5 + Math.random() * 0.9);
+ }
+ const emberGeo = new THREE.BufferGeometry();
+ emberGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+ const embers = new THREE.Points(emberGeo, new THREE.PointsMaterial({
+ color: '#ffae5c', size: 0.045, map: glowTexture('rgba(255,255,255,1)', 'rgba(255,170,80,0.6)'),
+ transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false,
+ }));
+ fire.add(embers);
+
+ // Flickering firelight, as an A-Frame light so the default lights stay off.
+ // It fades out by the edge of the clearing, so the forest beyond is dark.
+ const light = document.createElement('a-entity');
+ light.setAttribute('light', { type: 'point', color: '#ff9a4d', intensity: 15, distance: 12, decay: 2 });
+ light.setAttribute('position', '0 1.1 0');
+ this.el.appendChild(light);
+
+ this.animated.push((t, dt) => {
+ for (const f of flames) {
+ const n = Math.sin(t * 9 + f.phase) * 0.12 + Math.sin(t * 15.3 + f.phase * 2) * 0.08;
+ f.mesh.scale.set(1 + n * 0.4, 1 + n, 1 + n * 0.4);
+ f.mesh.rotation.y = t * 0.7 + f.phase;
+ }
+ const flicker = 1 + Math.sin(t * 11) * 0.06 + Math.sin(t * 23.7) * 0.05 + (Math.random() - 0.5) * 0.05;
+ glow.material.opacity = 0.75 * flicker;
+ const lightObj = light.getObject3D('light');
+ if (lightObj) lightObj.intensity = 15 * flicker;
+
+ const pos = emberGeo.attributes.position;
+ for (let i = 0; i < EMBERS; i++) {
+ let y = pos.getY(i) + speeds[i] * dt;
+ let x = pos.getX(i) + Math.sin(t * 2 + i) * 0.12 * dt;
+ let z = pos.getZ(i) + Math.cos(t * 1.7 + i * 0.5) * 0.12 * dt;
+ if (y > 3) {
+ y = 0.2;
+ x = (Math.random() - 0.5) * 0.4;
+ z = (Math.random() - 0.5) * 0.4;
+ }
+ pos.setXYZ(i, x, y, z);
+ }
+ pos.needsUpdate = true;
+ });
+ },
+
+ buildFireflies() {
+ const COUNT = 36;
+ const anchors = [];
+ const positions = new Float32Array(COUNT * 3);
+ const colors = new Float32Array(COUNT * 3);
+ for (let i = 0; i < COUNT; i++) {
+ const p = polar(6 + this.rand() * 10, this.rand() * 360);
+ anchors.push({ x: p.x, z: p.z, y: 0.5 + this.rand() * 1.8, phase: this.rand() * 10, speed: 0.3 + this.rand() * 0.4 });
+ }
+ const geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+ geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
+ const flies = new THREE.Points(geo, new THREE.PointsMaterial({
+ size: 0.07, vertexColors: true, map: glowTexture('rgba(255,255,255,1)', 'rgba(220,255,140,0.5)'),
+ transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false,
+ }));
+ this.el.object3D.add(flies);
+
+ const tint = new THREE.Color('#d8ff7a');
+ this.animated.push((t) => {
+ for (let i = 0; i < COUNT; i++) {
+ const a = anchors[i];
+ const s = t * a.speed + a.phase;
+ positions[i * 3] = a.x + Math.sin(s) * 0.8;
+ positions[i * 3 + 1] = a.y + Math.sin(s * 1.7) * 0.25;
+ positions[i * 3 + 2] = a.z + Math.cos(s * 0.8) * 0.8;
+ const on = Math.max(0, Math.sin(s * 2.3)) ** 3;
+ colors[i * 3] = tint.r * on;
+ colors[i * 3 + 1] = tint.g * on;
+ colors[i * 3 + 2] = tint.b * on;
+ }
+ geo.attributes.position.needsUpdate = true;
+ geo.attributes.color.needsUpdate = true;
+ });
+ },
+
+ // Jack-o'-lanterns for Halloween, at the edge of the firelight and facing
+ // the fire, with a couple of uncarved ones. Each has a flickering candle.
+ buildPumpkins() {
+ // Their own random numbers, so the forest stays where it was.
+ const rand = mulberry32(SEED + 31);
+ const geometry = pumpkinGeometry();
+ const stemGeometry = new THREE.CylinderGeometry(0.07, 0.12, 0.4, 6).translate(0, 0.2, 0);
+ const stemMaterial = new THREE.MeshLambertMaterial({ color: '#5a4a24' });
+ const faces = [0, 1].map((face) => {
+ const { map, glow } = pumpkinTextures(face);
+ return new THREE.MeshLambertMaterial({ map, emissive: '#ffffff', emissiveMap: glow, emissiveIntensity: 1.4 });
+ });
+ const plain = new THREE.MeshLambertMaterial({ color: '#d9661c' });
+ const candle = glowTexture('rgba(255,180,80,0.5)', 'rgba(255,110,20,0.15)');
+
+ // [angle around the fire (degrees), distance, size (radius, m), face or null]
+ this.pumpkins = [
+ [38, 4.5, 0.22, 0], [82, 4.8, 0.14, null], [140, 4.4, 0.26, 1],
+ [196, 4.6, 0.2, 0], [298, 4.5, 0.24, 1], [314, 4.9, 0.15, null],
+ ];
+ const glows = [];
+ for (const [deg, dist, size, face] of this.pumpkins) {
+ const p = polar(dist, deg);
+ const yaw = p.angle + Math.PI + (rand() - 0.5) * 0.5;
+ const pumpkin = new THREE.Group();
+ pumpkin.position.set(p.x, 0.56 * size, p.z);
+ pumpkin.rotation.y = yaw;
+ pumpkin.scale.setScalar(size);
+ pumpkin.add(new THREE.Mesh(geometry, face === null ? plain : faces[face]));
+ const stem = new THREE.Mesh(stemGeometry, stemMaterial);
+ stem.position.y = 0.5;
+ stem.rotation.set((rand() - 0.5) * 0.5, 0, (rand() - 0.5) * 0.5);
+ pumpkin.add(stem);
+ this.el.object3D.add(pumpkin);
+ if (face === null) continue;
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
+ map: candle, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true, fog: false,
+ }));
+ sprite.position.set(p.x + Math.sin(yaw) * size * 1.1, 0.6 * size, p.z + Math.cos(yaw) * size * 1.1);
+ sprite.scale.setScalar(size * 5);
+ this.el.object3D.add(sprite);
+ glows.push({ sprite, phase: rand() * 10 });
+ }
+
+ this.animated.push((t) => {
+ faces.forEach((m, i) => {
+ m.emissiveIntensity = 1.4 * (0.82 + 0.1 * Math.sin(t * 9.1 + i * 2) + 0.08 * Math.sin(t * 23.3 + i));
+ });
+ for (const g of glows) {
+ g.sprite.material.opacity = 0.75 + 0.15 * Math.sin(t * 8.3 + g.phase) + 0.1 * Math.sin(t * 19.7 + g.phase);
+ }
+ });
+ },
+
+ layout() {
+ // Fire ring.
+ this.place('campfire_stones', 0, 0, { scale: 3.2 });
+ this.place('campfire_logs', 0, 0, { scale: 3.2, yaw: 0.4, y: 0.02 });
+ this.block(0, 0, 3.4);
+
+ // Log seats around the fire, with a gap to walk in. Each log seats two.
+ for (const deg of [20, 100, 180, 260]) {
+ const p = polar(2.55, deg);
+ this.place('log', p.x, p.z, { scale: 3.2, yaw: p.angle + Math.PI / 2 });
+ for (const along of [-0.5, 0.5]) {
+ this.addSeat(p.x + Math.cos(p.angle) * along, p.z - Math.sin(p.angle) * along, LOG_TOP);
+ }
+ }
+ for (const deg of [60, 220]) {
+ const p = polar(3.1, deg);
+ this.place('stump_round', p.x, p.z, { scale: 2.6, yaw: p.angle });
+ this.addSeat(p.x, p.z, STUMP_TOP);
+ }
+
+ // Tents, facing the fire.
+ const bigTent = polar(7.6, 205);
+ this.place('tent_detailedOpen', bigTent.x, bigTent.z, { scale: 4.2, yaw: bigTent.angle + Math.PI });
+ this.block(bigTent.x, bigTent.z, 2.4);
+ const smallTent = polar(7.8, 150);
+ this.place('tent_smallClosed', smallTent.x, smallTent.z, { scale: 4.2, yaw: smallTent.angle + Math.PI });
+ this.block(smallTent.x, smallTent.z, 1.8);
+ const woodpile = polar(5.6, 235);
+ this.place('log_stack', woodpile.x, woodpile.z, { scale: 3.2, yaw: woodpile.angle });
+ this.place('stump_old', polar(6.2, 120).x, polar(6.2, 120).z, { scale: 3 });
+
+ // Pond with lily pads and a canoe pulled up on the bank.
+ this.block(POND.x, POND.z, POND.r + 0.6);
+ this.place('canoe', POND.x - 3.6, POND.z + 2.6, { scale: 3.4, yaw: 0.9, y: 0.02 });
+ this.place('lily_large', POND.x + 0.8, POND.z - 0.6, { scale: 3, y: 0.0 });
+ this.place('lily_small', POND.x - 1.1, POND.z + 0.9, { scale: 3, yaw: 1.2, y: 0.0 });
+ this.place('lily_large', POND.x - 0.4, POND.z - 1.8, { scale: 2.6, yaw: 2.4, y: 0.0 });
+ for (let i = 0; i < 10; i++) {
+ const a = (i / 10) * Math.PI * 2 + 0.3;
+ const r = POND.r + 0.35;
+ this.place(i % 2 ? 'grass_large' : 'plant_bush', POND.x + Math.sin(a) * r, POND.z + Math.cos(a) * r, { scale: 3, yaw: a });
+ }
+
+ // Forest ring, undergrowth and boulders.
+ this.scatter(ROCKS, 12, 7.5, 18, { minScale: 3, maxScale: 5, keepOut: 1.2 });
+ this.scatter(PINES, 120, 12.5, 42, { minScale: 3.6, maxScale: 6.5, keepOut: 1.0 });
+ this.scatter(PINES, 14, 8.5, 12.5, { minScale: 2.8, maxScale: 4, keepOut: 1.4 });
+ // Keep the undergrowth off the pumpkins (only it reaches in this close).
+ for (const [deg, dist, size] of this.pumpkins) {
+ const p = polar(dist, deg);
+ this.block(p.x, p.z, size + 0.3);
+ }
+ this.scatter(UNDERGROWTH, 70, 4.2, 16, { minScale: 2.4, maxScale: 3.6, keepOut: 0.3 });
+ },
+
+ loadModels() {
+ const loader = new THREE.GLTFLoader();
+ const up = new THREE.Matrix4();
+ for (const [name, matrices] of this.placements) {
+ loader.load(`${ENV_DIR}${name}.glb`, (gltf) => {
+ gltf.scene.updateMatrixWorld(true);
+ gltf.scene.traverse((obj) => {
+ if (!obj.isMesh) return;
+ const material = Array.isArray(obj.material) ? obj.material.map(lambert) : lambert(obj.material);
+ const mesh = new THREE.InstancedMesh(obj.geometry, material, matrices.length);
+ matrices.forEach((m, i) => mesh.setMatrixAt(i, up.multiplyMatrices(m, obj.matrixWorld)));
+ mesh.instanceMatrix.needsUpdate = true;
+ mesh.computeBoundingSphere();
+ this.el.object3D.add(mesh);
+ });
+ }, undefined, (err) => console.warn(`Couldn't load ${name}`, err));
+ }
+ },
+ });
+})();
diff --git a/public/js/components/chess.js b/public/js/components/chess.js
new file mode 100644
index 0000000..9b28c9b
--- /dev/null
+++ b/public/js/components/chess.js
@@ -0,0 +1,742 @@
+/* global AFRAME, THREE, NAF */
+// A chess board and its pieces on a table. Nothing knows the rules: pick a
+// piece up and, while it's over the board, the square under it lights up (red
+// if a piece is already there); let go and it stands in the middle of that
+// square, and a piece that was there goes off to the side of the board. Let go
+// anywhere else and it drops onto the table, or the floor. In VR, squeeze grip
+// next to a piece (the one you'd get glows). On desktop, F picks up the piece
+// you're looking at; it hovers over wherever you look, and F again puts it there. resetChess() (the
+// world's action, or the buttons on the table in VR) sets the board up again.
+// In a shared room everyone sees the same board, newcomers included.
+//
+// The entity sits on the middle of the tabletop. White is at +z, so someone
+// who arrives facing -z sits down to play white.
+(function () {
+ const SQUARE = 0.055;
+ const HALF = SQUARE * 4; // half the playing area
+ const BORDER = 0.022;
+ const EDGE = HALF + BORDER; // half the whole board
+ const THICK = 0.016; // the squares are on top
+ const SCALE = 0.12; // the models' king is 0.89 tall
+ const SLOT = 0.045; // spacing of taken pieces beside the board
+ const GRAB_REACH = 0.06; // hand to a piece's middle line
+ const LOOK_REACH = 2.2; // desktop: head to piece
+ const LOOK_AIM = 0.03; // desktop: how far off your line of sight
+ const BUTTON_REACH = 0.1; // hand to a reset button
+ const TOUCH_REACH = 0.2; // wield.js's reach for touchables
+ const ARMED_MS = 3000; // VR: how long the second trigger pull has to reset
+ const POSE_MS = 66; // how often a held piece's pose is sent
+ const SMOOTH = 0.05; // seconds to ease toward someone else's pose
+ const GRAVITY = 9.8;
+ const MODEL_DIR = 'assets/chess/';
+ const SOUNDS = ['chess-1', 'chess-2', 'chess-3'];
+ const SOUND_DIR = 'assets/sounds/';
+
+ const BACK_RANK = ['rook', 'knight', 'bishop', 'queen', 'king', 'bishop', 'knight', 'rook'];
+ const HEIGHT = { pawn: 0.6, rook: 0.7, knight: 0.72, bishop: 0.75, queen: 0.78, king: 0.89 };
+ const COLOR = { white: '#efe4cc', black: '#2e2622' };
+ // On desktop a held piece hovers this high over where you look, or else
+ // floats low in front of you.
+ const DESK_HOVER = 0.05;
+ const DESK_AHEAD = 0.32;
+ const DESK_DROP = 0.2;
+
+ const Y = new THREE.Vector3(0, 1, 0);
+ const v1 = new THREE.Vector3();
+ const v2 = new THREE.Vector3();
+ const v3 = new THREE.Vector3();
+ const q1 = new THREE.Quaternion();
+ const m1 = new THREE.Matrix4();
+ const euler = new THREE.Euler(0, 0, 0, 'YXZ');
+
+ const online = () => typeof NAF !== 'undefined' && NAF.connection && NAF.connection.isConnected();
+ const finite = (a, n) => Array.isArray(a) && a.length === n && a.every((x) => Number.isFinite(x) && Math.abs(x) < 1000);
+ const round = (x) => Math.round(x * 1e4) / 1e4;
+ const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
+
+ // Where a piece can be: on a square, beside the board having been taken, or
+ // anywhere else ("loose"), all in the board's own coordinates.
+ function validAt(at) {
+ if (!at || typeof at !== 'object') return null;
+ if (Number.isInteger(at.sq) && at.sq >= 0 && at.sq < 64) return { sq: at.sq };
+ if (Number.isInteger(at.slot) && at.slot >= 0 && at.slot < 16) return { slot: at.slot };
+ if (finite(at.p, 3) && Number.isFinite(at.yaw)) return { p: at.p.slice(), yaw: at.yaw };
+ return null;
+ }
+
+ function buzz(handEl, strength, ms) {
+ try {
+ const tracked = handEl.components['tracked-controls'];
+ const pad = tracked && tracked.controller && tracked.controller.gamepad;
+ const motor = pad && pad.hapticActuators && pad.hapticActuators[0];
+ if (motor && motor.pulse) Promise.resolve(motor.pulse(strength, ms)).catch(() => {});
+ } catch (e) {
+ // No haptics on this controller.
+ }
+ }
+
+ // Distance from the ray (origin o, unit direction d) to point p, and how far along it.
+ function offRay(o, d, p) {
+ v3.subVectors(p, o);
+ const along = v3.dot(d);
+ return [v3.addScaledVector(d, -along).length(), along];
+ }
+
+ AFRAME.registerComponent('chess', {
+ schema: {
+ table: { default: 0.4 }, // half the tabletop's width
+ height: { default: 0.75 }, // the tabletop above the floor
+ head: { type: 'selector', default: '#player' },
+ leftHand: { type: 'selector', default: '#left-hand' },
+ rightHand: { type: 'selector', default: '#right-hand' },
+ rig: { type: 'selector', default: '#rig' },
+ },
+
+ init() {
+ this.group = new THREE.Group();
+ this.el.setObject3D('mesh', this.group);
+ this.epoch = 0; // goes up each time the board is set up again
+ this.pieces = [];
+ this.lastPose = 0;
+ this.armedAt = -Infinity;
+ this.buildBoard();
+ this.buildButtons();
+ this.loadPieces();
+ this.loadSounds();
+
+ const hand = (el, vr) => ({ el, vr, piece: null, near: null });
+ this.hands = [hand(this.data.leftHand, true), hand(this.data.rightHand, true)];
+ this.desk = hand(this.data.head, false);
+
+ this.listeners = [];
+ const on = (target, type, fn, opts) => {
+ target.addEventListener(type, fn, opts);
+ this.listeners.push([target, type, fn, opts]);
+ };
+ // The world is added after the rig, so wield.js hears grip first and a
+ // pen it picks up wins; screens.js waits for both of us.
+ for (const h of this.hands) {
+ on(h.el, 'gripdown', () => this.gripDown(h));
+ on(h.el, 'gripup', () => this.release(h));
+ on(h.el, 'controllerdisconnected', () => this.release(h));
+ }
+ on(this.el.sceneEl, 'enter-vr', () => this.release(this.desk));
+ on(this.el.sceneEl, 'exit-vr', () => this.hands.forEach((h) => this.release(h)));
+
+ // F is wield.js's key too: take it first when it's about a piece.
+ const typing = (evt) => evt.target.closest && evt.target.closest('input, textarea, select');
+ on(window, 'keydown', (evt) => {
+ if (evt.code !== 'KeyF' || evt.repeat || typing(evt) || this.el.sceneEl.is('vr-mode')) return;
+ if (this.desk.piece) {
+ this.release(this.desk);
+ } else {
+ const wield = this.wield();
+ if (wield && wield.desk.tool) return; // F puts down the pen
+ const piece = this.looked();
+ if (!piece) return;
+ if (piece.by) this.data.rig.emit('wield-failed', { reason: 'held', label: 'that piece' }, false);
+ else this.grab(this.desk, piece);
+ }
+ evt.stopImmediatePropagation();
+ }, true);
+
+ if (typeof NAF !== 'undefined' && NAF.connection) {
+ NAF.connection.subscribeToDataChannel('chess', (from, type, msg) => this.onMessage(msg));
+ on(document.body, 'clientConnected', (evt) => this.catchUp(evt.detail.clientId));
+ on(document.body, 'clientDisconnected', (evt) => {
+ for (const piece of this.pieces) {
+ if (piece.by !== evt.detail.clientId) continue;
+ piece.by = null;
+ piece.target = null;
+ this.settle(piece, this.restingAt(piece), true);
+ }
+ });
+ }
+ },
+
+ remove() {
+ for (const [target, type, fn, opts] of this.listeners) target.removeEventListener(type, fn, opts);
+ for (const h of this.hands.concat(this.desk)) this.release(h);
+ if (typeof NAF !== 'undefined' && NAF.connection) NAF.connection.unsubscribeToDataChannel('chess');
+ for (const s of this.sounds || []) if (s.isPlaying) s.stop();
+ this.group.traverse((o) => {
+ if (o.geometry) o.geometry.dispose();
+ });
+ this.el.removeObject3D('mesh');
+ },
+
+ // ---- making it ---------------------------------------------------------------
+
+ buildBoard() {
+ const wood = new THREE.MeshStandardMaterial({ color: '#4a2e1c', roughness: 0.55 });
+ const frame = new THREE.Mesh(new THREE.BoxGeometry(EDGE * 2, THICK - 0.002, EDGE * 2), wood);
+ frame.position.y = (THICK - 0.002) / 2;
+ this.group.add(frame);
+ const light = new THREE.MeshStandardMaterial({ color: '#e3c99a', roughness: 0.5 });
+ const dark = new THREE.MeshStandardMaterial({ color: '#8a5a36', roughness: 0.5 });
+ const tile = new THREE.BoxGeometry(SQUARE, 0.002, SQUARE);
+ for (let sq = 0; sq < 64; sq++) {
+ const mesh = new THREE.Mesh(tile, (sq % 8 + Math.floor(sq / 8)) % 2 ? light : dark);
+ mesh.position.copy(this.squareCenter(sq)).y -= 0.001;
+ this.group.add(mesh);
+ }
+ // The square a held piece would go to.
+ this.glowGeometry = new THREE.PlaneGeometry(SQUARE * 0.94, SQUARE * 0.94).rotateX(-Math.PI / 2);
+ this.glowFree = new THREE.MeshBasicMaterial({ color: '#6fd0ff', transparent: true, opacity: 0.55, depthWrite: false });
+ this.glowTake = new THREE.MeshBasicMaterial({ color: '#ff6a4a', transparent: true, opacity: 0.6, depthWrite: false });
+ },
+
+ // A red button on each of the two free corners of the table: in VR, reach
+ // for one and pull the trigger twice to set the board up again.
+ buildButtons() {
+ const t = this.data.table - 0.06;
+ const red = new THREE.MeshStandardMaterial({ color: '#b8352a', roughness: 0.4 });
+ const base = new THREE.MeshStandardMaterial({ color: '#222222', roughness: 0.6 });
+ this.buttons = [new THREE.Vector3(-t, 0, t), new THREE.Vector3(t, 0, -t)].map((at, k) => {
+ const button = new THREE.Group();
+ button.position.copy(at);
+ const ring = new THREE.Mesh(new THREE.CylinderGeometry(0.032, 0.034, 0.008, 24), base);
+ ring.position.y = 0.004;
+ const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.024, 0.024, 0.01, 24), red);
+ cap.position.y = 0.012;
+ button.add(ring, cap);
+ this.group.add(button);
+ const label = document.createElement('a-entity');
+ label.setAttribute('text', { value: 'reset', align: 'center', width: 0.35, color: '#333333' });
+ // Lying on the table, the right way up for the player it's nearest.
+ label.object3D.position.set(at.x, 0.001, at.z + (k ? 0.048 : -0.048));
+ label.object3D.rotation.set(-Math.PI / 2, 0, k ? Math.PI : 0);
+ this.el.appendChild(label);
+ return button;
+ });
+ const world = this;
+ this.touchables = this.buttons.map((button) => ({
+ name: 'chess-reset',
+ label: () => (world.el.sceneEl.time - world.armedAt < ARMED_MS ? 'again to reset the board' : 'set up the chess board'),
+ // Closer than for most touchables, so reaching for a piece doesn't offer it.
+ distance: (p) => button.getWorldPosition(v1).distanceTo(p) + TOUCH_REACH - BUTTON_REACH,
+ use: () => {
+ const now = world.el.sceneEl.time;
+ if (now - world.armedAt < ARMED_MS) {
+ world.armedAt = -Infinity;
+ world.resetChess();
+ } else {
+ world.armedAt = now;
+ }
+ },
+ }));
+ },
+
+ loadPieces() {
+ const loader = new THREE.GLTFLoader();
+ const kinds = [...new Set(BACK_RANK.concat('pawn'))];
+ const load = (kind) => new Promise((resolve, reject) => loader.load(`${MODEL_DIR}${kind}.glb`, resolve, undefined, reject))
+ .then((gltf) => {
+ let geometry = null;
+ let material = null;
+ gltf.scene.traverse((o) => {
+ if (o.isMesh && !geometry) {
+ geometry = o.geometry;
+ material = o.material;
+ }
+ });
+ return [kind, geometry, material];
+ });
+ Promise.all(kinds.map(load)).then((loaded) => {
+ const models = {};
+ for (const [kind, geometry, material] of loaded) models[kind] = geometry;
+ // Each side's own look, and a glow for the piece your hand would take.
+ const template = loaded[0][2];
+ this.materials = {};
+ for (const side of ['white', 'black']) {
+ const plain = template.clone();
+ plain.color.set(COLOR[side]);
+ plain.roughness = side === 'white' ? 0.45 : 0.35;
+ const lit = plain.clone();
+ lit.emissive.set('#3d9bd6');
+ lit.emissiveIntensity = side === 'white' ? 0.45 : 0.8;
+ this.materials[side] = { plain, lit };
+ }
+ this.makePieces(models);
+ }).catch((err) => console.warn("Couldn't load the chess pieces", err));
+ },
+
+ // Pieces 0–15 are white's, 16–31 black's; each starts on its own square.
+ makePieces(models) {
+ for (let i = 0; i < 32; i++) {
+ const side = i < 16 ? 'white' : 'black';
+ const n = i % 16;
+ const file = n % 8;
+ const rank = side === 'white' ? (n < 8 ? 0 : 1) : (n < 8 ? 7 : 6);
+ const kind = n < 8 ? BACK_RANK[file] : 'pawn';
+ const mesh = new THREE.Mesh(models[kind], this.materials[side].plain);
+ mesh.scale.setScalar(SCALE);
+ const object = new THREE.Group();
+ object.add(mesh);
+ this.group.add(object);
+ const glow = new THREE.Mesh(this.glowGeometry, this.glowFree);
+ glow.visible = false;
+ this.group.add(glow);
+ const piece = {
+ i, side, kind, mesh, object, glow, home: rank * 8 + file, height: HEIGHT[kind] * SCALE,
+ at: null, from: null, holder: null, by: null, v: 0, target: null, fall: null, lit: false,
+ };
+ piece.at = { sq: piece.home };
+ this.pose(piece, piece.at);
+ this.pieces.push(piece);
+ }
+ },
+
+ loadSounds() {
+ const sceneEl = this.el.sceneEl;
+ if (!sceneEl.audioListener) {
+ sceneEl.audioListener = new THREE.AudioListener();
+ if (sceneEl.camera) sceneEl.camera.add(sceneEl.audioListener);
+ sceneEl.addEventListener('camera-set-active', (e) => e.detail.cameraEl.getObject3D('camera').add(sceneEl.audioListener));
+ }
+ const loader = new THREE.AudioLoader();
+ Promise.all(SOUNDS.map((name) => new Promise((resolve, reject) => loader.load(`${SOUND_DIR}${name}.ogg`, resolve, undefined, reject))))
+ .then((buffers) => {
+ this.buffers = buffers;
+ this.sounds = [0, 1, 2, 3].map(() => {
+ const sound = new THREE.PositionalAudio(sceneEl.audioListener);
+ sound.setRefDistance(0.6);
+ sound.setRolloffFactor(1.2);
+ this.group.add(sound);
+ return sound;
+ });
+ })
+ .catch((err) => console.warn("Couldn't load the chess sounds", err));
+ },
+
+ // A wooden knock where a piece lands, a little different each time.
+ knock(at, loud) {
+ if (!this.sounds) return;
+ const sound = this.sounds.find((s) => !s.isPlaying);
+ if (!sound) return;
+ sound.setBuffer(this.buffers[Math.floor(Math.random() * this.buffers.length)]);
+ sound.setPlaybackRate(0.92 + Math.random() * 0.22);
+ sound.setVolume(loud * (0.75 + Math.random() * 0.25));
+ sound.position.copy(at);
+ sound.play();
+ },
+
+ // ---- where pieces go -----------------------------------------------------------
+
+ // Square 0 is a1 (white's left), 7 is h1, 63 is h8.
+ squareCenter(sq, out = new THREE.Vector3()) {
+ return out.set((sq % 8 - 3.5) * SQUARE, THICK, (3.5 - Math.floor(sq / 8)) * SQUARE);
+ },
+
+ // The square under a point, if it's over the board.
+ squareAt(p) {
+ if (Math.abs(p.x) > EDGE || Math.abs(p.z) > EDGE || p.y < -0.05) return null;
+ const file = clamp(Math.floor((p.x + HALF) / SQUARE), 0, 7);
+ const rank = clamp(Math.floor((HALF - p.z) / SQUARE), 0, 7);
+ return rank * 8 + file;
+ },
+
+ // Pieces face the other side: the knights' heads point at the opponent.
+ facing(piece) {
+ return piece.side === 'white' ? Math.PI : 0;
+ },
+
+ // Where a piece stands for `at`: { p, yaw } in the board's coordinates.
+ placeOf(piece, at) {
+ if (at.sq !== undefined) return { p: this.squareCenter(at.sq), yaw: this.facing(piece) };
+ if (at.slot !== undefined) {
+ // Taken pieces stand in two rows beside the board, on the right of
+ // whoever took them: black's on white's right, white's on black's.
+ const s = piece.side === 'black' ? 1 : -1;
+ const col = Math.floor(at.slot / 8);
+ const row = at.slot % 8;
+ const p = new THREE.Vector3(s * (EDGE + 0.035 + col * SLOT), 0, s * (EDGE + 0.1 - row * SLOT));
+ return { p, yaw: this.facing(piece) };
+ }
+ return { p: new THREE.Vector3().fromArray(at.p), yaw: at.yaw };
+ },
+
+ // Puts a piece straight there.
+ pose(piece, at) {
+ const { p, yaw } = this.placeOf(piece, at);
+ piece.object.position.copy(p);
+ piece.object.quaternion.setFromAxisAngle(Y, yaw);
+ },
+
+ // Where a piece that's let go of here lands: on the table under it, or on the floor.
+ looseAt(piece) {
+ const p = piece.object.position;
+ const t = this.data.table;
+ const y = Math.abs(p.x) < t && Math.abs(p.z) < t && p.y > -0.02 ? 0 : -this.data.height;
+ euler.setFromQuaternion(piece.object.quaternion);
+ return { p: [round(p.x), y, round(p.z)], yaw: round(euler.y) };
+ },
+
+ // Where a piece that's let go of here goes: the square under it, or else
+ // down onto the table or floor.
+ restingAt(piece) {
+ const sq = this.squareAt(piece.object.position);
+ return sq !== null ? { sq } : this.looseAt(piece);
+ },
+
+ // Moves a piece to `at`: it drops straight down if that's under it, or else
+ // glides there; then it knocks.
+ settle(piece, at, sound) {
+ piece.at = at;
+ const { p, yaw } = this.placeOf(piece, at);
+ const from = piece.object.position.clone();
+ const drop = from.y - p.y;
+ const across = Math.hypot(from.x - p.x, from.z - p.z);
+ const falling = drop > 0.005 && across < 0.04;
+ piece.fall = {
+ from, to: p, q0: piece.object.quaternion.clone(), q1: new THREE.Quaternion().setFromAxisAngle(Y, yaw),
+ t: 0, falling, sound,
+ dur: falling ? Math.sqrt(2 * drop / GRAVITY) : clamp(from.distanceTo(p) / 1.5, 0.1, 0.35),
+ };
+ },
+
+ animate(piece, dt) {
+ const f = piece.fall;
+ f.t += dt;
+ const k = Math.min(1, f.t / f.dur);
+ const obj = piece.object;
+ if (f.falling) {
+ obj.position.lerpVectors(f.from, f.to, k);
+ obj.position.y = Math.max(f.to.y, f.from.y - 0.5 * GRAVITY * f.t * f.t);
+ } else {
+ obj.position.lerpVectors(f.from, f.to, k * k * (3 - 2 * k));
+ }
+ obj.quaternion.slerpQuaternions(f.q0, f.q1, Math.min(1, f.t / Math.min(f.dur, 0.12)));
+ if (k < 1) return;
+ piece.fall = null;
+ if (f.sound) this.knock(obj.position, f.falling ? clamp(0.4 + (f.from.y - f.to.y) * 2, 0.4, 1) : 0.5);
+ },
+
+ occupant(sq, except) {
+ return this.pieces.find((p) => p !== except && p.at && p.at.sq === sq);
+ },
+
+ freeSlot(side) {
+ const used = new Set(this.pieces.filter((p) => p.side === side && p.at && p.at.slot !== undefined).map((p) => p.at.slot));
+ for (let slot = 0; slot < 16; slot++) if (!used.has(slot)) return slot;
+ return 15;
+ },
+
+ // Puts down a piece you had, taking whatever was on its square.
+ place(piece, at) {
+ if (at.sq !== undefined) {
+ const taken = this.occupant(at.sq, piece);
+ if (taken) {
+ taken.v++;
+ this.settle(taken, { slot: this.freeSlot(taken.side) }, true);
+ this.send({ op: 'put', i: taken.i, v: taken.v, at: taken.at }, true);
+ }
+ }
+ piece.v++;
+ this.settle(piece, at, true);
+ this.send({ op: 'put', i: piece.i, v: piece.v, at: piece.at }, true);
+ },
+
+ // Everything back where it starts, for everyone.
+ resetChess() {
+ if (!this.pieces.length) return;
+ this.setUp(this.epoch + 1);
+ this.send({ op: 'reset' }, true);
+ this.knock(v1.set(0, THICK, 0), 0.8);
+ },
+
+ setUp(epoch) {
+ this.epoch = epoch;
+ for (const piece of this.pieces) {
+ if (piece.holder) this.letGoOf(piece);
+ Object.assign(piece, { at: { sq: piece.home }, by: null, v: 0, target: null, fall: null });
+ this.pose(piece, piece.at);
+ }
+ },
+
+ // ---- holding ---------------------------------------------------------------------
+
+ wield() {
+ const el = this.data.rig;
+ return el && el.components.wield;
+ },
+
+ myId() {
+ return online() ? NAF.clientId : 'me';
+ },
+
+ // Whether a hand of yours (its entity) holds a piece; for screens.js.
+ holds(handEl) {
+ return this.hands.some((h) => h.el === handEl && h.piece);
+ },
+
+ // The base and top of a piece, in the world.
+ ends(piece) {
+ piece.object.updateWorldMatrix(true, false);
+ const base = piece.object.getWorldPosition(new THREE.Vector3());
+ const up = Y.clone().applyQuaternion(piece.object.getWorldQuaternion(q1));
+ return [base, base.clone().addScaledVector(up, piece.height)];
+ },
+
+ // The piece nearest an empty hand, within reach.
+ nearest(hand) {
+ const at = hand.el.object3D.getWorldPosition(v2);
+ let best = null;
+ let bestDistance = GRAB_REACH;
+ for (const piece of this.pieces) {
+ if (piece.by || piece.holder) continue;
+ const [a, b] = this.ends(piece);
+ v3.subVectors(b, a);
+ const t = clamp(v3.dot(v1.subVectors(at, a)) / v3.lengthSq(), 0, 1);
+ const d = at.distanceTo(v1.copy(a).addScaledVector(v3, t));
+ if (d < bestDistance) {
+ best = piece;
+ bestDistance = d;
+ }
+ }
+ return best;
+ },
+
+ // Desktop: the piece nearest your line of sight, within reach.
+ looked() {
+ const head = this.data.head.object3D;
+ const eye = head.getWorldPosition(new THREE.Vector3());
+ const ahead = new THREE.Vector3(0, 0, -1).applyQuaternion(head.getWorldQuaternion(q1));
+ let best = null;
+ let bestAim = LOOK_AIM;
+ for (const piece of this.pieces) {
+ if (piece.holder) continue;
+ const [a, b] = this.ends(piece);
+ for (let k = 0; k <= 6; k++) {
+ const [aim, along] = offRay(eye, ahead, v2.lerpVectors(a, b, k / 6));
+ if (along < 0 || along > LOOK_REACH || aim >= bestAim) continue;
+ best = piece;
+ bestAim = aim;
+ }
+ }
+ return best;
+ },
+
+ // Desktop: where you're looking, as somewhere to put a piece: a square,
+ // a spot on the table, or nowhere.
+ lookedAt(piece) {
+ const head = this.data.head.object3D;
+ const o = this.group.worldToLocal(head.getWorldPosition(new THREE.Vector3()));
+ m1.copy(this.group.matrixWorld).invert();
+ const d = new THREE.Vector3(0, 0, -1).applyQuaternion(head.getWorldQuaternion(q1)).transformDirection(m1);
+ if (d.y > -0.05) return null;
+ const onBoard = o.clone().addScaledVector(d, (THICK - o.y) / d.y);
+ const sq = this.squareAt(onBoard);
+ if (sq !== null && onBoard.distanceTo(o) < LOOK_REACH) return { sq };
+ const onTable = o.clone().addScaledVector(d, -o.y / d.y);
+ const t = this.data.table - 0.02;
+ if (Math.abs(onTable.x) < t && Math.abs(onTable.z) < t && onTable.distanceTo(o) < LOOK_REACH) {
+ euler.setFromQuaternion(piece.object.quaternion);
+ return { p: [round(onTable.x), 0, round(onTable.z)], yaw: round(euler.y) };
+ }
+ return null;
+ },
+
+ gripDown(hand) {
+ if (hand.piece || !this.pieces.length) return;
+ const wield = this.wield();
+ const held = wield && wield.hands.find((h) => h.el === hand.el);
+ if (held && held.tool) return; // it picked up a pen instead
+ const piece = this.nearest(hand);
+ if (piece) this.grab(hand, piece);
+ },
+
+ grab(hand, piece) {
+ if (piece.holder) this.letGoOf(piece); // from your other hand
+ piece.from = piece.at;
+ piece.at = null;
+ piece.fall = null;
+ piece.target = null;
+ piece.by = null;
+ piece.holder = hand;
+ hand.piece = piece;
+ // In VR it stays just as it was in your hand; on desktop it floats in front of you.
+ if (hand.vr) hand.el.object3D.attach(piece.object);
+ piece.v++;
+ this.sendPose(piece, true);
+ if (hand.vr) buzz(hand.el, 0.4, 30);
+ },
+
+ // Takes a piece out of your hand without putting it anywhere.
+ letGoOf(piece) {
+ const hand = piece.holder;
+ if (!hand) return;
+ hand.piece = null;
+ piece.holder = null;
+ piece.glow.visible = false;
+ this.group.attach(piece.object);
+ },
+
+ release(hand) {
+ const piece = hand.piece;
+ if (!piece) return;
+ let at;
+ if (hand.vr) {
+ this.letGoOf(piece);
+ at = this.restingAt(piece);
+ buzz(hand.el, 0.25, 20);
+ } else {
+ at = this.lookedAt(piece) || piece.from || this.looseAt(piece);
+ this.letGoOf(piece);
+ }
+ this.place(piece, at);
+ },
+
+ // ---- syncing -------------------------------------------------------------------------
+
+ send(msg, guaranteed, to) {
+ if (!online()) return;
+ msg.e = this.epoch;
+ if (to) NAF.connection.sendDataGuaranteed(to, 'chess', msg);
+ else if (guaranteed) NAF.connection.broadcastDataGuaranteed('chess', msg);
+ else NAF.connection.broadcastData('chess', msg);
+ },
+
+ // A held piece's pose in the board's coordinates.
+ sendPose(piece, guaranteed, to) {
+ if (!online()) return;
+ piece.object.updateWorldMatrix(true, false);
+ m1.copy(this.group.matrixWorld).invert().multiply(piece.object.matrixWorld);
+ const p = new THREE.Vector3();
+ const q = new THREE.Quaternion();
+ m1.decompose(p, q, v1);
+ this.send({
+ op: 'pose', i: piece.i, v: piece.v, by: piece.holder ? this.myId() : piece.by,
+ p: [round(p.x), round(p.y), round(p.z)], q: [round(q.x), round(q.y), round(q.z), round(q.w)],
+ }, guaranteed, to);
+ },
+
+ onMessage(msg) {
+ if (!msg || !Number.isInteger(msg.e) || msg.e < this.epoch || !this.pieces.length) return;
+ if (msg.e > this.epoch) this.setUp(msg.e);
+ if (msg.op === 'reset') return;
+ const piece = Number.isInteger(msg.i) && this.pieces[msg.i];
+ if (!piece || !Number.isInteger(msg.v) || msg.v < piece.v) return;
+ const by = msg.op === 'pose' && typeof msg.by === 'string' ? msg.by : null;
+ const current = piece.holder ? this.myId() : piece.by;
+ // Two people took it at once: the lower id keeps it, the same for everyone.
+ if (msg.v === piece.v && by !== current && (by || '￿') > (current || '￿')) return;
+ if (msg.op === 'pose') {
+ if (!finite(msg.p, 3) || !finite(msg.q, 4)) return;
+ const q = new THREE.Quaternion().fromArray(msg.q);
+ if (q.lengthSq() < 1e-6) return;
+ piece.v = msg.v;
+ if (piece.holder && by !== this.myId()) this.letGoOf(piece);
+ if (piece.holder) return;
+ if (piece.at) piece.from = piece.at;
+ Object.assign(piece, { at: null, fall: null, by, target: { p: new THREE.Vector3().fromArray(msg.p), q: q.normalize() } });
+ } else if (msg.op === 'put') {
+ const at = validAt(msg.at);
+ if (!at) return;
+ piece.v = msg.v;
+ if (piece.holder) this.letGoOf(piece);
+ piece.by = null;
+ piece.target = null;
+ this.settle(piece, at, true);
+ }
+ },
+
+ // Tells someone who just arrived about every piece that's been moved since
+ // the board was last set up. Everyone here does this; they keep the newest.
+ catchUp(clientId) {
+ if (!online()) return;
+ if (this.epoch > 0) this.send({ op: 'reset' }, true, clientId);
+ for (const piece of this.pieces) {
+ if (!piece.v) continue;
+ if (piece.holder || piece.by) this.sendPose(piece, true, clientId);
+ else if (piece.at) this.send({ op: 'put', i: piece.i, v: piece.v, at: piece.at }, true, clientId);
+ }
+ },
+
+ // ---- every frame -----------------------------------------------------------------------
+
+ tick(time, delta) {
+ if (!this.pieces.length) return;
+ const dt = Math.min(delta, 100) / 1000;
+ const ease = 1 - Math.exp(-dt / SMOOTH);
+ const vr = this.el.sceneEl.is('vr-mode');
+
+ if (this.desk.piece) this.hover(this.desk.piece, ease);
+
+ for (const piece of this.pieces) {
+ if (piece.target && !piece.holder) {
+ piece.object.position.lerp(piece.target.p, ease);
+ piece.object.quaternion.slerp(piece.target.q, ease);
+ }
+ if (piece.fall) this.animate(piece, dt);
+ this.showSquare(piece);
+ }
+
+ // The piece each empty hand would take glows.
+ const wield = this.wield();
+ const near = new Set();
+ for (const hand of this.hands) {
+ const busy = hand.piece || !vr || !hand.el.object3D.visible || (wield && wield.hands.some((h) => h.el === hand.el && h.tool));
+ const piece = busy ? null : this.nearest(hand);
+ if (piece && piece !== hand.near) buzz(hand.el, 0.1, 12);
+ hand.near = piece;
+ if (piece) near.add(piece);
+ }
+ if (!vr && !this.desk.piece) {
+ const piece = this.looked();
+ if (piece && !piece.by) near.add(piece);
+ }
+ for (const piece of this.pieces) {
+ const lit = near.has(piece);
+ if (lit !== piece.lit) {
+ piece.lit = lit;
+ piece.mesh.material = this.materials[piece.side][lit ? 'lit' : 'plain'];
+ }
+ }
+
+ if (time - this.lastPose >= POSE_MS) {
+ this.lastPose = time;
+ for (const piece of this.pieces) if (piece.holder) this.sendPose(piece, false);
+ }
+ },
+
+ // Desktop: a held piece hovers over where you're looking, upright, or
+ // floats low in front of you if that's nowhere.
+ hover(piece, ease) {
+ const at = this.lookedAt(piece);
+ let p;
+ if (at) {
+ p = this.placeOf(piece, at).p;
+ p.y += DESK_HOVER;
+ } else {
+ const head = this.data.head.object3D;
+ euler.setFromQuaternion(head.getWorldQuaternion(q1));
+ p = v2.set(-Math.sin(euler.y), 0, -Math.cos(euler.y)).multiplyScalar(DESK_AHEAD).add(head.getWorldPosition(v1));
+ p.y -= DESK_DROP;
+ this.group.worldToLocal(p);
+ }
+ piece.object.position.lerp(p, Math.min(1, ease * 2));
+ piece.object.quaternion.setFromAxisAngle(Y, this.facing(piece));
+ },
+
+ // Lights the square a held piece would go to: under it in VR or for
+ // someone else, or where you're looking on desktop.
+ showSquare(piece) {
+ let sq = null;
+ if (piece.holder === this.desk) {
+ const at = this.lookedAt(piece);
+ sq = at && at.sq !== undefined ? at.sq : null;
+ } else if (piece.holder || piece.by) {
+ piece.object.updateWorldMatrix(true, false);
+ sq = this.squareAt(this.group.worldToLocal(piece.object.getWorldPosition(v1)));
+ }
+ const glow = piece.glow;
+ glow.visible = sq !== null;
+ if (sq === null) return;
+ if (sq !== piece.glowSq && piece.holder && piece.holder.vr) buzz(piece.holder.el, 0.08, 10);
+ piece.glowSq = sq;
+ this.squareCenter(sq, glow.position).y += 0.0015;
+ glow.material = this.occupant(sq, piece) ? this.glowTake : this.glowFree;
+ },
+ });
+})();
diff --git a/public/js/components/hand-tilt.js b/public/js/components/hand-tilt.js
new file mode 100644
index 0000000..d874fc8
--- /dev/null
+++ b/public/js/components/hand-tilt.js
@@ -0,0 +1,37 @@
+/* global AFRAME, THREE */
+// Tips a tracked hand down about its fist. The controller's grip pose points
+// noticeably upward, so hands, whatever they hold, and the hands everyone else
+// sees all angle up. Turning the pose itself, right after it's read from the
+// headset, carries the change to all of them; where the hand is stays put.
+(function () {
+ const X = new THREE.Vector3(1, 0, 0);
+
+ AFRAME.registerComponent('hand-tilt', {
+ schema: {
+ degrees: { default: -30 }, // negative tips the fingers down
+ },
+
+ init() {
+ this.tilt = new THREE.Quaternion();
+ this.update();
+ },
+
+ update() {
+ this.tilt.setFromAxisAngle(X, THREE.MathUtils.degToRad(this.data.degrees));
+ },
+
+ // tracked-controls arrives when a controller connects (and again if it's
+ // swapped), so wrap whichever one is there.
+ tick() {
+ const tracked = this.el.components['tracked-controls'];
+ if (!tracked || tracked.tiltedBy === this) return;
+ const updatePose = tracked.updatePose;
+ const tilt = this.tilt;
+ tracked.updatePose = function () {
+ updatePose.call(this);
+ if (this.pose) this.el.object3D.quaternion.multiply(tilt);
+ };
+ tracked.tiltedBy = this;
+ },
+ });
+})();
diff --git a/public/js/components/locomotion.js b/public/js/components/locomotion.js
new file mode 100644
index 0000000..4bd5ac9
--- /dev/null
+++ b/public/js/components/locomotion.js
@@ -0,0 +1,312 @@
+/* global AFRAME, THREE, VrmBody, CurrentWorld */
+// Moving your own rig: left stick or WASD walks where you're looking, right
+// stick or Q/E snap-turns around your head, A or Space jumps, and X or C sits
+// on the nearest free seat (and stands back up). Keeps you inside the world,
+// including when you physically walk around your room: the world's own
+// confine() and floorAt() if it has them (see world.js), otherwise the
+// campfire's clearing, out of the fire, on flat ground.
+(function () {
+ const headPos = new THREE.Vector3();
+ const headQuat = new THREE.Quaternion();
+ const forward = new THREE.Vector3();
+ const right = new THREE.Vector3();
+ const eyes = new THREE.Vector3();
+ const before = new THREE.Vector3();
+
+ // Until your avatar's proportions are known: eyes this high above the seat
+ // and this far in front of it.
+ const DEFAULT_SEATED_EYES = { height: 0.8, forward: 0.12 };
+ const SEAT_REACH = 1.6; // how near a seat has to be to sit on it (m)
+ const LEAVE_SEAT = 0.6; // walking this far off the seat in your room stands you up
+ const SETTLE_TIME = 1.5; // seconds to ease your view to seated height
+ const STEP_OFF = 0.45; // standing up steps you this far toward the fire
+ const JUMP_SPEED = 4; // m/s up; with this gravity, a ~0.5 m hop
+ const GRAVITY = 16; // m/s², stronger than real so you're back down in half a second
+ const STEP_TIME = 0.08; // seconds to ease onto a step up or down
+
+ function wrapAngle(a) {
+ return Math.atan2(Math.sin(a), Math.cos(a));
+ }
+
+ AFRAME.registerComponent('locomotion', {
+ schema: {
+ speed: { default: 1.8 }, // m/s
+ snapDegrees: { default: 30 },
+ boundary: { default: 24 }, // campfire: max distance from the fire
+ fireRadius: { default: 0.9 }, // campfire: can't stand closer than this
+ head: { type: 'selector', default: '#player' },
+ leftHand: { type: 'selector', default: '#left-hand' },
+ rightHand: { type: 'selector', default: '#right-hand' },
+ },
+
+ init() {
+ this.keys = new Set();
+ this.stick = { x: 0, y: 0 };
+ this.turnArmed = true;
+ this.seat = null; // the seat you're on
+ this.seatedEyes = null; // from your avatar, once measured
+ this.velocityY = 0;
+ this.airborne = false;
+
+ this.onKeyDown = (evt) => {
+ if (evt.target.closest && evt.target.closest('input, textarea')) return;
+ if (evt.code === 'Space') evt.preventDefault(); // don't scroll or press a focused button
+ this.keys.add(evt.code);
+ if (evt.repeat) return;
+ if (evt.code === 'KeyQ') this.snapTurn(1);
+ if (evt.code === 'KeyE') this.snapTurn(-1);
+ if (evt.code === 'Space') this.jump();
+ if (evt.code === 'KeyC') this.toggleSit();
+ };
+ this.onKeyUp = (evt) => {
+ if (evt.code === 'Space' && !(evt.target.closest && evt.target.closest('input, textarea'))) evt.preventDefault();
+ this.keys.delete(evt.code);
+ };
+ this.onBlur = () => this.keys.clear();
+ this.onLeftStick = (evt) => {
+ this.stick.x = evt.detail.x;
+ this.stick.y = evt.detail.y;
+ };
+ this.onRightStick = (evt) => {
+ const x = evt.detail.x;
+ if (this.turnArmed && Math.abs(x) > 0.75) {
+ this.snapTurn(x > 0 ? -1 : 1);
+ this.turnArmed = false;
+ } else if (Math.abs(x) < 0.3) {
+ this.turnArmed = true;
+ }
+ };
+ this.onJumpButton = () => this.jump();
+ this.onSitButton = () => this.toggleSit();
+ this.onInfo = (evt) => {
+ const slug = evt.detail.avatar;
+ if (slug === this.avatar) return;
+ this.avatar = slug;
+ this.seatedEyes = null;
+ VrmBody.seatedEyes(slug).then((eyes) => {
+ if (slug === this.avatar) this.seatedEyes = eyes;
+ });
+ };
+
+ window.addEventListener('keydown', this.onKeyDown);
+ window.addEventListener('keyup', this.onKeyUp);
+ window.addEventListener('blur', this.onBlur);
+ this.data.leftHand.addEventListener('thumbstickmoved', this.onLeftStick);
+ this.data.rightHand.addEventListener('thumbstickmoved', this.onRightStick);
+ this.data.rightHand.addEventListener('abuttondown', this.onJumpButton);
+ this.data.leftHand.addEventListener('xbuttondown', this.onSitButton);
+ this.el.addEventListener('player-info-changed', this.onInfo);
+ },
+
+ remove() {
+ window.removeEventListener('keydown', this.onKeyDown);
+ window.removeEventListener('keyup', this.onKeyUp);
+ window.removeEventListener('blur', this.onBlur);
+ this.data.leftHand.removeEventListener('thumbstickmoved', this.onLeftStick);
+ this.data.rightHand.removeEventListener('thumbstickmoved', this.onRightStick);
+ this.data.rightHand.removeEventListener('abuttondown', this.onJumpButton);
+ this.data.leftHand.removeEventListener('xbuttondown', this.onSitButton);
+ this.el.removeEventListener('player-info-changed', this.onInfo);
+ },
+
+ // Where you arrive: the world's spawn point (spread out a little so people
+ // don't land inside each other), or a spot around the fire.
+ spawn() {
+ const world = CurrentWorld(this.el.sceneEl);
+ const spot = world && world.spawn;
+ if (!spot) {
+ this.placeAround(3.6, Math.random() * Math.PI * 2);
+ return;
+ }
+ const rig = this.el.object3D;
+ const angle = Math.random() * Math.PI * 2;
+ const r = Math.random() * 0.6;
+ rig.position.set(spot.x + Math.sin(angle) * r, this.floorAt(spot.x, spot.z), spot.z + Math.cos(angle) * r);
+ rig.rotation.set(0, spot.yaw, 0);
+ },
+
+ floorAt(x, z) {
+ const world = CurrentWorld(this.el.sceneEl);
+ return world && world.floorAt ? world.floorAt(x, z) : 0;
+ },
+
+ // Face the fire from a spot on a circle around it.
+ placeAround(radius, angle) {
+ const rig = this.el.object3D;
+ rig.position.set(Math.sin(angle) * radius, 0, Math.cos(angle) * radius);
+ rig.rotation.set(0, angle, 0);
+ },
+
+ snapTurn(direction) {
+ const angle = THREE.MathUtils.degToRad(this.data.snapDegrees) * direction;
+ const rig = this.el.object3D;
+ this.data.head.object3D.getWorldPosition(headPos);
+ // Rotate the rig around your head so you turn in place.
+ const dx = rig.position.x - headPos.x;
+ const dz = rig.position.z - headPos.z;
+ const c = Math.cos(angle);
+ const s = Math.sin(angle);
+ rig.position.x = headPos.x + dx * c + dz * s;
+ rig.position.z = headPos.z - dx * s + dz * c;
+ rig.rotation.y += angle;
+ },
+
+ jump() {
+ if (this.seat) {
+ this.stand();
+ return;
+ }
+ if (this.airborne) return;
+ this.airborne = true;
+ this.velocityY = JUMP_SPEED;
+ },
+
+ toggleSit() {
+ if (this.seat) this.stand();
+ else if (!this.airborne) this.sit();
+ },
+
+ // Where your eyes go on a seat: above it, a little toward the fire.
+ eyesOn(seat, out) {
+ const eyes = this.seatedEyes || DEFAULT_SEATED_EYES;
+ return out.set(
+ seat.x - Math.sin(seat.yaw) * eyes.forward,
+ seat.y + eyes.height,
+ seat.z - Math.cos(seat.yaw) * eyes.forward,
+ );
+ },
+
+ sit() {
+ // The world is added after you choose it, so look for its seats each time.
+ const world = CurrentWorld(this.el.sceneEl);
+ const seats = world ? world.seats : [];
+ const taken = new Set();
+ for (const el of this.el.sceneEl.querySelectorAll('[player-info]')) {
+ if (el !== this.el) taken.add(el.getAttribute('player-info').seat);
+ }
+
+ const head = this.data.head.object3D;
+ head.getWorldPosition(headPos);
+ let best = -1;
+ let bestDistance = SEAT_REACH;
+ let nearbyTaken = false;
+ seats.forEach((seat, i) => {
+ const d = Math.hypot(seat.x - headPos.x, seat.z - headPos.z);
+ if (d >= bestDistance) return;
+ if (taken.has(i)) {
+ nearbyTaken = true;
+ } else {
+ best = i;
+ bestDistance = d;
+ }
+ });
+ if (best < 0) {
+ this.el.emit('sit-failed', { reason: nearbyTaken ? 'taken' : 'far' }, false);
+ return;
+ }
+
+ // Turn so your head faces the fire, then move your eyes onto the seat.
+ const seat = seats[best];
+ const rig = this.el.object3D;
+ head.getWorldQuaternion(headQuat);
+ forward.set(0, 0, -1).applyQuaternion(headQuat);
+ rig.rotation.y += wrapAngle(seat.yaw - Math.atan2(-forward.x, -forward.z));
+ rig.updateMatrixWorld(true);
+ head.getWorldPosition(headPos);
+ this.eyesOn(seat, eyes);
+ rig.position.x += eyes.x - headPos.x;
+ rig.position.z += eyes.z - headPos.z;
+ rig.position.y = eyes.y - head.position.y;
+
+ this.seat = seat;
+ this.el.setAttribute('player-info', 'seat', best);
+ },
+
+ stand() {
+ if (!this.seat) return;
+ const rig = this.el.object3D;
+ rig.position.x -= Math.sin(this.seat.yaw) * STEP_OFF;
+ rig.position.z -= Math.cos(this.seat.yaw) * STEP_OFF;
+ this.data.head.object3D.getWorldPosition(headPos);
+ rig.position.y = this.floorAt(headPos.x, headPos.z);
+ this.seat = null;
+ this.el.setAttribute('player-info', 'seat', -1);
+ },
+
+ tick(time, delta) {
+ const rig = this.el.object3D;
+ const dt = Math.min(delta, 100) / 1000;
+
+ let x = this.stick.x;
+ let y = this.stick.y;
+ const k = this.keys;
+ if (k.has('KeyW') || k.has('ArrowUp')) y -= 1;
+ if (k.has('KeyS') || k.has('ArrowDown')) y += 1;
+ if (k.has('KeyA') || k.has('ArrowLeft')) x -= 1;
+ if (k.has('KeyD') || k.has('ArrowRight')) x += 1;
+
+ const amount = Math.hypot(x, y);
+ if (amount > 0.15) {
+ if (this.seat) this.stand(); // walking off gets you up
+ const scale = Math.min(1, amount) / amount;
+ this.data.head.object3D.getWorldQuaternion(headQuat);
+ forward.set(0, 0, -1).applyQuaternion(headQuat).setY(0).normalize();
+ right.set(-forward.z, 0, forward.x);
+ rig.position.addScaledVector(right, x * scale * this.data.speed * dt);
+ rig.position.addScaledVector(forward, -y * scale * this.data.speed * dt);
+ }
+
+ const head = this.data.head.object3D;
+ head.getWorldPosition(headPos);
+ const floor = this.seat ? 0 : this.floorAt(headPos.x, headPos.z);
+
+ if (this.airborne) {
+ this.velocityY -= GRAVITY * dt;
+ rig.position.y += this.velocityY * dt;
+ if (rig.position.y <= floor) {
+ rig.position.y = floor;
+ this.velocityY = 0;
+ this.airborne = false;
+ }
+ }
+
+ if (this.seat) {
+ // Wandering off in your room gets you up.
+ this.eyesOn(this.seat, eyes);
+ if (Math.hypot(headPos.x - eyes.x, headPos.z - eyes.z) > LEAVE_SEAT) {
+ this.stand();
+ return;
+ }
+ // Ease your eyes to seated height, whether you're standing or sitting
+ // at home, and keep them there if you change posture.
+ const want = eyes.y - head.position.y;
+ rig.position.y += (want - rig.position.y) * (1 - Math.exp(-dt / SETTLE_TIME));
+ return;
+ }
+
+ // Step up or down onto whatever floor is under you.
+ if (!this.airborne) rig.position.y += (floor - rig.position.y) * (1 - Math.exp(-dt / STEP_TIME));
+
+ const world = CurrentWorld(this.el.sceneEl);
+ if (world && world.confine) {
+ before.copy(headPos);
+ world.confine(headPos);
+ rig.position.x += headPos.x - before.x;
+ rig.position.z += headPos.z - before.z;
+ return;
+ }
+
+ // Keep your head inside the clearing and out of the fire.
+ const r = Math.hypot(headPos.x, headPos.z);
+ if (r > this.data.boundary) {
+ rig.position.x -= (headPos.x / r) * (r - this.data.boundary);
+ rig.position.z -= (headPos.z / r) * (r - this.data.boundary);
+ } else if (r < this.data.fireRadius) {
+ const dirX = r > 1e-4 ? headPos.x / r : 0;
+ const dirZ = r > 1e-4 ? headPos.z / r : 1;
+ rig.position.x += dirX * (this.data.fireRadius - r);
+ rig.position.z += dirZ * (this.data.fireRadius - r);
+ }
+ },
+ });
+})();
diff --git a/public/js/components/nametag.js b/public/js/components/nametag.js
new file mode 100644
index 0000000..6fb1d87
--- /dev/null
+++ b/public/js/components/nametag.js
@@ -0,0 +1,69 @@
+/* global AFRAME, THREE */
+// Floating name above a remote person's head. Always faces you, shows when
+// they're muted, and a dot under it glows while they talk.
+(function () {
+ const worldPos = new THREE.Vector3();
+ const cameraPos = new THREE.Vector3();
+ const parentQuat = new THREE.Quaternion();
+ const yawQuat = new THREE.Quaternion();
+ const UP = new THREE.Vector3(0, 1, 0);
+
+ AFRAME.registerComponent('nametag', {
+ init() {
+ this.label = document.createElement('a-entity');
+ this.label.setAttribute('text', {
+ value: '', align: 'center', anchor: 'center', baseline: 'bottom',
+ width: 1.4, wrapCount: 18, color: '#ffffff', side: 'double',
+ });
+ this.el.appendChild(this.label);
+
+ this.dot = document.createElement('a-circle');
+ this.dot.setAttribute('radius', 0.022);
+ this.dot.setAttribute('position', '0 -0.035 0');
+ this.dot.setAttribute('material', { shader: 'flat', color: '#ffb36b', transparent: true, opacity: 0.25, side: 'double' });
+ this.el.appendChild(this.dot);
+
+ this.head = this.el.parentEl;
+ this.rig = this.head && this.head.parentEl;
+ this.onInfo = (evt) => this.render(evt.detail);
+ if (this.rig) {
+ this.rig.addEventListener('player-info-changed', this.onInfo);
+ const info = this.rig.getAttribute('player-info');
+ if (info) this.render(info);
+ }
+ },
+
+ remove() {
+ if (this.rig) this.rig.removeEventListener('player-info-changed', this.onInfo);
+ },
+
+ render(info) {
+ const name = (info.name || 'Someone').slice(0, 20);
+ this.label.setAttribute('text', {
+ value: info.muted ? `${name}\n(muted)` : name,
+ color: info.muted ? '#c9c9c9' : '#ffffff',
+ });
+ this.dot.setAttribute('material', 'color', info.color || '#ffb36b');
+ },
+
+ tick() {
+ const camera = this.el.sceneEl.camera;
+ if (!camera) return;
+
+ // Face the viewer, but stay upright however the speaker tilts their head.
+ this.el.object3D.getWorldPosition(worldPos);
+ camera.getWorldPosition(cameraPos);
+ yawQuat.setFromAxisAngle(UP, Math.atan2(cameraPos.x - worldPos.x, cameraPos.z - worldPos.z));
+ this.el.object3D.parent.getWorldQuaternion(parentQuat).invert();
+ this.el.object3D.quaternion.copy(parentQuat).multiply(yawQuat);
+
+ const voice = this.head && this.head.components['voice-level'];
+ const level = voice ? voice.level : 0;
+ const mesh = this.dot.getObject3D('mesh');
+ if (mesh) {
+ mesh.material.opacity = 0.25 + level * 0.75;
+ mesh.scale.setScalar(1 + level * 0.8);
+ }
+ },
+ });
+})();
diff --git a/public/js/components/player-info.js b/public/js/components/player-info.js
new file mode 100644
index 0000000..6110756
--- /dev/null
+++ b/public/js/components/player-info.js
@@ -0,0 +1,16 @@
+/* global AFRAME */
+// Who someone is. Lives on each person's rig and is synced by Networked-A-Frame,
+// so remote copies fire 'player-info-changed' whenever the owner updates it.
+AFRAME.registerComponent('player-info', {
+ schema: {
+ name: { default: '' },
+ color: { type: 'color', default: '#ffb36b' },
+ avatar: { default: '' },
+ muted: { default: false },
+ seat: { type: 'int', default: -1 }, // index into the campsite's seats, or -1 standing
+ },
+
+ update() {
+ this.el.emit('player-info-changed', this.data, false);
+ },
+});
diff --git a/public/js/components/screens.js b/public/js/components/screens.js
new file mode 100644
index 0000000..89586af
--- /dev/null
+++ b/public/js/components/screens.js
@@ -0,0 +1,483 @@
+/* global AFRAME, THREE, NAF, CurrentWorld */
+// Shared screens. Someone joins a room as "My screen" from a computer (see
+// share.js), and what they share shows up here as a big screen everyone sees,
+// with its sound coming from it. Anyone can move one: in VR point a hand at it
+// and squeeze grip, then squeeze the other grip too and move your hands apart
+// or together to resize it; on desktop look at it and press G, scroll to
+// resize, and press G again to put it down. A screen stays upright and turns to
+// face whoever is moving it. Where screens are is synced like wield.js's tools.
+//
+// The sharer's connection carries nothing else (no voice), so whatever video
+// and sound arrive on it are the screen's. They're read straight off the
+// peer connection rather than through EasyRTC's stream names, which can arrive
+// after the media does.
+(function () {
+ const REACH = 25; // how far away you can grab a screen (m)
+ const EDGE = 0.15; // grabbing just outside the picture still counts
+ const DEFAULT_WIDTH = 3;
+ const MIN_WIDTH = 0.6;
+ const MAX_WIDTH = 12;
+ const FRAME = 0.04; // dark border around the picture
+ const CHIN = 0.16; // the border's bottom edge, with the name on it
+ const POSE_MS = 66; // how often a moving screen's pose is sent
+ const CHECK_MS = 1000; // how often to look for the sharer's video and sound
+ const SMOOTH = 0.06; // seconds to ease toward someone else's move
+ const MAX_SCREENS = 8;
+
+ const v1 = new THREE.Vector3();
+ const v2 = new THREE.Vector3();
+ const v3 = new THREE.Vector3();
+ const q1 = new THREE.Quaternion();
+
+ const online = () => typeof NAF !== 'undefined' && NAF.connection && NAF.connection.isConnected();
+ const finite = (a, n) => Array.isArray(a) && a.length === n && a.every((x) => Number.isFinite(x) && Math.abs(x) < 1000);
+ const round = (x) => Math.round(x * 1e4) / 1e4;
+ const clampWidth = (w) => THREE.MathUtils.clamp(w, MIN_WIDTH, MAX_WIDTH);
+
+ function cleanName(value) {
+ return String(value || '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim().slice(0, 20) || 'Someone';
+ }
+
+ // The video and sound coming in from a sharer, if any yet.
+ function incoming(clientId) {
+ const adapter = NAF.connection.adapter;
+ const pc = adapter && adapter.easyrtc && adapter.easyrtc.getPeerConnectionByUserId(clientId);
+ const found = { video: null, audio: null };
+ if (!pc || !pc.getTransceivers) return found;
+ for (const t of pc.getTransceivers()) {
+ const dir = t.currentDirection;
+ if (dir !== 'recvonly' && dir !== 'sendrecv') continue;
+ const track = t.receiver && t.receiver.track;
+ if (track && track.readyState === 'live') found[track.kind] = track;
+ }
+ return found;
+ }
+
+ AFRAME.registerComponent('screens', {
+ schema: {
+ head: { type: 'selector', default: '#player' },
+ leftHand: { type: 'selector', default: '#left-hand' },
+ rightHand: { type: 'selector', default: '#right-hand' },
+ },
+
+ init() {
+ this.screens = new Map(); // sharer's client id → screen
+ this.pending = new Map(); // poses heard before the screen itself
+ this.lastPose = 0;
+ this.lastCheck = 0;
+ this.hand = null; // what's moving a screen of yours: { screen, el?, dist, ox, oy }
+ this.stretch = null; // your other hand while resizing: { el, from, width }
+
+ this.listeners = [];
+ const on = (target, type, fn, opts) => {
+ target.addEventListener(type, fn, opts);
+ this.listeners.push([target, type, fn, opts]);
+ };
+
+ for (const el of [this.data.leftHand, this.data.rightHand]) {
+ // After everyone else's listeners, so a pen grabbed by wield.js wins.
+ on(el, 'gripdown', () => Promise.resolve().then(() => this.gripDown(el)));
+ on(el, 'gripup', () => this.gripUp(el));
+ on(el, 'controllerdisconnected', () => this.gripUp(el));
+ }
+ on(this.el.sceneEl, 'enter-vr', () => this.letGo());
+ on(this.el.sceneEl, 'exit-vr', () => this.letGo());
+
+ const typing = (evt) => evt.target.closest && evt.target.closest('input, textarea, select');
+ on(window, 'keydown', (evt) => {
+ if (evt.code !== 'KeyG' || evt.repeat || typing(evt) || this.el.sceneEl.is('vr-mode')) return;
+ if (this.hand) this.letGo();
+ else this.grabLooked();
+ });
+ on(window, 'wheel', (evt) => {
+ if (!this.hand || this.hand.el) return;
+ evt.preventDefault();
+ this.resize(this.hand.screen, this.hand.screen.width * Math.exp(-evt.deltaY * 0.0015));
+ }, { passive: false });
+
+ if (typeof NAF !== 'undefined' && NAF.connection) {
+ NAF.connection.subscribeToDataChannel('screen', (from, type, msg) => this.onMessage(from, msg));
+ on(document.body, 'clientConnected', (evt) => this.catchUp(evt.detail.clientId));
+ on(document.body, 'clientDisconnected', (evt) => this.drop(evt.detail.clientId));
+ }
+ },
+
+ remove() {
+ for (const [target, type, fn, opts] of this.listeners) target.removeEventListener(type, fn, opts);
+ for (const id of [...this.screens.keys()]) this.drop(id);
+ },
+
+ // "Ann's screen", for the list of who's here.
+ labels() {
+ return [...this.screens.values()].map((s) => `${s.name}'s screen`);
+ },
+
+ // ---- screens coming and going ----------------------------------------------
+
+ onMessage(from, msg) {
+ if (!msg || typeof from !== 'string') return;
+ if (msg.op === 'on') this.show(from, cleanName(msg.name));
+ else if (msg.op === 'off') this.drop(from);
+ else if (msg.op === 'pose') this.onPose(msg);
+ },
+
+ show(id, name) {
+ let screen = this.screens.get(id);
+ if (screen) {
+ if (screen.name !== name) {
+ screen.name = name;
+ screen.label.setAttribute('text', 'value', `${name}'s screen`);
+ }
+ return;
+ }
+ if (this.screens.size >= MAX_SCREENS) return;
+ screen = this.build(id, name);
+ this.screens.set(id, screen);
+ this.placeNew(screen);
+ const early = this.pending.get(id);
+ this.pending.delete(id);
+ if (early) this.onPose(early);
+ this.el.emit('screens-changed', null, false);
+ },
+
+ build(id, name) {
+ const el = document.createElement('a-entity');
+ el.classList.add('shared-screen');
+ this.el.sceneEl.appendChild(el);
+ const group = el.object3D;
+
+ const frame = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ color: '#0b0b0d', side: THREE.DoubleSide }));
+ frame.position.z = -0.005;
+ const picture = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ color: '#000000', toneMapped: false }));
+ group.add(frame, picture);
+
+ const label = document.createElement('a-entity');
+ label.setAttribute('text', { value: `${name}'s screen`, align: 'center', color: '#c8c8c8', width: 2, wrapCount: 30 });
+ const waiting = document.createElement('a-entity');
+ waiting.setAttribute('text', { value: 'Waiting for the picture…', align: 'center', color: '#9a9a9a', width: 2, wrapCount: 30 });
+ waiting.object3D.position.z = 0.01;
+ el.append(label, waiting);
+
+ const video = document.createElement('video');
+ video.muted = true; // the sound plays from the screen's spot instead (below)
+ video.playsInline = true;
+ video.autoplay = true;
+ const texture = new THREE.VideoTexture(video);
+ texture.colorSpace = THREE.SRGBColorSpace;
+
+ const screen = {
+ id, name, el, group, frame, picture, label, waiting, video, texture,
+ sound: null, tracks: { video: null, audio: null },
+ width: DEFAULT_WIDTH, aspect: 16 / 9, yaw: 0,
+ v: 0, by: null, mine: false, target: null,
+ };
+ video.addEventListener('resize', () => this.fit(screen));
+ video.addEventListener('playing', () => {
+ picture.material.map = texture;
+ picture.material.color.set('#ffffff');
+ picture.material.needsUpdate = true;
+ waiting.object3D.visible = false;
+ this.fit(screen);
+ });
+ this.fit(screen);
+ return screen;
+ },
+
+ drop(id) {
+ this.pending.delete(id);
+ const screen = this.screens.get(id);
+ if (!screen) return;
+ if (this.hand && this.hand.screen === screen) {
+ this.hand = null;
+ this.stretch = null;
+ }
+ if (screen.sound) {
+ try { screen.sound.disconnect(); } catch (e) { /* never connected */ }
+ }
+ screen.video.srcObject = null;
+ screen.texture.dispose();
+ for (const mesh of [screen.frame, screen.picture]) {
+ mesh.geometry.dispose();
+ mesh.material.dispose();
+ }
+ screen.el.remove();
+ this.screens.delete(id);
+ this.el.emit('screens-changed', null, false);
+ },
+
+ // Size the picture to the shared video's shape, with the name on the
+ // border below it, where it reads in any light.
+ fit(screen) {
+ const { videoWidth: w, videoHeight: h } = screen.video;
+ if (w && h) screen.aspect = THREE.MathUtils.clamp(w / h, 0.3, 4);
+ const height = screen.width / screen.aspect;
+ screen.picture.scale.set(screen.width, height, 1);
+ screen.frame.scale.set(screen.width + 2 * FRAME, height + FRAME + CHIN, 1);
+ screen.frame.position.y = -(CHIN - FRAME) / 2;
+ screen.label.object3D.position.set(0, -height / 2 - CHIN / 2, 0.003);
+ screen.label.setAttribute('text', 'width', THREE.MathUtils.clamp(screen.width * 0.45, 0.9, 2.4));
+ screen.waiting.setAttribute('text', 'width', THREE.MathUtils.clamp(screen.width * 0.6, 1, 3));
+ },
+
+ height(screen) {
+ return screen.width / screen.aspect;
+ },
+
+ // Where a screen first appears: the world's `screen` spot (see world.js),
+ // or a few metres ahead of where people arrive. More screens line up beside
+ // it, in the same order for everyone.
+ placeNew(screen) {
+ const world = typeof CurrentWorld === 'function' ? CurrentWorld(this.el.sceneEl) : null;
+ let spot = world && world.screen;
+ if (!spot) {
+ const s = (world && world.spawn) || { x: 0, z: 0, yaw: 0 };
+ spot = { x: s.x - Math.sin(s.yaw) * 4, y: 1.8, z: s.z - Math.cos(s.yaw) * 4, yaw: s.yaw };
+ }
+ screen.width = clampWidth(spot.width || DEFAULT_WIDTH);
+ const rank = [...this.screens.keys()].sort().indexOf(screen.id);
+ const step = rank === 0 ? 0 : (rank % 2 ? 1 : -1) * Math.ceil(rank / 2) * (screen.width + 0.4);
+ screen.yaw = spot.yaw;
+ screen.group.position.set(spot.x + Math.cos(spot.yaw) * step, spot.y, spot.z - Math.sin(spot.yaw) * step);
+ screen.group.rotation.set(0, spot.yaw, 0);
+ this.fit(screen);
+ },
+
+ // ---- the sharer's video and sound -----------------------------------------
+
+ bind(screen) {
+ const found = incoming(screen.id);
+ if (found.video === screen.tracks.video && found.audio === screen.tracks.audio) return;
+ screen.tracks = found;
+ const tracks = [found.video, found.audio].filter(Boolean);
+ // Chrome only lets remote sound into Web Audio while a media element is
+ // playing the stream too, so the (muted) video element gets both.
+ screen.video.srcObject = tracks.length ? new MediaStream(tracks) : null;
+ if (tracks.length) screen.video.play().catch(() => {});
+ if (screen.sound) {
+ try { screen.sound.disconnect(); } catch (e) { /* never connected */ }
+ }
+ if (found.audio) {
+ if (!screen.sound) {
+ screen.sound = new THREE.PositionalAudio(this.listener());
+ screen.sound.setDistanceModel('inverse');
+ screen.sound.setRefDistance(3);
+ screen.sound.setRolloffFactor(0.6);
+ screen.group.add(screen.sound);
+ }
+ screen.sound.setMediaStreamSource(new MediaStream([found.audio]));
+ }
+ },
+
+ listener() {
+ const sceneEl = this.el.sceneEl;
+ if (!sceneEl.audioListener) {
+ sceneEl.audioListener = new THREE.AudioListener();
+ if (sceneEl.camera) sceneEl.camera.add(sceneEl.audioListener);
+ sceneEl.addEventListener('camera-set-active', (e) => e.detail.cameraEl.getObject3D('camera').add(sceneEl.audioListener));
+ }
+ return sceneEl.audioListener;
+ },
+
+ // ---- moving and resizing ---------------------------------------------------
+
+ myId() {
+ return online() ? NAF.clientId : 'me';
+ },
+
+ // The nearest screen along a ray, and where it hits in the screen's own
+ // coordinates (x right, y up from the middle).
+ aim(origin, dir) {
+ let best = null;
+ for (const screen of this.screens.values()) {
+ const g = screen.group;
+ const normal = v1.set(Math.sin(screen.yaw), 0, Math.cos(screen.yaw));
+ const denom = dir.dot(normal);
+ if (Math.abs(denom) < 1e-4) continue;
+ const t = v2.subVectors(g.position, origin).dot(normal) / denom;
+ if (t < 0 || t > REACH || (best && t > best.dist)) continue;
+ const hit = v3.copy(origin).addScaledVector(dir, t).sub(g.position);
+ const ox = hit.x * Math.cos(screen.yaw) - hit.z * Math.sin(screen.yaw);
+ const oy = hit.y;
+ if (Math.abs(ox) > screen.width / 2 + EDGE || Math.abs(oy) > this.height(screen) / 2 + EDGE) continue;
+ best = { screen, dist: t, ox, oy };
+ }
+ return best;
+ },
+
+ ray(el, origin, dir) {
+ el.object3D.updateWorldMatrix(true, false);
+ el.object3D.getWorldPosition(origin);
+ dir.set(0, 0, -1).applyQuaternion(el.object3D.getWorldQuaternion(q1));
+ return [origin, dir];
+ },
+
+ take(grab, el) {
+ const screen = grab.screen;
+ if (screen.by && screen.by !== this.myId()) {
+ this.el.emit('screen-busy', { name: screen.name }, false);
+ return;
+ }
+ this.hand = { ...grab, el };
+ screen.mine = true;
+ screen.by = null;
+ screen.target = null;
+ screen.v++;
+ this.sendPose(screen, true);
+ },
+
+ grabLooked() {
+ const [origin, dir] = this.ray(this.data.head, new THREE.Vector3(), new THREE.Vector3());
+ const grab = this.aim(origin, dir);
+ if (grab) this.take(grab, null);
+ },
+
+ gripDown(el) {
+ if (!this.el.sceneEl.is('vr-mode') || !this.screens.size) return;
+ const wield = this.el.components.wield;
+ const held = wield && wield.hands.find((h) => h.el === el);
+ if (held && held.tool) return; // it picked up a pen instead
+ const board = this.el.sceneEl.querySelector('[chess]');
+ if (board && board.components.chess && board.components.chess.holds(el)) return; // or a chess piece
+ if (this.hand && this.hand.el && this.hand.el !== el) {
+ // The other hand joins in: resize by how far apart your hands are.
+ const other = this.hand.el.object3D.getWorldPosition(v1);
+ const from = el.object3D.getWorldPosition(v2).distanceTo(other);
+ this.stretch = { el, from: Math.max(from, 0.05), width: this.hand.screen.width };
+ return;
+ }
+ if (this.hand) return;
+ const [origin, dir] = this.ray(el, new THREE.Vector3(), new THREE.Vector3());
+ const grab = this.aim(origin, dir);
+ if (grab) this.take(grab, el);
+ if (this.hand) this.buzz(el);
+ },
+
+ gripUp(el) {
+ if (this.stretch && this.stretch.el === el) this.stretch = null;
+ else if (this.hand && this.hand.el === el) this.letGo();
+ },
+
+ letGo() {
+ const hand = this.hand;
+ if (!hand) return;
+ this.hand = null;
+ this.stretch = null;
+ hand.screen.mine = false;
+ hand.screen.v++;
+ this.sendPose(hand.screen, true);
+ },
+
+ resize(screen, width) {
+ screen.width = clampWidth(width);
+ this.fit(screen);
+ },
+
+ // Follows your hand (or gaze) at the distance you grabbed it, upright and
+ // turned to face you, and never into the floor.
+ move() {
+ const hand = this.hand;
+ const screen = hand.screen;
+ const [origin, dir] = this.ray(hand.el || this.data.head, v1, v2);
+ if (this.stretch) {
+ const apart = this.stretch.el.object3D.getWorldPosition(v3).distanceTo(origin);
+ this.resize(screen, this.stretch.width * (apart / this.stretch.from));
+ }
+ const point = v3.copy(origin).addScaledVector(dir, hand.dist);
+ const g = screen.group;
+ const sx = Math.cos(screen.yaw);
+ const sz = -Math.sin(screen.yaw);
+ g.position.set(point.x - sx * hand.ox, point.y - hand.oy, point.z - sz * hand.ox);
+ const head = this.data.head.object3D.getWorldPosition(v1);
+ screen.yaw = Math.atan2(head.x - g.position.x, head.z - g.position.z);
+ g.rotation.set(0, screen.yaw, 0);
+ const world = typeof CurrentWorld === 'function' ? CurrentWorld(this.el.sceneEl) : null;
+ const floor = world && world.floorAt ? world.floorAt(g.position.x, g.position.z) : 0;
+ g.position.y = Math.max(g.position.y, floor + this.height(screen) / 2 + CHIN + 0.2);
+ },
+
+ buzz(el) {
+ try {
+ const tracked = el.components['tracked-controls'];
+ const pad = tracked && tracked.controller && tracked.controller.gamepad;
+ const motor = pad && pad.hapticActuators && pad.hapticActuators[0];
+ if (motor && motor.pulse) Promise.resolve(motor.pulse(0.4, 40)).catch(() => {});
+ } catch (e) {
+ // No haptics on this controller.
+ }
+ },
+
+ // ---- syncing where screens are ---------------------------------------------
+
+ sendPose(screen, guaranteed, to) {
+ if (!online()) return;
+ const p = screen.group.position;
+ const msg = {
+ op: 'pose', s: screen.id, v: screen.v, by: screen.mine ? this.myId() : null,
+ p: [round(p.x), round(p.y), round(p.z)], yaw: round(screen.yaw), w: round(screen.width),
+ };
+ if (to) NAF.connection.sendDataGuaranteed(to, 'screen', msg);
+ else if (guaranteed) NAF.connection.broadcastDataGuaranteed('screen', msg);
+ else NAF.connection.broadcastData('screen', msg);
+ },
+
+ onPose(msg) {
+ if (typeof msg.s !== 'string' || msg.s.length > 40 || !Number.isInteger(msg.v) || !finite(msg.p, 3)) return;
+ if (!Number.isFinite(msg.yaw) || !Number.isFinite(msg.w)) return;
+ const screen = this.screens.get(msg.s);
+ if (!screen) {
+ // Heard where it is before hearing it's there; keep that for a moment.
+ if (this.pending.size < MAX_SCREENS * 2) this.pending.set(msg.s, msg);
+ return;
+ }
+ const by = typeof msg.by === 'string' ? msg.by : null;
+ const current = screen.mine ? this.myId() : screen.by;
+ if (msg.v < screen.v) return;
+ // Two people grabbed it at once: the lower id keeps it, the same for everyone.
+ if (msg.v === screen.v && by !== current && (by || '￿') > (current || '￿')) return;
+ screen.v = msg.v;
+ if (screen.mine && by !== this.myId()) {
+ this.hand = null;
+ this.stretch = null;
+ screen.mine = false;
+ }
+ if (screen.mine) return;
+ screen.by = by;
+ screen.target = { p: new THREE.Vector3().fromArray(msg.p), yaw: msg.yaw, w: clampWidth(msg.w) };
+ },
+
+ // Tells someone who just arrived where the screens have been moved to.
+ catchUp(clientId) {
+ for (const screen of this.screens.values()) if (screen.v > 0) this.sendPose(screen, true, clientId);
+ },
+
+ // ---- every frame -----------------------------------------------------------
+
+ tick(time, delta) {
+ if (!this.screens.size) return;
+ if (time - this.lastCheck >= CHECK_MS) {
+ this.lastCheck = time;
+ for (const screen of this.screens.values()) this.bind(screen);
+ }
+ const ease = 1 - Math.exp(-(Math.min(delta, 100) / 1000) / SMOOTH);
+ for (const screen of this.screens.values()) {
+ const target = screen.target;
+ if (!target || screen.mine) continue;
+ const g = screen.group;
+ g.position.lerp(target.p, ease);
+ let turn = target.yaw - screen.yaw;
+ turn = Math.atan2(Math.sin(turn), Math.cos(turn));
+ screen.yaw += turn * ease;
+ g.rotation.set(0, screen.yaw, 0);
+ if (Math.abs(target.w - screen.width) > 1e-3) this.resize(screen, screen.width + (target.w - screen.width) * ease);
+ if (!screen.by && g.position.distanceToSquared(target.p) < 1e-8 && Math.abs(turn) < 1e-4) screen.target = null;
+ }
+ if (this.hand) {
+ this.move();
+ if (time - this.lastPose >= POSE_MS) {
+ this.lastPose = time;
+ this.sendPose(this.hand.screen, false);
+ }
+ }
+ },
+ });
+})();
diff --git a/public/js/components/sky.js b/public/js/components/sky.js
new file mode 100644
index 0000000..49867bd
--- /dev/null
+++ b/public/js/components/sky.js
@@ -0,0 +1,498 @@
+/* global AFRAME, THREE, Astro */
+// The night sky over the site's location, live: the 9,096 stars of the Yale Bright Star
+// Catalog, the Milky Way from NASA's Deep Star Maps, and the Moon and planets
+// where they really are (js/astro.js). It always stays night: the Sun isn't
+// drawn, but it still lights the Moon, so the Moon shows its real phase, and a
+// bright Moon washes out the faint stars and lights the clearing a little.
+//
+// The sky is drawn as from a dark site; a city's own light would hide most of
+// it. The location comes from the server (SKY_LATITUDE, SKY_LONGITUDE), which
+// app.js passes in when it loads a world. The celestial sphere follows the head, since stars show no parallax, and
+// turns with the sidereal time every frame.
+//
+// To see another time, add ?sky=2027-07-04T23:00-07:00 to the page's address,
+// and &skyspeed=600 to run the sky 600 times faster.
+(function () {
+ const SKY_DIR = 'assets/sky/';
+ const RAD = Math.PI / 180;
+ const R_MILKY_WAY = 450;
+ const R_STARS = 400;
+ const R_PLANETS = 390;
+ const R_MOON = 380;
+ const EARTH_RADIUS_KM = 6378.14;
+ const MOON_RADIUS_KM = 1737.4;
+
+ const PLANET_COLORS = {
+ mercury: '#e8ddd0', venus: '#fff8e8', mars: '#ffb98a',
+ jupiter: '#fff0d8', saturn: '#f6e2b0', uranus: '#d8f4f6',
+ };
+
+ // The larger maria, to paint the Moon's face: selenographic latitude,
+ // longitude (east positive) and radius, in degrees.
+ const MARIA = [
+ [33, -16, 17], [28, 17, 10], [8, 31, 12], [17, 59, 8], [-8, 51, 9],
+ [-15, 35, 5.5], [-21, -17, 10], [-24, -39, 6], [13, 4, 4], [-10, -23, 6],
+ [25, -55, 16], [5, -50, 13], [7.5, -31, 6], [56, -10, 6], [57, 15, 6],
+ ];
+
+ // Night-sky colours (sRGB). The horizon colour also becomes the fog colour,
+ // so the forest fades into the sky. Moonlight adds a deep blue.
+ const ZENITH = '#000002';
+ const MOON_SKY = '#0a1330';
+ const MOON_GLOW = '#c4d0ff';
+
+ // Air near the horizon dims what's behind it: the airmass for a direction
+ // whose sine of altitude is h, and 0.25 magnitudes lost per airmass.
+ const AIRMASS = /* glsl */ `
+ float airmass(float h) {
+ h = max(h, 0.0);
+ return 1.0 / (h + 0.025 * exp(-11.0 * h));
+ }
+ `;
+
+ const STAR_VERTEX = /* glsl */ `
+ attribute float aMag;
+ attribute vec3 aColor;
+ attribute float aTwinkle;
+ uniform float uTime;
+ uniform float uPxScale;
+ uniform float uFaint;
+ uniform float uWash;
+ varying vec3 vColor;
+ ${AIRMASS}
+ void main() {
+ vec4 world = modelMatrix * vec4(position, 1.0);
+ vec3 dir = normalize(world.xyz - cameraPosition);
+ float x = airmass(dir.y);
+ // 1 for a star at the naked-eye limit (6.5) overhead, times uFaint.
+ float lum = uFaint * exp(0.921 * (6.5 - aMag - 0.25 * (x - 1.0)));
+ // Stars twinkle, more so low down; planets hardly do.
+ float tw = sin(uTime * 6.3 + position.x * 0.37) * sin(uTime * 2.9 + position.y * 0.53);
+ lum *= 1.0 + aTwinkle * min(x, 8.0) * 0.05 * tw;
+ lum = max(lum - uWash, 0.0);
+ vColor = aColor * min(lum, 1.0);
+ // Brighter than the screen can show: grow instead.
+ gl_PointSize = 2.2 * uPxScale * min(pow(max(lum, 1.0), 0.25), 7.0);
+ gl_Position = projectionMatrix * viewMatrix * world;
+ // Below the horizon or washed out: move it outside the clip volume.
+ if (dir.y < 0.0 || lum < 0.004) gl_Position = vec4(0.0, 0.0, 2.0, 1.0);
+ }
+ `;
+
+ const STAR_FRAGMENT = /* glsl */ `
+ varying vec3 vColor;
+ void main() {
+ vec2 p = gl_PointCoord * 2.0 - 1.0;
+ float r2 = dot(p, p);
+ if (r2 > 1.0) discard;
+ gl_FragColor = vec4(vColor * exp(-3.0 * r2), 1.0);
+ #include <colorspace_fragment>
+ }
+ `;
+
+ const SKY_VERTEX = /* glsl */ `
+ varying vec2 vUv;
+ varying vec3 vDir;
+ void main() {
+ vUv = uv;
+ vec4 world = modelMatrix * vec4(position, 1.0);
+ vDir = world.xyz - cameraPosition;
+ gl_Position = projectionMatrix * viewMatrix * world;
+ }
+ `;
+
+ const SKY_FRAGMENT = /* glsl */ `
+ uniform sampler2D uMap;
+ uniform float uGain;
+ uniform float uSaturation;
+ uniform vec3 uZenith;
+ uniform vec3 uHorizon;
+ uniform vec3 uMoonDir;
+ uniform vec3 uMoonGlow;
+ varying vec2 vUv;
+ varying vec3 vDir;
+ ${AIRMASS}
+ void main() {
+ vec3 dir = normalize(vDir);
+ float up = step(0.0, dir.y);
+ vec3 col = mix(uZenith, uHorizon, pow(1.0 - max(dir.y, 0.0), 4.0));
+ vec3 mw = texture2D(uMap, vUv).rgb;
+ mw = mix(vec3(dot(mw, vec3(0.2126, 0.7152, 0.0722))), mw, uSaturation);
+ col += mw * uGain * exp(-0.23 * (airmass(dir.y) - 1.0)) * up;
+ // Moonlight scattered in the air: a bright aureole and a wide glow.
+ float theta = sqrt(max(2.0 * (1.0 - dot(dir, uMoonDir)), 0.0));
+ col += uMoonGlow * (0.8 * exp(-theta / 0.03) + 0.2 * exp(-theta / 0.35)) * up;
+ gl_FragColor = vec4(col, 1.0);
+ #include <colorspace_fragment>
+ // Dither, so the dark gradients don't band.
+ gl_FragColor.rgb += (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) / 255.0;
+ }
+ `;
+
+ const MOON_VERTEX = /* glsl */ `
+ varying vec3 vLocal;
+ varying vec3 vNormal;
+ varying vec3 vView;
+ void main() {
+ vLocal = normalize(position);
+ vec4 world = modelMatrix * vec4(position, 1.0);
+ vNormal = mat3(modelMatrix) * position;
+ vView = normalize(cameraPosition - world.xyz);
+ gl_Position = projectionMatrix * viewMatrix * world;
+ }
+ `;
+
+ const MOON_FRAGMENT = /* glsl */ `
+ uniform vec4 uMaria[${MARIA.length}];
+ uniform vec3 uSunDir;
+ uniform vec3 uTint;
+ varying vec3 vLocal;
+ varying vec3 vNormal;
+ varying vec3 vView;
+ void main() {
+ vec3 n = normalize(vNormal);
+ // Lommel-Seeliger scattering: why the full Moon looks like a flat disc.
+ float mu0 = dot(n, uSunDir);
+ float mu = max(dot(n, vView), 0.0);
+ float lit = mu0 > 0.0 ? 2.0 * mu0 / (mu0 + mu + 1e-3) : 0.0;
+ float sea = 0.0;
+ for (int i = 0; i < ${MARIA.length}; i++) {
+ float a = acos(clamp(dot(vLocal, uMaria[i].xyz), -1.0, 1.0));
+ sea = max(sea, 1.0 - smoothstep(uMaria[i].w * 0.7, uMaria[i].w, a));
+ }
+ // Faint earthshine on the dark part.
+ gl_FragColor = vec4(uTint * (1.0 - 0.42 * sea) * (0.95 * lit + 0.006), 1.0);
+ #include <colorspace_fragment>
+ }
+ `;
+
+ // A sphere in J2000 equatorial coordinates (pole on +z) whose texture
+ // coordinates follow the NASA map: plate carrée, 0h in the middle, right
+ // ascension increasing to the left.
+ function celestialSphere(radius, raSteps, decSteps) {
+ const positions = [];
+ const uvs = [];
+ const index = [];
+ for (let j = 0; j <= decSteps; j++) {
+ const v = j / decSteps;
+ const dec = (v - 0.5) * Math.PI;
+ for (let i = 0; i <= raSteps; i++) {
+ const u = i / raSteps;
+ const ra = (0.5 - u) * 2 * Math.PI;
+ positions.push(radius * Math.cos(dec) * Math.cos(ra), radius * Math.cos(dec) * Math.sin(ra), radius * Math.sin(dec));
+ uvs.push(u, v);
+ }
+ }
+ for (let j = 0; j < decSteps; j++) {
+ for (let i = 0; i < raSteps; i++) {
+ const a = j * (raSteps + 1) + i;
+ const c = a + raSteps + 1;
+ index.push(a, a + 1, c + 1, a, c + 1, c);
+ }
+ }
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
+ geometry.setIndex(index);
+ return geometry;
+ }
+
+ // B-V colour index to a gentle tint: Ballesteros' formula for the
+ // temperature, then a blackbody's colour (Tanner Helland's fit), mostly
+ // washed back toward white, because the eye sees little colour in starlight.
+ function starColor(bv, out) {
+ const b = Math.min(2, Math.max(-0.4, bv));
+ const k = 46 * (1 / (0.92 * b + 1.7) + 1 / (0.92 * b + 0.62));
+ const clamp = (x) => Math.min(255, Math.max(0, x)) / 255;
+ const r = k <= 66 ? 1 : clamp(329.698727446 * Math.pow(k - 60, -0.1332047592));
+ const g = k <= 66 ? clamp(99.4708025861 * Math.log(k) - 161.1195681661) : clamp(288.1221695283 * Math.pow(k - 60, -0.0755148492));
+ const bl = k >= 66 ? 1 : k <= 19 ? 0 : clamp(138.5177312231 * Math.log(k - 10) - 305.0447927307);
+ const max = Math.max(r, g, bl);
+ return out.setRGB(0.5 + 0.5 * r / max, 0.5 + 0.5 * g / max, 0.5 + 0.5 * bl / max, THREE.SRGBColorSpace);
+ }
+
+ function toMatrix4(rows, out) {
+ const [a, b, c] = rows;
+ return out.set(a[0], a[1], a[2], 0, b[0], b[1], b[2], 0, c[0], c[1], c[2], 0, 0, 0, 0, 1);
+ }
+
+ const smooth = THREE.MathUtils.smoothstep;
+
+ AFRAME.registerComponent('sky', {
+ schema: {
+ latitude: { default: 51.4769 }, // Greenwich, until app.js sets the site's
+ longitude: { default: -0.0005 },
+ // Which way true South lies in the scene, as an angle like campsite.js's
+ // polar(): toward the pond, the most open view. The Moon, the planets
+ // and the Milky Way's core all pass through the southern sky here.
+ south: { default: 132 },
+ milkyWay: { default: 0.1 },
+ // How much a full Moon overhead adds to the two lights.
+ moonlight: { default: 0.08 },
+ ambientLight: { type: 'selector', default: '#ambient-light' },
+ moonLight: { type: 'selector', default: '#moon-light' },
+ // A world seen through one window can hold the Moon in it: the sky turns
+ // about the Moon to keep it at this bearing (an angle like `south`) and
+ // altitude (degrees), so the stars stay right around it and its phase is
+ // still real. moonSize makes it look bigger than it really is.
+ pinMoon: { default: false },
+ moonBearing: { default: 180 },
+ moonAltitude: { default: 10 },
+ moonSize: { default: 1 },
+ },
+
+ init() {
+ const params = new URLSearchParams(location.search);
+ const start = Date.parse(params.get('sky') || '');
+ this.speed = Number(params.get('skyspeed')) || 1;
+ this.realStart = Date.now();
+ this.skyStart = Number.isFinite(start) ? start : this.realStart;
+ this.bodiesAt = -Infinity;
+
+ const scene = this.el.sceneEl.object3D;
+ this.horizonBase = scene.fog ? scene.fog.color.clone() : new THREE.Color('#020306');
+ this.ambientBase = this.data.ambientLight ? this.data.ambientLight.getAttribute('light').intensity : 0;
+ this.moonLightBase = this.data.moonLight ? this.data.moonLight.getAttribute('light').intensity : 0;
+ this.moonLightHome = this.data.moonLight ? this.data.moonLight.object3D.position.clone() : null;
+
+ // Equator and equinox of the date; the catalogue and map are J2000.
+ this.celestial = new THREE.Group();
+ this.j2000 = new THREE.Group();
+ this.celestial.matrixAutoUpdate = this.j2000.matrixAutoUpdate = false;
+ this.celestial.add(this.j2000);
+ scene.add(this.celestial);
+
+ const s = this.data.south * RAD;
+ const north = new THREE.Vector3(-Math.sin(s), 0, -Math.cos(s));
+ const east = new THREE.Vector3(-north.z, 0, north.x);
+ this.basis = new THREE.Matrix4().makeBasis(north, east, new THREE.Vector3(0, 1, 0));
+ this.horizon = new THREE.Matrix4();
+ this.rotation = new THREE.Matrix4();
+ this.lightRotation = new THREE.Matrix4();
+ this.pin = new THREE.Matrix4();
+ this.pinQuat = new THREE.Quaternion();
+ this.moonNow = new THREE.Vector3();
+ const b = this.data.moonBearing * RAD;
+ const h = this.data.moonAltitude * RAD;
+ this.moonTarget = new THREE.Vector3(Math.sin(b) * Math.cos(h), Math.sin(h), Math.cos(b) * Math.cos(h));
+ this.head = new THREE.Vector3();
+ this.bufferSize = new THREE.Vector2();
+ this.sunEq = new THREE.Vector3(1, 0, 0);
+ this.moonEq = new THREE.Vector3(1, 0, 0);
+ this.moonLit = 0;
+
+ this.buildMilkyWay();
+ this.buildPoints();
+ this.buildMoon();
+ this.loadStars();
+ },
+
+ remove() {
+ this.el.sceneEl.object3D.remove(this.celestial);
+ },
+
+ buildMilkyWay() {
+ const texture = new THREE.TextureLoader().load(SKY_DIR + 'milkyway-4k.jpg');
+ texture.colorSpace = THREE.SRGBColorSpace;
+ this.skyUniforms = {
+ uMap: { value: texture },
+ uGain: { value: this.data.milkyWay },
+ uSaturation: { value: 0.6 },
+ uZenith: { value: new THREE.Color(ZENITH) },
+ uHorizon: { value: this.horizonBase.clone() },
+ uMoonDir: { value: new THREE.Vector3(0, -1, 0) },
+ uMoonGlow: { value: new THREE.Color(0, 0, 0) },
+ };
+ const sphere = new THREE.Mesh(celestialSphere(R_MILKY_WAY, 96, 48), new THREE.ShaderMaterial({
+ uniforms: this.skyUniforms, vertexShader: SKY_VERTEX, fragmentShader: SKY_FRAGMENT,
+ side: THREE.DoubleSide, depthWrite: false, fog: false,
+ }));
+ sphere.renderOrder = -10;
+ sphere.frustumCulled = false;
+ this.j2000.add(sphere);
+ },
+
+ buildPoints() {
+ this.pointUniforms = {
+ uTime: { value: 0 },
+ uPxScale: { value: 1 },
+ uFaint: { value: 0.14 },
+ uWash: { value: 0 },
+ };
+ this.pointMaterial = new THREE.ShaderMaterial({
+ uniforms: this.pointUniforms, vertexShader: STAR_VERTEX, fragmentShader: STAR_FRAGMENT,
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, fog: false,
+ });
+
+ const names = Object.keys(PLANET_COLORS);
+ const geometry = new THREE.BufferGeometry();
+ const color = new THREE.Color();
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(names.length * 3), 3));
+ geometry.setAttribute('aMag', new THREE.BufferAttribute(new Float32Array(names.length).fill(99), 1));
+ geometry.setAttribute('aColor', new THREE.Float32BufferAttribute(names.flatMap((n) => color.set(PLANET_COLORS[n]).toArray()), 3));
+ geometry.setAttribute('aTwinkle', new THREE.BufferAttribute(new Float32Array(names.length).fill(0.15), 1));
+ this.planets = new THREE.Points(geometry, this.pointMaterial);
+ this.planets.renderOrder = 2;
+ this.planets.frustumCulled = false;
+ this.celestial.add(this.planets);
+ },
+
+ buildMoon() {
+ this.moonUniforms = {
+ uMaria: {
+ value: MARIA.map(([lat, lon, r]) => new THREE.Vector4(
+ Math.cos(lat * RAD) * Math.sin(lon * RAD), Math.sin(lat * RAD), Math.cos(lat * RAD) * Math.cos(lon * RAD), r * RAD,
+ )),
+ },
+ uSunDir: { value: new THREE.Vector3(1, 0, 0) },
+ uTint: { value: new THREE.Color(1, 1, 1) },
+ };
+ // Local +z faces the observer and +y celestial north (see updateBodies),
+ // so selenographic east is +x: Mare Crisium on the right, as seen from
+ // the northern hemisphere.
+ this.moon = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 16), new THREE.ShaderMaterial({
+ uniforms: this.moonUniforms, vertexShader: MOON_VERTEX, fragmentShader: MOON_FRAGMENT,
+ transparent: true, blending: THREE.AdditiveBlending, fog: false,
+ }));
+ this.moon.renderOrder = 1; // before the stars, so it hides the ones behind it
+ this.moon.visible = false;
+ this.moon.frustumCulled = false;
+ this.celestial.add(this.moon);
+ },
+
+ loadStars() {
+ fetch(SKY_DIR + 'stars.bin')
+ .then((res) => {
+ if (!res.ok) throw new Error(res.statusText);
+ return res.arrayBuffer();
+ })
+ .then((buffer) => {
+ // Four floats a star: J2000 right ascension and declination (radians), V magnitude, B-V.
+ const data = new Float32Array(buffer);
+ const count = data.length / 4;
+ const position = new Float32Array(count * 3);
+ const color = new Float32Array(count * 3);
+ const mag = new Float32Array(count);
+ const c = new THREE.Color();
+ for (let i = 0; i < count; i++) {
+ const ra = data[i * 4];
+ const dec = data[i * 4 + 1];
+ position[i * 3] = R_STARS * Math.cos(dec) * Math.cos(ra);
+ position[i * 3 + 1] = R_STARS * Math.cos(dec) * Math.sin(ra);
+ position[i * 3 + 2] = R_STARS * Math.sin(dec);
+ mag[i] = data[i * 4 + 2];
+ starColor(data[i * 4 + 3], c).toArray(color, i * 3);
+ }
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute('position', new THREE.BufferAttribute(position, 3));
+ geometry.setAttribute('aMag', new THREE.BufferAttribute(mag, 1));
+ geometry.setAttribute('aColor', new THREE.BufferAttribute(color, 3));
+ geometry.setAttribute('aTwinkle', new THREE.BufferAttribute(new Float32Array(count).fill(1), 1));
+ const stars = new THREE.Points(geometry, this.pointMaterial);
+ stars.renderOrder = 2;
+ stars.frustumCulled = false;
+ this.j2000.add(stars);
+ })
+ .catch((err) => console.warn("Couldn't load the stars", err));
+ },
+
+ tick(time) {
+ const { latitude, longitude } = this.data;
+ const ms = this.skyStart + (Date.now() - this.realStart) * this.speed;
+ if (Math.abs(ms - this.bodiesAt) > 30000) this.updateBodies(ms);
+
+ this.skyRotation(ms, this.rotation);
+ const camera = this.el.sceneEl.camera;
+ if (camera) camera.getWorldPosition(this.head);
+ this.celestial.matrix.copy(this.rotation).setPosition(this.head);
+ this.celestial.matrixWorldNeedsUpdate = true;
+
+ this.moonUniforms.uSunDir.value.copy(this.sunEq).transformDirection(this.rotation);
+ this.skyUniforms.uMoonDir.value.copy(this.moonEq).transformDirection(this.rotation);
+ this.pointUniforms.uTime.value = time / 1000;
+ this.pointUniforms.uPxScale.value = THREE.MathUtils.clamp(this.pixelsPerRadian() / 1300, 1, 2.5);
+ },
+
+ /** Equatorial to scene directions at a time: the real sky, or turned to hold the Moon where pinMoon wants it. */
+ skyRotation(ms, out) {
+ out.multiplyMatrices(this.basis, toMatrix4(Astro.horizonMatrix(ms, this.data.latitude, this.data.longitude), this.horizon));
+ if (!this.data.pinMoon) return out;
+ this.moonNow.copy(this.moonEq).transformDirection(out);
+ return out.premultiply(this.pin.makeRotationFromQuaternion(this.pinQuat.setFromUnitVectors(this.moonNow, this.moonTarget)));
+ },
+
+ /** Roughly how many pixels a radian of sky covers, so stars look the same size on any screen. */
+ pixelsPerRadian() {
+ const renderer = this.el.sceneEl.renderer;
+ if (renderer.xr.isPresenting) {
+ const view = renderer.xr.getCamera().cameras[0];
+ return view ? view.viewport.w * 0.5 * view.projectionMatrix.elements[5] : 0;
+ }
+ const camera = this.el.sceneEl.camera;
+ if (!camera) return 0;
+ return renderer.getDrawingBufferSize(this.bufferSize).y * 0.5 * camera.projectionMatrix.elements[5];
+ },
+
+ updateBodies(ms) {
+ this.bodiesAt = ms;
+ const { latitude, longitude } = this.data;
+ const sky = Astro.bodies(ms, latitude, longitude);
+ const horizon = Astro.horizonMatrix(ms, latitude, longitude);
+ toMatrix4(Astro.precessionMatrix(ms), this.j2000.matrix);
+ this.j2000.matrixWorldNeedsUpdate = true;
+ this.sunEq.fromArray(sky.sun);
+ this.moonEq.fromArray(sky.moon.dir);
+
+ const position = this.planets.geometry.attributes.position;
+ const mag = this.planets.geometry.attributes.aMag;
+ sky.planets.forEach((p, i) => {
+ position.setXYZ(i, p.dir[0] * R_PLANETS, p.dir[1] * R_PLANETS, p.dir[2] * R_PLANETS);
+ mag.setX(i, p.magnitude);
+ });
+ position.needsUpdate = mag.needsUpdate = true;
+
+ // The Moon, at its true size, turned so its north points to the celestial pole.
+ // A pinned Moon is lit and coloured as if well up, even when it's held low
+ // in a window, and it's there whether or not it has really risen.
+ const moonAlt = this.data.pinMoon ? Math.max(this.data.moonAltitude, 30) : Astro.altAz(sky.moon.dir, horizon).alt;
+ const toUs = this.moonEq.clone().negate();
+ const up = new THREE.Vector3(0, 0, 1).addScaledVector(this.moonEq, -this.moonEq.z).normalize();
+ const right = new THREE.Vector3().crossVectors(up, toUs);
+ this.moon.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(right, up, toUs));
+ this.moon.position.copy(this.moonEq).multiplyScalar(R_MOON);
+ this.moon.scale.setScalar(this.data.moonSize * R_MOON * Math.tan(Math.asin(MOON_RADIUS_KM / (sky.moon.dist * EARTH_RADIUS_KM))));
+ this.moon.visible = moonAlt > -0.5;
+ // Low down, the Moon is dimmer and orange.
+ const high = smooth(moonAlt, 0, 20);
+ this.moonUniforms.uTint.value.setRGB(1, 0.62 + 0.36 * high, 0.36 + 0.58 * high).multiplyScalar(0.55 + 0.45 * high);
+
+ // How much the Moon lights things: 1 for a full Moon well up, about a
+ // third at quarter (by eye, not by light meter), nothing once it's set.
+ const flux = Math.pow(10, -0.4 * (sky.moon.magnitude + 12.73));
+ const m = Math.sqrt(flux) * smooth(moonAlt, -0.5, 12);
+ this.moonLit = m;
+
+ const moonSky = new THREE.Color(MOON_SKY).multiplyScalar(m);
+ this.skyUniforms.uHorizon.value.copy(this.horizonBase).add(moonSky);
+ this.skyUniforms.uZenith.value.set(ZENITH).add(moonSky.multiplyScalar(0.6));
+ this.skyUniforms.uMoonGlow.value.set(MOON_GLOW).multiplyScalar(0.05 * m);
+ this.skyUniforms.uGain.value = this.data.milkyWay * (1 - 0.75 * m);
+ this.pointUniforms.uWash.value = 0.5 * m;
+ const fog = this.el.sceneEl.object3D.fog;
+ if (fog) fog.color.copy(this.skyUniforms.uHorizon.value);
+
+ this.updateLights(m);
+ },
+
+ updateLights(m) {
+ const { ambientLight, moonLight, moonlight } = this.data;
+ if (ambientLight) ambientLight.setAttribute('light', 'intensity', this.ambientBase + moonlight * 0.5 * m);
+ if (!moonLight) return;
+ moonLight.setAttribute('light', 'intensity', this.moonLightBase + moonlight * m);
+ // Shine from the Moon while it's up.
+ const toMoon = this.moonEq.clone().transformDirection(this.skyRotation(this.bodiesAt, this.lightRotation));
+ const home = this.moonLightHome.clone().normalize();
+ moonLight.object3D.position.copy(home.lerp(toMoon, Math.min(1, m * 4)).normalize().multiplyScalar(40));
+ },
+ });
+})();
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 };
+})();
diff --git a/public/js/components/testroom.js b/public/js/components/testroom.js
new file mode 100644
index 0000000..ba5a16a
--- /dev/null
+++ b/public/js/components/testroom.js
@@ -0,0 +1,72 @@
+/* global AFRAME, THREE */
+// The world: a plain, brightly lit white room with a small table in the middle,
+// for trying things out. The chess board on the table is its own entity
+// (chess.js); its reset buttons and resetChess() are passed through here.
+(function () {
+ const ROOM = { half: 4, height: 3 }; // 8 × 8 m, 3 m tall
+ const TABLE = { half: 0.4, height: 0.75, top: 0.04, leg: 0.05 };
+ const MARGIN = 0.25; // how close your head gets to a wall
+ const LEAN = 0.08; // and to the table, so you can reach across it
+
+ AFRAME.registerComponent('testroom', {
+ init() {
+ this.group = new THREE.Group();
+ this.el.setObject3D('mesh', this.group);
+ this.seats = [];
+ this.spawn = { x: 0, z: 2.5, yaw: 0 };
+ this.screen = { x: 0, y: 1.55, z: -3.85, yaw: 0, width: 3 }; // against the far wall
+ // Child entities start first, so the board is there already.
+ const chess = this.chess();
+ this.touchables = chess ? chess.touchables : [];
+
+ const white = new THREE.MeshLambertMaterial({ color: '#ffffff', side: THREE.BackSide });
+ const room = new THREE.Mesh(new THREE.BoxGeometry(ROOM.half * 2, ROOM.height, ROOM.half * 2), white);
+ room.position.y = ROOM.height / 2;
+ this.group.add(room);
+
+ const wood = new THREE.MeshLambertMaterial({ color: '#b08a60' });
+ const top = new THREE.Mesh(new THREE.BoxGeometry(TABLE.half * 2, TABLE.top, TABLE.half * 2), wood);
+ top.position.y = TABLE.height - TABLE.top / 2;
+ this.group.add(top);
+ const legHeight = TABLE.height - TABLE.top;
+ const legGeometry = new THREE.BoxGeometry(TABLE.leg, legHeight, TABLE.leg);
+ const inset = TABLE.half - TABLE.leg;
+ for (const [x, z] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
+ const leg = new THREE.Mesh(legGeometry, wood);
+ leg.position.set(x * inset, legHeight / 2, z * inset);
+ this.group.add(leg);
+ }
+ },
+
+ chess() {
+ const el = this.el.querySelector('[chess]');
+ return el && el.components.chess;
+ },
+
+ resetChess() {
+ const chess = this.chess();
+ if (chess) chess.resetChess();
+ },
+
+ remove() {
+ this.group.traverse((o) => {
+ if (o.geometry) o.geometry.dispose();
+ if (o.material) o.material.dispose();
+ });
+ this.el.removeObject3D('mesh');
+ },
+
+ // Keeps a head position inside the walls and out of the table.
+ confine(p) {
+ const wall = ROOM.half - MARGIN;
+ p.x = Math.min(wall, Math.max(-wall, p.x));
+ p.z = Math.min(wall, Math.max(-wall, p.z));
+ const t = TABLE.half + LEAN;
+ if (Math.abs(p.x) < t && Math.abs(p.z) < t) {
+ if (Math.abs(p.x) > Math.abs(p.z)) p.x = Math.sign(p.x) * t;
+ else p.z = (Math.sign(p.z) || 1) * t;
+ }
+ return p;
+ },
+ });
+})();
diff --git a/public/js/components/voice-level.js b/public/js/components/voice-level.js
new file mode 100644
index 0000000..44e9ccc
--- /dev/null
+++ b/public/js/components/voice-level.js
@@ -0,0 +1,39 @@
+/* global AFRAME */
+// Measures how loud a remote person is talking (0..1) from their incoming voice
+// stream, for lip-sync and the speaking indicator.
+AFRAME.registerComponent('voice-level', {
+ init() {
+ this.level = 0;
+ this.analyser = null;
+ this.samples = null;
+
+ this.onSoundSource = (evt) => {
+ const source = evt.detail.soundSource;
+ if (this.analyser) this.analyser.disconnect();
+ this.analyser = source.context.createAnalyser();
+ this.analyser.fftSize = 512;
+ this.samples = new Uint8Array(this.analyser.fftSize);
+ source.connect(this.analyser);
+ };
+ this.el.addEventListener('sound-source-set', this.onSoundSource);
+ },
+
+ remove() {
+ this.el.removeEventListener('sound-source-set', this.onSoundSource);
+ if (this.analyser) this.analyser.disconnect();
+ },
+
+ tick() {
+ if (!this.analyser) return;
+ this.analyser.getByteTimeDomainData(this.samples);
+ let sum = 0;
+ for (let i = 0; i < this.samples.length; i++) {
+ const v = (this.samples[i] - 128) / 128;
+ sum += v * v;
+ }
+ const rms = Math.sqrt(sum / this.samples.length);
+ const target = Math.min(1, Math.max(0, (rms - 0.012) * 9));
+ // Open quickly, close a little slower, so mouths don't chatter.
+ this.level += (target - this.level) * (target > this.level ? 0.6 : 0.2);
+ },
+});
diff --git a/public/js/components/vrm-avatar.js b/public/js/components/vrm-avatar.js
new file mode 100644
index 0000000..7568f73
--- /dev/null
+++ b/public/js/components/vrm-avatar.js
@@ -0,0 +1,486 @@
+/* global AFRAME, THREE, CurrentWorld */
+// A remote person's body: a VRM 0.x avatar that hangs from their tracked head,
+// turns its body to follow where they look, reaches its arms to their tracked
+// hands (two-bone IK), opens its mouth while they talk, and blinks. When they
+// sit, its hips go on their seat, its back leans toward their head and its
+// feet rest on the ground; when they jump, its knees tuck up.
+//
+// Lives on the remote copy of each person's rig. The head and hands are the
+// networked child entities NAF creates inside that rig.
+(function () {
+ const AVATAR_DIR = 'assets/avatars/';
+ const AVATAR_SLUG = /^[a-z0-9-]{1,40}$/;
+
+ // Avatars are scaled so their head bone sits this high when standing.
+ const HEAD_BONE_HEIGHT = 1.52;
+ // From the eyes (tracked camera) to the head bone's pivot, in camera space.
+ const EYES_TO_HEAD_PIVOT = new THREE.Vector3(0, -0.1, 0.07);
+ // From the tracked controller grip back to the wrist, in hand space.
+ const GRIP_TO_WRIST = new THREE.Vector3(0, -0.01, 0.07);
+ // Body turns to follow the head, never lagging more than this (radians).
+ const MAX_BODY_TWIST = 0.6;
+ // Sitting: the hip joints are this far above the seat, and the back leans
+ // at most this much (radians) to bring the head to where theirs is.
+ const HIP_ABOVE_SEAT = 0.08;
+ const MAX_LEAN = 0.6;
+
+ const UP = new THREE.Vector3(0, 1, 0);
+ const v1 = new THREE.Vector3();
+ const v2 = new THREE.Vector3();
+ const v3 = new THREE.Vector3();
+ const cross = new THREE.Vector3();
+ const shoulder = new THREE.Vector3();
+ const elbow = new THREE.Vector3();
+ const wrist = new THREE.Vector3();
+ const target = new THREE.Vector3();
+ const pole = new THREE.Vector3();
+ const pivot = new THREE.Vector3();
+ const forward = new THREE.Vector3();
+ const headPos = new THREE.Vector3();
+ const headQuat = new THREE.Quaternion();
+ const bodyQuat = new THREE.Quaternion();
+ const q1 = new THREE.Quaternion();
+ const q2 = new THREE.Quaternion();
+ const leanQuat = new THREE.Quaternion();
+ const noRotation = new THREE.Quaternion();
+
+ const loader = new THREE.GLTFLoader();
+
+ function wrapAngle(a) {
+ return Math.atan2(Math.sin(a), Math.cos(a));
+ }
+
+ function setBoneWorldQuaternion(bone, worldQuat) {
+ bone.parent.getWorldQuaternion(q1).invert();
+ bone.quaternion.copy(q1).multiply(worldQuat);
+ bone.updateMatrixWorld(true);
+ }
+
+ function rotateBoneWorld(bone, deltaQuat) {
+ bone.getWorldQuaternion(q2).premultiply(deltaQuat);
+ setBoneWorldQuaternion(bone, q2);
+ }
+
+ // Bend the elbow (or knee) so shoulder->wrist spans the target distance, aim
+ // the limb at the target, then roll it so the elbow points toward `poleDir`.
+ function solveLimb(upper, lower, end, targetPos, poleDir) {
+ upper.getWorldPosition(shoulder);
+ lower.getWorldPosition(elbow);
+ end.getWorldPosition(wrist);
+ const a = shoulder.distanceTo(elbow);
+ const b = elbow.distanceTo(wrist);
+ const reach = Math.min(Math.max(shoulder.distanceTo(targetPos), Math.abs(a - b) + 1e-3), (a + b) * 0.999);
+
+ // 1. Elbow bend (law of cosines), bending away from the pole.
+ const wantInterior = Math.acos(THREE.MathUtils.clamp((a * a + b * b - reach * reach) / (2 * a * b), -1, 1));
+ const haveInterior = v1.subVectors(shoulder, elbow).angleTo(v2.subVectors(wrist, elbow));
+ const upperDir = v1.subVectors(elbow, shoulder).normalize();
+ const bendDir = v2.copy(poleDir).negate();
+ bendDir.addScaledVector(upperDir, -bendDir.dot(upperDir)).normalize();
+ const axis = v3.crossVectors(upperDir, bendDir).normalize();
+ rotateBoneWorld(lower, q1.setFromAxisAngle(axis, haveInterior - wantInterior));
+
+ // 2. Aim the whole limb at the target.
+ end.getWorldPosition(wrist);
+ v1.subVectors(wrist, shoulder).normalize();
+ v2.subVectors(targetPos, shoulder).normalize();
+ rotateBoneWorld(upper, q1.setFromUnitVectors(v1, v2));
+
+ // 3. Roll around the shoulder->target axis so the elbow faces the pole.
+ const n = v3.subVectors(targetPos, shoulder).normalize();
+ lower.getWorldPosition(elbow);
+ const toElbow = v1.subVectors(elbow, shoulder);
+ toElbow.addScaledVector(n, -toElbow.dot(n));
+ const toPole = v2.copy(poleDir).addScaledVector(n, -poleDir.dot(n));
+ if (toElbow.lengthSq() > 1e-8 && toPole.lengthSq() > 1e-8) {
+ const roll = Math.atan2(n.dot(cross.crossVectors(toElbow, toPole)), toElbow.dot(toPole));
+ rotateBoneWorld(upper, q1.setFromAxisAngle(n, roll));
+ }
+ }
+
+ // No tracked hand (desktop, or controllers set down): let the arm hang.
+ function relaxArm(arm, bodyQuaternion) {
+ arm.upper.getWorldPosition(shoulder);
+ arm.hand.getWorldPosition(wrist);
+ v1.subVectors(wrist, shoulder).normalize();
+ v2.set(arm.side * 0.22, -1, -0.04).normalize().applyQuaternion(bodyQuaternion);
+ rotateBoneWorld(arm.upper, q1.setFromUnitVectors(v1, v2));
+ }
+
+ // VRM 0.x stores morph-target "blend shape" presets (a, blink, ...) by glTF mesh index.
+ function blendShape(gltf, meshesByIndex, preset) {
+ const groups = (gltf.parser.json.extensions.VRM.blendShapeMaster || {}).blendShapeGroups || [];
+ const group = groups.find((g) => g.presetName === preset);
+ if (!group) return [];
+ const binds = [];
+ for (const bind of group.binds || []) {
+ for (const mesh of meshesByIndex.get(bind.mesh) || []) {
+ if (mesh.morphTargetInfluences) binds.push({ mesh, index: bind.index, weight: bind.weight / 100 });
+ }
+ }
+ return binds;
+ }
+
+ function setBlendShape(binds, value) {
+ for (const b of binds) b.mesh.morphTargetInfluences[b.index] = value * b.weight;
+ }
+
+ // Lambert lighting lets the campfire light avatars; a little emissive keeps
+ // them readable in the dark.
+ function toLambert(material) {
+ const lambert = new THREE.MeshLambertMaterial({
+ map: material.map || null,
+ color: material.color ? material.color.clone() : new THREE.Color(0xffffff),
+ emissive: new THREE.Color(0xffffff),
+ emissiveMap: material.map || null,
+ emissiveIntensity: 0.3,
+ transparent: material.transparent,
+ alphaTest: material.alphaTest,
+ side: material.side,
+ });
+ material.dispose();
+ return lambert;
+ }
+
+ // Rest-pose positions of a VRM's humanoid bones, scaled like the drawn
+ // avatar, read from the glTF JSON alone.
+ function restBones(json) {
+ const nodes = json.nodes || [];
+ const parents = new Map();
+ nodes.forEach((n, i) => (n.children || []).forEach((c) => parents.set(c, i)));
+ const world = new Map();
+ const worldMatrix = (i) => {
+ if (!world.has(i)) {
+ const n = nodes[i];
+ const m = n.matrix ? new THREE.Matrix4().fromArray(n.matrix) : new THREE.Matrix4().compose(
+ new THREE.Vector3().fromArray(n.translation || [0, 0, 0]),
+ new THREE.Quaternion().fromArray(n.rotation || [0, 0, 0, 1]),
+ new THREE.Vector3().fromArray(n.scale || [1, 1, 1]),
+ );
+ world.set(i, parents.has(i) ? worldMatrix(parents.get(i)).clone().multiply(m) : m);
+ }
+ return world.get(i);
+ };
+ const pos = (name) => {
+ const hb = json.extensions.VRM.humanoid.humanBones.find((b) => b.bone === name);
+ return hb ? new THREE.Vector3().setFromMatrixPosition(worldMatrix(hb.node)) : null;
+ };
+ const head = pos('head');
+ const left = pos('leftUpperLeg');
+ const right = pos('rightUpperLeg');
+ if (!head || !left || !right) return null;
+ const scale = HEAD_BONE_HEIGHT / Math.max(0.2, head.y);
+ return {
+ head: head.multiplyScalar(scale),
+ hips: left.add(right).multiplyScalar(scale / 2), // between the hip joints
+ };
+ }
+
+ // Where your eyes go when you sit as this avatar, so that its hips land on
+ // the seat: how high above the seat, and how far in front of it.
+ const seatedEyesCache = new Map();
+ function seatedEyes(slug) {
+ if (!AVATAR_SLUG.test(slug || '')) return Promise.resolve(null);
+ if (!seatedEyesCache.has(slug)) {
+ seatedEyesCache.set(slug, fetch(AVATAR_DIR + slug + '.vrm')
+ .then((res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.arrayBuffer();
+ })
+ .then((buf) => {
+ // GLB: 12-byte header, then the JSON chunk's length, type, and bytes.
+ const length = new DataView(buf).getUint32(12, true);
+ const bones = restBones(JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 20, length))));
+ if (!bones) return null;
+ return {
+ height: THREE.MathUtils.clamp(HIP_ABOVE_SEAT + bones.head.y - bones.hips.y - EYES_TO_HEAD_PIVOT.y, 0.55, 1.25),
+ forward: bones.hips.z - bones.head.z + EYES_TO_HEAD_PIVOT.z, // the avatar faces -Z
+ };
+ })
+ .catch((err) => {
+ console.warn(`Couldn't measure avatar "${slug}"`, err);
+ return null;
+ }));
+ }
+ return seatedEyesCache.get(slug);
+ }
+
+ window.VrmBody = { seatedEyes };
+
+ AFRAME.registerComponent('vrm-avatar', {
+ init() {
+ this.root = null;
+ this.slug = '';
+ this.loadToken = 0;
+ this.bodyYaw = null;
+ this.nextBlink = 1 + Math.random() * 3;
+ this.blinkTime = -1;
+ this.seatIndex = -1;
+ this.tuck = 0;
+
+ this.onInfo = (evt) => {
+ this.seatIndex = evt.detail.seat;
+ this.setAvatar(evt.detail.avatar);
+ };
+ this.el.addEventListener('player-info-changed', this.onInfo);
+ const info = this.el.getAttribute('player-info');
+ if (info) {
+ this.seatIndex = info.seat;
+ if (info.avatar) this.setAvatar(info.avatar);
+ }
+ },
+
+ remove() {
+ this.el.removeEventListener('player-info-changed', this.onInfo);
+ this.loadToken++;
+ this.disposeAvatar();
+ },
+
+ setAvatar(slug) {
+ if (!AVATAR_SLUG.test(slug || '') || slug === this.slug) return;
+ this.slug = slug;
+ const token = ++this.loadToken;
+ loader.load(AVATAR_DIR + slug + '.vrm', (gltf) => {
+ if (token !== this.loadToken || !this.el.isConnected) return;
+ this.disposeAvatar();
+ this.buildAvatar(gltf);
+ }, undefined, (err) => console.warn(`Couldn't load avatar "${slug}"`, err));
+ },
+
+ buildAvatar(gltf) {
+ const vrm = gltf.parser.json.extensions && gltf.parser.json.extensions.VRM;
+ if (!vrm) {
+ console.warn(`Avatar "${this.slug}" is not a VRM 0.x file`);
+ return;
+ }
+
+ const nodes = new Map();
+ const meshesByIndex = new Map();
+ gltf.scene.traverse((obj) => {
+ const assoc = gltf.parser.associations.get(obj);
+ if (assoc && assoc.nodes !== undefined) nodes.set(assoc.nodes, obj);
+ if (obj.isMesh) {
+ obj.frustumCulled = false; // skinned bounds don't follow the bones
+ obj.material = Array.isArray(obj.material) ? obj.material.map(toLambert) : toLambert(obj.material);
+ if (assoc && assoc.meshes !== undefined) {
+ if (!meshesByIndex.has(assoc.meshes)) meshesByIndex.set(assoc.meshes, []);
+ meshesByIndex.get(assoc.meshes).push(obj);
+ }
+ }
+ });
+ const bone = (name) => {
+ const hb = vrm.humanoid.humanBones.find((b) => b.bone === name);
+ return hb ? nodes.get(hb.node) : null;
+ };
+
+ const root = new THREE.Group();
+ root.add(gltf.scene);
+ this.el.sceneEl.object3D.add(root);
+ root.updateMatrixWorld(true);
+
+ this.head = bone('head');
+ if (!this.head) {
+ console.warn(`Avatar "${this.slug}" has no head bone`);
+ this.el.sceneEl.object3D.remove(root);
+ return;
+ }
+
+ // Rest pose, measured with the root at the origin (VRM 0.x faces -Z, like the camera).
+ this.headRestPos = this.head.getWorldPosition(new THREE.Vector3());
+ this.headRestQuat = this.head.getWorldQuaternion(new THREE.Quaternion());
+ this.scale = HEAD_BONE_HEIGHT / Math.max(0.2, this.headRestPos.y);
+
+ this.arms = [['left', -1], ['right', 1]].map(([side, sign]) => {
+ const upper = bone(`${side}UpperArm`);
+ const lower = bone(`${side}LowerArm`);
+ const hand = bone(`${side}Hand`);
+ if (!upper || !lower || !hand) return null;
+ return {
+ name: side, side: sign, upper, lower, hand,
+ upperRest: upper.quaternion.clone(),
+ lowerRest: lower.quaternion.clone(),
+ handRestScale: hand.scale.clone(),
+ };
+ }).filter(Boolean);
+
+ this.legs = ['left', 'right'].map((side) => {
+ const upper = bone(`${side}UpperLeg`);
+ const lower = bone(`${side}LowerLeg`);
+ const foot = bone(`${side}Foot`);
+ if (!upper || !lower || !foot) return null;
+ const hip = upper.getWorldPosition(new THREE.Vector3());
+ const knee = lower.getWorldPosition(new THREE.Vector3());
+ const ankle = foot.getWorldPosition(new THREE.Vector3());
+ return {
+ upper, lower, foot,
+ upperRest: upper.quaternion.clone(),
+ lowerRest: lower.quaternion.clone(),
+ thigh: hip.distanceTo(knee),
+ length: hip.distanceTo(knee) + knee.distanceTo(ankle),
+ ankleHeight: ankle.y,
+ };
+ }).filter(Boolean);
+ this.spine = bone('spine');
+ this.spineRest = this.spine ? this.spine.quaternion.clone() : null;
+ const bones = restBones(gltf.parser.json);
+ this.hipsRestPos = bones ? bones.hips.divideScalar(this.scale) : null;
+
+ this.mouth = blendShape(gltf, meshesByIndex, 'a');
+ this.blink = blendShape(gltf, meshesByIndex, 'blink');
+ this.root = root;
+ this.root.visible = false;
+ },
+
+ disposeAvatar() {
+ if (!this.root) return;
+ this.el.sceneEl.object3D.remove(this.root);
+ this.root.traverse((obj) => {
+ if (!obj.isMesh) return;
+ obj.geometry.dispose();
+ for (const m of [].concat(obj.material)) {
+ if (m.map) m.map.dispose();
+ m.dispose();
+ }
+ });
+ this.root = null;
+ },
+
+ // The seat they're on, if any.
+ currentSeat() {
+ if (this.seatIndex < 0 || !this.hipsRestPos) return null;
+ const world = CurrentWorld(this.el.sceneEl);
+ return (world && world.seats[this.seatIndex]) || null;
+ },
+
+ findChild(predicate) {
+ for (const child of this.el.children) {
+ if (predicate(child)) return child;
+ }
+ return null;
+ },
+
+ findHand(side) {
+ return this.findChild((c) => {
+ const hc = c.components && c.components['networked-hand-controls'];
+ return hc && hc.data.hand === side;
+ });
+ },
+
+ tick(time, delta) {
+ if (!this.root) return;
+ const headEl = this.findChild((c) => c.classList.contains('remote-head'));
+ if (!headEl) {
+ this.root.visible = false;
+ return;
+ }
+ this.root.visible = true;
+
+ const dt = Math.min(delta, 100) / 1000;
+ const seat = this.currentSeat();
+
+ // Body: face the fire when seated, otherwise turn toward where they're
+ // looking, lazily.
+ headEl.object3D.getWorldPosition(headPos);
+ headEl.object3D.getWorldQuaternion(headQuat);
+ v1.set(0, 0, -1).applyQuaternion(headQuat);
+ const lookYaw = Math.atan2(-v1.x, -v1.z);
+ if (seat) {
+ this.bodyYaw = seat.yaw;
+ } else {
+ if (this.bodyYaw === null) this.bodyYaw = lookYaw;
+ let twist = wrapAngle(lookYaw - this.bodyYaw);
+ this.bodyYaw += twist * Math.min(1, dt * 2.5);
+ twist = wrapAngle(lookYaw - this.bodyYaw);
+ if (Math.abs(twist) > MAX_BODY_TWIST) this.bodyYaw += twist - Math.sign(twist) * MAX_BODY_TWIST;
+ }
+ bodyQuat.setFromAxisAngle(UP, this.bodyYaw);
+
+ // Hang the avatar from the head pivot, or sit its hips on the seat.
+ pivot.copy(EYES_TO_HEAD_PIVOT).applyQuaternion(headQuat).add(headPos);
+ if (seat) {
+ v3.copy(this.hipsRestPos).multiplyScalar(this.scale).applyQuaternion(bodyQuat);
+ this.root.position.set(seat.x, seat.y + HIP_ABOVE_SEAT, seat.z).sub(v3);
+ } else {
+ v3.copy(this.headRestPos).multiplyScalar(this.scale).applyQuaternion(bodyQuat);
+ this.root.position.subVectors(pivot, v3);
+ }
+ this.root.quaternion.copy(bodyQuat);
+ this.root.scale.setScalar(this.scale);
+ for (const limb of this.arms.concat(this.legs)) {
+ limb.upper.quaternion.copy(limb.upperRest);
+ limb.lower.quaternion.copy(limb.lowerRest);
+ }
+ if (this.spine) this.spine.quaternion.copy(this.spineRest);
+ this.root.updateMatrixWorld(true);
+
+ // Seated: lean the back so the head goes where their head is.
+ if (seat && this.spine) {
+ this.spine.getWorldPosition(v1);
+ this.head.getWorldPosition(v2).sub(v1).normalize();
+ v3.subVectors(pivot, v1).normalize();
+ leanQuat.setFromUnitVectors(v2, v3);
+ const lean = 2 * Math.acos(Math.min(1, Math.abs(leanQuat.w)));
+ if (lean > MAX_LEAN) leanQuat.slerp(noRotation, 1 - MAX_LEAN / lean);
+ rotateBoneWorld(this.spine, leanQuat);
+ }
+
+ // Head: same rotation as their real head.
+ setBoneWorldQuaternion(this.head, q2.copy(headQuat).multiply(this.headRestQuat));
+
+ // Arms: reach for tracked hands, otherwise hang.
+ for (const arm of this.arms) {
+ const handEl = this.findHand(arm.name);
+ if (handEl && handEl.object3D.visible) {
+ handEl.object3D.getWorldQuaternion(q2);
+ handEl.object3D.getWorldPosition(target);
+ target.add(v1.copy(GRIP_TO_WRIST).applyQuaternion(q2));
+ pole.set(arm.side * 0.5, -1, 0.6).normalize().applyQuaternion(bodyQuat);
+ solveLimb(arm.upper, arm.lower, arm.hand, target, pole);
+ arm.hand.scale.setScalar(0.001); // their networked hand model shows instead
+ } else {
+ relaxArm(arm, bodyQuat);
+ arm.hand.scale.copy(arm.handRestScale);
+ }
+ arm.hand.updateMatrixWorld(true);
+ }
+
+ // Legs: feet on the ground in front of the seat, knees tucked up while
+ // they're in the air (their rig only rises off the ground in a jump),
+ // otherwise straight down.
+ const world = CurrentWorld(this.el.sceneEl);
+ const rigPos = this.el.object3D.position;
+ const floor = world && world.floorAt ? world.floorAt(seat ? seat.x : rigPos.x, seat ? seat.z : rigPos.z) : 0;
+ const airborne = !seat && rigPos.y > floor + 0.02;
+ this.tuck += ((airborne ? 1 : 0) - this.tuck) * Math.min(1, dt * 12);
+ if (seat || this.tuck > 0.01) {
+ forward.set(0, 0, -1).applyQuaternion(bodyQuat);
+ pole.set(0, 0.3, -1).normalize().applyQuaternion(bodyQuat);
+ for (const leg of this.legs) {
+ leg.foot.getWorldPosition(target);
+ if (seat) {
+ target.addScaledVector(forward, leg.thigh * this.scale);
+ target.y = floor + leg.ankleHeight * this.scale;
+ } else {
+ target.addScaledVector(forward, 0.2 * leg.length * this.scale * this.tuck);
+ target.y += 0.35 * leg.length * this.scale * this.tuck;
+ }
+ solveLimb(leg.upper, leg.lower, leg.foot, target, pole);
+ }
+ }
+
+ // Face: talk and blink.
+ const voice = headEl.components['voice-level'];
+ setBlendShape(this.mouth, voice ? voice.level : 0);
+ const t = time / 1000;
+ if (this.blinkTime < 0 && t > this.nextBlink) this.blinkTime = t;
+ if (this.blinkTime >= 0) {
+ const p = (t - this.blinkTime) / 0.16;
+ setBlendShape(this.blink, p < 1 ? Math.sin(p * Math.PI) : 0);
+ if (p >= 1) {
+ this.blinkTime = -1;
+ this.nextBlink = t + 2 + Math.random() * 4;
+ }
+ }
+ },
+ });
+})();
diff --git a/public/js/components/wield.js b/public/js/components/wield.js
new file mode 100644
index 0000000..864ff8d
--- /dev/null
+++ b/public/js/components/wield.js
@@ -0,0 +1,629 @@
+/* global AFRAME, THREE, NAF, CurrentWorld */
+// Picking things up and drawing in the air with them, like the pens in
+// VRChat. A world offers `tools` (see world.js), each lying along its own +x.
+// In VR, squeeze grip near one and it goes into your hand by its grip; let go
+// and it floats where you left it. Pull the trigger while holding it to draw
+// from its tip. With an empty hand, touch a line and pull the trigger to erase
+// it. On desktop, F picks up (or lets go of) the one you're looking at, holding
+// the mouse button draws, and Backspace erases your last line. In a shared room
+// everyone sees who holds what and every line, and newcomers are caught up.
+// A world can also offer `touchables` (the temple's Veil): reach an empty hand
+// to one, a label above your hand says what the trigger will do, and the
+// trigger does it.
+(function () {
+ const GRAB_REACH = 0.1; // hand to tool (m)
+ const LOOK_REACH = 2.2; // desktop: head to tool
+ const LOOK_AIM = 0.35; // desktop: how far off your line of sight it can be
+ const ERASE_REACH = 0.05; // hand to line
+ const TOUCH_REACH = 0.2; // hand to a touchable
+ const STEP = 0.003; // a new point every 3 mm of tip travel
+ const RADIUS = 0.004;
+ const SIDES = 6;
+ const MAX_POINTS = 1024; // per line; a longer stroke carries on in a new line
+ const MAX_LINES = 300;
+ const MAX_TOTAL = 100000; // points in all lines; the oldest lines go first
+ const POSE_MS = 66; // how often a held tool's pose is sent
+ const INK_MS = 100; // how often new points are sent
+ const SYNC_POINTS = 3000; // points per catch-up message
+ const SMOOTH = 0.05; // seconds to ease toward someone else's pose
+
+ // In the WebXR grip space a held object runs along -Z out of the fist and
+ // the palm faces ±X, so the tool's +x goes to -Z and its flat faces the palm.
+ const HOLD = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(
+ new THREE.Vector3(0, 0, -1), new THREE.Vector3(-1, 0, 0), new THREE.Vector3(0, 1, 0)));
+ // On desktop it's held low and to the right, its tip toward the middle of your view.
+ const DESK_POS = new THREE.Vector3(0.16, -0.2, -0.3);
+ const DESK_QUAT = new THREE.Quaternion().setFromEuler(new THREE.Euler(0.35, 0.35, 0, 'YXZ')).multiply(HOLD);
+
+ const v1 = new THREE.Vector3();
+ const v2 = new THREE.Vector3();
+ const v3 = new THREE.Vector3();
+ const side = new THREE.Vector3();
+ const up = new THREE.Vector3();
+ const q1 = new THREE.Quaternion();
+
+ const online = () => typeof NAF !== 'undefined' && NAF.connection && NAF.connection.isConnected();
+ const finite = (a, n) => Array.isArray(a) && a.length === n && a.every((x) => Number.isFinite(x) && Math.abs(x) < 1000);
+ const validId = (id) => typeof id === 'string' && id.length > 0 && id.length <= 40;
+ const validColor = (c) => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c);
+ const round = (x) => Math.round(x * 1e4) / 1e4;
+
+ // Distance from p to the segment a–b.
+ function toSegment(p, a, b) {
+ v3.subVectors(b, a);
+ const t = THREE.MathUtils.clamp(v3.dot(v1.subVectors(p, a)) / v3.lengthSq(), 0, 1);
+ return p.distanceTo(v1.copy(a).addScaledVector(v3, t));
+ }
+
+ // Every line has the same triangles, so they share one index.
+ let sharedIndex = null;
+ function lineIndex() {
+ if (sharedIndex) return sharedIndex;
+ const index = new Uint16Array((MAX_POINTS - 1) * SIDES * 6);
+ let k = 0;
+ for (let seg = 0; seg < MAX_POINTS - 1; seg++) {
+ for (let s = 0; s < SIDES; s++) {
+ const a = seg * SIDES + s;
+ const b = seg * SIDES + ((s + 1) % SIDES);
+ index.set([a, b, a + SIDES, b, b + SIDES, a + SIDES], k);
+ k += 6;
+ }
+ }
+ sharedIndex = new THREE.BufferAttribute(index, 1);
+ return sharedIndex;
+ }
+
+ const materials = new Map();
+ function inkMaterial(color) {
+ if (!materials.has(color)) {
+ materials.set(color, new THREE.MeshBasicMaterial({ color, toneMapped: false, fog: false, side: THREE.DoubleSide }));
+ }
+ return materials.get(color);
+ }
+
+ // One stroke: a thin tube that grows a ring at a time.
+ class Line {
+ constructor(id, color) {
+ this.id = id;
+ this.color = color;
+ this.points = []; // x, y, z, …
+ this.count = 0;
+ this.normal = new THREE.Vector3();
+ this.tangent = new THREE.Vector3();
+ const geometry = new THREE.BufferGeometry();
+ this.position = new THREE.BufferAttribute(new Float32Array(MAX_POINTS * SIDES * 3), 3);
+ this.position.setUsage(THREE.DynamicDrawUsage);
+ geometry.setAttribute('position', this.position);
+ geometry.setIndex(lineIndex());
+ geometry.setDrawRange(0, 0);
+ this.mesh = new THREE.Mesh(geometry, inkMaterial(color));
+ this.mesh.frustumCulled = false;
+ }
+
+ get full() {
+ return this.count >= MAX_POINTS;
+ }
+
+ last(out) {
+ const p = this.points;
+ return out.set(p[p.length - 3], p[p.length - 2], p[p.length - 1]);
+ }
+
+ push(x, y, z) {
+ if (this.full) return;
+ const i = this.count;
+ this.points.push(x, y, z);
+ this.count++;
+ if (i === 0) return;
+ const p = this.points;
+ const t = this.tangent.set(x - p[3 * i - 3], y - p[3 * i - 2], z - p[3 * i - 1]);
+ if (t.lengthSq() < 1e-12) t.set(1, 0, 0);
+ t.normalize();
+ // Carry the ring's orientation along the line so the tube doesn't twist.
+ const n = this.normal;
+ if (i > 1) n.addScaledVector(t, -n.dot(t));
+ if (i === 1 || n.lengthSq() < 1e-6) {
+ up.set(0, 1, 0);
+ if (Math.abs(t.y) > 0.9) up.set(1, 0, 0);
+ n.copy(up).addScaledVector(t, -up.dot(t));
+ }
+ n.normalize();
+ if (i === 1) this.ring(0);
+ this.ring(i);
+ this.mesh.geometry.setDrawRange(0, (this.count - 1) * SIDES * 6);
+ this.position.needsUpdate = true;
+ }
+
+ ring(i) {
+ const p = this.points;
+ const b = side.crossVectors(this.tangent, this.normal);
+ const a = this.position.array;
+ for (let s = 0; s < SIDES; s++) {
+ const angle = (s / SIDES) * Math.PI * 2;
+ const c = Math.cos(angle) * RADIUS;
+ const d = Math.sin(angle) * RADIUS;
+ const o = (i * SIDES + s) * 3;
+ a[o] = p[3 * i] + this.normal.x * c + b.x * d;
+ a[o + 1] = p[3 * i + 1] + this.normal.y * c + b.y * d;
+ a[o + 2] = p[3 * i + 2] + this.normal.z * c + b.z * d;
+ }
+ }
+
+ near(pos, reach) {
+ const p = this.points;
+ const r2 = reach * reach;
+ for (let k = 0; k < p.length; k += 3) {
+ const dx = p[k] - pos.x;
+ const dy = p[k + 1] - pos.y;
+ const dz = p[k + 2] - pos.z;
+ if (dx * dx + dy * dy + dz * dz < r2) return true;
+ }
+ return false;
+ }
+
+ dispose() {
+ this.mesh.removeFromParent();
+ this.mesh.geometry.setIndex(null); // the index is shared; keep it
+ this.mesh.geometry.dispose();
+ }
+ }
+
+ function buzz(handEl, strength, ms) {
+ try {
+ const tracked = handEl.components['tracked-controls'];
+ const pad = tracked && tracked.controller && tracked.controller.gamepad;
+ const motor = pad && pad.hapticActuators && pad.hapticActuators[0];
+ if (motor && motor.pulse) Promise.resolve(motor.pulse(strength, ms)).catch(() => {});
+ } catch (e) {
+ // No haptics on this controller.
+ }
+ }
+
+ AFRAME.registerComponent('wield', {
+ schema: {
+ head: { type: 'selector', default: '#player' },
+ leftHand: { type: 'selector', default: '#left-hand' },
+ rightHand: { type: 'selector', default: '#right-hand' },
+ },
+
+ init() {
+ this.tools = null; // the world's, once it has loaded
+ this.touchables = [];
+ this.lines = new Map(); // id → Line, oldest first
+ this.erased = new Set(); // ids of erased lines, so a late catch-up doesn't bring them back
+ this.total = 0;
+ this.prefix = Math.random().toString(36).slice(2, 8);
+ this.serial = 0;
+ this.lastPose = 0;
+ this.group = new THREE.Group();
+ this.el.sceneEl.object3D.add(this.group);
+
+ const hand = (el, vr) => ({ el, vr, tool: null, line: null, sent: 0, sentAt: 0, trigger: false, erasing: false, touching: null });
+ this.hands = [hand(this.data.leftHand, true), hand(this.data.rightHand, true)];
+ this.desk = hand(this.data.head, false);
+ for (const h of this.hands) h.prompt = this.makePrompt();
+
+ this.listeners = [];
+ const on = (target, type, fn) => {
+ target.addEventListener(type, fn);
+ this.listeners.push([target, type, fn]);
+ };
+
+ for (const h of this.hands) {
+ on(h.el, 'gripdown', () => this.grabNear(h));
+ on(h.el, 'gripup', () => this.release(h));
+ on(h.el, 'triggerdown', () => this.pull(h, true));
+ on(h.el, 'triggerup', () => this.pull(h, false));
+ on(h.el, 'controllerdisconnected', () => this.release(h));
+ }
+ on(this.el.sceneEl, 'enter-vr', () => this.release(this.desk));
+ on(this.el.sceneEl, 'exit-vr', () => this.hands.forEach((h) => this.release(h)));
+
+ const typing = (evt) => evt.target.closest && evt.target.closest('input, textarea');
+ on(window, 'keydown', (evt) => {
+ if (evt.repeat || typing(evt) || this.el.sceneEl.is('vr-mode')) return;
+ if (evt.code === 'KeyF') {
+ if (this.desk.tool) this.release(this.desk);
+ else this.grabLooked();
+ }
+ if (evt.code === 'Backspace') {
+ evt.preventDefault();
+ this.eraseLast();
+ }
+ });
+ on(window, 'mousedown', (evt) => {
+ if (evt.button === 0 && document.pointerLockElement === this.el.sceneEl.canvas) this.pull(this.desk, true);
+ });
+ on(window, 'mouseup', (evt) => {
+ if (evt.button === 0) this.pull(this.desk, false);
+ });
+ on(window, 'blur', () => this.pull(this.desk, false));
+
+ if (typeof NAF !== 'undefined' && NAF.connection) {
+ NAF.connection.subscribeToDataChannel('wield', (from, type, msg) => this.onPose(msg));
+ NAF.connection.subscribeToDataChannel('ink', (from, type, msg) => this.onInk(msg));
+ on(document.body, 'clientConnected', (evt) => this.catchUp(evt.detail.clientId));
+ on(document.body, 'clientDisconnected', (evt) => {
+ for (const tool of this.tools || []) if (tool.by === evt.detail.clientId) tool.by = null;
+ });
+ }
+ },
+
+ remove() {
+ for (const [target, type, fn] of this.listeners) target.removeEventListener(type, fn);
+ this.group.removeFromParent();
+ },
+
+ // A label that floats above your hand while it's near something to touch.
+ makePrompt() {
+ const el = document.createElement('a-entity');
+ el.setAttribute('geometry', { primitive: 'plane', width: 0.26, height: 0.045 });
+ el.setAttribute('material', { shader: 'flat', color: '#000000', opacity: 0.6, transparent: true });
+ el.setAttribute('text', { value: '', align: 'center', width: 0.4, wrapCount: 28, color: '#ffe2c4' });
+ el.object3D.visible = false;
+ this.el.sceneEl.appendChild(el);
+ return el;
+ },
+
+ // The world is added after the rig, so look for its tools until they're there.
+ findTools() {
+ const world = typeof CurrentWorld === 'function' ? CurrentWorld(this.el.sceneEl) : null;
+ if (!world) return;
+ this.touchables = world.touchables || [];
+ this.tools = (world.tools || []).map((spec) => {
+ const mark = new THREE.Mesh(new THREE.SphereGeometry(0.005, 10, 8), inkMaterial(spec.ink));
+ mark.position.copy(spec.tip);
+ mark.visible = false;
+ spec.object.add(mark);
+ return { ...spec, mark, holder: null, by: null, v: 0, target: null };
+ });
+ },
+
+ // ---- holding -------------------------------------------------------------
+
+ myId() {
+ return online() ? NAF.clientId : 'me';
+ },
+
+ // Who holds it now: you, someone else, or no one (null).
+ holderId(tool) {
+ return tool.holder ? this.myId() : tool.by;
+ },
+
+ worldEnds(tool) {
+ tool.object.updateWorldMatrix(true, false);
+ return [tool.object.localToWorld(new THREE.Vector3().copy(tool.butt)), tool.object.localToWorld(new THREE.Vector3().copy(tool.tip))];
+ },
+
+ grabNear(hand) {
+ if (!this.tools || hand.tool) return;
+ const at = hand.el.object3D.getWorldPosition(new THREE.Vector3());
+ let best = null;
+ let bestDistance = GRAB_REACH;
+ for (const tool of this.tools) {
+ if (tool.by) continue; // someone else has it
+ const [a, b] = this.worldEnds(tool);
+ const d = toSegment(at, a, b);
+ if (d < bestDistance) {
+ best = tool;
+ bestDistance = d;
+ }
+ }
+ if (best) this.grab(hand, best);
+ },
+
+ // Desktop: the tool nearest your line of sight, within reach.
+ grabLooked() {
+ if (!this.tools) return;
+ const head = this.data.head.object3D;
+ const eye = head.getWorldPosition(new THREE.Vector3());
+ const ahead = new THREE.Vector3(0, 0, -1).applyQuaternion(head.getWorldQuaternion(q1));
+ let best = null;
+ let bestAim = LOOK_AIM;
+ let heldNearby = null;
+ for (const tool of this.tools) {
+ const [a, b] = this.worldEnds(tool);
+ for (let i = 0; i <= 8; i++) {
+ const p = v2.lerpVectors(a, b, i / 8).sub(eye);
+ const along = p.dot(ahead);
+ if (along < 0 || p.length() > LOOK_REACH) continue;
+ const aim = p.addScaledVector(ahead, -along).length();
+ if (aim >= bestAim) continue;
+ if (tool.by) {
+ heldNearby = tool;
+ continue;
+ }
+ best = tool;
+ bestAim = aim;
+ }
+ }
+ if (best) this.grab(this.desk, best);
+ else this.el.emit('wield-failed', { reason: heldNearby ? 'held' : 'far', label: heldNearby && heldNearby.label }, false);
+ },
+
+ grab(hand, tool) {
+ if (tool.holder && tool.holder !== hand) {
+ // From your other hand.
+ this.stopDrawing(tool.holder);
+ tool.holder.tool = null;
+ }
+ const obj = tool.object;
+ hand.el.object3D.add(obj);
+ obj.quaternion.copy(hand.vr ? HOLD : DESK_QUAT);
+ obj.position.copy(tool.grip).applyQuaternion(obj.quaternion).negate();
+ if (!hand.vr) obj.position.add(DESK_POS);
+ tool.holder = hand;
+ tool.by = null;
+ tool.target = null;
+ hand.tool = tool;
+ tool.v++;
+ this.sendPose(tool, true);
+ if (hand.vr) buzz(hand.el, 0.4, 40);
+ hand.erasing = false;
+ if (hand.trigger) this.startLine(hand); // trigger already held
+ },
+
+ // Lets go: it stays exactly where it was, floating.
+ release(hand) {
+ const tool = hand.tool;
+ if (!tool) return;
+ this.stopDrawing(hand);
+ this.el.sceneEl.object3D.attach(tool.object);
+ tool.holder = null;
+ hand.tool = null;
+ tool.v++;
+ this.sendPose(tool, true);
+ },
+
+ sendPose(tool, guaranteed, to) {
+ if (!online()) return;
+ const obj = tool.object;
+ obj.updateWorldMatrix(true, false);
+ const p = obj.getWorldPosition(v1);
+ const q = obj.getWorldQuaternion(q1);
+ const msg = {
+ t: tool.name, by: this.holderId(tool), v: tool.v,
+ p: [round(p.x), round(p.y), round(p.z)], q: [round(q.x), round(q.y), round(q.z), round(q.w)],
+ };
+ if (to) NAF.connection.sendDataGuaranteed(to, 'wield', msg);
+ else if (guaranteed) NAF.connection.broadcastDataGuaranteed('wield', msg);
+ else NAF.connection.broadcastData('wield', msg);
+ },
+
+ onPose(msg) {
+ if (!this.tools) this.findTools();
+ if (!this.tools || !msg) return;
+ const tool = this.tools.find((t) => t.name === msg.t);
+ if (!tool || !Number.isInteger(msg.v) || !finite(msg.p, 3) || !finite(msg.q, 4)) return;
+ const by = typeof msg.by === 'string' ? msg.by : null;
+ const current = this.holderId(tool);
+ if (msg.v < tool.v) return;
+ // Two people grabbed it at once: the lower id keeps it, the same for everyone.
+ if (msg.v === tool.v && by !== current && (by || '￿') > (current || '￿')) return;
+ tool.v = msg.v;
+ if (tool.holder && by !== this.myId()) {
+ const hand = tool.holder;
+ this.stopDrawing(hand);
+ hand.tool = null;
+ tool.holder = null;
+ }
+ if (tool.holder) return;
+ tool.by = by;
+ if (tool.object.parent !== this.el.sceneEl.object3D) this.el.sceneEl.object3D.attach(tool.object);
+ const q = new THREE.Quaternion().fromArray(msg.q);
+ if (q.lengthSq() < 1e-6) return;
+ tool.target = { p: new THREE.Vector3().fromArray(msg.p), q: q.normalize() };
+ },
+
+ // ---- drawing and erasing -------------------------------------------------
+
+ // Trigger (or mouse button) down or up: draw with what you hold, use what
+ // an empty hand is touching, or else erase with it.
+ pull(hand, down) {
+ if (hand.trigger === down) return;
+ hand.trigger = down;
+ if (!down) {
+ hand.erasing = false;
+ this.stopDrawing(hand);
+ } else if (hand.tool) {
+ this.startLine(hand);
+ } else if (hand.touching) {
+ hand.touching.use();
+ buzz(hand.el, 0.5, 60);
+ } else {
+ hand.erasing = hand.vr;
+ }
+ },
+
+ // Which touchable an empty hand is near, and its label above the hand.
+ feel(hand) {
+ let near = null;
+ if (!hand.tool && this.el.sceneEl.is('vr-mode') && hand.el.object3D.visible) {
+ const at = hand.el.object3D.getWorldPosition(v1);
+ let best = TOUCH_REACH;
+ for (const t of this.touchables) {
+ const d = t.distance(at);
+ if (d < best) {
+ near = t;
+ best = d;
+ }
+ }
+ }
+ if (near && near !== hand.touching) buzz(hand.el, 0.15, 20);
+ hand.touching = near;
+ const prompt = hand.prompt.object3D;
+ prompt.visible = Boolean(near);
+ if (!near) return;
+ const text = `Trigger: ${near.label()}`;
+ if (hand.prompt.getAttribute('text').value !== text) hand.prompt.setAttribute('text', 'value', text);
+ prompt.position.copy(v1).y += 0.1;
+ prompt.lookAt(this.data.head.object3D.getWorldPosition(v2));
+ },
+
+ startLine(hand) {
+ const line = this.addLine(`${this.prefix}.${this.serial++}`, hand.tool.ink);
+ hand.line = line;
+ hand.sent = 0;
+ hand.sentAt = 0;
+ },
+
+ stopDrawing(hand) {
+ const line = hand.line;
+ if (!line) return;
+ this.sendInk(hand);
+ hand.line = null;
+ if (line.count < 2) this.erase([line.id], hand.sent > 0);
+ },
+
+ addLine(id, color) {
+ const line = new Line(id, color);
+ this.lines.set(id, line);
+ this.group.add(line.mesh);
+ return line;
+ },
+
+ addPoints(line, x, y, z) {
+ line.push(x, y, z);
+ this.total++;
+ },
+
+ // The oldest lines go once there are too many, here and for everyone else
+ // by the same rule.
+ trim() {
+ for (const line of this.lines.values()) {
+ if (this.lines.size <= MAX_LINES && this.total <= MAX_TOTAL) return;
+ if (this.hands.concat(this.desk).some((h) => h.line === line)) continue;
+ this.erase([line.id], false);
+ }
+ },
+
+ erase(ids, share) {
+ for (const id of ids) {
+ this.erased.add(id);
+ const line = this.lines.get(id);
+ if (!line) continue;
+ this.total -= line.count;
+ line.dispose();
+ this.lines.delete(id);
+ }
+ if (this.erased.size > 5000) this.erased.delete(this.erased.values().next().value);
+ if (share && online()) NAF.connection.broadcastDataGuaranteed('ink', { op: 'erase', ids });
+ },
+
+ eraseLast() {
+ const mine = [...this.lines.values()].reverse().find((l) => l.id.startsWith(`${this.prefix}.`) && l !== this.desk.line);
+ if (mine) this.erase([mine.id], true);
+ },
+
+ sendInk(hand) {
+ const line = hand.line;
+ if (!line || hand.sent >= line.count) return;
+ if (online()) {
+ const pts = line.points.slice(hand.sent * 3).map((x) => Math.round(x * 1000));
+ NAF.connection.broadcastDataGuaranteed('ink', { op: 'add', id: line.id, c: line.color, n: hand.sent, pts });
+ }
+ hand.sent = line.count;
+ },
+
+ // Points (in mm) for a line, from someone else; `n` is how many it already had.
+ takePoints(id, color, n, pts) {
+ if (!validId(id) || this.erased.has(id) || !validColor(color) || !Number.isInteger(n) || n < 0) return;
+ if (!Array.isArray(pts) || pts.length % 3 || pts.length > MAX_POINTS * 3 || !pts.every((x) => Number.isFinite(x) && Math.abs(x) < 1e6)) return;
+ const line = this.lines.get(id) || this.addLine(id, color);
+ for (let k = Math.max(0, line.count - n) * 3; k < pts.length && !line.full; k += 3) {
+ this.addPoints(line, pts[k] / 1000, pts[k + 1] / 1000, pts[k + 2] / 1000);
+ }
+ this.trim();
+ },
+
+ onInk(msg) {
+ if (!msg) return;
+ if (msg.op === 'add') this.takePoints(msg.id, msg.c, msg.n, msg.pts);
+ if (msg.op === 'erase' && Array.isArray(msg.ids)) this.erase(msg.ids.filter(validId).slice(0, 50), false);
+ if (msg.op === 'sync') {
+ if (Array.isArray(msg.erased)) this.erase(msg.erased.filter(validId).slice(0, 1000), false);
+ if (Array.isArray(msg.lines)) for (const l of msg.lines.slice(0, MAX_LINES)) if (l) this.takePoints(l.id, l.c, 0, l.pts);
+ }
+ },
+
+ // Tells someone who just arrived where the tools are and what's drawn.
+ // Everyone here does this; lines they already have are skipped.
+ catchUp(clientId) {
+ if (!online()) return;
+ for (const tool of this.tools || []) if (tool.v > 0) this.sendPose(tool, true, clientId);
+ let batch = [];
+ let size = 0;
+ const send = (erased) => {
+ NAF.connection.sendDataGuaranteed(clientId, 'ink', { op: 'sync', lines: batch, erased });
+ batch = [];
+ size = 0;
+ };
+ for (const line of this.lines.values()) {
+ batch.push({ id: line.id, c: line.color, pts: line.points.map((x) => Math.round(x * 1000)) });
+ size += line.count;
+ if (size >= SYNC_POINTS) send([]);
+ }
+ send([...this.erased].slice(-1000));
+ },
+
+ // ---- every frame -----------------------------------------------------------
+
+ tick(time, delta) {
+ if (!this.tools) {
+ this.findTools();
+ if (!this.tools) return;
+ }
+ const dt = Math.min(delta, 100) / 1000;
+ const ease = 1 - Math.exp(-dt / SMOOTH);
+
+ for (const tool of this.tools) {
+ tool.mark.visible = Boolean(tool.holder || tool.by);
+ if (!tool.target || tool.holder) continue;
+ const obj = tool.object;
+ obj.position.lerp(tool.target.p, ease);
+ obj.quaternion.slerp(tool.target.q, ease);
+ if (!tool.by && obj.position.distanceToSquared(tool.target.p) < 1e-8) tool.target = null;
+ }
+
+ for (const hand of this.hands) this.feel(hand);
+ for (const hand of this.hands.concat(this.desk)) {
+ if (hand.line) this.draw(hand, time);
+ if (hand.erasing) {
+ const at = hand.el.object3D.getWorldPosition(v1);
+ const hit = [...this.lines.values()].filter((l) => l.near(at, ERASE_REACH)).map((l) => l.id);
+ if (hit.length) {
+ this.erase(hit, true);
+ buzz(hand.el, 0.25, 25);
+ }
+ }
+ }
+
+ if (time - this.lastPose >= POSE_MS) {
+ this.lastPose = time;
+ for (const tool of this.tools) if (tool.holder) this.sendPose(tool, false);
+ }
+ },
+
+ draw(hand, time) {
+ const tool = hand.tool;
+ tool.object.updateWorldMatrix(true, false);
+ const tip = tool.object.localToWorld(v2.copy(tool.tip));
+ let line = hand.line;
+ if (line.count && line.last(v3).distanceTo(tip) < STEP) return;
+ if (line.full) {
+ // Carry on in a new line from where this one ends.
+ const end = line.last(new THREE.Vector3());
+ this.sendInk(hand);
+ this.startLine(hand);
+ line = hand.line;
+ this.addPoints(line, end.x, end.y, end.z);
+ }
+ this.addPoints(line, tip.x, tip.y, tip.z);
+ this.trim();
+ if (time - hand.sentAt >= INK_MS) {
+ hand.sentAt = time;
+ this.sendInk(hand);
+ }
+ },
+ });
+})();