From e2ee2b94b3b826182a8164d28910b334b21e3949 Mon Sep 17 00:00:00 2001 From: Cailean Finn Date: Thu, 9 Oct 2025 16:05:30 +0100 Subject: [PATCH] VR supported added, and teleport done --- js/Player.js | 286 +++++++++++++++++++++++++++++++++++++++++++++++---- js/main.js | 22 ++-- server.js | 2 +- 3 files changed, 280 insertions(+), 30 deletions(-) diff --git a/js/Player.js b/js/Player.js index b265e3e..5af849c 100644 --- a/js/Player.js +++ b/js/Player.js @@ -3,7 +3,7 @@ import { RigidBodyDesc, ColliderDesc } from '@dimforge/rapier3d-compat'; import { AudioContent, VideoContent } from './Content'; export class Player { - constructor(rapierWorld, spawnPosition = new THREE.Vector3(0, 1, 0), itemList) { + constructor(rapierWorld, renderer, scene, spawnPosition = new THREE.Vector3(0, 1, 0), itemList) { this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); this.position = spawnPosition.clone(); this.rotation = new THREE.Euler(0, 0, 0, 'YXZ'); @@ -13,6 +13,9 @@ export class Player { this.name = null; this.audioListener = new THREE.AudioListener(); + this.renderer = renderer; + this.scene = scene; + this.rapierWorld = rapierWorld; this.rigibody = null; this.collider = null; @@ -41,6 +44,19 @@ export class Player { this.enableInput = false; + /* VR Stuff */ + this.vrControllers = []; + this.vrGamepads = [null, null]; + this.teleArc = null; + this.teleMarker = null; + this.teleporting = false; + this.teleportTarget = new THREE.Vector3(); + this.playerRig = new THREE.Group(); + + this.teleportDistanceFactor = 1.0; // Default distance multiplier + this.minTeleportDistanceFactor = 0.1; // Minimum distance multiplier + this.maxTeleportDistanceFactor = 1.5; // Maximum distance multiplier + this._init(); this._setupInput(); this._bindEvents(); @@ -113,6 +129,8 @@ export class Player { // Attach audio listener to the camera/player this.camera.add(this.audioListener); + + this.playerRig.add(this.camera); } _setupInput() { @@ -155,6 +173,205 @@ export class Player { this.isDrawing = false; } + _setupVR() { + const markerGeometry = new THREE.CircleGeometry(0.5, 32); + markerGeometry.rotateX(-Math.PI / 2); + const markerMat = new THREE.MeshBasicMaterial({color: 0x00ff00, transparent: false, opacity: 0.5}); + this.teleMarker = new THREE.Mesh(markerGeometry, markerMat); + this.teleMarker.visible = false; + this.playerRig.add(this.teleMarker); + + // Setup teleport arc + const arcMaterial = new THREE.LineBasicMaterial({ color: 0x00ff00, linewidth: 2 }); + const arcGeometry = new THREE.BufferGeometry(); + this.teleArc = new THREE.Line(arcGeometry, arcMaterial); + this.teleArc.visible = false; + this.playerRig.add(this.teleArc); + + // Controller Setup + for (let i = 0; i < 2; i++) { + const controller = this.renderer.xr.getController(i); + this.playerRig.add(controller); + this.vrControllers.push(controller); + + // --- NEW: Add the 'connected' listener to get the Gamepad object --- + controller.addEventListener('connected', (event) => { + // The Gamepad object is in event.data.gamepad + this.vrGamepads[i] = event.data.gamepad; + console.log(`Controller ${i} connected, Gamepad stored.`); + }); + + // --- OPTIONAL: Handle disconnection --- + controller.addEventListener('disconnected', () => { + this.vrGamepads[i] = null; + console.log(`Controller ${i} disconnected.`); + }); + + // Add a debug sphere to the controller + const sphereGeometry = new THREE.SphereGeometry(0.05, 8, 8); + const sphereMaterial = new THREE.MeshBasicMaterial({ color: (i === 0 ? 0xff0000 : 0x0000ff) }); // Red for left, Blue for right + const debugSphere = new THREE.Mesh(sphereGeometry, sphereMaterial); + controller.add(debugSphere); + + const lineGeometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1)]); + const line = new THREE.Line(lineGeometry); + line.scale.z = 5; + controller.add(line); + + controller.addEventListener('selectstart', () => this._OnVRSelectStart(i)); + controller.addEventListener('selectend', () => this._OnVRSelectEnd(i)); + controller.addEventListener('squeezestart', () => this._OnVRSqueezeStart(i)); + } + } + + _OnVRSelectStart(controllerIndex) { + const controller = this.vrControllers[controllerIndex]; + // Right controller (index 1) for drawing/interaction + console.log(`Select Started: ${controllerIndex}`); + if (controllerIndex === 0) { + if (this.currentIntItem && !this.attachedItem) { + this.attachedItem = this.currentIntItem; + this.attachedItem.isActive = true; + } else { + this.isDrawing = true; + } + } + // Left controller (index 0) for teleporting + if (controllerIndex === 1) { + this.teleporting = true; + this.teleArc.visible = true; + } + } + + _OnVRSelectEnd(controllerIndex) { + // Right controller + console.log(`Select End: ${controllerIndex}`); + if (controllerIndex === 0) { + this.isDrawing = false; + } + // Left controller + if (controllerIndex === 1) { + this.teleporting = false; + this.teleArc.visible = false; + if (this.teleMarker.visible) { + const newPosition = this.teleportTarget.clone(); + newPosition.y = 10; // Maintain height + this.playerRig.position.copy(newPosition); + + this.rigibody.setNextKinematicTranslation({ x: newPosition.x, y: newPosition.y, z: newPosition.z }); + this.position.copy(newPosition); + } + this.teleMarker.visible = false; + } + } + + _OnVRSqueezeStart(controllerIndex) { + console.log(`Squeeze Started: ${controllerIndex}`); + // Use squeeze on right controller to detach item + if (controllerIndex === 0 && this.attachedItem) { + this.attachedItem.isActive = false; + this.attachedItem._removeContentDisplay(); + this.attachedItem = null; + } + } + + _handleVRJoystick() { + // Get the gamepad for the left controller (index 1 in your setup) + const gamepad = this.vrGamepads[1]; + + // You no longer need to check this.vrControllers[1] since the gamepad is null if disconnected. + if (!gamepad) return; + + // The axes array for a thumbstick is often at index 2 (X) and 3 (Y) for the primary stick. + // If the left stick is the primary for movement/teleport, these are the typical indices. + // Always check for undefined or use a safe index, just in case. + const joystickVertical = gamepad.axes[3]; + + if (joystickVertical !== undefined) { + // joystickVertical is -1 (forward) to 1 (backward). We want forward to be max distance. + // We'll map the [-1, 1] range to our [min, max] distance factor range. + + // 1. Convert [-1, 1] to [0, 1] (Mapping: -1 -> 1, 0 -> 0.5, 1 -> 0) + // Since Y is typically -1 forward, using (-Y + 1) / 2 makes full forward (Y=-1) equal to 1. + const normalizedValue = (-joystickVertical + 1) / 2; + + // 2. Linearly interpolate between min and max factors + this.teleportDistanceFactor = this.minTeleportDistanceFactor + normalizedValue * (this.maxTeleportDistanceFactor - this.minTeleportDistanceFactor); + + // Optional: Apply a small deadzone to prevent accidental changes when the stick is centered + const deadzone = 0.05; + if (Math.abs(joystickVertical) < deadzone) { + // If centered, reset to the default factor (e.g., the midpoint of your min/max range) + this.teleportDistanceFactor = (this.minTeleportDistanceFactor + this.maxTeleportDistanceFactor) / 2; + } + + // console.log(`Normalized: ${normalizedValue.toFixed(2)}, Factor: ${this.teleportDistanceFactor.toFixed(2)}`); + } + } + + _handleVRTeleport(floorObjects) { + if (!this.teleporting) { + this.teleMarker.visible = false; + this.teleArc.visible = false; + return; + } + + const controller = this.vrControllers[1]; // Left controller for teleporting + const controllerMatrix = controller.matrixWorld; + + const initialVelocity = 50 * this.teleportDistanceFactor;; + const gravity = -9.8; + const timeStep = 0.03; + const numSegments = 100; + + const points = []; + const startPoint = new THREE.Vector3().setFromMatrixPosition(controllerMatrix); + points.push(startPoint.clone()); + const launchDirection = new THREE.Vector3(0, 0, -1).applyMatrix4(new THREE.Matrix4().extractRotation(controllerMatrix)); + + let lastPoint = startPoint.clone(); + let hit = false; + + for (let i = 1; i < numSegments; i++) { + const t = i * timeStep; + const currentPoint = new THREE.Vector3( + startPoint.x + launchDirection.x * initialVelocity * t, + startPoint.y + launchDirection.y * initialVelocity * t + 0.5 * gravity * t * t, + startPoint.z + launchDirection.z * initialVelocity * t + ); + + const ray = new THREE.Raycaster(lastPoint, currentPoint.clone().sub(lastPoint).normalize()); + ray.far = lastPoint.distanceTo(currentPoint); + const floorMeshes = floorObjects.map(obj => obj.mesh); + const intersects = ray.intersectObjects(floorMeshes); + + if (intersects.length > 0) { + const intersectPoint = intersects[0].point; + points.push(intersectPoint); + this.teleportTarget.copy(intersectPoint); + this.teleMarker.position.copy(this.playerRig.worldToLocal(this.teleportTarget.clone())); + this.teleMarker.position.y += 0.01; // Avoid z-fighting + this.teleMarker.visible = true; + hit = true; + break; + } + + points.push(currentPoint); + lastPoint = currentPoint; + } + + if (!hit) { + this.teleMarker.visible = false; + } + + // Convert world-space points to local-space for the rig + const localPoints = points.map(p => this.playerRig.worldToLocal(p.clone())); + + this.teleArc.geometry.setFromPoints(localPoints); + this.teleArc.geometry.computeBoundingSphere(); // Important for visibility + this.teleArc.visible = true; + } + _drawOnTexture(intersect, color = 'red') { const object = intersect.object; const uv = intersect.uv; @@ -199,20 +416,36 @@ export class Player { } draw(drawableObjects) { - if (!this.isDrawing) return; - - this.pointer.x = 0; - this.pointer.y = 0; - - this.raycast.setFromCamera(this.pointer, this.camera); - const meshesToIntersect = drawableObjects.map(obj => obj.mesh); - const intersections = this.raycast.intersectObjects(meshesToIntersect); - if( intersections.length > 0) { - const intersect = intersections[0]; - if( intersect.object.material.map && intersect.object.material.map.isCanvasTexture ) { - this._drawOnTexture(intersect); + // Desktop drawing + if (this.isDrawing && !this.vrControllers[0]?.userData.isDrawing) { + this.pointer.x = 0; + this.pointer.y = 0; + this.raycast.setFromCamera(this.pointer, this.camera); + const intersections = this.raycast.intersectObjects(meshesToIntersect); + if (intersections.length > 0) { + const intersect = intersections[0]; + if (intersect.object.material.map && intersect.object.material.map.isCanvasTexture) { + this._drawOnTexture(intersect); + } + } + } + + // VR drawing (right controller) + const vrController = this.vrControllers[0]; + if (this.isDrawing && vrController) { + const controllerMatrix = vrController.matrixWorld; + const ray = new THREE.Raycaster(); + ray.ray.origin.setFromMatrixPosition(controllerMatrix); + ray.ray.direction.set(0, 0, -1).applyMatrix4(new THREE.Matrix4().extractRotation(controllerMatrix)); + + const intersections = ray.intersectObjects(meshesToIntersect); + if (intersections.length > 0) { + const intersect = intersections[0]; + if (intersect.object.material.map && intersect.object.material.map.isCanvasTexture) { + this._drawOnTexture(intersect); + } } } } @@ -220,7 +453,18 @@ export class Player { _checkForInteractableItems() { const ray = new THREE.Raycaster(); - ray.set(this.camera.position, this.camera.getWorldDirection(new THREE.Vector3())); + let isVR = this.vrControllers[0] && this.vrControllers[0].visible; + + if (isVR) { + // Use right controller for interaction ray + const controller = this.vrControllers[0]; + const controllerMatrix = controller.matrixWorld; + ray.ray.origin.setFromMatrixPosition(controllerMatrix); + ray.ray.direction.set(0, 0, -1).applyMatrix4(new THREE.Matrix4().extractRotation(controllerMatrix)); + } else { + // Use camera for desktop interaction ray + ray.set(this.camera.position, this.camera.getWorldDirection(new THREE.Vector3())); + } const nearbyItems = this.itemList.filter(item => item.object && this.position.distanceTo(item.object.position) < this.maxInteractionDistance); @@ -295,15 +539,21 @@ export class Player { this.rigibody.setNextKinematicTranslation({ x: newPosition.x, y: newPosition.y, z: newPosition.z }); } - update(delta) { + update(delta, spawnedObjects) { + //console.log(`Number of Controllers: ${this.vrControllers.length}`); + if (this.renderer.xr.isPresenting) { + this._handleVRJoystick(); + this._handleVRTeleport(spawnedObjects); + } + if (this.enableInput) { if (this.attachedItem) { - this._lockCameraForAttachedItem(); - } else { + this._lockCameraForAttachedItem(); + } else if (!this.renderer.xr.isPresenting) { // Only update movement if not in VR this._updatePlayerMovement(delta); } this._checkForInteractableItems(); - } + } this.input.mouseDelta.x = 0; this.input.mouseDelta.y = 0; diff --git a/js/main.js b/js/main.js index 9c50ae8..a0008ce 100644 --- a/js/main.js +++ b/js/main.js @@ -81,20 +81,16 @@ async function init() { document.body.appendChild( renderer.domElement ); document.body.appendChild( VRButton.createButton( renderer ) ); - player = new Player(rapierWorld, new THREE.Vector3(0, 1, 0), interactableItems); + player = new Player(rapierWorld, renderer, scene, new THREE.Vector3(0, 1, 0), interactableItems); + player._setupVR(renderer); + scene.add(player.playerRig); - scene.add(player.camera); + //scene.add(player.camera); setupSocketIO(); /* Load Items/Content */ - loadWorldModel("models/demo-world-comp.glb"); - - //itemManager = new ItemManager("json/Items.json", scene, rapierWorld, player, interactableItems); - - const item = new Item(rapierWorld, scene, player, true, new THREE.Vector3(0, 20, 0), 20, "test", '/models/init.glb', null, spawnedObjects, null); - await item.loadModel(); - + loadWorldModel("models/demo-world-comp.glb"); instructions.innerHTML = "Click to play"; instructions.addEventListener( 'click', function () { @@ -146,7 +142,7 @@ async function animate() { drawDebugRapier(); // (1) Update the player positions - player.update(delta); + player.update(delta, spawnedObjects); for (const item of interactableItems) { await item.update(delta); @@ -162,7 +158,11 @@ async function animate() { // // (3) Update the camera position, after physics step has run. const newPosition = player.rigibody.translation(); player.position.set(newPosition.x, newPosition.y, newPosition.z); - player.camera.position.copy(player.position); + + // Only copy the rigidbody position to the camera when not in VR + if (!renderer.xr.isPresenting) { + player.playerRig.position.copy(player.position); + } player.draw(spawnedObjects); diff --git a/server.js b/server.js index caece0e..b49dc91 100644 --- a/server.js +++ b/server.js @@ -18,7 +18,7 @@ io.on('connection', (socket) => { players[socket.id] = { rotation: { x: 0, y: 0, z: 0 }, position: { x: 0, y: 1, z: 0 }, - id: socket.id + socketId: socket.id }; // Send the new player the list of all other players