import * as THREE from 'three'; import { state } from '../state.js'; import { SceneFeature } from './SceneFeature.js'; import sceneFeatureManager from './SceneFeatureManager.js'; const musicianTextureUrls = [ '/textures/musician1.png', '/textures/musician2.png', '/textures/musician3.png', '/textures/musician4.png', ]; // --- Stage dimensions for positioning --- const stageHeight = 1.5; const stageDepth = 5; const length = 40; // --- Billboard Properties --- const musicianHeight = 2.5; const musicianWidth = 2.5; export class MedievalMusicians extends SceneFeature { constructor() { super(); this.musicians = []; sceneFeatureManager.register(this); } async init() { const processTexture = (texture) => { // 1. Draw texture to canvas to process it 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); // 2. Get the key color from the top-left pixel const keyPixelData = context.getImageData(0, 0, 1, 1).data; const keyColor = { r: keyPixelData[0], g: keyPixelData[1], b: keyPixelData[2] }; // 3. Process the entire canvas to make background transparent const imageData = context.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; const threshold = 20; // Adjust this for more/less color tolerance for (let i = 0; i < data.length; i += 4) { const r = data[i]; const g = data[i + 1]; const 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; // Set alpha to 0 (transparent) } } context.putImageData(imageData, 0, 0); return new THREE.CanvasTexture(canvas); }; // Load and process all textures, creating a material for each const materials = await Promise.all(musicianTextureUrls.map(async (url) => { const texture = await state.loader.loadAsync(url); const processedTexture = processTexture(texture); return new THREE.MeshStandardMaterial({ map: processedTexture, side: THREE.DoubleSide, alphaTest: 0.5, // Treat pixels with alpha < 0.5 as fully transparent roughness: 0.7, metalness: 0.1, }); })); const createMusicians = () => { // 6. Create and position the musicians const geometry = new THREE.PlaneGeometry(musicianWidth, musicianHeight); const musicianPositions = [ new THREE.Vector3(-2, stageHeight + musicianHeight / 2, -length / 2 + stageDepth / 2 - 1), new THREE.Vector3(0, stageHeight + musicianHeight / 2, -length / 2 + stageDepth / 2 - 1.5), new THREE.Vector3(2.5, stageHeight + musicianHeight / 2, -length / 2 + stageDepth / 2 - 1.2), new THREE.Vector3(1.2, stageHeight + musicianHeight / 2, -length / 2 + stageDepth / 2 - 0.4), ]; musicianPositions.forEach((pos, index) => { // Randomly pick one of the created materials const material = materials[Math.floor(index % materials.length)]; const musician = new THREE.Mesh(geometry, material); musician.position.copy(pos); state.scene.add(musician); // Store musician object with state for animation this.musicians.push({ mesh: musician, // --- State for complex movement --- currentPlane: 'stage', // 'stage' or 'floor' state: 'WAITING', targetPosition: pos.clone(), waitStartTime: 0, waitTime: 1 + Math.random() * 2, // Wait 1-3 seconds jumpStartPos: null, jumpEndPos: null, jumpProgress: 0, isMirrored: false, canChangePose: true, // --- State for jumping in place --- isJumping: false, jumpStartTime: 0, }); }); }; createMusicians(); } update(deltaTime) { // Billboard effect: make each musician face the camera if (this.musicians.length > 0) { const cameraPosition = new THREE.Vector3(); state.camera.getWorldPosition(cameraPosition); const time = state.clock.getElapsedTime(); const moveSpeed = 2.0; const stageArea = { x: 10, z: 4, y: stageHeight, centerZ: -length / 2 + stageDepth / 2 }; const floorArea = { x: 10, z: 4, y: 0, centerZ: -length / 2 + stageDepth + 2 }; const planeEdgeZ = -length / 2 + stageDepth; const planeJumpChance = 0.1; const jumpChance = 0.005; const jumpDuration = 0.5; const jumpHeight = 1.0; const jumpVariance = 1.0; const jumpPlaneVariance = 2.0; this.musicians.forEach(musicianObj => { const { mesh } = musicianObj; // We only want to rotate on the Y axis to keep them upright mesh.lookAt(cameraPosition.x, mesh.position.y, cameraPosition.z); // --- Mirroring on Beat --- if (state.music) { if (state.music.beatIntensity > 0.8 && musicianObj.canChangePose) { musicianObj.isMirrored = Math.random() < 0.5; mesh.material.map.repeat.x = musicianObj.isMirrored ? -1 : 1; mesh.material.map.offset.x = musicianObj.isMirrored ? 1 : 0; musicianObj.canChangePose = false; } else if (state.music.beatIntensity < 0.2) { musicianObj.canChangePose = true; } } // --- Main State Machine --- const area = musicianObj.currentPlane === 'stage' ? stageArea : floorArea; const otherArea = musicianObj.currentPlane === 'stage' ? floorArea : stageArea; if (musicianObj.state === 'WAITING') { if (time > musicianObj.waitStartTime + musicianObj.waitTime) { if (Math.random() < planeJumpChance) { // --- Decide to jump to the other plane --- musicianObj.state = 'PREPARING_JUMP'; const targetX = (Math.random() - 0.5) * area.x; musicianObj.targetPosition = new THREE.Vector3(targetX, area.y + musicianHeight/2, planeEdgeZ); } else { // --- Decide to move to a new spot on the current plane --- const newTarget = new THREE.Vector3( (Math.random() - 0.5) * area.x, area.y + musicianHeight/2, area.centerZ + (Math.random() - 0.5) * area.z ); musicianObj.targetPosition = newTarget; musicianObj.state = 'MOVING'; } } } else if (musicianObj.state === 'MOVING') { const distance = mesh.position.distanceTo(musicianObj.targetPosition); if (distance > 0.1) { const direction = musicianObj.targetPosition.clone().sub(mesh.position).normalize(); mesh.position.add(direction.multiplyScalar(moveSpeed * deltaTime)); } else { musicianObj.state = 'WAITING'; musicianObj.waitStartTime = time; musicianObj.waitTime = 1 + Math.random() * 2; } } else if (musicianObj.state === 'PREPARING_JUMP') { const distance = mesh.position.distanceTo(musicianObj.targetPosition); if (distance > 0.1) { const direction = musicianObj.targetPosition.clone().sub(mesh.position).normalize(); mesh.position.add(direction.multiplyScalar(moveSpeed * deltaTime)); } else { // --- Arrived at edge, start the plane jump --- musicianObj.state = 'JUMPING_PLANE'; musicianObj.jumpHeight = jumpHeight + Math.random() * jumpPlaneVariance; musicianObj.jumpStartPos = mesh.position.clone(); const targetPlane = musicianObj.currentPlane === 'stage' ? 'floor' : 'stage'; const targetArea = targetPlane === 'stage' ? stageArea : floorArea; musicianObj.jumpEndPos = new THREE.Vector3( mesh.position.x, targetArea.y + musicianHeight/2, planeEdgeZ + (targetPlane === 'stage' ? -1 : 1) ); musicianObj.targetPosition = musicianObj.jumpEndPos.clone(); musicianObj.currentPlane = targetPlane; musicianObj.jumpProgress = 0; } } else if (musicianObj.state === 'JUMPING_PLANE') { musicianObj.jumpProgress += deltaTime / jumpDuration; if (musicianObj.jumpProgress < 1) { // Determine base height based on which half of the jump we're in const baseHeight = musicianObj.jumpProgress < 0.5 ? musicianObj.jumpStartPos.y : musicianObj.jumpEndPos.y; const arcHeight = Math.sin(musicianObj.jumpProgress * Math.PI) * musicianObj.jumpHeight; // Interpolate horizontal position const horizontalProgress = musicianObj.jumpProgress; mesh.position.x = THREE.MathUtils.lerp(musicianObj.jumpStartPos.x, musicianObj.jumpEndPos.x, horizontalProgress); mesh.position.z = THREE.MathUtils.lerp(musicianObj.jumpStartPos.z, musicianObj.jumpEndPos.z, horizontalProgress); // Apply vertical arc mesh.position.y = baseHeight + arcHeight; } else { // Landed mesh.position.copy(musicianObj.jumpEndPos); musicianObj.state = 'WAITING'; musicianObj.waitStartTime = time; musicianObj.waitTime = 1 + Math.random() * 2; } } // --- Jumping in place (can happen in any state except during a plane jump) --- if (musicianObj.isJumping) { const jumpProgress = (time - musicianObj.jumpStartTime) / jumpDuration; if (jumpProgress < 1) { const baseHeight = area.y + musicianHeight/2; mesh.position.y = baseHeight + Math.sin(jumpProgress * Math.PI) * musicianObj.jumpHeight; } else { musicianObj.isJumping = false; mesh.position.y = area.y + musicianHeight / 2; } } else { let currentJumpChance = jumpChance * deltaTime; // Base chance over time if (state.music && state.music.beatIntensity > 0.8) { currentJumpChance = 0.1; // High, fixed chance on the beat } if (Math.random() < currentJumpChance && musicianObj.state !== 'JUMPING_PLANE' && musicianObj.state !== 'PREPARING_JUMP') { musicianObj.isJumping = true; musicianObj.jumpHeight = jumpHeight + Math.random() * jumpVariance; musicianObj.jumpStartTime = time; } } }); } } } new MedievalMusicians();