import * as THREE from 'three'; import { RigidBodyDesc, ColliderDesc } from '@dimforge/rapier3d-compat'; import { ModelLoader } from './ModelLoader'; import { AudioContent, Content, TextContent } from './Content'; import { Noise } from 'noisejs'; export class Item { constructor(rapierWorld, scene, player, isCollider = false, spawnPosition = new THREE.Vector3(0, 1, 0), scale, name, model, texture = null, spawnedObjects, content = null, object = null) { this.name = name; this.id = null; this.groupId = null; this.model = model; this.texture = texture; this.object = object this.spawnedObjects = spawnedObjects; this.scene = scene; this.spawnPosition = spawnPosition.clone(); this.spawnRotation = new THREE.Euler(0, 0, 0, 'YXZ'); this.scale = new THREE.Vector3(1 * scale, 1 * scale, 1 * scale); this.rotationSpeed = new THREE.Vector3(); this.initialPositon = null; this.targetPosition = null; this.centerOffset = new THREE.Vector3(); this.rapierWorld = rapierWorld; this.rb = null; this.coll = null; this.isCollider = isCollider; this.debugBox = null; this.isActive = false; this.modelLoader = new ModelLoader(); this.player = player; this.content = content; this.contentDisplay = null; this.loadState = "unloaded"; this.lastPosition = null; this.links = []; this._init(); this.isVisible = true; this.outlineMesh = null; this.pulseTime = 0; this.noise = new Noise(Math.random()); this.noiseTime = Math.random() * 1000; } _init() { } async loadModel() { if (this.loadState !== 'unloaded') return; this.loadState = 'loading'; try { if (!this.object) { this.object = await this.modelLoader.loadDRACOModelURL(this.model, this.texture, this.scale); } this.object.traverse((child) => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; this.spawnedObjects.push( { mesh: child, body: null} ); } }); this.spawnRotation.copy(this.object.rotation); this._addObject(); if(!this.isCollider || this.object.name.startsWith('TRIGGER') || this.object.name.startsWith('stContainer') || this.object.name.startsWith('stGrass')) { this.loadState = 'loaded'; return; } if (this.object.name.startsWith('stCollider')) { this.object.visible = false; this.object.layers.disableAll(); //console.log(this.object); } // --- Simplified Convex Hull Generation --- const vertices = []; // Decimation rate: 1 = use all vertices, 10 = use 1 in every 10. Higher is faster. const decimation = 1; let vertexCount = 0; this.object.updateWorldMatrix(true, true); // gets vertices this.object.traverse((child) => { if (child.isMesh) { const geometry = child.geometry; if (geometry.attributes.position) { const positionAttribute = geometry.attributes.position; for (let i = 0; i < positionAttribute.count; i++) { // Only process every Nth vertex to simplify the mesh if (vertexCount % decimation === 0) { const vertex = new THREE.Vector3(); vertex.fromBufferAttribute(positionAttribute, i); // Transform vertex to world space vertex.applyMatrix4(child.matrixWorld); // Then to parent's local space (unscaled) vertex.sub(this.object.position); vertex.applyQuaternion(this.object.quaternion.clone().invert()); vertices.push(vertex.x, vertex.y, vertex.z); } vertexCount++; } } } }); const verticesFloat32Array = new Float32Array(vertices); let rbDesc; if (this.content === null) { rbDesc = RigidBodyDesc.kinematicPositionBased(); } else { rbDesc = RigidBodyDesc.dynamic(); } rbDesc.setTranslation(this.object.position.x, this.object.position.y, this.object.position.z); this.rb = this.rapierWorld.createRigidBody(rbDesc); let colDesc; if (verticesFloat32Array.length > 0) { colDesc = ColliderDesc.convexHull(verticesFloat32Array); } else { // Fallback to a cuboid if simplification results in no vertices console.warn(`Could not generate convex hull for ${this.name}, falling back to cuboid.`); const boundingBox = new THREE.Box3().setFromObject(this.object); const size = new THREE.Vector3(); boundingBox.getSize(size); const center = new THREE.Vector3(); boundingBox.getCenter(center).sub(this.object.position); colDesc = ColliderDesc.cuboid(size.x / 2, size.y / 2, size.z / 2) .setTranslation(center.x, center.y, center.z); } this.coll = this.rapierWorld.createCollider(colDesc, this.rb); this.loadState = 'loaded'; } catch (error) { console.error(`Failed to load model for item: ${this.name}`); this.loadState = 'unloaded'; } } unloadModel() { if (this.loadState !== 'loaded') return; // Remove physics body if (this.rb) { this.rapierWorld.removeRigidBody(this.rb); this.rb = null; this.coll = null; } // Remove visual object if (this.object) { this.scene.remove(this.object); // Dispose of geometries and materials to free memory this.object.traverse(child => { if (child.isMesh) { child.geometry.dispose(); child.material.dispose(); } }); this.object = null; } // Clean up content display this._removeContentDisplay(); this.loadState = 'unloaded'; } _addObject() { if (this.object != null) { if (this.lastPosition != null) { this.object.position.copy(this.lastPosition); } else { this.object.position.copy(this.spawnPosition); } this.object.rotation.copy(this.spawnRotation); this.scene.add(this.object); } } _drawBoundingBox() { if (this.isCollider && this.isVisible) { // 1. Remove the previous frame's box if (this.debugBox) { this.scene.remove(this.debugBox); this.debugBox.geometry.dispose(); this.debugBox.material.dispose(); } if (this.loadState !== 'loaded') return; // 2. Calculate the new world-space bounding box const worldAABB = new THREE.Box3().setFromObject(this.object); const size = new THREE.Vector3(); worldAABB.getSize(size); const center = new THREE.Vector3(); worldAABB.getCenter(center); const scaleFactor = 1.2; size.multiplyScalar(scaleFactor); // 3. Create a new 2D box with the updated dimensions const w = size.x / 2; const h = size.y / 2; const points = [ new THREE.Vector3(-w, -h, 0), new THREE.Vector3(w, -h, 0), new THREE.Vector3(w, h, 0), new THREE.Vector3(-w, h, 0) ]; const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineBasicMaterial({ linewidth: 2, color: 'red' }); this.debugBox = new THREE.LineLoop(geometry, material); // 4. Position and orient the new box this.debugBox.position.copy(center); this.debugBox.quaternion.copy(this.player.playerRig.quaternion); this.scene.add(this.debugBox); } } show() { if (this.object) { this.object.visible = true; } if (this.rb) { this.rb.setEnabled(true); } this.isVisible = true; } hide() { if (this.object) { this.object.visible = false; } if (this.rb) { this.rb.setEnabled(false); } this.isVisible = false; } async _moveActiveObject(delta) { if (this.isActive) { if (this.rb && !this.rb.isKinematic()) { this.rb.setBodyType(1); // 1 is kinematic } if (!this.initialPositon) { this.initialPositon = this.object.position.clone(); this.targetPosition = this.initialPositon.clone(); this.rotationSpeed.set( (Math.random() - 0.5) * 0.5, (Math.random() - 0.5) * 0.5, (Math.random() - 0.5) * 0.5 ); await this._createContentDisplay(); } // --- Perlin noise movement --- this.noiseTime += delta * 0.05; // Adjust speed as needed const noiseX = this.noise.perlin2(this.noiseTime, 0); const noiseY = this.noise.perlin2(0, this.noiseTime); // Scale the movement const moveScale = 50.0; // Adjust for desired movement range this.targetPosition.x = this.initialPositon.x + noiseX * moveScale; this.targetPosition.y = this.initialPositon.y + (noiseY * moveScale) + 20; // Directly update the object's position for smooth animation this.object.position.lerp(this.targetPosition, 0.01); this.rb.setNextKinematicTranslation(this.object.position); this.object.rotation.x += this.rotationSpeed.x * delta; this.object.rotation.y += this.rotationSpeed.y * delta; this.object.rotation.z += this.rotationSpeed.z * delta; this.rb.setNextKinematicRotation(this.object.quaternion); if (this.content) this.content.update(); } else { if (this.rb && !this.rb.isDynamic()) { // Set the rigid body's position to the animated object's final position // before switching back to dynamic. this.rb.setTranslation(this.object.position, true); this.rb.setRotation(this.object.quaternion, true); this.rb.setBodyType(0); // 0 is dynamic this.initialPositon = null; this.targetPosition = null; this._removeContentDisplay(); } // When not active, the physics engine controls the object if (this.rb) { const pos = this.rb.translation(); const rot = this.rb.rotation(); this.object.position.set(pos.x, pos.y, pos.z); this.object.quaternion.set(rot.x, rot.y, rot.z, rot.w); } } } async _createContentDisplay() { //console.log(this.contentDisplay) // Prevent creating a new display if the old one is still fading out. if (this.content instanceof AudioContent && this.content.isFadingOut) { return; } if (this.content && !this.contentDisplay) { // Check if content needs to receieve a promise (async loading) const meshOrPromise = this.content.createDisplayMesh(); if (meshOrPromise instanceof Promise) { this.contentDisplay = await meshOrPromise; } else { this.contentDisplay = meshOrPromise; } // Only add the resolved mesh, attach to either object or player if (!(this.content instanceof AudioContent)) { this.player.camera.add(this.contentDisplay); this.contentDisplay.position.set(0, 0, -5); this.contentDisplay.rotation.set(0, 0, 0); //console.log("Added content display to screen"); } else { this.object.add(this.contentDisplay); this.contentDisplay.position.set(0, 0, 0); this.contentDisplay.rotation.set(0, 0, 0); //console.log("Added content display to Object"); } } } _removeContentDisplay() { if (this.content && this.contentDisplay) { if (!(this.content instanceof AudioContent)) { this.player.camera.remove(this.contentDisplay); this.content.dispose(); this.contentDisplay = null; //console.log("Removed content display"); } else { // If audio fade out this.content.fadeOutAndDispose(3000, this.object, this.contentDisplay); this.contentDisplay = null; } } } showOutline() { if (this.hoverIndicator || !this.object) return; function getRandomColor() { const hexChars = '0123456789ABCDEF'; let color = '#'; for (let i = 0; i < 6; i++) { color += hexChars[Math.floor(Math.random() * 16)]; } return color; } // Create a circular texture using a canvas const canvas = document.createElement('canvas'); const size = 128; canvas.width = size; canvas.height = size; const context = canvas.getContext('2d'); const centerX = size / 2; const centerY = size / 2; const radius = size / 2; // Create a radial gradient // The gradient goes from the center to the outer edge of the canvas const gradient = context.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius); // Add color stops to control opacity gradient.addColorStop(0, 'rgba(0, 0, 0, 0)'); // Center: transparent gradient.addColorStop(0.75, getRandomColor()); // 75% of the way out: fully opaque gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); // Edge: transparent // Apply the gradient and draw the circle context.fillStyle = gradient; context.fillRect(0, 0, size, size); const texture = new THREE.CanvasTexture(canvas); // Create a sprite material with the new texture const material = new THREE.SpriteMaterial({ map: texture, blending: THREE.AdditiveBlending, transparent: true }); this.hoverIndicator = new THREE.Sprite(material); // Calculate the size and position for the indicator const boundingBox = new THREE.Box3().setFromObject(this.object); const boundingSphere = new THREE.Sphere(); boundingBox.getBoundingSphere(boundingSphere); // Set the desired world scale for the indicator based on the object's size const desiredScale = boundingSphere.radius * 2.5; this.hoverIndicator.scale.set(desiredScale, desiredScale, desiredScale); // Counteract the parent's scale by dividing the indicator's scale by the parent's scale. if (this.object.scale.x !== 0 && this.object.scale.y !== 0 && this.object.scale.z !== 0) { this.hoverIndicator.scale.divide(this.object.scale); } // Position the sprite at the center of the object, in local space. this.hoverIndicator.position.copy(boundingSphere.center).sub(this.object.position); this.object.add(this.hoverIndicator); } hideOutline() { if (!this.hoverIndicator) return; this.object.remove(this.hoverIndicator); this.hoverIndicator.material.map.dispose(); this.hoverIndicator.material.dispose(); this.hoverIndicator = null; } async update(delta) { //this._drawBoundingBox(); if (this.loadState !== 'loaded') return; if (this.hoverIndicator) { this.pulseTime += delta * 5; // Adjust the multiplier to change pulse speed const minOpacity = 0.1; const maxOpacity = 0.8; // Create a sine wave that oscillates between 0 and 1 const oscillation = (Math.sin(this.pulseTime) + 1) / 4; // Apply the oscillation to the desired opacity range this.hoverIndicator.material.opacity = minOpacity + oscillation * (maxOpacity - minOpacity); } this.lastPosition = this.object.position.clone(); await this._moveActiveObject(delta); if (this.content) { this.content.update(); } } }