Compare commits

...

14 Commits

Author SHA1 Message Date
Dejvino
3e773361e2 Feature: blobby DJ 2025-12-30 23:42:57 +00:00
Dejvino
32679ced8e Feature: configurable projection screen resolution 2025-12-30 23:28:49 +00:00
Dejvino
7f2abc635d Feature: basic fog 2025-12-30 23:22:53 +00:00
Dejvino
4feeab53de Feature: repro wall surrounding the stage 2025-12-30 22:22:00 +00:00
Dejvino
94ae337e96 Feature: waving projection screen 2025-12-30 21:52:17 +00:00
Dejvino
3dcfdaff5a Feature: projection screen invisible before starting 2025-12-30 21:47:47 +00:00
Dejvino
c17311036b Feature: guests have collisions + different sizes 2025-12-30 18:20:03 +00:00
Dejvino
b76810e883 Feature: blob party guests 2025-12-30 18:13:44 +00:00
Dejvino
eb8e74273d Feature: pixelated projection screen 2025-12-30 17:50:49 +00:00
Dejvino
c98d4890eb Feature: projection screen playing videos or music visualizer 2025-12-30 17:37:22 +00:00
Dejvino
5d3a05ec69 Feature: DJ with colorful lights 2025-12-30 06:56:14 +00:00
Dejvino
cb9a6c4a48 Feature: configurable party guests + changed textures 2025-12-30 06:22:10 +00:00
Dejvino
ab8334f9ab Feature: stage lights 2025-12-29 23:13:12 +00:00
Dejvino
ccd52ba00a New project: party stage 2025-12-29 22:54:39 +00:00
57 changed files with 4270 additions and 0 deletions

View File

@ -0,0 +1,32 @@
{
"name": "Node.js App Dev Container",
"image": "mcr.microsoft.com/devcontainers/javascript-node:24",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/git:1": {}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
5173, // dev mode for vite
4173 // preview mode for vite
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "npm install",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
]
}
}
// "remoteUser": "node"
}

2
party-stage/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

39
party-stage/index.html Normal file
View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Party Cathedral</title>
<style>
/* Cheerful medieval aesthetic */
body {
background-color: #f5eeda; /* A light parchment color */
margin: 0;
overflow: hidden;
font-family: 'Inter', sans-serif;
}
canvas {
display: block;
}
</style>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module" src="/src/main.js"></script>
</head>
<body>
<!-- 3D Canvas will be injected here by Three.js -->
<div id="ui-container" class="absolute top-0 left-0 w-full h-full flex justify-center items-center">
<div class="text-center">
<button id="loadMusicButton" class="px-8 py-4 bg-[#8B4513] text-white font-bold text-2xl uppercase tracking-wider rounded-lg shadow-lg hover:bg-[#A0522D] transition duration-150 active:translate-y-px">
START THE PARTY
</button>
<input type="file" id="musicFileInput" accept=".mp3,.flac" style="display: none;">
</div>
<div id="metadata-container" class="text-center text-white hidden">
<h1 id="song-title" class="text-4xl font-bold tracking-widest"></h1>
</div>
</div>
<audio id="audioPlayer" style="display: none;"></audio>
</body>
</html>
<!-- textures sourced from https://animalia-life.club/ -->

1070
party-stage/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
party-stage/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "tv-player",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"author": "",
"license": "ISC",
"devDependencies": {
"vite": "^7.2.2"
},
"dependencies": {
"three": "^0.181.1"
}
}

3
party-stage/preview.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
nix-shell -p nodejs --run "npx vite build && npx vite preview"

3
party-stage/serve.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
nix-shell -p nodejs --run "npx vite"

View File

@ -0,0 +1,38 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { onResizePostprocessing } from './postprocessing.js';
import sceneFeatureManager from '../scene/SceneFeatureManager.js';
function updateShaderTime() {
if (state.tvScreen && state.tvScreen.material.uniforms && state.tvScreen.material.uniforms.u_time) {
state.tvScreen.material.uniforms.u_time.value = state.clock.getElapsedTime();
}
}
// --- Animation Loop ---
export function animate() {
requestAnimationFrame(animate);
const deltaTime = state.clock.getDelta();
if (deltaTime > 0) {
sceneFeatureManager.update(deltaTime);
state.effectsManager.update(deltaTime);
updateShaderTime();
}
// RENDER!
if (state.composer) {
state.composer.render();
} else {
state.renderer.render(state.scene, state.camera);
}
}
// --- Window Resize Handler ---
export function onWindowResize() {
state.camera.aspect = window.innerWidth / window.innerHeight;
state.camera.updateProjectionMatrix();
state.renderer.setSize(window.innerWidth, window.innerHeight);
onResizePostprocessing();
}

View File

@ -0,0 +1,44 @@
import * as THREE from 'three';
import { state, initState } from '../state.js';
import { EffectsManager } from '../effects/EffectsManager.js';
import { createSceneObjects } from '../scene/root.js';
import { animate, onWindowResize } from './animate.js';
import { initPostprocessing } from './postprocessing.js';
// --- Initialization ---
export function init() {
initState();
// 1. Scene Setup (Dark, Ambient)
state.scene = new THREE.Scene();
state.scene.background = new THREE.Color(0x000000);
// 2. Camera Setup
const FOV = 95;
state.camera = new THREE.PerspectiveCamera(FOV, window.innerWidth / window.innerHeight, 0.1, 1000);
state.camera.position.set(0, 1.5, 4);
// 3. Renderer Setup
state.renderer = new THREE.WebGLRenderer({ antialias: true });
state.renderer.setSize(window.innerWidth, window.innerHeight);
state.renderer.setPixelRatio(window.devicePixelRatio);
// Enable shadows on the renderer
state.renderer.shadowMap.enabled = true;
state.renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Softer shadows
state.container.appendChild(state.renderer.domElement);
// 5. Build the entire scene with TV and surrounding objects
createSceneObjects();
// 6. Initialize all visual effects via the manager
state.effectsManager = new EffectsManager(state.scene);
// 9. Event Listeners
window.addEventListener('resize', onWindowResize, false);
initPostprocessing();
// Start the animation loop
animate();
}

View File

@ -0,0 +1,34 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
export function initPostprocessing() {
const composer = new EffectComposer(state.renderer);
// 1. The first pass is always to render the scene itself.
const renderPass = new RenderPass(state.scene, state.camera);
composer.addPass(renderPass);
const resolution = new THREE.Vector2( window.innerWidth, window.innerHeight );
const bloomPass = new UnrealBloomPass( resolution, 0.9, 0.1, 0.6 );
composer.addPass( bloomPass );
// 3. Add an output pass to render the final result to the screen.
const outputPass = new OutputPass();
composer.addPass(outputPass);
// Store the composer and passes in the global state
state.composer = composer;
}
export function onResizePostprocessing() {
if (state.composer) {
state.composer.setSize(window.innerWidth, window.innerHeight);
}
if (state.ssaoPass) {
state.ssaoPass.setSize(window.innerWidth, window.innerHeight);
}
}

View File

@ -0,0 +1,170 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { turnTvScreenOff, turnTvScreenOn } from '../scene/projection-screen.js';
// --- Play video by index ---
export function playVideoByIndex(index) {
state.currentVideoIndex = index;
const url = state.videoUrls[index];
// Dispose of previous texture to free resources
if (state.videoTexture) {
state.videoTexture.dispose();
state.videoTexture = null;
}
// Loop logic: if index is out of bounds, wrap around
if (index >= state.videoUrls.length) {
index = 0;
}
state.videoElement.src = url;
state.videoElement.muted = true;
state.videoElement.load();
// Set loop property: only loop if it's the only video loaded
state.videoElement.loop = false; //state.videoUrls.length === 1;
// Auto-play next video when this one ends
state.videoElement.onended = () => playNextVideo();
state.videoElement.onloadeddata = () => {
// 1. Create the Three.js texture
state.videoTexture = new THREE.VideoTexture(state.videoElement);
state.videoTexture.minFilter = THREE.LinearFilter;
state.videoTexture.magFilter = THREE.LinearFilter;
state.videoTexture.format = THREE.RGBAFormat;
state.videoTexture.needsUpdate = true;
// 2. Apply the video texture to the screen mesh
turnTvScreenOn();
// 3. Start playback and trigger the warm-up effect simultaneously
state.videoElement.play().then(() => {
state.isVideoLoaded = true;
// Use the defined base intensity for screen glow
state.screenLight.intensity = state.originalScreenIntensity;
// Initial status message with tape count
console.info(`Playing tape ${state.currentVideoIndex + 1} of ${state.videoUrls.length}.`);
updatePlayPauseButton();
}).catch(error => {
state.screenLight.intensity = state.originalScreenIntensity * 0.5; // Dim the light if playback fails
console.error(`Playback blocked for tape ${state.currentVideoIndex + 1}. Click Next Tape to try again.`);
console.error('Playback Error: Could not start video playback.', error);
});
};
state.videoElement.onerror = (e) => {
state.screenLight.intensity = 0.1; // Keep minimum intensity for shadow map
console.error(`Error loading tape ${state.currentVideoIndex + 1}.`);
console.error('Video Load Error:', e);
};
}
// --- Cycle to the next video ---
export function playNextVideo() {
// Determine the next index, cycling back to 0 if we reach the end
let nextIndex = (state.currentVideoIndex + 1) % state.videoUrls.length;
state.baseTime += state.videoElement.duration;
playVideoByIndex(nextIndex);
}
export function playPreviousVideo() {
let prevIndex = state.currentVideoIndex - 1;
if (prevIndex < 0) {
prevIndex = state.videoUrls.length - 1;
}
playVideoByIndex(prevIndex);
}
export function togglePlayPause() {
if (!state.videoElement) return;
if (state.videoElement.paused) {
state.videoElement.play();
} else {
state.videoElement.pause();
}
updatePlayPauseButton();
}
function updatePlayPauseButton() {
const btn = document.getElementById('video-play-pause-btn');
if (btn) {
btn.innerText = (state.videoElement && !state.videoElement.paused) ? 'Pause' : 'Play';
}
}
export function initVideoUI() {
// 1. File Input
if (!state.fileInput) {
const input = document.createElement('input');
input.type = 'file';
input.id = 'fileInput';
input.multiple = true;
input.accept = 'video/*';
input.style.display = 'none';
document.body.appendChild(input);
state.fileInput = input;
}
state.fileInput.onchange = loadVideoFile;
// 2. Load Button
if (!state.loadTapeButton) {
const btn = document.createElement('button');
btn.id = 'loadTapeButton';
btn.innerText = 'Load Tapes';
Object.assign(btn.style, {
position: 'absolute',
top: '20px',
left: '20px',
zIndex: '1000',
padding: '10px 20px',
fontSize: '16px',
cursor: 'pointer',
background: '#fff',
color: '#000',
border: 'none',
borderRadius: '5px',
fontFamily: 'sans-serif'
});
document.body.appendChild(btn);
state.loadTapeButton = btn;
}
state.loadTapeButton.onclick = () => state.fileInput.click();
}
// --- Video Loading Logic (handles multiple files) ---
export function loadVideoFile(event) {
const files = event.target.files;
if (files.length === 0) {
console.info('File selection cancelled.');
return;
}
// 1. Clear previous URLs and revoke object URLs to prevent memory leaks
state.videoUrls.forEach(url => URL.revokeObjectURL(url));
state.videoUrls = [];
// 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));
}
}
if (state.videoUrls.length === 0) {
console.info('No valid video files selected.');
return;
}
// 3. Start playback of the first video
console.info(`Loaded ${state.videoUrls.length} tapes. Starting playback...`);
state.loadTapeButton.classList.add("hidden");
const startDelay = 5;
console.info(`Video will start in ${startDelay} seconds.`);
setTimeout(() => { playVideoByIndex(0); }, startDelay * 1000);
}

