Compare commits

...

13 Commits

13 changed files with 931 additions and 139 deletions

View File

@ -103,6 +103,15 @@ export class ConfigUI extends SceneFeature {
rightContainer.appendChild(row);
};
// Debug Panel Toggle
createToggle('Debug Panel', 'debugPanelEnabled', (enabled) => {
const debugPanel = sceneFeatureManager.features.find(f => f.constructor.name === 'DebugPanel');
if (debugPanel) debugPanel.setVisibility(enabled);
});
// Blackout Toggle
createToggle('BLACKOUT', 'blackout');
// Torches Toggle
createToggle('Stage Torches', 'torchesEnabled');
@ -475,7 +484,9 @@ export class ConfigUI extends SceneFeature {
lightBarsEnabled: true,
laserColorMode: 'RUNNING',
guestCount: 150,
djHat: 'None'
blackout: true,
djHat: 'None',
debugPanelEnabled: false
};
for (const key in defaults) {
state.config[key] = defaults[key];

View File

@ -0,0 +1,475 @@
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
import { state } from '../state.js';
export class DebugPanel extends SceneFeature {
constructor() {
super();
this.frames = 0;
this.timeAccumulator = 0;
this.fpsElement = null;
this.textElement = null;
this.canvas = null;
this.ctx = null;
this.history = [];
this.memTextElement = null;
this.memCanvas = null;
this.memCtx = null;
this.memHistory = [];
this.musicTextElement = null;
this.musicCanvas = null;
this.musicCtx = null;
this.spectrumTextElement = null;
this.spectrumCanvas = null;
this.spectrumCtx = null;
this.musicHistory = [];
sceneFeatureManager.register(this);
}
init() {
const graphWidth = 150;
const graphHeight = 80;
// Create the FPS display container
this.fpsElement = document.createElement('div');
Object.assign(this.fpsElement.style, {
position: 'absolute',
top: '0',
right: '0',
zIndex: '10000',
padding: '5px',
background: 'rgba(0, 0, 0, 0.5)',
pointerEvents: 'none',
// Allow pointer events on the panel itself if we want to interact, but usually debug panels are pass-through
userSelect: 'none',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end'
});
// Text Display
this.textElement = document.createElement('div');
Object.assign(this.textElement.style, {
color: '#00ff00',
fontFamily: 'monospace',
fontSize: '16px',
fontWeight: 'bold',
marginBottom: '2px'
});
this.textElement.innerText = 'FPS: --';
this.fpsElement.appendChild(this.textElement);
// Graph Canvas
this.canvas = document.createElement('canvas');
this.canvas.width = graphWidth;
this.canvas.height = 40;
Object.assign(this.canvas.style, {
width: graphWidth + 'px',
height: '40px',
background: '#222',
border: '1px solid #444'
});
this.ctx = this.canvas.getContext('2d');
this.fpsElement.appendChild(this.canvas);
// Memory Text Display
this.memTextElement = document.createElement('div');
Object.assign(this.memTextElement.style, {
color: '#00ffff',
fontFamily: 'monospace',
fontSize: '16px',
fontWeight: 'bold',
marginBottom: '2px',
marginTop: '5px'
});
this.memTextElement.innerText = 'MEM: --';
this.fpsElement.appendChild(this.memTextElement);
// Memory Graph Canvas
this.memCanvas = document.createElement('canvas');
this.memCanvas.width = graphWidth;
this.memCanvas.height = 40;
Object.assign(this.memCanvas.style, {
width: graphWidth + 'px',
height: '40px',
background: '#222',
border: '1px solid #444'
});
this.memCtx = this.memCanvas.getContext('2d');
this.fpsElement.appendChild(this.memCanvas);
// Music Text Display
this.musicTextElement = document.createElement('div');
Object.assign(this.musicTextElement.style, {
color: '#ff00ff',
fontFamily: 'monospace',
fontSize: '16px',
fontWeight: 'bold',
marginBottom: '2px',
marginTop: '5px'
});
this.musicTextElement.innerText = 'MUSIC';
this.fpsElement.appendChild(this.musicTextElement);
// Music Graph Canvas
this.musicCanvas = document.createElement('canvas');
this.musicCanvas.width = graphWidth;
this.musicCanvas.height = graphHeight;
Object.assign(this.musicCanvas.style, {
width: graphWidth + 'px',
height: graphHeight + 'px',
background: '#222',
border: '1px solid #444'
});
this.musicCtx = this.musicCanvas.getContext('2d');
this.fpsElement.appendChild(this.musicCanvas);
// Spectrum Text Display
this.spectrumTextElement = document.createElement('div');
Object.assign(this.spectrumTextElement.style, {
color: '#ffff00',
fontFamily: 'monospace',
fontSize: '16px',
fontWeight: 'bold',
marginBottom: '2px',
marginTop: '5px'
});
this.spectrumTextElement.innerText = 'SPECTRUM';
this.fpsElement.appendChild(this.spectrumTextElement);
// Spectrum Graph Canvas
this.spectrumCanvas = document.createElement('canvas');
this.spectrumCanvas.width = graphWidth;
this.spectrumCanvas.height = 60;
Object.assign(this.spectrumCanvas.style, {
width: graphWidth + 'px',
height: '60px',
background: '#222',
border: '1px solid #444'
});
this.spectrumCtx = this.spectrumCanvas.getContext('2d');
this.fpsElement.appendChild(this.spectrumCanvas);
document.body.appendChild(this.fpsElement);
this.history = new Array(this.canvas.width).fill(0);
this.memHistory = new Array(this.memCanvas.width).fill(0);
this.musicHistory = new Array(this.musicCanvas.width).fill({l:0, la:0, low:0, lla:0, high:0, b:0, lt:0, qt:0, m:0.5, bo: false, al: 0, bt: 0});
// Initialize visibility based on config
if (state.config) {
this.setVisibility(state.config.debugPanelEnabled);
}
}
update(deltaTime) {
this.frames++;
this.timeAccumulator += deltaTime;
const segment = 0.25;
if (this.timeAccumulator >= segment) {
const fps = Math.round(this.frames / this.timeAccumulator);
if (this.textElement) this.textElement.innerText = `FPS: ${fps}`;
// Update Graph
this.history.push(fps);
if (this.history.length > this.canvas.width) {
this.history.shift();
}
this.drawGraph();
// Update Memory
if (performance && performance.memory) {
const mem = performance.memory.usedJSHeapSize / 1048576; // MB
if (this.memTextElement) this.memTextElement.innerText = `MEM: ${Math.round(mem)} MB`;
this.memHistory.push(mem);
if (this.memHistory.length > this.memCanvas.width) {
this.memHistory.shift();
}
this.drawMemGraph();
} else if (state.renderer) {
// Fallback for Firefox: Visualize Scene Complexity (Triangles)
const tris = state.renderer.info.render.triangles;
const kTris = Math.round(tris / 1000);
if (this.memTextElement) this.memTextElement.innerText = `TRIS: ${kTris}k`;
this.memHistory.push(kTris);
if (this.memHistory.length > this.memCanvas.width) {
this.memHistory.shift();
}
this.drawMemGraph();
} else {
if (this.memTextElement) this.memTextElement.innerText = `MEM: N/A`;
}
this.frames = 0;
this.timeAccumulator = 0;
}
// Update Music Graph (Every frame for smoothness)
if (state.music) {
const loudness = state.music.loudness || 0;
const loudnessAvg = state.music.loudnessAverage || 0;
const lows = state.music.loudnessLows || 0;
const lowsAvg = state.music.loudnessLowsAverage || 0;
const avgLoudness = state.music.averageLoudness || 0;
const highs = state.music.loudnessHighs || 0;
const beat = state.music.beatIntensity || 0;
const beatThreshold = state.music.beatThreshold || 0;
const thresholds = state.music.thresholds || { loud: 0, quiet: 0 };
let modeVal = 0.5;
if (state.music.mode === 'Loud') modeVal = 1.0;
else if (state.music.mode === 'Quiet') modeVal = 0.0;
this.musicHistory.push({ l: loudness, la: loudnessAvg, low: lows, lla: lowsAvg, high: highs, b: beat, lt: thresholds.loud, qt: thresholds.quiet, m: modeVal, bo: state.blackoutMode, al: avgLoudness, bt: beatThreshold });
if (this.musicHistory.length > this.musicCanvas.width) {
this.musicHistory.shift();
}
this.drawMusicGraph();
this.drawSpectrumGraph();
if (this.musicTextElement) {
this.musicTextElement.innerText = `Volume: ${loudness.toFixed(2)}\nBPM: ${state.music.bpm}`;
}
}
}
setVisibility(visible) {
if (!this.fpsElement) return;
this.fpsElement.style.display = visible ? 'flex' : 'none';
}
drawGraph() {
if (!this.ctx) return;
const ctx = this.ctx;
const w = this.canvas.width;
const h = this.canvas.height;
const maxFps = 70;
ctx.clearRect(0, 0, w, h);
// Draw 60 FPS reference line
const y60 = h - (60 / maxFps) * h;
ctx.beginPath();
ctx.strokeStyle = '#555';
ctx.moveTo(0, y60);
ctx.lineTo(w, y60);
ctx.stroke();
// Draw Graph
ctx.beginPath();
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 1.5;
for (let i = 0; i < this.history.length; i++) {
const val = this.history[i];
const x = i;
const y = h - (val / maxFps) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
drawMemGraph() {
if (!this.memCtx) return;
const ctx = this.memCtx;
const w = this.memCanvas.width;
const h = this.memCanvas.height;
// Auto-scale max memory
const maxMem = Math.max(...this.memHistory, 100) * 1.1;
ctx.clearRect(0, 0, w, h);
// Draw Graph
ctx.beginPath();
ctx.strokeStyle = '#00ffff';
ctx.lineWidth = 1.5;
for (let i = 0; i < this.memHistory.length; i++) {
const val = this.memHistory[i];
const x = i;
const y = h - (val / maxMem) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
drawMusicGraph() {
if (!this.musicCtx) return;
const ctx = this.musicCtx;
const w = this.musicCanvas.width;
const h = this.musicCanvas.height;
ctx.clearRect(0, 0, w, h);
// Draw Lows (Brown)
ctx.beginPath();
ctx.strokeStyle = '#D2691E'; // Chocolate Brown
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].low !== undefined ? this.musicHistory[i].low : 0;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw Lows Avg (Darker Brown)
ctx.beginPath();
ctx.strokeStyle = '#8B4513'; // Saddle Brown
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].lla !== undefined ? this.musicHistory[i].lla : 0;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw All (Blue)
ctx.beginPath();
ctx.strokeStyle = '#66aaff'; // Visible Blue
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].l;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw Highs (White)
ctx.beginPath();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].high !== undefined ? this.musicHistory[i].high : 0;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw All Avg (Blue)
ctx.beginPath();
ctx.strokeStyle = '#8866ff';
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].la;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw Average Loudness (Pink)
ctx.beginPath();
ctx.strokeStyle = '#FF69B4'; // HotPink
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].al !== undefined ? this.musicHistory[i].al : 0;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw Beat Intensity (Magenta)
ctx.beginPath();
ctx.strokeStyle = '#ff00ff';
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].b;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw Beat Threshold (Cyan)
ctx.beginPath();
ctx.strokeStyle = '#00ffff';
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].bt !== undefined ? this.musicHistory[i].bt : 0;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw Active Threshold (Green for Loud/Blackout, Red for Quiet/Normal)
ctx.lineWidth = 1;
for (let i = 0; i < this.musicHistory.length - 1; i++) {
const curr = this.musicHistory[i];
const next = this.musicHistory[i+1];
ctx.beginPath();
if (curr.bo) {
// Blackout Mode: Draw Loud Threshold (Green Dashed)
ctx.strokeStyle = '#00ff00';
ctx.setLineDash([2, 2]);
ctx.moveTo(i, h - (curr.lt * h));
ctx.lineTo(i + 1, h - (next.lt * h));
} else {
// Normal Mode: Draw Quiet Threshold (Red Solid)
ctx.strokeStyle = '#ff0000';
ctx.setLineDash([]);
ctx.moveTo(i, h - (curr.qt * h));
ctx.lineTo(i + 1, h - (next.qt * h));
}
ctx.stroke();
}
// Draw Mode (Yellow)
ctx.beginPath();
ctx.strokeStyle = '#ffff00';
ctx.setLineDash([1, 2]);
for (let i = 0; i < this.musicHistory.length; i++) {
const val = this.musicHistory[i].m !== undefined ? this.musicHistory[i].m : 0.5;
const x = i;
const y = h - (val * h);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.setLineDash([]); // Reset dash
}
drawSpectrumGraph() {
if (!this.spectrumCtx || !state.music || !state.music.frequencyData) return;
const ctx = this.spectrumCtx;
const w = this.spectrumCanvas.width;
const h = this.spectrumCanvas.height;
const data = state.music.frequencyData;
const bufferLength = data.length/2;
ctx.clearRect(0, 0, w, h);
const barWidth = w / bufferLength;
let x = 0;
for(let i = 0; i < bufferLength; i++) {
const val = (data[i] / 255);
const barHeight = val * h;
const hue = (i / bufferLength) * 260; // Red to Purple
ctx.fillStyle = `hsl(${hue}, 100%, 50%)`;
ctx.fillRect(x, h - barHeight, barWidth + 1, barHeight);
x += barWidth;
}
}
}
new DebugPanel();

