From 3a7251e185f7e60b1a79a1a079fe529afc58b015 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Sat, 3 Jan 2026 21:42:55 +0000 Subject: [PATCH] Feature: stage lasers --- party-stage/src/scene/root.js | 1 + party-stage/src/scene/stage-lasers.js | 209 ++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 party-stage/src/scene/stage-lasers.js diff --git a/party-stage/src/scene/root.js b/party-stage/src/scene/root.js index cc0c2a9..aa8554c 100644 --- a/party-stage/src/scene/root.js +++ b/party-stage/src/scene/root.js @@ -17,6 +17,7 @@ import { StageLights } from './stage-lights.js'; import { MusicConsole } from './music-console.js'; import { DJ } from './dj.js'; import { ProjectionScreen } from './projection-screen.js'; +import { StageLasers } from './stage-lasers.js'; // Scene Features ^^^ // --- Scene Modeling Function --- diff --git a/party-stage/src/scene/stage-lasers.js b/party-stage/src/scene/stage-lasers.js new file mode 100644 index 0000000..cba562c --- /dev/null +++ b/party-stage/src/scene/stage-lasers.js @@ -0,0 +1,209 @@ +import * as THREE from 'three'; +import { state } from '../state.js'; +import { SceneFeature } from './SceneFeature.js'; +import sceneFeatureManager from './SceneFeatureManager.js'; + +export class StageLasers extends SceneFeature { + constructor() { + super(); + this.lasers = []; + this.pattern = 0; + this.lastPatternChange = 0; + this.averageLoudness = 0; + this.activationState = 'IDLE'; + this.stateTimer = 0; + this.initialSilenceSeconds = 10; + sceneFeatureManager.register(this); + } + + init() { + // Geometry: Long thin cylinder, pivot at one end + const length = 80; + const geometry = new THREE.CylinderGeometry(0.03, 0.03, length, 8); + geometry.rotateX(-Math.PI / 2); // Align with Z axis + geometry.translate(0, 0, length / 2); // Pivot at start + + // Material: Additive blending for light beam effect + const material = new THREE.MeshBasicMaterial({ + color: 0x00ff00, + transparent: true, + opacity: 0.3, + blending: THREE.AdditiveBlending, + depthWrite: false, + side: THREE.DoubleSide + }); + + this.sharedGeometry = geometry; + this.sharedMaterial = material; + + // Fixture assets + this.fixtureGeometry = new THREE.BoxGeometry(0.2, 0.2, 0.3); + this.fixtureMaterial = new THREE.MeshStandardMaterial({ + color: 0x111111, + roughness: 0.7, + metalness: 0.2 + }); + + // Create Banks of Lasers + // 1. Left + this.createBank(new THREE.Vector3(-7, 8.2, -18), 3, 0.4, 0.3); + // 2. Right + this.createBank(new THREE.Vector3(7, 8.2, -18), 3, 0.4, -0.3); + // 3. Center + this.createBank(new THREE.Vector3(0, 8.5, -16), 8, 0.5, 0); + } + + createBank(position, count, spacing, angleOffsetY) { + const group = new THREE.Group(); + group.position.copy(position); + // Rotate bank slightly to face center/audience + group.rotation.y = angleOffsetY; + state.scene.add(group); + + // --- Connecting Bar --- + const barWidth = (count * spacing) + 0.2; + const barGeo = new THREE.BoxGeometry(barWidth, 0.1, 0.1); + const barMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.7, metalness: 0.5 }); + const bar = new THREE.Mesh(barGeo, barMat); + bar.position.set(0, 0, -0.25); // Behind the fixtures + group.add(bar); + + for (let i = 0; i < count; i++) { + const mesh = new THREE.Mesh(this.sharedGeometry, this.sharedMaterial.clone()); + // Center the bank + const xOff = (i - (count - 1) / 2) * spacing; + mesh.position.set(xOff, 0, 0); + + // Add Static Fixture + const fixture = new THREE.Mesh(this.fixtureGeometry, this.fixtureMaterial); + fixture.position.set(xOff, 0, -0.15); + fixture.castShadow = true; + group.add(fixture); + + // Add a source flare + const flare = new THREE.Mesh( + new THREE.SphereGeometry(0.06, 8, 8), + new THREE.MeshBasicMaterial({ color: 0xffffff }) + ); + mesh.add(flare); + + group.add(mesh); + + this.lasers.push({ + mesh: mesh, + flare: flare, + index: i, + totalInBank: count, + bankId: position.x < 0 ? 0 : (position.x > 0 ? 1 : 2) // 0:L, 1:R, 2:C + }); + } + } + + update(deltaTime) { + if (!state.partyStarted) { + this.lasers.forEach(l => l.mesh.visible = false); + return; + } + + const time = state.clock.getElapsedTime(); + + // --- Loudness Check --- + let isActive = false; + if (state.music) { + const loudness = state.music.loudness || 0; + // Update running average + this.averageLoudness = THREE.MathUtils.lerp(this.averageLoudness, loudness, deltaTime * 0.2); + + if (this.activationState === 'IDLE') { + // Wait for song to pick up before first activation + if (time > this.initialSilenceSeconds && loudness > this.averageLoudness + 0.1) { + this.activationState = 'ACTIVE'; + this.stateTimer = 4.0; // Active duration + } + } else if (this.activationState === 'ACTIVE') { + isActive = true; + this.stateTimer -= deltaTime; + if (this.stateTimer <= 0) { + this.activationState = 'COOLDOWN'; + this.stateTimer = 4.0; // Cooldown duration + } + } else if (this.activationState === 'COOLDOWN') { + this.stateTimer -= deltaTime; + if (this.stateTimer <= 0) { + this.activationState = 'IDLE'; + } + } + } + + if (!isActive) { + this.lasers.forEach(l => l.mesh.visible = false); + return; + } + + // --- Pattern Logic --- + if (time - this.lastPatternChange > 8) { // Change every 8 seconds + this.pattern = (this.pattern + 1) % 4; + this.lastPatternChange = time; + } + + // --- Color & Intensity --- + const beat = state.music ? state.music.beatIntensity : 0; + const hue = (time * 0.1) % 1; + const color = new THREE.Color().setHSL(hue, 1.0, 0.5); + let intensity = 0.2 + beat * 0.6; + + // Strobe Mode: Flash rapidly when beat intensity is high + if (beat > 0.7) { + // Rapid on/off based on time (approx 15Hz) + if (Math.sin(time * 100) < 0) { + intensity = 0.05; + } else { + intensity = 1.0; + } + } + + this.lasers.forEach(l => { + l.mesh.visible = true; + l.mesh.material.color.copy(color); + l.mesh.material.opacity = intensity; + l.flare.material.color.copy(color); + + // --- Movement Calculation --- + let yaw = 0; + let pitch = 0; + const t = time * 2.0; + const idx = l.index; + + switch (this.pattern) { + case 0: // Lissajous / Figure 8 + yaw = Math.sin(t + idx * 0.2) * 0.5; + pitch = Math.cos(t * 1.5 + idx * 0.2) * 0.3; + break; + case 1: // Horizontal Scan / Wave + yaw = Math.sin(t * 2 + idx * 0.5) * 0.8; + pitch = Math.sin(t * 0.5) * 0.1; + break; + case 2: // Tunnel / Circle + const offset = (Math.PI * 2 / l.totalInBank) * idx; + const radius = 0.4; + yaw = Math.cos(t + offset) * radius; + pitch = Math.sin(t + offset) * radius; + break; + case 3: // Chaos / Random + yaw = Math.sin(t * 3 + idx) * 0.6; + pitch = Math.cos(t * 2.5 + idx * 2) * 0.4; + break; + } + + // Apply rotation + // Default points +Z. + l.mesh.rotation.set(pitch, yaw, 0); + }); + } + + onPartyEnd() { + this.lasers.forEach(l => l.mesh.visible = false); + } +} + +new StageLasers(); \ No newline at end of file