import * as THREE from 'three'; import { state } from '../state.js'; import { SceneFeature } from './SceneFeature.js'; import sceneFeatureManager from './SceneFeatureManager.js'; import { applyVibrancyToMaterial } from '../shaders/vibrant-billboard-shader.js'; const dancerTextureUrls = [ '/textures/dancer1.png', ]; // --- Scene dimensions for positioning --- const stageHeight = 1.5; const stageDepth = 5; const length = 40; // --- Billboard Properties --- const dancerHeight = 2.5; const dancerWidth = 2.5; export class Dancers extends SceneFeature { constructor() { super(); this.dancers = []; sceneFeatureManager.register(this); } async init() { const processTexture = (texture) => { const image = texture.image; const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; const context = canvas.getContext('2d'); context.drawImage(image, 0, 0); const keyPixelData = context.getImageData(0, 0, 1, 1).data; const keyColor = { r: keyPixelData[0], g: keyPixelData[1], b: keyPixelData[2] }; const imageData = context.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; const threshold = 20; for (let i = 0; i < data.length; i += 4) { const r = data[i], g = data[i + 1], b = data[i + 2]; const distance = Math.sqrt(Math.pow(r - keyColor.r, 2) + Math.pow(g - keyColor.g, 2) + Math.pow(b - keyColor.b, 2)); if (distance < threshold) data[i + 3] = 0; } context.putImageData(imageData, 0, 0); return new THREE.CanvasTexture(canvas); }; const materials = await Promise.all(dancerTextureUrls.map(async (url) => { const texture = await state.loader.loadAsync(url); const processedTexture = processTexture(texture); // Configure texture for a 2x2 sprite sheet processedTexture.repeat.set(0.5, 0.5); const material = new THREE.MeshStandardMaterial({ map: processedTexture, side: THREE.DoubleSide, alphaTest: 0.5, roughness: 0.7, metalness: 0.1, }); applyVibrancyToMaterial(material, processedTexture); return material; })); const createDancers = () => { const geometry = new THREE.PlaneGeometry(dancerWidth, dancerHeight); const dancerPositions = [ new THREE.Vector3(-4, stageHeight + dancerHeight / 2, -length / 2 + stageDepth / 2 - 2), new THREE.Vector3(0, stageHeight + dancerHeight / 2, -length / 2 + stageDepth / 2 - 1.8), new THREE.Vector3(4, stageHeight + dancerHeight / 2, -length / 2 + stageDepth / 2 - 2.2), ]; dancerPositions.forEach((pos, index) => { const material = materials[index % materials.length]; const dancer = new THREE.Mesh(geometry, material); dancer.position.copy(pos); dancer.visible = false; // Start invisible state.scene.add(dancer); this.dancers.push({ mesh: dancer, baseY: pos.y, // --- Movement State --- state: 'WAITING', targetPosition: pos.clone(), waitStartTime: 0, waitTime: 1 + Math.random() * 2, // Wait 1-3 seconds // --- Animation State --- currentFrame: Math.floor(Math.random() * 4), // Start on a random frame isMirrored: false, canChangePose: true, // Flag to ensure pose changes only once per beat // --- Jumping State --- isJumping: false, jumpStartTime: 0, }); }); }; createDancers(); } update(deltaTime) { if (this.dancers.length === 0 || !state.partyStarted) return; const cameraPosition = new THREE.Vector3(); state.camera.getWorldPosition(cameraPosition); const time = state.clock.getElapsedTime(); const jumpDuration = 0.5; const jumpHeight = 2.0; const moveSpeed = 2.0; const movementArea = { x: 9, z: 3.6, centerZ: -length / 2 + stageDepth / 2 }; this.dancers.forEach(dancerObj => { const { mesh } = dancerObj; mesh.lookAt(cameraPosition.x, mesh.position.y, cameraPosition.z); // --- Point-to-Point Movement Logic --- if (dancerObj.state === 'WAITING') { if (time > dancerObj.waitStartTime + dancerObj.waitTime) { // Time to find a new spot const newTarget = new THREE.Vector3( (Math.random() - 0.5) * movementArea.x, dancerObj.baseY, movementArea.centerZ + (Math.random() - 0.5) * movementArea.z ); dancerObj.targetPosition = newTarget; dancerObj.state = 'MOVING'; } } else if (dancerObj.state === 'MOVING') { const distance = mesh.position.distanceTo(dancerObj.targetPosition); if (distance > 0.1) { const direction = dancerObj.targetPosition.clone().sub(mesh.position).normalize(); mesh.position.add(direction.multiplyScalar(moveSpeed * deltaTime)); } else { // Arrived at destination dancerObj.state = 'WAITING'; dancerObj.waitStartTime = time; dancerObj.waitTime = 1 + Math.random() * 2; // Set new wait time } } // --- Spritesheet Animation --- if (state.music) { if (state.music.beatIntensity > 0.8 && dancerObj.canChangePose) { // On the beat, select a new random frame and mirroring state dancerObj.currentFrame = Math.floor(Math.random() * 4); // Select a random frame on the beat dancerObj.isMirrored = Math.random() < 0.5; const frameX = dancerObj.currentFrame % 2; const frameY = Math.floor(dancerObj.currentFrame / 2); // Adjust repeat and offset for mirroring mesh.material.map.repeat.x = dancerObj.isMirrored ? -0.5 : 0.5; mesh.material.map.offset.x = dancerObj.isMirrored ? (frameX * 0.5) + 0.5 : frameX * 0.5; // The Y offset is inverted because UV coordinates start from the bottom-left mesh.material.map.offset.y = (1 - frameY) * 0.5; dancerObj.canChangePose = false; // Prevent changing again on this same beat } else if (state.music.beatIntensity < 0.2) { dancerObj.canChangePose = true; // Reset the flag when the beat is over } } // --- Jumping Logic --- if (dancerObj.isJumping) { const jumpProgress = (time - dancerObj.jumpStartTime) / jumpDuration; if (jumpProgress < 1.0) { mesh.position.y = dancerObj.baseY + Math.sin(jumpProgress * Math.PI) * jumpHeight; } else { dancerObj.isJumping = false; mesh.position.y = dancerObj.baseY; } } else { const musicTime = state.clock.getElapsedTime(); if (state.music && state.music.isLoudEnough && state.music.beatIntensity > 0.8 && Math.random() < 0.5 && musicTime > 10) { dancerObj.isJumping = true; dancerObj.jumpStartTime = time; } } }); } onPartyStart() { this.dancers.forEach(dancerObj => { dancerObj.mesh.visible = true; // Teleport to stage dancerObj.state = 'WAITING'; dancerObj.mesh.position.y = dancerObj.baseY; dancerObj.waitStartTime = state.clock.getElapsedTime(); }); } onPartyEnd() { this.dancers.forEach(dancerObj => { dancerObj.isJumping = false; //dancerObj.mesh.visible = false; dancerObj.state = 'WAITING'; dancerObj.waitStartTime = state.clock.getElapsedTime(); }); } } new Dancers();