import * as THREE from 'three'; import { RigidBodyDesc, ColliderDesc } from '@dimforge/rapier3d-compat'; import { AudioContent, VideoContent } from './Content'; import { ModelLoader } from './ModelLoader'; export class Player { constructor(rapierWorld, renderer, scene, spawnPosition = new THREE.Vector3(0, 1, 0), itemList, socket) { this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); this.position = spawnPosition.clone(); this.rotation = new THREE.Euler(0, 89.6, 0, 'YXZ'); this.velocity = new THREE.Vector3(); this.acceleration = new THREE.Vector3(); this.id = null; this.name = null; this.audioListener = new THREE.AudioListener(); this.attachSound = null; this.detachSound = null; this.hoverSound = null; this.ambientSound = null; this.collectedItems = []; this.numCollectedItems = []; this.areaList = []; this.currentArea = null; this.areasVisited = 0; this.itemCountDisplay = null; this.itemCountAnim = { active: false, time: 0, duration: 1.2, // seconds fadeIn: 0.3, fadeOut: 0.5, startY: -1, endY: -0.7 }; this.sprayCan = null; this.hand = null; this.currentSprayColor = '#FF0000'; this.modelLoader = new ModelLoader(); this.shakeData = { lastPosition: new THREE.Vector3(), lastVelocity: new THREE.Vector3(), lastShakeTime: 0, shakeThreshold: 1000, // Acceleration magnitude needed to trigger shakeCooldown: 0.5 // Seconds between shakes }; this.interactPrompt = null; this.interactPromptTime = 0; this.lastPromptItem = null; this.renderer = renderer; this.scene = scene; this.rapierWorld = rapierWorld; this.rigibody = null; this.collider = null; this.moveSpeed = 100; //40 this.mouseSensitivity = 0.002; this.maxInteractionDistance = 200.0; this.vrDrawing = false; 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.socket = socket; this._loadSounds(); 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.playerRig.position); this.rotation.setFromQuaternion(this.playerRig.quaternion, 'YXZ'); if (this.detachSound && this.detachSound.isPlaying) this.detachSound.stop(); if (this.detachSound) { this.detachSound.playbackRate = 0.5 + Math.random() * 0.5; this.detachSound.play(); } } else if(e.code == 'KeyF' && this.currentIntItem && !this.attachedItem){ this.attachedItem = this.currentIntItem; this.attachedItem.isActive = true; if (this.attachSound && this.attachSound.isPlaying) this.attachSound.stop(); if (this.attachSound){ this.attachSound.playbackRate = 0.5 + Math.random() * 0.5; this.attachSound.play(); } if (!this.collectedItems.includes(this.attachedItem.name)) { this.collectedItems.push(this.attachedItem.name); this.numCollectedItems++; console.log("Number of items collected: ", this.numCollectedItems); // Update item count display this.camera.remove(this.itemCountDisplay); this.itemCountDisplay = this._createItemCountDisplay(); this.camera.add(this.itemCountDisplay); this.itemCountAnim.active = true; this.itemCountAnim.time = 0; this.itemCountDisplay.position.set(0, this.itemCountAnim.startY, -2); this.itemCountDisplay.visible = true; // Set initial opacity this.itemCountDisplay.traverse(obj => { if (obj.material) obj.material.opacity = 0; }); } 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 = 20; const tempPos = new THREE.Vector3(0, 20, 0); const rbDesc = RigidBodyDesc.kinematicPositionBased().setTranslation(this.position.x, this.position.y, this.position.z); this.rigibody = this.rapierWorld.createRigidBody(rbDesc); const colliderDesc = ColliderDesc.capsule(3, 4); this.collider = this.rapierWorld.createCollider(colliderDesc, this.rigibody); // Initialize Character Controller // offset: small gap to prevent snagging (0.1) this.characterController = this.rapierWorld.createCharacterController(0.01); //this.characterController.enableAutostep(1, 1, true); // Handle small steps/stairs this.characterController.setApplyImpulsesToDynamicBodies(true); // Allow pushing dynamic objects // Offset from ground this.camera.position.copy(tempPos); // Attach audio listener to the camera/player this.camera.add(this.audioListener); this.playerRig.add(this.camera); } _loadSprayCan(modelUrl, scale) { return this.modelLoader.loadModel(modelUrl, scale) .then((model) => { //console.log('loaded spraycan'); return model; }) .catch((err) => { console.error('Failed to load spray can model:', err); return null; }); } _loadSounds() { const audioLoader = new THREE.AudioLoader(); this.attachSound = new THREE.Audio(this.audioListener); this.detachSound = new THREE.Audio(this.audioListener); this.hoverSound = new THREE.Audio(this.audioListener); this.ambientSound = new THREE.Audio(this.audioListener); audioLoader.load('sounds/item-int-open.wav', (buffer) => { this.attachSound.setBuffer(buffer); this.attachSound.setVolume(0.05); }); audioLoader.load('sounds/item-int-close.wav', (buffer) => { this.detachSound.setBuffer(buffer); this.detachSound.setVolume(0.0); }); audioLoader.load('sounds/hover-2.wav', (buffer) => { this.hoverSound.setBuffer(buffer); this.hoverSound.setVolume(0.05); }); audioLoader.load('sounds/ambient.mp3', (buffer) => { this.ambientSound.setBuffer(buffer); this.ambientSound.setVolume(0.02); this.ambientSound.setLoop(true); this.ambientSound.play(); }); } _initAreaTriggers() { this.areaList = []; const testLayers = new THREE.Layers(); testLayers.set(4); this.scene.traverse((object) => { // Find meshes on the AREA_LAYER if (object.isMesh && object.layers.test(testLayers)) { this.areaList.push(object); object.visible = false; // Optionally make trigger volumes invisible } }); } _checkAreaTriggers() { if (this.areaList.length == 0) return; let inAnyArea = false; for (const trigger of this.areaList) { const triggerBox = new THREE.Box3().setFromObject(trigger); if (triggerBox.containsPoint(this.position)) { inAnyArea = true; if (this.currentArea !== trigger) { this.currentArea = trigger; this.areasVisited++; } break; } } // Check if the player has left an area if (!inAnyArea && this.currentArea !== null) { this.currentArea = null; } } _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)); } _createItemCountDisplay() { const text = `${this.numCollectedItems} / 50`; const group = new THREE.Group(); const fontSize = 0.25; let totalWidth = 0; const charWidths = []; for (let i = 0; i < text.length; i++) { const charWidth = fontSize * (text[i] === ' ' ? 0.3 : 0.4); charWidths.push(charWidth); totalWidth += charWidth; } let offsetX = -totalWidth / 2; for (let i = 0; i < text.length; i++) { const char = text[i]; const canvas = document.createElement('canvas'); canvas.width = 128; canvas.height = 128; const ctx = canvas.getContext('2d'); ctx.font = '100px Redacted70'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ffd500ff'; // Consistent color ctx.fillText(char, canvas.width / 2, canvas.height / 2); const texture = new THREE.CanvasTexture(canvas); texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; texture.needsUpdate = true; const geometry = new THREE.PlaneGeometry(fontSize, fontSize); const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true }); const mesh = new THREE.Mesh(geometry, material); mesh.position.x = offsetX + charWidths[i] / 2; group.add(mesh); offsetX += charWidths[i]; } group.name = 'itemCountDisplay'; return group; } _createInteractPrompt() { function getRandomColor() { const hexChars = '0123456789ABCDEF'; let color = '#'; for (let i = 0; i < 6; i++) { color += hexChars[Math.floor(Math.random() * 16)]; } return color; } const text = '(F) Interact'; const charMeshes = []; const group = new THREE.Group(); const fontSize = 0.25; // Adjust as needed // First, calculate total width let totalWidth = 0; const charWidths = []; for (let i = 0; i < text.length; i++) { const charWidth = fontSize * (text[i] === ' ' ? 0.2 : 0.3); //Changes gap size in text charWidths.push(charWidth); totalWidth += charWidth; } // Now, create meshes and position them centered let offsetX = -totalWidth / 2; for (let i = 0; i < text.length; i++) { const char = text[i]; // Create a canvas for each character const canvas = document.createElement('canvas'); canvas.width = 128; canvas.height = 128; const ctx = canvas.getContext('2d'); ctx.font = '100px Redacted70'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = getRandomColor(); ctx.fillText(char, canvas.width / 2, canvas.height / 2); const texture = new THREE.CanvasTexture(canvas); texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; texture.needsUpdate = true; const geometry = new THREE.PlaneGeometry(fontSize, fontSize); const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true }); const mesh = new THREE.Mesh(geometry, material); mesh.position.x = offsetX + charWidths[i] / 2; group.add(mesh); charMeshes.push(mesh); // --- SNAP ROTATION STATE --- mesh.userData.snapRotation = 0; mesh.userData.nextSnap = performance.now() + Math.random() * 600 + 300; // 100-700ms offsetX += charWidths[i]; } group.userData.charMeshes = charMeshes; // Store for animation return group; } onPointerDown() { if (document.pointerLockElement) { this.isDrawing = true; } } onPointerUp() { this.isDrawing = false; } async _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 lineMaterial = new THREE.LineBasicMaterial({ color: 0xff0000, opacity: 0.4, transparent: true }); // White color for the ray const line = new THREE.Line(lineGeometry, lineMaterial); line.name = 'controller-ray'; // Give the line a name to find it later line.scale.z = this.maxInteractionDistance; ; if (i == 0) { this._loadSprayCan('/models/spray-can.glb', 0.04).then((model) => { if (model instanceof THREE.Object3D) { controller.add(model); this.sprayCan = model; controller.add(line) } else { console.warn('Spray can model is not a THREE.Object3D:', model); } }); } controller.addEventListener('selectstart', () => this._OnVRSelectStart(i)); controller.addEventListener('selectend', () => this._OnVRSelectEnd(i)); controller.addEventListener('squeezestart', () => this._OnVRSqueezeStart(i)); controller.addEventListener('squeezeend', () => this._OnVRSqueezeEnd(i)); } } _OnVRSelectStart(controllerIndex) { const controller = this.vrControllers[controllerIndex]; console.log(`Select Started: ${controllerIndex}`); if (controllerIndex === 0) { if (this.currentIntItem && !this.attachedItem) { this.attachedItem = this.currentIntItem; this.attachedItem.isActive = true; console.log("Attaced Item to (VR) ", this.attachedItem.name); } } if (controllerIndex === 1) { this.teleporting = true; this.teleArc.visible = true; } } _OnVRSelectEnd(controllerIndex) { // Right controller console.log(`Select End: ${controllerIndex}`); if (controllerIndex === 0 && this.attachedItem) { this.attachedItem.isActive = false; this.attachedItem._removeContentDisplay(); this.attachedItem = null; console.log("Dettached item from player (VR)"); } // Left controller if (controllerIndex === 1) { this.teleporting = false; this.teleArc.visible = false; if (this.teleMarker.visible) { const newPosition = this.teleportTarget.clone(); newPosition.y = 20; // 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.vrDrawing = true; this.isDrawing = true; } } _OnVRSqueezeEnd(controllerIndex) { if (controllerIndex === 0) { this.vrDrawing = false; this.isDrawing = false; } } _handleVRJoystick() { // --- Left Controller (Teleport Distance) --- const leftGamepad = this.vrGamepads[1]; if (leftGamepad) { const joystickVertical = leftGamepad.axes[3]; if (joystickVertical !== undefined) { const normalizedValue = (-joystickVertical + 1) / 2; this.teleportDistanceFactor = this.minTeleportDistanceFactor + normalizedValue * (this.maxTeleportDistanceFactor - this.minTeleportDistanceFactor); const deadzone = 0.05; if (Math.abs(joystickVertical) < deadzone) { this.teleportDistanceFactor = (this.minTeleportDistanceFactor + this.maxTeleportDistanceFactor) / 2; } } } // --- Right Controller (Content Interaction) --- const rightGamepad = this.vrGamepads[0]; if (rightGamepad && this.attachedItem && this.attachedItem.content) { const joystickVertical = rightGamepad.axes[3]; // Y-axis of the thumbstick const deadzone = 0.5; // Use a larger deadzone to prevent accidental scrolling if (joystickVertical < -deadzone) { // Pushed Up if (typeof this.attachedItem.content.scrollUp === 'function') { this.attachedItem.content.scrollUp(); } } else if (joystickVertical > deadzone) { // Pushed Down if (typeof this.attachedItem.content.scrollDown === 'function') { this.attachedItem.content.scrollDown(); } } } } _handleVRTeleport() { 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); ray.layers.set(3); // TP_SURFACE Layer // Intersect the whole scene, but only objects on the TP_OBJECT_LAYER will be hit const intersects = ray.intersectObjects(this.scene.children, true); //console.log(intersects.length); 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.dispose(); this.teleArc.geometry.setFromPoints(localPoints); this.teleArc.geometry.computeBoundingSphere(); // Important for visibility this.teleArc.visible = true; } _handleControllerShake(delta) { if (!this.renderer.xr.isPresenting || !this.vrControllers[0] || delta === 0) { return; } const controller = this.vrControllers[0]; const now = performance.now() / 1000; // Check if cooldown has passed if (now - this.shakeData.lastShakeTime < this.shakeData.shakeCooldown) { // Update position even during cooldown to avoid a spike when it ends this.shakeData.lastPosition.copy(controller.position); this.shakeData.lastVelocity.set(0, 0, 0); return; } const currentPosition = controller.position.clone(); // Velocity = (current_pos - last_pos) / time const currentVelocity = currentPosition.clone().sub(this.shakeData.lastPosition).divideScalar(delta); // Acceleration = (current_vel - last_vel) / time const acceleration = currentVelocity.clone().sub(this.shakeData.lastVelocity).divideScalar(delta); // Check if acceleration exceeds the threshold if (acceleration.length() > this.shakeData.shakeThreshold) { // Shake detected! this.shakeData.lastShakeTime = now; // Change to a new random color const r = Math.floor(Math.random() * 256).toString(16).padStart(2, '0'); const g = Math.floor(Math.random() * 256).toString(16).padStart(2, '0'); const b = Math.floor(Math.random() * 256).toString(16).padStart(2, '0'); this.currentSprayColor = `#${r}${g}${b}`; console.log(`Shake detected! New color: ${this.currentSprayColor}`); // Optional: Play a sound effect here } // Update values for the next frame this.shakeData.lastPosition.copy(currentPosition); this.shakeData.lastVelocity.copy(currentVelocity); } _drawOnTexture(intersect, color = 'red') { const object = intersect.object; const objectId = object.userData.drawingId; const uv = intersect.uv; const texture = object.material.map; const canvas = texture.image; const context = canvas.getContext('2d'); // --- Dynamic Brush Size Calculation --- const worldBrushRadius = 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; const gradient = context.createRadialGradient( x, y, 0, x, y, Math.max(4, pixelBrushRadius * 2) ); gradient.addColorStop(0, color); gradient.addColorStop(1, 'rgba(0,0,0,0)'); context.globalCompositeOperation = 'source-over'; context.globalAlpha = 0.6; // tweak softness context.fillStyle = gradient; context.beginPath(); context.arc(x, y, Math.max(1, pixelBrushRadius), 0, 2 * Math.PI); context.fill(); context.globalAlpha = 0.6; texture.needsUpdate = true; // Emit drawing data to server if (this.socket && this.socket.connected) { this.socket.emit('drawingUpdate', { objectId: objectId, drawData: { x: x, y: y, radius: Math.max(1, pixelBrushRadius), color: color } }); } } draw(drawableObjects) { const meshesToIntersect = drawableObjects.map(obj => obj.mesh); // Desktop drawing if (this.isDrawing && !this.renderer.xr.isPresenting) { this.pointer.x = 0; this.pointer.y = 0; this.raycast.setFromCamera(this.pointer, this.camera); this.raycast.layers.set(1); const intersections = this.raycast.intersectObjects(meshesToIntersect, true); if (intersections.length > 0) { const intersect = intersections[0]; if (intersect.object.material.map && intersect.object.material.map.isCanvasTexture) { this._drawOnTexture(intersect, this.currentSprayColor); } } } // VR drawing (right controller) const vrController = this.vrControllers[0]; if (this.isDrawing && vrController && this.renderer.xr.isPresenting) { 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)); ray.layers.set(1); const intersections = ray.intersectObjects(meshesToIntersect, true); if (intersections.length > 0) { const intersect = intersections[0]; if (intersect.object.material.map && intersect.object.material.map.isCanvasTexture) { this._drawOnTexture(intersect, this.currentSprayColor); } } } } _checkForInteractableItems() { const ray = new THREE.Raycaster(); let isVR = this.renderer.xr.isPresenting && this.vrControllers[0] && this.vrControllers[0].visible; if (isVR) { // Use right controller for interaction ray const controller = this.vrControllers[0]; controller.getWorldPosition(ray.ray.origin); ray.ray.direction.set(0, 0, -1).applyQuaternion(controller.getWorldQuaternion(new THREE.Quaternion())); } else { // Use camera for desktop interaction ray ray.setFromCamera({ x: 0, y: 0 }, this.camera); } 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); // Keep track of the previously hovered item const previouslyHoveredItem = this.currentIntItem; // Update ray visualization const controllerRay = this.vrControllers[0]?.getObjectByName('controller-ray'); if (controllerRay) { controllerRay.visible = isVR; } if (intersects.length > 0) { let intersectedObject = intersects[0].object; // Traverse up to find the root object that is in our itemObj list let rootObject = intersectedObject; while (rootObject.parent && itemObj.indexOf(rootObject) === -1) { rootObject = rootObject.parent; } // Find the item that corresponds to this root object const foundItem = nearbyItems.find(item => item.object === rootObject); if (foundItem) { if (this.currentIntItem !== foundItem) { // Optional: Add some visual feedback for the newly highlighted item //console.log("Hovering over:", foundItem.object.name); if (this.hoverSound && this.hoverSound.isPlaying) this.hoverSound.stop(); if (this.hoverSound) { this.hoverSound.playbackRate = 0.9 + Math.random() * 0.1; this.hoverSound.play(); } } this.currentIntItem = foundItem; } else { this.currentIntItem = null; } } else { this.currentIntItem = null; } // If the hovered item has changed, update outlines if (previouslyHoveredItem !== this.currentIntItem) { if (previouslyHoveredItem) { previouslyHoveredItem.hideOutline(); } if (this.currentIntItem) { this.currentIntItem.showOutline(); } } } _lockCameraForAttachedItem() { const itemCenter = new THREE.Vector3(); new THREE.Box3().setFromObject(this.attachedItem.object).getCenter(itemCenter); // Get the camera's world position to correctly calculate the lookAt matrix const cameraWorldPosition = new THREE.Vector3(); this.camera.getWorldPosition(cameraWorldPosition); // Calculate the desired distance from the object const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(this.playerRig.quaternion); const desiredCameraPosition = itemCenter.clone().add(forward.multiplyScalar(2)); // Calculate the target position for the rig by subtracting the camera's local offset // from the desired world position of the camera. const targetPosition = desiredCameraPosition.clone().sub(this.camera.position); this.playerRig.position.lerp(targetPosition, 0.1); // The target rotation should make the camera (not the rig) look at the item center. const targetRotation = new THREE.Quaternion().setFromRotationMatrix( new THREE.Matrix4().lookAt(cameraWorldPosition, itemCenter, this.playerRig.up) ); this.playerRig.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.playerRig.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; // Only apply yaw (Y axis) rotation for WASD movement const yaw = this.rotation.y; const move = new THREE.Vector3(direction.x, 0, direction.z) .applyAxisAngle(new THREE.Vector3(0, 1, 0), yaw) .normalize(); // Add vertical movement from Q/E // let vertical = 0; // if (this.input.up) vertical += 1; // if (this.input.down) vertical -= 1; // move.y = vertical; move.multiplyScalar(this.moveSpeed * delta); // --- COLLISION LOGIC START --- // Calculate movement with collision detection this.characterController.computeColliderMovement( this.collider, move ); // Get the corrected movement vector (sliding against walls, stopping, etc.) const correctedMovement = this.characterController.computedMovement(); // Updating the position of the RB based on the corrected movement const newPosition = this.position.clone().add(correctedMovement); // 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 }); // --- COLLISION LOGIC END --- } update(delta, spawnedObjects) { if (this.renderer.xr.isPresenting) { this._handleVRJoystick(); this._handleVRTeleport(); this._handleControllerShake(delta); } if (this.enableInput) { if (this.attachedItem && !this.renderer.xr.isPresenting) { // Only lock camera for desktop this._lockCameraForAttachedItem(); } else if (!this.renderer.xr.isPresenting) { // Only update movement if not in VR this._updatePlayerMovement(delta); // --- Head Sway Logic --- const swayAmplitude = 0.2; // How far the head moves up/down const swaySpeed = 0.7; // How fast the sway is (Hz) const swayOffset = Math.sin(performance.now() * 0.001 * swaySpeed * Math.PI * 2) * swayAmplitude; this.camera.position.y = 20 + swayOffset; // 20 is your default camera Y } } this._checkForInteractableItems(); this._checkAreaTriggers(); // --- Interact Prompt Logic --- if (!this.interactPrompt) { this.interactPrompt = this._createInteractPrompt(); this.camera.add(this.interactPrompt); this.interactPrompt.position.set(0, -0.5, -2); this.interactPrompt.visible = false; } // Show prompt if hovering over item and not holding one if (this.currentIntItem && !this.attachedItem) { // Regenerate prompt if hovered item changed if (this.lastPromptItem !== this.currentIntItem) { // Remove old prompt mesh this.camera.remove(this.interactPrompt); // Dispose all character meshes in the group if (this.interactPrompt.userData.charMeshes) { this.interactPrompt.userData.charMeshes.forEach(mesh => { if (mesh.geometry) mesh.geometry.dispose(); if (mesh.material) { if (mesh.material.map) mesh.material.map.dispose(); mesh.material.dispose(); } }); } // Create new prompt with new random colors this.interactPrompt = this._createInteractPrompt(); this.camera.add(this.interactPrompt); this.interactPrompt.position.set(0, -0.5, -2); } this.interactPrompt.visible = true; this.interactPromptTime += delta; const bob = Math.sin(this.interactPromptTime * 2) * 0.01; this.interactPrompt.position.y = -0.5 + bob; this.lastPromptItem = this.currentIntItem; } else { this.interactPrompt.visible = false; this.interactPromptTime = 0; this.lastPromptItem = null; } if (this.interactPrompt && this.interactPrompt.userData.charMeshes) { const now = performance.now(); this.interactPrompt.userData.charMeshes.forEach((mesh, i) => { // Don't animate spaces if (mesh.material.map.image) { const ctx = mesh.material.map.image.getContext && mesh.material.map.image.getContext('2d'); if (ctx && ctx.measureText && ctx.measureText(' ').width === 0) return; } // Snap rotation at random intervals if (now > mesh.userData.nextSnap) { mesh.userData.snapRotation = (Math.random() * 40 - 20) * Math.PI / 180; // -20 to +20 deg in radians mesh.userData.nextSnap = now + Math.random() * 600 + 300; // 100-700ms } mesh.rotation.z = mesh.userData.snapRotation; }); } // --- Item Counter Animation --- if (this.itemCountDisplay && this.itemCountAnim.active) { this.itemCountAnim.time += delta; const t = this.itemCountAnim.time; const { duration, fadeIn, fadeOut, startY, endY } = this.itemCountAnim; // Fade in let opacity = 1; if (t < fadeIn) { opacity = t / fadeIn; } // Fade out else if (t > duration - fadeOut) { opacity = Math.max(0, 1 - (t - (duration - fadeOut)) / fadeOut); } // Move up let y = startY; if (t < duration) { y = startY + (endY - startY) * (t / duration); } else { y = endY; } this.itemCountDisplay.position.set(0, y, -2); this.itemCountDisplay.traverse(obj => { if (obj.material) obj.material.opacity = opacity; }); // Hide after animation if (t > duration) { this.itemCountDisplay.visible = false; this.itemCountAnim.active = false; } } this.input.mouseDelta.x = 0; this.input.mouseDelta.y = 0; } }