diff options
| author | skylar <me@casper> | 2026-11-01 05:43:24 -0800 |
|---|---|---|
| committer | skylar <me@casper> | 2026-11-01 05:43:24 -0800 |
| commit | e69ad01e700e80fa959dfee08cd556ca0d777ffd (patch) | |
| tree | 645fc9db2142e87a5ef302733a77ba2fec848d88 /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.js | 130 | ||||
| -rw-r--r-- | public/js/components/balcony.js | 592 | ||||
| -rw-r--r-- | public/js/components/cabin.js | 1064 | ||||
| -rw-r--r-- | public/js/components/campsite.js | 479 | ||||
| -rw-r--r-- | public/js/components/chess.js | 742 | ||||
| -rw-r--r-- | public/js/components/hand-tilt.js | 37 | ||||
| -rw-r--r-- | public/js/components/locomotion.js | 312 | ||||
| -rw-r--r-- | public/js/components/nametag.js | 69 | ||||
| -rw-r--r-- | public/js/components/player-info.js | 16 | ||||
| -rw-r--r-- | public/js/components/screens.js | 483 | ||||
| -rw-r--r-- | public/js/components/sky.js | 498 | ||||
| -rw-r--r-- | public/js/components/temple.js | 1999 | ||||
| -rw-r--r-- | public/js/components/testroom.js | 72 | ||||
| -rw-r--r-- | public/js/components/voice-level.js | 39 | ||||
| -rw-r--r-- | public/js/components/vrm-avatar.js | 486 | ||||
| -rw-r--r-- | public/js/components/wield.js | 629 |
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); + } + }, + }); +})(); |