View File

@ -0,0 +1,22 @@
import { DustEffect } from './dust.js';
export class EffectsManager {
constructor(scene) {
this.effects = [];
this._initializeEffects(scene);
}
_initializeEffects(scene) {
// Add all desired effects here.
// This is now the single place to manage which effects are active.
this.addEffect(new DustEffect(scene));
}
addEffect(effect) {
this.effects.push(effect);
}
update(deltaTime) {
this.effects.forEach(effect => effect.update(deltaTime));
}
}

View File

@ -0,0 +1,47 @@
import * as THREE from 'three';
export class DustEffect {
constructor(scene) {
this.dust = null;
this._create(scene);
}
_create(scene) {
const particleCount = 3000;
const particlesGeometry = new THREE.BufferGeometry();
const positions = [];
for (let i = 0; i < particleCount; i++) {
positions.push(
(Math.random() - 0.5) * 15,
Math.random() * 8,
(Math.random() - 0.5) * 45
);
}
particlesGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const particleMaterial = new THREE.PointsMaterial({
color: 0xaaaaaa,
size: 0.015,
transparent: true,
opacity: 0.08,
blending: THREE.AdditiveBlending
});
this.dust = new THREE.Points(particlesGeometry, particleMaterial);
scene.add(this.dust);
}
update(deltaTime) {
if (deltaTime && this.dust) {
const positions = this.dust.geometry.attributes.position.array;
for (let i = 1; i < positions.length; i += 3) {
positions[i] -= deltaTime * 0.006;
if (positions[i] < -2) {
positions[i] = 8;
}
}
this.dust.geometry.attributes.position.needsUpdate = true;
}
}
}

View File

@ -0,0 +1,39 @@
// --- Global Variables ---
let scene, camera, renderer, tvScreen, videoTexture, screenLight, lampLightPoint, lampLightSpot, effectsManager;
// VCR Display related variables
let simulatedPlaybackTime = 0;
let lastUpdateTime = -1;
let baseTime = 0;
let isVideoLoaded = false;
let videoUrls = []; // Array to hold all video URLs
let currentVideoIndex = -1; // Index of the currently playing video
const originalLampIntensity = 0.8; // Base intensity for the flickering lamp
const originalScreenIntensity = 0.2; // Base intensity for the screen glow
const screenIntensityPulse = 0.2;
const roomSize = 5;
const roomHeight = 3;
const container = document.body;
const videoElement = document.getElementById('video');
const fileInput = document.getElementById('fileInput');
const loadTapeButton = document.getElementById('loadTapeButton');
const loader = new THREE.TextureLoader();
const debugLight = false;
let landingSurfaces = []; // Array to hold floor and table for fly landings
const raycaster = new THREE.Raycaster();
// --- Configuration ---
const ROOM_SIZE = roomSize;
const FLIGHT_HEIGHT_MIN = 0.5; // Min height for flying
const FLIGHT_HEIGHT_MAX = roomHeight * 0.9; // Max height for flying
const FLY_FLIGHT_SPEED_FACTOR = 0.01; // How quickly 't' increases per frame
const DAMPING_FACTOR = 0.05;
const FLY_WAIT_BASE = 1000;
const FLY_LAND_CHANCE = 0.3;
// --- Seedable Random Number Generator (Mulberry32) ---
let seed = 12345; // Default seed, will be overridden per shelf

5
party-stage/src/main.js Normal file
View File

@ -0,0 +1,5 @@
import * as THREE from 'three';
import { init } from './core/init.js';
// Start everything
init();

View File

@ -0,0 +1,6 @@
// SceneFeature.js
export class SceneFeature {
init() {}
update(deltaTime) {}
}

View File

@ -0,0 +1,31 @@
// SceneFeatureManager.js
class SceneFeatureManager {
constructor() {
if (SceneFeatureManager.instance) {
return SceneFeatureManager.instance;
}
this.features = [];
SceneFeatureManager.instance = this;
}
register(feature) {
this.features.push(feature);
}
init() {
for (const feature of this.features) {
feature.init();
}
}
update(deltaTime) {
for (const feature of this.features) {
feature.update(deltaTime);
}
}
}
const sceneFeatureManager = new SceneFeatureManager();
export default sceneFeatureManager;

View File

@ -0,0 +1,156 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
const minSwitchInterval = 2;
const maxSwitchInterval = 10;
export class CameraManager extends SceneFeature {
constructor() {
super();
this.cameras = [];
this.activeCameraIndex = 0;
this.switchInterval = 10; // seconds
this.lastSwitchTime = 0;
sceneFeatureManager.register(this);
}
init() {
// The main camera from init.js is our first camera
const mainCamera = state.camera;
mainCamera.fov = 20;
const mainCameraSetup = {
camera: mainCamera,
type: 'dynamic',
name: 'MainDynamicCamera',
update: this.updateDynamicCamera, // Assign its update function
};
this.cameras.push(mainCameraSetup);
// --- Static Camera 1: Left Aisle View ---
const staticCam1 = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
staticCam1.position.set(-5, 3, -13);
staticCam1.lookAt(0, 2, -18); // Look at the stage
this.cameras.push({
camera: staticCam1,
type: 'static',
name: 'LeftAisleCam'
});
// --- Static Camera 2: Right Aisle View ---
const staticCam2 = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
staticCam2.position.set(5, 4, -6);
staticCam2.lookAt(0, 1.5, -18); // Look at the stage
this.cameras.push({
camera: staticCam2,
type: 'static',
name: 'RightAisleCam'
});
// --- Static Camera 3: Far-Back view ---
const staticCam3 = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 100);
staticCam3.position.set(0, 3, 12);
staticCam3.lookAt(0, 1.5, -20); // Look at the stage
this.cameras.push({
camera: staticCam3,
type: 'static',
name: 'BackCam'
});
// --- Static Camera 3: Back view ---
const staticCam4 = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 100);
staticCam4.position.set(0, 4, 0);
staticCam4.lookAt(0, 1.5, -20); // Look at the stage
this.cameras.push({
camera: staticCam4,
type: 'static',
name: 'BackCam'
});
// make the main camera come up more often
this.cameras.push(mainCameraSetup);
// --- Add Debug Helpers ---
if (state.debugCamera) {
this.cameras.forEach(camData => {
const helper = new THREE.CameraHelper(camData.camera);
state.scene.add(helper);
});
}
this.lastSwitchTime = state.clock.getElapsedTime();
this.switchCamera(4);
}
// This is the logic moved from animate.js
updateDynamicCamera(timeDiff) {
if (!state.partyStarted) return;
const globalTime = Date.now() * 0.0001;
const lookAtTime = Date.now() * 0.0002;
const baseX = 0, baseY = 3.6, baseZ = -5.0;
const camAmplitude = new THREE.Vector3(1.0, 1.0, 6.0);
const baseTargetX = 0, baseTargetY = 1.6, baseTargetZ = -30.0;
const lookAmplitude = 8.0;
const camOffsetX = Math.sin(globalTime * 3.1) * camAmplitude.x;
const camOffsetY = Math.cos(globalTime * 2.5) * camAmplitude.y;
const camOffsetZ = Math.cos(globalTime * 3.2) * camAmplitude.z;
state.camera.position.x = baseX + camOffsetX;
state.camera.position.y = baseY + camOffsetY;
state.camera.position.z = baseZ + camOffsetZ;
const lookOffsetX = Math.sin(lookAtTime * 1.5) * lookAmplitude;
const lookOffsetZ = Math.cos(lookAtTime * 2.5) * lookAmplitude;
const lookOffsetY = Math.cos(lookAtTime * 1.2) * lookAmplitude * 0.5;
state.camera.lookAt(baseTargetX + lookOffsetX, baseTargetY + lookOffsetY, baseTargetZ + lookOffsetZ);
}
switchCamera(index) {
if (index >= this.cameras.length || index < 0) return;
this.activeCameraIndex = index;
const newCam = this.cameras[this.activeCameraIndex].camera;
// Copy properties from the new camera to the main state camera
state.camera.position.copy(newCam.position);
state.camera.rotation.copy(newCam.rotation);
state.camera.fov = newCam.fov;
state.camera.aspect = newCam.aspect;
state.camera.near = newCam.near;
state.camera.far = newCam.far;
state.camera.updateProjectionMatrix();
}
update(deltaTime) {
const time = state.clock.getElapsedTime();
// Handle camera switching
if (state.partyStarted) {
if (time > this.lastSwitchTime + this.switchInterval) {
const newIndex = Math.floor(Math.random() * this.cameras.length);
this.switchCamera(newIndex);
this.lastSwitchTime = time;
this.switchInterval = minSwitchInterval + Math.random() * (maxSwitchInterval - minSwitchInterval);
}
}
// Update the currently active camera if it has an update function
const activeCamData = this.cameras[this.activeCameraIndex];
if (activeCamData.update) {
activeCamData.update();
}
}
onPartyStart() {
// Start the camera switching timer only when the party starts
this.lastSwitchTime = state.clock.getElapsedTime();
}
}
new CameraManager();

View File