View File

@ -186,6 +186,11 @@ export class MusicConsole extends SceneFeature {
const beatIntensity = state.music.beatIntensity;
this.lights.forEach(light => {
if (state.blackoutMode) {
light.mesh.material.color.copy(light.offColor);
return;
}
if (time > light.nextToggle) {
// Toggle state
light.active = !light.active;
@ -207,7 +212,7 @@ export class MusicConsole extends SceneFeature {
// Update Front LED Array
if (this.frontLedMesh) {
if (!state.config.consoleRGBEnabled) {
if (!state.config.consoleRGBEnabled || state.blackoutMode) {
this.frontLedMesh.visible = false;
return;
}

View File

@ -11,15 +11,18 @@ export class MusicPlayer extends SceneFeature {
this.analyser = null;
this.source = null;
this.dataArray = null;
this.loudnessHistory = [];
sceneFeatureManager.register(this);
}
init() {
state.music.player = document.getElementById('audioPlayer');
if (!state.music.player) {
state.music.player = document.createElement('audio');
state.music.player.id = 'audioPlayer';
document.body.appendChild(state.music.player);
}
state.music.loudness = 0;
state.music.isLoudEnough = false;
state.music.loudnessAverage = 0;
const loadButton = document.getElementById('loadMusicButton');
const fileInput = document.getElementById('musicFileInput');
@ -53,11 +56,12 @@ export class MusicPlayer extends SceneFeature {
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.analyser.fftSize = 1024; // Higher resolution for better bass detection
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);
state.music.frequencyData = this.dataArray;
}
// Update State
@ -76,6 +80,10 @@ export class MusicPlayer extends SceneFeature {
}
startSequence() {
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
const uiContainer = document.getElementById('ui-container');
const configUILeft = document.getElementById('config-ui-left');
const configUIRight = document.getElementById('config-ui-right');
@ -99,9 +107,6 @@ export class MusicPlayer extends SceneFeature {
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');
}
@ -125,27 +130,7 @@ export class MusicPlayer extends SceneFeature {
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();
new MusicPlayer();

View File

@ -1,3 +1,4 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
@ -5,35 +6,192 @@ import sceneFeatureManager from './SceneFeatureManager.js';
export class MusicVisualizer extends SceneFeature {
constructor() {
super();
this.lastStateChangeTime = 0;
this.lastBlackoutMode = false;
this.lastBeatTime = 0;
this.beatIntervals = [];
this.beatThreshold = 0.3;
this.loudnessHistory = [];
this.loudnessLowsHistory = [];
sceneFeatureManager.register(this);
}
init() {
// Initialize music state
state.music = {
bpm: 120,
beatDuration: 60 / 120,
measureDuration: (60 / 120) * 4,
beatIntensity: 0,
measurePulse: 0,
isLoudEnough: false,
thresholds: { loud: 0, quiet: 0 },
mode: 'Normal',
bpm: 1,
loudness: 0,
loudnessLows: 0,
loudnessHighs: 0,
loudnessAverage: 0,
loudnessLowsAverage: 0,
frequencyData: null,
beatThreshold: 0
};
this.beatPhase = 0;
this.measurePhase = 0;
this.averageLoudness = 0;
}
update(deltaTime) {
if (!state.music || !state.partyStarted) return;
// --- Calculate Loudness from Frequency Data ---
if (state.music.frequencyData) {
const dataArray = state.music.frequencyData;
let sumLows = 0;
let sumHighs = 0;
let sumAll = 0;
const countLows = Math.floor(Math.max(2, dataArray.length * 0.2)); // Focus on bottom 10% for bass
const countHighs = Math.min(dataArray.length * 0.6, dataArray.length);
for (let i = 0; i < dataArray.length; i++) {
sumAll += dataArray[i];
if (i < countLows) {
sumLows += dataArray[i];
}
if (i > dataArray.length - countHighs) {
sumHighs += dataArray[i];
}
}
const averageLows = sumLows / countLows;
const averageHighs = sumHighs / countHighs;
const averageAll = sumAll / dataArray.length;
// Normalize to 0-1 range
state.music.loudness = averageAll / 255;
state.music.loudnessLows = averageLows / 255;
state.music.loudnessHighs = averageHighs / 255;
// --- Track loudness over the last X seconds ---
const loudnessHistorySeconds = 0.5;
this.loudnessHistory.push(state.music.loudness);
if (this.loudnessHistory.length > loudnessHistorySeconds*60) this.loudnessHistory.shift();
this.loudnessLowsHistory.push(state.music.loudnessLows);
if (this.loudnessLowsHistory.length > loudnessHistorySeconds*60) this.loudnessLowsHistory.shift();
const avgLoudness = this.loudnessHistory.reduce((a, b) => a + b, 0) / this.loudnessHistory.length;
const avgLowsLoudness = this.loudnessLowsHistory.reduce((a, b) => a + b, 0) / this.loudnessLowsHistory.length;
state.music.loudnessAverage = avgLoudness;
state.music.loudnessLowsAverage = avgLowsLoudness;
state.music.isLoudEnough = avgLoudness > 0.1;
}
const time = state.clock.getElapsedTime();
const loudness = state.music.loudnessLows || 0;
const loudnessAvg = state.music.loudnessLowsAverage || 0;
const songTime = state.music.player ? state.music.player.currentTime : 0;
this.averageLoudness = THREE.MathUtils.lerp(this.averageLoudness, loudnessAvg, deltaTime * (songTime < 5 ? 0.5 : 0.05));
state.music.averageLoudness = this.averageLoudness;
// --- 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);
// --- Beat Detection & Auto-BPM ---
// Use Lows (Bass) for clearer beat detection
const beatSignal = state.music.loudnessLows || 0;
const beatAvg = state.music.loudnessLowsAverage || 0;
// --- 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;
// Decay threshold based on time
this.beatThreshold -= deltaTime * 0.5;
const thresholdFloor = Math.max(0.15, beatAvg * 1.1);
if (this.beatThreshold < thresholdFloor) this.beatThreshold = thresholdFloor;
state.music.beatThreshold = this.beatThreshold;
const now = time;
const lastBeatInterval = now - this.lastBeatTime;
if (beatSignal > this.beatThreshold) {
if (lastBeatInterval > 0.25) { // Min interval (~240 BPM)
this.lastBeatTime = now;
this.beatThreshold = beatSignal * 1.2; // Bump threshold
// Add only valid BPM range into history of beats
if (lastBeatInterval >= 0.2 && lastBeatInterval <= 1.5) {
this.beatIntervals.push(lastBeatInterval);
if (this.beatIntervals.length > 8) this.beatIntervals.shift();
const avgInterval = this.beatIntervals.reduce((a, b) => a + b, 0) / this.beatIntervals.length;
// Smoothly adjust beat duration
state.music.beatDuration = THREE.MathUtils.lerp(state.music.beatDuration, avgInterval, 0.1);
state.music.measureDuration = state.music.beatDuration * 4;
state.music.bpm = Math.round(60 / state.music.beatDuration);
}
// Sync phase on beat
this.beatPhase = 0;
}
}
// --- Phase Accumulation ---
this.beatPhase += deltaTime / state.music.beatDuration;
if (this.beatPhase >= 1) this.beatPhase -= 1;
this.measurePhase += deltaTime / state.music.measureDuration;
if (this.measurePhase >= 1) this.measurePhase -= 1;
// --- Calculate Beat Intensity ---
const realBeatFactor = lastBeatInterval < 1.0 ? 1.0 : ((lastBeatInterval > 5) ? 0.0 : (5 - lastBeatInterval)/5);
state.music.beatIntensity = Math.pow(1.0 - this.beatPhase, 2) * realBeatFactor;
// --- Calculate Measure Pulse ---
state.music.measurePulse = this.measurePhase < 0.2 ? Math.sin(this.measurePhase * Math.PI * 5) : 0;
if (state.blackoutMode !== this.lastBlackoutMode) {
this.lastStateChangeTime = time;
this.lastBlackoutMode = state.blackoutMode;
}
const timeInState = time - this.lastStateChangeTime;
let timeInStateLoud = 0;
let timeInStateQuiet = 0;
if (state.blackoutMode) {
timeInStateQuiet = timeInState;
} else {
timeInStateLoud = timeInState;
}
// Dynamic Thresholds
// Enter blackout if loudness falls below 80% of average
let quietThreshold = this.averageLoudness * 0.8;
// Exit blackout if loudness returns to normal (average) or above
let loudThreshold = this.averageLoudness;
// --- Auto-Blackout Logic ---
// If blackout is active, monitor for loud events (The Drop) to disable it.
if (state.config.blackout) {
if (state.blackoutMode) {
if (loudness >= loudThreshold) {
state.blackoutMode = false;
}
} else {
if (loudness < quietThreshold) {
state.blackoutMode = true;
}
}
} else {
state.blackoutMode = false;
}
state.music.thresholds = { loud: loudThreshold, quiet: quietThreshold };
if (loudness > loudThreshold) {
state.music.mode = 'Loud';
} else if (loudness < quietThreshold) {
state.music.mode = 'Quiet';
} else {
state.music.mode = 'Normal';
}
}
}

View File

@ -152,6 +152,21 @@ export class PartyGuests extends SceneFeature {
const time = state.clock.getElapsedTime();
const minDistance = 0.8; // Minimum distance to maintain
const minDistSq = minDistance * minDistance;
// --- Spatial Grid Optimization ---
const cellSize = 2.0;
const grid = {};
// Populate grid
for (let i = 0; i < this.guests.length; i++) {
const g = this.guests[i];
const cx = Math.floor(g.mesh.position.x / cellSize);
const cz = Math.floor(g.mesh.position.z / cellSize);
const key = cx + ':' + cz;
if (!grid[key]) grid[key] = [];
grid[key].push(i);
}
this.guests.forEach((guestObj, i) => {
const { mesh, leftArm, rightArm } = guestObj;
@ -160,19 +175,31 @@ export class PartyGuests extends SceneFeature {
let separationX = 0;
let separationZ = 0;
for (let j = 0; j < this.guests.length; j++) {
if (i === j) continue;
const otherMesh = this.guests[j].mesh;
const dx = mesh.position.x - otherMesh.position.x;
const dz = mesh.position.z - otherMesh.position.z;
const distSq = dx*dx + dz*dz;
if (distSq < minDistance * minDistance && distSq > 0.0001) {
const dist = Math.sqrt(distSq);
const force = (minDistance - dist) / minDistance;
separationX += (dx / dist) * force;
separationZ += (dz / dist) * force;
const cx = Math.floor(mesh.position.x / cellSize);
const cz = Math.floor(mesh.position.z / cellSize);
for (let ox = -1; ox <= 1; ox++) {
for (let oz = -1; oz <= 1; oz++) {
const key = (cx + ox) + ':' + (cz + oz);
const cell = grid[key];
if (!cell) continue;
for (let k = 0; k < cell.length; k++) {
const j = cell[k];
if (i === j) continue;
const otherMesh = this.guests[j].mesh;
const dx = mesh.position.x - otherMesh.position.x;
const dz = mesh.position.z - otherMesh.position.z;
const distSq = dx*dx + dz*dz;
if (distSq < minDistSq && distSq > 0.0001) {
const dist = Math.sqrt(distSq);
const force = (minDistance - dist) / minDistance;
separationX += (dx / dist) * force;
separationZ += (dz / dist) * force;
}
}
}
}

View File

@ -130,6 +130,18 @@ export class ProjectionScreen extends SceneFeature {
this.isVisualizerActive = false;
this.screens = [];
this.colorBuffer = new Float32Array(16 * 3);
this.standbyState = {
active: false,
scrollX: 0,
text: '',
header: '',
subtext: ''
};
this.standbyCanvas = null;
this.standbyCtx = null;
this.standbyTexture = null;
sceneFeatureManager.register(this);
}
@ -232,6 +244,10 @@ export class ProjectionScreen extends SceneFeature {
update(deltaTime) {
updateScreenEffect();
if (this.standbyState.active) {
this.renderStandbyFrame();
}
// Wobble Logic
const time = state.clock.getElapsedTime();
@ -257,6 +273,10 @@ export class ProjectionScreen extends SceneFeature {
const beat = (state.music && state.music.beatIntensity) ? state.music.beatIntensity : 0.0;
state.screenLight.intensity = state.originalScreenIntensity * (0.5 + beat * 0.5);
if (state.blackoutMode) {
state.screenLight.intensity = 0;
}
// Update color buffer
const colors = state.config.lightBarColors;
const colorCount = colors ? Math.min(colors.length, 16) : 0;
@ -277,6 +297,10 @@ export class ProjectionScreen extends SceneFeature {
if (s.mesh.material.uniforms.u_colorCount) {
s.mesh.material.uniforms.u_colorCount.value = colorCount;
}
if (s.mesh.material.uniforms.u_opacity) {
const targetOpacity = state.blackoutMode ? 0.1 : state.screenOpacity;
s.mesh.material.uniforms.u_opacity.value = targetOpacity;
}
}
});
}
@ -329,68 +353,129 @@ export class ProjectionScreen extends SceneFeature {
this.isVisualizerActive = false;
turnTvScreenOff();
}
activateStandby() {
this.isVisualizerActive = false;
if (!this.standbyCanvas) {
this.standbyCanvas = document.createElement('canvas');
this.standbyCanvas.width = 1024;
this.standbyCanvas.height = 576;
this.standbyCtx = this.standbyCanvas.getContext('2d');
this.standbyTexture = new THREE.CanvasTexture(this.standbyCanvas);
this.standbyTexture.minFilter = THREE.LinearFilter;
this.standbyTexture.magFilter = THREE.LinearFilter;
}
const songTitle = (state.music && state.music.songTitle) ? state.music.songTitle.toUpperCase() : "";
this.standbyState = {
active: true,
scrollX: 0,
text: songTitle,
header: "PLEASE STAND BY",
subtext: "WAITING FOR PARTY START"
};
const material = new THREE.MeshBasicMaterial({
map: this.standbyTexture,
side: THREE.DoubleSide
});
this.applyMaterialToAll(material);
this.setAllVisible(true);
state.screenLight.intensity = 0.5;
// Measure text to set initial scroll
this.standbyCtx.font = 'bold 80px monospace';
const textWidth = this.standbyCtx.measureText(this.standbyState.text).width;
if (textWidth > this.standbyCanvas.width * 0.9) {
this.standbyState.scrollX = this.standbyCanvas.width;
}
this.renderStandbyFrame();
}
renderStandbyFrame() {
if (!this.standbyState.active || !this.standbyCtx) return;
const ctx = this.standbyCtx;
const width = this.standbyCanvas.width;
const height = this.standbyCanvas.height;
// Draw Background
const colors = ['#ffffff', '#ffff00', '#00ffff', '#00ff00', '#ff00ff', '#ff0000', '#0000ff'];
const barWidth = width / colors.length;
colors.forEach((color, i) => {
ctx.fillStyle = color;
ctx.fillRect(i * barWidth, 0, barWidth, height);
});
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, width, height);
// Header
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold 40px monospace';
ctx.fillText(this.standbyState.header, width / 2, height * 0.2);
// Song Title
const text = this.standbyState.text;
ctx.font = 'bold 80px monospace';
const textMetrics = ctx.measureText(text);
const textWidth = textMetrics.width;
const centerY = height * 0.5;
if (textWidth > width * 0.9) {
// Scroll
this.standbyState.scrollX -= 3; // Speed
if (this.standbyState.scrollX < -textWidth) {
this.standbyState.scrollX = width;
}
ctx.textAlign = 'left';
ctx.fillText(text, this.standbyState.scrollX, centerY);
} else {
// Center
ctx.textAlign = 'center';
ctx.fillText(text, width / 2, centerY);
}
// Subtext
ctx.font = '30px monospace';
ctx.fillStyle = '#cccccc';
ctx.textAlign = 'center';
ctx.fillText(this.standbyState.subtext, width / 2, height * 0.8);
if (this.standbyTexture) this.standbyTexture.needsUpdate = true;
}
}
// --- Exported Control Functions ---
export function showStandbyScreen() {
if (projectionScreenInstance) projectionScreenInstance.isVisualizerActive = false;
let texture;
if (projectionScreenInstance) {
projectionScreenInstance.isVisualizerActive = false;
projectionScreenInstance.standbyState.active = false;
}
if (state.posterImage) {
texture = new THREE.TextureLoader().load(state.posterImage);
} else {
const canvas = document.createElement('canvas');
canvas.width = 1024;
canvas.height = 576;
const ctx = canvas.getContext('2d');
// Draw Color Bars
const colors = ['#ffffff', '#ffff00', '#00ffff', '#00ff00', '#ff00ff', '#ff0000', '#0000ff'];
const barWidth = canvas.width / colors.length;
colors.forEach((color, i) => {
ctx.fillStyle = color;
ctx.fillRect(i * barWidth, 0, barWidth, canvas.height);
const texture = new THREE.TextureLoader().load(state.posterImage);
const material = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.DoubleSide
});
// Semi-transparent overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Text settings
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Song Title
let text = "PLEASE STAND BY";
if (state.music && state.music.songTitle) {
text = state.music.songTitle.toUpperCase();
if (projectionScreenInstance) {
projectionScreenInstance.applyMaterialToAll(material);
projectionScreenInstance.setAllVisible(true);
}
ctx.font = 'bold 60px monospace';
if (ctx.measureText(text).width > canvas.width * 0.9) {
ctx.font = 'bold 40px monospace';
}
ctx.fillText(text, canvas.width / 2, canvas.height / 2 - 20);
// Subtext
ctx.font = '30px monospace';
ctx.fillStyle = '#cccccc';
ctx.fillText("WAITING FOR PARTY START", canvas.width / 2, canvas.height / 2 + 50);
texture = new THREE.CanvasTexture(canvas);
state.screenLight.intensity = 0.5;
return;
}
const material = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.DoubleSide
});
if (projectionScreenInstance) projectionScreenInstance.applyMaterialToAll(material);
if (projectionScreenInstance) projectionScreenInstance.setAllVisible(true);
state.screenLight.intensity = 0.5;
if (projectionScreenInstance) {
projectionScreenInstance.activateStandby();
}
}
export function turnTvScreenOn() {
@ -460,13 +545,24 @@ export function updateScreenEffect() {
const material = s.mesh.material;
if (!material || !material.uniforms) return;
material.uniforms.u_time.value = state.clock.getElapsedTime();
material.uniforms.u_effect_type.value = state.screenEffect.type;
material.uniforms.u_effect_strength.value = strength;
if (material.uniforms.u_time) {
material.uniforms.u_time.value = state.clock.getElapsedTime();
}
if (material.uniforms.u_effect_type) {
material.uniforms.u_effect_type.value = state.screenEffect.type;
}
if (material.uniforms.u_effect_strength) {
material.uniforms.u_effect_strength.value = strength;
}
if (material.uniforms.u_opacity) {
const targetOpacity = state.blackoutMode ? 0.1 : (state.screenOpacity !== undefined ? state.screenOpacity : 0.7);
material.uniforms.u_opacity.value = targetOpacity;
}
if (progress >= 1.0) {
material.uniforms.u_effect_type.value = 0.0;
material.uniforms.u_effect_strength.value = 0.0;
if (material.uniforms.u_effect_type) material.uniforms.u_effect_type.value = 0.0;
if (material.uniforms.u_effect_strength) material.uniforms.u_effect_strength.value = 0.0;
}
});
}

View File

@ -20,6 +20,7 @@ import { ProjectionScreen } from './projection-screen.js';
import { StageLasers } from './stage-lasers.js';
import { ConfigUI } from './config-ui.js';
import { StageLightBars } from './stage-light-bars.js';
import { DebugPanel } from './fps-counter.js';
// Scene Features ^^^
// --- Scene Modeling Function ---

View File

@ -14,6 +14,8 @@ export class StageLasers extends SceneFeature {
this.stateTimer = 0;
this.initialSilenceSeconds = 10;
this.currentCycleMode = 'RUNNING';
this.dummyColor = new THREE.Color();
this.areLasersVisible = true;
sceneFeatureManager.register(this);
}
@ -103,14 +105,20 @@ export class StageLasers extends SceneFeature {
update(deltaTime) {
if (!state.partyStarted) {
this.lasers.forEach(l => l.mesh.visible = false);
if (this.areLasersVisible) {
this.lasers.forEach(l => l.mesh.visible = false);
this.areLasersVisible = false;
}
return;
}
const time = state.clock.getElapsedTime();
if (!state.config.lasersEnabled) {
this.lasers.forEach(l => l.mesh.visible = false);
if (!state.config.lasersEnabled || state.blackoutMode) {
if (this.areLasersVisible) {
this.lasers.forEach(l => l.mesh.visible = false);
this.areLasersVisible = false;
}
if (state.laserData) state.laserData.count = 0;
return;
}
@ -124,7 +132,7 @@ export class StageLasers extends SceneFeature {
if (this.activationState === 'IDLE') {
// Wait for song to pick up before first activation
if (time > this.initialSilenceSeconds && loudness > this.averageLoudness + 0.1) {
if (time > this.initialSilenceSeconds && state.music.isLoudEnough && loudness > this.averageLoudness + 0.1) {
this.activationState = 'WARMUP';
this.stateTimer = 1.0; // Warmup duration
@ -156,6 +164,7 @@ export class StageLasers extends SceneFeature {
if (this.stateTimer <= 0) {
this.activationState = 'COOLDOWN';
this.stateTimer = 4.0; // Cooldown duration
isActive = false;
}
} else if (this.activationState === 'COOLDOWN') {
this.stateTimer -= deltaTime;
@ -166,10 +175,18 @@ export class StageLasers extends SceneFeature {
}
if (!isActive) {
this.lasers.forEach(l => l.mesh.visible = false);
if (this.areLasersVisible) {
this.lasers.forEach(l => l.mesh.visible = false);
this.areLasersVisible = false;
}
return;
}
if (!this.areLasersVisible) {
this.lasers.forEach(l => l.mesh.visible = true);
this.areLasersVisible = true;
}
// --- Pattern Logic ---
if (time - this.lastPatternChange > 8) { // Change every 8 seconds
this.pattern = (this.pattern + 1) % 4;
@ -192,8 +209,6 @@ export class StageLasers extends SceneFeature {
}
this.lasers.forEach(l => {
l.mesh.visible = !['IDLE', 'COOLDOWN'].includes(this.activationState);
let currentIntensity = intensity;
let flareScale = 1.0;
@ -235,8 +250,8 @@ export class StageLasers extends SceneFeature {
} else {
// Fallback if no palette: Cycle hue
const hue = (time * 0.1) % 1;
const c = new THREE.Color().setHSL(hue, 1.0, 0.5);
colorHex = c.getHex();
this.dummyColor.setHSL(hue, 1.0, 0.5);
colorHex = this.dummyColor.getHex();
}
l.mesh.material.color.set(colorHex);
@ -279,7 +294,10 @@ export class StageLasers extends SceneFeature {
}
onPartyEnd() {
this.lasers.forEach(l => l.mesh.visible = false);
if (this.areLasersVisible) {
this.lasers.forEach(l => l.mesh.visible = false);
this.areLasersVisible = false;
}
}
}

View File

@ -114,6 +114,13 @@ export class StageLightBars extends SceneFeature {
update(deltaTime) {
if (!state.partyStarted) return;
if (!state.config.lightBarsEnabled) return;
if (state.blackoutMode) {
this.bars.forEach(bar => {
bar.material.emissiveIntensity = 0;
});
return;
}
const time = state.clock.getElapsedTime();
const beatIntensity = state.music ? state.music.beatIntensity : 0;

View File

@ -136,7 +136,10 @@ export class StageLights extends SceneFeature {
this.focusPoint.lerp(this.targetFocusPoint, deltaTime * 2.0);
// Update each light
const intensity = state.music ? 20 + state.music.beatIntensity * 150 : 50;
let intensity = state.music ? 20 + state.music.beatIntensity * 150 : 50;
if (state.blackoutMode) {
intensity = 0;
}
const spread = 0.2 + (state.music ? state.music.beatIntensity * 0.4 : 0);
const bounce = state.music ? state.music.beatIntensity * 0.5 : 0;

View File

@ -104,23 +104,17 @@ export class StageTorches extends SceneFeature {
}
update(deltaTime) {
const enabled = state.config.torchesEnabled;
const configEnabled = state.config.torchesEnabled;
this.torches.forEach(torch => {
if (torch.group.visible !== enabled) torch.group.visible = enabled;
if (torch.group.visible !== configEnabled) torch.group.visible = configEnabled;
});
if (!enabled) return;
if (!configEnabled) return;
if (!state.partyStarted) {
this.torches.forEach(torch => {
if (torch.light.visible) torch.light.visible = false;
if (torch.particles.visible) torch.particles.visible = false;
});
return;
}
const fireActive = !state.blackoutMode && !(!state.partyStarted && state.music.isLoudEnough);
this.torches.forEach(torch => {
if (!torch.light.visible) torch.light.visible = true;
if (torch.light.visible !== fireActive) torch.light.visible = fireActive;
if (!torch.particles.visible) torch.particles.visible = true;
let measurePulse = 0;
@ -134,26 +128,35 @@ export class StageTorches extends SceneFeature {
// --- Animate Particles ---
const positions = torch.particles.geometry.attributes.position.array;
let averageY = 0;
let activeParticleCount = 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;
if (fireActive) {
// 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 {
// Stop producing: move out of view
positions[i * 3 + 1] = -100;
}
} 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];
if (positions[i * 3 + 1] > -50) {
averageY += positions[i * 3 + 1];
activeParticleCount++;
}
}
averageY = averageY / positions.length;
averageY = activeParticleCount > 0 ? averageY / activeParticleCount : 0;
torch.particles.geometry.attributes.position.needsUpdate = true;
// --- Flicker Light ---

View File

@ -14,7 +14,9 @@ export function initState() {
laserColorMode: 'RUNNING', // 'SINGLE', 'RANDOM', 'RUNNING', 'ANY'
lightBarColors: ['#ff00ff', '#00ffff', '#ffff00'], // Default neon colors
guestCount: 150,
djHat: 'None' // 'None', 'Santa', 'Top Hat'
blackout: false,
djHat: 'None', // 'None', 'Santa', 'Top Hat'
debugPanelEnabled: false
};
try {
const saved = localStorage.getItem('partyConfig');
@ -61,6 +63,7 @@ export function initState() {
debugLight: false, // Turn on light helpers
debugCamera: false, // Turn on camera helpers
partyStarted: false,
blackoutMode: false,
// Feature Configuration
config: config,