Feature: Lectern with spellbook flipping pages

This commit is contained in:
Dejvino 2025-11-20 22:13:13 +01:00
parent a71fd38e9e
commit bb923968e5
4 changed files with 219 additions and 0 deletions

View File

@ -5,6 +5,7 @@ import { updateScreenEffect } from '../scene/magic-mirror.js'
import { updateCauldron } from '../scene/cauldron.js'; import { updateCauldron } from '../scene/cauldron.js';
import { updateFire } from '../scene/fireplace.js'; import { updateFire } from '../scene/fireplace.js';
import { updateRats } from '../scene/rat.js'; import { updateRats } from '../scene/rat.js';
import { updateLectern } from '../scene/lectern.js';
function updateCamera() { function updateCamera() {
const globalTime = Date.now() * 0.00003; const globalTime = Date.now() * 0.00003;
@ -160,6 +161,7 @@ export function animate() {
updateScreenEffect(); updateScreenEffect();
updateFire(); updateFire();
updateCauldron(); updateCauldron();
updateLectern();
updateRats(); updateRats();
// RENDER! // RENDER!

View File

@ -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;
}
}
}

View File

@ -7,6 +7,7 @@ import { createFireplace } from './fireplace.js';
import { createTable } from './table.js'; import { createTable } from './table.js';
import { createCauldron } from './cauldron.js'; import { createCauldron } from './cauldron.js';
import { createRats } from './rat.js'; import { createRats } from './rat.js';
import { createLectern } from './lectern.js';
import { PictureFrame } from './PictureFrame.js'; import { PictureFrame } from './PictureFrame.js';
import painting1 from '/textures/painting1.jpg'; import painting1 from '/textures/painting1.jpg';
import painting2 from '/textures/painting2.jpg'; import painting2 from '/textures/painting2.jpg';
@ -142,6 +143,9 @@ export function createSceneObjects() {
// --- 9. Fireplace --- // --- 9. Fireplace ---
createFireplace(state.roomSize / 2 - 0.5, -1, -Math.PI / 2); 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); 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); //createBookshelf(-state.roomSize/2 + 0.2, state.roomSize/2*0.1, Math.PI/2, 0);

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB