Compare commits
14 Commits
87a5153fe2
...
3e773361e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e773361e2 | ||
|
|
32679ced8e | ||
|
|
7f2abc635d | ||
|
|
4feeab53de | ||
|
|
94ae337e96 | ||
|
|
3dcfdaff5a | ||
|
|
c17311036b | ||
|
|
b76810e883 | ||
|
|
eb8e74273d | ||
|
|
c98d4890eb | ||
|
|
5d3a05ec69 | ||
|
|
cb9a6c4a48 | ||
|
|
ab8334f9ab | ||
|
|
ccd52ba00a |
32
party-stage/.devcontainer/devcontainer.json
Normal 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
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
39
party-stage/index.html
Normal 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
19
party-stage/package.json
Normal 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
@ -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
@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
nix-shell -p nodejs --run "npx vite"
|
||||
38
party-stage/src/core/animate.js
Normal 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();
|
||||
}
|
||||
44
party-stage/src/core/init.js
Normal 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();
|
||||
}
|
||||
34
party-stage/src/core/postprocessing.js
Normal 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);
|
||||
}
|
||||
}
|
||||
170
party-stage/src/core/video-player.js
Normal 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);
|
||||
}
|
||||
22
party-stage/src/effects/EffectsManager.js
Normal 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));
|
||||
}
|
||||
}
|
||||
47
party-stage/src/effects/dust.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
party-stage/src/global-variables.js
Normal 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
@ -0,0 +1,5 @@
|
||||
import * as THREE from 'three';
|
||||
import { init } from './core/init.js';
|
||||
|
||||
// Start everything
|
||||
init();
|
||||
6
party-stage/src/scene/SceneFeature.js
Normal file
@ -0,0 +1,6 @@
|
||||
// SceneFeature.js
|
||||
|
||||
export class SceneFeature {
|
||||
init() {}
|
||||
update(deltaTime) {}
|
||||
}
|
||||
31
party-stage/src/scene/SceneFeatureManager.js
Normal 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;
|
||||
156
party-stage/src/scene/camera-manager.js
Normal 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();
|
||||
207
party-stage/src/scene/dancers.js
Normal 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
@ -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();
|
||||
95
party-stage/src/scene/light-ball.js
Normal 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();
|
||||
238
party-stage/src/scene/music-console.js
Normal 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();
|
||||
129
party-stage/src/scene/music-player.js
Normal 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();
|
||||
40
party-stage/src/scene/music-visualizer.js
Normal 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();
|
||||
300
party-stage/src/scene/party-guests.js
Normal 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();
|
||||
0
party-stage/src/scene/pillar-candles.js
Normal file
324
party-stage/src/scene/projection-screen.js
Normal 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();
|
||||
92
party-stage/src/scene/repro-wall.js
Normal 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();
|
||||
64
party-stage/src/scene/root.js
Normal 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);
|
||||
}
|
||||
141
party-stage/src/scene/stage-lights.js
Normal 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();
|
||||
170
party-stage/src/scene/stage-torches.js
Normal 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();
|
||||
44
party-stage/src/scene/stage.js
Normal 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();
|
||||
91
party-stage/src/scene/wall-curtain.js
Normal 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();
|
||||
63
party-stage/src/shaders/fire-shaders.js
Normal 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);
|
||||
}
|
||||
`;
|
||||
94
party-stage/src/shaders/screen-shaders.js
Normal 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;
|
||||
}
|
||||
`;
|
||||
22
party-stage/src/shaders/vibrant-billboard-shader.js
Normal 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
@ -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
@ -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}`;
|
||||
}
|
||||
BIN
party-stage/textures/dancer1.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
party-stage/textures/floor.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
party-stage/textures/guest1.png
Normal file
|
After Width: | Height: | Size: 663 KiB |
BIN
party-stage/textures/guest2.png
Normal file
|
After Width: | Height: | Size: 752 KiB |
BIN
party-stage/textures/guest3.png
Normal file
|
After Width: | Height: | Size: 661 KiB |
BIN
party-stage/textures/guest4.png
Normal file
|
After Width: | Height: | Size: 850 KiB |
BIN
party-stage/textures/musician1.png
Normal file
|
After Width: | Height: | Size: 841 KiB |
BIN
party-stage/textures/musician2.png
Normal file
|
After Width: | Height: | Size: 909 KiB |
BIN
party-stage/textures/musician3.png
Normal file
|
After Width: | Height: | Size: 899 KiB |
BIN
party-stage/textures/musician4.png
Normal file
|
After Width: | Height: | Size: 914 KiB |
BIN
party-stage/textures/musician5.png
Normal file
|
After Width: | Height: | Size: 945 KiB |
BIN
party-stage/textures/spark.png
Normal file
|
After Width: | Height: | Size: 950 B |
BIN
party-stage/textures/stage_wall.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
party-stage/textures/stone_wall.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
party-stage/textures/tapestry.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
party-stage/textures/wall.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
party-stage/textures/wood.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |