diff --git a/public/js/webgl-book-shape-lab.js b/public/js/webgl-book-shape-lab.js index 29c968d..7e4c3aa 100644 --- a/public/js/webgl-book-shape-lab.js +++ b/public/js/webgl-book-shape-lab.js @@ -6,8 +6,10 @@ const progressInput = document.getElementById('progress'); const progressValue = document.getElementById('progress_value'); const pageCountInput = document.getElementById('page_count'); const pageCountValue = document.getElementById('page_count_value'); +const fastBackwardButton = document.getElementById('fast_backward'); const flipBackwardButton = document.getElementById('flip_backward'); const flipForwardButton = document.getElementById('flip_forward'); +const fastForwardButton = document.getElementById('fast_forward'); const flipCountValue = document.getElementById('flip_count'); const urlParams = new URLSearchParams(window.location.search); @@ -58,14 +60,17 @@ const BOOK_PROFILE = { singlePageCoverGap: 0.006, bundleSpacing: 0.014 }; +const NORMAL_FLIP_DURATION = 1800; +const FAST_FLIP_DURATION = 900; +const FAST_FLIP_COUNT = 10; +const FAST_FLIP_OVERLAP = 5; let readingProgress = readInitialProgress(); let pageCount = readInitialPageCount(); let lastLengthError = 0; let lastSpacingError = 0; let lastBookModel = null; -let activeFlip = null; -let activeFlipMesh = null; +let activeFlips = []; let pendingPageFlips = 0; progressInput.value = readingProgress.toFixed(3); progressValue.value = readingProgress.toFixed(2); @@ -84,6 +89,10 @@ pageCountInput.addEventListener('input', () => { setPageCount(pageCountInput.value); }); +fastBackwardButton.addEventListener('click', () => { + startFastPageFlip(-1); +}); + flipBackwardButton.addEventListener('click', () => { startPageFlip(-1); }); @@ -92,6 +101,10 @@ flipForwardButton.addEventListener('click', () => { startPageFlip(1); }); +fastForwardButton.addEventListener('click', () => { + startFastPageFlip(1); +}); + window.addEventListener('resize', resize); window.BookShapeLab = { @@ -120,6 +133,12 @@ window.BookShapeLab = { }, flipBackward() { return startPageFlip(-1); + }, + fastForward() { + return startFastPageFlip(1); + }, + fastBackward() { + return startFastPageFlip(-1); } }; @@ -780,22 +799,55 @@ function createEndpointPolyline(lines, depth) { } function startPageFlip(direction) { - if (activeFlip || !lastBookModel || !canPageFlip(direction)) return false; + if (activeFlips.length || !lastBookModel || !canPageFlip(direction)) return false; + const flip = createPageFlip(direction, performance.now(), NORMAL_FLIP_DURATION); + if (!flip) return false; + + activeFlips.push(flip); + updateFlipControls(); + updateActiveFlips(flip.startTime); + return true; +} + +function startFastPageFlip(direction) { + if (activeFlips.length || !lastBookModel || !canPageFlip(direction)) return false; + const firstFlip = createPageFlip(direction, performance.now(), FAST_FLIP_DURATION); + if (!firstFlip) return false; + + const startTime = firstFlip.startTime; + const interval = FAST_FLIP_DURATION / FAST_FLIP_OVERLAP; + for (let index = 0; index < FAST_FLIP_COUNT; index += 1) { + activeFlips.push({ + ...firstFlip, + mesh: null, + startTime: startTime + index * interval, + pageOffset: index * 0.002, + commitBundleOnFinish: index === FAST_FLIP_COUNT - 1, + countAsPending: false + }); + } + updateFlipControls(); + updateActiveFlips(startTime); + return true; +} + +function createPageFlip(direction, startTime, duration) { const sourceSide = direction > 0 ? 1 : -1; const sourceLine = topVisibleLine(sourceSide); const destinationLine = topVisibleLine(-sourceSide); - if (!sourceLine || !destinationLine) return false; + if (!sourceLine || !destinationLine) return null; - activeFlip = { + return { direction, sourceLine, destinationLine, - startTime: performance.now(), - duration: 1800 + startTime, + duration, + pageOffset: 0, + commitBundleOnFinish: false, + countAsPending: true, + mesh: null }; - updateFlipControls(); - updateActiveFlip(activeFlip.startTime); - return true; } function canPageFlip(direction) { @@ -811,18 +863,23 @@ function topVisibleLine(side) { return sideLines[sideLines.length - 1] ?? null; } -function updateActiveFlip(now) { - if (!activeFlip || !lastBookModel) return; - const elapsed = (now - activeFlip.startTime) / activeFlip.duration; - const t = THREE.MathUtils.clamp(elapsed, 0, 1); - const surface = buildFlippingPageSurface(activeFlip.sourceLine, activeFlip.destinationLine, activeFlip.direction, easeInOutCubic(t)); - setActivePageGeometry(surface); - if (t < 1) return; - - finishActiveFlip(); +function updateActiveFlips(now) { + if (!activeFlips.length || !lastBookModel) return; + const completed = []; + activeFlips.forEach((flip) => { + const elapsed = (now - flip.startTime) / flip.duration; + if (elapsed < 0) return; + const t = THREE.MathUtils.clamp(elapsed, 0, 1); + const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset); + setActivePageGeometry(flip, surface); + if (t >= 1) completed.push(flip); + }); + completed.forEach((flip) => { + finishActiveFlip(flip); + }); } -function buildFlippingPageSurface(sourceLine, destinationLine, direction, t) { +function buildFlippingPageSurface(sourceLine, destinationLine, direction, t, pageOffset = 0) { const widthSegments = sourceLine.points.length - 1; const depthSegments = 18; const zFront = lastBookModel.pageDepth * 0.5 + 0.018; @@ -852,7 +909,7 @@ function buildFlippingPageSurface(sourceLine, destinationLine, direction, t) { const relaxedY = THREE.MathUtils.lerp(stackPoint.y, anchor.y + Math.sin(angle) * radius, lift); const point = { x: anchor.x + Math.cos(angle) * radius, - y: relaxedY + 0.055 * lift * Math.sin(Math.PI * u), + y: relaxedY + pageOffset + 0.055 * lift * Math.sin(Math.PI * u), z }; keepFlippingSurfacePointAboveStacks(point, lift); @@ -930,15 +987,15 @@ function lineYAtX(points, x) { return y; } -function setActivePageGeometry(surface) { +function setActivePageGeometry(flip, surface) { const geometry = createFlippingPageGeometry(surface); - if (!activeFlipMesh) { - activeFlipMesh = new THREE.Mesh(geometry, materials.flippingPage); - book.add(activeFlipMesh); + if (!flip.mesh) { + flip.mesh = new THREE.Mesh(geometry, materials.flippingPage); + book.add(flip.mesh); return; } - activeFlipMesh.geometry.dispose(); - activeFlipMesh.geometry = geometry; + flip.mesh.geometry.dispose(); + flip.mesh.geometry = geometry; } function createFlippingPageGeometry(surface) { @@ -1002,31 +1059,51 @@ function createFlippingPageGeometry(surface) { } } -function finishActiveFlip() { - const direction = activeFlip.direction; - clearActiveFlip(); - pendingPageFlips += direction; +function finishActiveFlip(flip) { + removeFlipMesh(flip); + activeFlips = activeFlips.filter((active) => active !== flip); + if (flip.commitBundleOnFinish) { + shiftReadingProgressByBundle(flip.direction); + return; + } + if (!flip.countAsPending) { + updateFlipControls(); + return; + } + + pendingPageFlips += flip.direction; if (Math.abs(pendingPageFlips) >= 10) { const commitDirection = Math.sign(pendingPageFlips); pendingPageFlips -= commitDirection * 10; - const step = 1 / (lastBookModel.bundleCount - 1); - setReadingProgress(readingProgress + commitDirection * step); + shiftReadingProgressByBundle(commitDirection); return; } updateFlipControls(); } +function shiftReadingProgressByBundle(direction) { + const step = 1 / (lastBookModel.bundleCount - 1); + setReadingProgress(readingProgress + direction * step); +} + function clearActiveFlip() { - activeFlip = null; - if (!activeFlipMesh) return; - book.remove(activeFlipMesh); - activeFlipMesh.geometry.dispose(); - activeFlipMesh = null; + activeFlips.forEach(removeFlipMesh); + activeFlips = []; +} + +function removeFlipMesh(flip) { + if (!flip.mesh) return; + book.remove(flip.mesh); + flip.mesh.geometry.dispose(); + flip.mesh = null; } function updateFlipControls() { - flipBackwardButton.disabled = Boolean(activeFlip) || !canPageFlip(-1); - flipForwardButton.disabled = Boolean(activeFlip) || !canPageFlip(1); + const busy = activeFlips.length > 0; + fastBackwardButton.disabled = busy || !canPageFlip(-1); + flipBackwardButton.disabled = busy || !canPageFlip(-1); + flipForwardButton.disabled = busy || !canPageFlip(1); + fastForwardButton.disabled = busy || !canPageFlip(1); flipCountValue.textContent = `${Math.abs(pendingPageFlips)} / 10`; } @@ -1048,7 +1125,7 @@ function animate() { const t = performance.now() * 0.00035; setReadingProgress(0.5 + Math.sin(t) * 0.48); } - updateActiveFlip(performance.now()); + updateActiveFlips(performance.now()); controls.update(); renderer.render(scene, camera); } diff --git a/public/webgl-book-shape-lab.html b/public/webgl-book-shape-lab.html index fd30fa5..1587b21 100644 --- a/public/webgl-book-shape-lab.html +++ b/public/webgl-book-shape-lab.html @@ -68,10 +68,12 @@ 240 + + 0 / 10 - +