Compare commits

..

4 Commits

Author SHA1 Message Date
Dejvino
40952b348a Feature: Improved mirror fade effect 2025-11-20 10:34:47 +01:00
Dejvino
6a2498ef04 Feature: Cauldron handles 2025-11-20 10:04:00 +01:00
Dejvino
499fc006f5 Feature: bubling cauldron 2025-11-19 23:43:52 +01:00
Dejvino
a98c058f3d Fix: beams overlap 2025-11-19 23:25:44 +01:00
9 changed files with 196 additions and 467 deletions

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Magical Crystal Ball</title> <title>Magical Mirror</title>
<style> <style>
/* Cheerful medieval aesthetic */ /* Cheerful medieval aesthetic */

View File

@ -3,6 +3,7 @@ import { updateDoor } from '../scene/door.js';
import { updateVcrDisplay } from '../scene/vcr-display.js'; import { updateVcrDisplay } from '../scene/vcr-display.js';
import { state } from '../state.js'; import { state } from '../state.js';
import { updateScreenEffect } from '../scene/magic-mirror.js' import { updateScreenEffect } from '../scene/magic-mirror.js'
import { updateCauldron } from '../scene/cauldron.js';
import { updateFire } from '../scene/fireplace.js'; import { updateFire } from '../scene/fireplace.js';
function updateCamera() { function updateCamera() {
@ -174,6 +175,7 @@ export function animate() {
// updatePictureFrame(); // updatePictureFrame();
updateScreenEffect(); updateScreenEffect();
updateFire(); updateFire();
updateCauldron();
// RENDER! // RENDER!
state.renderer.render(state.scene, state.camera); state.renderer.render(state.scene, state.camera);

View File

@ -1,6 +1,6 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { state } from '../state.js'; import { state } from '../state.js';
import { turnTvScreenOff, turnTvScreenOn, setScreenEffect } from '../scene/tv-set.js'; import { turnTvScreenOff, turnTvScreenOn } from '../scene/magic-mirror.js';
// --- Play video by index --- // --- Play video by index ---
export function playVideoByIndex(index) { export function playVideoByIndex(index) {

View File

@ -0,0 +1,119 @@
import * as THREE from 'three';
import { state } from '../state.js';
let cauldronParticles;
let cauldronLight;
const particleCount = 8;
const particleVelocities = [];
export function createCauldron(x, y, z) {
const cauldronGroup = new THREE.Group();
const cauldronRadius = 0.2;
const cauldronHeight = 0.25;
// 1. Cauldron Body
const cauldronMaterial = new THREE.MeshPhongMaterial({ color: 0x111111, shininess: 80 });
// Use a sphere geometry cut in half for the bowl shape
const cauldronGeo = new THREE.SphereGeometry(cauldronRadius, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2);
const cauldronMesh = new THREE.Mesh(cauldronGeo, cauldronMaterial);
cauldronMesh.castShadow = true;
cauldronMesh.rotation.z = Math.PI;
cauldronGroup.add(cauldronMesh);
// 1.5. Cauldron Handles
const handleRadius = 0.08;
const handleTube = 0.01;
const handleMaterial = new THREE.MeshPhongMaterial({ color: 0x333333, shininess: 50 });
const handleGeo = new THREE.TorusGeometry(handleRadius, handleTube, 8, 32);
const handleShiftX = 0.07;
const handleShiftY = 0.12;
const leftHandle = new THREE.Mesh(handleGeo, handleMaterial);
leftHandle.rotation.y = Math.PI / 2;
leftHandle.position.set(-cauldronRadius - handleRadius * 0.5 + handleShiftX, cauldronHeight * 0.5 - handleShiftY, 0);
leftHandle.castShadow = true;
cauldronGroup.add(leftHandle);
const rightHandle = new THREE.Mesh(handleGeo, handleMaterial);
rightHandle.rotation.y = -Math.PI / 2;
rightHandle.position.set(cauldronRadius + handleRadius * 0.5 - handleShiftX, cauldronHeight * 0.5 - handleShiftY, 0);
rightHandle.castShadow = true;
cauldronGroup.add(rightHandle);
// 2. Glowing Liquid Surface
const liquidColor = 0x00ff00; // Bright green
const liquidMaterial = new THREE.MeshPhongMaterial({
color: liquidColor,
emissive: liquidColor,
emissiveIntensity: 0.6,
shininess: 100
});
const liquidGeo = new THREE.CircleGeometry(cauldronRadius * 0.95, 32);
const liquidSurface = new THREE.Mesh(liquidGeo, liquidMaterial);
liquidSurface.rotation.x = -Math.PI / 2;
liquidSurface.position.y = -0.01; // Slightly below the rim
cauldronGroup.add(liquidSurface);
// 3. Bubbling Particles
const particleGeo = new THREE.BufferGeometry();
const positions = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount; i++) {
positions[i * 3] = (Math.random() - 0.5) * cauldronRadius * 1.5;
positions[i * 3 + 1] = (Math.random() - 0.5) * 0.05; // Start near the surface
positions[i * 3 + 2] = (Math.random() - 0.5) * cauldronRadius * 1.5;
particleVelocities.push((0.05 + Math.random() * 0.1) / 60); // Random upward velocity
}
particleGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const particleMaterial = new THREE.PointsMaterial({
color: liquidColor,
size: 0.015,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
opacity: 0.7
});
cauldronParticles = new THREE.Points(particleGeo, particleMaterial);
cauldronGroup.add(cauldronParticles);
// 4. Light from the cauldron
cauldronLight = new THREE.PointLight(liquidColor, 0.8, 3);
cauldronLight.position.y = 0.2;
cauldronLight.castShadow = true;
cauldronGroup.add(cauldronLight);
// Position and add to scene
cauldronGroup.position.set(x, y, z);
state.scene.add(cauldronGroup);
}
export function updateCauldron() {
if (!cauldronParticles || !cauldronLight) return;
// Animate Bubbles
const positions = cauldronParticles.geometry.attributes.position.array;
const bubbleMaxHeight = 0.1;
const overfly = Math.random() * bubbleMaxHeight;
for (let i = 0; i < particleCount; i++) {
positions[i * 3 + 1] += particleVelocities[i]; // Move bubble up
// Reset bubble if it goes too high
if (positions[i * 3 + 1] > bubbleMaxHeight + overfly) {
positions[i * 3 + 1] = (Math.random() - 0.5) * 0.05;
// Give it a new random X/Z position
positions[i * 3] = (Math.random() - 0.5) * 0.2 * 1.5;
positions[i * 3 + 2] = (Math.random() - 0.5) * 0.2 * 1.5;
}
}
cauldronParticles.geometry.attributes.position.needsUpdate = true;
// Flicker Light
const flicker = Math.random() * 0.02;
cauldronLight.intensity = 0.1 + flicker;
}

View File

@ -1,167 +0,0 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { screenVertexShader, screenFragmentShader } from '../shaders/screen-shaders.js';
export function createCrystalBall(x, z, rotY) {
// --- Materials ---
const woodMaterial = new THREE.MeshPhongMaterial({ color: 0x5c4033, shininess: 30 });
const standMaterial = new THREE.MeshPhongMaterial({ color: 0x3d2d1d, shininess: 50, specular: 0x444444 });
const ballGroup = new THREE.Group();
// --- 1. Small Pedestal Table ---
const tableHeight = 0.7;
const tableWidth = 1.0;
const tableDepth = 1.0;
const legThickness = 0.08;
// Table Top
const topGeometry = new THREE.BoxGeometry(tableWidth, 0.05, tableDepth);
const tableTop = new THREE.Mesh(topGeometry, woodMaterial);
tableTop.position.y = tableHeight;
tableTop.castShadow = true;
tableTop.receiveShadow = true;
ballGroup.add(tableTop);
// Legs
const legHeight = tableHeight;
const legGeometry = new THREE.BoxGeometry(legThickness, legHeight, legThickness);
const legOffset = (tableWidth / 2) - (legThickness * 1.5);
const depthOffset = (tableDepth / 2) - (legThickness * 1.5);
const createLeg = (lx, lz) => {
const leg = new THREE.Mesh(legGeometry, woodMaterial);
leg.position.set(lx, legHeight / 2, lz);
leg.castShadow = true;
leg.receiveShadow = true;
return leg;
};
ballGroup.add(createLeg(-legOffset, depthOffset));
ballGroup.add(createLeg(legOffset, depthOffset));
ballGroup.add(createLeg(-legOffset, -depthOffset));
ballGroup.add(createLeg(legOffset, -depthOffset));
// --- 2. Crystal Ball Stand ---
const standBaseGeo = new THREE.CylinderGeometry(0.25, 0.35, 0.1, 16);
const standBase = new THREE.Mesh(standBaseGeo, standMaterial);
standBase.position.y = tableHeight + 0.05;
standBase.castShadow = true;
standBase.receiveShadow = true;
ballGroup.add(standBase);
const standNeckGeo = new THREE.CylinderGeometry(0.15, 0.20, 0.2, 12);
const standNeck = new THREE.Mesh(standNeckGeo, standMaterial);
standNeck.position.y = standBase.position.y + 0.15;
standNeck.castShadow = true;
standNeck.receiveShadow = true;
ballGroup.add(standNeck);
// --- 3. The Crystal Ball ---
const ballRadius = 0.35;
const ballGeometry = new THREE.SphereGeometry(ballRadius, 64, 32);
// The 'tvScreen' from state will now be our crystal ball
state.tvScreen = new THREE.Mesh(ballGeometry, new THREE.MeshBasicMaterial({ color: 0x000000 }));
state.tvScreen.position.y = standNeck.position.y + 0.15 + ballRadius * 0.5;
setCrystalBallOffMaterial(); // Set its initial "off" state
ballGroup.add(state.tvScreen);
// --- 4. Light from the Crystal Ball ---
state.screenLight = new THREE.PointLight(0xffffff, 0, 10);
state.screenLight.position.copy(state.tvScreen.position);
state.screenLight.position.y += 0.1; // Position light slightly above the center
state.screenLight.castShadow = true;
state.screenLight.shadow.mapSize.width = 1024;
state.screenLight.shadow.mapSize.height = 1024;
state.screenLight.shadow.camera.near = 0.2;
state.screenLight.shadow.camera.far = 5;
ballGroup.add(state.screenLight);
// Position and rotate the entire group
ballGroup.position.set(x, 0, z);
ballGroup.rotation.y = rotY;
state.scene.add(ballGroup);
}
function setCrystalBallOffMaterial() {
if (state.tvScreen.material) {
state.tvScreen.material.dispose();
}
// A slightly reflective, dark, magical-looking material for when it's off
state.tvScreen.material = new THREE.MeshPhongMaterial({
color: 0x100510,
shininess: 100,
specular: 0xeeeeff,
transparent: true,
opacity: 0.85
});
state.tvScreen.material.needsUpdate = true;
}
export function turnTvScreenOff() {
if (state.tvScreenPowered) {
state.tvScreenPowered = false;
setScreenEffect(2, () => {
setCrystalBallOffMaterial();
state.screenLight.intensity = 0.0;
}); // Trigger power down effect
}
}
export function turnTvScreenOn() {
if (state.tvScreen.material) {
state.tvScreen.material.dispose();
}
// Use the same shader material as the TV for video playback
state.tvScreen.material = new THREE.ShaderMaterial({
uniforms: {
videoTexture: { value: state.videoTexture },
u_effect_type: { value: 0.0 },
u_effect_strength: { value: 0.0 },
},
vertexShader: screenVertexShader,
fragmentShader: screenFragmentShader,
transparent: true,
});
state.tvScreen.material.needsUpdate = true;
if (!state.tvScreenPowered) {
state.tvScreenPowered = true;
setScreenEffect(1); // Trigger power on effect
}
}
export function setScreenEffect(effectType, onComplete) {
const material = state.tvScreen.material;
if (!material.uniforms) return;
state.screenEffect.active = true;
state.screenEffect.type = effectType;
state.screenEffect.startTime = state.clock.getElapsedTime() * 1000;
state.screenEffect.onComplete = onComplete;
}
export function updateScreenEffect() {
if (!state.screenEffect.active) return;
const material = state.tvScreen.material;
if (!material.uniforms) return;
const elapsedTime = (state.clock.getElapsedTime() * 1000) - state.screenEffect.startTime;
const progress = Math.min(elapsedTime / state.screenEffect.duration, 1.0);
const easedProgress = state.screenEffect.easing(progress);
material.uniforms.u_effect_type.value = state.screenEffect.type;
material.uniforms.u_effect_strength.value = easedProgress;
if (progress >= 1.0) {
state.screenEffect.active = false;
material.uniforms.u_effect_strength.value = (state.screenEffect.type === 2) ? 1.0 : 0.0;
if (state.screenEffect.onComplete) {
state.screenEffect.onComplete();
}
material.uniforms.u_effect_type.value = 0.0;
}
}

View File

@ -41,12 +41,27 @@ export function createMagicMirror(x, z, rotY) {
const mirrorRadius = 0.8; // Adjusted radius for scaling const mirrorRadius = 0.8; // Adjusted radius for scaling
const mirrorGeo = new THREE.CircleGeometry(mirrorRadius, 64); const mirrorGeo = new THREE.CircleGeometry(mirrorRadius, 64);
// The 'tvScreen' from state will now be our mirror surface // --- 3a. The permanent reflective mirror surface ---
state.tvScreen = new THREE.Mesh(mirrorGeo, new THREE.MeshBasicMaterial({ color: 0x000000 })); const mirrorBackMaterial = new THREE.MeshPhongMaterial({
state.tvScreen.position.y = 1.4; // Center height color: 0x051020, // Dark blue tint
state.tvScreen.position.z = 0.1; // Slightly forward in the frame shininess: 100,
state.tvScreen.scale.set(1, 1.5, 1); // Scale Y to make it a tall ellipse specular: 0xcccccc,
setMirrorOffMaterial(); // Set its initial "off" state envMap: state.scene.background, // Reflect the room
reflectivity: 0.9 // Increased reflectivity
});
const mirrorBack = new THREE.Mesh(mirrorGeo, mirrorBackMaterial);
mirrorBack.position.y = 1.4; // Center height
mirrorBack.position.z = 0.1; // Slightly forward in the frame
mirrorBack.scale.set(1, 1.5, 1); // Scale Y to make it a tall ellipse
mirrorGroup.add(mirrorBack);
// --- 3b. The video surface that appears when playing ---
// This is what state.tvScreen will now refer to
state.tvScreen = new THREE.Mesh(mirrorGeo, new THREE.MeshBasicMaterial({ transparent: true, opacity: 0 }));
state.tvScreen.position.copy(mirrorBack.position);
state.tvScreen.position.z += 0.01; // Place it just in front of the reflective surface
state.tvScreen.scale.copy(mirrorBack.scale);
state.tvScreen.visible = false; // Start invisible
mirrorGroup.add(state.tvScreen); mirrorGroup.add(state.tvScreen);
// --- 4. Ornate Elliptical Mirror Frame (Torus) --- // --- 4. Ornate Elliptical Mirror Frame (Torus) ---
@ -68,7 +83,7 @@ export function createMagicMirror(x, z, rotY) {
state.screenLight.shadow.mapSize.height = 1024; state.screenLight.shadow.mapSize.height = 1024;
state.screenLight.shadow.camera.near = 0.2; state.screenLight.shadow.camera.near = 0.2;
state.screenLight.shadow.camera.far = 5; state.screenLight.shadow.camera.far = 5;
mirrorGroup.add(state.screenLight); //mirrorGroup.add(state.screenLight);
// Position and rotate the entire group // Position and rotate the entire group
mirrorGroup.position.set(x, 0, z); mirrorGroup.position.set(x, 0, z);
@ -77,26 +92,11 @@ export function createMagicMirror(x, z, rotY) {
state.scene.add(mirrorGroup); state.scene.add(mirrorGroup);
} }
function setMirrorOffMaterial() {
if (state.tvScreen.material) {
state.tvScreen.material.dispose();
}
// A reflective, dark material for when it's off
state.tvScreen.material = new THREE.MeshPhongMaterial({
color: 0x051020, // Dark blue tint
shininess: 100,
specular: 0xcccccc,
envMap: state.scene.background, // Reflect the room
reflectivity: 0.9 // Increased reflectivity
});
state.tvScreen.material.needsUpdate = true;
}
export function turnTvScreenOff() { export function turnTvScreenOff() {
if (state.tvScreenPowered) { if (state.tvScreenPowered) {
state.tvScreenPowered = false; state.tvScreenPowered = false;
setScreenEffect(2, () => { setScreenEffect(2, () => {
setMirrorOffMaterial(); state.tvScreen.visible = false; // Hide the video surface on completion
state.screenLight.intensity = 0.0; state.screenLight.intensity = 0.0;
}); // Trigger power down effect }); // Trigger power down effect
} }
@ -106,6 +106,9 @@ export function turnTvScreenOn() {
if (state.tvScreen.material) { if (state.tvScreen.material) {
state.tvScreen.material.dispose(); state.tvScreen.material.dispose();
} }
state.tvScreen.visible = true; // Make the video surface visible
// Use the shader material for video playback // Use the shader material for video playback
state.tvScreen.material = new THREE.ShaderMaterial({ state.tvScreen.material = new THREE.ShaderMaterial({
uniforms: { uniforms: {

View File

@ -5,6 +5,7 @@ import { createBookshelf } from './bookshelf.js';
import { createMagicMirror } from './magic-mirror.js'; import { createMagicMirror } from './magic-mirror.js';
import { createFireplace } from './fireplace.js'; import { createFireplace } from './fireplace.js';
import { createTable } from './table.js'; import { createTable } from './table.js';
import { createCauldron } from './cauldron.js';
import { PictureFrame } from './PictureFrame.js'; import { PictureFrame } from './PictureFrame.js';
import painting1 from '/textures/painting1.jpg'; import painting1 from '/textures/painting1.jpg';
import painting2 from '/textures/painting2.jpg'; import painting2 from '/textures/painting2.jpg';
@ -78,13 +79,16 @@ export function createSceneObjects() {
createTable(-1.8, 0, -0.8, Math.PI / 2.3); createTable(-1.8, 0, -0.8, Math.PI / 2.3);
// Add cauldron on top of the table (Y = table height + cauldron radius)
createCauldron(-1.8, 0.5 + 0.2, -0.8);
// --- 8. Timber Frames --- // --- 8. Timber Frames ---
const beamThickness = 0.15; const beamThickness = 0.15;
const beamDepth = 0.2; const beamDepth = 0.2;
// Ceiling Beams // Ceiling Beams
const ceilingBeamGeoX = new THREE.BoxGeometry(state.roomSize, beamDepth, beamThickness); const ceilingBeamGeoX = new THREE.BoxGeometry(state.roomSize, beamDepth * 1.3, beamThickness);
const ceilingBeamGeoZ = new THREE.BoxGeometry(beamThickness, beamDepth, state.roomSize); const ceilingBeamGeoZ = new THREE.BoxGeometry(beamThickness, beamDepth * 0.8, state.roomSize);
const createBeam = (geo, pos, rotY = 0) => { const createBeam = (geo, pos, rotY = 0) => {
const beam = new THREE.Mesh(geo, woodMaterial); const beam = new THREE.Mesh(geo, woodMaterial);
@ -121,8 +125,8 @@ export function createSceneObjects() {
createBeam(wallBeamGeo, new THREE.Vector3(state.roomSize / 2 - beamDepth / 2, state.roomHeight / 2, 1.5), -Math.PI / 2); createBeam(wallBeamGeo, new THREE.Vector3(state.roomSize / 2 - beamDepth / 2, state.roomHeight / 2, 1.5), -Math.PI / 2);
// Wall Beams (Horizontal) // Wall Beams (Horizontal)
const wallBeamGeoX = new THREE.BoxGeometry(state.roomSize, beamThickness, beamDepth); const wallBeamGeoX = new THREE.BoxGeometry(state.roomSize, beamThickness, beamDepth * 1.3);
const wallBeamGeoZ = new THREE.BoxGeometry(beamDepth, beamThickness, state.roomSize); const wallBeamGeoZ = new THREE.BoxGeometry(beamDepth * 1.3, beamThickness, state.roomSize);
// Back Wall // Back Wall
createBeam(wallBeamGeoX, new THREE.Vector3(0, state.roomHeight - 0.5, -state.roomSize / 2 + beamDepth / 2)); createBeam(wallBeamGeoX, new THREE.Vector3(0, state.roomHeight - 0.5, -state.roomSize / 2 + beamDepth / 2));

View File

@ -1,250 +0,0 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { createVcr } from './vcr.js';
import { screenVertexShader, screenFragmentShader } from '../shaders/screen-shaders.js';
export function createTvSet(x, z, rotY) {
// --- Materials (MeshPhongMaterial) ---
const tvPlastic = new THREE.MeshPhongMaterial({ color: 0x4d4d4d, shininess: 30 });
const tvGroup = new THREE.Group();
// --- TV Table Dimensions & Material ---
const woodColor = 0x5a3e36; // Dark brown wood
const tableHeight = 0.7; // Height from floor to top surface
const tableWidth = 2.0;
const tableDepth = 1.0;
const legThickness = 0.05;
const shelfThickness = 0.03;
// Use standard material for realistic shadowing
const material = new THREE.MeshStandardMaterial({ color: woodColor, roughness: 0.8, metalness: 0.1 });
// VCR gap dimensions calculation
const shelfGap = 0.2; // Height of the VCR opening
const shelfY = tableHeight - shelfGap - (shelfThickness / 2); // Y position of the bottom shelf
// 2. Table Top
const topGeometry = new THREE.BoxGeometry(tableWidth, shelfThickness, tableDepth);
const tableTop = new THREE.Mesh(topGeometry, material);
tableTop.position.set(0, tableHeight, 0);
tableTop.castShadow = true;
tableTop.receiveShadow = true;
tvGroup.add(tableTop);
// 3. VCR Shelf (Middle Shelf)
const shelfGeometry = new THREE.BoxGeometry(tableWidth, shelfThickness, tableDepth);
const vcrShelf = new THREE.Mesh(shelfGeometry, material);
vcrShelf.position.set(0, shelfY, 0);
vcrShelf.castShadow = true;
vcrShelf.receiveShadow = true;
tvGroup.add(vcrShelf);
// 4. Side Walls for VCR Compartment (NEW CODE)
const wallHeight = shelfGap; // Height is the gap itself
const wallThickness = shelfThickness; // Reuse the shelf thickness for the wall width/depth
const wallGeometry = new THREE.BoxGeometry(wallThickness, wallHeight, tableDepth);
// Calculate the Y center position for the wall
const wallYCenter = tableHeight - (shelfThickness / 2) - (wallHeight / 2);
// Calculate the X position to be flush with the table sides
const wallXPosition = (tableWidth / 2) - (wallThickness / 2);
// Left Wall
const sideWallLeft = new THREE.Mesh(wallGeometry, material);
sideWallLeft.position.set(-wallXPosition, wallYCenter, 0);
sideWallLeft.castShadow = true;
sideWallLeft.receiveShadow = true;
tvGroup.add(sideWallLeft);
// Right Wall
const sideWallRight = new THREE.Mesh(wallGeometry, material);
sideWallRight.position.set(wallXPosition, wallYCenter, 0);
sideWallRight.castShadow = true;
sideWallRight.receiveShadow = true;
tvGroup.add(sideWallRight);
// 5. Legs
const legHeight = shelfY; // Legs go from the floor (y=0) to the shelf (y=shelfY)
const legGeometry = new THREE.BoxGeometry(legThickness, legHeight, legThickness);
// Utility function to create and position a leg
const createLeg = (x, z) => {
const leg = new THREE.Mesh(legGeometry, material);
// Position the leg so the center is at half its height
leg.position.set(x, legHeight / 2, z);
leg.castShadow = true;
leg.receiveShadow = true;
return leg;
};
// Calculate offsets for positioning the legs near the corners
const offset = (tableWidth / 2) - (legThickness * 2);
const depthOffset = (tableDepth / 2) - (legThickness * 2);
// Front Left
tvGroup.add(createLeg(-offset, depthOffset));
// Front Right
tvGroup.add(createLeg(offset, depthOffset));
// Back Left
tvGroup.add(createLeg(-offset, -depthOffset));
// Back Right
tvGroup.add(createLeg(offset, -depthOffset));
// --- 2. The TV box ---
const cabinetGeometry = new THREE.BoxGeometry(1.9, 1.5, 1.0);
const cabinet = new THREE.Mesh(cabinetGeometry, tvPlastic);
cabinet.position.y = 1.51;
cabinet.castShadow = true;
cabinet.receiveShadow = true;
tvGroup.add(cabinet);
// --- 3. Screen Frame ---
const frameGeometry = new THREE.BoxGeometry(1.7, 1.3, 0.1);
const frameMaterial = new THREE.MeshPhongMaterial({ color: 0x111111, shininess: 20 });
const frame = new THREE.Mesh(frameGeometry, frameMaterial);
frame.position.set(0, 1.5, 0.68);
frame.castShadow = true;
frame.receiveShadow = true;
tvGroup.add(frame);
// --- 4. Curved Screen (CRT Effect) ---
const screenRadius = 3.0; // Radius for the subtle curve
const screenWidth = 1.6;
const screenHeight = 1.2;
const thetaLength = screenWidth / screenRadius; // Calculate angle needed for the arc
// Use CylinderGeometry as a segment
const screenGeometry = new THREE.CylinderGeometry(
screenRadius, screenRadius,
screenHeight, // Cylinder height is the vertical dimension of the screen
32,
1,
true,
(Math.PI / 2) - (thetaLength / 2), // Start angle to center the arc
thetaLength // Arc length (width)
);
// Rotate the cylinder segment:
// 1. Rotate around X-axis by 90 degrees to lay the height (Y) along Z (depth).
//screenGeometry.rotateX(Math.PI / 2);
// 2. Rotate around Y-axis by 90 degrees to align the segment's arc across the X-axis (width).
screenGeometry.rotateY(-Math.PI/2);
const screenMaterial = new THREE.MeshBasicMaterial({ color: 0x000000 });
state.tvScreen = new THREE.Mesh(screenGeometry, screenMaterial);
// Position the curved screen
state.tvScreen.position.set(0.0, 1.5, -2.1);
setTvScreenOffMaterial();
tvGroup.add(state.tvScreen);
tvGroup.position.set(x, 0, z);
tvGroup.rotation.y = rotY;
// Light from the screen (initially low intensity, will increase when video loads)
state.screenLight = new THREE.PointLight(0xffffff, 0, 10);
state.screenLight.position.set(0, 1.5, 1.0);
// Screen light casts shadows
state.screenLight.castShadow = true;
state.screenLight.shadow.mapSize.width = 1024;
state.screenLight.shadow.mapSize.height = 1024;
state.screenLight.shadow.camera.near = 0.2;
state.screenLight.shadow.camera.far = 5;
tvGroup.add(state.screenLight);
// -- VCR --
const vcr = createVcr();
vcr.position.set(-0.3, 0.6, 0.05);
tvGroup.add(vcr);
state.scene.add(tvGroup);
}
function setTvScreenOffMaterial() {
if (state.tvScreen.material) {
state.tvScreen.material.dispose();
}
state.tvScreen.material = new THREE.MeshPhongMaterial({
color: 0x203530,
shininess: 45,
specular: 0x111111,
});
state.tvScreen.material.needsUpdate = true;
}
export function turnTvScreenOff() {
if (state.tvScreenPowered) {
state.tvScreenPowered = false;
setScreenEffect(2, () => {
setTvScreenOffMaterial();
state.screenLight.intensity = 0.0;
}); // Trigger power down
}
}
export function turnTvScreenOn() {
if (state.tvScreen.material) {
state.tvScreen.material.dispose();
}
state.tvScreen.material = new THREE.ShaderMaterial({
uniforms: {
videoTexture: { value: state.videoTexture },
u_effect_type: { value: 0.0 },
u_effect_strength: { value: 0.0 },
},
vertexShader: screenVertexShader,
fragmentShader: screenFragmentShader,
transparent: true,
});
state.tvScreen.material.needsUpdate = true;
if (!state.tvScreenPowered) {
state.tvScreenPowered = true;
setScreenEffect(1); // Trigger warm-up
}
}
/**
* Controls the warm-up and power-down effects on the TV screen.
* @param {number} effectType - 0 normal, 1 for warm-up, 2 for power-down.
* @param {function} onComplete - Optional callback when the animation finishes.
*/
export function setScreenEffect(effectType, onComplete) {
const material = state.tvScreen.material;
if (!material.uniforms) return;
state.screenEffect.active = true;
state.screenEffect.type = effectType;
state.screenEffect.startTime = state.clock.getElapsedTime() * 1000;
state.screenEffect.onComplete = onComplete;
}
/**
* Updates the screen effect animation. Should be called in the main render loop.
*/
export function updateScreenEffect() {
if (!state.screenEffect.active) return;
const material = state.tvScreen.material;
if (!material.uniforms) return;
const elapsedTime = (state.clock.getElapsedTime() * 1000) - state.screenEffect.startTime;
const progress = Math.min(elapsedTime / state.screenEffect.duration, 1.0);
const easedProgress = state.screenEffect.easing(progress);
material.uniforms.u_effect_type.value = state.screenEffect.type;
material.uniforms.u_effect_strength.value = easedProgress;
if (progress >= 1.0) {
state.screenEffect.active = false;
material.uniforms.u_effect_strength.value = (state.screenEffect.type === 2) ? 1.0 : 0.0; // Final state
if (state.screenEffect.onComplete) {
state.screenEffect.onComplete();
}
material.uniforms.u_effect_type.value = 0.0; // Reset effect type
}
}

View File

@ -35,40 +35,58 @@ float noise (vec2 st) {
return mix(a, b, u.x) + return mix(a, b, u.x) +
(c - a)* u.y * (1.0 - u.x) + (c - a)* u.y * (1.0 - u.x) +
(d - b) * u.x * u.y; (d - b) * u.x * u.y;
} }
void main() { void main() {
vec4 finalColor; vec4 finalColor;
if (u_effect_type < 0.5) { // No effect // Shimmering edge effect - ALWAYS ON
vec4 videoColor = texture2D(videoTexture, vUv); vec4 videoColor = texture2D(videoTexture, vUv);
// Shimmering edge effect // Shimmering edge effect
float dist = distance(vUv, vec2(0.5)); float dist = distance(vUv, vec2(0.5));
float shimmer = noise(vUv * 20.0 + vec2(u_time * 2.0, 0.0)); float shimmer = noise(vUv * 20.0 + vec2(u_time * 2.0, 0.0));
float edgeFactor = smoothstep(0.3, 0.5, dist); float edgeFactor = smoothstep(0.3, 0.5, dist);
vec3 shimmerColor = vec3(0.7, 0.8, 1.0) * shimmer * edgeFactor * 0.5; vec3 shimmerColor = vec3(0.7, 0.8, 1.0) * shimmer * edgeFactor * 0.5;
finalColor = vec4(videoColor.rgb + shimmerColor, videoColor.a); vec4 baseColor = vec4(videoColor.rgb + shimmerColor, videoColor.a);
} else if (u_effect_type < 1.5) { // "Summon Vision" (Warm-up) effect if (u_effect_type < 0.9) {
// Swirling mist clears to reveal the video // 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 * 10.0); float noiseVal = noise(vUv * 10.0);
float revealFactor = smoothstep(0.0, 0.7, u_effect_strength); vec3 mistColor = vec3(0.8, 0.7, 1.0) * noiseVal;
float mist = smoothstep(revealFactor - 0.2, revealFactor, noiseVal);
vec4 videoColor = texture2D(videoTexture, vUv); vec4 videoColor = texture2D(videoTexture, vUv);
finalColor = mix(vec4(0.8, 0.7, 1.0, 1.0) * noiseVal, videoColor, mist);
// 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 } else { // "Vision Fades" (Power-down) effect
// Video dissolves into a magical fog // Multi-stage effect: Last frame -> fade to mist -> fade to transparent
float noiseVal = noise(vUv * 10.0);
float dissolveFactor = smoothstep(0.3, 1.0, u_effect_strength);
float mist = smoothstep(dissolveFactor - 0.2, dissolveFactor, noiseVal);
float noiseVal = noise(vUv * 10.0);
vec3 mistColor = vec3(0.8, 0.7, 1.0) * noiseVal;
vec4 videoColor = texture2D(videoTexture, vUv); vec4 videoColor = texture2D(videoTexture, vUv);
finalColor = mix(videoColor, vec4(0.8, 0.7, 1.0, 1.0) * noiseVal, mist);
// 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; gl_FragColor = finalColor;