Refactoring: add Vite to serve JS modules

This commit is contained in:
Dejvino 2025-11-16 13:20:33 +01:00
parent 75c87c9d03
commit 1c2a3c6d3a
17 changed files with 1506 additions and 332 deletions

1
tv-player/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

View File

@ -4,9 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Retro TV Player</title>
<!-- Load Tailwind CSS for styling --><script src="./vendor/tailwind-3.4.17.js" x-src="https://cdn.tailwindcss.com"></script>
<!-- Load Tailwind CSS for styling --><script src="/vendor/tailwind-3.4.17.js"></script>
<!-- Load Three.js for 3D rendering --><script src="./vendor/three.min.js" x-src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="./src/tailwind-config.js"></script>
<style>
/* Dark room aesthetic */
@ -45,16 +44,8 @@
</div>
</div>
<script src="./src/global-variables.js"></script>
<script src="./src/utils.js"></script>
<script src="./src/scene.js"></script>
<script src="./src/video-player.js"></script>
<script src="./src/effects_dust.js"></script>
<script src="./src/effects_flies.js"></script>
<script src="./src/EffectsManager.js"></script>
<script src="./src/vcr-display.js"></script>
<script src="./src/animate.js"></script>
<script src="./src/init.js"></script>
<!-- Main entry point for the application -->
<script type="module" src="/src/main.js"></script>
<!-- 3D Canvas will be injected here by Three.js -->
</body>

1069
tv-player/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
tv-player/package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "tv-player",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"vite": "^7.2.2"
},
"dependencies": {
"three": "^0.181.1"
}
}

View File

@ -1,4 +1,7 @@
class EffectsManager {
import { DustEffect } from './effects_dust.js';
import { FliesEffect } from './effects_flies.js';
export class EffectsManager {
constructor(scene) {
this.effects = [];
this._initializeEffects(scene);

View File

@ -1,105 +0,0 @@
function updateCamera() {
const globalTime = Date.now() * 0.00005;
const lookAtTime = Date.now() * 0.00003;
const camAmplitude = 0.7;
const lookAmplitude = 0.05;
// Base Camera Position in front of the TV
const baseX = -0.5;
const baseY = 1.5;
const baseZ = 2.5;
// 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;
camera.position.x = baseX + camOffsetX;
camera.position.y = baseY + camOffsetY;
camera.position.z = baseZ + camOffsetZ;
// LookAt Target Offsets (Subtle Gaze Shift)
const lookOffsetX = Math.sin(lookAtTime * 1.5) * lookAmplitude;
const lookOffsetY = Math.cos(lookAtTime * 1.2) * lookAmplitude;
// Apply lookAt to the subtly shifted target
camera.lookAt(baseTargetX + lookOffsetX, baseTargetY + lookOffsetY, baseTargetZ);
}
function updateLampFlicker() {
const flickerChance = 0.995;
const restoreRate = 0.15;
if (Math.random() > flickerChance) {
// Flickers quickly to a dimmer random value (between 0.3 and 1.05)
let lampLightIntensity = originalLampIntensity * (0.3 + Math.random() * 0.7);
lampLightSpot.intensity = lampLightIntensity;
lampLightPoint.intensity = lampLightIntensity;
} else if (lampLightPoint.intensity < originalLampIntensity) {
// Smoothly restore original intensity
let lampLightIntensity = THREE.MathUtils.lerp(lampLightPoint.intensity, originalLampIntensity, restoreRate);
lampLightSpot.intensity = lampLightIntensity;
lampLightPoint.intensity = lampLightIntensity;
}
}
function updateScreenLight() {
if (isVideoLoaded && screenLight.intensity > 0) {
const pulseTarget = originalScreenIntensity + (Math.random() - 0.5) * screenIntensityPulse;
screenLight.intensity = THREE.MathUtils.lerp(screenLight.intensity, pulseTarget, 0.1);
const lightTime = Date.now() * 0.0001;
const radius = 0.01;
const centerX = 0;
const centerY = 1.5;
screenLight.position.x = centerX + Math.cos(lightTime) * radius;
screenLight.position.y = centerY + Math.sin(lightTime * 1.5) * radius * 0.5; // Slightly different freq for Y
}
}
function updateVideo() {
if (videoTexture) {
videoTexture.needsUpdate = true;
}
}
function updateVcr() {
const currentTime = baseTime + videoElement.currentTime;
if (Math.abs(currentTime - lastUpdateTime) > 0.1) {
updateVcrDisplay(currentTime);
lastUpdateTime = currentTime;
}
if (currentTime - lastBlinkToggleTime > 0.5) { // Blink every 0.5 seconds
blinkState = !blinkState;
lastBlinkToggleTime = currentTime;
}
}
// --- Animation Loop ---
function animate() {
requestAnimationFrame(animate);
effectsManager.update();
updateCamera();
updateLampFlicker();
updateScreenLight();
updateVideo();
updateVcr();
// RENDER!
renderer.render(scene, camera);
}
// --- Window Resize Handler ---
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}

View File

@ -0,0 +1,110 @@
import * as THREE from 'three';
import { state } from '../state.js';
import { updateVcrDisplay } from '../vcr-display.js';
function updateCamera() {
const globalTime = Date.now() * 0.00005;
const lookAtTime = Date.now() * 0.00003;
const camAmplitude = 0.7;
const lookAmplitude = 0.05;
// Base Camera Position in front of the TV
const baseX = -0.5;
const baseY = 1.5;
const baseZ = 2.5;
// 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;
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 (Math.random() > flickerChance) {
// Flickers quickly to a dimmer random value (between 0.3 and 1.05)
let lampLightIntensity = state.originalLampIntensity * (0.3 + Math.random() * 0.7);
state.lampLightSpot.intensity = lampLightIntensity;
state.lampLightPoint.intensity = lampLightIntensity;
} else if (state.lampLightPoint.intensity < state.originalLampIntensity) {
// Smoothly restore original intensity
let lampLightIntensity = THREE.MathUtils.lerp(state.lampLightPoint.intensity, state.originalLampIntensity, restoreRate);
state.lampLightSpot.intensity = lampLightIntensity;
state.lampLightPoint.intensity = lampLightIntensity;
}
}
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 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;
}
}
// --- Animation Loop ---
export function animate() {
requestAnimationFrame(animate);
state.effectsManager.update();
updateCamera();
updateLampFlicker();
updateScreenLight();
updateVideo();
updateVcr();
// 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);
}

