summaryrefslogtreecommitdiff
path: root/public/js/components/locomotion.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/js/components/locomotion.js')
-rw-r--r--public/js/components/locomotion.js312
1 files changed, 312 insertions, 0 deletions
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);
+ }
+ },
+ });
+})();