New project: party stage

This commit is contained in:
Dejvino 2025-12-29 22:54:39 +00:00
parent 87a5153fe2
commit ccd52ba00a
54 changed files with 3393 additions and 0 deletions

View File

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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);
}

View File

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

View File

@ -0,0 +1,47 @@
import * as THREE from 'three';
export class DustEffect {
constructor(scene) {
this.dust = null;
this._create(scene);
}
_create(scene) {
const particleCount = 3000;
const particlesGeometry = new THREE.BufferGeometry();
const positions = [];
for (let i = 0; i < particleCount; i++) {
positions.push(
(Math.random() - 0.5) * 15,
Math.random() * 8,
(Math.random() - 0.5) * 45
);
}
particlesGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const particleMaterial = new THREE.PointsMaterial({
color: 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;
}
}
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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();

View File

View 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();

View 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);
}

View 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();

View 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();

View File

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

View File

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

View File

@ -0,0 +1,91 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { SceneFeature } from './SceneFeature.js';
import sceneFeatureManager from './SceneFeatureManager.js';
import curtainTextureUrl from '/textures/tapestry.png';
export class WallCurtain extends SceneFeature {
constructor() {
super();
this.curtains = [];
this.waving = true;
sceneFeatureManager.register(this);
}
init() {
// --- Curtain Properties ---
const naveWidth = 12;
const naveHeight = 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();

View File

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

View File

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

View File

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long