New project: party stage
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
// SceneFeature.js
|
||||
|
||||
export class SceneFeature {
|
||||
init() {}
|
||||
update(deltaTime) {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// SceneFeatureManager.js
|
||||
|
||||
class SceneFeatureManager {
|
||||
constructor() {
|
||||
if (SceneFeatureManager.instance) {
|
||||
return SceneFeatureManager.instance;
|
||||
}
|
||||
|
||||
this.features = [];
|
||||
SceneFeatureManager.instance = this;
|
||||
}
|
||||
|
||||
register(feature) {
|
||||
this.features.push(feature);
|
||||
}
|
||||
|
||||
init() {
|
||||
for (const feature of this.features) {
|
||||
feature.init();
|
||||
}
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
for (const feature of this.features) {
|
||||
feature.update(deltaTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sceneFeatureManager = new SceneFeatureManager();
|
||||
export default sceneFeatureManager;
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
|
||||
const minSwitchInterval = 2;
|
||||
const maxSwitchInterval = 10;
|
||||
|
||||
export class CameraManager extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
this.cameras = [];
|
||||
this.activeCameraIndex = 0;
|
||||
this.switchInterval = 10; // seconds
|
||||
this.lastSwitchTime = 0;
|
||||
sceneFeatureManager.register(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
// The main camera from init.js is our first camera
|
||||
const mainCamera = state.camera;
|
||||
mainCamera.fov = 20;
|
||||
const mainCameraSetup = {
|
||||
camera: mainCamera,
|
||||
type: 'dynamic',
|
||||
name: 'MainDynamicCamera',
|
||||
update: this.updateDynamicCamera, // Assign its update function
|
||||
};
|
||||
this.cameras.push(mainCameraSetup);
|
||||
|
||||
// --- Static Camera 1: Left Aisle View ---
|
||||
const staticCam1 = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
|
||||
staticCam1.position.set(-5, 3, -13);
|
||||
staticCam1.lookAt(0, 2, -18); // Look at the stage
|
||||
this.cameras.push({
|
||||
camera: staticCam1,
|
||||
type: 'static',
|
||||
name: 'LeftAisleCam'
|
||||
});
|
||||
|
||||
// --- Static Camera 2: Right Aisle View ---
|
||||
const staticCam2 = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
|
||||
staticCam2.position.set(5, 4, -6);
|
||||
staticCam2.lookAt(0, 1.5, -18); // Look at the stage
|
||||
this.cameras.push({
|
||||
camera: staticCam2,
|
||||
type: 'static',
|
||||
name: 'RightAisleCam'
|
||||
});
|
||||
|
||||
// --- Static Camera 3: Far-Back view ---
|
||||
const staticCam3 = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 100);
|
||||
staticCam3.position.set(0, 3, 12);
|
||||
staticCam3.lookAt(0, 1.5, -20); // Look at the stage
|
||||
this.cameras.push({
|
||||
camera: staticCam3,
|
||||
type: 'static',
|
||||
name: 'BackCam'
|
||||
});
|
||||
|
||||
// --- Static Camera 3: Back view ---
|
||||
const staticCam4 = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 100);
|
||||
staticCam4.position.set(0, 4, 0);
|
||||
staticCam4.lookAt(0, 1.5, -20); // Look at the stage
|
||||
this.cameras.push({
|
||||
camera: staticCam4,
|
||||
type: 'static',
|
||||
name: 'BackCam'
|
||||
});
|
||||
|
||||
// make the main camera come up more often
|
||||
this.cameras.push(mainCameraSetup);
|
||||
|
||||
// --- Add Debug Helpers ---
|
||||
if (state.debugCamera) {
|
||||
this.cameras.forEach(camData => {
|
||||
const helper = new THREE.CameraHelper(camData.camera);
|
||||
state.scene.add(helper);
|
||||
});
|
||||
}
|
||||
|
||||
this.lastSwitchTime = state.clock.getElapsedTime();
|
||||
this.switchCamera(4);
|
||||
}
|
||||
|
||||
// This is the logic moved from animate.js
|
||||
updateDynamicCamera(timeDiff) {
|
||||
if (!state.partyStarted) return;
|
||||
|
||||
const globalTime = Date.now() * 0.0001;
|
||||
const lookAtTime = Date.now() * 0.0002;
|
||||
|
||||
const baseX = 0, baseY = 3.6, baseZ = -5.0;
|
||||
const camAmplitude = new THREE.Vector3(1.0, 1.0, 6.0);
|
||||
|
||||
const baseTargetX = 0, baseTargetY = 1.6, baseTargetZ = -30.0;
|
||||
const lookAmplitude = 8.0;
|
||||
|
||||
const camOffsetX = Math.sin(globalTime * 3.1) * camAmplitude.x;
|
||||
const camOffsetY = Math.cos(globalTime * 2.5) * camAmplitude.y;
|
||||
const camOffsetZ = Math.cos(globalTime * 3.2) * camAmplitude.z;
|
||||
|
||||
state.camera.position.x = baseX + camOffsetX;
|
||||
state.camera.position.y = baseY + camOffsetY;
|
||||
state.camera.position.z = baseZ + camOffsetZ;
|
||||
|
||||
const lookOffsetX = Math.sin(lookAtTime * 1.5) * lookAmplitude;
|
||||
const lookOffsetZ = Math.cos(lookAtTime * 2.5) * lookAmplitude;
|
||||
const lookOffsetY = Math.cos(lookAtTime * 1.2) * lookAmplitude * 0.5;
|
||||
|
||||
state.camera.lookAt(baseTargetX + lookOffsetX, baseTargetY + lookOffsetY, baseTargetZ + lookOffsetZ);
|
||||
}
|
||||
|
||||
switchCamera(index) {
|
||||
if (index >= this.cameras.length || index < 0) return;
|
||||
|
||||
this.activeCameraIndex = index;
|
||||
const newCam = this.cameras[this.activeCameraIndex].camera;
|
||||
|
||||
// Copy properties from the new camera to the main state camera
|
||||
state.camera.position.copy(newCam.position);
|
||||
state.camera.rotation.copy(newCam.rotation);
|
||||
state.camera.fov = newCam.fov;
|
||||
state.camera.aspect = newCam.aspect;
|
||||
state.camera.near = newCam.near;
|
||||
state.camera.far = newCam.far;
|
||||
state.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
const time = state.clock.getElapsedTime();
|
||||
|
||||
// Handle camera switching
|
||||
if (state.partyStarted) {
|
||||
if (time > this.lastSwitchTime + this.switchInterval) {
|
||||
const newIndex = Math.floor(Math.random() * this.cameras.length);
|
||||
this.switchCamera(newIndex);
|
||||
this.lastSwitchTime = time;
|
||||
this.switchInterval = minSwitchInterval + Math.random() * (maxSwitchInterval - minSwitchInterval);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the currently active camera if it has an update function
|
||||
const activeCamData = this.cameras[this.activeCameraIndex];
|
||||
if (activeCamData.update) {
|
||||
activeCamData.update();
|
||||
}
|
||||
}
|
||||
|
||||
onPartyStart() {
|
||||
// Start the camera switching timer only when the party starts
|
||||
this.lastSwitchTime = state.clock.getElapsedTime();
|
||||
}
|
||||
}
|
||||
|
||||
new CameraManager();
|
||||
@@ -0,0 +1,207 @@
|
||||
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();
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
|
||||
// --- Dimensions from room-walls.js for positioning ---
|
||||
const naveWidth = 12;
|
||||
const naveHeight = 15;
|
||||
const length = 40;
|
||||
|
||||
export class LightBall extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
this.lightBalls = [];
|
||||
sceneFeatureManager.register(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
// --- Ball Properties ---
|
||||
const ballRadius = 0.2;
|
||||
const lightIntensity = 5.0;
|
||||
const lightColors = [0xff2222, 0x11ff11, 0x2222ff, 0xffff11, 0x00ffff, 0xff00ff]; // Red, Green, Blue, Yellow
|
||||
|
||||
lightColors.forEach(color => {
|
||||
// --- Create the Ball ---
|
||||
const ballGeometry = new THREE.SphereGeometry(ballRadius, 32, 32);
|
||||
const ballMaterial = new THREE.MeshBasicMaterial({ color: color, emissive: color, emissiveIntensity: 1.2 });
|
||||
const ball = new THREE.Mesh(ballGeometry, ballMaterial);
|
||||
ball.castShadow = false;
|
||||
ball.receiveShadow = false;
|
||||
ball.visible = false; // Start invisible
|
||||
|
||||
// --- Create the Light ---
|
||||
const light = new THREE.PointLight(color, lightIntensity, length / 1.5);
|
||||
light.visible = false; // Start invisible
|
||||
|
||||
// --- Initial Position ---
|
||||
ball.position.set(
|
||||
(Math.random() - 0.5) * naveWidth,
|
||||
naveHeight * 0.6 + Math.random() * 4,
|
||||
(Math.random() - 0.5) * length * 0.8
|
||||
);
|
||||
light.position.copy(ball.position);
|
||||
|
||||
//state.scene.add(ball); // no need to show the ball
|
||||
state.scene.add(light);
|
||||
|
||||
this.lightBalls.push({
|
||||
mesh: ball,
|
||||
light: light,
|
||||
driftSpeed: 0.2 + Math.random() * 0.2,
|
||||
driftAmplitude: 4.0 + Math.random() * 4.0,
|
||||
offset: Math.random() * Math.PI * 6,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
if (!state.partyStarted) return;
|
||||
|
||||
const time = state.clock.getElapsedTime();
|
||||
this.lightBalls.forEach(lb => {
|
||||
const { mesh, light, driftSpeed, offset } = lb;
|
||||
mesh.position.x = Math.sin(time * driftSpeed + offset) * naveWidth/2 * 0.8;
|
||||
mesh.position.y = 10 + Math.cos(time * driftSpeed * 1.3 + offset) * naveHeight/2 * 0.6;
|
||||
mesh.position.z = Math.cos(time * driftSpeed * 0.7 + offset) * length/2 * 0.8;
|
||||
light.position.copy(mesh.position);
|
||||
|
||||
// --- Music Visualization ---
|
||||
if (state.music) {
|
||||
const baseIntensity = 4.0;
|
||||
light.intensity = baseIntensity + state.music.beatIntensity * 3.0;
|
||||
|
||||
const baseScale = 1.0;
|
||||
mesh.scale.setScalar(baseScale + state.music.beatIntensity * 0.5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPartyStart() {
|
||||
this.lightBalls.forEach(lb => {
|
||||
//lb.mesh.visible = true; // no visible ball
|
||||
lb.light.visible = true;
|
||||
});
|
||||
}
|
||||
|
||||
onPartyEnd() {
|
||||
this.lightBalls.forEach(lb => {
|
||||
lb.mesh.visible = false;
|
||||
lb.light.visible = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
new LightBall();
|
||||
@@ -0,0 +1,129 @@
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
|
||||
export class MusicPlayer extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
this.audioContext = null;
|
||||
this.analyser = null;
|
||||
this.source = null;
|
||||
this.dataArray = null;
|
||||
this.loudnessHistory = [];
|
||||
sceneFeatureManager.register(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
state.music.player = document.getElementById('audioPlayer');
|
||||
state.music.loudness = 0;
|
||||
state.music.isLoudEnough = false;
|
||||
|
||||
const loadButton = document.getElementById('loadMusicButton');
|
||||
const fileInput = document.getElementById('musicFileInput');
|
||||
const uiContainer = document.getElementById('ui-container');
|
||||
const metadataContainer = document.getElementById('metadata-container');
|
||||
const songTitleElement = document.getElementById('song-title');
|
||||
|
||||
loadButton.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
// Setup Web Audio API if not already done
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
this.analyser.fftSize = 128; // Lower resolution is fine for loudness
|
||||
this.source = this.audioContext.createMediaElementSource(state.music.player);
|
||||
this.source.connect(this.analyser);
|
||||
this.analyser.connect(this.audioContext.destination);
|
||||
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
|
||||
}
|
||||
|
||||
// Hide the main button
|
||||
loadButton.style.display = 'none';
|
||||
|
||||
// Show metadata
|
||||
songTitleElement.textContent = file.name.replace(/\.[^/.]+$/, ""); // Show filename without extension
|
||||
metadataContainer.classList.remove('hidden');
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
state.music.player.src = url;
|
||||
|
||||
// Wait 5 seconds, then start the party
|
||||
setTimeout(() => {
|
||||
metadataContainer.classList.add('hidden');
|
||||
this.startParty();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
state.music.player.addEventListener('ended', () => {
|
||||
this.stopParty();
|
||||
uiContainer.style.display = 'flex'; // Show the button again
|
||||
});
|
||||
}
|
||||
|
||||
startParty() {
|
||||
state.clock.start();
|
||||
state.music.player.play();
|
||||
document.getElementById('ui-container').style.display = 'none';
|
||||
state.partyStarted = true;
|
||||
|
||||
// You could add BPM detection here in the future
|
||||
// For now, we use the fixed BPM
|
||||
|
||||
// Trigger 'start' event for other features
|
||||
this.notifyFeatures('onPartyStart');
|
||||
}
|
||||
|
||||
stopParty() {
|
||||
state.clock.stop();
|
||||
state.partyStarted = false;
|
||||
setTimeout(() => {
|
||||
const startButton = document.getElementById('loadMusicButton');
|
||||
startButton.style.display = 'block';
|
||||
startButton.textContent = "Party some more?"
|
||||
}, 5000);
|
||||
// Trigger 'end' event for other features
|
||||
this.notifyFeatures('onPartyEnd');
|
||||
}
|
||||
|
||||
notifyFeatures(methodName) {
|
||||
sceneFeatureManager.features.forEach(feature => {
|
||||
if (typeof feature[methodName] === 'function') {
|
||||
feature[methodName]();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
if (!state.partyStarted || !this.analyser) return;
|
||||
|
||||
this.analyser.getByteFrequencyData(this.dataArray);
|
||||
|
||||
// --- Calculate current loudness ---
|
||||
let sum = 0;
|
||||
for (let i = 0; i < this.dataArray.length; i++) {
|
||||
sum += this.dataArray[i];
|
||||
}
|
||||
const average = sum / this.dataArray.length;
|
||||
state.music.loudness = average / 255; // Normalize to 0-1 range
|
||||
|
||||
// --- Track loudness over the last 2 seconds ---
|
||||
this.loudnessHistory.push(state.music.loudness);
|
||||
if (this.loudnessHistory.length > 120) { // Assuming ~60fps, 2 seconds of history
|
||||
this.loudnessHistory.shift();
|
||||
}
|
||||
|
||||
// --- Determine if it's loud enough to jump ---
|
||||
const avgLoudness = this.loudnessHistory.reduce((a, b) => a + b, 0) / this.loudnessHistory.length;
|
||||
const quietThreshold = 0.1; // Adjust this value based on testing
|
||||
|
||||
state.music.isLoudEnough = avgLoudness > quietThreshold;
|
||||
}
|
||||
}
|
||||
|
||||
new MusicPlayer();
|
||||
@@ -0,0 +1,40 @@
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
|
||||
export class MusicVisualizer extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
sceneFeatureManager.register(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize music state
|
||||
state.music = {
|
||||
bpm: 120,
|
||||
beatDuration: 60 / 120,
|
||||
measureDuration: (60 / 120) * 4,
|
||||
beatIntensity: 0,
|
||||
measurePulse: 0,
|
||||
isLoudEnough: false,
|
||||
};
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
if (!state.music || !state.partyStarted) return;
|
||||
|
||||
const time = state.clock.getElapsedTime();
|
||||
|
||||
// --- Calculate Beat Intensity (pulses every beat) ---
|
||||
// This creates a sharp attack and slower decay (0 -> 1 -> 0)
|
||||
const beatProgress = (time % state.music.beatDuration) / state.music.beatDuration;
|
||||
state.music.beatIntensity = Math.pow(1.0 - beatProgress, 2);
|
||||
|
||||
// --- Calculate Measure Pulse (spikes every 4 beats) ---
|
||||
// This creates a very sharp spike for the torch flame effect
|
||||
const measureProgress = (time % state.music.measureDuration) / state.music.measureDuration;
|
||||
state.music.measurePulse = measureProgress < 0.2 ? Math.sin(measureProgress * Math.PI * 5) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
new MusicVisualizer();
|
||||
@@ -0,0 +1,191 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
const guestTextureUrls = [
|
||||
'/textures/guest1.png',
|
||||
'/textures/guest2.png',
|
||||
'/textures/guest3.png',
|
||||
'/textures/guest4.png',
|
||||
];
|
||||
|
||||
// --- Scene dimensions for positioning ---
|
||||
const stageHeight = 1.5;
|
||||
const stageDepth = 5;
|
||||
const length = 44;
|
||||
|
||||
// --- Billboard Properties ---
|
||||
const guestHeight = 2.5;
|
||||
const guestWidth = 2.5;
|
||||
|
||||
export class PartyGuests extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
this.guests = [];
|
||||
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(guestTextureUrls.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,
|
||||
roughness: 0.7,
|
||||
metalness: 0.1,
|
||||
});
|
||||
}));
|
||||
|
||||
const createGuests = () => {
|
||||
const geometry = new THREE.PlaneGeometry(guestWidth, guestHeight);
|
||||
const numGuests = 80;
|
||||
|
||||
for (let i = 0; i < numGuests; i++) {
|
||||
const material = materials[i % materials.length];
|
||||
const guest = new THREE.Mesh(geometry, material);
|
||||
const pos = new THREE.Vector3(
|
||||
(Math.random() - 0.5) * 10,
|
||||
guestHeight / 2,
|
||||
(Math.random() * 20) - 2 // Position them in the main hall
|
||||
);
|
||||
guest.visible = false; // Start invisible
|
||||
guest.position.copy(pos);
|
||||
state.scene.add(guest);
|
||||
|
||||
this.guests.push({
|
||||
mesh: guest,
|
||||
state: 'WAITING',
|
||||
targetPosition: pos.clone(),
|
||||
waitStartTime: 0,
|
||||
waitTime: 3 + Math.random() * 4, // Wait longer: 3-7 seconds
|
||||
isMirrored: false,
|
||||
canChangePose: true,
|
||||
isJumping: false,
|
||||
jumpStartTime: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
createGuests();
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
if (this.guests.length === 0 || !state.partyStarted) return;
|
||||
|
||||
const cameraPosition = new THREE.Vector3();
|
||||
state.camera.getWorldPosition(cameraPosition);
|
||||
|
||||
const time = state.clock.getElapsedTime();
|
||||
const moveSpeed = 1.0; // Move slower
|
||||
const movementArea = { x: 15, z: 20, y: 0, centerZ: -4 };
|
||||
const jumpChance = 0.05; // Jump way more
|
||||
const jumpDuration = 0.5;
|
||||
const jumpHeight = 0.1;
|
||||
const jumpVariance = 0.5;
|
||||
|
||||
this.guests.forEach(guestObj => {
|
||||
const { mesh } = guestObj;
|
||||
mesh.lookAt(cameraPosition.x, mesh.position.y, cameraPosition.z);
|
||||
|
||||
// --- Mirroring on Beat ---
|
||||
if (state.music) {
|
||||
if (state.music.beatIntensity > 0.8 && guestObj.canChangePose) {
|
||||
guestObj.isMirrored = Math.random() < 0.5;
|
||||
mesh.material.map.repeat.x = guestObj.isMirrored ? -1 : 1;
|
||||
mesh.material.map.offset.x = guestObj.isMirrored ? 1 : 0;
|
||||
guestObj.canChangePose = false;
|
||||
} else if (state.music.beatIntensity < 0.2) {
|
||||
guestObj.canChangePose = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (guestObj.state === 'WAITING') {
|
||||
if (time > guestObj.waitStartTime + guestObj.waitTime) {
|
||||
const newTarget = new THREE.Vector3(
|
||||
(Math.random() - 0.5) * movementArea.x,
|
||||
movementArea.y + guestHeight / 2,
|
||||
movementArea.centerZ + (Math.random() - 0.5) * movementArea.z
|
||||
);
|
||||
guestObj.targetPosition = newTarget;
|
||||
guestObj.state = 'MOVING';
|
||||
}
|
||||
} else if (guestObj.state === 'MOVING') {
|
||||
const distance = mesh.position.distanceTo(guestObj.targetPosition);
|
||||
if (distance > 0.1) {
|
||||
const direction = guestObj.targetPosition.clone().sub(mesh.position).normalize();
|
||||
mesh.position.add(direction.multiplyScalar(moveSpeed * deltaTime));
|
||||
} else {
|
||||
guestObj.state = 'WAITING';
|
||||
guestObj.waitStartTime = time;
|
||||
guestObj.waitTime = 3 + Math.random() * 4;
|
||||
}
|
||||
}
|
||||
|
||||
if (guestObj.isJumping) {
|
||||
const jumpProgress = (time - guestObj.jumpStartTime) / jumpDuration;
|
||||
if (jumpProgress < 1) {
|
||||
const baseHeight = movementArea.y + guestHeight / 2;
|
||||
mesh.position.y = baseHeight + Math.sin(jumpProgress * Math.PI) * guestObj.jumpHeight;
|
||||
} else {
|
||||
guestObj.isJumping = false;
|
||||
mesh.position.y = movementArea.y + guestHeight / 2;
|
||||
}
|
||||
} else {
|
||||
let currentJumpChance = jumpChance * deltaTime; // Base chance over time
|
||||
if (state.music && state.music.isLoudEnough && state.music.beatIntensity > 0.8) {
|
||||
currentJumpChance = 0.1; // High, fixed chance on the beat
|
||||
}
|
||||
|
||||
if (Math.random() < currentJumpChance) {
|
||||
guestObj.isJumping = true;
|
||||
guestObj.jumpHeight = jumpHeight + Math.random() * jumpVariance;
|
||||
guestObj.jumpStartTime = time;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPartyStart() {
|
||||
const stageFrontZ = -40 / 2 + 5 + 5; // In front of the stage
|
||||
this.guests.forEach((guestObj, index) => {
|
||||
guestObj.mesh.visible = true;
|
||||
// Rush to the stage
|
||||
guestObj.state = 'MOVING';
|
||||
if (index % 2 === 0) {
|
||||
guestObj.targetPosition.z = stageFrontZ + (Math.random() - 0.5) * 5;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPartyEnd() {
|
||||
this.guests.forEach(guestObj => {
|
||||
guestObj.isJumping = false;
|
||||
guestObj.state = 'WAITING';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
new PartyGuests();
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
|
||||
export class ReproWall extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
this.boxes = [];
|
||||
sceneFeatureManager.register(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
const boxSize = 1.2;
|
||||
const geometry = new THREE.BoxGeometry(boxSize, boxSize, boxSize);
|
||||
|
||||
const cabinetMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x333333,
|
||||
roughness: 0.6,
|
||||
metalness: 0.2,
|
||||
});
|
||||
|
||||
const meshMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x050505,
|
||||
roughness: 0.9,
|
||||
metalness: 0.1,
|
||||
});
|
||||
|
||||
const materials = [
|
||||
cabinetMaterial, cabinetMaterial, cabinetMaterial,
|
||||
cabinetMaterial, meshMaterial, cabinetMaterial
|
||||
];
|
||||
|
||||
// Helper to create a stack of boxes
|
||||
const createStack = (baseX, baseZ) => {
|
||||
const stackHeight = 3 + Math.floor(Math.random() * 4); // 3 to 6 boxes high
|
||||
for (let i = 0; i < stackHeight; i++) {
|
||||
const box = new THREE.Mesh(geometry, materials);
|
||||
// Slight random offset for realism
|
||||
const x = baseX + (Math.random() * 0.1 - 0.05);
|
||||
const z = baseZ + (Math.random() * 0.1 - 0.05);
|
||||
const y = (i * boxSize) + (boxSize / 2);
|
||||
|
||||
box.position.set(x, y, z);
|
||||
|
||||
// Slight random rotation
|
||||
box.rotation.y = (Math.random() * 0.1 - 0.05);
|
||||
|
||||
box.castShadow = true;
|
||||
box.receiveShadow = true;
|
||||
|
||||
state.scene.add(box);
|
||||
this.boxes.push({ mesh: box, originalScale: new THREE.Vector3(1, 1, 1) });
|
||||
}
|
||||
};
|
||||
|
||||
// Create walls on both sides of the stage
|
||||
const startZ = -20;
|
||||
const endZ = -18;
|
||||
const leftX = -8;
|
||||
const rightX = 8;
|
||||
|
||||
for (let z = startZ; z <= endZ; z += boxSize) {
|
||||
// Left side wall (2 layers deep)
|
||||
createStack(leftX, z);
|
||||
createStack(leftX - boxSize, z);
|
||||
|
||||
// Right side wall (2 layers deep)
|
||||
createStack(rightX, z);
|
||||
createStack(rightX + boxSize, z);
|
||||
}
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
if (state.music && state.music.beatIntensity > 0.5) {
|
||||
const scale = 1 + (state.music.beatIntensity - 0.5) * 0.1;
|
||||
this.boxes.forEach(item => {
|
||||
item.mesh.scale.setScalar(scale);
|
||||
});
|
||||
} else {
|
||||
this.boxes.forEach(item => {
|
||||
item.mesh.scale.lerp(item.originalScale, deltaTime * 5);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new ReproWall();
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import floorTextureUrl from '/textures/floor.png';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
// Scene Features registered here:
|
||||
import { CameraManager } from './camera-manager.js';
|
||||
import { LightBall } from './light-ball.js';
|
||||
import { Stage } from './stage.js';
|
||||
import { PartyGuests } from './party-guests.js';
|
||||
import { StageTorches } from './stage-torches.js';
|
||||
import { MusicVisualizer } from './music-visualizer.js';
|
||||
import { RoseWindowLight } from './rose-window-light.js';
|
||||
import { RoseWindowLightshafts } from './rose-window-lightshafts.js';
|
||||
import { MusicPlayer } from './music-player.js';
|
||||
import { WallCurtain } from './wall-curtain.js';
|
||||
import { ReproWall } from './repro-wall.js';
|
||||
// Scene Features ^^^
|
||||
|
||||
// --- Scene Modeling Function ---
|
||||
export function createSceneObjects() {
|
||||
sceneFeatureManager.init();
|
||||
|
||||
// --- Materials (MeshPhongMaterial) ---
|
||||
|
||||
// --- 1. Floor --- (Resized to match the new cathedral dimensions)
|
||||
const floorWidth = 30;
|
||||
const floorLength = 50;
|
||||
const floorGeometry = new THREE.PlaneGeometry(floorWidth, floorLength);
|
||||
const floorTexture = state.loader.load(floorTextureUrl);
|
||||
floorTexture.wrapS = THREE.RepeatWrapping;
|
||||
floorTexture.wrapT = THREE.RepeatWrapping;
|
||||
floorTexture.repeat.set(floorWidth / 5, floorLength / 5); // Adjust texture repeat for new size
|
||||
const floorMaterial = new THREE.MeshPhongMaterial({ map: floorTexture, color: 0x666666, shininess: 5 });
|
||||
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.position.y = 0;
|
||||
floor.receiveShadow = true;
|
||||
state.scene.add(floor);
|
||||
|
||||
// 3. Lighting (Minimal and focused)
|
||||
const ambientLight = new THREE.AmbientLight(0x606060, 0.2); // Increased ambient light for a larger space
|
||||
state.scene.add(ambientLight);
|
||||
|
||||
// Add a HemisphereLight for more natural, general illumination in a large space.
|
||||
const hemisphereLight = new THREE.HemisphereLight(0xffddcc, 0x444455, 0.5);
|
||||
|
||||
// Visual aids for the light source positions
|
||||
if (state.debugLight && THREE.HemisphereLightHelper) {
|
||||
// Lamp Helper will now work since lampLight is added to the scene
|
||||
const hemisphereLightHelper = new THREE.HemisphereLightHelper(hemisphereLight, 0.1, 0x00ff00); // Green for lamp
|
||||
state.scene.add(hemisphereLightHelper);
|
||||
}
|
||||
|
||||
state.scene.add(hemisphereLight);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
|
||||
export class RoseWindowLight extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
this.spotlight = null;
|
||||
this.helper = null;
|
||||
sceneFeatureManager.register(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
// --- Dimensions for positioning ---
|
||||
const length = 40;
|
||||
const naveHeight = 15;
|
||||
const stageDepth = 5;
|
||||
|
||||
// --- Create the spotlight ---
|
||||
this.spotlight = new THREE.SpotLight(0xffffff, 100.0); // White light, high intensity
|
||||
this.spotlight.position.set(0, naveHeight, -length / 2 + 10); // Position it at the rose window
|
||||
this.spotlight.angle = Math.PI / 9; // A reasonably focused beam
|
||||
this.spotlight.penumbra = 0.3; // Soft edges
|
||||
this.spotlight.decay = 0.7;
|
||||
this.spotlight.distance = 30;
|
||||
|
||||
this.spotlight.castShadow = false;
|
||||
this.spotlight.shadow.mapSize.width = 1024;
|
||||
this.spotlight.shadow.mapSize.height = 1024;
|
||||
this.spotlight.shadow.camera.near = 1;
|
||||
this.spotlight.shadow.camera.far = 30;
|
||||
this.spotlight.shadow.focus = 1;
|
||||
|
||||
// --- Create a target for the spotlight to aim at ---
|
||||
const targetObject = new THREE.Object3D();
|
||||
targetObject.position.set(0, 0, -length / 2 + stageDepth); // Aim at the center of the stage
|
||||
state.scene.add(targetObject);
|
||||
this.spotlight.target = targetObject;
|
||||
|
||||
state.scene.add(this.spotlight);
|
||||
|
||||
// --- Add a debug helper ---
|
||||
if (state.debugLight) {
|
||||
this.helper = new THREE.SpotLightHelper(this.spotlight);
|
||||
state.scene.add(this.helper);
|
||||
}
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
if (!this.spotlight) return;
|
||||
|
||||
// Make the light pulse with the music
|
||||
if (state.music) {
|
||||
const baseIntensity = 4.0;
|
||||
this.spotlight.intensity = baseIntensity + state.music.beatIntensity * 1.0;
|
||||
}
|
||||
|
||||
// Update the helper if it exists
|
||||
if (this.helper) {
|
||||
this.helper.update();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new RoseWindowLight();
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
|
||||
export class RoseWindowLightshafts extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
this.shafts = [];
|
||||
sceneFeatureManager.register(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
// --- Dimensions for positioning ---
|
||||
const length = 40;
|
||||
const naveWidth = 12;
|
||||
const naveHeight = 15;
|
||||
const stageDepth = 5;
|
||||
const stageWidth = naveWidth - 1;
|
||||
|
||||
const roseWindowRadius = naveWidth / 2 - 2;
|
||||
const roseWindowCenter = new THREE.Vector3(0, naveHeight, -length / 2 - 1.1);
|
||||
|
||||
// --- Procedural Noise Texture for Light Shafts ---
|
||||
const createNoiseTexture = () => {
|
||||
const width = 128;
|
||||
const height = 512;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
const imageData = context.createImageData(width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
// Create vertical streaks of noise
|
||||
const y = Math.floor((i / 4) / width);
|
||||
const noise = Math.pow(Math.random(), 2.5) * (1 - y / height) * 255;
|
||||
data[i] = noise; // R
|
||||
data[i + 1] = noise; // G
|
||||
data[i + 2] = noise; // B
|
||||
data[i + 3] = 255; // A
|
||||
}
|
||||
context.putImageData(imageData, 0, 0);
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
};
|
||||
|
||||
const baseMaterial = new THREE.MeshBasicMaterial({
|
||||
//map: texture,
|
||||
blending: THREE.AdditiveBlending,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
opacity: 1.0,
|
||||
color: 0x88aaff, // Give the light a cool blueish tint
|
||||
});
|
||||
|
||||
// --- Create multiple thin light shafts ---
|
||||
const numShafts = 16;
|
||||
for (let i = 0; i < numShafts; i++) {
|
||||
const texture = createNoiseTexture();
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
const material = baseMaterial.clone(); // Each shaft needs its own material for individual opacity
|
||||
material.map = texture;
|
||||
|
||||
const startAngle = Math.random() * Math.PI * 2;
|
||||
const startRadius = Math.random() * roseWindowRadius;
|
||||
const startPoint = new THREE.Vector3(
|
||||
roseWindowCenter.x + Math.cos(startAngle) * startRadius,
|
||||
roseWindowCenter.y + Math.sin(startAngle) * startRadius,
|
||||
roseWindowCenter.z
|
||||
);
|
||||
|
||||
// Define a linear path on the floor for the beam to travel
|
||||
const floorStartPoint = new THREE.Vector3(
|
||||
(Math.random() - 0.5) * stageWidth * 0.75,
|
||||
0,
|
||||
-length / 2 + Math.random() * 8 + 0
|
||||
);
|
||||
const floorEndPoint = new THREE.Vector3(
|
||||
(Math.random() - 0.5) * stageWidth * 0.75,
|
||||
0,
|
||||
-length / 2 + Math.random() * 8 + 3
|
||||
);
|
||||
|
||||
const distance = startPoint.distanceTo(floorStartPoint);
|
||||
const geometry = new THREE.CylinderGeometry(0.01, 0.5 + Math.random() * 0.5, distance, 16, 1, true);
|
||||
const lightShaft = new THREE.Mesh(geometry, material);
|
||||
|
||||
state.scene.add(lightShaft);
|
||||
this.shafts.push({
|
||||
mesh: lightShaft,
|
||||
startPoint: startPoint, // The stationary point in the window
|
||||
endPoint: floorStartPoint.clone(), // The current position of the beam on the floor
|
||||
floorStartPoint: floorStartPoint, // The start of the sweep path
|
||||
floorEndPoint: floorEndPoint, // The end of the sweep path
|
||||
moveSpeed: 0.01 + Math.random() * 0.5, // Each shaft has a different speed
|
||||
// No 'state' needed anymore
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
const baseOpacity = 0.1;
|
||||
|
||||
this.shafts.forEach(shaft => {
|
||||
const { mesh, startPoint, endPoint, floorStartPoint, floorEndPoint, moveSpeed } = shaft;
|
||||
|
||||
// Animate texture for dust motes
|
||||
mesh.material.map.offset.y += deltaTime * 0.004;
|
||||
mesh.material.map.offset.x -= deltaTime * 0.02;
|
||||
|
||||
if (mesh.material.map.offset.y >= 0.2) {
|
||||
mesh.material.map.offset.y -= 0.2;
|
||||
}
|
||||
if (mesh.material.map.offset.x <= 0.0) {
|
||||
mesh.material.map.offset.x += 1.0;
|
||||
}
|
||||
|
||||
// --- Movement Logic ---
|
||||
const pathDirection = floorEndPoint.clone().sub(floorStartPoint).normalize();
|
||||
const pathLength = floorStartPoint.distanceTo(floorEndPoint);
|
||||
|
||||
// Move the endpoint along its path
|
||||
endPoint.add(pathDirection.clone().multiplyScalar(moveSpeed * deltaTime));
|
||||
|
||||
const currentDistance = floorStartPoint.distanceTo(endPoint);
|
||||
|
||||
if (currentDistance >= pathLength) {
|
||||
// Reached the end, reset to the start
|
||||
endPoint.copy(floorStartPoint);
|
||||
}
|
||||
|
||||
// --- Opacity based on Progress ---
|
||||
const progress = Math.min(currentDistance / pathLength, 1.0);
|
||||
// Use a sine curve to fade in at the start and out at the end
|
||||
const fadeOpacity = Math.sin(progress * Math.PI) * baseOpacity;
|
||||
|
||||
// --- Update Mesh Position and Orientation ---
|
||||
const distance = startPoint.distanceTo(endPoint);
|
||||
mesh.scale.y = -distance/5;
|
||||
mesh.position.lerpVectors(startPoint, endPoint, 0.5);
|
||||
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const cylinderUp = new THREE.Vector3(0, 1, 0);
|
||||
const direction = new THREE.Vector3().subVectors(endPoint, startPoint).normalize();
|
||||
quaternion.setFromUnitVectors(cylinderUp, direction);
|
||||
mesh.quaternion.copy(quaternion);
|
||||
|
||||
// --- Music Visualization ---
|
||||
const beatPulse = state.music ? state.music.beatIntensity * 0.05 : 0;
|
||||
mesh.material.opacity = fadeOpacity + beatPulse;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
new RoseWindowLightshafts();
|
||||
@@ -0,0 +1,170 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
import sparkTextureUrl from '/textures/spark.png';
|
||||
|
||||
const lightPositionBaseY = 1.2;
|
||||
|
||||
export class StageTorches extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
this.torches = [];
|
||||
sceneFeatureManager.register(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
// --- Stage Dimensions for positioning ---
|
||||
const length = 40;
|
||||
const naveWidth = 12;
|
||||
const stageWidth = naveWidth - 2;
|
||||
const stageHeight = 1.5;
|
||||
const stageDepth = 5;
|
||||
|
||||
const torchPositions = [
|
||||
new THREE.Vector3(-stageWidth / 2, stageHeight, -length / 2 + 0.5),
|
||||
new THREE.Vector3(stageWidth / 2, stageHeight, -length / 2 + 0.5),
|
||||
new THREE.Vector3(-stageWidth / 2, stageHeight, -length / 2 + stageDepth - 0.5),
|
||||
new THREE.Vector3(stageWidth / 2, stageHeight, -length / 2 + stageDepth - 0.5),
|
||||
];
|
||||
|
||||
torchPositions.forEach(pos => {
|
||||
const torch = this.createTorch(pos);
|
||||
this.torches.push(torch);
|
||||
state.scene.add(torch.group);
|
||||
});
|
||||
}
|
||||
|
||||
createTorch(position) {
|
||||
const torchGroup = new THREE.Group();
|
||||
torchGroup.position.copy(position);
|
||||
torchGroup.visible = false; // Start invisible
|
||||
|
||||
// --- Torch Holder ---
|
||||
const holderMaterial = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.6, metalness: 0.5 });
|
||||
const holderGeo = new THREE.CylinderGeometry(0.1, 0.15, 1.0, 12);
|
||||
const holderMesh = new THREE.Mesh(holderGeo, holderMaterial);
|
||||
holderMesh.position.y = 0.5;
|
||||
holderMesh.castShadow = true;
|
||||
holderMesh.receiveShadow = true;
|
||||
torchGroup.add(holderMesh);
|
||||
|
||||
// --- Point Light ---
|
||||
const pointLight = new THREE.PointLight(0xffaa44, 2.5, 8);
|
||||
pointLight.position.y = lightPositionBaseY;
|
||||
pointLight.castShadow = true;
|
||||
pointLight.shadow.mapSize.width = 128;
|
||||
pointLight.shadow.mapSize.height = 128;
|
||||
torchGroup.add(pointLight);
|
||||
|
||||
// --- Particle System for Fire ---
|
||||
const particleCount = 100;
|
||||
const particles = new THREE.BufferGeometry();
|
||||
const positions = [];
|
||||
const particleData = [];
|
||||
|
||||
const sparkTexture = state.loader.load(sparkTextureUrl);
|
||||
const particleMaterial = new THREE.PointsMaterial({
|
||||
map: sparkTexture,
|
||||
color: 0xffaa00,
|
||||
size: 0.5,
|
||||
blending: THREE.AdditiveBlending,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
positions.push(0, 1, 0);
|
||||
particleData.push({
|
||||
velocity: new THREE.Vector3((Math.random() - 0.5) * 0.2, Math.random() * 1.5, (Math.random() - 0.5) * 0.2),
|
||||
life: Math.random() * 1.0,
|
||||
});
|
||||
}
|
||||
particles.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
const particleSystem = new THREE.Points(particles, particleMaterial);
|
||||
torchGroup.add(particleSystem);
|
||||
|
||||
return { group: torchGroup, light: pointLight, particles: particleSystem, particleData: particleData };
|
||||
}
|
||||
|
||||
resetParticles(torch) {
|
||||
const positions = torch.particles.geometry.attributes.position.array;
|
||||
for (let i = 0; i < torch.particleData.length; i++) {
|
||||
const data = torch.particleData[i];
|
||||
// Reset particle
|
||||
positions[i * 3] = 0;
|
||||
positions[i * 3 + 1] = 1;
|
||||
positions[i * 3 + 2] = 0;
|
||||
data.life = Math.random() * 1.0;
|
||||
data.velocity.y = Math.random() * 1.5;
|
||||
}
|
||||
torch.particles.geometry.attributes.position.needsUpdate = true;
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
if (!state.partyStarted) return;
|
||||
|
||||
this.torches.forEach(torch => {
|
||||
let measurePulse = 0;
|
||||
if (state.music) {
|
||||
measurePulse = state.music.measurePulse * 2.0; // Make flames jump higher
|
||||
}
|
||||
if (state.music.isLoudEnough) {
|
||||
measurePulse += 2;
|
||||
}
|
||||
|
||||
// --- Animate Particles ---
|
||||
const positions = torch.particles.geometry.attributes.position.array;
|
||||
let averageY = 0;
|
||||
for (let i = 0; i < torch.particleData.length; i++) {
|
||||
const data = torch.particleData[i];
|
||||
data.life -= deltaTime;
|
||||
const yVelocity = data.velocity.y;
|
||||
if (data.life <= 0 || positions[i * 3 + 1] < 0) {
|
||||
// Reset particle
|
||||
positions[i * 3] = (Math.random() - 0.5) * 0.2;
|
||||
positions[i * 3 + 1] = 1;
|
||||
positions[i * 3 + 2] = (Math.random() - 0.5) * 0.2;
|
||||
data.life = Math.random() * 1.0;
|
||||
data.velocity.y = Math.random() * 1.2 + measurePulse;
|
||||
} else {
|
||||
// Update position
|
||||
positions[i * 3] += data.velocity.x * deltaTime;
|
||||
positions[i * 3 + 1] += yVelocity * deltaTime;
|
||||
positions[i * 3 + 2] += data.velocity.z * deltaTime;
|
||||
}
|
||||
averageY += positions[i * 3 + 1];
|
||||
}
|
||||
averageY = averageY / positions.length;
|
||||
torch.particles.geometry.attributes.position.needsUpdate = true;
|
||||
|
||||
// --- Flicker Light ---
|
||||
const baseIntensity = 2.0;
|
||||
const flicker = Math.random() * 0.6;
|
||||
let beatPulse = 0;
|
||||
if (state.music) {
|
||||
beatPulse = state.music.beatIntensity * 1.5;
|
||||
if (state.music.isLoudEnough) {
|
||||
beatPulse += 2;
|
||||
}
|
||||
}
|
||||
|
||||
torch.light.intensity = baseIntensity + flicker + beatPulse;
|
||||
torch.light.position.y = lightPositionBaseY + averageY;
|
||||
});
|
||||
}
|
||||
|
||||
onPartyStart() {
|
||||
this.torches.forEach(torch => {
|
||||
torch.group.visible = true;
|
||||
this.resetParticles(torch);
|
||||
});
|
||||
}
|
||||
|
||||
onPartyEnd() {
|
||||
this.torches.forEach(torch => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
new StageTorches();
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
import stageWallTextureUrl from '/textures/stage_wall.png';
|
||||
|
||||
export class Stage extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
sceneFeatureManager.register(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
// --- Dimensions from room-walls.js for positioning ---
|
||||
const length = 40;
|
||||
const naveWidth = 12;
|
||||
|
||||
// --- Stage Properties ---
|
||||
const stageWidth = naveWidth - 1; // Slightly narrower than the nave
|
||||
const stageHeight = 1.5;
|
||||
const stageDepth = 5;
|
||||
|
||||
// --- Material ---
|
||||
const woodTexture = state.loader.load(stageWallTextureUrl);
|
||||
woodTexture.wrapS = THREE.RepeatWrapping;
|
||||
woodTexture.wrapT = THREE.RepeatWrapping;
|
||||
woodTexture.repeat.set(stageWidth / 3, 1);
|
||||
const woodMaterial = new THREE.MeshStandardMaterial({
|
||||
map: woodTexture,
|
||||
roughness: 0.8,
|
||||
metalness: 0.1,
|
||||
});
|
||||
|
||||
// --- Create Stage Mesh ---
|
||||
const stageGeo = new THREE.BoxGeometry(stageWidth, stageHeight, stageDepth);
|
||||
const stageMesh = new THREE.Mesh(stageGeo, woodMaterial);
|
||||
stageMesh.castShadow = true;
|
||||
stageMesh.receiveShadow = true;
|
||||
stageMesh.position.set(0, stageHeight / 2, -length / 2 + stageDepth / 2);
|
||||
state.scene.add(stageMesh);
|
||||
}
|
||||
}
|
||||
|
||||
new Stage();
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
import curtainTextureUrl from '/textures/tapestry.png';
|
||||
|
||||
export class WallCurtain extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
this.curtains = [];
|
||||
this.waving = true;
|
||||
sceneFeatureManager.register(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
// --- Curtain Properties ---
|
||||
const naveWidth = 12;
|
||||
const naveHeight = 7;
|
||||
const stageHeight = 1.5;
|
||||
const curtainWidth = naveWidth; // Span the width of the nave
|
||||
const curtainHeight = naveHeight - stageHeight; // Hang from the ceiling down to the stage
|
||||
const segmentsX = 50; // More segments for a smoother wave
|
||||
const segmentsY = 50;
|
||||
|
||||
// --- Texture ---
|
||||
const texture = state.loader.load(curtainTextureUrl);
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
texture.repeat.set(5, 1); // Repeat the texture 5 times horizontally
|
||||
|
||||
// --- Material ---
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
map: texture,
|
||||
side: THREE.DoubleSide,
|
||||
roughness: 0.9,
|
||||
metalness: 0.1,
|
||||
});
|
||||
|
||||
// --- Create and Place Curtains ---
|
||||
const createAndPlaceCurtain = (position, rotationY) => {
|
||||
const geometry = new THREE.PlaneGeometry(curtainWidth, curtainHeight, segmentsX, segmentsY);
|
||||
const originalPositions = geometry.attributes.position.clone();
|
||||
const curtainMesh = new THREE.Mesh(geometry, material);
|
||||
curtainMesh.position.copy(position);
|
||||
curtainMesh.rotation.y = rotationY;
|
||||
curtainMesh.castShadow = true;
|
||||
curtainMesh.receiveShadow = true;
|
||||
state.scene.add(curtainMesh);
|
||||
|
||||
this.curtains.push({
|
||||
mesh: curtainMesh,
|
||||
originalPositions: originalPositions,
|
||||
});
|
||||
};
|
||||
|
||||
// Place a single large curtain behind the stage
|
||||
const backWallZ = -20;
|
||||
const curtainY = stageHeight + curtainHeight / 2;
|
||||
const curtainPosition = new THREE.Vector3(0, curtainY, backWallZ + 0.1);
|
||||
|
||||
createAndPlaceCurtain(curtainPosition, 0); // No rotation needed
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
if (!this.waving) { return; }
|
||||
const time = state.clock.getElapsedTime();
|
||||
const waveSpeed = 0.5;
|
||||
const waveFrequency = 1.2;
|
||||
const waveAmplitude = 0.3;
|
||||
|
||||
this.curtains.forEach(curtain => {
|
||||
const positions = curtain.mesh.geometry.attributes.position;
|
||||
const originalPos = curtain.originalPositions;
|
||||
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
const originalX = originalPos.getX(i);
|
||||
// The wave now moves horizontally across the curtain
|
||||
const zOffset = Math.sin(originalX * waveFrequency + time * waveSpeed) * waveAmplitude;
|
||||
positions.setZ(i, originalPos.getZ(i) + zOffset);
|
||||
}
|
||||
|
||||
// Mark positions as needing an update
|
||||
positions.needsUpdate = true;
|
||||
|
||||
// Recalculate normals for correct lighting on the waving surface
|
||||
curtain.mesh.geometry.computeVertexNormals();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
new WallCurtain();
|
||||
Reference in New Issue
Block a user