New project: Magic mirror
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import * as THREE from 'three';
|
||||
import { updateDoor } from '../scene/door.js';
|
||||
import { updateVcrDisplay } from '../scene/vcr-display.js';
|
||||
import { state } from '../state.js';
|
||||
import { updateScreenEffect } from '../scene/magic-mirror.js'
|
||||
|
||||
function updateCamera() {
|
||||
const globalTime = Date.now() * 0.00003;
|
||||
const lookAtTime = Date.now() * 0.0002;
|
||||
|
||||
const camAmplitude = 0.2;
|
||||
const lookAmplitude = 0.1;
|
||||
|
||||
// Base Camera Position in front of the TV
|
||||
const baseX = -0.5;
|
||||
const baseY = 1.5;
|
||||
const baseZ = 2.2;
|
||||
|
||||
// Base LookAt target (Center of the screen)
|
||||
const baseTargetX = -0.7;
|
||||
const baseTargetY = 1.7;
|
||||
const baseTargetZ = -0.3;
|
||||
|
||||
// Camera Position Offsets (Drift)
|
||||
const camOffsetX = Math.sin(globalTime * 3.1) * camAmplitude;
|
||||
const camOffsetY = Math.cos(globalTime * 2.5) * camAmplitude * 0.4;
|
||||
const camOffsetZ = Math.cos(globalTime * 3.2) * camAmplitude * 1.4;
|
||||
|
||||
state.camera.position.x = baseX + camOffsetX;
|
||||
state.camera.position.y = baseY + camOffsetY;
|
||||
state.camera.position.z = baseZ + camOffsetZ;
|
||||
|
||||
// LookAt Target Offsets (Subtle Gaze Shift)
|
||||
const lookOffsetX = Math.sin(lookAtTime * 1.5) * lookAmplitude * 3;
|
||||
const lookOffsetY = Math.cos(lookAtTime * 1.2) * lookAmplitude;
|
||||
|
||||
// Apply lookAt to the subtly shifted target
|
||||
state.camera.lookAt(baseTargetX + lookOffsetX, baseTargetY + lookOffsetY, baseTargetZ);
|
||||
}
|
||||
|
||||
function updateLampFlicker() {
|
||||
const flickerChance = 0.995;
|
||||
const restoreRate = 0.15;
|
||||
|
||||
if (state.candleLight) {
|
||||
const elapsedTime = state.clock.getElapsedTime();
|
||||
const flickerSpeed = 20;
|
||||
const flickerAmount = 0.05;
|
||||
// Make the candle flicker
|
||||
state.candleLight.intensity = state.originalLampIntensity + Math.sin(elapsedTime * flickerSpeed) * flickerAmount;
|
||||
}
|
||||
}
|
||||
|
||||
function updateScreenLight() {
|
||||
if (state.isVideoLoaded && state.screenLight.intensity > 0) {
|
||||
const pulseTarget = state.originalScreenIntensity + (Math.random() - 0.5) * state.screenIntensityPulse;
|
||||
state.screenLight.intensity = THREE.MathUtils.lerp(state.screenLight.intensity, pulseTarget, 0.1);
|
||||
|
||||
const lightTime = Date.now() * 0.0001;
|
||||
const radius = 0.01;
|
||||
const centerX = 0;
|
||||
const centerY = 1.5;
|
||||
|
||||
state.screenLight.position.x = centerX + Math.cos(lightTime) * radius;
|
||||
state.screenLight.position.y = centerY + Math.sin(lightTime * 1.5) * radius * 0.5; // Slightly different freq for Y
|
||||
}
|
||||
}
|
||||
|
||||
function updateShaderTime() {
|
||||
if (state.tvScreen && state.tvScreen.material.uniforms && state.tvScreen.material.uniforms.u_time) {
|
||||
if (state.tvScreenPowered) {
|
||||
state.tvScreen.material.uniforms.u_time.value = state.clock.getElapsedTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateVideo() {
|
||||
if (state.videoTexture) {
|
||||
state.videoTexture.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function updateVcr() {
|
||||
const currentTime = state.baseTime + state.videoElement.currentTime;
|
||||
if (Math.abs(currentTime - state.lastUpdateTime) > 0.1) {
|
||||
updateVcrDisplay(currentTime);
|
||||
state.lastUpdateTime = currentTime;
|
||||
}
|
||||
if (currentTime - state.lastBlinkToggleTime > 0.5) { // Blink every 0.5 seconds
|
||||
state.blinkState = !state.blinkState;
|
||||
state.lastBlinkToggleTime = currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
function updateBooks() {
|
||||
const LEVITATE_CHANCE = 0.0003; // Chance for a resting book to start levitating per frame
|
||||
const LEVITATE_DURATION_MIN = 100; // frames
|
||||
const LEVITATE_DURATION_MAX = 300; // frames
|
||||
const LEVITATE_AMPLITUDE = 0.02; // Max vertical displacement
|
||||
const LEVITATE_SPEED_FACTOR = 0.03; // Speed of oscillation
|
||||
const START_RATE = 0.05; // How quickly a book starts to levitate
|
||||
const RETURN_RATE = 0.1; // How quickly a book returns to original position
|
||||
const START_DURATION = 120; // frames for the starting transition
|
||||
const levitation = state.bookLevitation;
|
||||
|
||||
// Manage the global levitation state
|
||||
if (levitation.state === 'resting') {
|
||||
if (Math.random() < LEVITATE_CHANCE) {
|
||||
levitation.state = 'starting';
|
||||
levitation.timer = START_DURATION;
|
||||
}
|
||||
} else if (levitation.state === 'starting') {
|
||||
levitation.timer--;
|
||||
if (levitation.timer <= 0) {
|
||||
levitation.state = 'levitating';
|
||||
levitation.timer = LEVITATE_DURATION_MIN + Math.random() * (LEVITATE_DURATION_MAX - LEVITATE_DURATION_MIN);
|
||||
}
|
||||
} else if (levitation.state === 'levitating') {
|
||||
levitation.timer--;
|
||||
if (levitation.timer <= 0) {
|
||||
levitation.state = 'returning';
|
||||
}
|
||||
}
|
||||
|
||||
// Animate books based on the global state
|
||||
let allBooksReturned = true;
|
||||
state.books.forEach(book => {
|
||||
const data = book.userData;
|
||||
|
||||
if (levitation.state === 'starting') {
|
||||
allBooksReturned = false;
|
||||
book.position.y = THREE.MathUtils.lerp(book.position.y, data.originalY + LEVITATE_AMPLITUDE/2, START_RATE);
|
||||
data.oscillationTime = 0;
|
||||
} else if (levitation.state === 'levitating') {
|
||||
allBooksReturned = false;
|
||||
data.oscillationTime += LEVITATE_SPEED_FACTOR;
|
||||
data.levitateOffset = Math.sin(data.oscillationTime) * LEVITATE_AMPLITUDE;
|
||||
book.position.y = data.originalY + data.levitateOffset + LEVITATE_AMPLITUDE/2;
|
||||
} else if (levitation.state === 'returning') {
|
||||
book.position.y = THREE.MathUtils.lerp(book.position.y, data.originalY, RETURN_RATE);
|
||||
data.levitateOffset = book.position.y - data.originalY;
|
||||
|
||||
if (Math.abs(data.levitateOffset) > 0.001) {
|
||||
allBooksReturned = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (levitation.state === 'returning' && allBooksReturned) {
|
||||
levitation.state = 'resting';
|
||||
}
|
||||
}
|
||||
|
||||
function updatePictureFrame() {
|
||||
state.pictureFrames.forEach((pictureFrame) => {
|
||||
pictureFrame.update();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Animation Loop ---
|
||||
export function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
state.effectsManager.update();
|
||||
updateCamera();
|
||||
updateLampFlicker();
|
||||
updateScreenLight();
|
||||
updateVideo();
|
||||
updateShaderTime();
|
||||
// updateVcr();
|
||||
updateBooks();
|
||||
// updateDoor();
|
||||
// updatePictureFrame();
|
||||
updateScreenEffect();
|
||||
|
||||
// RENDER!
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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 { loadVideoFile, playNextVideo } from './video-player.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 = 65;
|
||||
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);
|
||||
|
||||
// --- 8. Debug Visualization Helpers ---
|
||||
// Visual aids for the light source positions
|
||||
if (state.debugLight && THREE.PointLightHelper) {
|
||||
const screenHelper = new THREE.PointLightHelper(state.screenLight, 0.1, 0xff0000); // Red for screen
|
||||
state.scene.add(screenHelper);
|
||||
|
||||
// Lamp Helper will now work since lampLight is added to the scene
|
||||
const lampHelperPoint = new THREE.PointLightHelper(state.lampLightPoint, 0.1, 0x00ff00); // Green for lamp
|
||||
state.scene.add(lampHelperPoint);
|
||||
}
|
||||
|
||||
// 9. Event Listeners
|
||||
window.addEventListener('resize', onWindowResize, false);
|
||||
state.fileInput.addEventListener('change', loadVideoFile);
|
||||
|
||||
// Button logic
|
||||
state.loadTapeButton.addEventListener('click', () => {
|
||||
state.fileInput.click();
|
||||
});
|
||||
|
||||
// Auto-advance to the next video when the current one finishes.
|
||||
state.videoElement.addEventListener('ended', playNextVideo);
|
||||
|
||||
// Start the animation loop
|
||||
animate();
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../state.js';
|
||||
import { turnTvScreenOff, turnTvScreenOn, setScreenEffect } from '../scene/tv-set.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);
|
||||
}
|
||||
Reference in New Issue
Block a user