import * as THREE from 'three'; import { RigidBodyDesc, ColliderDesc } from '@dimforge/rapier3d-compat'; import { AudioContent, VideoContent } from './Content'; export class Player { 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'); this.velocity = new THREE.Vector3(); this.acceleration = new THREE.Vector3(); this.id = null; this.name = null; this.audioListener = new THREE.AudioListener(); this.renderer = renderer; this.scene = scene; this.rapierWorld = rapierWorld; this.rigibody = null; this.collider = null; this.moveSpeed = 20; this.mouseSensitivity = 0.002; this.maxInteractionDistance = 200.0; this.isDrawing = false; this.raycast = new THREE.Raycaster(); this.pointer = new THREE.Vector2(); this.itemList = itemList; this.currentIntItem = null; this.attachedItem = null; this.input = { forward: false, backwards: false, left: false, right:false, up: false, down: false, mouseDelta: { x: 0, y: 0} }; 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(); } _bindEvents() { window.addEventListener('keydown', (e) => this._onKeyDown(e)); window.addEventListener('wheel', (e) => this._onWheel(e)); } _onWheel(e) { if (this.attachedItem && this.attachedItem.content) { if (e.deltaY < 0 && typeof this.attachedItem.content.scrollUp === 'function') { this.attachedItem.content.scrollUp(); } else if (e.deltaY > 0 && typeof this.attachedItem.content.scrollDown === 'function') { this.attachedItem.content.scrollDown(); } } } _onKeyDown(e) { if (e.code == 'KeyF' && this.attachedItem) { console.log("Dettached item to player: ", this.attachedItem.object.name); this.attachedItem.isActive = false; this.attachedItem._removeContentDisplay(); this.attachedItem = null; // Update player's position and rotation to match the camera's current state this.position.copy(this.camera.position); this.rotation.setFromQuaternion(this.camera.quaternion, 'YXZ'); } else if(e.code == 'KeyF' && this.currentIntItem && !this.attachedItem){ this.attachedItem = this.currentIntItem; this.attachedItem.isActive = true; //console.log("Attached item to player: ", this.attachedItem.object.name); } if (e.code === 'Space' && this.attachedItem) { if (this.attachedItem.content instanceof VideoContent) { const video = this.attachedItem.content.video; if (video.paused) { video.play(); } else { video.pause(); } } } if (e.code === 'KeyM' && this.attachedItem) { if (this.attachedItem.content instanceof VideoContent) { const video = this.attachedItem.content.video; if (video) { video.muted = !video.muted; } } } } _init() { // Create rapier rb & coll this.position.y = 10; const rbDesc = RigidBodyDesc.kinematicPositionBased().setTranslation(this.position.x, this.position.y, this.position.z); this.rigibody = this.rapierWorld.createRigidBody(rbDesc); const colliderDesc = ColliderDesc.capsule(7.5, 1); this.collider = this.rapierWorld.createCollider(colliderDesc, this.rigibody); // Offset from ground this.camera.position.copy(this.position); // Attach audio listener to the camera/player this.camera.add(this.audioListener); this.playerRig.add(this.camera); } _setupInput() { window.addEventListener('keydown', (e) => { switch (e.code) { case 'KeyW': this.input.forward = true; break; case 'KeyS': this.input.backward = true; break; case 'KeyA': this.input.left = true; break; case 'KeyD': this.input.right = true; break; case 'KeyQ': this.input.down = true; break; case 'KeyE': this.input.up = true; break; } }); window.addEventListener('keyup', (e) => { switch (e.code) { case 'KeyW': this.input.forward = false; break; case 'KeyS': this.input.backward = false; break; case 'KeyA': this.input.left = false; break; case 'KeyD': this.input.right = false; break; case 'KeyQ': this.input.down = false; break; case 'KeyE': this.input.up = false; break; } }); window.addEventListener('mousemove', (e) => { this.input.mouseDelta.x += e.movementX; this.input.mouseDelta.y += e.movementY; }); document.addEventListener('pointerdown', this.onPointerDown.bind(this)); document.addEventListener('pointerup', this.onPointerUp.bind(this)); } onPointerDown() { if (document.pointerLockElement) { this.isDrawing = true; } } onPointerUp() { 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; const texture = object.material.map; const canvas = texture.image; const context = canvas.getContext('2d'); // --- Dynamic Brush Size Calculation --- const worldBrushRadius = 0.1; const face = intersect.face; const geometry = object.geometry; const positionAttribute = geometry.attributes.position; const uvAttribute = geometry.attributes.uv; const vA = new THREE.Vector3().fromBufferAttribute(positionAttribute, face.a); const vB = new THREE.Vector3().fromBufferAttribute(positionAttribute, face.b); const vC = new THREE.Vector3().fromBufferAttribute(positionAttribute, face.c); object.localToWorld(vA); object.localToWorld(vB); object.localToWorld(vC); const uvA = new THREE.Vector2().fromBufferAttribute(uvAttribute, face.a); const uvB = new THREE.Vector2().fromBufferAttribute(uvAttribute, face.b); const uvC = new THREE.Vector2().fromBufferAttribute(uvAttribute, face.c); const worldDistAB = vA.distanceTo(vB); const uvDistAB = uvA.distanceTo(uvB) * canvas.width; const texelsPerWorldUnit = uvDistAB / worldDistAB; const pixelBrushRadius = worldBrushRadius * texelsPerWorldUnit; // --- End Dynamic Calculation --- const x = uv.x * canvas.width; const y = uv.y * canvas.height; context.fillStyle = color; context.beginPath(); context.arc(x, y, Math.max(1, pixelBrushRadius), 0, 2 * Math.PI); context.fill(); texture.needsUpdate = true; } draw(drawableObjects) { const meshesToIntersect = drawableObjects.map(obj => obj.mesh); // 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); } } } } _checkForInteractableItems() { const ray = new THREE.Raycaster(); 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); const itemObj = nearbyItems.map(item => item.object); const intersects = ray.intersectObjects(itemObj, true); if (intersects.length > 0) { const intersected = intersects[0].object; // Find the item whose object contains the intersected mesh const foundItem = nearbyItems.find(item => { let found = false; item.object.traverse(child => { if (child === intersected) found = true; }); return found; }); if (foundItem) { this.currentIntItem = foundItem; } else { this.currentIntItem = null; } } else { this.currentIntItem = null; } } _lockCameraForAttachedItem() { const itemCenter = new THREE.Vector3(); new THREE.Box3().setFromObject(this.attachedItem.object).getCenter(itemCenter); const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(this.camera.quaternion); const targetPosition = itemCenter.clone().add(forward.multiplyScalar(2)); this.camera.position.lerp(targetPosition, 0.1); const targetRotation = new THREE.Quaternion().setFromRotationMatrix( new THREE.Matrix4().lookAt(this.camera.position, itemCenter, this.camera.up) ); this.camera.quaternion.slerp(targetRotation, 0.1); } _updatePlayerMovement(delta) { // Normal movement and camera logic this.rotation.y -= this.input.mouseDelta.x * this.mouseSensitivity; this.rotation.x -= this.input.mouseDelta.y * this.mouseSensitivity; this.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.rotation.x)); // Only update rotation here. Position will be updated in the main loop after the physics step. this.camera.rotation.copy(this.rotation); let direction = new THREE.Vector3(); if (this.input.forward) direction.z -= 1; if (this.input.backward) direction.z += 1; if (this.input.left) direction.x -= 1; if (this.input.right) direction.x += 1; if (this.input.up) direction.y += 1; if (this.input.down) direction.y -= 1; direction.normalize(); const move = new THREE.Vector3(direction.x, direction.y, direction.z); move.applyEuler(this.rotation); move.multiplyScalar(this.moveSpeed * delta); const newPosition = this.position.clone().add(move); if( newPosition.y <= 10 ) newPosition.y = 10; // Tell the physics engine where we want to go in the next step. this.rigibody.setNextKinematicTranslation({ x: newPosition.x, y: newPosition.y, z: newPosition.z }); } 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 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; } }