Compare commits

...

8 Commits

Author SHA1 Message Date
Dejvino
8b2d1aed53 Fix: Stage lights using stage colors 2026-01-04 07:29:43 +00:00
Dejvino
8bf82f5f8a Feature: Stage colors configurable and used everywhere 2026-01-04 07:26:27 +00:00
Dejvino
7296715a5e Feature: stage light bars 2026-01-04 07:04:28 +00:00
Dejvino
edbed229b3 Fix: torches toggle 2026-01-04 06:52:29 +00:00
Dejvino
47b7645046 Feature: DJ has a gameboy music controller 2026-01-04 06:47:21 +00:00
Dejvino
6b292b32ad Feature: configurable toggle for music console and count of guests 2026-01-04 06:28:28 +00:00
Dejvino
dcf12771d6 Feature: static stage lights before the show 2026-01-04 06:20:53 +00:00
Dejvino
066fcc26cc Feature: config UI split across Schedule and Venue panels 2026-01-04 06:17:57 +00:00
12 changed files with 760 additions and 138 deletions

View File

@ -1,4 +1,5 @@
import { state } from '../state.js';
import * as THREE from 'three';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
import { MediaStorage } from '../core/media-storage.js';
@ -9,12 +10,14 @@ export class ConfigUI extends SceneFeature {
super();
sceneFeatureManager.register(this);
this.toggles = {};
this.containers = [];
}
init() {
const container = document.createElement('div');
container.id = 'config-ui';
Object.assign(container.style, {
// --- Left Panel: Party Schedule ---
const leftContainer = document.createElement('div');
leftContainer.id = 'config-ui-left';
Object.assign(leftContainer.style, {
position: 'absolute',
top: '70px',
left: '20px',
@ -29,6 +32,47 @@ export class ConfigUI extends SceneFeature {
gap: '10px',
minWidth: '200px'
});
this.containers.push(leftContainer);
const heading = document.createElement('h3');
heading.innerText = 'Party schedule';
Object.assign(heading.style, {
margin: '0 0 15px 0',
textAlign: 'center',
borderBottom: '1px solid #555',
paddingBottom: '10px'
});
leftContainer.appendChild(heading);
// --- Right Panel: Party Venue ---
const rightContainer = document.createElement('div');
rightContainer.id = 'config-ui-right';
Object.assign(rightContainer.style, {
position: 'absolute',
top: '70px',
right: '20px',
zIndex: '1000',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: '15px',
borderRadius: '8px',
color: 'white',
fontFamily: 'sans-serif',
display: 'flex',
flexDirection: 'column',
gap: '10px',
minWidth: '200px'
});
this.containers.push(rightContainer);
const venueHeading = document.createElement('h3');
venueHeading.innerText = 'Party venue';
Object.assign(venueHeading.style, {
margin: '0 0 15px 0',
textAlign: 'center',
borderBottom: '1px solid #555',
paddingBottom: '10px'
});
rightContainer.appendChild(venueHeading);
const saveConfig = () => {
localStorage.setItem('partyConfig', JSON.stringify(state.config));
@ -56,24 +100,170 @@ export class ConfigUI extends SceneFeature {
this.toggles[configKey] = { checkbox: chk, callback: onChange };
row.appendChild(lbl);
row.appendChild(chk);
container.appendChild(row);
rightContainer.appendChild(row);
};
// Torches Toggle
createToggle('Stage Torches', 'torchesEnabled', (enabled) => {
const torches = sceneFeatureManager.features.find(f => f.constructor.name === 'StageTorches');
if (torches && torches.group) torches.group.visible = enabled;
});
createToggle('Stage Torches', 'torchesEnabled');
// Lasers Toggle
createToggle('Lasers', 'lasersEnabled');
// Laser Color Mode
const laserModeRow = document.createElement('div');
laserModeRow.style.display = 'flex';
laserModeRow.style.alignItems = 'center';
laserModeRow.style.justifyContent = 'space-between';
const laserModeLabel = document.createElement('label');
laserModeLabel.innerText = 'Laser Colors';
const laserModeSelect = document.createElement('select');
['SINGLE', 'RANDOM', 'RUNNING', 'ANY'].forEach(mode => {
const opt = document.createElement('option');
opt.value = mode;
opt.innerText = mode.charAt(0) + mode.slice(1).toLowerCase();
if (mode === state.config.laserColorMode) opt.selected = true;
laserModeSelect.appendChild(opt);
});
laserModeSelect.onchange = (e) => {
state.config.laserColorMode = e.target.value;
saveConfig();
};
this.laserModeSelect = laserModeSelect;
laserModeRow.appendChild(laserModeLabel);
laserModeRow.appendChild(laserModeSelect);
rightContainer.appendChild(laserModeRow);
// Side Screens Toggle
createToggle('Side Screens', 'sideScreensEnabled');
// Music Console Toggle
createToggle('Music Console', 'consoleEnabled', (enabled) => {
const consoleFeature = sceneFeatureManager.features.find(f => f.constructor.name === 'MusicConsole');
if (consoleFeature && consoleFeature.group) consoleFeature.group.visible = enabled;
});
// Console RGB Toggle
createToggle('Console RGB Panel', 'consoleRGBEnabled');
// Gameboy Toggle
createToggle('Gameboy', 'gameboyEnabled');
// Stage Light Bars Toggle
createToggle('Stage Light Bars', 'lightBarsEnabled', (enabled) => {
const lightBars = sceneFeatureManager.features.find(f => f.constructor.name === 'StageLightBars');
if (lightBars) lightBars.setVisibility(enabled);
});
// --- Light Bar Colors ---
const colorContainer = document.createElement('div');
Object.assign(colorContainer.style, {
display: 'flex',
flexDirection: 'column',
gap: '5px',
marginTop: '5px',
paddingTop: '5px',
borderTop: '1px solid #444'
});
const colorLabel = document.createElement('label');
colorLabel.innerText = 'Stage colors';
colorContainer.appendChild(colorLabel);
const colorControls = document.createElement('div');
colorControls.style.display = 'flex';
colorControls.style.gap = '5px';
const colorPicker = document.createElement('input');
colorPicker.type = 'color';
colorPicker.value = '#ff00ff';
colorPicker.style.width = '40px';
colorPicker.style.border = 'none';
colorPicker.style.cursor = 'pointer';
const addColorBtn = document.createElement('button');
addColorBtn.innerText = '+';
addColorBtn.style.cursor = 'pointer';
addColorBtn.onclick = () => {
state.config.lightBarColors.push(colorPicker.value);
saveConfig();
this.updateColorList();
const lightBars = sceneFeatureManager.features.find(f => f.constructor.name === 'StageLightBars');
if (lightBars) lightBars.refreshColors();
};
const clearColorsBtn = document.createElement('button');
clearColorsBtn.innerText = 'Clear';
clearColorsBtn.style.cursor = 'pointer';
clearColorsBtn.onclick = () => {
state.config.lightBarColors = [];
saveConfig();
this.updateColorList();
const lightBars = sceneFeatureManager.features.find(f => f.constructor.name === 'StageLightBars');
if (lightBars) lightBars.refreshColors();
};
const randomizeColorsBtn = document.createElement('button');
randomizeColorsBtn.innerText = 'Randomize';
randomizeColorsBtn.style.cursor = 'pointer';
randomizeColorsBtn.onclick = () => {
const count = 2 + Math.floor(Math.random() * 5); // 2 to 6
const newColors = [];
for(let i=0; i<count; i++) {
newColors.push('#' + new THREE.Color().setHSL(Math.random(), 1.0, 0.5).getHexString());
}
state.config.lightBarColors = newColors;
saveConfig();
this.updateColorList();
const lightBars = sceneFeatureManager.features.find(f => f.constructor.name === 'StageLightBars');
if (lightBars) lightBars.refreshColors();
};
colorControls.appendChild(colorPicker);
colorControls.appendChild(addColorBtn);
colorControls.appendChild(clearColorsBtn);
colorControls.appendChild(randomizeColorsBtn);
colorContainer.appendChild(colorControls);
const colorList = document.createElement('div');
colorList.style.display = 'flex';
colorList.style.flexWrap = 'wrap';
colorList.style.gap = '4px';
colorList.style.marginTop = '4px';
this.colorList = colorList;
colorContainer.appendChild(colorList);
rightContainer.appendChild(colorContainer);
// Guest Count Input
const guestRow = document.createElement('div');
guestRow.style.display = 'flex';
guestRow.style.alignItems = 'center';
guestRow.style.justifyContent = 'space-between';
const guestLabel = document.createElement('label');
guestLabel.innerText = 'Guest Count';
const guestInput = document.createElement('input');
guestInput.type = 'number';
guestInput.min = '0';
guestInput.max = '500';
guestInput.value = state.config.guestCount;
guestInput.style.width = '60px';
guestInput.onchange = (e) => {
const val = parseInt(e.target.value, 10);
state.config.guestCount = val;
saveConfig();
const guestsFeature = sceneFeatureManager.features.find(f => f.constructor.name === 'PartyGuests');
if (guestsFeature) guestsFeature.setGuestCount(val);
};
this.guestInput = guestInput;
guestRow.appendChild(guestLabel);
guestRow.appendChild(guestInput);
rightContainer.appendChild(guestRow);
// DJ Hat Selector
const hatRow = document.createElement('div');
hatRow.style.display = 'flex';
@ -99,14 +289,11 @@ export class ConfigUI extends SceneFeature {
hatRow.appendChild(hatLabel);
hatRow.appendChild(hatSelect);
container.appendChild(hatRow);
rightContainer.appendChild(hatRow);
// --- Status & Control Section ---
const statusContainer = document.createElement('div');
Object.assign(statusContainer.style, {
marginTop: '15px',
paddingTop: '10px',
borderTop: '1px solid #555',
display: 'flex',
flexDirection: 'column',
gap: '8px'
@ -143,7 +330,7 @@ export class ConfigUI extends SceneFeature {
marginTop: '10px',
padding: '8px',
cursor: 'pointer',
backgroundColor: '#555',
backgroundColor: '#ff9800', // Default orange
color: 'white',
border: 'none',
borderRadius: '4px',
@ -168,6 +355,7 @@ export class ConfigUI extends SceneFeature {
loadPosterBtn.onclick = () => {
posterInput.click();
};
this.loadPosterBtn = loadPosterBtn;
statusContainer.appendChild(loadPosterBtn);
// Load Tapes Button
@ -178,7 +366,7 @@ export class ConfigUI extends SceneFeature {
marginTop: '10px',
padding: '8px',
cursor: 'pointer',
backgroundColor: '#555',
backgroundColor: '#ff9800', // Default orange
color: 'white',
border: 'none',
borderRadius: '4px',
@ -194,7 +382,7 @@ export class ConfigUI extends SceneFeature {
marginTop: '10px',
padding: '8px',
cursor: 'pointer',
backgroundColor: '#555',
backgroundColor: '#ff9800', // Default orange
color: 'white',
border: 'none',
borderRadius: '4px',
@ -204,6 +392,7 @@ export class ConfigUI extends SceneFeature {
const fileInput = document.getElementById('musicFileInput');
if (fileInput) fileInput.click();
};
this.chooseSongBtn = chooseSongBtn;
statusContainer.appendChild(chooseSongBtn);
// Start Party Button
@ -214,8 +403,8 @@ export class ConfigUI extends SceneFeature {
marginTop: '10px',
padding: '10px',
cursor: 'not-allowed',
backgroundColor: '#333',
color: '#777',
backgroundColor: '#dc3545', // Default red
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
@ -227,7 +416,7 @@ export class ConfigUI extends SceneFeature {
if (musicPlayer) musicPlayer.startSequence();
};
statusContainer.appendChild(this.startButton);
container.appendChild(statusContainer);
leftContainer.appendChild(statusContainer);
// Reset Button
const resetBtn = document.createElement('button');
@ -281,6 +470,11 @@ export class ConfigUI extends SceneFeature {
lasersEnabled: true,
sideScreensEnabled: true,
consoleRGBEnabled: true,
consoleEnabled: true,
gameboyEnabled: false,
lightBarsEnabled: true,
laserColorMode: 'RUNNING',
guestCount: 150,
djHat: 'None'
};
for (const key in defaults) {
@ -290,14 +484,17 @@ export class ConfigUI extends SceneFeature {
if (this.toggles[key].callback) this.toggles[key].callback(defaults[key]);
}
}
if (this.guestInput) this.guestInput.value = defaults.guestCount;
if (this.laserModeSelect) this.laserModeSelect.value = defaults.laserColorMode;
if (this.hatSelect) this.hatSelect.value = defaults.djHat;
this.updateStatus();
};
container.appendChild(resetBtn);
leftContainer.appendChild(resetBtn);
document.body.appendChild(container);
this.container = container;
document.body.appendChild(leftContainer);
document.body.appendChild(rightContainer);
this.updateStatus();
this.updateColorList();
// Restore poster
MediaStorage.getPoster().then(file => {
@ -308,26 +505,59 @@ export class ConfigUI extends SceneFeature {
});
}
updateColorList() {
if (!this.colorList) return;
this.colorList.innerHTML = '';
state.config.lightBarColors.forEach(color => {
const swatch = document.createElement('div');
Object.assign(swatch.style, {
width: '15px',
height: '15px',
backgroundColor: color,
border: '1px solid #fff',
borderRadius: '2px'
});
this.colorList.appendChild(swatch);
});
}
updateStatus() {
if (!this.songLabel) return;
const orange = '#ff9800';
const green = '#28a745';
const red = '#dc3545';
// Update Song Info
if (state.music && state.music.songTitle) {
this.songLabel.innerText = `Song: ${state.music.songTitle}`;
this.songLabel.style.color = '#fff';
this.startButton.disabled = false;
this.startButton.style.backgroundColor = '#28a745';
this.startButton.style.backgroundColor = green;
this.startButton.style.color = 'white';
this.startButton.style.cursor = 'pointer';
this.startButton.style.opacity = '1';
if (this.chooseSongBtn) this.chooseSongBtn.style.backgroundColor = green;
} else {
this.songLabel.innerText = 'Song: (None)';
this.songLabel.style.color = '#aaa';
this.startButton.disabled = true;
this.startButton.style.backgroundColor = '#333';
this.startButton.style.color = '#777';
this.startButton.style.backgroundColor = red;
this.startButton.style.color = 'white';
this.startButton.style.opacity = '0.6';
this.startButton.style.cursor = 'not-allowed';
if (this.chooseSongBtn) this.chooseSongBtn.style.backgroundColor = orange;
}
if (this.loadPosterBtn) {
this.loadPosterBtn.style.backgroundColor = state.posterImage ? green : orange;
}
if (state.loadTapeButton) {
state.loadTapeButton.style.backgroundColor = (state.videoUrls && state.videoUrls.length > 0) ? green : orange;
}
// Update Tape List
@ -356,10 +586,10 @@ export class ConfigUI extends SceneFeature {
}
onPartyStart() {
if (this.container) this.container.style.display = 'none';
this.containers.forEach(c => c.style.display = 'none');
}
onPartyEnd() {
if (this.container) this.container.style.display = 'flex';
this.containers.forEach(c => c.style.display = 'flex');
}
}

View File

@ -142,6 +142,42 @@ export class DJ extends SceneFeature {
topHatGroup.visible = false;
this.head.add(topHatGroup);
this.hats['Top Hat'] = topHatGroup;
// --- Gameboy / Portable Console ---
const gbGroup = new THREE.Group();
// Body
const gbGeo = new THREE.BoxGeometry(0.2, 0.3, 0.05);
const gbMat = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.4 });
const gbBody = new THREE.Mesh(gbGeo, gbMat);
gbGroup.add(gbBody);
// Screen (Emissive)
const screenGeo = new THREE.PlaneGeometry(0.16, 0.12);
this.gbScreenMat = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const screen = new THREE.Mesh(screenGeo, this.gbScreenMat);
screen.position.set(0, 0.05, 0.026);
gbGroup.add(screen);
// Knobs/Buttons
const knobGeo = new THREE.CylinderGeometry(0.02, 0.02, 0.02, 8);
const knobMat = new THREE.MeshStandardMaterial({ color: 0xcccccc });
const k1 = new THREE.Mesh(knobGeo, knobMat);
k1.rotation.x = Math.PI/2;
k1.position.set(-0.05, -0.08, 0.026);
gbGroup.add(k1);
const k2 = new THREE.Mesh(knobGeo, knobMat);
k2.rotation.x = Math.PI/2;
k2.position.set(0.05, -0.08, 0.026);
gbGroup.add(k2);
// Attach to Left Arm (Hand position)
gbGroup.position.set(0, -0.9, 0.1);
gbGroup.rotation.x = -Math.PI / 4; // Tilted up
gbGroup.rotation.z = Math.PI; // Upside down
this.leftArm.add(gbGroup);
this.gameboy = gbGroup;
}
update(deltaTime) {
@ -162,9 +198,16 @@ export class DJ extends SceneFeature {
// Update Arm State
this.armTimer -= deltaTime;
if (this.armTimer <= 0) {
this.armState = Math.floor(Math.random() * 5);
this.armState = Math.floor(Math.random() * 4); // 0-3: Dance moves
this.armTimer = 2 + Math.random() * 4;
if (Math.random() < 0.3) this.armState = 4; // Twiddling some more
const rand = Math.random();
// Gameboy Fiddling
if (state.config.gameboyEnabled && rand < 0.35) {
this.armState = 5;
} else if (state.config.consoleEnabled && rand < 0.65) {
this.armState = 4; // Console Twiddling
}
}
const upAngle = Math.PI * 0.85;
@ -176,12 +219,19 @@ export class DJ extends SceneFeature {
let targetLeftX = 0;
let targetRightX = 0;
if (this.armState === 4) {
if (this.armState === 4 && state.config.consoleEnabled) {
// Twiddling
targetLeftZ = 0.2;
targetRightZ = 0.2;
targetLeftX = twiddleX;
targetRightX = twiddleX;
} else if (this.armState === 5 && state.config.gameboyEnabled) {
targetLeftZ = -0.3;
targetRightZ = -0.5; // Cross inwards
const timeRotX = Math.cos(time * 0.5) * 0.3;
const consoleRotX = state.config.consoleEnabled ? -0.5 : 0;
targetLeftX = -1.0 + timeRotX + consoleRotX; // Lift up
targetRightX = -1.1 + timeRotX + consoleRotX; // Lift up
} else {
targetLeftZ = (this.armState === 1 || this.armState === 3) ? upAngle : downAngle;
targetRightZ = (this.armState === 2 || this.armState === 3) ? upAngle : downAngle;
@ -192,12 +242,18 @@ export class DJ extends SceneFeature {
this.currentLeftAngleX = THREE.MathUtils.lerp(this.currentLeftAngleX, targetLeftX, deltaTime * 5);
this.currentRightAngleX = THREE.MathUtils.lerp(this.currentRightAngleX, targetRightX, deltaTime * 5);
if (this.armState === 4) {
if (this.armState === 4 && state.config.consoleEnabled) {
const t = time * 15;
this.leftArm.rotation.z = -this.currentLeftAngle + Math.cos(t) * 0.05;
this.rightArm.rotation.z = this.currentRightAngle + Math.sin(t) * 0.05;
this.leftArm.rotation.x = this.currentLeftAngleX + Math.sin(t) * 0.1;
this.rightArm.rotation.x = this.currentRightAngleX + Math.cos(t) * 0.1;
} else if (this.armState === 5 && state.config.gameboyEnabled) {
const t = time * 20;
this.leftArm.rotation.z = -this.currentLeftAngle + Math.sin(t * 0.2) * 0.01;
this.rightArm.rotation.z = this.currentRightAngle + Math.sin(t) * 0.05;
this.leftArm.rotation.x = this.currentLeftAngleX + Math.cos(t * 0.2) * 0.01;
this.rightArm.rotation.x = this.currentRightAngleX + Math.cos(t) * 0.05;
} else {
const wave = Math.sin(time * 8) * 0.1;
const beatBounce = beatIntensity * 0.2;
@ -215,7 +271,8 @@ export class DJ extends SceneFeature {
this.moveTimer -= deltaTime;
if (this.moveTimer <= 0) {
this.state = 'MOVING';
this.targetX = (Math.random() - 0.5) * 2.5;
const range = state.config.consoleEnabled ? 2.5 : 10.0;
this.targetX = (Math.random() - 0.5) * range;
}
} else if (this.state === 'MOVING') {
const speed = 1.5;
@ -232,6 +289,15 @@ export class DJ extends SceneFeature {
for (const [name, mesh] of Object.entries(this.hats)) {
mesh.visible = (state.config.djHat === name);
}
// Update Gameboy
if (this.gameboy) {
this.gameboy.visible = state.config.gameboyEnabled;
if (this.gameboy.visible && state.music) {
const hue = (time * 0.5) % 1;
this.gbScreenMat.color.setHSL(hue, 1.0, 0.5);
}
}
}
onPartyStart() {

View File

@ -21,6 +21,8 @@ export class MusicConsole extends SceneFeature {
// Position on stage, centered
group.position.set(0, stageY, -16.5);
state.scene.add(group);
this.group = group;
this.group.visible = state.config.consoleEnabled;
// 1. The Stand/Table Body
const standGeo = new THREE.BoxGeometry(consoleWidth, consoleHeight, consoleDepth);
@ -226,11 +228,20 @@ export class MusicConsole extends SceneFeature {
const intensity = (wave1 + wave2 + wave3) / 3 * 0.5 + 0.5;
// Color palette shifting
const hue = (time * 0.1 + u * 0.3 + intensity * 0.2) % 1;
const sat = 0.9;
const light = intensity * (0.1 + beatIntensity * 0.9); // Pulse brightness with beat
color.setHSL(hue, sat, light);
const palette = state.config.lightBarColors;
if (palette && palette.length > 0) {
const drive = (time * 0.1 + u * 0.3 + intensity * 0.2);
const paletteIndex = Math.floor(((drive % 1) + 1) % 1 * palette.length);
color.set(palette[paletteIndex]);
const light = intensity * (0.1 + beatIntensity * 0.9);
color.multiplyScalar(light);
} else {
const hue = (time * 0.1 + u * 0.3 + intensity * 0.2) % 1;
const sat = 0.9;
const light = intensity * (0.1 + beatIntensity * 0.9); // Pulse brightness with beat
color.setHSL(hue, sat, light);
}
this.frontLedMesh.setColorAt(idx, color);
idx++;
}

View File

@ -77,10 +77,12 @@ export class MusicPlayer extends SceneFeature {
startSequence() {
const uiContainer = document.getElementById('ui-container');
const configUI = document.getElementById('config-ui');
const configUILeft = document.getElementById('config-ui-left');
const configUIRight = document.getElementById('config-ui-right');
if (uiContainer) uiContainer.style.display = 'none';
if (configUI) configUI.style.display = 'none';
if (configUILeft) configUILeft.style.display = 'none';
if (configUIRight) configUIRight.style.display = 'none';
if (state.loadTapeButton) state.loadTapeButton.classList.add('hidden');
showStandbyScreen();

View File

@ -7,7 +7,6 @@ import sceneFeatureManager from './SceneFeatureManager.js';
const stageHeight = 1.5;
const stageDepth = 5;
const length = 25;
const numGuests = 150;
const moveSpeed = 0.8;
const movementArea = { x: 15, z: length, y: 0, centerZ: -2 };
const jumpChance = 0.01;
@ -25,6 +24,7 @@ export class PartyGuests extends SceneFeature {
constructor() {
super();
this.guests = [];
this.guestPool = []; // Store all created guests to reuse them
sceneFeatureManager.register(this);
}
@ -36,96 +36,115 @@ export class PartyGuests extends SceneFeature {
const headGeo = new THREE.SphereGeometry(0.25, 16, 16);
// Arm: Ellipsoid (Scaled Sphere)
const armGeo = new THREE.SphereGeometry(0.12, 16, 16);
this.geometries = { bodyGeo, headGeo, armGeo };
const createGuests = () => {
for (let i = 0; i < numGuests; i++) {
// Random Color
// Dark gray-blue shades
const color = new THREE.Color().setHSL(
0.6 + (Math.random() * 0.1 - 0.05), // Hue around 0.6 (blue)
0.1 + Math.random() * 0.1, // Low saturation
0.01 + Math.random() * 0.05 // Much darker lightness
);
const material = new THREE.MeshStandardMaterial({
color: color,
roughness: 0.6,
metalness: 0.1,
});
// Initialize with config count
this.setGuestCount(state.config.guestCount);
}
const group = new THREE.Group();
createGuest() {
// Random Color
// Dark gray-blue shades
const color = new THREE.Color().setHSL(
0.6 + (Math.random() * 0.1 - 0.05), // Hue around 0.6 (blue)
0.1 + Math.random() * 0.1, // Low saturation
0.01 + Math.random() * 0.05 // Much darker lightness
);
const material = new THREE.MeshStandardMaterial({
color: color,
roughness: 0.6,
metalness: 0.1,
});
const scale = 0.85 + Math.random() * 0.3;
group.scale.setScalar(scale);
const group = new THREE.Group();
// Body
const body = new THREE.Mesh(bodyGeo, material);
body.position.y = 0.7; // Center of capsule (0.8 length + 0.3*2 radius = 1.4 total height. Center at 0.7)
body.castShadow = true;
body.receiveShadow = true;
group.add(body);
const scale = 0.85 + Math.random() * 0.3;
group.scale.setScalar(scale);
// Head
const head = new THREE.Mesh(headGeo, material);
head.position.y = 1.55; // Top of body
head.castShadow = true;
head.receiveShadow = true;
group.add(head);
// Body
const body = new THREE.Mesh(this.geometries.bodyGeo, material);
body.position.y = 0.7;
body.castShadow = true;
body.receiveShadow = true;
group.add(body);
// Arms
const createArm = (isLeft) => {
const pivot = new THREE.Group();
// Shoulder position
pivot.position.set(isLeft ? -0.35 : 0.35, 1.3, 0);
const arm = new THREE.Mesh(armGeo, material);
arm.scale.set(1, 3.5, 1); // Ellipsoid
arm.position.y = -0.4; // Hang down from pivot
arm.castShadow = true;
arm.receiveShadow = true;
pivot.add(arm);
return pivot;
};
// Head
const head = new THREE.Mesh(this.geometries.headGeo, material);
head.position.y = 1.55;
head.castShadow = true;
head.receiveShadow = true;
group.add(head);
const leftArm = createArm(true);
const rightArm = createArm(false);
group.add(leftArm);
group.add(rightArm);
// Position
const pos = new THREE.Vector3(
(Math.random() - 0.5) * movementArea.x,
0,
movementArea.centerZ + ( rushIn
? (((Math.random()-0.5) * length * 0.6) - length * 0.3)
: ((Math.random()-0.5) * length))
);
group.position.copy(pos);
group.visible = false;
state.scene.add(group);
this.guests.push({
mesh: group,
leftArm,
rightArm,
state: 'WAITING',
targetPosition: pos.clone(),
waitStartTime: 0,
waitTime: 3 + Math.random() * 4, // Wait longer: 3-7 seconds
isJumping: false,
jumpStartTime: 0,
jumpHeight: 0,
shouldRaiseArms: false,
handsUpTimer: 0,
handsRaisedType: 'BOTH',
randomOffset: Math.random() * 100
});
}
// Arms
const createArm = (isLeft) => {
const pivot = new THREE.Group();
// Shoulder position
pivot.position.set(isLeft ? -0.35 : 0.35, 1.3, 0);
const arm = new THREE.Mesh(this.geometries.armGeo, material);
arm.scale.set(1, 3.5, 1); // Ellipsoid
arm.position.y = -0.4; // Hang down from pivot
arm.castShadow = true;
arm.receiveShadow = true;
pivot.add(arm);
return pivot;
};
createGuests();
const leftArm = createArm(true);
const rightArm = createArm(false);
group.add(leftArm);
group.add(rightArm);
// Position
const pos = new THREE.Vector3(
(Math.random() - 0.5) * movementArea.x,
0,
movementArea.centerZ + ( rushIn
? (((Math.random()-0.5) * length * 0.6) - length * 0.3)
: ((Math.random()-0.5) * length))
);
group.position.copy(pos);
group.visible = false;
state.scene.add(group);
return {
mesh: group,
leftArm,
rightArm,
state: 'WAITING',
targetPosition: pos.clone(),
waitStartTime: 0,
waitTime: 3 + Math.random() * 4,
isJumping: false,
jumpStartTime: 0,
jumpHeight: 0,
shouldRaiseArms: false,
handsUpTimer: 0,
handsRaisedType: 'BOTH',
randomOffset: Math.random() * 100
};
}
setGuestCount(count) {
// Ensure we have enough guests in the pool
while (this.guestPool.length < count) {
this.guestPool.push(this.createGuest());
}
// Update visibility and active list
this.guests = [];
this.guestPool.forEach((guest, index) => {
if (index < count) {
guest.mesh.visible = state.partyStarted; // Only visible if party started
this.guests.push(guest);
} else {
guest.mesh.visible = false;
}
});
}
update(deltaTime) {
@ -280,7 +299,7 @@ export class PartyGuests extends SceneFeature {
onPartyStart() {
const stageFrontZ = -40 / 2 + 5 + 5; // In front of the stage
this.guests.forEach((guestObj, index) => {
guestObj.mesh.visible = true;
guestObj.mesh.visible = true; // Make sure active guests are visible
// Rush to the stage
guestObj.state = 'MOVING';
if (index % 2 === 0) {

View File

@ -64,6 +64,8 @@ uniform float u_time;
uniform float u_beat;
uniform float u_opacity;
uniform vec2 u_resolution;
uniform vec3 u_colors[16];
uniform int u_colorCount;
varying vec2 vUv;
vec3 hsv2rgb(vec3 c) {
@ -98,7 +100,24 @@ void main() {
float hue = fract(u_time * 0.1 + d * 0.2);
float val = 0.5 + 0.5 * sin(wave + beatWave);
gl_FragColor = vec4(hsv2rgb(vec3(hue, 0.8, val)), mask);
vec3 finalColor;
if (u_colorCount > 0) {
float indexFloat = hue * float(u_colorCount);
int index = int(mod(indexFloat, float(u_colorCount)));
vec3 c = vec3(0.0);
for (int i = 0; i < 16; i++) {
if (i == index) {
c = u_colors[i];
break;
}
}
finalColor = c * val;
} else {
finalColor = hsv2rgb(vec3(hue, 0.8, val));
}
gl_FragColor = vec4(finalColor, mask);
}
`;
@ -110,6 +129,7 @@ export class ProjectionScreen extends SceneFeature {
projectionScreenInstance = this;
this.isVisualizerActive = false;
this.screens = [];
this.colorBuffer = new Float32Array(16 * 3);
sceneFeatureManager.register(this);
}
@ -236,11 +256,27 @@ export class ProjectionScreen extends SceneFeature {
if (this.isVisualizerActive) {
const beat = (state.music && state.music.beatIntensity) ? state.music.beatIntensity : 0.0;
state.screenLight.intensity = state.originalScreenIntensity * (0.5 + beat * 0.5);
// Update color buffer
const colors = state.config.lightBarColors;
const colorCount = colors ? Math.min(colors.length, 16) : 0;
if (colorCount > 0) {
for (let i = 0; i < colorCount; i++) {
const c = new THREE.Color(colors[i]);
this.colorBuffer[i * 3] = c.r;
this.colorBuffer[i * 3 + 1] = c.g;
this.colorBuffer[i * 3 + 2] = c.b;
}
}
this.screens.forEach(s => {
if (s.mesh.material && s.mesh.material.uniforms && s.mesh.material.uniforms.u_time) {
s.mesh.material.uniforms.u_time.value = state.clock.getElapsedTime();
s.mesh.material.uniforms.u_beat.value = beat;
if (s.mesh.material.uniforms.u_colorCount) {
s.mesh.material.uniforms.u_colorCount.value = colorCount;
}
}
});
}
@ -273,7 +309,9 @@ export class ProjectionScreen extends SceneFeature {
u_time: { value: 0.0 },
u_beat: { value: 0.0 },
u_opacity: { value: state.screenOpacity },
u_resolution: { value: new THREE.Vector2(1, 1) } // Placeholder, set in applyMaterialToAll
u_resolution: { value: new THREE.Vector2(1, 1) }, // Placeholder, set in applyMaterialToAll
u_colors: { value: this.colorBuffer },
u_colorCount: { value: 0 }
},
vertexShader: screenVertexShader,
fragmentShader: visualizerFragmentShader,

View File

@ -19,6 +19,7 @@ import { DJ } from './dj.js';
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';
// Scene Features ^^^
// --- Scene Modeling Function ---

View File

@ -13,6 +13,7 @@ export class StageLasers extends SceneFeature {
this.activationState = 'IDLE';
this.stateTimer = 0;
this.initialSilenceSeconds = 10;
this.currentCycleMode = 'RUNNING';
sceneFeatureManager.register(this);
}
@ -94,7 +95,8 @@ export class StageLasers extends SceneFeature {
flare: flare,
index: i,
totalInBank: count,
bankId: position.x < 0 ? 0 : (position.x > 0 ? 1 : 2) // 0:L, 1:R, 2:C
bankId: position.x < 0 ? 0 : (position.x > 0 ? 1 : 2), // 0:L, 1:R, 2:C
staticColorIndex: Math.floor(Math.random() * 100)
});
}
}
@ -125,6 +127,10 @@ export class StageLasers extends SceneFeature {
if (time > this.initialSilenceSeconds && loudness > this.averageLoudness + 0.1) {
this.activationState = 'WARMUP';
this.stateTimer = 1.0; // Warmup duration
// Pick a random mode for this activation cycle (used if config is 'ANY')
const modes = ['SINGLE', 'RANDOM', 'RUNNING'];
this.currentCycleMode = modes[Math.floor(Math.random() * modes.length)];
}
} else if (this.activationState === 'WARMUP') {
isActive = true;
@ -172,8 +178,7 @@ export class StageLasers extends SceneFeature {
// --- Color & Intensity ---
const beat = state.music ? state.music.beatIntensity : 0;
const hue = (time * 0.1) % 1;
const color = new THREE.Color().setHSL(hue, 1.0, 0.5);
let intensity = 0.2 + beat * 0.6;
// Strobe Mode: Flash rapidly when beat intensity is high
@ -205,9 +210,39 @@ export class StageLasers extends SceneFeature {
flareScale = fade;
}
l.mesh.material.color.copy(color);
let colorHex = 0x00ff00; // Default green
const palette = state.config.lightBarColors;
let mode = state.config.laserColorMode;
if (mode === 'ANY') {
mode = this.currentCycleMode;
}
if (palette && palette.length > 0) {
if (mode === 'SINGLE') {
colorHex = palette[0];
} else if (mode === 'RANDOM') {
colorHex = palette[l.staticColorIndex % palette.length];
} else if (mode === 'RUNNING') {
const offset = Math.floor(time * 4); // Speed of running
const idx = (l.index + offset) % palette.length;
colorHex = palette[idx];
} else {
// Fallback to running if unknown
const idx = l.index % palette.length;
colorHex = palette[idx];
}
} 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();
}
l.mesh.material.color.set(colorHex);
l.flare.material.color.set(colorHex);
l.mesh.material.opacity = currentIntensity;
l.flare.material.color.copy(color);
l.flare.scale.setScalar(flareScale);
// --- Movement Calculation ---

View File

@ -0,0 +1,163 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
export class StageLightBars extends SceneFeature {
constructor() {
super();
this.bars = [];
this.mode = 'STATIC'; // 'STATIC' or 'CHASE'
this.lastModeChange = 0;
this.chaseOffset = 0;
this.staticIndices = [];
sceneFeatureManager.register(this);
}
init() {
// Shared Geometry/Material setup
// We use individual materials to allow different colors per bar if needed,
// or we can update them dynamically.
// 1. Stage Front Edge Bar
this.createBar(new THREE.Vector3(0, 1.50, -14.95), new THREE.Vector3(11, 0.1, 0.1));
// 2. Stage Side Bars (Vertical)
// Left Front
this.createBar(new THREE.Vector3(-5.45, 0.7, -14.95), new THREE.Vector3(0.1, 1.5, 0.1));
// Right Front
this.createBar(new THREE.Vector3(5.45, 0.7, -14.95), new THREE.Vector3(0.1, 1.5, 0.1));
// 3. Overhead Beam Bars
this.createBar(new THREE.Vector3(0, 8.5, -14), new THREE.Vector3(31, 0.1, 0.1));
// 4. Vertical Truss Bars (Sides of the beam)
this.createBar(new THREE.Vector3(-15.9, 3, -14), new THREE.Vector3(0.2, 12, 0.2));
this.createBar(new THREE.Vector3(15.9, 3, -14), new THREE.Vector3(0.2, 12, 0.2));
this.applyColors();
this.setVisibility(state.config.lightBarsEnabled);
}
createBar(position, size) {
const geometry = new THREE.BoxGeometry(size.x, size.y, size.z);
const material = new THREE.MeshStandardMaterial({
color: 0xffffff,
emissive: 0xffffff,
emissiveIntensity: 1.0,
roughness: 0.4,
metalness: 0.8
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.copy(position);
mesh.castShadow = true; // Maybe cast shadow?
state.scene.add(mesh);
this.bars.push({ mesh, material });
}
applyColors() {
const colors = state.config.lightBarColors;
if (!colors || colors.length === 0) {
this.staticIndices = [];
} else {
// Default distribution (sequential)
this.staticIndices = this.bars.map((_, i) => i % colors.length);
}
this.updateBarColors();
}
updateBarColors() {
const colors = state.config.lightBarColors;
if (!colors || colors.length === 0) {
this.bars.forEach(bar => {
bar.material.color.setHex(0x111111);
bar.material.emissive.setHex(0x000000);
});
return;
}
this.bars.forEach((bar, index) => {
let colorIndex;
if (this.mode === 'CHASE') {
colorIndex = Math.floor(index + this.chaseOffset) % colors.length;
} else {
// Ensure staticIndices is populated
if (this.staticIndices.length <= index) {
this.staticIndices.push(index % colors.length);
}
colorIndex = this.staticIndices[index] % colors.length;
}
if (colorIndex < 0) colorIndex += colors.length;
const colorHex = colors[colorIndex];
const color = new THREE.Color(colorHex);
bar.material.color.copy(color);
bar.material.emissive.copy(color);
});
}
redistributeColors() {
const colors = state.config.lightBarColors;
if (colors && colors.length > 0) {
this.staticIndices = this.bars.map(() => Math.floor(Math.random() * colors.length));
}
}
setVisibility(visible) {
this.bars.forEach(bar => {
bar.mesh.visible = visible;
});
}
update(deltaTime) {
if (!state.partyStarted) return;
if (!state.config.lightBarsEnabled) return;
const time = state.clock.getElapsedTime();
const beatIntensity = state.music ? state.music.beatIntensity : 0;
const isBeat = beatIntensity > 0.8;
// Mode Switching Logic
if (isBeat && time - this.lastModeChange > 4.0) {
// Chance to switch mode or redistribute
if (Math.random() < 0.3) {
this.mode = this.mode === 'STATIC' ? 'CHASE' : 'STATIC';
this.lastModeChange = time;
if (this.mode === 'STATIC') {
this.redistributeColors();
}
} else if (this.mode === 'STATIC' && Math.random() < 0.5) {
// Redistribute without switching mode
this.redistributeColors();
this.lastModeChange = time;
}
}
// Update Chase
if (this.mode === 'CHASE') {
this.chaseOffset += deltaTime * 4.0;
}
this.updateBarColors();
// Pulsate
const baseIntensity = 0.2;
const pulse = Math.sin(time * 2.0) * 0.3 + 0.3; // Breathing
const beatFlash = beatIntensity * 2.0; // Sharp flash on beat
const totalIntensity = baseIntensity + pulse + beatFlash;
this.bars.forEach(bar => {
bar.material.emissiveIntensity = totalIntensity;
});
}
refreshColors() {
this.applyColors();
}
}
new StageLightBars();

View File

@ -82,10 +82,29 @@ export class StageLights extends SceneFeature {
baseX: x
});
}
// Initialize static random state for pre-party
this.staticColor = new THREE.Color().setHSL(Math.random(), 0.8, 0.5);
this.focusPoint.set((Math.random() - 0.5) * 20, Math.random() * 2, -10 + (Math.random() - 0.5) * 10);
this.targetFocusPoint.copy(this.focusPoint);
}
update(deltaTime) {
const time = state.clock.getElapsedTime();
if (!state.partyStarted) {
this.lights.forEach((item) => {
const targetX = this.focusPoint.x + (item.baseX * 0.2);
item.target.position.set(targetX, this.focusPoint.y, this.focusPoint.z);
item.fixture.lookAt(targetX, this.focusPoint.y, this.focusPoint.z);
item.light.intensity = 30;
if (this.staticColor) {
item.light.color.copy(this.staticColor);
item.lens.material.color.copy(this.staticColor);
}
});
return;
}
// Change target area logic
let shouldChange = false;
@ -119,21 +138,29 @@ export class StageLights extends SceneFeature {
// Update each light
const intensity = state.music ? 20 + state.music.beatIntensity * 150 : 50;
const hue = (time * 0.2) % 1;
const color = new THREE.Color().setHSL(hue, 0.8, 0.5);
const spread = 0.2 + (state.music ? state.music.beatIntensity * 0.4 : 0);
const bounce = state.music ? state.music.beatIntensity * 0.5 : 0;
const palette = state.config.lightBarColors;
this.lights.forEach((item) => {
this.lights.forEach((item, index) => {
// Converge lights on focus point, but keep slight X offset for spread
const targetX = this.focusPoint.x + (item.baseX * spread);
item.target.position.set(targetX, this.focusPoint.y + bounce, this.focusPoint.z);
item.fixture.lookAt(targetX, this.focusPoint.y, this.focusPoint.z);
item.light.intensity = intensity;
item.light.color.copy(color);
item.lens.material.color.copy(color);
if (palette && palette.length > 0) {
const colorIndex = Math.floor(time * 0.5) % palette.length;
const c = new THREE.Color(palette[colorIndex]);
item.light.color.copy(c);
item.lens.material.color.copy(c);
} else {
const hue = (time * 0.2) % 1;
const c = new THREE.Color().setHSL(hue, 0.8, 0.5);
item.light.color.copy(c);
item.lens.material.color.copy(c);
}
});
}
}

View File

@ -38,7 +38,7 @@ export class StageTorches extends SceneFeature {
createTorch(position) {
const torchGroup = new THREE.Group();
torchGroup.position.copy(position);
torchGroup.visible = false; // Start invisible
torchGroup.visible = state.config.torchesEnabled;
// --- Torch Holder ---
const holderMaterial = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.6, metalness: 0.5 });
@ -55,6 +55,7 @@ export class StageTorches extends SceneFeature {
pointLight.castShadow = true;
pointLight.shadow.mapSize.width = 128;
pointLight.shadow.mapSize.height = 128;
pointLight.visible = false;
torchGroup.add(pointLight);
// --- Particle System for Fire ---
@ -82,6 +83,7 @@ export class StageTorches extends SceneFeature {
}
particles.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const particleSystem = new THREE.Points(particles, particleMaterial);
particleSystem.visible = false;
torchGroup.add(particleSystem);
return { group: torchGroup, light: pointLight, particles: particleSystem, particleData: particleData };
@ -102,9 +104,25 @@ export class StageTorches extends SceneFeature {
}
update(deltaTime) {
if (!state.partyStarted) return;
const enabled = state.config.torchesEnabled;
this.torches.forEach(torch => {
if (torch.group.visible !== enabled) torch.group.visible = enabled;
});
if (!enabled) 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;
}
this.torches.forEach(torch => {
if (!torch.light.visible) torch.light.visible = true;
if (!torch.particles.visible) torch.particles.visible = true;
let measurePulse = 0;
if (state.music) {
measurePulse = state.music.measurePulse * 2.0; // Make flames jump higher
@ -156,8 +174,14 @@ export class StageTorches extends SceneFeature {
onPartyStart() {
this.torches.forEach(torch => {
torch.group.visible = true;
this.resetParticles(torch);
if (state.config.torchesEnabled) {
torch.group.visible = true;
torch.light.visible = true;
torch.particles.visible = true;
this.resetParticles(torch);
} else {
torch.group.visible = false;
}
});
}

View File

@ -8,6 +8,12 @@ export function initState() {
lasersEnabled: true,
sideScreensEnabled: true,
consoleRGBEnabled: true,
consoleEnabled: true,
gameboyEnabled: false,
lightBarsEnabled: true,
laserColorMode: 'RUNNING', // 'SINGLE', 'RANDOM', 'RUNNING', 'ANY'
lightBarColors: ['#ff00ff', '#00ffff', '#ffff00'], // Default neon colors
guestCount: 150,
djHat: 'None' // 'None', 'Santa', 'Top Hat'
};
try {