diff --git a/party-stage/src/scene/dj.js b/party-stage/src/scene/dj.js new file mode 100644 index 0000000..7e0ddb4 --- /dev/null +++ b/party-stage/src/scene/dj.js @@ -0,0 +1,138 @@ +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 djTextureUrls = [ + '/textures/musician1.png', + '/textures/musician2.png', + '/textures/musician3.png', + '/textures/musician4.png', + '/textures/musician5.png', +]; + +export class DJ extends SceneFeature { + constructor() { + super(); + sceneFeatureManager.register(this); + } + + async init() { + // Reuse the texture processing logic from MedievalMusicians for consistency + 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 = 25; + 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); + }; + + this.materials = await Promise.all(djTextureUrls.map(async (url) => { + const texture = await state.loader.loadAsync(url); + const processedTexture = processTexture(texture); + + 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 height = 2.5; + const width = 2.5; + const geometry = new THREE.PlaneGeometry(width, height); + this.mesh = new THREE.Mesh(geometry, this.materials[0]); + + // Position behind the console (console is at z=-16.5, depth 0.8) + // We place the DJ slightly behind the console + this.baseY = 1.5 + height / 2; + this.mesh.position.set(0, this.baseY, -17.3); + this.mesh.castShadow = true; + this.mesh.receiveShadow = true; + this.mesh.visible = false; // Start invisible until party starts + + state.scene.add(this.mesh); + + // Movement State + this.state = 'IDLE'; + this.targetX = 0; + this.moveTimer = 0; + this.canSwitchTexture = true; + } + + update(deltaTime) { + if (!this.mesh || !state.partyStarted) return; + + const time = state.clock.getElapsedTime(); + + // Billboard: Always face the camera + const cameraPos = new THREE.Vector3(); + state.camera.getWorldPosition(cameraPos); + this.mesh.lookAt(cameraPos.x, this.mesh.position.y, cameraPos.z); + + // Bobbing to music + let bobAmount = 0; + if (state.music) { + const beat = state.music.beatIntensity; + // Bob faster and deeper with the beat + bobAmount = Math.sin(time * 15) * 0.05 * (0.5 + beat); + + // Texture switching on beat + if (beat > 0.8 && this.canSwitchTexture) { + const randomMat = this.materials[Math.floor(Math.random() * this.materials.length)]; + this.mesh.material = randomMat; + this.canSwitchTexture = false; + } else if (beat < 0.2) { + this.canSwitchTexture = true; + } + } + this.mesh.position.y = this.baseY + bobAmount; + + // Movement Logic: Slide along the console + if (this.state === 'IDLE') { + this.moveTimer -= deltaTime; + if (this.moveTimer <= 0) { + this.state = 'MOVING'; + // Pick random spot behind console (width ~4.0, so range +/- 1.5 is safe) + this.targetX = (Math.random() - 0.5) * 3.0; + } + } else if (this.state === 'MOVING') { + const speed = 1.5; + if (Math.abs(this.mesh.position.x - this.targetX) < 0.1) { + this.state = 'IDLE'; + this.moveTimer = 2 + Math.random() * 5; // Wait 2-7 seconds before moving again + } else { + const dir = Math.sign(this.targetX - this.mesh.position.x); + this.mesh.position.x += dir * speed * deltaTime; + } + } + } + + onPartyStart() { + if(this.mesh) this.mesh.visible = true; + } + + onPartyEnd() { + // Optional: Hide DJ or stop movement + } +} + +new DJ(); \ No newline at end of file diff --git a/party-stage/src/scene/music-console.js b/party-stage/src/scene/music-console.js new file mode 100644 index 0000000..f4067e3 --- /dev/null +++ b/party-stage/src/scene/music-console.js @@ -0,0 +1,238 @@ +import * as THREE from 'three'; +import { state } from '../state.js'; +import { SceneFeature } from './SceneFeature.js'; +import sceneFeatureManager from './SceneFeatureManager.js'; + +export class MusicConsole extends SceneFeature { + constructor() { + super(); + this.lights = []; + sceneFeatureManager.register(this); + } + + init() { + // Stage Y top is 1.5 + const stageY = 1.5; + const consoleWidth = 4.0; + const consoleDepth = 0.8; + const consoleHeight = 1.2; + + const group = new THREE.Group(); + // Position on stage, centered + group.position.set(0, stageY, -16.5); + state.scene.add(group); + + // 1. The Stand/Table Body + const standGeo = new THREE.BoxGeometry(consoleWidth, consoleHeight, consoleDepth); + const standMat = new THREE.MeshStandardMaterial({ + color: 0x1a1a1a, + roughness: 0.3, + metalness: 0.6 + }); + const stand = new THREE.Mesh(standGeo, standMat); + stand.position.y = consoleHeight / 2; + stand.castShadow = true; + stand.receiveShadow = true; + group.add(stand); + + // 2. Control Surface (Top Plate) + const topGeo = new THREE.BoxGeometry(consoleWidth + 0.1, 0.05, consoleDepth + 0.1); + const topMat = new THREE.MeshStandardMaterial({ color: 0x050505, roughness: 0.8 }); + const topPlate = new THREE.Mesh(topGeo, topMat); + topPlate.position.y = consoleHeight + 0.025; + topPlate.receiveShadow = true; + group.add(topPlate); + + const surfaceY = consoleHeight + 0.05; + + // 3. Knobs (Left side) + const knobGeo = new THREE.CylinderGeometry(0.015, 0.015, 0.02, 12); + const knobMat = new THREE.MeshStandardMaterial({ color: 0xcccccc, metalness: 0.8, roughness: 0.2 }); + const tinyLedGeo = new THREE.CircleGeometry(0.005, 8); + tinyLedGeo.rotateX(-Math.PI / 2); + + for(let row=0; row<4; row++) { + for(let col=0; col<16; col++) { + const knob = new THREE.Mesh(knobGeo, knobMat); + const x = -consoleWidth/2 + 0.3 + (col * 0.06); + const z = -0.25 + (row * 0.08); + knob.position.set(x, surfaceY + 0.01, z); + group.add(knob); + + // Add tiny LED next to some knobs + if (Math.random() > 0.5) { + const ledMat = new THREE.MeshBasicMaterial({ color: 0x222222 }); + const led = new THREE.Mesh(tinyLedGeo, ledMat); + led.position.set(x + 0.02, surfaceY + 0.001, z + 0.02); + group.add(led); + + this.lights.push({ + mesh: led, + onColor: new THREE.Color().setHSL(Math.random(), 1.0, 0.5), + offColor: new THREE.Color(0x222222), + active: false, + nextToggle: Math.random() + }); + } + } + } + + // 4. Sliders (Middle) + const sliderTrackGeo = new THREE.BoxGeometry(0.01, 0.005, 0.25); + const sliderHandleGeo = new THREE.BoxGeometry(0.025, 0.03, 0.015); + const trackMat = new THREE.MeshBasicMaterial({ color: 0x111111 }); + const handleMat = new THREE.MeshStandardMaterial({ color: 0xffffff }); + + for(let i=0; i<24; i++) { + const x = -0.5 + (i * 0.05); + + const track = new THREE.Mesh(sliderTrackGeo, trackMat); + track.position.set(x, surfaceY, 0.1); + group.add(track); + + const handle = new THREE.Mesh(sliderHandleGeo, handleMat); + // Randomize slider positions slightly + const slidePos = (Math.random() - 0.5) * 0.2; + handle.position.set(x, surfaceY + 0.015, 0.1 + slidePos); + group.add(handle); + + // LED above slider + const ledMat = new THREE.MeshBasicMaterial({ color: 0x222222 }); + const led = new THREE.Mesh(tinyLedGeo, ledMat); + led.position.set(x, surfaceY + 0.001, -0.05); + group.add(led); + + this.lights.push({ + mesh: led, + onColor: new THREE.Color().setHSL(Math.random(), 1.0, 0.5), + offColor: new THREE.Color(0x222222), + active: false, + nextToggle: Math.random() + }); + } + + // 5. Blinky Lights (Right side grid) + const lightGeo = new THREE.PlaneGeometry(0.03, 0.03); + lightGeo.rotateX(-Math.PI / 2); // Lay flat + + const rows = 8; + const cols = 16; + + for(let r=0; r { + if (time > light.nextToggle) { + // Toggle state + light.active = !light.active; + + // Determine next toggle time based on music intensity + // Higher intensity = faster toggles + const speed = 0.1 + (1.0 - beatIntensity) * 0.5; + light.nextToggle = time + speed + Math.random() * 0.2; + } + + if (light.active) { + light.mesh.material.color.copy(light.onColor); + // Pulse brightness with beat + light.mesh.material.color.multiplyScalar(1 + beatIntensity * 2); + } else { + light.mesh.material.color.copy(light.offColor); + } + }); + + // Update Front LED Array + if (this.frontLedMesh) { + const color = new THREE.Color(); + let idx = 0; + + for(let r=0; r