@ -0,0 +1,207 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
import { applyVibrancyToMaterial } from '../shaders/vibrant-billboard-shader.js';
const dancerTextureUrls = [
'/textures/dancer1.png',
];
// --- Scene dimensions for positioning ---
const stageHeight = 1.5;
const stageDepth = 5;
const length = 40;
// --- Billboard Properties ---
const dancerHeight = 2.5;
const dancerWidth = 2.5;
export class Dancers extends SceneFeature {
constructor() {
super();
this.dancers = [];
sceneFeatureManager.register(this);
}
async init() {
const processTexture = (texture) => {
const image = texture.image;
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
const keyPixelData = context.getImageData(0, 0, 1, 1).data;
const keyColor = { r: keyPixelData[0], g: keyPixelData[1], b: keyPixelData[2] };
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const threshold = 20;
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
const distance = Math.sqrt(Math.pow(r - keyColor.r, 2) + Math.pow(g - keyColor.g, 2) + Math.pow(b - keyColor.b, 2));
if (distance < threshold) data[i + 3] = 0;
}
context.putImageData(imageData, 0, 0);
return new THREE.CanvasTexture(canvas);
};
const materials = await Promise.all(dancerTextureUrls.map(async (url) => {
const texture = await state.loader.loadAsync(url);
const processedTexture = processTexture(texture);
// Configure texture for a 2x2 sprite sheet
processedTexture.repeat.set(0.5, 0.5);
const material = new THREE.MeshStandardMaterial({
map: processedTexture,
side: THREE.DoubleSide,
alphaTest: 0.5,
roughness: 0.7,
metalness: 0.1,
});
applyVibrancyToMaterial(material, processedTexture);
return material;
}));
const createDancers = () => {
const geometry = new THREE.PlaneGeometry(dancerWidth, dancerHeight);
const dancerPositions = [
new THREE.Vector3(-4, stageHeight + dancerHeight / 2, -length / 2 + stageDepth / 2 - 2),
new THREE.Vector3(0, stageHeight + dancerHeight / 2, -length / 2 + stageDepth / 2 - 1.8),
new THREE.Vector3(4, stageHeight + dancerHeight / 2, -length / 2 + stageDepth / 2 - 2.2),
];
dancerPositions.forEach((pos, index) => {
const material = materials[index % materials.length];
const dancer = new THREE.Mesh(geometry, material);
dancer.position.copy(pos);
dancer.visible = false; // Start invisible
state.scene.add(dancer);
this.dancers.push({
mesh: dancer,
baseY: pos.y,
// --- Movement State ---
state: 'WAITING',
targetPosition: pos.clone(),
waitStartTime: 0,
waitTime: 1 + Math.random() * 2, // Wait 1-3 seconds
// --- Animation State ---
currentFrame: Math.floor(Math.random() * 4), // Start on a random frame
isMirrored: false,
canChangePose: true, // Flag to ensure pose changes only once per beat
// --- Jumping State ---
isJumping: false,
jumpStartTime: 0,
});
});
};
createDancers();
}
update(deltaTime) {
if (this.dancers.length === 0 || !state.partyStarted) return;
const cameraPosition = new THREE.Vector3();
state.camera.getWorldPosition(cameraPosition);
const time = state.clock.getElapsedTime();
const jumpDuration = 0.5;
const jumpHeight = 2.0;
const moveSpeed = 2.0;
const movementArea = { x: 9, z: 3.6, centerZ: -length / 2 + stageDepth / 2 };
this.dancers.forEach(dancerObj => {
const { mesh } = dancerObj;
mesh.lookAt(cameraPosition.x, mesh.position.y, cameraPosition.z);
// --- Point-to-Point Movement Logic ---
if (dancerObj.state === 'WAITING') {
if (time > dancerObj.waitStartTime + dancerObj.waitTime) {
// Time to find a new spot
const newTarget = new THREE.Vector3(
(Math.random() - 0.5) * movementArea.x,
dancerObj.baseY,
movementArea.centerZ + (Math.random() - 0.5) * movementArea.z
);
dancerObj.targetPosition = newTarget;
dancerObj.state = 'MOVING';
}
} else if (dancerObj.state === 'MOVING') {
const distance = mesh.position.distanceTo(dancerObj.targetPosition);
if (distance > 0.1) {
const direction = dancerObj.targetPosition.clone().sub(mesh.position).normalize();
mesh.position.add(direction.multiplyScalar(moveSpeed * deltaTime));
} else {
// Arrived at destination
dancerObj.state = 'WAITING';
dancerObj.waitStartTime = time;
dancerObj.waitTime = 1 + Math.random() * 2; // Set new wait time
}
}
// --- Spritesheet Animation ---
if (state.music) {
if (state.music.beatIntensity > 0.8 && dancerObj.canChangePose) {
// On the beat, select a new random frame and mirroring state
dancerObj.currentFrame = Math.floor(Math.random() * 4); // Select a random frame on the beat
dancerObj.isMirrored = Math.random() < 0.5;
const frameX = dancerObj.currentFrame % 2;
const frameY = Math.floor(dancerObj.currentFrame / 2);
// Adjust repeat and offset for mirroring
mesh.material.map.repeat.x = dancerObj.isMirrored ? -0.5 : 0.5;
mesh.material.map.offset.x = dancerObj.isMirrored ? (frameX * 0.5) + 0.5 : frameX * 0.5;
// The Y offset is inverted because UV coordinates start from the bottom-left
mesh.material.map.offset.y = (1 - frameY) * 0.5;
dancerObj.canChangePose = false; // Prevent changing again on this same beat
} else if (state.music.beatIntensity < 0.2) {
dancerObj.canChangePose = true; // Reset the flag when the beat is over
}
}
// --- Jumping Logic ---
if (dancerObj.isJumping) {
const jumpProgress = (time - dancerObj.jumpStartTime) / jumpDuration;
if (jumpProgress < 1.0) {
mesh.position.y = dancerObj.baseY + Math.sin(jumpProgress * Math.PI) * jumpHeight;
} else {
dancerObj.isJumping = false;
mesh.position.y = dancerObj.baseY;
}
} else {
const musicTime = state.clock.getElapsedTime();
if (state.music && state.music.isLoudEnough && state.music.beatIntensity > 0.8 && Math.random() < 0.5 && musicTime > 10) {
dancerObj.isJumping = true;
dancerObj.jumpStartTime = time;
}
}
});
}
onPartyStart() {
this.dancers.forEach(dancerObj => {
dancerObj.mesh.visible = true;
// Teleport to stage
dancerObj.state = 'WAITING';
dancerObj.mesh.position.y = dancerObj.baseY;
dancerObj.waitStartTime = state.clock.getElapsedTime();
});
}
onPartyEnd() {
this.dancers.forEach(dancerObj => {
dancerObj.isJumping = false;
//dancerObj.mesh.visible = false;
dancerObj.state = 'WAITING';
dancerObj.waitStartTime = state.clock.getElapsedTime();
});
}
}
new Dancers();

212
party-stage/src/scene/dj.js Normal file
View File

@ -0,0 +1,212 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
export class DJ extends SceneFeature {
constructor() {
super();
sceneFeatureManager.register(this);
}
init() {
this.group = new THREE.Group();
// Position behind the console (console is at z=-16.5)
this.baseY = 1.8; // Stage height
this.group.position.set(0, this.baseY, -17.5);
// 1. Body (Blobby)
const bodyGeo = new THREE.CapsuleGeometry(0.3, 0.8, 4, 8);
const bodyMat = new THREE.MeshStandardMaterial({
color: 0x3355ff, // Bright Blue
roughness: 0.6,
metalness: 0.1
});
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.position.y = 0.7;
body.castShadow = true;
body.receiveShadow = true;
this.group.add(body);
// 2. Head
const headGeo = new THREE.SphereGeometry(0.25, 16, 16);
const head = new THREE.Mesh(headGeo, bodyMat);
head.position.y = 1.55;
head.castShadow = true;
head.receiveShadow = true;
this.group.add(head);
this.head = head;
// 3. Glasses
const glassesGroup = new THREE.Group();
glassesGroup.position.set(0, 0, 0.18); // On face
head.add(glassesGroup);
const glassesMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.2, metalness: 0.8 });
const lensGeo = new THREE.BoxGeometry(0.13, 0.08, 0.15);
const bridgeGeo = new THREE.BoxGeometry(0.04, 0.02, 0.15);
const leftLens = new THREE.Mesh(lensGeo, glassesMat);
leftLens.position.set(-0.085, 0, 0);
glassesGroup.add(leftLens);
const rightLens = new THREE.Mesh(lensGeo, glassesMat);
rightLens.position.set(0.085, 0, 0);
glassesGroup.add(rightLens);
const bridge = new THREE.Mesh(bridgeGeo, glassesMat);
glassesGroup.add(bridge);
// 4. Headphones
const headphoneMat = new THREE.MeshStandardMaterial({ color: 0xcccccc, roughness: 0.3, metalness: 0.8 });
const earCupGeo = new THREE.CylinderGeometry(0.12, 0.12, 0.1, 16);
earCupGeo.rotateZ(Math.PI / 2);
const leftCup = new THREE.Mesh(earCupGeo, headphoneMat);
leftCup.position.set(-0.26, 0, 0);
head.add(leftCup);
const rightCup = new THREE.Mesh(earCupGeo, headphoneMat);
rightCup.position.set(0.26, 0, 0);
head.add(rightCup);
const bandGeo = new THREE.TorusGeometry(0.26, 0.03, 8, 24, Math.PI);
const band = new THREE.Mesh(bandGeo, headphoneMat);
band.position.set(0, 0, 0);
head.add(band);
// 5. Arms
const armGeo = new THREE.SphereGeometry(0.12, 16, 16);
const createArm = (isLeft) => {
const pivot = new THREE.Group();
pivot.position.set(isLeft ? -0.35 : 0.35, 1.3, 0);
const arm = new THREE.Mesh(armGeo, bodyMat);
arm.scale.set(1, 3.5, 1);
arm.position.y = -0.4;
arm.castShadow = true;
arm.receiveShadow = true;
pivot.add(arm);
return pivot;
};
this.leftArm = createArm(true);
this.rightArm = createArm(false);
this.group.add(this.leftArm);
this.group.add(this.rightArm);
this.group.visible = false;
state.scene.add(this.group);
// Movement State
this.state = 'IDLE';
this.targetX = 0;
this.moveTimer = 0;
// Arm State
this.armState = 3; // 0: None, 1: Left, 2: Right, 3: Both, 4: Twiddling
this.armTimer = 0;
this.currentLeftAngle = Math.PI * 0.85;
this.currentRightAngle = Math.PI * 0.85;
this.currentLeftAngleX = 0;
this.currentRightAngleX = 0;
}
update(deltaTime) {
if (!this.group || !state.partyStarted) return;
const time = state.clock.getElapsedTime();
// Bobbing
let bobAmount = 0;
let beatIntensity = 0;
if (state.music) {
beatIntensity = state.music.beatIntensity;
bobAmount = Math.sin(time * 15) * 0.05 * (0.5 + beatIntensity);
}
this.group.position.y = this.baseY + bobAmount;
// Arms Up Animation
// Update Arm State
this.armTimer -= deltaTime;
if (this.armTimer <= 0) {
this.armState = Math.floor(Math.random() * 5);
this.armTimer = 2 + Math.random() * 4;
if (Math.random() < 0.3) this.armState = 4; // Twiddling some more
}
const upAngle = Math.PI * 0.85;
const downAngle = 0.1;
const twiddleX = -1.2;
let targetLeftZ = downAngle;
let targetRightZ = downAngle;
let targetLeftX = 0;
let targetRightX = 0;
if (this.armState === 4) {
// Twiddling
targetLeftZ = 0.2;
targetRightZ = 0.2;
targetLeftX = twiddleX;
targetRightX = twiddleX;
} else {
targetLeftZ = (this.armState === 1 || this.armState === 3) ? upAngle : downAngle;
targetRightZ = (this.armState === 2 || this.armState === 3) ? upAngle : downAngle;
}
this.currentLeftAngle = THREE.MathUtils.lerp(this.currentLeftAngle, targetLeftZ, deltaTime * 3);
this.currentRightAngle = THREE.MathUtils.lerp(this.currentRightAngle, targetRightZ, deltaTime * 3);
this.currentLeftAngleX = THREE.MathUtils.lerp(this.currentLeftAngleX, targetLeftX, deltaTime * 5);
this.currentRightAngleX = THREE.MathUtils.lerp(this.currentRightAngleX, targetRightX, deltaTime * 5);
if (this.armState === 4) {
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 {
const wave = Math.sin(time * 8) * 0.1;
const beatBounce = beatIntensity * 0.2;
this.leftArm.rotation.z = -this.currentLeftAngle + wave - beatBounce;
this.rightArm.rotation.z = this.currentRightAngle - wave + beatBounce;
this.leftArm.rotation.x = this.currentLeftAngleX;
this.rightArm.rotation.x = this.currentRightAngleX;
}
// Head Bop
this.head.rotation.x = beatIntensity * 0.2;
// Movement Logic: Slide along the console
if (this.state === 'IDLE') {
this.moveTimer -= deltaTime;
if (this.moveTimer <= 0) {
this.state = 'MOVING';
this.targetX = (Math.random() - 0.5) * 2.5;
}
} else if (this.state === 'MOVING') {
const speed = 1.5;
if (Math.abs(this.group.position.x - this.targetX) < 0.1) {
this.state = 'IDLE';
this.moveTimer = 2 + Math.random() * 5; // Wait 2-7 seconds before moving again
} else {
const dir = Math.sign(this.targetX - this.group.position.x);
this.group.position.x += dir * speed * deltaTime;
}
}
}
onPartyStart() {
if(this.group) this.group.visible = true;
}
onPartyEnd() {
// Optional: Hide DJ or stop movement
}
}
new DJ();

