Compare commits
13 Commits
3e773361e2
...
03474298a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03474298a9 | ||
|
|
e7931174de | ||
|
|
8ed8ea9d34 | ||
|
|
2d570b8141 | ||
|
|
3a7251e185 | ||
|
|
48fe11bf3f | ||
|
|
0dc61d12d9 | ||
|
|
0e9deaa161 | ||
|
|
1586df7e51 | ||
|
|
e2ac3e90a1 | ||
|
|
fd3d33aaab | ||
|
|
032f2981a0 | ||
|
|
eba86f81d6 |
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Party Cathedral</title>
|
||||
<title>Party Stage</title>
|
||||
|
||||
<style>
|
||||
/* Cheerful medieval aesthetic */
|
||||
|
||||
66
party-stage/src/core/media-storage.js
Normal file
66
party-stage/src/core/media-storage.js
Normal file
@ -0,0 +1,66 @@
|
||||
const DB_NAME = 'PartyMediaDB';
|
||||
const DB_VERSION = 2;
|
||||
|
||||
export const MediaStorage = {
|
||||
open: () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains('music')) db.createObjectStore('music');
|
||||
if (!db.objectStoreNames.contains('tapes')) db.createObjectStore('tapes');
|
||||
if (!db.objectStoreNames.contains('poster')) db.createObjectStore('poster');
|
||||
};
|
||||
request.onsuccess = (e) => resolve(e.target.result);
|
||||
request.onerror = (e) => reject(e);
|
||||
});
|
||||
},
|
||||
saveMusic: async (file) => {
|
||||
const db = await MediaStorage.open();
|
||||
const tx = db.transaction('music', 'readwrite');
|
||||
tx.objectStore('music').put(file, 'currentSong');
|
||||
},
|
||||
getMusic: async () => {
|
||||
const db = await MediaStorage.open();
|
||||
return new Promise((resolve) => {
|
||||
const req = db.transaction('music', 'readonly').objectStore('music').get('currentSong');
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
},
|
||||
saveTapes: async (files) => {
|
||||
const db = await MediaStorage.open();
|
||||
const tx = db.transaction('tapes', 'readwrite');
|
||||
const store = tx.objectStore('tapes');
|
||||
store.clear();
|
||||
Array.from(files).forEach((file, i) => store.put(file, i));
|
||||
},
|
||||
getTapes: async () => {
|
||||
const db = await MediaStorage.open();
|
||||
return new Promise((resolve) => {
|
||||
const req = db.transaction('tapes', 'readonly').objectStore('tapes').getAll();
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => resolve([]);
|
||||
});
|
||||
},
|
||||
savePoster: async (file) => {
|
||||
const db = await MediaStorage.open();
|
||||
const tx = db.transaction('poster', 'readwrite');
|
||||
tx.objectStore('poster').put(file, 'currentPoster');
|
||||
},
|
||||
getPoster: async () => {
|
||||
const db = await MediaStorage.open();
|
||||
return new Promise((resolve) => {
|
||||
const req = db.transaction('poster', 'readonly').objectStore('poster').get('currentPoster');
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
},
|
||||
clear: async () => {
|
||||
const db = await MediaStorage.open();
|
||||
const tx = db.transaction(['music', 'tapes', 'poster'], 'readwrite');
|
||||
tx.objectStore('music').clear();
|
||||
tx.objectStore('tapes').clear();
|
||||
tx.objectStore('poster').clear();
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,25 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import { turnTvScreenOff, turnTvScreenOn } from '../scene/projection-screen.js';
|
||||
import { turnTvScreenOff, turnTvScreenOn, showStandbyScreen } from '../scene/projection-screen.js';
|
||||
import sceneFeatureManager from '../scene/SceneFeatureManager.js';
|
||||
import { MediaStorage } from './media-storage.js';
|
||||
|
||||
// Register a feature to handle party start
|
||||
sceneFeatureManager.register({
|
||||
init: () => {},
|
||||
update: () => {},
|
||||
onPartyStart: () => {
|
||||
if (state.videoUrls && state.videoUrls.length > 0) {
|
||||
startVideoPlayback();
|
||||
}
|
||||
},
|
||||
onPartyEnd: () => {
|
||||
if (state.videoElement && !state.videoElement.paused) {
|
||||
state.videoElement.pause();
|
||||
updatePlayPauseButton();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Play video by index ---
|
||||
export function playVideoByIndex(index) {
|
||||
@ -70,6 +89,14 @@ export function playNextVideo() {
|
||||
playVideoByIndex(nextIndex);
|
||||
}
|
||||
|
||||
export function startVideoPlayback() {
|
||||
if (state.videoUrls && state.videoUrls.length > 0) {
|
||||
console.info(`Starting playback of ${state.videoUrls.length} tapes.`);
|
||||
if (state.loadTapeButton) state.loadTapeButton.classList.add("hidden");
|
||||
playVideoByIndex(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function playPreviousVideo() {
|
||||
let prevIndex = state.currentVideoIndex - 1;
|
||||
if (prevIndex < 0) {
|
||||
@ -133,6 +160,13 @@ export function initVideoUI() {
|
||||
state.loadTapeButton = btn;
|
||||
}
|
||||
state.loadTapeButton.onclick = () => state.fileInput.click();
|
||||
|
||||
// Restore tapes from storage
|
||||
MediaStorage.getTapes().then(files => {
|
||||
if (files && files.length > 0) {
|
||||
processVideoFiles(files);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Video Loading Logic (handles multiple files) ---
|
||||
@ -143,15 +177,22 @@ export function loadVideoFile(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
processVideoFiles(files);
|
||||
MediaStorage.saveTapes(files);
|
||||
}
|
||||
|
||||
function processVideoFiles(files) {
|
||||
// 1. Clear previous URLs and revoke object URLs to prevent memory leaks
|
||||
state.videoUrls.forEach(url => URL.revokeObjectURL(url));
|
||||
state.videoUrls = [];
|
||||
state.videoFilenames = [];
|
||||
|
||||
// 2. Populate the new videoUrls array
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.type.startsWith('video/')) {
|
||||
state.videoUrls.push(URL.createObjectURL(file));
|
||||
state.videoFilenames.push(file.name);
|
||||
}
|
||||
}
|
||||
|
||||
@ -160,11 +201,18 @@ export function loadVideoFile(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Start playback of the first video
|
||||
console.info(`Loaded ${state.videoUrls.length} tapes. Starting playback...`);
|
||||
state.loadTapeButton.classList.add("hidden");
|
||||
// Update Config UI
|
||||
const configUI = sceneFeatureManager.features.find(f => f.constructor.name === 'ConfigUI');
|
||||
if (configUI) configUI.updateStatus();
|
||||
|
||||
const startDelay = 5;
|
||||
console.info(`Video will start in ${startDelay} seconds.`);
|
||||
setTimeout(() => { playVideoByIndex(0); }, startDelay * 1000);
|
||||
// 3. Start playback logic
|
||||
console.info(`Loaded ${state.videoUrls.length} tapes.`);
|
||||
|
||||
if (state.partyStarted) {
|
||||
startVideoPlayback();
|
||||
} else {
|
||||
console.info("Tapes loaded. Waiting for party start...");
|
||||
if (state.loadTapeButton) state.loadTapeButton.innerText = "Tapes Ready";
|
||||
showStandbyScreen();
|
||||
}
|
||||
}
|
||||
@ -124,7 +124,10 @@ export class CameraManager extends SceneFeature {
|
||||
state.camera.aspect = newCam.aspect;
|
||||
state.camera.near = newCam.near;
|
||||
state.camera.far = newCam.far;
|
||||
state.camera.aspect = window.innerWidth / window.innerHeight;
|
||||
state.camera.updateProjectionMatrix();
|
||||
|
||||
state.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
|
||||
365
party-stage/src/scene/config-ui.js
Normal file
365
party-stage/src/scene/config-ui.js
Normal file
@ -0,0 +1,365 @@
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
import { MediaStorage } from '../core/media-storage.js';
|
||||
import { showStandbyScreen } from './projection-screen.js';
|
||||
|
||||
export class ConfigUI extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
sceneFeatureManager.register(this);
|
||||
this.toggles = {};
|
||||
}
|
||||
|
||||
init() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'config-ui';
|
||||
Object.assign(container.style, {
|
||||
position: 'absolute',
|
||||
top: '70px',
|
||||
left: '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'
|
||||
});
|
||||
|
||||
const saveConfig = () => {
|
||||
localStorage.setItem('partyConfig', JSON.stringify(state.config));
|
||||
};
|
||||
|
||||
const createToggle = (label, configKey, onChange) => {
|
||||
const row = document.createElement('div');
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.justifyContent = 'space-between';
|
||||
|
||||
const lbl = document.createElement('label');
|
||||
lbl.innerText = label;
|
||||
|
||||
const chk = document.createElement('input');
|
||||
chk.type = 'checkbox';
|
||||
chk.checked = state.config[configKey];
|
||||
chk.style.cursor = 'pointer';
|
||||
chk.onchange = (e) => {
|
||||
state.config[configKey] = e.target.checked;
|
||||
saveConfig();
|
||||
if (onChange) onChange(e.target.checked);
|
||||
};
|
||||
|
||||
this.toggles[configKey] = { checkbox: chk, callback: onChange };
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(chk);
|
||||
container.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;
|
||||
});
|
||||
|
||||
// Lasers Toggle
|
||||
createToggle('Lasers', 'lasersEnabled');
|
||||
|
||||
// Side Screens Toggle
|
||||
createToggle('Side Screens', 'sideScreensEnabled');
|
||||
|
||||
// Console RGB Toggle
|
||||
createToggle('Console RGB Panel', 'consoleRGBEnabled');
|
||||
|
||||
// DJ Hat Selector
|
||||
const hatRow = document.createElement('div');
|
||||
hatRow.style.display = 'flex';
|
||||
hatRow.style.alignItems = 'center';
|
||||
hatRow.style.justifyContent = 'space-between';
|
||||
|
||||
const hatLabel = document.createElement('label');
|
||||
hatLabel.innerText = 'DJ Hat';
|
||||
|
||||
const hatSelect = document.createElement('select');
|
||||
['None', 'Santa', 'Top Hat'].forEach(opt => {
|
||||
const option = document.createElement('option');
|
||||
option.value = opt;
|
||||
option.innerText = opt;
|
||||
if (opt === state.config.djHat) option.selected = true;
|
||||
hatSelect.appendChild(option);
|
||||
});
|
||||
hatSelect.onchange = (e) => {
|
||||
state.config.djHat = e.target.value;
|
||||
saveConfig();
|
||||
};
|
||||
this.hatSelect = hatSelect;
|
||||
|
||||
hatRow.appendChild(hatLabel);
|
||||
hatRow.appendChild(hatSelect);
|
||||
container.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'
|
||||
});
|
||||
|
||||
// Loaded Song Label
|
||||
this.songLabel = document.createElement('div');
|
||||
this.songLabel.innerText = 'Song: (None)';
|
||||
this.songLabel.style.fontSize = '13px';
|
||||
this.songLabel.style.color = '#aaa';
|
||||
statusContainer.appendChild(this.songLabel);
|
||||
|
||||
// Loaded Tapes List
|
||||
const tapesLabel = document.createElement('div');
|
||||
tapesLabel.innerText = 'Loaded Tapes:';
|
||||
tapesLabel.style.fontSize = '13px';
|
||||
statusContainer.appendChild(tapesLabel);
|
||||
|
||||
this.tapeList = document.createElement('ul');
|
||||
Object.assign(this.tapeList.style, {
|
||||
margin: '0',
|
||||
paddingLeft: '20px',
|
||||
fontSize: '12px',
|
||||
color: '#aaa',
|
||||
maxHeight: '100px',
|
||||
overflowY: 'auto'
|
||||
});
|
||||
statusContainer.appendChild(this.tapeList);
|
||||
|
||||
// Load Poster Button
|
||||
const loadPosterBtn = document.createElement('button');
|
||||
loadPosterBtn.innerText = 'Load Poster';
|
||||
Object.assign(loadPosterBtn.style, {
|
||||
marginTop: '10px',
|
||||
padding: '8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#555',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
});
|
||||
|
||||
const posterInput = document.createElement('input');
|
||||
posterInput.type = 'file';
|
||||
posterInput.accept = 'image/*';
|
||||
posterInput.style.display = 'none';
|
||||
posterInput.onchange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
if (state.posterImage) URL.revokeObjectURL(state.posterImage);
|
||||
state.posterImage = URL.createObjectURL(file);
|
||||
MediaStorage.savePoster(file);
|
||||
showStandbyScreen();
|
||||
}
|
||||
};
|
||||
document.body.appendChild(posterInput);
|
||||
|
||||
loadPosterBtn.onclick = () => {
|
||||
posterInput.click();
|
||||
};
|
||||
statusContainer.appendChild(loadPosterBtn);
|
||||
|
||||
// Load Tapes Button
|
||||
const loadTapesBtn = document.createElement('button');
|
||||
loadTapesBtn.id = 'loadTapeButton';
|
||||
loadTapesBtn.innerText = 'Load Tapes';
|
||||
Object.assign(loadTapesBtn.style, {
|
||||
marginTop: '10px',
|
||||
padding: '8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#555',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
});
|
||||
statusContainer.appendChild(loadTapesBtn);
|
||||
state.loadTapeButton = loadTapesBtn;
|
||||
|
||||
// Choose Song Button
|
||||
const chooseSongBtn = document.createElement('button');
|
||||
chooseSongBtn.innerText = 'Choose Song';
|
||||
Object.assign(chooseSongBtn.style, {
|
||||
marginTop: '10px',
|
||||
padding: '8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#555',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
});
|
||||
chooseSongBtn.onclick = () => {
|
||||
const fileInput = document.getElementById('musicFileInput');
|
||||
if (fileInput) fileInput.click();
|
||||
};
|
||||
statusContainer.appendChild(chooseSongBtn);
|
||||
|
||||
// Start Party Button
|
||||
this.startButton = document.createElement('button');
|
||||
this.startButton.innerText = 'Start the Party';
|
||||
this.startButton.disabled = true;
|
||||
Object.assign(this.startButton.style, {
|
||||
marginTop: '10px',
|
||||
padding: '10px',
|
||||
cursor: 'not-allowed',
|
||||
backgroundColor: '#333',
|
||||
color: '#777',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold'
|
||||
});
|
||||
|
||||
this.startButton.onclick = () => {
|
||||
const musicPlayer = sceneFeatureManager.features.find(f => f.constructor.name === 'MusicPlayer');
|
||||
if (musicPlayer) musicPlayer.startSequence();
|
||||
};
|
||||
statusContainer.appendChild(this.startButton);
|
||||
container.appendChild(statusContainer);
|
||||
|
||||
// Reset Button
|
||||
const resetBtn = document.createElement('button');
|
||||
resetBtn.innerText = 'Reset Defaults';
|
||||
Object.assign(resetBtn.style, {
|
||||
marginTop: '10px',
|
||||
padding: '8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#555',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
});
|
||||
resetBtn.onclick = () => {
|
||||
localStorage.removeItem('partyConfig');
|
||||
MediaStorage.clear();
|
||||
|
||||
// Clear State - Video
|
||||
if (state.videoUrls) {
|
||||
state.videoUrls.forEach(url => URL.revokeObjectURL(url));
|
||||
}
|
||||
state.videoUrls = [];
|
||||
state.videoFilenames = [];
|
||||
state.isVideoLoaded = false;
|
||||
state.currentVideoIndex = -1;
|
||||
if (state.loadTapeButton) {
|
||||
state.loadTapeButton.innerText = 'Load Tapes';
|
||||
state.loadTapeButton.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Clear State - Music
|
||||
if (state.music) {
|
||||
state.music.songTitle = null;
|
||||
if (state.music.player) {
|
||||
state.music.player.pause();
|
||||
state.music.player.removeAttribute('src');
|
||||
state.music.player.load();
|
||||
}
|
||||
}
|
||||
|
||||
if (state.posterImage) {
|
||||
URL.revokeObjectURL(state.posterImage);
|
||||
state.posterImage = null;
|
||||
}
|
||||
|
||||
showStandbyScreen();
|
||||
|
||||
const defaults = {
|
||||
torchesEnabled: true,
|
||||
lasersEnabled: true,
|
||||
sideScreensEnabled: true,
|
||||
consoleRGBEnabled: true,
|
||||
djHat: 'None'
|
||||
};
|
||||
for (const key in defaults) {
|
||||
state.config[key] = defaults[key];
|
||||
if (this.toggles[key]) {
|
||||
this.toggles[key].checkbox.checked = defaults[key];
|
||||
if (this.toggles[key].callback) this.toggles[key].callback(defaults[key]);
|
||||
}
|
||||
}
|
||||
if (this.hatSelect) this.hatSelect.value = defaults.djHat;
|
||||
this.updateStatus();
|
||||
};
|
||||
container.appendChild(resetBtn);
|
||||
|
||||
document.body.appendChild(container);
|
||||
this.container = container;
|
||||
this.updateStatus();
|
||||
|
||||
// Restore poster
|
||||
MediaStorage.getPoster().then(file => {
|
||||
if (file) {
|
||||
state.posterImage = URL.createObjectURL(file);
|
||||
showStandbyScreen();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateStatus() {
|
||||
if (!this.songLabel) return;
|
||||
|
||||
// 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.color = 'white';
|
||||
this.startButton.style.cursor = 'pointer';
|
||||
} 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.cursor = 'not-allowed';
|
||||
}
|
||||
|
||||
// Update Tape List
|
||||
this.tapeList.innerHTML = '';
|
||||
if (state.videoUrls && state.videoUrls.length > 0) {
|
||||
state.videoUrls.forEach((url, index) => {
|
||||
const li = document.createElement('li');
|
||||
let name = `Tape ${index + 1}`;
|
||||
if (state.videoFilenames && state.videoFilenames[index]) {
|
||||
name = state.videoFilenames[index];
|
||||
}
|
||||
|
||||
if (name.length > 25) {
|
||||
li.innerText = name.substring(0, 22) + '...';
|
||||
li.title = name;
|
||||
} else {
|
||||
li.innerText = name;
|
||||
}
|
||||
this.tapeList.appendChild(li);
|
||||
});
|
||||
} else {
|
||||
const li = document.createElement('li');
|
||||
li.innerText = '(No tapes loaded)';
|
||||
this.tapeList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
onPartyStart() {
|
||||
if (this.container) this.container.style.display = 'none';
|
||||
}
|
||||
|
||||
onPartyEnd() {
|
||||
if (this.container) this.container.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
@ -113,6 +113,35 @@ export class DJ extends SceneFeature {
|
||||
this.currentRightAngle = Math.PI * 0.85;
|
||||
this.currentLeftAngleX = 0;
|
||||
this.currentRightAngleX = 0;
|
||||
|
||||
// --- Hats ---
|
||||
this.hats = {};
|
||||
|
||||
// Santa Hat
|
||||
const santaGroup = new THREE.Group();
|
||||
const santaCone = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.3, 16), new THREE.MeshStandardMaterial({ color: 0xff0000 }));
|
||||
santaCone.position.y = 0.15;
|
||||
santaCone.rotation.x = -0.2;
|
||||
const santaRim = new THREE.Mesh(new THREE.TorusGeometry(0.14, 0.04, 8, 16), new THREE.MeshStandardMaterial({ color: 0xffffff }));
|
||||
santaRim.rotation.x = Math.PI/2;
|
||||
const santaBall = new THREE.Mesh(new THREE.SphereGeometry(0.04), new THREE.MeshStandardMaterial({ color: 0xffffff }));
|
||||
santaBall.position.set(0, 0.28, -0.08);
|
||||
santaGroup.add(santaCone, santaRim, santaBall);
|
||||
santaGroup.position.set(0, 0.22, 0);
|
||||
santaGroup.visible = false;
|
||||
this.head.add(santaGroup);
|
||||
this.hats['Santa'] = santaGroup;
|
||||
|
||||
// Top Hat
|
||||
const topHatGroup = new THREE.Group();
|
||||
const brim = new THREE.Mesh(new THREE.CylinderGeometry(0.25, 0.25, 0.02, 16), new THREE.MeshStandardMaterial({ color: 0x111111 }));
|
||||
const cylinder = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.15, 0.25, 16), new THREE.MeshStandardMaterial({ color: 0x111111 }));
|
||||
cylinder.position.y = 0.125;
|
||||
topHatGroup.add(brim, cylinder);
|
||||
topHatGroup.position.set(0, 0.22, 0);
|
||||
topHatGroup.visible = false;
|
||||
this.head.add(topHatGroup);
|
||||
this.hats['Top Hat'] = topHatGroup;
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
@ -198,6 +227,11 @@ export class DJ extends SceneFeature {
|
||||
this.group.position.x += dir * speed * deltaTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Hat Visibility
|
||||
for (const [name, mesh] of Object.entries(this.hats)) {
|
||||
mesh.visible = (state.config.djHat === name);
|
||||
}
|
||||
}
|
||||
|
||||
onPartyStart() {
|
||||
|
||||
@ -205,6 +205,11 @@ export class MusicConsole extends SceneFeature {
|
||||
|
||||
// Update Front LED Array
|
||||
if (this.frontLedMesh) {
|
||||
if (!state.config.consoleRGBEnabled) {
|
||||
this.frontLedMesh.visible = false;
|
||||
return;
|
||||
}
|
||||
this.frontLedMesh.visible = true;
|
||||
const color = new THREE.Color();
|
||||
let idx = 0;
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
import { showStandbyScreen } from './projection-screen.js';
|
||||
import { MediaStorage } from '../core/media-storage.js';
|
||||
|
||||
export class MusicPlayer extends SceneFeature {
|
||||
constructor() {
|
||||
@ -20,50 +22,73 @@ export class MusicPlayer extends SceneFeature {
|
||||
|
||||
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();
|
||||
});
|
||||
// Hide the big start button as we use ConfigUI now
|
||||
if (loadButton) loadButton.style.display = 'none';
|
||||
|
||||
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);
|
||||
this.loadMusicFile(file);
|
||||
MediaStorage.saveMusic(file);
|
||||
}
|
||||
});
|
||||
|
||||
state.music.player.addEventListener('ended', () => {
|
||||
this.stopParty();
|
||||
uiContainer.style.display = 'flex'; // Show the button again
|
||||
const uiContainer = document.getElementById('ui-container');
|
||||
if (uiContainer) uiContainer.style.display = 'flex'; // Show the button again
|
||||
});
|
||||
|
||||
// Restore from storage
|
||||
MediaStorage.getMusic().then(file => {
|
||||
if (file) {
|
||||
this.loadMusicFile(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadMusicFile(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);
|
||||
}
|
||||
|
||||
// Update State
|
||||
const songName = file.name.replace(/\.[^/.]+$/, "");
|
||||
state.music.songTitle = songName;
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
state.music.player.src = url;
|
||||
|
||||
// Update Config UI
|
||||
const configUI = sceneFeatureManager.features.find(f => f.constructor.name === 'ConfigUI');
|
||||
if (configUI) configUI.updateStatus();
|
||||
|
||||
// Update Projection Screen with new song title
|
||||
showStandbyScreen();
|
||||
}
|
||||
|
||||
startSequence() {
|
||||
const uiContainer = document.getElementById('ui-container');
|
||||
const configUI = document.getElementById('config-ui');
|
||||
|
||||
if (uiContainer) uiContainer.style.display = 'none';
|
||||
if (configUI) configUI.style.display = 'none';
|
||||
if (state.loadTapeButton) state.loadTapeButton.classList.add('hidden');
|
||||
|
||||
showStandbyScreen();
|
||||
|
||||
// Wait 5 seconds, then start the party
|
||||
setTimeout(() => {
|
||||
this.startParty();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
startParty() {
|
||||
@ -82,11 +107,6 @@ export class MusicPlayer extends SceneFeature {
|
||||
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');
|
||||
}
|
||||
|
||||
@ -12,15 +12,13 @@ void main() {
|
||||
}
|
||||
`;
|
||||
|
||||
const ledCountX = 256;
|
||||
const ledCountY = ledCountX * (9 / 16);
|
||||
|
||||
const screenFragmentShader = `
|
||||
uniform sampler2D videoTexture;
|
||||
uniform float u_effect_type;
|
||||
uniform float u_effect_strength;
|
||||
uniform float u_time;
|
||||
uniform float u_opacity;
|
||||
uniform vec2 u_resolution;
|
||||
varying vec2 vUv;
|
||||
|
||||
float random(vec2 st) {
|
||||
@ -29,14 +27,21 @@ float random(vec2 st) {
|
||||
|
||||
void main() {
|
||||
// LED Grid Setup
|
||||
float ledCountX = ${ledCountX}.0;
|
||||
float ledCountY = ${ledCountY}.0;
|
||||
float ledCountX = u_resolution.x;
|
||||
float ledCountY = u_resolution.y;
|
||||
|
||||
vec2 gridUV = vec2(vUv.x * ledCountX, vUv.y * ledCountY);
|
||||
|
||||
// Anti-aliasing: Calculate grid density
|
||||
vec2 gridDeriv = fwidth(gridUV);
|
||||
float gridDensity = max(gridDeriv.x, gridDeriv.y);
|
||||
float blurFactor = smoothstep(0.3, 0.8, gridDensity);
|
||||
|
||||
vec2 cell = fract(gridUV);
|
||||
vec2 pixelatedUV = (floor(gridUV) + 0.5) / vec2(ledCountX, ledCountY);
|
||||
vec2 sampleUV = mix(pixelatedUV, vUv, blurFactor);
|
||||
|
||||
vec4 color = texture2D(videoTexture, pixelatedUV);
|
||||
vec4 color = texture2D(videoTexture, sampleUV);
|
||||
|
||||
// Effect 1: Static/Noise (Power On/Off)
|
||||
if (u_effect_type > 0.0) {
|
||||
@ -46,11 +51,11 @@ void main() {
|
||||
}
|
||||
|
||||
float dist = distance(cell, vec2(0.5));
|
||||
float mask = 1.0 - smoothstep(0.35, 0.45, dist);
|
||||
float brightness = max(color.r, max(color.g, color.b));
|
||||
float contentAlpha = smoothstep(0.05, 0.15, brightness);
|
||||
|
||||
gl_FragColor = vec4(color.rgb, contentAlpha * mask * u_opacity);
|
||||
float edgeSoftness = clamp(gridDensity * 1.5, 0.0, 0.5);
|
||||
float mask = 1.0 - smoothstep(0.35 - edgeSoftness, 0.45 + edgeSoftness, dist);
|
||||
mask = mix(mask, 1.0, blurFactor);
|
||||
|
||||
gl_FragColor = vec4(color.rgb, mask);
|
||||
}
|
||||
`;
|
||||
|
||||
@ -58,6 +63,7 @@ const visualizerFragmentShader = `
|
||||
uniform float u_time;
|
||||
uniform float u_beat;
|
||||
uniform float u_opacity;
|
||||
uniform vec2 u_resolution;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec3 hsv2rgb(vec3 c) {
|
||||
@ -67,15 +73,22 @@ vec3 hsv2rgb(vec3 c) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
float ledCountX = 128.0;
|
||||
float ledCountY = 72.0;
|
||||
float ledCountX = u_resolution.x;
|
||||
float ledCountY = u_resolution.y;
|
||||
|
||||
vec2 gridUV = vec2(vUv.x * ledCountX, vUv.y * ledCountY);
|
||||
|
||||
vec2 gridDeriv = fwidth(gridUV);
|
||||
float gridDensity = max(gridDeriv.x, gridDeriv.y);
|
||||
float blurFactor = smoothstep(0.3, 0.8, gridDensity);
|
||||
|
||||
vec2 cell = fract(gridUV);
|
||||
vec2 uv = (floor(gridUV) + 0.5) / vec2(ledCountX, ledCountY);
|
||||
vec2 uv = mix((floor(gridUV) + 0.5) / vec2(ledCountX, ledCountY), vUv, blurFactor);
|
||||
|
||||
float dist = distance(cell, vec2(0.5));
|
||||
float mask = 1.0 - smoothstep(0.35, 0.45, dist);
|
||||
float edgeSoftness = clamp(gridDensity * 1.5, 0.0, 0.5);
|
||||
float mask = 1.0 - smoothstep(0.35 - edgeSoftness, 0.45 + edgeSoftness, dist);
|
||||
mask = mix(mask, 1.0, blurFactor);
|
||||
|
||||
float d = length(uv - 0.5);
|
||||
float angle = atan(uv.y - 0.5, uv.x - 0.5);
|
||||
@ -85,9 +98,7 @@ void main() {
|
||||
|
||||
float hue = fract(u_time * 0.1 + d * 0.2);
|
||||
float val = 0.5 + 0.5 * sin(wave + beatWave);
|
||||
float contentAlpha = smoothstep(0.1, 0.3, val);
|
||||
|
||||
gl_FragColor = vec4(hsv2rgb(vec3(hue, 0.8, val)), contentAlpha * mask * u_opacity);
|
||||
gl_FragColor = vec4(hsv2rgb(vec3(hue, 0.8, val)), mask);
|
||||
}
|
||||
`;
|
||||
|
||||
@ -98,7 +109,7 @@ export class ProjectionScreen extends SceneFeature {
|
||||
super();
|
||||
projectionScreenInstance = this;
|
||||
this.isVisualizerActive = false;
|
||||
this.originalPositions = null;
|
||||
this.screens = [];
|
||||
sceneFeatureManager.register(this);
|
||||
}
|
||||
|
||||
@ -132,8 +143,6 @@ export class ProjectionScreen extends SceneFeature {
|
||||
const geometry = new THREE.PlaneGeometry(width, height, 32, 32);
|
||||
|
||||
// Initial black material
|
||||
this.originalPositions = geometry.attributes.position.clone();
|
||||
|
||||
const material = new THREE.MeshBasicMaterial({ color: 0x000000 });
|
||||
|
||||
this.mesh = new THREE.Mesh(geometry, material);
|
||||
@ -141,8 +150,30 @@ export class ProjectionScreen extends SceneFeature {
|
||||
this.mesh.position.set(0, 5.5, -20.5);
|
||||
state.scene.add(this.mesh);
|
||||
|
||||
this.screens.push({
|
||||
mesh: this.mesh,
|
||||
originalPositions: geometry.attributes.position.clone(),
|
||||
resolution: new THREE.Vector2(200, 200 * (9 / 16))
|
||||
});
|
||||
|
||||
// --- Create Side Screens ---
|
||||
const sideWidth = 4.5;
|
||||
const sideHeight = sideWidth * (9 / 16);
|
||||
const sideGeometry = new THREE.PlaneGeometry(sideWidth, sideHeight, 16, 16);
|
||||
|
||||
const createSideScreen = (x, y, z, rotY, resX) => {
|
||||
const resY = resX * (9 / 16);
|
||||
const mesh = new THREE.Mesh(sideGeometry, material); // Share initial material
|
||||
mesh.position.set(x, y, z);
|
||||
mesh.rotation.y = rotY;
|
||||
state.scene.add(mesh);
|
||||
this.screens.push({ mesh, originalPositions: sideGeometry.attributes.position.clone(), resolution: new THREE.Vector2(resX, resY) });
|
||||
};
|
||||
|
||||
createSideScreen(-11, 4.0, -15.6, 0.4, 100); // Left (Lower resolution)
|
||||
createSideScreen(11, 4.0, -15.5, -0.4, 100); // Right (Lower resolution)
|
||||
|
||||
state.tvScreen = this.mesh;
|
||||
state.tvScreen.visible = false;
|
||||
|
||||
// --- Screen Light ---
|
||||
// A light that projects the screen's color/ambiance into the room
|
||||
@ -152,41 +183,67 @@ export class ProjectionScreen extends SceneFeature {
|
||||
state.screenLight.shadow.mapSize.width = 512;
|
||||
state.screenLight.shadow.mapSize.height = 512;
|
||||
state.scene.add(state.screenLight);
|
||||
|
||||
showStandbyScreen();
|
||||
}
|
||||
|
||||
setAllVisible(visible) {
|
||||
this.screens.forEach((s, index) => {
|
||||
if (index === 0) {
|
||||
s.mesh.visible = visible;
|
||||
} else {
|
||||
s.mesh.visible = visible && state.config.sideScreensEnabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyMaterialToAll(baseMaterial) {
|
||||
this.screens.forEach(s => {
|
||||
if (baseMaterial instanceof THREE.ShaderMaterial && baseMaterial.uniforms.u_resolution) {
|
||||
const newMat = baseMaterial.clone();
|
||||
newMat.uniforms = THREE.UniformsUtils.clone(baseMaterial.uniforms);
|
||||
newMat.uniforms.u_resolution.value.copy(s.resolution);
|
||||
s.mesh.material = newMat;
|
||||
} else {
|
||||
s.mesh.material = baseMaterial;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
updateScreenEffect();
|
||||
|
||||
// Wobble Logic
|
||||
if (this.mesh && this.originalPositions) {
|
||||
const time = state.clock.getElapsedTime();
|
||||
const waveSpeed = 0.5;
|
||||
const waveFrequency = 1.2;
|
||||
const waveAmplitude = 0.3;
|
||||
// same as stage-curtain ^^^
|
||||
|
||||
const positions = this.mesh.geometry.attributes.position;
|
||||
|
||||
const time = state.clock.getElapsedTime();
|
||||
const waveSpeed = 0.5;
|
||||
const waveFrequency = 1.2;
|
||||
const waveAmplitude = 0.3;
|
||||
|
||||
this.screens.forEach(screenObj => {
|
||||
const positions = screenObj.mesh.geometry.attributes.position;
|
||||
const originalPositions = screenObj.originalPositions;
|
||||
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
const originalX = this.originalPositions.getX(i);
|
||||
const originalZ = this.originalPositions.getZ(i);
|
||||
|
||||
const originalX = originalPositions.getX(i);
|
||||
const originalZ = originalPositions.getZ(i);
|
||||
const zOffset = Math.sin(originalX * waveFrequency + time * waveSpeed) * waveAmplitude;
|
||||
|
||||
positions.setZ(i, originalZ + zOffset);
|
||||
}
|
||||
positions.needsUpdate = true;
|
||||
this.mesh.geometry.computeVertexNormals();
|
||||
}
|
||||
screenObj.mesh.geometry.computeVertexNormals();
|
||||
});
|
||||
|
||||
if (this.isVisualizerActive && state.tvScreen.material.uniforms) {
|
||||
state.tvScreen.material.uniforms.u_time.value = state.clock.getElapsedTime();
|
||||
if (this.isVisualizerActive) {
|
||||
const beat = (state.music && state.music.beatIntensity) ? state.music.beatIntensity : 0.0;
|
||||
state.tvScreen.material.uniforms.u_beat.value = beat;
|
||||
|
||||
// Sync light to beat
|
||||
state.screenLight.intensity = state.originalScreenIntensity * (0.5 + beat * 0.5);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onPartyStart() {
|
||||
@ -210,19 +267,23 @@ export class ProjectionScreen extends SceneFeature {
|
||||
|
||||
activateVisualizer() {
|
||||
this.isVisualizerActive = true;
|
||||
state.tvScreen.visible = true;
|
||||
state.tvScreenPowered = true;
|
||||
state.tvScreen.material = new THREE.ShaderMaterial({
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
u_time: { value: 0.0 },
|
||||
u_beat: { value: 0.0 },
|
||||
u_opacity: { value: state.screenOpacity }
|
||||
u_opacity: { value: state.screenOpacity },
|
||||
u_resolution: { value: new THREE.Vector2(1, 1) } // Placeholder, set in applyMaterialToAll
|
||||
},
|
||||
vertexShader: screenVertexShader,
|
||||
fragmentShader: visualizerFragmentShader,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true
|
||||
transparent: true,
|
||||
derivatives: true
|
||||
});
|
||||
|
||||
this.applyMaterialToAll(material);
|
||||
this.setAllVisible(true);
|
||||
state.screenLight.intensity = state.originalScreenIntensity;
|
||||
}
|
||||
|
||||
@ -234,31 +295,88 @@ export class ProjectionScreen extends SceneFeature {
|
||||
|
||||
// --- Exported Control Functions ---
|
||||
|
||||
export function showStandbyScreen() {
|
||||
if (projectionScreenInstance) projectionScreenInstance.isVisualizerActive = false;
|
||||
|
||||
let texture;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function turnTvScreenOn() {
|
||||
if (projectionScreenInstance) projectionScreenInstance.isVisualizerActive = false;
|
||||
|
||||
if (state.tvScreen.material) {
|
||||
state.tvScreen.material.dispose();
|
||||
}
|
||||
|
||||
state.tvScreen.visible = true;
|
||||
|
||||
// Switch to ShaderMaterial for video playback
|
||||
state.tvScreen.material = new THREE.ShaderMaterial({
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
videoTexture: { value: state.videoTexture },
|
||||
u_effect_type: { value: 0.0 },
|
||||
u_effect_strength: { value: 0.0 },
|
||||
u_time: { value: 0.0 },
|
||||
u_opacity: { value: state.screenOpacity !== undefined ? state.screenOpacity : 0.7 }
|
||||
u_opacity: { value: state.screenOpacity !== undefined ? state.screenOpacity : 0.7 },
|
||||
u_resolution: { value: new THREE.Vector2(1, 1) } // Placeholder
|
||||
},
|
||||
vertexShader: screenVertexShader,
|
||||
fragmentShader: screenFragmentShader,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true
|
||||
transparent: true,
|
||||
derivatives: true
|
||||
});
|
||||
|
||||
state.tvScreen.material.needsUpdate = true;
|
||||
if (projectionScreenInstance) projectionScreenInstance.applyMaterialToAll(material);
|
||||
if (projectionScreenInstance) projectionScreenInstance.setAllVisible(true);
|
||||
|
||||
if (!state.tvScreenPowered) {
|
||||
state.tvScreenPowered = true;
|
||||
@ -271,7 +389,8 @@ export function turnTvScreenOff() {
|
||||
state.tvScreenPowered = false;
|
||||
setScreenEffect(2, () => {
|
||||
// Revert to black material or hide
|
||||
state.tvScreen.material = new THREE.MeshBasicMaterial({ color: 0x000000 });
|
||||
const blackMat = new THREE.MeshBasicMaterial({ color: 0x000000 });
|
||||
if (projectionScreenInstance) projectionScreenInstance.applyMaterialToAll(blackMat);
|
||||
state.screenLight.intensity = 0.0;
|
||||
});
|
||||
}
|
||||
@ -290,34 +409,35 @@ export function setScreenEffect(effectType, onComplete) {
|
||||
export function updateScreenEffect() {
|
||||
if (!state.screenEffect || !state.screenEffect.active) return;
|
||||
|
||||
const material = state.tvScreen.material;
|
||||
if (!material || !material.uniforms) return;
|
||||
|
||||
// Update time uniform for noise
|
||||
material.uniforms.u_time.value = state.clock.getElapsedTime();
|
||||
|
||||
const elapsedTime = (state.clock.getElapsedTime() * 1000) - state.screenEffect.startTime;
|
||||
const progress = Math.min(elapsedTime / state.screenEffect.duration, 1.0);
|
||||
|
||||
// Simple linear fade for effect strength
|
||||
// Type 1 (On): 1.0 -> 0.0
|
||||
// Type 2 (Off): 0.0 -> 1.0
|
||||
let strength = progress;
|
||||
if (state.screenEffect.type === 1) {
|
||||
strength = 1.0 - progress;
|
||||
}
|
||||
|
||||
material.uniforms.u_effect_type.value = state.screenEffect.type;
|
||||
material.uniforms.u_effect_strength.value = strength;
|
||||
if (projectionScreenInstance) {
|
||||
projectionScreenInstance.screens.forEach(s => {
|
||||
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 (progress >= 1.0) {
|
||||
material.uniforms.u_effect_type.value = 0.0;
|
||||
material.uniforms.u_effect_strength.value = 0.0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (progress >= 1.0) {
|
||||
state.screenEffect.active = false;
|
||||
if (state.screenEffect.onComplete) {
|
||||
state.screenEffect.onComplete();
|
||||
}
|
||||
// Reset effect uniforms
|
||||
material.uniforms.u_effect_type.value = 0.0;
|
||||
material.uniforms.u_effect_strength.value = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,10 +17,13 @@ import { StageLights } from './stage-lights.js';
|
||||
import { MusicConsole } from './music-console.js';
|
||||
import { DJ } from './dj.js';
|
||||
import { ProjectionScreen } from './projection-screen.js';
|
||||
import { StageLasers } from './stage-lasers.js';
|
||||
import { ConfigUI } from './config-ui.js';
|
||||
// Scene Features ^^^
|
||||
|
||||
// --- Scene Modeling Function ---
|
||||
export function createSceneObjects() {
|
||||
new ConfigUI();
|
||||
sceneFeatureManager.init();
|
||||
initVideoUI();
|
||||
|
||||
|
||||
251
party-stage/src/scene/stage-lasers.js
Normal file
251
party-stage/src/scene/stage-lasers.js
Normal file
@ -0,0 +1,251 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import { SceneFeature } from './SceneFeature.js';
|
||||
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||
|
||||
export class StageLasers extends SceneFeature {
|
||||
constructor() {
|
||||
super();
|
||||
this.lasers = [];
|
||||
this.pattern = 0;
|
||||
this.lastPatternChange = 0;
|
||||
this.averageLoudness = 0;
|
||||
this.activationState = 'IDLE';
|
||||
this.stateTimer = 0;
|
||||
this.initialSilenceSeconds = 10;
|
||||
sceneFeatureManager.register(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
// Geometry: Long thin cylinder, pivot at one end
|
||||
const length = 80;
|
||||
const geometry = new THREE.CylinderGeometry(0.03, 0.03, length, 8);
|
||||
geometry.rotateX(-Math.PI / 2); // Align with Z axis
|
||||
geometry.translate(0, 0, length / 2); // Pivot at start
|
||||
|
||||
// Material: Additive blending for light beam effect
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: 0x00ff00,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
this.sharedGeometry = geometry;
|
||||
this.sharedMaterial = material;
|
||||
|
||||
// Fixture assets
|
||||
this.fixtureGeometry = new THREE.BoxGeometry(0.2, 0.2, 0.3);
|
||||
this.fixtureMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x111111,
|
||||
roughness: 0.7,
|
||||
metalness: 0.2
|
||||
});
|
||||
|
||||
// Create Banks of Lasers
|
||||
// 1. Left
|
||||
this.createBank(new THREE.Vector3(-7, 8.2, -18), 3, 0.4, 0.3);
|
||||
// 2. Right
|
||||
this.createBank(new THREE.Vector3(7, 8.2, -18), 3, 0.4, -0.3);
|
||||
// 3. Center
|
||||
this.createBank(new THREE.Vector3(0, 8.5, -16), 8, 0.5, 0);
|
||||
}
|
||||
|
||||
createBank(position, count, spacing, angleOffsetY) {
|
||||
const group = new THREE.Group();
|
||||
group.position.copy(position);
|
||||
// Rotate bank slightly to face center/audience
|
||||
group.rotation.y = angleOffsetY;
|
||||
state.scene.add(group);
|
||||
|
||||
// --- Connecting Bar ---
|
||||
const barWidth = (count * spacing) + 0.2;
|
||||
const barGeo = new THREE.BoxGeometry(barWidth, 0.1, 0.1);
|
||||
const barMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.7, metalness: 0.5 });
|
||||
const bar = new THREE.Mesh(barGeo, barMat);
|
||||
bar.position.set(0, 0, -0.25); // Behind the fixtures
|
||||
group.add(bar);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const mesh = new THREE.Mesh(this.sharedGeometry, this.sharedMaterial.clone());
|
||||
// Center the bank
|
||||
const xOff = (i - (count - 1) / 2) * spacing;
|
||||
mesh.position.set(xOff, 0, 0);
|
||||
|
||||
// Add Static Fixture
|
||||
const fixture = new THREE.Mesh(this.fixtureGeometry, this.fixtureMaterial);
|
||||
fixture.position.set(xOff, 0, -0.15);
|
||||
fixture.castShadow = true;
|
||||
group.add(fixture);
|
||||
|
||||
// Add a source flare
|
||||
const flare = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.06, 8, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffffff })
|
||||
);
|
||||
mesh.add(flare);
|
||||
|
||||
group.add(mesh);
|
||||
|
||||
this.lasers.push({
|
||||
mesh: mesh,
|
||||
flare: flare,
|
||||
index: i,
|
||||
totalInBank: count,
|
||||
bankId: position.x < 0 ? 0 : (position.x > 0 ? 1 : 2) // 0:L, 1:R, 2:C
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
if (!state.partyStarted) {
|
||||
this.lasers.forEach(l => l.mesh.visible = false);
|
||||
return;
|
||||
}
|
||||
|
||||
const time = state.clock.getElapsedTime();
|
||||
|
||||
if (!state.config.lasersEnabled) {
|
||||
this.lasers.forEach(l => l.mesh.visible = false);
|
||||
if (state.laserData) state.laserData.count = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Loudness Check ---
|
||||
let isActive = false;
|
||||
if (state.music) {
|
||||
const loudness = state.music.loudness || 0;
|
||||
// Update running average
|
||||
this.averageLoudness = THREE.MathUtils.lerp(this.averageLoudness, loudness, deltaTime * 0.2);
|
||||
|
||||
if (this.activationState === 'IDLE') {
|
||||
// Wait for song to pick up before first activation
|
||||
if (time > this.initialSilenceSeconds && loudness > this.averageLoudness + 0.1) {
|
||||
this.activationState = 'WARMUP';
|
||||
this.stateTimer = 1.0; // Warmup duration
|
||||
}
|
||||
} else if (this.activationState === 'WARMUP') {
|
||||
isActive = true;
|
||||
this.stateTimer -= deltaTime;
|
||||
if (this.stateTimer <= 0) {
|
||||
if (state.music.beatIntensity > 0.8) {
|
||||
this.activationState = 'ACTIVE';
|
||||
this.stateTimer = 4.0; // Active duration
|
||||
}
|
||||
}
|
||||
} else if (this.activationState === 'ACTIVE') {
|
||||
isActive = true;
|
||||
this.stateTimer -= deltaTime;
|
||||
if (this.stateTimer <= 0) {
|
||||
if (state.music.beatIntensity > 0.8) {
|
||||
this.activationState = 'FADEOUT';
|
||||
this.stateTimer = 1.0; // Fadeout duration
|
||||
}
|
||||
}
|
||||
} else if (this.activationState === 'FADEOUT') {
|
||||
isActive = true;
|
||||
this.stateTimer -= deltaTime;
|
||||
if (this.stateTimer <= 0) {
|
||||
this.activationState = 'COOLDOWN';
|
||||
this.stateTimer = 4.0; // Cooldown duration
|
||||
}
|
||||
} else if (this.activationState === 'COOLDOWN') {
|
||||
this.stateTimer -= deltaTime;
|
||||
if (this.stateTimer <= 0) {
|
||||
this.activationState = 'IDLE';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
this.lasers.forEach(l => l.mesh.visible = false);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Pattern Logic ---
|
||||
if (time - this.lastPatternChange > 8) { // Change every 8 seconds
|
||||
this.pattern = (this.pattern + 1) % 4;
|
||||
this.lastPatternChange = time;
|
||||
}
|
||||
|
||||
// --- 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
|
||||
if (beat > 0.7) {
|
||||
// Rapid on/off based on time (approx 15Hz)
|
||||
if (Math.sin(time * 100) < 0) {
|
||||
intensity = 0.05;
|
||||
} else {
|
||||
intensity = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
this.lasers.forEach(l => {
|
||||
l.mesh.visible = !['IDLE', 'COOLDOWN'].includes(this.activationState);
|
||||
|
||||
let currentIntensity = intensity;
|
||||
let flareScale = 1.0;
|
||||
|
||||
if (this.activationState === 'WARMUP') {
|
||||
currentIntensity = 0;
|
||||
if (this.stateTimer > 0) {
|
||||
flareScale = 1.0 - this.stateTimer;
|
||||
} else {
|
||||
flareScale = 1.0 + Math.sin(time * 30) * 0.2; // Pulse while waiting for beat
|
||||
}
|
||||
} else if (this.activationState === 'FADEOUT') {
|
||||
const fade = Math.max(0, this.stateTimer / 1.0);
|
||||
currentIntensity = 0;
|
||||
flareScale = fade;
|
||||
}
|
||||
|
||||
l.mesh.material.color.copy(color);
|
||||
l.mesh.material.opacity = currentIntensity;
|
||||
l.flare.material.color.copy(color);
|
||||
l.flare.scale.setScalar(flareScale);
|
||||
|
||||
// --- Movement Calculation ---
|
||||
let yaw = 0;
|
||||
let pitch = 0;
|
||||
const t = time * 2.0;
|
||||
const idx = l.index;
|
||||
|
||||
switch (this.pattern) {
|
||||
case 0: // Lissajous / Figure 8
|
||||
yaw = Math.sin(t + idx * 0.2) * 0.5;
|
||||
pitch = Math.cos(t * 1.5 + idx * 0.2) * 0.3;
|
||||
break;
|
||||
case 1: // Horizontal Scan / Wave
|
||||
yaw = Math.sin(t * 2 + idx * 0.5) * 0.8;
|
||||
pitch = Math.sin(t * 0.5) * 0.1;
|
||||
break;
|
||||
case 2: // Tunnel / Circle
|
||||
const offset = (Math.PI * 2 / l.totalInBank) * idx;
|
||||
const radius = 0.4;
|
||||
yaw = Math.cos(t + offset) * radius;
|
||||
pitch = Math.sin(t + offset) * radius;
|
||||
break;
|
||||
case 3: // Chaos / Random
|
||||
yaw = Math.sin(t * 3 + idx) * 0.6;
|
||||
pitch = Math.cos(t * 2.5 + idx * 2) * 0.4;
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply rotation
|
||||
// Default points +Z.
|
||||
l.mesh.rotation.set(pitch, yaw, 0);
|
||||
});
|
||||
}
|
||||
|
||||
onPartyEnd() {
|
||||
this.lasers.forEach(l => l.mesh.visible = false);
|
||||
}
|
||||
}
|
||||
|
||||
new StageLasers();
|
||||
@ -3,6 +3,18 @@ import * as THREE from 'three';
|
||||
export let state = undefined;
|
||||
|
||||
export function initState() {
|
||||
let config = {
|
||||
torchesEnabled: true,
|
||||
lasersEnabled: true,
|
||||
sideScreensEnabled: true,
|
||||
consoleRGBEnabled: true,
|
||||
djHat: 'None' // 'None', 'Santa', 'Top Hat'
|
||||
};
|
||||
try {
|
||||
const saved = localStorage.getItem('partyConfig');
|
||||
if (saved) config = { ...config, ...JSON.parse(saved) };
|
||||
} catch (e) { console.warn('Error loading config', e); }
|
||||
|
||||
state = {
|
||||
// Core Three.js components
|
||||
scene: null,
|
||||
@ -30,7 +42,9 @@ export function initState() {
|
||||
// Video Playback
|
||||
isVideoLoaded: false,
|
||||
videoUrls: [],
|
||||
videoFilenames: [],
|
||||
currentVideoIndex: -1,
|
||||
posterImage: null,
|
||||
|
||||
// Scene constants
|
||||
originalLampIntensity: 0.3,
|
||||
@ -42,6 +56,9 @@ export function initState() {
|
||||
debugCamera: false, // Turn on camera helpers
|
||||
partyStarted: false,
|
||||
|
||||
// Feature Configuration
|
||||
config: config,
|
||||
|
||||
// DOM Elements
|
||||
container: document.body,
|
||||
videoElement: document.getElementById('video'),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user