/* 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); } }, }); })();