New project: party stage
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
party-stage/src/core/video-player.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { turnTvScreenOff, turnTvScreenOn } from '../scene/magic-mirror.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < 0 || index >= state.videoUrls.length) {
|
||||||
|
console.info('End of playlist reached. Reload tapes to start again.');
|
||||||
|
turnTvScreenOff();
|
||||||
|
state.isVideoLoaded = false;
|
||||||
|
state.lastUpdateTime = -1; // force VCR to redraw
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
||||||
|
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}.`);
|
||||||
|
}).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;
|
||||||
|
if (nextIndex < state.videoUrls.length) {
|
||||||
|
state.baseTime += state.videoElement.duration;
|
||||||
|
}
|
||||||
|
playVideoByIndex(nextIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- 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: 0xffffff,
|
||||||
|
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();
|
||||||
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();
|
||||||
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();
|
||||||
191
party-stage/src/scene/party-guests.js
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { SceneFeature } from './SceneFeature.js';
|
||||||
|
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||||
|
const guestTextureUrls = [
|
||||||
|
'/textures/guest1.png',
|
||||||
|
'/textures/guest2.png',
|
||||||
|
'/textures/guest3.png',
|
||||||
|
'/textures/guest4.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Scene dimensions for positioning ---
|
||||||
|
const stageHeight = 1.5;
|
||||||
|
const stageDepth = 5;
|
||||||
|
const length = 44;
|
||||||
|
|
||||||
|
// --- Billboard Properties ---
|
||||||
|
const guestHeight = 2.5;
|
||||||
|
const guestWidth = 2.5;
|
||||||
|
|
||||||
|
export class PartyGuests extends SceneFeature {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.guests = [];
|
||||||
|
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(guestTextureUrls.map(async (url) => {
|
||||||
|
const texture = await state.loader.loadAsync(url);
|
||||||
|
const processedTexture = processTexture(texture);
|
||||||
|
return new THREE.MeshStandardMaterial({
|
||||||
|
map: processedTexture,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
alphaTest: 0.5,
|
||||||
|
roughness: 0.7,
|
||||||
|
metalness: 0.1,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createGuests = () => {
|
||||||
|
const geometry = new THREE.PlaneGeometry(guestWidth, guestHeight);
|
||||||
|
const numGuests = 80;
|
||||||
|
|
||||||
|
for (let i = 0; i < numGuests; i++) {
|
||||||
|
const material = materials[i % materials.length];
|
||||||
|
const guest = new THREE.Mesh(geometry, material);
|
||||||
|
const pos = new THREE.Vector3(
|
||||||
|
(Math.random() - 0.5) * 10,
|
||||||
|
guestHeight / 2,
|
||||||
|
(Math.random() * 20) - 2 // Position them in the main hall
|
||||||
|
);
|
||||||
|
guest.visible = false; // Start invisible
|
||||||
|
guest.position.copy(pos);
|
||||||
|
state.scene.add(guest);
|
||||||
|
|
||||||
|
this.guests.push({
|
||||||
|
mesh: guest,
|
||||||
|
state: 'WAITING',
|
||||||
|
targetPosition: pos.clone(),
|
||||||
|
waitStartTime: 0,
|
||||||
|
waitTime: 3 + Math.random() * 4, // Wait longer: 3-7 seconds
|
||||||
|
isMirrored: false,
|
||||||
|
canChangePose: true,
|
||||||
|
isJumping: false,
|
||||||
|
jumpStartTime: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createGuests();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime) {
|
||||||
|
if (this.guests.length === 0 || !state.partyStarted) return;
|
||||||
|
|
||||||
|
const cameraPosition = new THREE.Vector3();
|
||||||
|
state.camera.getWorldPosition(cameraPosition);
|
||||||
|
|
||||||
|
const time = state.clock.getElapsedTime();
|
||||||
|
const moveSpeed = 1.0; // Move slower
|
||||||
|
const movementArea = { x: 15, z: 20, y: 0, centerZ: -4 };
|
||||||
|
const jumpChance = 0.05; // Jump way more
|
||||||
|
const jumpDuration = 0.5;
|
||||||
|
const jumpHeight = 0.1;
|
||||||
|
const jumpVariance = 0.5;
|
||||||
|
|
||||||
|
this.guests.forEach(guestObj => {
|
||||||
|
const { mesh } = guestObj;
|
||||||
|
mesh.lookAt(cameraPosition.x, mesh.position.y, cameraPosition.z);
|
||||||
|
|
||||||
|
// --- Mirroring on Beat ---
|
||||||
|
if (state.music) {
|
||||||
|
if (state.music.beatIntensity > 0.8 && guestObj.canChangePose) {
|
||||||
|
guestObj.isMirrored = Math.random() < 0.5;
|
||||||
|
mesh.material.map.repeat.x = guestObj.isMirrored ? -1 : 1;
|
||||||
|
mesh.material.map.offset.x = guestObj.isMirrored ? 1 : 0;
|
||||||
|
guestObj.canChangePose = false;
|
||||||
|
} else if (state.music.beatIntensity < 0.2) {
|
||||||
|
guestObj.canChangePose = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guestObj.state === 'WAITING') {
|
||||||
|
if (time > guestObj.waitStartTime + guestObj.waitTime) {
|
||||||
|
const newTarget = new THREE.Vector3(
|
||||||
|
(Math.random() - 0.5) * movementArea.x,
|
||||||
|
movementArea.y + guestHeight / 2,
|
||||||
|
movementArea.centerZ + (Math.random() - 0.5) * movementArea.z
|
||||||
|
);
|
||||||
|
guestObj.targetPosition = newTarget;
|
||||||
|
guestObj.state = 'MOVING';
|
||||||
|
}
|
||||||
|
} else if (guestObj.state === 'MOVING') {
|
||||||
|
const distance = mesh.position.distanceTo(guestObj.targetPosition);
|
||||||
|
if (distance > 0.1) {
|
||||||
|
const direction = guestObj.targetPosition.clone().sub(mesh.position).normalize();
|
||||||
|
mesh.position.add(direction.multiplyScalar(moveSpeed * deltaTime));
|
||||||
|
} else {
|
||||||
|
guestObj.state = 'WAITING';
|
||||||
|
guestObj.waitStartTime = time;
|
||||||
|
guestObj.waitTime = 3 + Math.random() * 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guestObj.isJumping) {
|
||||||
|
const jumpProgress = (time - guestObj.jumpStartTime) / jumpDuration;
|
||||||
|
if (jumpProgress < 1) {
|
||||||
|
const baseHeight = movementArea.y + guestHeight / 2;
|
||||||
|
mesh.position.y = baseHeight + Math.sin(jumpProgress * Math.PI) * guestObj.jumpHeight;
|
||||||
|
} else {
|
||||||
|
guestObj.isJumping = false;
|
||||||
|
mesh.position.y = movementArea.y + guestHeight / 2;
|
||||||
|
}
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
88
party-stage/src/scene/repro-wall.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
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 = 1.2;
|
||||||
|
const geometry = new THREE.BoxGeometry(boxSize, boxSize, boxSize);
|
||||||
|
|
||||||
|
const cabinetMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x333333,
|
||||||
|
roughness: 0.6,
|
||||||
|
metalness: 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const meshMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x050505,
|
||||||
|
roughness: 0.9,
|
||||||
|
metalness: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const materials = [
|
||||||
|
cabinetMaterial, cabinetMaterial, cabinetMaterial,
|
||||||
|
cabinetMaterial, meshMaterial, cabinetMaterial
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper to create a stack of boxes
|
||||||
|
const createStack = (baseX, baseZ) => {
|
||||||
|
const stackHeight = 3 + Math.floor(Math.random() * 4); // 3 to 6 boxes high
|
||||||
|
for (let i = 0; i < stackHeight; i++) {
|
||||||
|
const box = new THREE.Mesh(geometry, materials);
|
||||||
|
// Slight random offset for realism
|
||||||
|
const x = baseX + (Math.random() * 0.1 - 0.05);
|
||||||
|
const z = baseZ + (Math.random() * 0.1 - 0.05);
|
||||||
|
const y = (i * boxSize) + (boxSize / 2);
|
||||||
|
|
||||||
|
box.position.set(x, y, z);
|
||||||
|
|
||||||
|
// Slight random rotation
|
||||||
|
box.rotation.y = (Math.random() * 0.1 - 0.05);
|
||||||
|
|
||||||
|
box.castShadow = true;
|
||||||
|
box.receiveShadow = true;
|
||||||
|
|
||||||
|
state.scene.add(box);
|
||||||
|
this.boxes.push({ mesh: box, originalScale: new THREE.Vector3(1, 1, 1) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create walls on both sides of the stage
|
||||||
|
const startZ = -20;
|
||||||
|
const endZ = -18;
|
||||||
|
const leftX = -8;
|
||||||
|
const rightX = 8;
|
||||||
|
|
||||||
|
for (let z = startZ; z <= endZ; z += boxSize) {
|
||||||
|
// Left side wall (2 layers deep)
|
||||||
|
createStack(leftX, z);
|
||||||
|
createStack(leftX - boxSize, z);
|
||||||
|
|
||||||
|
// Right side wall (2 layers deep)
|
||||||
|
createStack(rightX, z);
|
||||||
|
createStack(rightX + boxSize, z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
55
party-stage/src/scene/root.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import floorTextureUrl from '/textures/floor.png';
|
||||||
|
import sceneFeatureManager from './SceneFeatureManager.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 { RoseWindowLight } from './rose-window-light.js';
|
||||||
|
import { RoseWindowLightshafts } from './rose-window-lightshafts.js';
|
||||||
|
import { MusicPlayer } from './music-player.js';
|
||||||
|
import { WallCurtain } from './wall-curtain.js';
|
||||||
|
import { ReproWall } from './repro-wall.js';
|
||||||
|
// Scene Features ^^^
|
||||||
|
|
||||||
|
// --- Scene Modeling Function ---
|
||||||
|
export function createSceneObjects() {
|
||||||
|
sceneFeatureManager.init();
|
||||||
|
|
||||||
|
// --- 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, 0.2); // 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);
|
||||||
|
}
|
||||||
66
party-stage/src/scene/rose-window-light.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { SceneFeature } from './SceneFeature.js';
|
||||||
|
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||||
|
|
||||||
|
export class RoseWindowLight extends SceneFeature {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.spotlight = null;
|
||||||
|
this.helper = null;
|
||||||
|
sceneFeatureManager.register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// --- Dimensions for positioning ---
|
||||||
|
const length = 40;
|
||||||
|
const naveHeight = 15;
|
||||||
|
const stageDepth = 5;
|
||||||
|
|
||||||
|
// --- Create the spotlight ---
|
||||||
|
this.spotlight = new THREE.SpotLight(0xffffff, 100.0); // White light, high intensity
|
||||||
|
this.spotlight.position.set(0, naveHeight, -length / 2 + 10); // Position it at the rose window
|
||||||
|
this.spotlight.angle = Math.PI / 9; // A reasonably focused beam
|
||||||
|
this.spotlight.penumbra = 0.3; // Soft edges
|
||||||
|
this.spotlight.decay = 0.7;
|
||||||
|
this.spotlight.distance = 30;
|
||||||
|
|
||||||
|
this.spotlight.castShadow = false;
|
||||||
|
this.spotlight.shadow.mapSize.width = 1024;
|
||||||
|
this.spotlight.shadow.mapSize.height = 1024;
|
||||||
|
this.spotlight.shadow.camera.near = 1;
|
||||||
|
this.spotlight.shadow.camera.far = 30;
|
||||||
|
this.spotlight.shadow.focus = 1;
|
||||||
|
|
||||||
|
// --- Create a target for the spotlight to aim at ---
|
||||||
|
const targetObject = new THREE.Object3D();
|
||||||
|
targetObject.position.set(0, 0, -length / 2 + stageDepth); // Aim at the center of the stage
|
||||||
|
state.scene.add(targetObject);
|
||||||
|
this.spotlight.target = targetObject;
|
||||||
|
|
||||||
|
state.scene.add(this.spotlight);
|
||||||
|
|
||||||
|
// --- Add a debug helper ---
|
||||||
|
if (state.debugLight) {
|
||||||
|
this.helper = new THREE.SpotLightHelper(this.spotlight);
|
||||||
|
state.scene.add(this.helper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime) {
|
||||||
|
if (!this.spotlight) return;
|
||||||
|
|
||||||
|
// Make the light pulse with the music
|
||||||
|
if (state.music) {
|
||||||
|
const baseIntensity = 4.0;
|
||||||
|
this.spotlight.intensity = baseIntensity + state.music.beatIntensity * 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the helper if it exists
|
||||||
|
if (this.helper) {
|
||||||
|
this.helper.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new RoseWindowLight();
|
||||||
157
party-stage/src/scene/rose-window-lightshafts.js
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { SceneFeature } from './SceneFeature.js';
|
||||||
|
import sceneFeatureManager from './SceneFeatureManager.js';
|
||||||
|
|
||||||
|
export class RoseWindowLightshafts extends SceneFeature {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.shafts = [];
|
||||||
|
sceneFeatureManager.register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// --- Dimensions for positioning ---
|
||||||
|
const length = 40;
|
||||||
|
const naveWidth = 12;
|
||||||
|
const naveHeight = 15;
|
||||||
|
const stageDepth = 5;
|
||||||
|
const stageWidth = naveWidth - 1;
|
||||||
|
|
||||||
|
const roseWindowRadius = naveWidth / 2 - 2;
|
||||||
|
const roseWindowCenter = new THREE.Vector3(0, naveHeight, -length / 2 - 1.1);
|
||||||
|
|
||||||
|
// --- Procedural Noise Texture for Light Shafts ---
|
||||||
|
const createNoiseTexture = () => {
|
||||||
|
const width = 128;
|
||||||
|
const height = 512;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
const imageData = context.createImageData(width, height);
|
||||||
|
const data = imageData.data;
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
// Create vertical streaks of noise
|
||||||
|
const y = Math.floor((i / 4) / width);
|
||||||
|
const noise = Math.pow(Math.random(), 2.5) * (1 - y / height) * 255;
|
||||||
|
data[i] = noise; // R
|
||||||
|
data[i + 1] = noise; // G
|
||||||
|
data[i + 2] = noise; // B
|
||||||
|
data[i + 3] = 255; // A
|
||||||
|
}
|
||||||
|
context.putImageData(imageData, 0, 0);
|
||||||
|
return new THREE.CanvasTexture(canvas);
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
//map: texture,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
opacity: 1.0,
|
||||||
|
color: 0x88aaff, // Give the light a cool blueish tint
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Create multiple thin light shafts ---
|
||||||
|
const numShafts = 16;
|
||||||
|
for (let i = 0; i < numShafts; i++) {
|
||||||
|
const texture = createNoiseTexture();
|
||||||
|
texture.wrapS = THREE.RepeatWrapping;
|
||||||
|
texture.wrapT = THREE.RepeatWrapping;
|
||||||
|
const material = baseMaterial.clone(); // Each shaft needs its own material for individual opacity
|
||||||
|
material.map = texture;
|
||||||
|
|
||||||
|
const startAngle = Math.random() * Math.PI * 2;
|
||||||
|
const startRadius = Math.random() * roseWindowRadius;
|
||||||
|
const startPoint = new THREE.Vector3(
|
||||||
|
roseWindowCenter.x + Math.cos(startAngle) * startRadius,
|
||||||
|
roseWindowCenter.y + Math.sin(startAngle) * startRadius,
|
||||||
|
roseWindowCenter.z
|
||||||
|
);
|
||||||
|
|
||||||
|
// Define a linear path on the floor for the beam to travel
|
||||||
|
const floorStartPoint = new THREE.Vector3(
|
||||||
|
(Math.random() - 0.5) * stageWidth * 0.75,
|
||||||
|
0,
|
||||||
|
-length / 2 + Math.random() * 8 + 0
|
||||||
|
);
|
||||||
|
const floorEndPoint = new THREE.Vector3(
|
||||||
|
(Math.random() - 0.5) * stageWidth * 0.75,
|
||||||
|
0,
|
||||||
|
-length / 2 + Math.random() * 8 + 3
|
||||||
|
);
|
||||||
|
|
||||||
|
const distance = startPoint.distanceTo(floorStartPoint);
|
||||||
|
const geometry = new THREE.CylinderGeometry(0.01, 0.5 + Math.random() * 0.5, distance, 16, 1, true);
|
||||||
|
const lightShaft = new THREE.Mesh(geometry, material);
|
||||||
|
|
||||||
|
state.scene.add(lightShaft);
|
||||||
|
this.shafts.push({
|
||||||
|
mesh: lightShaft,
|
||||||
|
startPoint: startPoint, // The stationary point in the window
|
||||||
|
endPoint: floorStartPoint.clone(), // The current position of the beam on the floor
|
||||||
|
floorStartPoint: floorStartPoint, // The start of the sweep path
|
||||||
|
floorEndPoint: floorEndPoint, // The end of the sweep path
|
||||||
|
moveSpeed: 0.01 + Math.random() * 0.5, // Each shaft has a different speed
|
||||||
|
// No 'state' needed anymore
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime) {
|
||||||
|
const baseOpacity = 0.1;
|
||||||
|
|
||||||
|
this.shafts.forEach(shaft => {
|
||||||
|
const { mesh, startPoint, endPoint, floorStartPoint, floorEndPoint, moveSpeed } = shaft;
|
||||||
|
|
||||||
|
// Animate texture for dust motes
|
||||||
|
mesh.material.map.offset.y += deltaTime * 0.004;
|
||||||
|
mesh.material.map.offset.x -= deltaTime * 0.02;
|
||||||
|
|
||||||
|
if (mesh.material.map.offset.y >= 0.2) {
|
||||||
|
mesh.material.map.offset.y -= 0.2;
|
||||||
|
}
|
||||||
|
if (mesh.material.map.offset.x <= 0.0) {
|
||||||
|
mesh.material.map.offset.x += 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Movement Logic ---
|
||||||
|
const pathDirection = floorEndPoint.clone().sub(floorStartPoint).normalize();
|
||||||
|
const pathLength = floorStartPoint.distanceTo(floorEndPoint);
|
||||||
|
|
||||||
|
// Move the endpoint along its path
|
||||||
|
endPoint.add(pathDirection.clone().multiplyScalar(moveSpeed * deltaTime));
|
||||||
|
|
||||||
|
const currentDistance = floorStartPoint.distanceTo(endPoint);
|
||||||
|
|
||||||
|
if (currentDistance >= pathLength) {
|
||||||
|
// Reached the end, reset to the start
|
||||||
|
endPoint.copy(floorStartPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Opacity based on Progress ---
|
||||||
|
const progress = Math.min(currentDistance / pathLength, 1.0);
|
||||||
|
// Use a sine curve to fade in at the start and out at the end
|
||||||
|
const fadeOpacity = Math.sin(progress * Math.PI) * baseOpacity;
|
||||||
|
|
||||||
|
// --- Update Mesh Position and Orientation ---
|
||||||
|
const distance = startPoint.distanceTo(endPoint);
|
||||||
|
mesh.scale.y = -distance/5;
|
||||||
|
mesh.position.lerpVectors(startPoint, endPoint, 0.5);
|
||||||
|
|
||||||
|
const quaternion = new THREE.Quaternion();
|
||||||
|
const cylinderUp = new THREE.Vector3(0, 1, 0);
|
||||||
|
const direction = new THREE.Vector3().subVectors(endPoint, startPoint).normalize();
|
||||||
|
quaternion.setFromUnitVectors(cylinderUp, direction);
|
||||||
|
mesh.quaternion.copy(quaternion);
|
||||||
|
|
||||||
|
// --- Music Visualization ---
|
||||||
|
const beatPulse = state.music ? state.music.beatIntensity * 0.05 : 0;
|
||||||
|
mesh.material.opacity = fadeOpacity + beatPulse;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new RoseWindowLightshafts();
|
||||||
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 = 7;
|
||||||
|
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 = -20;
|
||||||
|
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: 840 KiB |
BIN
party-stage/textures/guest2.png
Normal file
|
After Width: | Height: | Size: 823 KiB |
BIN
party-stage/textures/guest3.png
Normal file
|
After Width: | Height: | Size: 856 KiB |
BIN
party-stage/textures/guest4.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
party-stage/textures/musician1.png
Normal file
|
After Width: | Height: | Size: 752 KiB |
BIN
party-stage/textures/musician2.png
Normal file
|
After Width: | Height: | Size: 878 KiB |
BIN
party-stage/textures/musician3.png
Normal file
|
After Width: | Height: | Size: 819 KiB |
BIN
party-stage/textures/musician4.png
Normal file
|
After Width: | Height: | Size: 906 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.5 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 |