View File

@ -0,0 +1,95 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
// --- Dimensions from room-walls.js for positioning ---
const naveWidth = 12;
const naveHeight = 15;
const length = 40;
export class LightBall extends SceneFeature {
constructor() {
super();
this.lightBalls = [];
sceneFeatureManager.register(this);
}
init() {
// --- Ball Properties ---
const ballRadius = 0.2;
const lightIntensity = 5.0;
const lightColors = [0xff2222, 0x11ff11, 0x2222ff, 0xffff11, 0x00ffff, 0xff00ff]; // Red, Green, Blue, Yellow
lightColors.forEach(color => {
// --- Create the Ball ---
const ballGeometry = new THREE.SphereGeometry(ballRadius, 32, 32);
const ballMaterial = new THREE.MeshBasicMaterial({ color: color, emissive: color, emissiveIntensity: 1.2 });
const ball = new THREE.Mesh(ballGeometry, ballMaterial);
ball.castShadow = false;
ball.receiveShadow = false;
ball.visible = false; // Start invisible
// --- Create the Light ---
const light = new THREE.PointLight(color, lightIntensity, length / 1.5);
light.visible = false; // Start invisible
// --- Initial Position ---
ball.position.set(
(Math.random() - 0.5) * naveWidth,
naveHeight * 0.6 + Math.random() * 4,
(Math.random() - 0.5) * length * 0.8
);
light.position.copy(ball.position);
//state.scene.add(ball); // no need to show the ball
state.scene.add(light);
this.lightBalls.push({
mesh: ball,
light: light,
driftSpeed: 0.2 + Math.random() * 0.2,
driftAmplitude: 4.0 + Math.random() * 4.0,
offset: Math.random() * Math.PI * 6,
});
});
}
update(deltaTime) {
if (!state.partyStarted) return;
const time = state.clock.getElapsedTime();
this.lightBalls.forEach(lb => {
const { mesh, light, driftSpeed, offset } = lb;
mesh.position.x = Math.sin(time * driftSpeed + offset) * naveWidth/2 * 0.8;
mesh.position.y = 10 + Math.cos(time * driftSpeed * 1.3 + offset) * naveHeight/2 * 0.6;
mesh.position.z = Math.cos(time * driftSpeed * 0.7 + offset) * length/2 * 0.8;
light.position.copy(mesh.position);
// --- Music Visualization ---
if (state.music) {
const baseIntensity = 4.0;
light.intensity = baseIntensity + state.music.beatIntensity * 3.0;
const baseScale = 1.0;
mesh.scale.setScalar(baseScale + state.music.beatIntensity * 0.5);
}
});
}
onPartyStart() {
this.lightBalls.forEach(lb => {
//lb.mesh.visible = true; // no visible ball
lb.light.visible = true;
});
}
onPartyEnd() {
this.lightBalls.forEach(lb => {
lb.mesh.visible = false;
lb.light.visible = false;
});
}
}
new LightBall();

View File

@ -0,0 +1,238 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
export class MusicConsole extends SceneFeature {
constructor() {
super();
this.lights = [];
sceneFeatureManager.register(this);
}
init() {
// Stage Y top is 1.5
const stageY = 1.5;
const consoleWidth = 4.0;
const consoleDepth = 0.8;
const consoleHeight = 1.2;
const group = new THREE.Group();
// Position on stage, centered
group.position.set(0, stageY, -16.5);
state.scene.add(group);
// 1. The Stand/Table Body
const standGeo = new THREE.BoxGeometry(consoleWidth, consoleHeight, consoleDepth);
const standMat = new THREE.MeshStandardMaterial({
color: 0x1a1a1a,
roughness: 0.3,
metalness: 0.6
});
const stand = new THREE.Mesh(standGeo, standMat);
stand.position.y = consoleHeight / 2;
stand.castShadow = true;
stand.receiveShadow = true;
group.add(stand);
// 2. Control Surface (Top Plate)
const topGeo = new THREE.BoxGeometry(consoleWidth + 0.1, 0.05, consoleDepth + 0.1);
const topMat = new THREE.MeshStandardMaterial({ color: 0x050505, roughness: 0.8 });
const topPlate = new THREE.Mesh(topGeo, topMat);
topPlate.position.y = consoleHeight + 0.025;
topPlate.receiveShadow = true;
group.add(topPlate);
const surfaceY = consoleHeight + 0.05;
// 3. Knobs (Left side)
const knobGeo = new THREE.CylinderGeometry(0.015, 0.015, 0.02, 12);
const knobMat = new THREE.MeshStandardMaterial({ color: 0xcccccc, metalness: 0.8, roughness: 0.2 });
const tinyLedGeo = new THREE.CircleGeometry(0.005, 8);
tinyLedGeo.rotateX(-Math.PI / 2);
for(let row=0; row<4; row++) {
for(let col=0; col<16; col++) {
const knob = new THREE.Mesh(knobGeo, knobMat);
const x = -consoleWidth/2 + 0.3 + (col * 0.06);
const z = -0.25 + (row * 0.08);
knob.position.set(x, surfaceY + 0.01, z);
group.add(knob);
// Add tiny LED next to some knobs
if (Math.random() > 0.5) {
const ledMat = new THREE.MeshBasicMaterial({ color: 0x222222 });
const led = new THREE.Mesh(tinyLedGeo, ledMat);
led.position.set(x + 0.02, surfaceY + 0.001, z + 0.02);
group.add(led);
this.lights.push({
mesh: led,
onColor: new THREE.Color().setHSL(Math.random(), 1.0, 0.5),
offColor: new THREE.Color(0x222222),
active: false,
nextToggle: Math.random()
});
}
}
}
// 4. Sliders (Middle)
const sliderTrackGeo = new THREE.BoxGeometry(0.01, 0.005, 0.25);
const sliderHandleGeo = new THREE.BoxGeometry(0.025, 0.03, 0.015);
const trackMat = new THREE.MeshBasicMaterial({ color: 0x111111 });
const handleMat = new THREE.MeshStandardMaterial({ color: 0xffffff });
for(let i=0; i<24; i++) {
const x = -0.5 + (i * 0.05);
const track = new THREE.Mesh(sliderTrackGeo, trackMat);
track.position.set(x, surfaceY, 0.1);
group.add(track);
const handle = new THREE.Mesh(sliderHandleGeo, handleMat);
// Randomize slider positions slightly
const slidePos = (Math.random() - 0.5) * 0.2;
handle.position.set(x, surfaceY + 0.015, 0.1 + slidePos);
group.add(handle);
// LED above slider
const ledMat = new THREE.MeshBasicMaterial({ color: 0x222222 });
const led = new THREE.Mesh(tinyLedGeo, ledMat);
led.position.set(x, surfaceY + 0.001, -0.05);
group.add(led);
this.lights.push({
mesh: led,
onColor: new THREE.Color().setHSL(Math.random(), 1.0, 0.5),
offColor: new THREE.Color(0x222222),
active: false,
nextToggle: Math.random()
});
}
// 5. Blinky Lights (Right side grid)
const lightGeo = new THREE.PlaneGeometry(0.03, 0.03);
lightGeo.rotateX(-Math.PI / 2); // Lay flat
const rows = 8;
const cols = 16;
for(let r=0; r<rows; r++) {
for(let c=0; c<cols; c++) {
const lightMat = new THREE.MeshBasicMaterial({ color: 0x222222 });
const lightMesh = new THREE.Mesh(lightGeo, lightMat);
const x = consoleWidth/2 - 1.0 + (c * 0.05);
const z = -0.25 + (r * 0.05);
lightMesh.position.set(x, surfaceY + 0.01, z);
group.add(lightMesh);
this.lights.push({
mesh: lightMesh,
// Assign random colors (Red, Green, Blue, Amber)
onColor: new THREE.Color().setHSL(Math.random(), 1.0, 0.5),
offColor: new THREE.Color(0x222222),
active: false,
nextToggle: Math.random()
});
}
}
// 6. Front LED Array (InstancedMesh)
const ledRows = 10;
const ledCols = 60;
this.ledRows = ledRows;
this.ledCols = ledCols;
const ledCount = ledRows * ledCols;
const ledDisplayGeo = new THREE.PlaneGeometry(0.04, 0.04);
const ledDisplayMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
this.frontLedMesh = new THREE.InstancedMesh(ledDisplayGeo, ledDisplayMat, ledCount);
const dummy = new THREE.Object3D();
let idx = 0;
const spacing = 0.06;
const gridWidth = ledCols * spacing;
const gridHeight = ledRows * spacing;
// Center on the front face of the stand
const startX = -gridWidth / 2 + spacing / 2;
const startY = (consoleHeight / 2) - gridHeight / 2 + spacing / 2;
const zPos = consoleDepth / 2 + 0.01; // Slightly in front of the stand
for(let r=0; r<ledRows; r++) {
for(let c=0; c<ledCols; c++) {
dummy.position.set(startX + c * spacing, startY + r * spacing, zPos);
dummy.updateMatrix();
this.frontLedMesh.setMatrixAt(idx, dummy.matrix);
this.frontLedMesh.setColorAt(idx, new THREE.Color(0x000000));
idx++;
}
}
this.frontLedMesh.instanceMatrix.needsUpdate = true;
if (this.frontLedMesh.instanceColor) this.frontLedMesh.instanceColor.needsUpdate = true;
group.add(this.frontLedMesh);
}
update(deltaTime) {
if (!state.partyStarted || !state.music) return;
const time = state.clock.getElapsedTime();
const beatIntensity = state.music.beatIntensity;
this.lights.forEach(light => {
if (time > light.nextToggle) {
// Toggle state
light.active = !light.active;
// Determine next toggle time based on music intensity
// Higher intensity = faster toggles
const speed = 0.1 + (1.0 - beatIntensity) * 0.5;
light.nextToggle = time + speed + Math.random() * 0.2;
}
if (light.active) {
light.mesh.material.color.copy(light.onColor);
// Pulse brightness with beat
light.mesh.material.color.multiplyScalar(1 + beatIntensity * 2);
} else {
light.mesh.material.color.copy(light.offColor);
}
});
// Update Front LED Array
if (this.frontLedMesh) {
const color = new THREE.Color();
let idx = 0;
for(let r=0; r<this.ledRows; r++) {
for(let c=0; c<this.ledCols; c++) {
const u = c / this.ledCols;
const v = r / this.ledRows;
// Dynamic wave pattern
const wave1 = Math.sin(u * 12 + time * 3);
const wave2 = Math.cos(v * 8 - time * 2);
const wave3 = Math.sin((u + v) * 5 + time * 5);
const intensity = (wave1 + wave2 + wave3) / 3 * 0.5 + 0.5;
// Color palette shifting
const hue = (time * 0.1 + u * 0.3 + intensity * 0.2) % 1;
const sat = 0.9;
const light = intensity * (0.1 + beatIntensity * 0.9); // Pulse brightness with beat
color.setHSL(hue, sat, light);
this.frontLedMesh.setColorAt(idx, color);
idx++;
}
}
if (this.frontLedMesh.instanceColor) this.frontLedMesh.instanceColor.needsUpdate = true;
}
}
}
new MusicConsole();

View File

@ -0,0 +1,129 @@
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
export class MusicPlayer extends SceneFeature {
constructor() {
super();
this.audioContext = null;
this.analyser = null;
this.source = null;
this.dataArray = null;
this.loudnessHistory = [];
sceneFeatureManager.register(this);
}
init() {
state.music.player = document.getElementById('audioPlayer');
state.music.loudness = 0;
state.music.isLoudEnough = false;
const loadButton = document.getElementById('loadMusicButton');
const fileInput = document.getElementById('musicFileInput');
const uiContainer = document.getElementById('ui-container');
const metadataContainer = document.getElementById('metadata-container');
const songTitleElement = document.getElementById('song-title');
loadButton.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (file) {
// Setup Web Audio API if not already done
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 128; // Lower resolution is fine for loudness
this.source = this.audioContext.createMediaElementSource(state.music.player);
this.source.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
}
// Hide the main button
loadButton.style.display = 'none';
// Show metadata
songTitleElement.textContent = file.name.replace(/\.[^/.]+$/, ""); // Show filename without extension
metadataContainer.classList.remove('hidden');
const url = URL.createObjectURL(file);
state.music.player.src = url;
// Wait 5 seconds, then start the party
setTimeout(() => {
metadataContainer.classList.add('hidden');
this.startParty();
}, 5000);
}
});
state.music.player.addEventListener('ended', () => {
this.stopParty();
uiContainer.style.display = 'flex'; // Show the button again
});
}
startParty() {
state.clock.start();
state.music.player.play();
document.getElementById('ui-container').style.display = 'none';
state.partyStarted = true;
// You could add BPM detection here in the future
// For now, we use the fixed BPM
// Trigger 'start' event for other features
this.notifyFeatures('onPartyStart');
}
stopParty() {
state.clock.stop();
state.partyStarted = false;
setTimeout(() => {
const startButton = document.getElementById('loadMusicButton');
startButton.style.display = 'block';
startButton.textContent = "Party some more?"
}, 5000);
// Trigger 'end' event for other features
this.notifyFeatures('onPartyEnd');
}
notifyFeatures(methodName) {
sceneFeatureManager.features.forEach(feature => {
if (typeof feature[methodName] === 'function') {
feature[methodName]();
}
});
}
update(deltaTime) {
if (!state.partyStarted || !this.analyser) return;
this.analyser.getByteFrequencyData(this.dataArray);
// --- Calculate current loudness ---
let sum = 0;
for (let i = 0; i < this.dataArray.length; i++) {
sum += this.dataArray[i];
}
const average = sum / this.dataArray.length;
state.music.loudness = average / 255; // Normalize to 0-1 range
// --- Track loudness over the last 2 seconds ---
this.loudnessHistory.push(state.music.loudness);
if (this.loudnessHistory.length > 120) { // Assuming ~60fps, 2 seconds of history
this.loudnessHistory.shift();
}
// --- Determine if it's loud enough to jump ---
const avgLoudness = this.loudnessHistory.reduce((a, b) => a + b, 0) / this.loudnessHistory.length;
const quietThreshold = 0.1; // Adjust this value based on testing
state.music.isLoudEnough = avgLoudness > quietThreshold;
}
}
new MusicPlayer();

View File

@ -0,0 +1,40 @@
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
export class MusicVisualizer extends SceneFeature {
constructor() {
super();
sceneFeatureManager.register(this);
}
init() {
// Initialize music state
state.music = {
bpm: 120,
beatDuration: 60 / 120,
measureDuration: (60 / 120) * 4,
beatIntensity: 0,
measurePulse: 0,
isLoudEnough: false,
};
}
update(deltaTime) {
if (!state.music || !state.partyStarted) return;
const time = state.clock.getElapsedTime();
// --- Calculate Beat Intensity (pulses every beat) ---
// This creates a sharp attack and slower decay (0 -> 1 -> 0)
const beatProgress = (time % state.music.beatDuration) / state.music.beatDuration;
state.music.beatIntensity = Math.pow(1.0 - beatProgress, 2);
// --- Calculate Measure Pulse (spikes every 4 beats) ---
// This creates a very sharp spike for the torch flame effect
const measureProgress = (time % state.music.measureDuration) / state.music.measureDuration;
state.music.measurePulse = measureProgress < 0.2 ? Math.sin(measureProgress * Math.PI * 5) : 0;
}
}
new MusicVisualizer();

View File

@ -0,0 +1,300 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
// --- Scene dimensions for positioning ---
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;
const jumpDuration = 0.3;
const jumpHeight = 0.2;
const jumpVariance = 0.1;
const rushIn = false;
const waitTimeBase = 10;
const waitTimeVariance = 60;
// --- Guest Properties ---
const guestHeight = 1.8; // Approx height of the blob+head
export class PartyGuests extends SceneFeature {
constructor() {
super();
this.guests = [];
sceneFeatureManager.register(this);
}
init() {
// Shared Geometries
// Body: Tall blob (Capsule)
const bodyGeo = new THREE.CapsuleGeometry(0.3, 0.8, 4, 8);
// Head: Sphere
const headGeo = new THREE.SphereGeometry(0.25, 16, 16);
// Arm: Ellipsoid (Scaled Sphere)
const armGeo = new THREE.SphereGeometry(0.12, 16, 16);
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,
});
const group = new THREE.Group();
const scale = 0.85 + Math.random() * 0.3;
group.scale.setScalar(scale);
// 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);
// 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);
// 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;
};
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
});
}
};
createGuests();
}
update(deltaTime) {
if (this.guests.length === 0 || !state.partyStarted) return;
const time = state.clock.getElapsedTime();
const minDistance = 0.8; // Minimum distance to maintain
this.guests.forEach((guestObj, i) => {
const { mesh, leftArm, rightArm } = guestObj;
// --- Collision Avoidance ---
let separationX = 0;
let separationZ = 0;
for (let j = 0; j < this.guests.length; j++) {
if (i === j) continue;
const otherMesh = this.guests[j].mesh;
const dx = mesh.position.x - otherMesh.position.x;
const dz = mesh.position.z - otherMesh.position.z;
const distSq = dx*dx + dz*dz;
if (distSq < minDistance * minDistance && distSq > 0.0001) {
const dist = Math.sqrt(distSq);
const force = (minDistance - dist) / minDistance;
separationX += (dx / dist) * force;
separationZ += (dz / dist) * force;
}
}
const separationStrength = 2.0;
mesh.position.x += separationX * separationStrength * deltaTime;
mesh.position.z += separationZ * separationStrength * deltaTime;
if (guestObj.state === 'WAITING') {
// Face the stage (approx z = -20)
const dx = 0 - mesh.position.x;
const dz = -20 - mesh.position.z;
const targetRotation = Math.atan2(dx, dz);
let rotDiff = targetRotation - mesh.rotation.y;
while (rotDiff > Math.PI) rotDiff -= Math.PI * 2;
while (rotDiff < -Math.PI) rotDiff += Math.PI * 2;
mesh.rotation.y += rotDiff * deltaTime * 2.0;
// Gentle Bob and Sway
if (!guestObj.isJumping) {
const bobSpeed = 6.0;
mesh.position.y = Math.abs(Math.sin(time * bobSpeed + guestObj.randomOffset)) * 0.05;
mesh.rotation.z = Math.sin(time * (bobSpeed * 0.5) + guestObj.randomOffset) * 0.05;
} else {
mesh.rotation.z = 0;
}
if (time > guestObj.waitStartTime + guestObj.waitTime) {
const newTarget = new THREE.Vector3(
(Math.random() - 0.5) * movementArea.x,
0,
movementArea.centerZ + (Math.random() - 0.5) * movementArea.z
);
guestObj.targetPosition = newTarget;
guestObj.state = 'MOVING';
}
} else if (guestObj.state === 'MOVING') {
const currentPosFlat = new THREE.Vector3(mesh.position.x, 0, mesh.position.z);
const targetPosFlat = new THREE.Vector3(guestObj.targetPosition.x, 0, guestObj.targetPosition.z);
const distance = currentPosFlat.distanceTo(targetPosFlat);
if (distance > 0.1) {
const direction = targetPosFlat.sub(currentPosFlat).normalize();
mesh.position.add(direction.multiplyScalar(moveSpeed * deltaTime));
// If moving away from stage (positive Z), drop hands
if (direction.z > 0.1) {
guestObj.handsUpTimer = 0;
}
// Face direction
const targetRotation = Math.atan2(direction.x, direction.z);
let rotDiff = targetRotation - mesh.rotation.y;
while (rotDiff > Math.PI) rotDiff -= Math.PI * 2;
while (rotDiff < -Math.PI) rotDiff += Math.PI * 2;
mesh.rotation.y += rotDiff * deltaTime * 5;
if (!guestObj.isJumping) {
mesh.position.y = 0;
mesh.rotation.z = 0;
}
} else {
guestObj.state = 'WAITING';
guestObj.waitStartTime = time;
guestObj.waitTime = waitTimeBase + Math.random() * waitTimeVariance;
}
}
// Update hands up timer
if (guestObj.handsUpTimer > 0) {
guestObj.handsUpTimer -= deltaTime;
}
if (guestObj.isJumping) {
const jumpProgress = (time - guestObj.jumpStartTime) / jumpDuration;
if (jumpProgress < 1) {
mesh.position.y = Math.sin(jumpProgress * Math.PI) * guestObj.jumpHeight;
} else {
guestObj.isJumping = false;
mesh.position.y = 0;
}
} else {
let currentJumpChance = jumpChance * deltaTime; // Base chance over time
if (state.music && state.music.isLoudEnough && state.music.beatIntensity > 0.8) {
currentJumpChance = 0.1; // High, fixed chance on the beat
}
if (Math.random() < currentJumpChance) {
guestObj.isJumping = true;
guestObj.jumpHeight = jumpHeight + Math.random() * jumpVariance;
guestObj.jumpStartTime = time;
if (Math.random() < 0.5) {
guestObj.handsUpTimer = 2.0 + Math.random() * 3.0; // Keep hands up for 2-5 seconds
const r = Math.random();
if (r < 0.33) guestObj.handsRaisedType = 'LEFT';
else if (r < 0.66) guestObj.handsRaisedType = 'RIGHT';
else guestObj.handsRaisedType = 'BOTH';
}
}
}
// Apply Arm Rotation
let targetLeftAngle = 0;
let targetRightAngle = 0;
if (guestObj.handsUpTimer > 0) {
const upAngle = -Math.PI;
if (guestObj.handsRaisedType === 'LEFT' || guestObj.handsRaisedType === 'BOTH') {
targetLeftAngle = upAngle;
}
if (guestObj.handsRaisedType === 'RIGHT' || guestObj.handsRaisedType === 'BOTH') {
targetRightAngle = upAngle;
}
}
leftArm.rotation.x = THREE.MathUtils.lerp(leftArm.rotation.x, targetLeftAngle, deltaTime * 5);
rightArm.rotation.x = THREE.MathUtils.lerp(rightArm.rotation.x, targetRightAngle, deltaTime * 5);
leftArm.rotation.z = 0;
rightArm.rotation.z = 0;
});
}
onPartyStart() {
const stageFrontZ = -40 / 2 + 5 + 5; // In front of the stage
this.guests.forEach((guestObj, index) => {
guestObj.mesh.visible = true;
// Rush to the stage
guestObj.state = 'MOVING';
if (index % 2 === 0) {
guestObj.targetPosition.z = stageFrontZ + (Math.random() - 0.5) * 5;
}
});
}
onPartyEnd() {
this.guests.forEach(guestObj => {
guestObj.isJumping = false;
guestObj.state = 'WAITING';
});
}
}
new PartyGuests();