View File

@ -0,0 +1,62 @@
import * as THREE from 'three';
import { state, initState } from '../state.js';
import { EffectsManager } from '../EffectsManager.js';
import { createSceneObjects } from '../scene.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();
}

View File

@ -1,4 +1,6 @@
class DustEffect {
import * as THREE from 'three';
export class DustEffect {
constructor(scene) {
this.dust = null;
this._create(scene);

View File

@ -1,6 +1,17 @@
import * as THREE from 'three';
import { state } from './state.js';
import { degToRad } from './utils.js';
const FLIES_COUNT = 2;
class FliesEffect {
// --- Configuration ---
const FLIGHT_HEIGHT_MIN = 0.5; // Min height for flying
const FLIGHT_HEIGHT_MAX = 2;//state.roomHeight * 0.9; // Max height for flying
const FLY_FLIGHT_SPEED_FACTOR = 0.01; // How quickly 't' increases per frame
const FLY_WAIT_BASE = 1000;
const FLY_LAND_CHANCE = 0.3;
export class FliesEffect {
constructor(scene) {
this.flies = [];
this._setupFlies(scene);
@ -8,9 +19,9 @@ class FliesEffect {
_randomFlyTarget() {
return new THREE.Vector3(
(Math.random() - 0.5) * (ROOM_SIZE - 1),
(Math.random() - 0.5) * (state.roomSize - 1),
FLIGHT_HEIGHT_MIN + Math.random() * (FLIGHT_HEIGHT_MAX - FLIGHT_HEIGHT_MIN),
(Math.random() - 0.5) * (ROOM_SIZE - 1)
(Math.random() - 0.5) * (state.roomSize - 1)
);
}
@ -86,8 +97,8 @@ class FliesEffect {
}
if (data.landCheckTimer <= 0 && Math.random() > FLY_LAND_CHANCE) {
raycaster.set(fly.position, new THREE.Vector3(0, -1, 0));
const intersects = raycaster.intersectObjects(landingSurfaces, false);
state.raycaster.set(fly.position, new THREE.Vector3(0, -1, 0));
const intersects = state.raycaster.intersectObjects(state.landingSurfaces, false);
if (intersects.length > 0) {
const intersect = intersects[0];

View File

@ -1,67 +0,0 @@
// --- Initialization ---
function init() {
// 1. Scene Setup (Dark, Ambient)
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
// 2. Camera Setup
const FOV = 65;
camera = new THREE.PerspectiveCamera(FOV, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 1.5, 4);
// 3. Renderer Setup
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
// Enable shadows on the renderer
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Softer shadows
container.appendChild(renderer.domElement);
// 4. Lighting (Minimal and focused)
const ambientLight = new THREE.AmbientLight(0x111111);
scene.add(ambientLight);
const roomLight = new THREE.PointLight(0xffaa55, 0.05, roomSize);
roomLight.position.set(0, 1.8, 0);
scene.add(roomLight);
// 5. Build the entire scene with TV and surrounding objects
createSceneObjects();
// 6. Initialize all visual effects via the manager
effectsManager = new EffectsManager(scene);
// 7. Create the Room Walls and Ceiling
createRoomWalls();
// --- 8. Debug Visualization Helpers ---
// Visual aids for the light source positions
if (debugLight && THREE.PointLightHelper) {
const screenHelper = new THREE.PointLightHelper(screenLight, 0.1, 0xff0000); // Red for screen
scene.add(screenHelper);
// Lamp Helper will now work since lampLight is added to the scene
const lampHelperPoint = new THREE.PointLightHelper(lampLightPoint, 0.1, 0x00ff00); // Green for lamp
scene.add(lampHelperPoint);
}
// 9. Event Listeners
window.addEventListener('resize', onWindowResize, false);
fileInput.addEventListener('change', loadVideoFile);
// Button logic
loadTapeButton.addEventListener('click', () => {
fileInput.click();
});
// Auto-advance to the next video when the current one finishes.
videoElement.addEventListener('ended', playNextVideo);
// Start the animation loop
animate();
}
// Start everything on window load
window.onload = init;

6
tv-player/src/main.js Normal file
View File

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

View File

@ -1,6 +1,11 @@
import * as THREE from 'three';
import { state } from './state.js';
import { getRandomColor, seededRandom } from './utils.js';
import { createVcrDisplay } from './vcr-display.js';
// --- Room Walls Function ---
function createRoomWalls() {
const wallTexture = loader.load('./textures/wall.jpg');
const wallTexture = state.loader.load('/textures/wall.jpg');
wallTexture.wrapS = THREE.RepeatWrapping;
wallTexture.wrapT = THREE.RepeatWrapping;
@ -13,34 +18,34 @@ function createRoomWalls() {
});
// 1. Back Wall (behind the TV)
const backWall = new THREE.Mesh(new THREE.PlaneGeometry(roomSize, roomHeight), wallMaterial);
backWall.position.set(0, roomHeight / 2, -roomSize / 2);
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;
scene.add(backWall);
state.scene.add(backWall);
// 2. Front Wall (behind the camera)
const frontWall = new THREE.Mesh(new THREE.PlaneGeometry(roomSize, roomHeight), wallMaterial);
frontWall.position.set(0, roomHeight / 2, roomSize / 2);
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.receiveShadow = true;
scene.add(frontWall);
state.scene.add(frontWall);
// 3. Left Wall
const leftWall = new THREE.Mesh(new THREE.PlaneGeometry(roomSize, roomHeight), wallMaterial);
const leftWall = new THREE.Mesh(new THREE.PlaneGeometry(state.roomSize, state.roomHeight), wallMaterial);
leftWall.rotation.y = Math.PI / 2;
leftWall.position.set(-roomSize / 2, roomHeight / 2, 0);
leftWall.position.set(-state.roomSize / 2, state.roomHeight / 2, 0);
leftWall.receiveShadow = true;
scene.add(leftWall);
state.scene.add(leftWall);
// 4. Right Wall
const rightWall = new THREE.Mesh(new THREE.PlaneGeometry(roomSize, roomHeight), wallMaterial);
const rightWall = new THREE.Mesh(new THREE.PlaneGeometry(state.roomSize, state.roomHeight), wallMaterial);
rightWall.rotation.y = -Math.PI / 2;
rightWall.position.set(roomSize / 2, roomHeight / 2, 0);
rightWall.position.set(state.roomSize / 2, state.roomHeight / 2, 0);
rightWall.receiveShadow = true;
scene.add(rightWall);
state.scene.add(rightWall);
// 5. Ceiling
const ceilingGeometry = new THREE.PlaneGeometry(roomSize, roomSize);
const ceilingGeometry = new THREE.PlaneGeometry(state.roomSize, state.roomSize);
const ceilingTexture = wallTexture;
ceilingTexture.repeat.set(4, 4);
// USING MeshPhongMaterial
@ -53,16 +58,16 @@ function createRoomWalls() {
const ceiling = new THREE.Mesh(ceilingGeometry, ceilingMaterial);
ceiling.rotation.x = Math.PI / 2;
ceiling.position.set(0, roomHeight, 0);
ceiling.position.set(0, state.roomHeight, 0);
ceiling.receiveShadow = true;
scene.add(ceiling);
state.scene.add(ceiling);
// --- 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.MeshBasicMaterial({
const nightSkyMaterial = new THREE.MeshPhongMaterial({
color: 0x0a1a3a,
emissive: 0x0a1a3a,
emissiveIntensity: 0.5,
@ -70,13 +75,13 @@ function createRoomWalls() {
});
const windowPane = new THREE.Mesh(windowGeometry, nightSkyMaterial);
const windowZ = -roomSize / 2 + 0.001;
windowPane.position.set(-3.5, roomHeight * 0.5 + 1.5, windowZ);
scene.add(windowPane);
const windowZ = -state.roomSize / 2 + 0.001;
windowPane.position.set(-3.5, state.roomHeight * 0.5 + 1.5, windowZ);
state.scene.add(windowPane);
}
function createBookshelf(x, z, rotationY, uniqueSeed) {
seed = uniqueSeed; // Reset seed for this specific shelf instance
state.seed = uniqueSeed; // Reset seed for this specific shelf instance
const shelfHeight = 2.2;
const shelfDepth = 0.35;
@ -126,7 +131,7 @@ function createBookshelf(x, z, rotationY, uniqueSeed) {
topPanel.castShadow = true;
shelfGroup.add(topPanel);
landingSurfaces.push(topPanel);
state.landingSurfaces.push(topPanel);
// 2. Individual Shelves & Books
const internalHeight = shelfHeight - (2 * woodThickness);
@ -185,7 +190,7 @@ function createBookshelf(x, z, rotationY, uniqueSeed) {
}
}
scene.add(shelfGroup);
state.scene.add(shelfGroup);
}
function createDoor(x, z, rotY) {
@ -224,7 +229,7 @@ function createDoor(x, z, rotY) {
knob.castShadow = true;
doorGroup.add(knob);
scene.add(doorGroup);
state.scene.add(doorGroup);
}
// --- VCR Model Function ---
@ -267,8 +272,8 @@ function createVcr() {
vcrGroup.position.set(0, 0.1, 0); // Position the whole VCR slightly above the floor
// Light from the VCR display itself
vcrDisplayLight = new THREE.PointLight(0x00ff44, 0.5, 1);
vcrDisplayLight.position.set(0.3, 0.03, 0.35 + 0.05); // Move light slightly closer to VCR surface
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;
@ -413,42 +418,42 @@ function createTvSet(x, z, rotY) {
screenGeometry.rotateY(-Math.PI/2);
const screenMaterial = new THREE.MeshBasicMaterial({ color: 0x000000 });
tvScreen = new THREE.Mesh(screenGeometry, screenMaterial);
state.tvScreen = new THREE.Mesh(screenGeometry, screenMaterial);
// Position the curved screen
tvScreen.position.set(0.0, 1.5, -2.1);
tvScreen.material = new THREE.MeshPhongMaterial({
state.tvScreen.position.set(0.0, 1.5, -2.1);
state.tvScreen.material = new THREE.MeshPhongMaterial({
color: 0x0a0a0a, // Deep black
shininess: 5,
specular: 0x111111
});
tvScreen.material.needsUpdate = true;
tvGroup.add(tvScreen);
state.tvScreen.material.needsUpdate = true;
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)
screenLight = new THREE.PointLight(0xffffff, 0, 10);
screenLight.position.set(0, 1.5, 1.0);
state.screenLight = new THREE.PointLight(0xffffff, 0, 10);
state.screenLight.position.set(0, 1.5, 1.0);
// Screen light casts shadows
screenLight.castShadow = true;
screenLight.shadow.mapSize.width = 1024;
screenLight.shadow.mapSize.height = 1024;
screenLight.shadow.camera.near = 0.2;
screenLight.shadow.camera.far = 5;
tvGroup.add(screenLight);
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);
scene.add(tvGroup);
state.scene.add(tvGroup);
}
// --- Scene Modeling Function ---
function createSceneObjects() {
export function createSceneObjects() {
// --- Materials (MeshPhongMaterial) ---
const darkWood = new THREE.MeshPhongMaterial({ color: 0x3d352e, shininess: 10 });
const darkMetal = new THREE.MeshPhongMaterial({
@ -460,22 +465,33 @@ function createSceneObjects() {
// --- 1. Floor ---
const floorGeometry = new THREE.PlaneGeometry(20, 20);
const floorTexture = loader.load('./textures/floor.jpg');
const floorTexture = state.loader.load('/textures/floor.jpg');
floorTexture.wrapS = THREE.RepeatWrapping;
floorTexture.wrapT = THREE.RepeatWrapping;
floorTexture.repeat.set(roomSize, roomSize);
floorTexture.repeat.set(state.roomSize, state.roomSize);
const floorMaterial = new THREE.MeshPhongMaterial({ map: floorTexture, color: 0x555555, shininess: 5 });
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2;
floor.position.y = 0;
floor.receiveShadow = true;
scene.add(floor);
state.scene.add(floor);
landingSurfaces.push(floor);
state.landingSurfaces.push(floor);
createTvSet(-roomSize/2 + 1.2, -roomSize/2 + 0.8, Math.PI * 0.1);
createRoomWalls();
// --- 5. Lamp (On the table, right side) ---
// 3. Lighting (Minimal and focused)
const ambientLight = new THREE.AmbientLight(0x111111, 1);
state.scene.add(ambientLight);
const roomLight = new THREE.PointLight(0xffaa55, 0.5, state.roomSize);
roomLight.position.set(0, 1.8, 0);
state.scene.add(roomLight);
createTvSet(-state.roomSize/2 + 1.2, -state.roomSize/2 + 0.8, Math.PI * 0.1);
// --- 5. Standing Lamp ---
const lampBase = new THREE.CylinderGeometry(0.05, 0.2, 0.1, 12);
const lampPole = new THREE.CylinderGeometry(0.02, 0.02, 1.5, 8);
const lampShade = new THREE.ConeGeometry(0.2, 0.4, 16);
@ -495,34 +511,34 @@ function createSceneObjects() {
shadeMesh.rotation.x = Math.PI;
// Lamp Light (Warm Glow) - Configured to cast shadows
lampLightPoint = new THREE.PointLight(0xffaa00, originalLampIntensity, 4);
lampLightPoint.position.set(-0.01, roomHeight-0.9, 0.01);
lampLightPoint.castShadow = true;
state.lampLightPoint = new THREE.PointLight(0xffaa00, state.originalLampIntensity, 4);
state.lampLightPoint.position.set(-0.01, state.roomHeight-0.9, 0.01);
state.lampLightPoint.castShadow = true;
// Optimization: Reduced map size and far plane to ease resource burden
lampLightPoint.shadow.mapSize.width = 512;
lampLightPoint.shadow.mapSize.height = 512;
lampLightPoint.shadow.camera.near = 0.1;
lampLightPoint.shadow.camera.far = 4; // Matches the light's attenuation distance (4)
lampLightPoint.penumbra = 0.5;
state.lampLightPoint.shadow.mapSize.width = 512;
state.lampLightPoint.shadow.mapSize.height = 512;
state.lampLightPoint.shadow.camera.near = 0.1;
state.lampLightPoint.shadow.camera.far = 4; // Matches the light's attenuation distance (4)
state.lampLightPoint.penumbra = 0.5;
lampLightSpot = new THREE.SpotLight(0xffaa00, originalLampIntensity, 4);
lampLightSpot.position.set(-0.01, 1.0, 0.01);
lampLightSpot.target.position.set(0, 5, 0);
lampLightSpot.castShadow = true;
state.lampLightSpot = new THREE.SpotLight(0xffaa00, state.originalLampIntensity, 4);
state.lampLightSpot.position.set(-0.01, 1.0, 0.01);
state.lampLightSpot.target.position.set(0, 5, 0);
state.lampLightSpot.castShadow = true;
// Optimization: Reduced map size and far plane to ease resource burden
lampLightSpot.shadow.mapSize.width = 512;
lampLightSpot.shadow.mapSize.height = 512;
lampLightSpot.shadow.camera.near = 0.1;
lampLightSpot.shadow.camera.far = 4; // Matches the light's attenuation distance (4)
lampLightSpot.penumbra = 0.5;
state.lampLightSpot.shadow.mapSize.width = 512;
state.lampLightSpot.shadow.mapSize.height = 512;
state.lampLightSpot.shadow.camera.near = 0.1;
state.lampLightSpot.shadow.camera.far = 4; // Matches the light's attenuation distance (4)
state.lampLightSpot.penumbra = 1;
const lampGroup = new THREE.Group();
lampGroup.add(baseMesh, poleMesh, shadeMesh, lampLightSpot, lampLightSpot.target, lampLightPoint);
lampGroup.position.set(0.8, 0.7, -roomSize/2+0.5);
lampGroup.add(baseMesh, poleMesh, shadeMesh, state.lampLightSpot, state.lampLightSpot.target, state.lampLightPoint);
lampGroup.position.set(0.8, 0.7, -state.roomSize/2+0.5);
scene.add(lampGroup);
state.scene.add(lampGroup);
landingSurfaces.push(shadeMesh);
state.landingSurfaces.push(shadeMesh);
// --- 7. Old Camera (On the table) ---
const cameraBody = new THREE.BoxGeometry(0.4, 0.3, 0.15);
@ -542,7 +558,7 @@ function createSceneObjects() {
cameraMesh.position.set(-1.7, 0.15, 0.4);
cameraMesh.rotation.y = -Math.PI / 10;
cameraMesh.castShadow = true; cameraMesh.receiveShadow = true;
scene.add(cameraMesh);
state.scene.add(cameraMesh);
// --- 8. Pizza Box ---
const boxGeometry = new THREE.BoxGeometry(0.5, 0.05, 0.5);
@ -551,7 +567,7 @@ function createSceneObjects() {
pizzaBox.position.set(-1.8, 0.025, -0.8);
pizzaBox.rotation.y = Math.PI / 5;
pizzaBox.castShadow = true; pizzaBox.receiveShadow = true;
scene.add(pizzaBox);
state.scene.add(pizzaBox);
// --- 8. Cassette ---
const cassetteGeometry = new THREE.BoxGeometry(0.2, 0.05, 0.45);
@ -560,11 +576,11 @@ function createSceneObjects() {
cassette.position.set(-0.5, 0.025, -1.4);
cassette.rotation.y = Math.PI / 3;
cassette.castShadow = true; cassette.receiveShadow = true;
scene.add(cassette);
state.scene.add(cassette);
createDoor(roomSize/2, -roomSize/2 * 0.5, -Math.PI/2);
createBookshelf(-roomSize/2 + 0.2, roomSize/2*0.2, Math.PI/2, 0);
createBookshelf(-roomSize/2 + 0.2, roomSize/2*0.7, Math.PI/2, 0);
createBookshelf(roomSize/2 * 0.7, -roomSize/2+0.3, 0, 1);
createDoor(state.roomSize/2, -state.roomSize/2 * 0.5, -Math.PI/2);
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);
}

50
tv-player/src/state.js Normal file
View File

@ -0,0 +1,50 @@
import * as THREE from 'three';
export let state = undefined;
export function initState() {
state = {
// Core Three.js components
scene: null,
camera: null,
renderer: null,
tvScreen: null,
videoTexture: null,
screenLight: null,
lampLightPoint: null,
lampLightSpot: null,
effectsManager: null,
// VCR Display
lastUpdateTime: -1,
baseTime: 0,
blinkState: false,
lastBlinkToggleTime: 0,
vcrDisplayTexture: null,
// Video Playback
isVideoLoaded: false,
videoUrls: [],
currentVideoIndex: -1,
// Scene constants
originalLampIntensity: 0.3,
originalScreenIntensity: 0.2,
screenIntensityPulse: 0.2,
roomSize: 5,
roomHeight: 3,
debugLight: false,
// DOM Elements
container: document.body,
videoElement: document.getElementById('video'),
fileInput: document.getElementById('fileInput'),
loadTapeButton: document.getElementById('loadTapeButton'),
// Utilities
loader: new THREE.TextureLoader(),
landingSurfaces: [],
raycaster: new THREE.Raycaster(),
seed: 12345,
};
}

View File

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

View File

@ -1,6 +1,7 @@
let vcrDisplayTexture;
let blinkState = false;
let lastBlinkToggleTime = 0;
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
@ -151,7 +152,7 @@ function drawSegmentDisplay(ctx, timeString) {
const currentY = SEG_PADDING;
// Draw Playback Arrow
if (isVideoLoaded && videoElement.readyState >= 3) {
if (state.isVideoLoaded && state.videoElement.readyState >= 3) {
drawPlaybackArrow(ctx, currentX, currentY, digitHeight);
}
currentX += arrowWidth + arrowPadding; // Move X after arrow and its padding
@ -160,7 +161,7 @@ function drawSegmentDisplay(ctx, timeString) {
const char = timeString[i];
if (char === ':') {
drawColon(ctx, currentX, currentY, digitHeight, blinkState); // Pass blinkState
drawColon(ctx, currentX, currentY, digitHeight, state.blinkState); // Pass blinkState
currentX += colonWidth;
} else if (char >= '0' && char <= '9') {
drawSegmentDigit(ctx, char, currentX, currentY, digitHeight);
@ -175,7 +176,7 @@ function drawSegmentDisplay(ctx, timeString) {
}
// --- VCR Display Functions ---
function createVcrDisplay() {
export function createVcrDisplay() {
const canvas = document.createElement('canvas');
canvas.width = 160; // Increased width for arrow and better spacing
canvas.height = 32;
@ -184,27 +185,28 @@ function createVcrDisplay() {
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
vcrDisplayTexture = new THREE.CanvasTexture(canvas);
vcrDisplayTexture.needsUpdate = true;
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: vcrDisplayTexture,
const displayMaterial = new THREE.MeshPhongMaterial({
map: state.vcrDisplayTexture,
side: THREE.FrontSide,
color: 0xffffff,
transparent: true,
emissive: 0x00ff44,
emissiveIntensity: 0.1
emissiveIntensity: 0.05,
shininess: 0
});
const displayMesh = new THREE.Mesh(displayGeometry, displayMaterial);
return displayMesh;
}
function updateVcrDisplay(time) {
if (!vcrDisplayTexture) return;
export function updateVcrDisplay(time) {
if (!state.vcrDisplayTexture) return;
const canvas = vcrDisplayTexture.image;
const canvas = state.vcrDisplayTexture.image;
const ctx = canvas.getContext('2d');
const timeString = formatTime(time);
@ -212,5 +214,5 @@ function updateVcrDisplay(time) {
// Uses the new segment drawing function with ghosting, including blinkState for colon
drawSegmentDisplay(ctx, timeString);
vcrDisplayTexture.needsUpdate = true;
state.vcrDisplayTexture.needsUpdate = true;
}

View File

@ -1,84 +1,87 @@
import * as THREE from 'three';
import { state } from './state.js';
// --- Play video by index ---
function playVideoByIndex(index) {
currentVideoIndex = index;
const url = videoUrls[index];
export function playVideoByIndex(index) {
state.currentVideoIndex = index;
const url = state.videoUrls[index];
// Dispose of previous texture to free resources
if (videoTexture) {
videoTexture.dispose();
videoTexture = null;
if (state.videoTexture) {
state.videoTexture.dispose();
state.videoTexture = null;
}
if (index < 0 || index >= videoUrls.length) {
if (index < 0 || index >= state.videoUrls.length) {
console.info('End of playlist reached. Reload tapes to start again.');
screenLight.intensity = 0.0;
tvScreen.material.dispose();
tvScreen.material = new THREE.MeshPhongMaterial({
state.screenLight.intensity = 0.0;
state.tvScreen.material.dispose();
state.tvScreen.material = new THREE.MeshPhongMaterial({
color: 0x0a0a0a, // Deep black
shininess: 5,
specular: 0x111111
});
tvScreen.material.needsUpdate = true;
isVideoLoaded = false;
lastUpdateTime = -1; // force VCR to redraw
state.tvScreen.material.needsUpdate = true;
state.isVideoLoaded = false;
state.lastUpdateTime = -1; // force VCR to redraw
return;
}
videoElement.src = url;
videoElement.muted = true;
videoElement.load();
state.videoElement.src = url;
state.videoElement.muted = true;
state.videoElement.load();
// Set loop property: only loop if it's the only video loaded
videoElement.loop = false; //videoUrls.length === 1;
state.videoElement.loop = false; //state.videoUrls.length === 1;
videoElement.onloadeddata = () => {
state.videoElement.onloadeddata = () => {
// 1. Create the Three.js texture
videoTexture = new THREE.VideoTexture(videoElement);
videoTexture.minFilter = THREE.LinearFilter;
videoTexture.magFilter = THREE.LinearFilter;
videoTexture.format = THREE.RGBAFormat;
videoTexture.needsUpdate = true;
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
tvScreen.material.dispose();
tvScreen.material = new THREE.MeshBasicMaterial({ map: videoTexture });
tvScreen.material.needsUpdate = true;
state.tvScreen.material.dispose();
state.tvScreen.material = new THREE.MeshBasicMaterial({ map: state.videoTexture });
state.tvScreen.material.needsUpdate = true;
// 3. Start playback
videoElement.play().then(() => {
isVideoLoaded = true;
state.videoElement.play().then(() => {
state.isVideoLoaded = true;
// Use the defined base intensity for screen glow
screenLight.intensity = originalScreenIntensity;
state.screenLight.intensity = state.originalScreenIntensity;
// Initial status message with tape count
console.info(`Playing tape ${currentVideoIndex + 1} of ${videoUrls.length}.`);
console.info(`Playing tape ${state.currentVideoIndex + 1} of ${state.videoUrls.length}.`);
}).catch(error => {
screenLight.intensity = originalScreenIntensity * 0.5; // Dim the light if playback fails
console.error(`Playback blocked for tape ${currentVideoIndex + 1}. Click Next Tape to try again.`);
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);
});
};
videoElement.onerror = (e) => {
screenLight.intensity = 0.1; // Keep minimum intensity for shadow map
console.error(`Error loading tape ${currentVideoIndex + 1}.`);
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 ---
function playNextVideo() {
export function playNextVideo() {
// Determine the next index, cycling back to 0 if we reach the end
let nextIndex = currentVideoIndex + 1;
if (nextIndex < videoUrls.length) {
baseTime += videoElement.duration;
let nextIndex = state.currentVideoIndex + 1;
if (nextIndex < state.videoUrls.length) {
state.baseTime += state.videoElement.duration;
}
playVideoByIndex(nextIndex);
}
// --- Video Loading Logic (handles multiple files) ---
function loadVideoFile(event) {
export function loadVideoFile(event) {
const files = event.target.files;
if (files.length === 0) {
console.info('File selection cancelled.');
@ -86,25 +89,25 @@ function loadVideoFile(event) {
}
// 1. Clear previous URLs and revoke object URLs to prevent memory leaks
videoUrls.forEach(url => URL.revokeObjectURL(url));
videoUrls = [];
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/')) {
videoUrls.push(URL.createObjectURL(file));
state.videoUrls.push(URL.createObjectURL(file));
}
}
if (videoUrls.length === 0) {
if (state.videoUrls.length === 0) {
console.info('No valid video files selected.');
return;
}
// 3. Start playback of the first video
console.info(`Loaded ${videoUrls.length} tapes. Starting playback...`);
loadTapeButton.classList.add("hidden");
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.`);