New project: Magic mirror

This commit is contained in:
Dejvino
2025-11-19 22:11:10 +01:00
parent 65ddd80f1d
commit da94aa0aa3
37 changed files with 3618 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
import * as THREE from 'three';
const FRAME_DEPTH = 0.05;
const TRANSITION_DURATION = 5000;
const IMAGE_CHANGE_CHANCE = 0.0001;
export class PictureFrame {
constructor(scene, { position, width, height, imageUrls, rotationY = 0 }) {
if (!imageUrls || imageUrls.length === 0) {
throw new Error('PictureFrame requires at least one image URL in the imageUrls array.');
}
this.scene = scene;
this.mesh = this._createPictureFrame(width, height, imageUrls, 0.05);
this.mesh.position.copy(position);
this.mesh.rotation.y = rotationY;
this.isTransitioning = false;
this.transitionStartTime = 0;
this.scene.add(this.mesh);
}
_createPictureFrame(width, height, imageUrls, frameThickness) {
const paintingGroup = new THREE.Group();
// 1. Create the wooden frame
const frameMaterial = new THREE.MeshPhongMaterial({ color: 0x8B4513 }); // SaddleBrown
const topFrame = new THREE.Mesh(new THREE.BoxGeometry(width + 2 * frameThickness, frameThickness, FRAME_DEPTH), frameMaterial);
topFrame.position.y = height / 2 + frameThickness / 2;
topFrame.castShadow = true;
topFrame.receiveShadow = true;
paintingGroup.add(topFrame);
const bottomFrame = new THREE.Mesh(new THREE.BoxGeometry(width + 2 * frameThickness, frameThickness, FRAME_DEPTH), frameMaterial);
bottomFrame.position.y = -height / 2 - frameThickness / 2;
bottomFrame.castShadow = true;
bottomFrame.receiveShadow = true;
paintingGroup.add(bottomFrame);
const leftFrame = new THREE.Mesh(new THREE.BoxGeometry(frameThickness, height, FRAME_DEPTH), frameMaterial);
leftFrame.position.x = -width / 2 - frameThickness / 2;
leftFrame.castShadow = true;
leftFrame.receiveShadow = true;
paintingGroup.add(leftFrame);
const rightFrame = new THREE.Mesh(new THREE.BoxGeometry(frameThickness, height, FRAME_DEPTH), frameMaterial);
rightFrame.position.x = width / 2 + frameThickness / 2;
rightFrame.castShadow = true;
rightFrame.receiveShadow = true;
paintingGroup.add(rightFrame);
// 2. Create the picture canvases with textures
const textureLoader = new THREE.TextureLoader();
this.textures = imageUrls.map(url => textureLoader.load(url));
this.currentTextureIndex = 0;
const pictureGeometry = new THREE.PlaneGeometry(width, height);
// Create two picture planes for cross-fading
this.pictureBack = new THREE.Mesh(pictureGeometry, new THREE.MeshPhongMaterial({ map: this.textures[this.currentTextureIndex] }));
this.pictureBack.position.z = 0.001;
this.pictureBack.receiveShadow = true;
paintingGroup.add(this.pictureBack);
this.pictureFront = new THREE.Mesh(pictureGeometry, new THREE.MeshPhongMaterial({ map: this.textures[this.currentTextureIndex], transparent: true, opacity: 0 }));
this.pictureFront.position.z = 0.003; // Place slightly in front to avoid z-fighting
this.pictureFront.receiveShadow = true;
paintingGroup.add(this.pictureFront);
return paintingGroup;
}
setPicture(index) {
if (this.isTransitioning || index === this.currentTextureIndex || index < 0 || index >= this.textures.length) {
return;
}
this.isTransitioning = true;
this.transitionStartTime = Date.now();
// Front plane fades in with the new texture
this.pictureFront.material.map = this.textures[index];
this.pictureFront.material.opacity = 0;
this.nextTextureIndex = index;
}
nextPicture() {
this.setPicture((this.currentTextureIndex + 1) % this.textures.length);
}
update() {
if (!this.isTransitioning) {
if (Math.random() > 1.0 - IMAGE_CHANGE_CHANCE) {
this.nextPicture();
}
return;
}
const elapsedTime = Date.now() - this.transitionStartTime;
const progress = Math.min(elapsedTime / TRANSITION_DURATION, 1.0);
this.pictureFront.material.opacity = progress;
if (progress >= 1.0) {
this.isTransitioning = false;
this.currentTextureIndex = this.nextTextureIndex;
// Reset for next transition
this.pictureBack.material.map = this.textures[this.currentTextureIndex];
this.pictureFront.material.opacity = 0;
}
}
}
+124
View File
@@ -0,0 +1,124 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { getRandomColor, seededRandom } from '../utils.js';
export function createBookshelf(x, z, rotationY, uniqueSeed) {
state.seed = uniqueSeed; // Reset seed for this specific shelf instance
const shelfHeight = 2.2;
const shelfDepth = 0.35;
const shelfWidth = 1.2;
const numShelves = 6;
const woodThickness = 0.04;
const woodColor = 0x5c4033; // Darker, richer wood
const shelfGroup = new THREE.Group();
shelfGroup.position.set(x, 0, z);
shelfGroup.rotation.y = rotationY;
const woodMaterial = new THREE.MeshPhongMaterial({ color: woodColor, shininess: 30 });
// 1. Build Frame (Hollow box)
// Back Panel
const backGeo = new THREE.BoxGeometry(shelfWidth, shelfHeight, woodThickness);
const backPanel = new THREE.Mesh(backGeo, woodMaterial);
backPanel.position.set(0, shelfHeight / 2, -shelfDepth / 2 + woodThickness / 2);
backPanel.castShadow = true;
backPanel.receiveShadow = true;
shelfGroup.add(backPanel);
// Side Panels (Left & Right)
const sideGeo = new THREE.BoxGeometry(woodThickness, shelfHeight, shelfDepth);
const leftSide = new THREE.Mesh(sideGeo, woodMaterial);
leftSide.position.set(-shelfWidth / 2 + woodThickness / 2, shelfHeight / 2, 0);
leftSide.castShadow = true;
leftSide.receiveShadow = true;
shelfGroup.add(leftSide);
const rightSide = new THREE.Mesh(sideGeo, woodMaterial);
rightSide.position.set(shelfWidth / 2 - woodThickness / 2, shelfHeight / 2, 0);
rightSide.castShadow = true;
rightSide.receiveShadow = true;
shelfGroup.add(rightSide);
// Top & Bottom Panels
const topBottomGeo = new THREE.BoxGeometry(shelfWidth, woodThickness, shelfDepth);
const bottomPanel = new THREE.Mesh(topBottomGeo, woodMaterial);
bottomPanel.position.set(0, woodThickness / 2, 0);
bottomPanel.receiveShadow = true;
shelfGroup.add(bottomPanel);
const topPanel = new THREE.Mesh(topBottomGeo, woodMaterial);
topPanel.position.set(0, shelfHeight - woodThickness / 2, 0);
topPanel.castShadow = true;
shelfGroup.add(topPanel);
state.landingSurfaces.push(topPanel);
// 2. Individual Shelves & Books
const internalHeight = shelfHeight - (2 * woodThickness);
const shelfSpacing = internalHeight / numShelves;
const internalWidth = shelfWidth - (2 * woodThickness);
for (let i = 0; i < numShelves; i++) {
const currentShelfY = woodThickness + (i * shelfSpacing);
// Shelf board (skip for the very bottom one as we have a bottom panel)
if (i > 0) {
const shelfBoard = new THREE.Mesh(
new THREE.BoxGeometry(internalWidth, woodThickness, shelfDepth - woodThickness), // Slightly shallower to fit inside back panel
woodMaterial
);
shelfBoard.position.set(0, currentShelfY, woodThickness / 2); // Offset forward slightly
shelfBoard.castShadow = true;
shelfBoard.receiveShadow = true;
shelfGroup.add(shelfBoard);
}
// 3. Procedural Books
let currentBookX = -internalWidth / 2 + 0.01; // Start at left inside edge
const shelfSurfaceY = currentShelfY + woodThickness / 2;
while (currentBookX < internalWidth / 2 - 0.05) {
// sizes vary
const bookWidth = 0.02 + seededRandom() * 0.05;
const bookHeight = (shelfSpacing * 0.6) + seededRandom() * (shelfSpacing * 0.1);
const bookDepth = 0.15 + seededRandom() * 0.03;
if (currentBookX + bookWidth > internalWidth / 2) break;
const bookColor = getRandomColor();
const bookMat = new THREE.MeshPhongMaterial({ color: bookColor, shininess: 60 });
const bookGeo = new THREE.BoxGeometry(bookWidth, bookHeight, bookDepth);
const book = new THREE.Mesh(bookGeo, bookMat);
// Position: Resting on shelf, pushed towards the back with slight random variation
const depthVariation = seededRandom() * 0.05;
book.position.set(
currentBookX + bookWidth / 2,
shelfSurfaceY + bookHeight / 2,
-shelfDepth / 2 + woodThickness + bookDepth / 2 + depthVariation
);
book.castShadow = true;
book.receiveShadow = true;
// Store original Y position and animation data
book.userData.originalY = book.position.y;
book.userData.levitateOffset = 0;
book.userData.oscillationTime = Math.random() * Math.PI * 2; // Start at random phase
shelfGroup.add(book);
if (Math.random() > 0.8) {
state.books.push(book);
}
currentBookX += bookWidth + 0.002; // Tiny gap between books
if (seededRandom() > 0.92) {
currentBookX += bookWidth * 3; // random bigger gaps
}
}
}
state.scene.add(shelfGroup);
}
+167
View File
@@ -0,0 +1,167 @@
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;
}
}
+120
View File
@@ -0,0 +1,120 @@
import * as THREE from 'three';
import { state } from '../state.js';
let doorGroupPanel;
let outsideMaterial; // Declare outsideMaterial globally
let glowIntensity = 0.0;
let glowDirection = 1; // 1 for increasing, -1 for decreasing
const DOOR_STATES = {
RESTING: 'resting',
OPENING: 'opening',
CLOSING: 'closing',
};
let doorState = DOOR_STATES.RESTING;
let stateTimer = 0;
export function createDoor(x, z, rotY) {
const doorWidth = 1;
const doorGroup = new THREE.Group();
doorGroup.position.set(x, 1.1, z); // Centered vertically for a 2.2m door
doorGroup.rotation.set(0, rotY, 0);
// Door Frame
const frameMaterial = new THREE.MeshPhongMaterial({ color: 0x473e3a }); // Dark wood for frame
const frameTop = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.1, 0.15), frameMaterial);
frameTop.position.set(0, 1.15, 0);
frameTop.castShadow = true;
doorGroup.add(frameTop);
const frameLeft = new THREE.Mesh(new THREE.BoxGeometry(0.1, 2.3, 0.15), frameMaterial);
frameLeft.position.set(-0.55, 0.05, 0);
frameLeft.castShadow = true;
doorGroup.add(frameLeft);
const frameRight = new THREE.Mesh(new THREE.BoxGeometry(0.1, 2.3, 0.15), frameMaterial);
frameRight.position.set(0.55, 0.05, 0);
frameRight.castShadow = true;
doorGroup.add(frameRight);
// Outside darkness
outsideMaterial = new THREE.MeshPhongMaterial({ color: 0x150505, emissive: 0x000000, shininess: 50 });
const outside = new THREE.Mesh(new THREE.BoxGeometry(doorWidth, 2.2, 0.04), outsideMaterial);
outside.position.set(0, 0, 0);
doorGroup.add(outside);
// Door group
doorGroupPanel = new THREE.Group();
// Main Door Panel
const doorMaterial = new THREE.MeshPhongMaterial({ color: 0x8b5a2b, shininess: 10 }); // Lighter wood for door
const door = new THREE.Mesh(new THREE.BoxGeometry(doorWidth, 2.2, 0.08), doorMaterial);
door.position.set(doorWidth/2, 0, 0);
door.castShadow = true;
door.receiveShadow = true;
doorGroupPanel.add(door);
// Door Knob
const knobMaterial = new THREE.MeshPhongMaterial({ color: 0xd4af37, shininess: 100 }); // Gold/Brass
const knob = new THREE.Mesh(new THREE.SphereGeometry(0.05, 16, 16), knobMaterial);
knob.position.set(doorWidth/2 + 0.4, 0, 0.06); // Position on the right side of the door
knob.castShadow = true;
doorGroupPanel.add(knob);
doorGroupPanel.position.x = -doorWidth/2;
doorGroupPanel.rotation.y = 0;
doorGroup.add(doorGroupPanel);
state.scene.add(doorGroup);
}
export function updateDoor() {
const speed = 0.0002;
const minAngle = 0;
const maxAngle = -0.7;
stateTimer -= 1 / 60; // Assuming 60fps
if (stateTimer <= 0) {
const nextState = Math.random();
if (nextState < 0.1) {
doorState = DOOR_STATES.RESTING;
stateTimer = 2 + Math.random() * 5;
} else if (nextState < 0.5) {
doorState = DOOR_STATES.OPENING;
stateTimer = 2 + Math.random() * 5;
} else {
doorState = DOOR_STATES.CLOSING;
stateTimer = 3 + Math.random() * 2;
}
}
switch (doorState) {
case DOOR_STATES.OPENING:
if (doorGroupPanel.rotation.y > maxAngle) {
doorGroupPanel.rotation.y -= speed;
}
break;
case DOOR_STATES.CLOSING:
if (doorGroupPanel.rotation.y < minAngle) {
doorGroupPanel.rotation.y += speed * 2;
}
break;
}
// Outside material pulsating glow
if (outsideMaterial) {
const glowMin = 0.001;
const glowMax = 0.005;
const glowSpeed = 0.0001; // Speed of the pulsation
glowIntensity += glowDirection * glowSpeed;
if (glowIntensity >= glowMax) {
glowIntensity = glowMax;
glowDirection = -1;
} else if (glowIntensity <= glowMin) {
glowIntensity = glowMin;
glowDirection = 1;
}
outsideMaterial.emissive.setRGB(glowIntensity, 0, 0);
}
}
+160
View File
@@ -0,0 +1,160 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { screenVertexShader, screenFragmentShader } from '../shaders/screen-shaders.js';
export function createMagicMirror(x, z, rotY) {
// --- Materials ---
const frameMaterial = new THREE.MeshPhongMaterial({ color: 0x8B4513, shininess: 40, specular: 0x333333 });
const metalMaterial = new THREE.MeshPhongMaterial({ color: 0xd4af37, shininess: 100, specular: 0xeeeeff }); // Gold-like
const mirrorGroup = new THREE.Group();
// --- 1. Mirror Stand Base ---
const baseWidth = 1.5;
const baseHeight = 0.2;
const baseDepth = 0.6;
const baseGeo = new THREE.BoxGeometry(baseWidth, baseHeight, baseDepth);
const base = new THREE.Mesh(baseGeo, frameMaterial);
base.position.y = baseHeight / 2;
base.castShadow = true;
base.receiveShadow = true;
mirrorGroup.add(base);
// --- 2. Stand Uprights ---
const uprightHeight = 2.4;
const uprightWidth = 0.15;
const uprightGeo = new THREE.BoxGeometry(uprightWidth, uprightHeight, uprightWidth);
const createUpright = (posX) => {
const upright = new THREE.Mesh(uprightGeo, frameMaterial);
upright.position.set(posX, uprightHeight / 2, 0);
upright.castShadow = true;
upright.receiveShadow = true;
return upright;
};
const uprightOffset = baseWidth / 2 - 0.3;
mirrorGroup.add(createUpright(-uprightOffset));
mirrorGroup.add(createUpright(uprightOffset));
// --- 3. The Elliptical Mirror Surface (The "Screen") ---
const mirrorRadius = 0.8; // Adjusted radius for scaling
const mirrorGeo = new THREE.CircleGeometry(mirrorRadius, 64);
// The 'tvScreen' from state will now be our mirror surface
state.tvScreen = new THREE.Mesh(mirrorGeo, new THREE.MeshBasicMaterial({ color: 0x000000 }));
state.tvScreen.position.y = 1.4; // Center height
state.tvScreen.position.z = 0.1; // Slightly forward in the frame
state.tvScreen.scale.set(1, 1.5, 1); // Scale Y to make it a tall ellipse
setMirrorOffMaterial(); // Set its initial "off" state
mirrorGroup.add(state.tvScreen);
// --- 4. Ornate Elliptical Mirror Frame (Torus) ---
const frameRadius = mirrorRadius;
const frameTubeRadius = 0.04; // Made the rim thinner
const frameRingGeo = new THREE.TorusGeometry(frameRadius, frameTubeRadius, 16, 100);
const frameRing = new THREE.Mesh(frameRingGeo, metalMaterial);
frameRing.position.copy(state.tvScreen.position);
frameRing.scale.copy(state.tvScreen.scale); // Apply the same scale to the frame
frameRing.castShadow = true;
mirrorGroup.add(frameRing);
// --- 5. Light from the Mirror ---
state.screenLight = new THREE.PointLight(0xffffff, 0, 10);
state.screenLight.position.copy(state.tvScreen.position);
state.screenLight.position.z += 0.3; // Position light in front of the mirror
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;
mirrorGroup.add(state.screenLight);
// Position and rotate the entire group
mirrorGroup.position.set(x, 0, z);
mirrorGroup.rotation.y = rotY;
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() {
if (state.tvScreenPowered) {
state.tvScreenPowered = false;
setScreenEffect(2, () => {
setMirrorOffMaterial();
state.screenLight.intensity = 0.0;
}); // Trigger power down effect
}
}
export function turnTvScreenOn() {
if (state.tvScreen.material) {
state.tvScreen.material.dispose();
}
// Use the shader material 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 },
u_time: { 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;
}
}
+85
View File
@@ -0,0 +1,85 @@
import * as THREE from 'three';
import { state } from '../state.js';
import wallTextureUrl from '/textures/wall.jpg';
export function createRoomWalls() {
const wallTexture = state.loader.load(wallTextureUrl);
wallTexture.wrapS = THREE.RepeatWrapping;
wallTexture.wrapT = THREE.RepeatWrapping;
// USING MeshPhongMaterial for specular highlights on walls
const wallMaterial = new THREE.MeshPhongMaterial({
map: wallTexture,
side: THREE.FrontSide,
shininess: 5,
specular: 0x111111 // Subtle reflection
});
// 1. Back Wall (behind the TV)
const backWall = new THREE.Mesh(new THREE.PlaneGeometry(state.roomSize, state.roomHeight), wallMaterial);
backWall.position.set(0, state.roomHeight / 2, -state.roomSize / 2);
backWall.receiveShadow = true;
backWall.name = 'backWall';
state.scene.add(backWall);
// 2. Front Wall (behind the camera)
const frontWall = new THREE.Mesh(new THREE.PlaneGeometry(state.roomSize, state.roomHeight), wallMaterial);
frontWall.position.set(0, state.roomHeight / 2, state.roomSize / 2);
frontWall.rotation.y = Math.PI;
frontWall.name = 'frontWall';
frontWall.receiveShadow = true;
state.scene.add(frontWall);
// 3. Left Wall
const leftWall = new THREE.Mesh(new THREE.PlaneGeometry(state.roomSize, state.roomHeight), wallMaterial);
leftWall.rotation.y = Math.PI / 2;
leftWall.name = 'leftWall';
leftWall.position.set(-state.roomSize / 2, state.roomHeight / 2, 0);
leftWall.receiveShadow = true;
state.scene.add(leftWall);
// 4. Right Wall
const rightWall = new THREE.Mesh(new THREE.PlaneGeometry(state.roomSize, state.roomHeight), wallMaterial);
rightWall.name = 'rightWall';
rightWall.rotation.y = -Math.PI / 2;
rightWall.position.set(state.roomSize / 2, state.roomHeight / 2, 0);
rightWall.receiveShadow = true;
state.scene.add(rightWall);
// 5. Ceiling
const ceilingGeometry = new THREE.PlaneGeometry(state.roomSize, state.roomSize);
const ceilingTexture = wallTexture;
ceilingTexture.repeat.set(4, 4);
// USING MeshPhongMaterial
const ceilingMaterial = new THREE.MeshPhongMaterial({
map: ceilingTexture,
side: THREE.FrontSide,
shininess: 5,
specular: 0x111111
});
const ceiling = new THREE.Mesh(ceilingGeometry, ceilingMaterial);
ceiling.rotation.x = Math.PI / 2;
ceiling.position.set(0, state.roomHeight, 0);
ceiling.receiveShadow = true;
state.scene.add(ceiling);
state.crawlSurfaces.push(backWall, leftWall, rightWall);
// --- 6. Add a Window to the Back Wall ---
const windowWidth = 1.5;
const windowHeight = 1.2;
const windowGeometry = new THREE.PlaneGeometry(windowWidth, windowHeight);
const nightSkyMaterial = new THREE.MeshPhongMaterial({
color: 0x0a1a3a,
emissive: 0x0a1a3a,
emissiveIntensity: 0.5,
side: THREE.FrontSide
});
const windowPane = new THREE.Mesh(windowGeometry, nightSkyMaterial);
const windowZ = -state.roomSize / 2 + 0.001;
windowPane.position.set(-3.5, state.roomHeight * 0.5 + 1.5, windowZ);
state.scene.add(windowPane);
}
+167
View File
@@ -0,0 +1,167 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { createRoomWalls } from './room-walls.js';
import { createBookshelf } from './bookshelf.js';
import { createMagicMirror } from './magic-mirror.js';
import { PictureFrame } from './PictureFrame.js';
import painting1 from '/textures/painting1.jpg';
import painting2 from '/textures/painting2.jpg';
import floorTextureUrl from '/textures/stone_floor.png';
import tableTextureUrl from '/textures/wood.png';
// --- Scene Modeling Function ---
export function createSceneObjects() {
// --- Materials (MeshPhongMaterial) ---
const darkMetal = new THREE.MeshPhongMaterial({
color: 0x6b6b6b,
shininess: 80,
specular: 0x888888
});
const woodMaterial = new THREE.MeshPhongMaterial({
map: state.loader.load(tableTextureUrl),
shininess: 10,
specular: 0x222222
});
// --- 1. Floor ---
const floorGeometry = new THREE.PlaneGeometry(20, 20);
const floorTexture = state.loader.load(floorTextureUrl);
floorTexture.wrapS = THREE.RepeatWrapping;
floorTexture.wrapT = THREE.RepeatWrapping;
floorTexture.repeat.set(state.roomSize, state.roomSize);
const floorMaterial = new THREE.MeshPhongMaterial({ map: floorTexture, color: 0xaaaaaa, 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);
state.landingSurfaces.push(floor);
createRoomWalls();
// 3. Lighting (Minimal and focused)
const ambientLight = new THREE.AmbientLight(0x404040, 1);
state.scene.add(ambientLight);
const roomLight = new THREE.PointLight(0xffdcb4, 0.8, state.roomSize);
roomLight.position.set(0, 1.8, 0);
state.scene.add(roomLight);
createMagicMirror(0, -state.roomSize/2 + 1.0, 0);
// --- 5. Candle ---
const candleStickGeo = new THREE.CylinderGeometry(0.05, 0.06, 0.3, 12);
const candleStick = new THREE.Mesh(candleStickGeo, darkMetal);
candleStick.castShadow = true;
candleStick.receiveShadow = true;
// Candle Light (Warm Glow)
state.candleLight = new THREE.PointLight(0xffaa00, state.originalLampIntensity, 4);
state.candleLight.position.set(0, 0.2, 0);
state.candleLight.castShadow = true;
state.candleLight.shadow.mapSize.width = 512;
state.candleLight.shadow.mapSize.height = 512;
state.candleLight.shadow.camera.near = 0.1;
state.candleLight.shadow.camera.far = 4;
state.candleLight.penumbra = 0.5;
const candleGroup = new THREE.Group();
candleGroup.add(candleStick, state.candleLight);
candleGroup.position.set(0.8, 0.15, -state.roomSize/2+0.5);
state.scene.add(candleGroup);
// --- 7. Table ---
const tableTopGeo = new THREE.BoxGeometry(1.5, 0.1, 0.8);
const tableTop = new THREE.Mesh(tableTopGeo, woodMaterial);
tableTop.position.y = 0.5;
tableTop.castShadow = true;
tableTop.receiveShadow = true;
const table = new THREE.Group();
table.add(tableTop); // You could add legs here for more detail
table.position.set(-1.7, 0, -0.8);
table.rotation.y = Math.PI / 5;
state.scene.add(table);
// --- 8. Timber Frames ---
const beamThickness = 0.15;
const beamDepth = 0.2;
// Ceiling Beams
const ceilingBeamGeoX = new THREE.BoxGeometry(state.roomSize, beamDepth, beamThickness);
const ceilingBeamGeoZ = new THREE.BoxGeometry(beamThickness, beamDepth, state.roomSize);
const createBeam = (geo, pos, rotY = 0) => {
const beam = new THREE.Mesh(geo, woodMaterial);
beam.position.copy(pos);
beam.rotation.y = rotY;
beam.castShadow = true;
beam.receiveShadow = true;
state.scene.add(beam);
return beam;
};
// Create a grid of ceiling beams
// Beams along X-axis
createBeam(ceilingBeamGeoX, new THREE.Vector3(0, state.roomHeight - beamDepth / 2, 0));
createBeam(ceilingBeamGeoX, new THREE.Vector3(0, state.roomHeight - beamDepth / 2, -state.roomSize / 2 + 1.5));
createBeam(ceilingBeamGeoX, new THREE.Vector3(0, state.roomHeight - beamDepth / 2, state.roomSize / 2 - 1.5));
// Beams along Z-axis
createBeam(ceilingBeamGeoZ, new THREE.Vector3(-state.roomSize / 2 + 1.5, state.roomHeight - beamDepth / 2, 0));
createBeam(ceilingBeamGeoZ, new THREE.Vector3(state.roomSize / 2 - 1.5, state.roomHeight - beamDepth / 2, 0));
// Wall Beams (Vertical)
const wallBeamGeo = new THREE.BoxGeometry(beamThickness, state.roomHeight, beamDepth);
// Back Wall
createBeam(wallBeamGeo, new THREE.Vector3(-1, state.roomHeight / 2, -state.roomSize / 2 + beamDepth / 2));
createBeam(wallBeamGeo, new THREE.Vector3(1, state.roomHeight / 2, -state.roomSize / 2 + beamDepth / 2));
// Left Wall
createBeam(wallBeamGeo, new THREE.Vector3(-state.roomSize / 2 + beamDepth / 2, state.roomHeight / 2, -1), Math.PI / 2);
createBeam(wallBeamGeo, new THREE.Vector3(-state.roomSize / 2 + beamDepth / 2, state.roomHeight / 2, 1), Math.PI / 2);
// Right Wall
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)
const wallBeamGeoX = new THREE.BoxGeometry(state.roomSize, beamThickness, beamDepth);
const wallBeamGeoZ = new THREE.BoxGeometry(beamDepth, beamThickness, state.roomSize);
// Back Wall
createBeam(wallBeamGeoX, new THREE.Vector3(0, state.roomHeight - 0.5, -state.roomSize / 2 + beamDepth / 2));
// Left Wall
createBeam(wallBeamGeoZ, new THREE.Vector3(-state.roomSize / 2 + beamDepth / 2, state.roomHeight - 0.5, 0));
// Right Wall
createBeam(wallBeamGeoZ, new THREE.Vector3(state.roomSize / 2 - beamDepth / 2, state.roomHeight - 0.5, 0));
createBookshelf(-state.roomSize/2 + 0.2, state.roomSize/2*0.2, Math.PI/2, 0);
createBookshelf(-state.roomSize/2 + 0.2, state.roomSize/2*0.7, Math.PI/2, 0);
createBookshelf(state.roomSize/2 * 0.7, -state.roomSize/2+0.3, 0, 1);
const pictureFrame = new PictureFrame(state.scene, {
position: new THREE.Vector3(-state.roomSize/2, 2.0, -state.roomSize/2 + 1.5),
width: 1.5,
height: 1,
imageUrls: [painting1, painting2],
rotationY: Math.PI / 2
});
state.pictureFrames.push(pictureFrame);
const pictureFrame2 = new PictureFrame(state.scene, {
position: new THREE.Vector3(state.roomSize/2, 2.0, 0.3),
width: 1.5,
height: 1,
imageUrls: [painting2, painting1],
rotationY: -Math.PI / 2
});
state.pictureFrames.push(pictureFrame2);
}
+18
View File
@@ -0,0 +1,18 @@
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;
void main() {
// Sample the video texture
gl_FragColor = texture2D(videoTexture, vUv);
}
`;
+250
View File
@@ -0,0 +1,250 @@
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
}
}
+214
View File
@@ -0,0 +1,214 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { formatTime } from '../utils.js';
// --- Segment Display Definitions ---
// Define which segments (indexed 0-6: A, B, C, D, E, F, G) are active for each digit
// A=Top, B=TR, C=BR, D=Bottom, E=BL, F=TL, G=Middle
const SEGMENTS = {
'0': [1, 1, 1, 1, 1, 1, 0],
'1': [0, 1, 1, 0, 0, 0, 0],
'2': [1, 1, 0, 1, 1, 0, 1],
'3': [1, 1, 1, 1, 0, 0, 1],
'4': [0, 1, 1, 0, 0, 1, 1],
'5': [1, 0, 1, 1, 0, 1, 1],
'6': [1, 0, 1, 1, 1, 1, 1],
'7': [1, 1, 1, 0, 0, 0, 0],
'8': [1, 1, 1, 1, 1, 1, 1],
'9': [1, 1, 1, 1, 0, 1, 1],
' ': [0, 0, 0, 0, 0, 0, 0]
};
const SEG_THICKNESS = 3; // Thickness of the segment line in canvas pixels
const SEG_PADDING = 2; // Padding within a digit segment's box
// Colors for active and inactive segments
const COLOR_ACTIVE = '#00ff44'; // Bright Fluorescent Green
const COLOR_INACTIVE = '#1a1a1a'; // Dim dark gray for 'ghost' segments
/**
* Draws a single 7-segment digit by drawing active segments.
* Now includes drawing of inactive (ghost) segments for better readability.
* @param {CanvasRenderingContext2D} ctx
* @param {string} digit The digit character (0-9).
* @param {number} x Left position of the digit area.
* @param {number} y Top position of the digit area.
* @param {number} H Total height of the digit area.
*/
function drawSegmentDigit(ctx, digit, x, y, H) {
const segments = SEGMENTS[digit] || SEGMENTS[' '];
const W = H / 2; // Width is half the height for standard aspect ratio
// Segment dimensions relative to W and H
const hLength = W - 2 * SEG_PADDING;
// Vertical length calculation: (Total height - 2 paddings - 3 horizontal thicknesses) / 2
const vLength = (H - (2 * SEG_PADDING) - (3 * SEG_THICKNESS)) / 2;
// Helper to draw horizontal segment (A, G, D)
const drawH = (index, x_start, y_start) => {
ctx.fillStyle = segments[index] ? COLOR_ACTIVE : COLOR_INACTIVE;
ctx.fillRect(x_start + SEG_PADDING, y_start, hLength, SEG_THICKNESS);
};
// Helper to draw vertical segment (F, B, E, C)
const drawV = (index, x_start, y_start) => {
ctx.fillStyle = segments[index] ? COLOR_ACTIVE : COLOR_INACTIVE;
ctx.fillRect(x_start, y_start, SEG_THICKNESS, vLength);
};
// Define segment positions
// Horizontal segments
// A (Top) - index 0
drawH(0, x, y + SEG_PADDING);
// G (Middle) - index 6
drawH(6, x, y + H/2 - SEG_THICKNESS/2);
// D (Bottom) - index 3
drawH(3, x, y + H - SEG_PADDING - SEG_THICKNESS);
// Vertical segments (Top Half)
const topVStart = y + SEG_PADDING + SEG_THICKNESS;
const rightVStart = x + W - SEG_PADDING - SEG_THICKNESS;
// F (Top-Left) - index 5
drawV(5, x + SEG_PADDING, topVStart);
// B (Top-Right) - index 1
drawV(1, rightVStart, topVStart);
// Vertical segments (Bottom Half)
const bottomVStart = y + H/2 + SEG_THICKNESS/2;
// E (Bottom-Left) - index 4
drawV(4, x + SEG_PADDING, bottomVStart);
// C (Bottom-Right) - index 2
drawV(2, rightVStart, bottomVStart);
}
// Function to draw the colon (two dots), now with blinking logic
function drawColon(ctx, x, y, H, isVisible) {
const dotSize = 4;
ctx.fillStyle = COLOR_ACTIVE;
if (isVisible) {
// Top dot
ctx.fillRect(x, y + H * 0.3 - dotSize / 2, dotSize, dotSize);
// Bottom dot
ctx.fillRect(x, y + H * 0.7 - dotSize / 2, dotSize, dotSize);
} else {
// Draw inactive colon if not visible, for consistency
ctx.fillStyle = COLOR_INACTIVE;
ctx.fillRect(x, y + H * 0.3 - dotSize / 2, dotSize, dotSize);
ctx.fillRect(x, y + H * 0.7 - dotSize / 2, dotSize, dotSize);
}
}
/**
* Draws a simple playback arrow (triangle)
* @param {CanvasRenderingContext2D} ctx
* @param {number} x Left position of the arrow area.
* @param {number} y Top position of the arrow area.
* @param {number} H Total height of the arrow area.
*/
function drawPlaybackArrow(ctx, x, y, H) {
const arrowWidth = H * 0.4; // Arrow width relative to digit height
const arrowHeight = H * 0.4; // Arrow height relative to digit height
ctx.fillStyle = COLOR_ACTIVE;
ctx.beginPath();
ctx.moveTo(x, y + H * 0.5 - arrowHeight / 2); // Top point
ctx.lineTo(x + arrowWidth, y + H * 0.5); // Right point (center)
ctx.lineTo(x, y + H * 0.5 + arrowHeight / 2); // Bottom point
ctx.closePath();
ctx.fill();
}
// Main function to render the entire time string using segments
function drawSegmentDisplay(ctx, timeString) {
const canvasWidth = ctx.canvas.width;
const canvasHeight = ctx.canvas.height;
const timeStringLength = timeString.length;
// Clear display to dark background
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Constants for layout
const charSpacing = 8; // Spacing between digits
const digitHeight = canvasHeight - 2 * SEG_PADDING;
const digitWidth = digitHeight / 2 + SEG_PADDING; // Total width slot for one digit
const colonWidth = 6;
const arrowWidth = digitHeight * 0.7; // Approx width for the arrow
const arrowPadding = 10; // Space between arrow and first digit
// Calculate total display width including arrow and spaces
const totalDisplayWidth = arrowWidth + arrowPadding + (4 * digitWidth) + colonWidth + ((timeStringLength - 1) * charSpacing);
// Calculate starting X to center the display
let currentX = (canvasWidth - totalDisplayWidth) / 2;
const currentY = SEG_PADDING;
// Draw Playback Arrow
if (state.isVideoLoaded && state.videoElement.readyState >= 3) {
drawPlaybackArrow(ctx, currentX, currentY, digitHeight);
}
currentX += arrowWidth + arrowPadding; // Move X after arrow and its padding
for (let i = 0; i < timeStringLength; i++) {
const char = timeString[i];
if (char === ':') {
drawColon(ctx, currentX, currentY, digitHeight, state.blinkState); // Pass blinkState
currentX += colonWidth;
} else if (char >= '0' && char <= '9') {
drawSegmentDigit(ctx, char, currentX, currentY, digitHeight);
currentX += digitWidth;
}
// Add spacing only if it's not the last element
if (i < timeStringLength - 1) {
currentX += charSpacing;
}
}
}
// --- VCR Display Functions ---
export function createVcrDisplay() {
const canvas = document.createElement('canvas');
canvas.width = 160; // Increased width for arrow and better spacing
canvas.height = 32;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#030303';
ctx.fillRect(0, 0, canvas.width, canvas.height);
state.vcrDisplayTexture = new THREE.CanvasTexture(canvas);
state.vcrDisplayTexture.needsUpdate = true;
const displayGeometry = new THREE.PlaneGeometry(0.45, 0.1); // Adjust geometry width for new canvas size
const displayMaterial = new THREE.MeshBasicMaterial({
map: state.vcrDisplayTexture,
side: THREE.FrontSide,
color: 0x105a10,
});
const displayMesh = new THREE.Mesh(displayGeometry, displayMaterial);
return displayMesh;
}
export function updateVcrDisplay(time) {
if (!state.vcrDisplayTexture) return;
const canvas = state.vcrDisplayTexture.image;
const ctx = canvas.getContext('2d');
const timeString = formatTime(time);
// Uses the new segment drawing function with ghosting, including blinkState for colon
drawSegmentDisplay(ctx, timeString);
state.vcrDisplayTexture.needsUpdate = true;
}
+51
View File
@@ -0,0 +1,51 @@
import * as THREE from 'three';
import { createVcrDisplay } from './vcr-display.js';
export function createVcr() {
// Materials
const vcrBodyMaterial = new THREE.MeshPhongMaterial({
color: 0x222222, // Dark metallic gray
shininess: 70,
specular: 0x444444
});
const slotMaterial = new THREE.MeshPhongMaterial({
color: 0x0a0a0a, // Deep black
shininess: 5,
specular: 0x111111
});
// VCR Body
const vcrBodyGeometry = new THREE.BoxGeometry(1.0, 0.2, 0.7);
const vcrBody = new THREE.Mesh(vcrBodyGeometry, vcrBodyMaterial);
vcrBody.position.y = 0; // Centered
vcrBody.castShadow = true;
vcrBody.receiveShadow = true;
// Cassette Slot / Front Face
const slotGeometry = new THREE.BoxGeometry(0.9, 0.05, 0.01);
const slotMesh = new THREE.Mesh(slotGeometry, slotMaterial);
slotMesh.position.set(0, -0.05, 0.35 + 0.005);
slotMesh.castShadow = true;
slotMesh.receiveShadow = true;
// VCR Display
const displayMesh = createVcrDisplay();
displayMesh.position.z = 0.35 + 0.005;
displayMesh.position.x = 0.2; // Adjusted X for arrow
displayMesh.position.y = 0.03;
// VCR Group
const vcrGroup = new THREE.Group();
vcrGroup.add(vcrBody, slotMesh, displayMesh);
vcrGroup.position.set(0, 0.1, 0); // Position the whole VCR slightly above the floor
// Light from the VCR display itself
const vcrDisplayLight = new THREE.PointLight(0x00ff44, 0.03, 1.8);
vcrDisplayLight.position.set(0.23, 0.03, 0.35 + 0.03);
vcrDisplayLight.castShadow = true;
vcrDisplayLight.shadow.mapSize.width = 256;
vcrDisplayLight.shadow.mapSize.height = 256;
vcrGroup.add(vcrDisplayLight);
return vcrGroup;
}