Feature: DJ with colorful lights
138
party-stage/src/scene/dj.js
Normal file
@ -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();
|
||||||
238
party-stage/src/scene/music-console.js
Normal file
@ -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<rows; r++) {
|
||||||
|
for(let c=0; c<cols; c++) {
|
||||||
|
const lightMat = new THREE.MeshBasicMaterial({ color: 0x222222 });
|
||||||
|
const lightMesh = new THREE.Mesh(lightGeo, lightMat);
|
||||||
|
|
||||||
|
const x = consoleWidth/2 - 1.0 + (c * 0.05);
|
||||||
|
const z = -0.25 + (r * 0.05);
|
||||||
|
|
||||||
|
lightMesh.position.set(x, surfaceY + 0.01, z);
|
||||||
|
group.add(lightMesh);
|
||||||
|
|
||||||
|
this.lights.push({
|
||||||
|
mesh: lightMesh,
|
||||||
|
// Assign random colors (Red, Green, Blue, Amber)
|
||||||
|
onColor: new THREE.Color().setHSL(Math.random(), 1.0, 0.5),
|
||||||
|
offColor: new THREE.Color(0x222222),
|
||||||
|
active: false,
|
||||||
|
nextToggle: Math.random()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Front LED Array (InstancedMesh)
|
||||||
|
const ledRows = 10;
|
||||||
|
const ledCols = 60;
|
||||||
|
this.ledRows = ledRows;
|
||||||
|
this.ledCols = ledCols;
|
||||||
|
const ledCount = ledRows * ledCols;
|
||||||
|
|
||||||
|
const ledDisplayGeo = new THREE.PlaneGeometry(0.04, 0.04);
|
||||||
|
const ledDisplayMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||||
|
this.frontLedMesh = new THREE.InstancedMesh(ledDisplayGeo, ledDisplayMat, ledCount);
|
||||||
|
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
const spacing = 0.06;
|
||||||
|
const gridWidth = ledCols * spacing;
|
||||||
|
const gridHeight = ledRows * spacing;
|
||||||
|
|
||||||
|
// Center on the front face of the stand
|
||||||
|
const startX = -gridWidth / 2 + spacing / 2;
|
||||||
|
const startY = (consoleHeight / 2) - gridHeight / 2 + spacing / 2;
|
||||||
|
const zPos = consoleDepth / 2 + 0.01; // Slightly in front of the stand
|
||||||
|
|
||||||
|
for(let r=0; r<ledRows; r++) {
|
||||||
|
for(let c=0; c<ledCols; c++) {
|
||||||
|
dummy.position.set(startX + c * spacing, startY + r * spacing, zPos);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
this.frontLedMesh.setMatrixAt(idx, dummy.matrix);
|
||||||
|
this.frontLedMesh.setColorAt(idx, new THREE.Color(0x000000));
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.frontLedMesh.instanceMatrix.needsUpdate = true;
|
||||||
|
if (this.frontLedMesh.instanceColor) this.frontLedMesh.instanceColor.needsUpdate = true;
|
||||||
|
group.add(this.frontLedMesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime) {
|
||||||
|
if (!state.partyStarted || !state.music) return;
|
||||||
|
|
||||||
|
const time = state.clock.getElapsedTime();
|
||||||
|
const beatIntensity = state.music.beatIntensity;
|
||||||
|
|
||||||
|
this.lights.forEach(light => {
|
||||||
|
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<this.ledRows; r++) {
|
||||||
|
for(let c=0; c<this.ledCols; c++) {
|
||||||
|
const u = c / this.ledCols;
|
||||||
|
const v = r / this.ledRows;
|
||||||
|
|
||||||
|
// Dynamic wave pattern
|
||||||
|
const wave1 = Math.sin(u * 12 + time * 3);
|
||||||
|
const wave2 = Math.cos(v * 8 - time * 2);
|
||||||
|
const wave3 = Math.sin((u + v) * 5 + time * 5);
|
||||||
|
|
||||||
|
const intensity = (wave1 + wave2 + wave3) / 3 * 0.5 + 0.5;
|
||||||
|
|
||||||
|
// Color palette shifting
|
||||||
|
const hue = (time * 0.1 + u * 0.3 + intensity * 0.2) % 1;
|
||||||
|
const sat = 0.9;
|
||||||
|
const light = intensity * (0.1 + beatIntensity * 0.9); // Pulse brightness with beat
|
||||||
|
|
||||||
|
color.setHSL(hue, sat, light);
|
||||||
|
this.frontLedMesh.setColorAt(idx, color);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.frontLedMesh.instanceColor) this.frontLedMesh.instanceColor.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new MusicConsole();
|
||||||
@ -13,6 +13,8 @@ import { MusicPlayer } from './music-player.js';
|
|||||||
import { WallCurtain } from './wall-curtain.js';
|
import { WallCurtain } from './wall-curtain.js';
|
||||||
import { ReproWall } from './repro-wall.js';
|
import { ReproWall } from './repro-wall.js';
|
||||||
import { StageLights } from './stage-lights.js';
|
import { StageLights } from './stage-lights.js';
|
||||||
|
import { MusicConsole } from './music-console.js';
|
||||||
|
import { DJ } from './dj.js';
|
||||||
// Scene Features ^^^
|
// Scene Features ^^^
|
||||||
|
|
||||||
// --- Scene Modeling Function ---
|
// --- Scene Modeling Function ---
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 752 KiB After Width: | Height: | Size: 841 KiB |
|
Before Width: | Height: | Size: 878 KiB After Width: | Height: | Size: 909 KiB |
|
Before Width: | Height: | Size: 819 KiB After Width: | Height: | Size: 899 KiB |
|
Before Width: | Height: | Size: 906 KiB After Width: | Height: | Size: 914 KiB |
BIN
party-stage/textures/musician5.png
Normal file
|
After Width: | Height: | Size: 945 KiB |