View File

View File

@ -0,0 +1,324 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
// --- Shaders for Screen Effects ---
const screenVertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
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;
varying vec2 vUv;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}
void main() {
// LED Grid Setup
float ledCountX = ${ledCountX}.0;
float ledCountY = ${ledCountY}.0;
vec2 gridUV = vec2(vUv.x * ledCountX, vUv.y * ledCountY);
vec2 cell = fract(gridUV);
vec2 pixelatedUV = (floor(gridUV) + 0.5) / vec2(ledCountX, ledCountY);
vec4 color = texture2D(videoTexture, pixelatedUV);
// Effect 1: Static/Noise (Power On/Off)
if (u_effect_type > 0.0) {
float noise = random(pixelatedUV + u_time);
vec3 noiseColor = vec3(noise);
color.rgb = mix(color.rgb, noiseColor, u_effect_strength);
}
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);
}
`;
const visualizerFragmentShader = `
uniform float u_time;
uniform float u_beat;
uniform float u_opacity;
varying vec2 vUv;
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
void main() {
float ledCountX = 128.0;
float ledCountY = 72.0;
vec2 gridUV = vec2(vUv.x * ledCountX, vUv.y * ledCountY);
vec2 cell = fract(gridUV);
vec2 uv = (floor(gridUV) + 0.5) / vec2(ledCountX, ledCountY);
float dist = distance(cell, vec2(0.5));
float mask = 1.0 - smoothstep(0.35, 0.45, dist);
float d = length(uv - 0.5);
float angle = atan(uv.y - 0.5, uv.x - 0.5);
float wave = sin(d * 20.0 - u_time * 2.0);
float beatWave = sin(angle * 5.0 + u_time) * u_beat;
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);
}
`;
let projectionScreenInstance = null;
export class ProjectionScreen extends SceneFeature {
constructor() {
super();
projectionScreenInstance = this;
this.isVisualizerActive = false;
this.originalPositions = null;
sceneFeatureManager.register(this);
}
init() {
// --- Initialize State ---
state.tvScreenPowered = false;
state.screenEffect = {
active: false,
type: 0,
startTime: 0,
duration: 1000,
easing: (t) => t,
onComplete: null
};
state.originalScreenIntensity = 2.0;
state.screenOpacity = 0.5;
// Ensure video element exists
if (!state.videoElement) {
state.videoElement = document.createElement('video');
state.videoElement.crossOrigin = 'anonymous';
state.videoElement.playsInline = true;
state.videoElement.style.display = 'none';
document.body.appendChild(state.videoElement);
}
// --- Create Screen Mesh ---
// 16:9 Aspect Ratio, large size
const width = 10;
const height = width * (9 / 16);
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);
// High enough to be seen
this.mesh.position.set(0, 5.5, -20.5);
state.scene.add(this.mesh);
state.tvScreen = this.mesh;
state.tvScreen.visible = false;
// --- Screen Light ---
// A light that projects the screen's color/ambiance into the room
state.screenLight = new THREE.PointLight(0xffffff, 0, 25);
state.screenLight.position.set(0, 5.5, -18);
state.screenLight.castShadow = true;
state.screenLight.shadow.mapSize.width = 512;
state.screenLight.shadow.mapSize.height = 512;
state.scene.add(state.screenLight);
}
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;
for (let i = 0; i < positions.count; i++) {
const originalX = this.originalPositions.getX(i);
const originalZ = this.originalPositions.getZ(i);
const zOffset = Math.sin(originalX * waveFrequency + time * waveSpeed) * waveAmplitude;
positions.setZ(i, originalZ + zOffset);
}
positions.needsUpdate = true;
this.mesh.geometry.computeVertexNormals();
}
if (this.isVisualizerActive && state.tvScreen.material.uniforms) {
state.tvScreen.material.uniforms.u_time.value = state.clock.getElapsedTime();
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);
}
}
onPartyStart() {
// Hide load button during playback
if (state.loadTapeButton) state.loadTapeButton.classList.add('hidden');
// If no video loaded, start visualizer
if (!state.isVideoLoaded) {
this.activateVisualizer();
}
}
onPartyEnd() {
// Show load button if no video is loaded
if (state.loadTapeButton && !state.isVideoLoaded) state.loadTapeButton.classList.remove('hidden');
if (this.isVisualizerActive) {
this.deactivateVisualizer();
}
}
activateVisualizer() {
this.isVisualizerActive = true;
state.tvScreen.visible = true;
state.tvScreenPowered = true;
state.tvScreen.material = new THREE.ShaderMaterial({
uniforms: {
u_time: { value: 0.0 },
u_beat: { value: 0.0 },
u_opacity: { value: state.screenOpacity }
},
vertexShader: screenVertexShader,
fragmentShader: visualizerFragmentShader,
side: THREE.DoubleSide,
transparent: true
});
state.screenLight.intensity = state.originalScreenIntensity;
}
deactivateVisualizer() {
this.isVisualizerActive = false;
turnTvScreenOff();
}
}
// --- Exported Control Functions ---
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({
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 }
},
vertexShader: screenVertexShader,
fragmentShader: screenFragmentShader,
side: THREE.DoubleSide,
transparent: true
});
state.tvScreen.material.needsUpdate = true;
if (!state.tvScreenPowered) {
state.tvScreenPowered = true;
setScreenEffect(1); // Trigger power on static effect
}
}
export function turnTvScreenOff() {
if (state.tvScreenPowered) {
state.tvScreenPowered = false;
setScreenEffect(2, () => {
// Revert to black material or hide
state.tvScreen.material = new THREE.MeshBasicMaterial({ color: 0x000000 });
state.screenLight.intensity = 0.0;
});
}
}
export function setScreenEffect(effectType, onComplete) {
const material = state.tvScreen.material;
if (!material.uniforms) return;
state.screenEffect.active = true;
state.screenEffect.type = effectType;
state.screenEffect.startTime = state.clock.getElapsedTime() * 1000;
state.screenEffect.onComplete = 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 (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;
}
}
new ProjectionScreen();

View File

@ -0,0 +1,92 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
export class ReproWall extends SceneFeature {
constructor() {
super();
this.boxes = [];
sceneFeatureManager.register(this);
}
init() {
const boxSize = 2.6;
const boxStacks = 3;
const startZ = -20;
const endZ = -18;
const leftX = -8;
const rightX = 8;
const cabinetGeometry = new THREE.BoxGeometry(boxSize, boxSize, boxSize);
const cabinetMaterial = new THREE.MeshStandardMaterial({
color: 0x333333,
roughness: 0.6,
metalness: 0.2,
});
const grilleSize = boxSize * 0.85;
const grilleGeometry = new THREE.PlaneGeometry(grilleSize, grilleSize);
const grilleMaterial = new THREE.MeshStandardMaterial({
color: 0x1a1a1a, // Not completely dark so the mesh is visible
roughness: 0.9,
metalness: 0.1,
});
// Helper to create a stack of boxes
const createStack = (baseX, baseZ, rotY) => {
const stackHeight = 4// + Math.floor(Math.random() * 2);
for (let i = 0; i < stackHeight; i++) {
const speakerGroup = new THREE.Group();
const cabinet = new THREE.Mesh(cabinetGeometry, cabinetMaterial);
cabinet.castShadow = true;
cabinet.receiveShadow = true;
speakerGroup.add(cabinet);
const grille = new THREE.Mesh(grilleGeometry, grilleMaterial);
grille.position.z = (boxSize / 2) + 0.01; // Place on front face, slightly forward
grille.receiveShadow = true;
speakerGroup.add(grille);
// Slight random offset for realism
const x = baseX + (Math.random() * 0.1 - 0.05);
const z = baseZ + (Math.random() * 0.4 - 0.05);
const y = (i * boxSize) + (boxSize / 2);
speakerGroup.position.set(x, y, z);
// Slight random rotation
speakerGroup.rotation.y = rotY + (Math.random() * 0.1 - 0.05);
state.scene.add(speakerGroup);
this.boxes.push({ mesh: speakerGroup, originalScale: new THREE.Vector3(1, 1, 1) });
}
};
// Create walls on both sides of the stage
const z = startZ;
for (let x = 0; x <= boxStacks * boxSize; x += boxSize) {
// left side
createStack(leftX - x, z + x / 2, 0.3);
// right side
createStack(rightX + x, z + x / 2, -0.3);
}
}
update(deltaTime) {
if (state.music && state.music.beatIntensity > 0.5) {
const scale = 1 + (state.music.beatIntensity - 0.5) * 0.1;
this.boxes.forEach(item => {
item.mesh.scale.setScalar(scale);
});
} else {
this.boxes.forEach(item => {
item.mesh.scale.lerp(item.originalScale, deltaTime * 5);
});
}
}
}
new ReproWall();

View File

@ -0,0 +1,64 @@
import * as THREE from 'three';
import { state } from '../state.js';
import floorTextureUrl from '/textures/floor.png';
import sceneFeatureManager from './SceneFeatureManager.js';
import { initVideoUI } from '../core/video-player.js';
// Scene Features registered here:
import { CameraManager } from './camera-manager.js';
import { LightBall } from './light-ball.js';
import { Stage } from './stage.js';
import { PartyGuests } from './party-guests.js';
import { StageTorches } from './stage-torches.js';
import { MusicVisualizer } from './music-visualizer.js';
import { MusicPlayer } from './music-player.js';
import { WallCurtain } from './wall-curtain.js';
import { ReproWall } from './repro-wall.js';
import { StageLights } from './stage-lights.js';
import { MusicConsole } from './music-console.js';
import { DJ } from './dj.js';
import { ProjectionScreen } from './projection-screen.js';
// Scene Features ^^^
// --- Scene Modeling Function ---
export function createSceneObjects() {
sceneFeatureManager.init();
initVideoUI();
// --- Materials (MeshPhongMaterial) ---
// --- 1. Floor --- (Resized to match the new cathedral dimensions)
const floorWidth = 30;
const floorLength = 50;
const floorGeometry = new THREE.PlaneGeometry(floorWidth, floorLength);
const floorTexture = state.loader.load(floorTextureUrl);
floorTexture.wrapS = THREE.RepeatWrapping;
floorTexture.wrapT = THREE.RepeatWrapping;
floorTexture.repeat.set(floorWidth / 5, floorLength / 5); // Adjust texture repeat for new size
const floorMaterial = new THREE.MeshPhongMaterial({ map: floorTexture, color: 0x666666, shininess: 5 });
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2;
floor.position.y = 0;
floor.receiveShadow = true;
state.scene.add(floor);
// 3. Lighting (Minimal and focused)
const ambientLight = new THREE.AmbientLight(0x606060, 1.0); // Increased ambient light for a larger space
state.scene.add(ambientLight);
// Add a HemisphereLight for more natural, general illumination in a large space.
const hemisphereLight = new THREE.HemisphereLight(0xffddcc, 0x444455, 0.5);
// Visual aids for the light source positions
if (state.debugLight && THREE.HemisphereLightHelper) {
// Lamp Helper will now work since lampLight is added to the scene
const hemisphereLightHelper = new THREE.HemisphereLightHelper(hemisphereLight, 0.1, 0x00ff00); // Green for lamp
state.scene.add(hemisphereLightHelper);
}
state.scene.add(hemisphereLight);
// Add fog for depth and atmosphere
const fogColor = 0x040409;
state.scene.fog = new THREE.FogExp2(fogColor, 0.04);
state.scene.background = new THREE.Color(fogColor);
}

View File

@ -0,0 +1,141 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
export class StageLights extends SceneFeature {
constructor() {
super();
this.lights = [];
this.focusPoint = new THREE.Vector3(0, 0, -10);
this.targetFocusPoint = new THREE.Vector3(0, 0, -10);
this.lastChangeTime = 0;
sceneFeatureManager.register(this);
}
init() {
// 1. Create the Steel Beam
const beamLength = 24;
const beamGeo = new THREE.BoxGeometry(beamLength, 0.5, 0.5);
const beamMat = new THREE.MeshStandardMaterial({
color: 0x444444,
metalness: 0.9,
roughness: 0.4
});
this.beam = new THREE.Mesh(beamGeo, beamMat);
// Positioned high above the front of the stage area
this.beam.position.set(0, 9, -14);
this.beam.castShadow = true;
this.beam.receiveShadow = true;
state.scene.add(this.beam);
// 2. Create Spotlights
const numLights = 8;
const spacing = beamLength / numLights;
// Geometry for the light fixture (par can style)
const fixtureGeo = new THREE.CylinderGeometry(0.2, 0.3, 0.6);
// Rotate geometry so -Y (bottom) points to +Z (lookAt direction)
fixtureGeo.rotateX(-Math.PI / 2);
const fixtureMat = new THREE.MeshStandardMaterial({ color: 0x111111 });
const lensGeo = new THREE.CircleGeometry(0.18, 32);
for (let i = 0; i < numLights; i++) {
const x = -beamLength / 2 + spacing/2 + i * spacing;
// Fixture Mesh
const fixture = new THREE.Mesh(fixtureGeo, fixtureMat);
fixture.position.set(x, 8.5, -14); // Match beam position
state.scene.add(fixture);
// Lens Mesh
const lensMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
const lens = new THREE.Mesh(lensGeo, lensMat);
lens.position.set(0, 0, 0.31);
fixture.add(lens);
// SpotLight
const spotLight = new THREE.SpotLight(0xffffee, 0);
spotLight.position.copy(fixture.position);
spotLight.angle = Math.PI / 6;
spotLight.penumbra = 0.3;
spotLight.decay = 1.5;
spotLight.distance = 60;
spotLight.castShadow = true;
spotLight.shadow.bias = -0.0001;
spotLight.shadow.mapSize.width = 512;
spotLight.shadow.mapSize.height = 512;
// Target Object
const target = new THREE.Object3D();
state.scene.add(target);
spotLight.target = target;
state.scene.add(spotLight);
this.lights.push({
light: spotLight,
fixture: fixture,
lens: lens,
target: target,
baseX: x
});
}
}
update(deltaTime) {
const time = state.clock.getElapsedTime();
// Change target area logic
let shouldChange = false;
if (time < this.lastChangeTime) this.lastChangeTime = time;
if (state.music && state.partyStarted) {
// Change on the measure (every 4 beats) if enough time has passed
if (state.music.measurePulse > 0.5 && time - this.lastChangeTime > 1.5) {
shouldChange = true;
}
} else if (time - this.lastChangeTime > 3.0) {
shouldChange = true;
}
if (shouldChange) {
this.lastChangeTime = time;
// Randomly pick a zone: Stage or Dance Floor
if (Math.random() < 0.4) {
// Stage Area (Z: -20 to -10)
this.targetFocusPoint.set((Math.random() - 0.5) * 15, 1, -15 + (Math.random() - 0.5) * 5);
} else {
// Dance Floor / Guests (Z: -5 to 15)
this.targetFocusPoint.set((Math.random() - 0.5) * 20, 0, 5 + (Math.random() - 0.5) * 15);
}
}
// Smoothly move the focus point
this.focusPoint.lerp(this.targetFocusPoint, deltaTime * 2.0);
// 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;
this.lights.forEach((item) => {
// 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);
});
}
}
new StageLights();

View File

@ -0,0 +1,170 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
import sparkTextureUrl from '/textures/spark.png';
const lightPositionBaseY = 1.2;
export class StageTorches extends SceneFeature {
constructor() {
super();
this.torches = [];
sceneFeatureManager.register(this);
}
init() {
// --- Stage Dimensions for positioning ---
const length = 40;
const naveWidth = 12;
const stageWidth = naveWidth - 2;
const stageHeight = 1.5;
const stageDepth = 5;
const torchPositions = [
new THREE.Vector3(-stageWidth / 2, stageHeight, -length / 2 + 0.5),
new THREE.Vector3(stageWidth / 2, stageHeight, -length / 2 + 0.5),
new THREE.Vector3(-stageWidth / 2, stageHeight, -length / 2 + stageDepth - 0.5),
new THREE.Vector3(stageWidth / 2, stageHeight, -length / 2 + stageDepth - 0.5),
];
torchPositions.forEach(pos => {
const torch = this.createTorch(pos);
this.torches.push(torch);
state.scene.add(torch.group);
});
}
createTorch(position) {
const torchGroup = new THREE.Group();
torchGroup.position.copy(position);
torchGroup.visible = false; // Start invisible
// --- Torch Holder ---
const holderMaterial = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.6, metalness: 0.5 });
const holderGeo = new THREE.CylinderGeometry(0.1, 0.15, 1.0, 12);
const holderMesh = new THREE.Mesh(holderGeo, holderMaterial);
holderMesh.position.y = 0.5;
holderMesh.castShadow = true;
holderMesh.receiveShadow = true;
torchGroup.add(holderMesh);
// --- Point Light ---
const pointLight = new THREE.PointLight(0xffaa44, 2.5, 8);
pointLight.position.y = lightPositionBaseY;
pointLight.castShadow = true;
pointLight.shadow.mapSize.width = 128;
pointLight.shadow.mapSize.height = 128;
torchGroup.add(pointLight);
// --- Particle System for Fire ---
const particleCount = 100;
const particles = new THREE.BufferGeometry();
const positions = [];
const particleData = [];
const sparkTexture = state.loader.load(sparkTextureUrl);
const particleMaterial = new THREE.PointsMaterial({
map: sparkTexture,
color: 0xffaa00,
size: 0.5,
blending: THREE.AdditiveBlending,
transparent: true,
depthWrite: false,
});
for (let i = 0; i < particleCount; i++) {
positions.push(0, 1, 0);
particleData.push({
velocity: new THREE.Vector3((Math.random() - 0.5) * 0.2, Math.random() * 1.5, (Math.random() - 0.5) * 0.2),
life: Math.random() * 1.0,
});
}
particles.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const particleSystem = new THREE.Points(particles, particleMaterial);
torchGroup.add(particleSystem);
return { group: torchGroup, light: pointLight, particles: particleSystem, particleData: particleData };
}
resetParticles(torch) {
const positions = torch.particles.geometry.attributes.position.array;
for (let i = 0; i < torch.particleData.length; i++) {
const data = torch.particleData[i];
// Reset particle
positions[i * 3] = 0;
positions[i * 3 + 1] = 1;
positions[i * 3 + 2] = 0;
data.life = Math.random() * 1.0;
data.velocity.y = Math.random() * 1.5;
}
torch.particles.geometry.attributes.position.needsUpdate = true;
}
update(deltaTime) {
if (!state.partyStarted) return;
this.torches.forEach(torch => {
let measurePulse = 0;
if (state.music) {
measurePulse = state.music.measurePulse * 2.0; // Make flames jump higher
}
if (state.music.isLoudEnough) {
measurePulse += 2;
}
// --- Animate Particles ---
const positions = torch.particles.geometry.attributes.position.array;
let averageY = 0;
for (let i = 0; i < torch.particleData.length; i++) {
const data = torch.particleData[i];
data.life -= deltaTime;
const yVelocity = data.velocity.y;
if (data.life <= 0 || positions[i * 3 + 1] < 0) {
// Reset particle
positions[i * 3] = (Math.random() - 0.5) * 0.2;
positions[i * 3 + 1] = 1;
positions[i * 3 + 2] = (Math.random() - 0.5) * 0.2;
data.life = Math.random() * 1.0;
data.velocity.y = Math.random() * 1.2 + measurePulse;
} else {
// Update position
positions[i * 3] += data.velocity.x * deltaTime;
positions[i * 3 + 1] += yVelocity * deltaTime;
positions[i * 3 + 2] += data.velocity.z * deltaTime;
}
averageY += positions[i * 3 + 1];
}
averageY = averageY / positions.length;
torch.particles.geometry.attributes.position.needsUpdate = true;
// --- Flicker Light ---
const baseIntensity = 2.0;
const flicker = Math.random() * 0.6;
let beatPulse = 0;
if (state.music) {
beatPulse = state.music.beatIntensity * 1.5;
if (state.music.isLoudEnough) {
beatPulse += 2;
}
}
torch.light.intensity = baseIntensity + flicker + beatPulse;
torch.light.position.y = lightPositionBaseY + averageY;
});
}
onPartyStart() {
this.torches.forEach(torch => {
torch.group.visible = true;
this.resetParticles(torch);
});
}
onPartyEnd() {
this.torches.forEach(torch => {
});
}
}
new StageTorches();

View File

@ -0,0 +1,44 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
import stageWallTextureUrl from '/textures/stage_wall.png';
export class Stage extends SceneFeature {
constructor() {
super();
sceneFeatureManager.register(this);
}
init() {
// --- Dimensions from room-walls.js for positioning ---
const length = 40;
const naveWidth = 12;
// --- Stage Properties ---
const stageWidth = naveWidth - 1; // Slightly narrower than the nave
const stageHeight = 1.5;
const stageDepth = 5;
// --- Material ---
const woodTexture = state.loader.load(stageWallTextureUrl);
woodTexture.wrapS = THREE.RepeatWrapping;
woodTexture.wrapT = THREE.RepeatWrapping;
woodTexture.repeat.set(stageWidth / 3, 1);
const woodMaterial = new THREE.MeshStandardMaterial({
map: woodTexture,
roughness: 0.8,
metalness: 0.1,
});
// --- Create Stage Mesh ---
const stageGeo = new THREE.BoxGeometry(stageWidth, stageHeight, stageDepth);
const stageMesh = new THREE.Mesh(stageGeo, woodMaterial);
stageMesh.castShadow = true;
stageMesh.receiveShadow = true;
stageMesh.position.set(0, stageHeight / 2, -length / 2 + stageDepth / 2);
state.scene.add(stageMesh);
}
}
new Stage();

View File

@ -0,0 +1,91 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
import curtainTextureUrl from '/textures/tapestry.png';
export class WallCurtain extends SceneFeature {
constructor() {
super();
this.curtains = [];
this.waving = true;
sceneFeatureManager.register(this);
}
init() {
// --- Curtain Properties ---
const naveWidth = 12;
const naveHeight = 10;
const stageHeight = 1.5;
const curtainWidth = naveWidth; // Span the width of the nave
const curtainHeight = naveHeight - stageHeight; // Hang from the ceiling down to the stage
const segmentsX = 50; // More segments for a smoother wave
const segmentsY = 50;
// --- Texture ---
const texture = state.loader.load(curtainTextureUrl);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(5, 1); // Repeat the texture 5 times horizontally
// --- Material ---
const material = new THREE.MeshStandardMaterial({
map: texture,
side: THREE.DoubleSide,
roughness: 0.9,
metalness: 0.1,
});
// --- Create and Place Curtains ---
const createAndPlaceCurtain = (position, rotationY) => {
const geometry = new THREE.PlaneGeometry(curtainWidth, curtainHeight, segmentsX, segmentsY);
const originalPositions = geometry.attributes.position.clone();
const curtainMesh = new THREE.Mesh(geometry, material);
curtainMesh.position.copy(position);
curtainMesh.rotation.y = rotationY;
curtainMesh.castShadow = true;
curtainMesh.receiveShadow = true;
state.scene.add(curtainMesh);
this.curtains.push({
mesh: curtainMesh,
originalPositions: originalPositions,
});
};
// Place a single large curtain behind the stage
const backWallZ = -21;
const curtainY = stageHeight + curtainHeight / 2;
const curtainPosition = new THREE.Vector3(0, curtainY, backWallZ + 0.1);
createAndPlaceCurtain(curtainPosition, 0); // No rotation needed
}
update(deltaTime) {
if (!this.waving) { return; }
const time = state.clock.getElapsedTime();
const waveSpeed = 0.5;
const waveFrequency = 1.2;
const waveAmplitude = 0.3;
this.curtains.forEach(curtain => {
const positions = curtain.mesh.geometry.attributes.position;
const originalPos = curtain.originalPositions;
for (let i = 0; i < positions.count; i++) {
const originalX = originalPos.getX(i);
// The wave now moves horizontally across the curtain
const zOffset = Math.sin(originalX * waveFrequency + time * waveSpeed) * waveAmplitude;
positions.setZ(i, originalPos.getZ(i) + zOffset);
}
// Mark positions as needing an update
positions.needsUpdate = true;
// Recalculate normals for correct lighting on the waving surface
curtain.mesh.geometry.computeVertexNormals();
});
}
}
new WallCurtain();

View File

@ -0,0 +1,63 @@
export const fireVertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
export const fireFragmentShader = `
varying vec2 vUv;
uniform float u_time;
// 2D Random function
float random (vec2 st) {
return fract(sin(dot(st.xy,
vec2(12.9898,78.233)))*
43758.5453123);
}
// 2D Noise function
float noise (in vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = random(i);
float b = random(i + vec2(1.0, 0.0));
float c = random(i + vec2(0.0, 1.0));
float d = random(i + vec2(1.0, 1.0));
vec2 u = f*f*(3.0-2.0*f);
return mix(a, b, u.x) +
(c - a)* u.y * (1.0 - u.x) +
(d - b) * u.x * u.y;
}
// Fractional Brownian Motion to create more complex noise
float fbm(in vec2 st) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 0.0;
for (int i = 0; i < 4; i++) {
value += amplitude * noise(st);
st *= 2.0;
amplitude *= 0.5;
}
return value;
}
void main() {
vec2 uv = vUv;
float q = fbm(uv * 2.0 - vec2(0.0, u_time * 1.2));
float r = fbm(uv * 2.0 + q + vec2(1.7, 9.2) + vec2(0.0, u_time * -0.3));
float fireAmount = fbm(uv * 2.0 + r + vec2(0.0, u_time * 0.15));
// Shape the fire to rise from the bottom
fireAmount *= (1.0 - uv.y);
vec3 fireColor = mix(vec3(0.9, 0.3, 0.1), vec3(1.0, 0.9, 0.3), fireAmount);
gl_FragColor = vec4(fireColor, fireAmount * 2.0);
}
`;

View File

@ -0,0 +1,94 @@
export const screenVertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
export const screenFragmentShader = `
varying vec2 vUv;
uniform sampler2D videoTexture;
uniform float u_effect_type; // 0: none, 1: warmup, 2: powerdown
uniform float u_effect_strength; // 0.0 to 1.0
uniform float u_time;
// 2D Random function
float random (vec2 st) {
return fract(sin(dot(st.xy,
vec2(12.9898,78.233)))*
43758.5453123);
}
// 2D Noise function
float noise (vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = random(i);
float b = random(i + vec2(1.0, 0.0));
float c = random(i + vec2(0.0, 1.0));
float d = random(i + vec2(1.0, 1.0));
vec2 u = f*f*(3.0-2.0*f);
return mix(a, b, u.x) +
(c - a)* u.y * (1.0 - u.x) +
(d - b) * u.x * u.y;
}
void main() {
vec4 finalColor;
// Shimmering edge effect - ALWAYS ON
vec4 videoColor = texture2D(videoTexture, vUv);
// Shimmering edge effect
float dist = distance(vUv, vec2(0.5));
float shimmer = noise(vUv * 20.0 + vec2(u_time * 2.0, 0.0));
float edgeFactor = smoothstep(0.3, 0.5, dist);
vec3 shimmerColor = vec3(0.7, 0.8, 1.0) * shimmer * edgeFactor * 0.5;
vec4 baseColor = vec4(videoColor.rgb + shimmerColor, videoColor.a);
if (u_effect_type < 0.9) {
// normal video
finalColor = baseColor;
} else if (u_effect_type < 1.9) { // "Summon Vision" (Warm-up) effect
// This is now a multi-stage effect controlled by u_effect_strength (0.0 -> 1.0)
float noiseVal = noise(vUv * 50.0 + vec2(0.0, u_time * -125.0));
vec3 mistColor = vec3(0.8, 0.7, 1.0) * noiseVal;
vec4 videoColor = texture2D(videoTexture, vUv);
// Stage 1: Fade in the mist (u_effect_strength: 0.0 -> 0.5)
// The overall opacity of the surface fades from 0 to 1.
float fadeInOpacity = smoothstep(0.0, 0.5, u_effect_strength);
// Stage 2: Fade out the mist to reveal the video (u_effect_strength: 0.5 -> 1.0)
// The mix factor between mist and video goes from 0 (all mist) to 1 (all video).
float revealMix = smoothstep(0.5, 1.0, u_effect_strength);
vec3 mixedColor = mix(mistColor, baseColor.rgb, revealMix);
finalColor = vec4(mixedColor, fadeInOpacity);
} else { // "Vision Fades" (Power-down) effect
// Multi-stage effect: Last frame -> fade to mist -> fade to transparent
float noiseVal = noise(vUv * 50.0 + vec2(0.0, u_time * 123.0));
vec3 mistColor = vec3(0.8, 0.7, 1.0) * noiseVal;
vec4 videoColor = texture2D(videoTexture, vUv);
// Stage 1: Fade in the mist over the last frame (u_effect_strength: 0.0 -> 0.5)
float mistMix = smoothstep(0.0, 0.5, u_effect_strength);
vec3 mixedColor = mix(baseColor.rgb, mistColor, mistMix);
// Stage 2: Fade out the entire surface to transparent (u_effect_strength: 0.5 -> 1.0)
float fadeOutOpacity = smoothstep(1.0, 0.5, u_effect_strength);
finalColor = vec4(mixedColor, fadeOutOpacity);
}
gl_FragColor = finalColor;
}
`;

View File

@ -0,0 +1,22 @@
import * as THREE from 'three';
export function applyVibrancyToMaterial(material, texture) {
// Inject custom shader code to boost vibrancy
material.onBeforeCompile = (shader) => {
// Pass the texture map to the fragment shader
shader.uniforms.vibrancyMap = { value: texture };
shader.fragmentShader = 'uniform sampler2D vibrancyMap;\n' + shader.fragmentShader;
shader.fragmentShader = shader.fragmentShader.replace(
'#include <dithering_fragment>',
`
#include <dithering_fragment>
// Get the pure texture color
vec4 texColor = texture2D(vibrancyMap, vMapUv);
// Mix the final lit color with the pure texture color to keep it vibrant
float vibrancy = 0.3; // 0.0 = full lighting, 1.0 = full texture color
gl_FragColor.rgb = mix(gl_FragColor.rgb, texColor.rgb, vibrancy) + texColor.rgb * 0.2;
`
);
};
}

58
party-stage/src/state.js Normal file
View File

@ -0,0 +1,58 @@
import * as THREE from 'three';
export let state = undefined;
export function initState() {
state = {
// Core Three.js components
scene: null,
camera: null,
renderer: null,
clock: new THREE.Clock(),
composer: null,
ssaoPass: null,
tvScreen: null,
tvScreenPowered: false,
videoTexture: null,
screenLight: null, // Light from the crystal ball
candleLight: null, // Light from the candle
effectsManager: null,
screenEffect: {
active: false,
type: 0,
startTime: 0,
duration: 1000, // in ms
onComplete: null,
easing: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t, // easeInOutQuad
},
// Video Playback
isVideoLoaded: false,
videoUrls: [],
currentVideoIndex: -1,
// Scene constants
originalLampIntensity: 0.3,
originalScreenIntensity: 0.2,
screenIntensityPulse: 0.2,
roomSize: 5,
roomHeight: 3,
debugLight: false, // Turn on light helpers
debugCamera: false, // Turn on camera helpers
partyStarted: false,
// DOM Elements
container: document.body,
videoElement: document.getElementById('video'),
fileInput: document.getElementById('fileInput'),
loadTapeButton: document.getElementById('loadTapeButton'),
// Utilities
loader: new THREE.TextureLoader(),
pictureFrames: [],
raycaster: new THREE.Raycaster(),
seed: 12345,
};
}

37
party-stage/src/utils.js Normal file
View File

@ -0,0 +1,37 @@
import * as THREE from 'three';
import { state } from './state.js';
// --- Utility: Random Color (seeded) ---
export function getRandomColor() {
const hue = seededRandom();
const saturation = 0.6 + seededRandom() * 0.4;
const lightness = 0.3 + seededRandom() * 0.4;
return new THREE.Color().setHSL(hue, saturation, lightness).getHex();
}
/**
* Converts degrees to radians.
* @param {number} degrees
* @returns {number}
*/
export function degToRad(degrees) {
return degrees * (Math.PI / 180);
}
// --- Seedable Random Number Generator (Mulberry32) ---
export function seededRandom() {
let t = state.seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
// --- Helper function to format seconds into MM:SS ---
export function formatTime(seconds) {
if (isNaN(seconds) || seconds === Infinity || seconds < 0) return '--:--';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
const paddedMinutes = String(minutes).padStart(2, '0');
const paddedSeconds = String(remainingSeconds).padStart(2, '0');
return `${paddedMinutes}:${paddedSeconds}`;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

83
party-stage/vendor/tailwind-3.4.17.js vendored Normal file

File diff suppressed because one or more lines are too long

6
party-stage/vendor/three.min.js vendored Normal file

File diff suppressed because one or more lines are too long