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/campsite.js | |
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/campsite.js')
| -rw-r--r-- | public/js/components/campsite.js | 479 |
1 files changed, 479 insertions, 0 deletions
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)); + } + }, + }); +})(); |