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