diff --git a/magic-mirror/src/core/animate.js b/magic-mirror/src/core/animate.js index 28c427f..d073f47 100644 --- a/magic-mirror/src/core/animate.js +++ b/magic-mirror/src/core/animate.js @@ -5,6 +5,7 @@ import { updateScreenEffect } from '../scene/magic-mirror.js' import { updateCauldron } from '../scene/cauldron.js'; import { updateFire } from '../scene/fireplace.js'; import { updateRats } from '../scene/rat.js'; +import { updateLectern } from '../scene/lectern.js'; function updateCamera() { const globalTime = Date.now() * 0.00003; @@ -160,6 +161,7 @@ export function animate() { updateScreenEffect(); updateFire(); updateCauldron(); + updateLectern(); updateRats(); // RENDER! diff --git a/magic-mirror/src/scene/lectern.js b/magic-mirror/src/scene/lectern.js new file mode 100644 index 0000000..76966d6 --- /dev/null +++ b/magic-mirror/src/scene/lectern.js @@ -0,0 +1,213 @@ +import * as THREE from 'three'; +import { state } from '../state.js'; +import woodTextureUrl from '/textures/wood.png'; +import spellbookSpritesheetUrl from '/textures/pages_sheet.png'; + +let lecternState = { + isFlipping: false, + flipProgress: 0, + flipSpeed: 0.015, + leftPageIndex: 0, + rightPageIndex: 1, + totalPages: 4, // 2x2 spritesheet + pageFlipChance: 0.01, + flippingPage: null, + flippingPageMaterials: null, + leftPage: null, + rightPage: null, + spritesheetTexture: null, +}; + +const bookWidth = 0.7; +const bookHeight = 0.45; + +function setPageTexture(material, pageIndex) { + const texture = material.map; + if (!texture) return; + + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; + texture.repeat.set(0.5, 0.5); + + const col = pageIndex % 2; + const row = 1 - Math.floor(pageIndex / 2); // Invert row for THREE.js UVs (bottom-left origin) + + texture.offset.set(col * 0.5, row * 0.5); +} + +export function createLectern(x, y, z, rotY) { + const lecternGroup = new THREE.Group(); + + // --- Materials --- + const woodMaterial = new THREE.MeshPhongMaterial({ + map: state.loader.load(woodTextureUrl), + color: 0x6b4f3a, + shininess: 20 + }); + + // 1. Lectern Stand + const baseGeo = new THREE.BoxGeometry(0.6, 0.05, 0.6); + const base = new THREE.Mesh(baseGeo, woodMaterial); + base.castShadow = true; + lecternGroup.add(base); + + const poleGeo = new THREE.CylinderGeometry(0.08, 0.1, 1.0, 12); + const pole = new THREE.Mesh(poleGeo, woodMaterial); + pole.position.y = 0.5; + pole.castShadow = true; + lecternGroup.add(pole); + + const topGeo = new THREE.BoxGeometry(0.8, 0.05, 0.5); + const top = new THREE.Mesh(topGeo, woodMaterial); + top.position.y = 1.1; + top.rotation.x = Math.PI/2 + -Math.PI * 0.3; // Slanted top + top.castShadow = true; + lecternGroup.add(top); + + // 2. Spellbook + const bookGroup = new THREE.Group(); + bookGroup.position.y = 1.2; + bookGroup.position.z = 0.01; + bookGroup.rotation.x = -Math.PI * 0.3; + + const pageGeo = new THREE.PlaneGeometry(bookWidth / 2, bookHeight); + + // 2.6. Stack of Pages + const pageStackHeight = 0.08; + const pageStackGeo = new THREE.BoxGeometry(bookWidth, bookHeight, pageStackHeight); + const pageStackMaterial = new THREE.MeshPhongMaterial({ color: 0xffeedd, shininess: 5 }); + const pageStack = new THREE.Mesh(pageStackGeo, pageStackMaterial); + pageStack.position.x = 0.01; + pageStack.position.z = -pageStackHeight/2; // Position it just behind the pages + pageStack.position.y = 0.01; // Position it a bit lower + pageStack.castShadow = true; + bookGroup.add(pageStack); + + + // 2.5. Book Cover + const coverMaterial = new THREE.MeshPhongMaterial({ color: 0x8B0000, shininess: 15 }); // DarkRed + const coverWidth = bookWidth + 0.04; + const coverHeight = bookHeight + 0.04; + const coverDepth = 0.01; + const coverGeo = new THREE.BoxGeometry(coverWidth, coverHeight, coverDepth); + const bookCover = new THREE.Mesh(coverGeo, coverMaterial); + // Position it just behind the pages + bookCover.position.x = 0.01; + bookCover.position.z = -pageStackHeight/2 -coverDepth / 2 - 0.01; + bookCover.castShadow = true; + bookGroup.add(bookCover); + + // Load single spritesheet texture + lecternState.spritesheetTexture = state.loader.load(spellbookSpritesheetUrl); + + // Create page materials + // We clone the material so each page can have an independent texture offset + const leftPageMat = new THREE.MeshPhongMaterial({ map: lecternState.spritesheetTexture.clone() }); + const rightPageMat = new THREE.MeshPhongMaterial({ map: lecternState.spritesheetTexture.clone() }); + + // Left and Right static pages + setPageTexture(leftPageMat, lecternState.leftPageIndex); + setPageTexture(rightPageMat, lecternState.rightPageIndex); + + lecternState.leftPage = new THREE.Mesh(pageGeo, leftPageMat); + lecternState.leftPage.position.x = -bookWidth / 4; + lecternState.leftPage.position.z = 0.01; + lecternState.leftPage.visible = true; + lecternState.leftPage.receiveShadow = true; + bookGroup.add(lecternState.leftPage); + + lecternState.rightPage = new THREE.Mesh(pageGeo, rightPageMat); + lecternState.rightPage.position.x = bookWidth / 4; + lecternState.rightPage.position.z = 0.01; + lecternState.rightPage.visible = true; + lecternState.rightPage.receiveShadow = true; + bookGroup.add(lecternState.rightPage); + + + // The page that will animate + const flippingPageMat = new THREE.MeshPhongMaterial({ + map: lecternState.spritesheetTexture.clone(), + }); + const flippingPageBackMat = new THREE.MeshPhongMaterial({ + map: lecternState.spritesheetTexture.clone(), + }); + // Assign a name to identify the back material in setPageTexture + flippingPageBackMat.name = 'back'; + lecternState.flippingPageMaterials = [flippingPageMat, flippingPageBackMat]; + + // Create a group for the flipping page + const flippingPageGroup = new THREE.Group(); + + const frontPage = new THREE.Mesh(pageGeo, flippingPageMat); + const backPage = new THREE.Mesh(pageGeo, flippingPageBackMat); + backPage.rotation.y = Math.PI; // Rotate the back page to face the other way + + flippingPageGroup.add(frontPage); + flippingPageGroup.add(backPage); + + lecternState.flippingPage = flippingPageGroup; // Assign the group to the state + lecternState.flippingPage.position.x = bookWidth / 4; + lecternState.flippingPage.visible = false; + lecternState.flippingPage.castShadow = true; + bookGroup.add(lecternState.flippingPage); + + lecternGroup.add(bookGroup); + + // Position and add to scene + lecternGroup.position.set(x, y, z); + lecternGroup.rotation.y = rotY; + state.scene.add(lecternGroup); +} + +export function updateLectern() { + const { isFlipping, pageFlipChance, flippingPage, flippingPageMaterials, leftPage, rightPage } = lecternState; + + // Randomly decide to start flipping a page + if (!isFlipping && Math.random() < pageFlipChance) { + lecternState.isFlipping = true; + lecternState.flipProgress = 0; + + const nextPageIndex = (lecternState.rightPageIndex + 1) % lecternState.totalPages; + const nextNextPageIndex = (lecternState.rightPageIndex + 2) % lecternState.totalPages; + + // Set the front of the flipping page to the current right page's content + setPageTexture(flippingPageMaterials[0], lecternState.rightPageIndex); + // Set the back of the flipping page to what will be the new right page's content + setPageTexture(flippingPageMaterials[1], nextPageIndex); + flippingPageMaterials[0].map.needsUpdate = true; + flippingPageMaterials[1].map.needsUpdate = true; + flippingPage.visible = true; + flippingPage.position.x = rightPage.position.x; + flippingPage.rotation.y = 0; + + // Immediately update the static right page to show the *next* page, and keep it visible. + setPageTexture(rightPage.material, nextNextPageIndex); + rightPage.material.needsUpdate = true; + } + + if (isFlipping) { + lecternState.flipProgress += lecternState.flipSpeed; + const progress = lecternState.flipProgress; + + // Animate the page turning from right to left (0 to PI) + flippingPage.rotation.y = -progress * Math.PI; + // Move it in an arc + flippingPage.position.x = (rightPage.position.x) * Math.cos(progress * Math.PI); + flippingPage.position.z = Math.sin(progress * Math.PI) * bookWidth/4 + 0.015; + + // When flip is complete + if (progress >= 1.0) { + // The left page now shows the content of the page that just landed. + lecternState.leftPageIndex = (lecternState.rightPageIndex + 1) % lecternState.totalPages; + // The right page index officially becomes the next page's index. + lecternState.rightPageIndex = (lecternState.rightPageIndex + 2) % lecternState.totalPages; + + // Update the left page's texture to finalize the flip. + setPageTexture(leftPage.material, lecternState.leftPageIndex); + + // Reset state + flippingPage.visible = false; + lecternState.isFlipping = false; + } + } +} \ No newline at end of file diff --git a/magic-mirror/src/scene/root.js b/magic-mirror/src/scene/root.js index 615fe75..ee6faff 100644 --- a/magic-mirror/src/scene/root.js +++ b/magic-mirror/src/scene/root.js @@ -7,6 +7,7 @@ import { createFireplace } from './fireplace.js'; import { createTable } from './table.js'; import { createCauldron } from './cauldron.js'; import { createRats } from './rat.js'; +import { createLectern } from './lectern.js'; import { PictureFrame } from './PictureFrame.js'; import painting1 from '/textures/painting1.jpg'; import painting2 from '/textures/painting2.jpg'; @@ -142,6 +143,9 @@ export function createSceneObjects() { // --- 9. Fireplace --- createFireplace(state.roomSize / 2 - 0.5, -1, -Math.PI / 2); + // --- Lectern --- + createLectern(-state.roomSize/2 + 0.4, 0, -0.1, Math.PI / 2.3); + createRats(state.roomSize/2 - 0.01, 0, 0.37, -Math.PI / 2); //createBookshelf(-state.roomSize/2 + 0.2, state.roomSize/2*0.1, Math.PI/2, 0); diff --git a/magic-mirror/textures/pages_sheet.png b/magic-mirror/textures/pages_sheet.png new file mode 100644 index 0000000..720f9ea Binary files /dev/null and b/magic-mirror/textures/pages_sheet.png differ