diff --git a/public/css/style.css b/public/css/style.css index 2c1465d..1bc61ae 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1940,7 +1940,8 @@ body.webgl-mode { background: #090705; } -#webgl_canvas { +#webgl_canvas, +#scene { position: absolute; inset: 0; width: 100%; @@ -1981,7 +1982,20 @@ body.webgl-mode { flex: 0 0 auto; } +.control_group { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.control_group label, +#lab_status { + white-space: nowrap; +} + #top_menu_controls button, +.transport_button, .modal-overview-row { font-family: 'EB Garamond', serif; font-size: 14px; @@ -1995,10 +2009,24 @@ body.webgl-mode { } #top_menu_controls button:hover, +.transport_button:hover, .modal-overview-row:hover { background: rgba(87, 55, 31, 0.78); } +.transport_button { + width: 28px; + height: 26px; + display: grid; + place-items: center; + padding: 0; +} + +.transport_button:disabled { + cursor: var(--default-cursor, default); + opacity: 0.38; +} + #modal_overview { position: fixed; z-index: 45; @@ -2038,47 +2066,6 @@ body.webgl-mode { color: rgba(246, 231, 201, 0.62); } -body.webgl-mode #book { - position: fixed; - z-index: 20; - inset: 38px 0 0; - width: 100vw; - height: calc(100vh - 38px); - max-width: none; - max-height: none; - background: transparent; - pointer-events: none; - transform: none; - opacity: 0; -} - -body.webgl-mode #page_left, -body.webgl-mode #page_right { - pointer-events: none; - top: 10%; - bottom: auto; - height: 68vh; - max-height: 760px; - width: min(31vw, 500px); - background: #f2dfb8; - border: 0; - box-shadow: none; - opacity: 1; - mix-blend-mode: normal; -} - -body.webgl-mode #page_left { - left: calc(50vw - min(33vw, 530px)); - transform: none; - transform-origin: right center; -} - -body.webgl-mode #page_right { - right: calc(50vw - min(33vw, 530px)); - transform: none; - transform-origin: left center; -} - body.webgl-mode #lighting { display: none; } @@ -2099,22 +2086,9 @@ body.webgl-mode #lighting { padding: 6px 7px; } - body.webgl-mode #book { - inset: 46px 0 0; + #lab_status, + .control_group label { + display: none; } - body.webgl-mode #page_left, - body.webgl-mode #page_right { - width: 44vw; - height: 66vh; - top: 12%; - } - - body.webgl-mode #page_left { - left: 6vw; - } - - body.webgl-mode #page_right { - right: 6vw; - } } diff --git a/public/js/loader.js b/public/js/loader.js index 54a0a10..5fccb04 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -24,7 +24,7 @@ const ModuleState = { ERROR: 'ERROR' }; -const MODULE_CACHE_BUSTER = '20260603-webgl-right-page-text'; +const MODULE_CACHE_BUSTER = '20260606-webgl-direct-page-crop-coords'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** diff --git a/public/js/options-ui-module.js b/public/js/options-ui-module.js index 02a519d..29d0f5e 100644 --- a/public/js/options-ui-module.js +++ b/public/js/options-ui-module.js @@ -53,7 +53,8 @@ class OptionsUIModule extends BaseModule { 'getPreference', 'updatePreference', 'updateUIText', - 'renderProviderStatuses' + 'renderProviderStatuses', + 'updateWebGLDisplays' ]); } @@ -249,6 +250,86 @@ class OptionsUIModule extends BaseModule { appSettingsSection.appendChild(speedContainer); body.appendChild(appSettingsSection); + + const webglSection = document.createElement('div'); + webglSection.className = 'options-section'; + + const webglTitle = document.createElement('h3'); + webglTitle.textContent = this.t('options.bookDisplay'); + webglSection.appendChild(webglTitle); + + const displayModeContainer = document.createElement('div'); + displayModeContainer.className = 'option-item'; + + const displayModeLabel = document.createElement('label'); + displayModeLabel.textContent = this.t('options.displayMode') + ':'; + displayModeContainer.appendChild(displayModeLabel); + + this.elements.webglMode = createUIElement('select', { + 'data-pref-bind': 'webgl.mode' + }, null, displayModeContainer); + [ + { value: '3d', label: this.t('options.displayMode3d') }, + { value: '2d', label: this.t('options.displayMode2d') } + ].forEach((optionConfig) => { + const option = document.createElement('option'); + option.value = optionConfig.value; + option.textContent = optionConfig.label; + this.elements.webglMode.appendChild(option); + }); + webglSection.appendChild(displayModeContainer); + + const bookSizeContainer = document.createElement('div'); + bookSizeContainer.className = 'option-item'; + + const bookSizeLabel = document.createElement('label'); + bookSizeLabel.textContent = this.t('options.bookSize') + ':'; + bookSizeContainer.appendChild(bookSizeLabel); + + const bookSizeValue = document.createElement('span'); + bookSizeValue.className = 'slider-value'; + bookSizeValue.textContent = '300'; + this.elements.webglBookSizeValue = bookSizeValue; + bookSizeContainer.appendChild(bookSizeValue); + + this.elements.webglBookSize = createUIElement('input', { + type: 'range', + min: 40, + max: 500, + step: 10, + value: 300, + 'data-pref-bind': 'webgl.bookPageCount', + 'data-pref-transform': 'integer:40,500' + }, null, bookSizeContainer); + this.elements.webglBookSize.addEventListener('input', () => this.updateWebGLDisplays()); + webglSection.appendChild(bookSizeContainer); + + const bookProgressContainer = document.createElement('div'); + bookProgressContainer.className = 'option-item'; + + const bookProgressLabel = document.createElement('label'); + bookProgressLabel.textContent = this.t('options.bookProgress') + ':'; + bookProgressContainer.appendChild(bookProgressLabel); + + const bookProgressValue = document.createElement('span'); + bookProgressValue.className = 'slider-value'; + bookProgressValue.textContent = '50%'; + this.elements.webglBookProgressValue = bookProgressValue; + bookProgressContainer.appendChild(bookProgressValue); + + this.elements.webglBookProgress = createUIElement('input', { + type: 'range', + min: 0, + max: 100, + step: 1, + value: 50, + 'data-pref-bind': 'webgl.bookProgress', + 'data-pref-transform': 'range:0,1' + }, null, bookProgressContainer); + this.elements.webglBookProgress.addEventListener('input', () => this.updateWebGLDisplays()); + webglSection.appendChild(bookProgressContainer); + + body.appendChild(webglSection); // TTS Section const ttsSection = document.createElement('div'); @@ -1020,6 +1101,7 @@ class OptionsUIModule extends BaseModule { console.log('Options UI: Preference bindings set up', this.bindings.length); this.updateSpeedDisplay(); this.updateVolumeDisplays(); + this.updateWebGLDisplays(); // Add event listeners for side effects when preferences change document.addEventListener('preference-updated', (event) => { @@ -1115,6 +1197,10 @@ class OptionsUIModule extends BaseModule { this.populateVoices(); } } + + if (category === 'webgl') { + this.updateWebGLDisplays(); + } if (key === 'speed' && this.elements.ttsSpeed) { this.updateSpeedDisplay(); } @@ -1155,6 +1241,15 @@ class OptionsUIModule extends BaseModule { this.elements.musicDuckingAmountValue.textContent = `${this.elements.musicDuckingAmount.value}%`; } } + + updateWebGLDisplays() { + if (this.elements.webglBookSize && this.elements.webglBookSizeValue) { + this.elements.webglBookSizeValue.textContent = String(this.elements.webglBookSize.value); + } + if (this.elements.webglBookProgress && this.elements.webglBookProgressValue) { + this.elements.webglBookProgressValue.textContent = `${this.elements.webglBookProgress.value}%`; + } + } } // Create the singleton instance diff --git a/public/js/persistence-manager-module.js b/public/js/persistence-manager-module.js index cee3c54..47ed0e1 100644 --- a/public/js/persistence-manager-module.js +++ b/public/js/persistence-manager-module.js @@ -67,6 +67,11 @@ class PersistenceManagerModule extends BaseModule { localeUserOverride: false, speed: 1.0, autoplay: true, + }, + webgl: { + mode: null, + bookPageCount: 300, + bookProgress: 0.5 } }; diff --git a/public/js/procedural-book-model.js b/public/js/procedural-book-model.js index aaa6235..99a50c4 100644 --- a/public/js/procedural-book-model.js +++ b/public/js/procedural-book-model.js @@ -6,7 +6,7 @@ export const PROCEDURAL_BOOK = { PAGE_COUNT_STEP: 10, PAGE_LINE_SEGMENTS: 48, PAGE_DEPTH: 2.24, - PAGE_WIDTH: 2.24 * 2 / 3, + PAGE_WIDTH: 2.24 * 0.806, COVER_DEPTH: 2.30, OPEN_SEAM_GAP: 0.003, PROFILE: { diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index cad39ff..2b539d3 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -4,7 +4,7 @@ import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js'; import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js'; import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js'; -import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js'; +import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260606-webgl-no-menu-offscreen-dom'; const canvas = document.getElementById('scene'); canvas.style.cursor = 'grab'; @@ -21,8 +21,13 @@ const tableDebugModes = { mirror: 10 }; const urlParams = new URLSearchParams(window.location.search); +const appInitialState = window.WebGLBookInitialState || {}; const tableDebugName = urlParams.get('tableDebug') || 'none'; const tableDebugMode = tableDebugModes[tableDebugName] ?? tableDebugModes.none; +const isAppIntegrationMode = appInitialState.appMode === true; +const html2CanvasPromise = isAppIntegrationMode + ? import('https://esm.sh/html2canvas@1.4.1') + : null; const labStatus = document.getElementById('lab_status'); if (labStatus && tableDebugMode !== tableDebugModes.none) { labStatus.textContent = tableDebugName === 'ao' ? 'scene debug: SSAO' : `table debug: ${tableDebugName}`; @@ -38,8 +43,14 @@ renderer.shadowMap.type = THREE.VSMShadowMap; const generatedTextureCanvases = {}; const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy(); const reflectionPixelRatio = Math.min(window.devicePixelRatio || 1, 2); -const pageTextureWidth = 3200; +const pageTextureWidth = isAppIntegrationMode ? 1280 : 3200; +const appPageTextureInset = 0; const reflectionTargetSize = new THREE.Vector2(); +const pageRaycaster = new THREE.Raycaster(); +const pointerNdc = new THREE.Vector2(); +let pageTextureRenderSerial = 0; +let pageTextureRenderInProgress = false; +let pageTextureRenderPending = false; let sceneComposerTarget = null; let composer = null; let sceneRenderPass = null; @@ -47,6 +58,7 @@ let sceneAoPass = null; let sceneSmaaPass = null; let sceneOutputPass = null; const aoExcludedObjects = new Set(); +let renderedFrameCount = 0; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x080604); @@ -59,11 +71,13 @@ let tableDustTexture = null; let tableGreaseTexture = null; const tableTopY = -0.02; const bookTableContactClearance = 0.002; -const tableReflectionTarget = new THREE.WebGLRenderTarget(4096, 2304, { +const tableReflectionBaseWidth = isAppIntegrationMode ? 1280 : 4096; +const tableReflectionBaseHeight = isAppIntegrationMode ? 720 : 2304; +const tableReflectionTarget = new THREE.WebGLRenderTarget(tableReflectionBaseWidth, tableReflectionBaseHeight, { colorSpace: THREE.SRGBColorSpace, depthBuffer: true, stencilBuffer: false, - samples: renderer.capabilities.isWebGL2 ? 8 : 0 + samples: renderer.capabilities.isWebGL2 ? (isAppIntegrationMode ? 2 : 8) : 0 }); tableReflectionTarget.texture.colorSpace = THREE.SRGBColorSpace; tableReflectionTarget.texture.minFilter = THREE.LinearFilter; @@ -82,7 +96,7 @@ const reflectionUp = new THREE.Vector3(); const candleShadowSources = []; const candleWorldPosition = new THREE.Vector3(); const flameWorldPosition = new THREE.Vector3(); -const bookShadowMapSize = 1536; +const bookShadowMapSize = isAppIntegrationMode ? 512 : 1536; const bookShadowTargets = Array.from({ length: 3 }, () => { const target = new THREE.WebGLRenderTarget(bookShadowMapSize, bookShadowMapSize, { colorSpace: THREE.NoColorSpace, @@ -119,6 +133,7 @@ const cameraRig = { minRadius: 2.4, maxRadius: 9.0, dragging: false, + navigationActive: false, pointerX: 0, pointerY: 0, keys: new Set() @@ -135,9 +150,9 @@ configureScenePostprocessing(); const clock = new THREE.Clock(); const book = new THREE.Group(); scene.add(book); -const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? '0.28'), 0, 1); +const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0.28'), 0, 1); let readingProgress = Number.isFinite(initialReadingProgress) ? initialReadingProgress : 0.28; -let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? '240'); +let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? appInitialState.pageCount ?? '240'); let currentProceduralBookModel = null; const progressInput = document.getElementById('progress_control'); const progressValue = document.getElementById('progress_value'); @@ -382,12 +397,34 @@ window.BookLabDebug = { setReadingProgress(value); return readingProgress; }, + setBookPageCount(value) { + setBookPageCount(value); + return bookPageCount; + }, + redrawPageTextures() { + redrawPageTexturesFromDom(); + return true; + }, + getTextureInfo() { + return { + pageTextureWidth, + pageTextureHeight: leftCanvas.height, + appPageTextureInset, + debug: getPageTextureDebugState() + }; + }, + projectPointerToPage(clientX, clientY) { + return projectPointerToPage(clientX, clientY); + }, exportTexture(name) { + if (name === 'left' || name === 'leftPage') return leftCanvas.toDataURL('image/png'); + if (name === 'right' || name === 'rightPage') return rightCanvas.toDataURL('image/png'); return generatedTextureCanvases[name]?.toDataURL('image/png') || null; } }; window.addEventListener('resize', resize); +document.addEventListener('webgl-book:redraw-pages', redrawPageTexturesFromDom); installBookControls(); installCameraControls(); resize(); @@ -1362,6 +1399,7 @@ function setReadingProgress(value) { readingProgress = nextProgress; buildBook(); syncBookControls(); + window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress); } function setBookPageCount(value) { @@ -1370,6 +1408,7 @@ function setBookPageCount(value) { bookPageCount = nextPageCount; buildBook(); syncBookControls(); + window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount); } function stepReadingProgress(pageDelta) { @@ -1405,6 +1444,179 @@ function syncBookControls() { if (fastForwardButton) fastForwardButton.disabled = busy || !canPageFlip(1); } +function redrawPageTexturesFromDom() { + if (pageTextureRenderInProgress) { + pageTextureRenderPending = true; + return; + } + const leftSource = document.getElementById('page_left'); + const rightSource = document.getElementById('page_right'); + if (!leftSource && !rightSource) return; + pageTextureRenderInProgress = true; + const serial = ++pageTextureRenderSerial; + (async () => { + try { + if (leftSource && await drawDomPageTexture(leftCanvas, leftSource, 'left')) { + leftTexture.needsUpdate = true; + } + if (rightSource && await drawDomPageTexture(rightCanvas, rightSource, 'right')) { + rightTexture.needsUpdate = true; + } + } finally { + pageTextureRenderInProgress = false; + if (pageTextureRenderPending && serial === pageTextureRenderSerial) { + pageTextureRenderPending = false; + redrawPageTexturesFromDom(); + } + } + })(); +} + +async function drawDomPageTexture(canvas, source, side) { + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#fffaf0'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const shade = ctx.createLinearGradient(0, 0, canvas.width, 0); + shade.addColorStop(0, 'rgba(93, 55, 24, 0.10)'); + shade.addColorStop(side === 'left' ? 0.85 : 0.15, 'rgba(255, 255, 255, 0)'); + shade.addColorStop(1, 'rgba(85, 49, 21, 0.08)'); + ctx.fillStyle = shade; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const painted = await paintRasterizedDomPage(ctx, canvas, source); + updatePageTextureDebugState(side, canvas, source, painted); + return painted; +} + +function getPageTextureDebugState() { + const rawState = document.documentElement.dataset.webglPageTextures; + if (!rawState) return {}; + try { + return JSON.parse(rawState); + } catch (error) { + return {}; + } +} + +function updatePageTextureDebugState(side, canvas, source, painted) { + const state = getPageTextureDebugState(); + state[side] = { + painted, + width: canvas.width, + height: canvas.height, + sourceId: source.id || '', + sourceTextLength: source.textContent?.trim().length || 0, + darkPixels: countPageTextureDarkPixels(canvas) + }; + document.documentElement.dataset.webglPageTextures = JSON.stringify(state); +} + +function countPageTextureDarkPixels(canvas) { + const sampleCanvas = document.createElement('canvas'); + const sampleSize = 64; + sampleCanvas.width = sampleSize; + sampleCanvas.height = sampleSize; + const sampleContext = sampleCanvas.getContext('2d'); + sampleContext.drawImage(canvas, 0, 0, sampleSize, sampleSize); + const pixels = sampleContext.getImageData(0, 0, sampleSize, sampleSize).data; + let darkPixels = 0; + for (let index = 0; index < pixels.length; index += 4) { + const alpha = pixels[index + 3]; + if (alpha < 8) continue; + const luminance = pixels[index] * 0.2126 + pixels[index + 1] * 0.7152 + pixels[index + 2] * 0.0722; + if (luminance < 96) darkPixels += 1; + } + return darkPixels; +} + +async function paintRasterizedDomPage(ctx, canvas, source) { + const pageRect = source.getBoundingClientRect(); + if (pageRect.width <= 0 || pageRect.height <= 0) return false; + const captured = await captureDomPageWithHtml2Canvas(source, pageRect, canvas); + if (captured) { + drawCapturedPageCanvas(ctx, canvas, captured); + return true; + } + return false; +} + +async function captureDomPageWithHtml2Canvas(source, pageRect, targetCanvas) { + if (!html2CanvasPromise) return null; + try { + const module = await html2CanvasPromise; + const html2canvas = module.default || module; + return await html2canvas(source, { + backgroundColor: null, + logging: false, + useCORS: true, + allowTaint: false, + foreignObjectRendering: true, + x: pageRect.left, + y: pageRect.top, + width: pageRect.width, + height: pageRect.height, + scrollX: 0, + scrollY: 0, + windowWidth: Math.ceil(Math.max(window.innerWidth, pageRect.right)), + windowHeight: Math.ceil(Math.max(window.innerHeight, pageRect.bottom)), + scale: Math.max(1, targetCanvas.width / pageRect.width) + }); + } catch (error) { + document.documentElement.dataset.webglLastCaptureError = error?.message || String(error); + return null; + } +} + +function drawCapturedPageCanvas(ctx, canvas, captured) { + const insetX = canvas.width * appPageTextureInset; + const insetY = canvas.height * appPageTextureInset * 0.35; + ctx.drawImage( + captured, + insetX, + insetY, + canvas.width - insetX * 2, + canvas.height - insetY * 2 + ); +} + +function projectPointerToPage(clientX, clientY) { + const rect = canvas.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; + pointerNdc.set( + ((clientX - rect.left) / rect.width) * 2 - 1, + -(((clientY - rect.top) / rect.height) * 2 - 1) + ); + pageRaycaster.setFromCamera(pointerNdc, camera); + const intersections = pageRaycaster.intersectObjects(book.children, true); + for (const hit of intersections) { + const pageSide = textureHitPageSide(hit); + if (!pageSide || !hit.uv) continue; + const insetX = appPageTextureInset; + const insetY = appPageTextureInset * 0.35; + const mappedX = THREE.MathUtils.clamp((hit.uv.x - insetX) / Math.max(0.001, 1 - insetX * 2), 0, 1); + const mappedY = 1 - THREE.MathUtils.clamp((hit.uv.y - insetY) / Math.max(0.001, 1 - insetY * 2), 0, 1); + return { + pageId: pageSide === 'left' ? 'page_left' : 'page_right', + x: mappedX, + y: mappedY, + uv: { x: hit.uv.x, y: hit.uv.y } + }; + } + return null; +} + +function textureHitPageSide(hit) { + const material = Array.isArray(hit.object.material) + ? hit.object.material[hit.face?.materialIndex ?? 0] + : hit.object.material; + if (material === materials.leftPage) return 'left'; + if (material === materials.rightPage) return 'right'; + if (material?.map === leftTexture) return 'left'; + if (material?.map === rightTexture) return 'right'; + return null; +} + function startPageFlip(direction) { if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false; const flip = createPageFlip(direction, performance.now(), normalFlipDuration); @@ -1956,41 +2168,9 @@ function createPageCanvas(side) { shade.addColorStop(1, 'rgba(85, 49, 21, 0.08)'); ctx.fillStyle = shade; ctx.fillRect(0, 0, canvas.width, canvas.height); - - ctx.fillStyle = inkColor; - ctx.textBaseline = 'top'; - const layout = hardcoverPageLayout(canvas, side); - if (side === 'left') { - drawTitlePage(ctx, layout); - } else { - drawNovelPage(ctx, layout, 'Click on new game or load to start the game'); - } return canvas; } -function hardcoverPageLayout(canvas, side) { - const inner = canvas.width * 0.125; - const outer = canvas.width * 0.075; - const top = canvas.height * 0.085; - const bottom = canvas.height * 0.115; - const margins = { - left: side === 'right' ? inner : outer, - right: side === 'right' ? outer : inner, - top, - bottom - }; - const width = canvas.width - margins.left - margins.right; - const height = canvas.height - margins.top - margins.bottom; - return { - margins, - x: margins.left, - y: margins.top, - width, - height, - em: width / 24 - }; -} - function createLeatherTextures() { const size = 1024; const colorCanvas = document.createElement('canvas'); @@ -2424,49 +2604,6 @@ function tintAmbientFromCanvas(canvas) { candleBounceLight.intensity = 0.28; } -function drawTitlePage(ctx, layout) { - const titleX = layout.x; - const titleWidth = layout.width; - drawCentered(ctx, 'Georg Tomitsch', layout.y + layout.height * 0.18, layout.em * 0.62, titleX, titleWidth); - drawCentered(ctx, 'Eibenreith', layout.y + layout.height * 0.235, layout.em * 1.72, titleX, titleWidth); - drawCentered(ctx, 'Ein Kaiserpunk Abenteuer', layout.y + layout.height * 0.315, layout.em * 0.76, titleX, titleWidth); - drawCentered(ctx, 'speech | autoplay | speed | new game | save | load | options', layout.y + layout.height * 0.47, layout.em * 0.42, titleX, titleWidth); - drawCentered(ctx, 'click on page or press spacebar to fast forward text animation', layout.y + layout.height * 0.56, layout.em * 0.42, titleX, titleWidth); -} - -function drawNovelPage(ctx, layout, text) { - const projectedX = Math.max(layout.margins.left * 0.25, layout.x - layout.margins.left * 0.75); - const projectedWidth = layout.width * 0.7; - drawParagraph(ctx, text, projectedX, layout.y + layout.height * 0.1, projectedWidth, layout.em * 0.72, 1.36, 0); -} - -function drawCentered(ctx, text, y, size, x = 0, width = ctx.canvas.width) { - ctx.font = `${Math.round(size)}px Georgia, "Times New Roman", serif`; - ctx.textAlign = 'center'; - ctx.fillText(text, x + width * 0.5, y); -} - -function drawParagraph(ctx, text, x, y, width, size, lineHeight, firstLineIndent = 0) { - const fontSize = Math.round(size); - ctx.font = `${fontSize}px Georgia, "Times New Roman", serif`; - ctx.textAlign = 'left'; - const words = text.split(/\s+/); - let line = ''; - let indent = firstLineIndent; - words.forEach((word) => { - const test = line ? `${line} ${word}` : word; - if (ctx.measureText(test).width > width - indent && line) { - ctx.fillText(line, x + indent, y); - line = word; - y += fontSize * lineHeight; - indent = 0; - } else { - line = test; - } - }); - if (line) ctx.fillText(line, x + indent, y); -} - function resize() { const width = Math.max(1, window.innerWidth); const height = Math.max(1, window.innerHeight); @@ -2481,8 +2618,8 @@ function resize() { 4096 / width, 2304 / height )); - const reflectionWidth = Math.floor(width * reflectionScale); - const reflectionHeight = Math.floor(height * reflectionScale); + const reflectionWidth = Math.min(tableReflectionBaseWidth, Math.floor(width * reflectionScale)); + const reflectionHeight = Math.min(tableReflectionBaseHeight, Math.floor(height * reflectionScale)); reflectionTargetSize.set(reflectionWidth, reflectionHeight); tableReflectionTarget.setSize( reflectionTargetSize.x, @@ -2491,8 +2628,14 @@ function resize() { } function installCameraControls() { + canvas.addEventListener('contextmenu', (event) => { + event.preventDefault(); + }); + canvas.addEventListener('pointerdown', (event) => { + if (event.button !== 2) return; cameraRig.dragging = true; + cameraRig.navigationActive = true; canvas.style.cursor = 'grabbing'; cameraRig.pointerX = event.clientX; cameraRig.pointerY = event.clientY; @@ -2515,17 +2658,23 @@ function installCameraControls() { }); canvas.addEventListener('pointerup', (event) => { + if (event.button !== 2) return; cameraRig.dragging = false; + cameraRig.navigationActive = false; + cameraRig.keys.clear(); canvas.style.cursor = 'grab'; canvas.releasePointerCapture(event.pointerId); }); canvas.addEventListener('pointercancel', () => { cameraRig.dragging = false; + cameraRig.navigationActive = false; + cameraRig.keys.clear(); canvas.style.cursor = 'grab'; }); canvas.addEventListener('wheel', (event) => { + if (!cameraRig.navigationActive) return; event.preventDefault(); const zoom = Math.exp(event.deltaY * 0.001); cameraRig.radius = THREE.MathUtils.clamp( @@ -2537,6 +2686,7 @@ function installCameraControls() { }, { passive: false }); window.addEventListener('keydown', (event) => { + if (!cameraRig.navigationActive) return; if (['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) { cameraRig.keys.add(event.code); event.preventDefault(); @@ -2679,6 +2829,7 @@ function updateTableReflection() { const previousXrEnabled = renderer.xr.enabled; const previousShadowAutoUpdate = renderer.shadowMap.autoUpdate; const previousToneMappingExposure = renderer.toneMappingExposure; + const pageTextureState = suppressPageContentMaps(); tableMesh.userData.wasVisibleForTableReflection = tableMesh.visible; tableMesh.visible = false; @@ -2692,10 +2843,29 @@ function updateTableReflection() { renderer.toneMappingExposure = previousToneMappingExposure; renderer.shadowMap.autoUpdate = previousShadowAutoUpdate; renderer.xr.enabled = previousXrEnabled; + restorePageContentMaps(pageTextureState); tableMesh.visible = tableMesh.userData.wasVisibleForTableReflection; delete tableMesh.userData.wasVisibleForTableReflection; } +function suppressPageContentMaps() { + if (!isAppIntegrationMode) return null; + return [materials.leftPage, materials.rightPage].map((material) => { + const previousMap = material.map; + material.map = null; + material.needsUpdate = true; + return { material, previousMap }; + }); +} + +function restorePageContentMaps(state) { + if (!state) return; + state.forEach(({ material, previousMap }) => { + material.map = previousMap; + material.needsUpdate = true; + }); +} + function renderMirrorDebugView() { const hiddenObjects = []; scene.traverse((object) => { @@ -2746,8 +2916,13 @@ function animate() { }); updateActiveFlips(performance.now()); updateCandleShadowUniforms(); - updateBookShadowMaps(); - updateTableReflection(); + renderedFrameCount += 1; + if (!isAppIntegrationMode || renderedFrameCount % 6 === 1 || activeFlips.length > 0) { + updateBookShadowMaps(); + } + if (!isAppIntegrationMode || renderedFrameCount % 4 === 1 || cameraRig.navigationActive || activeFlips.length > 0) { + updateTableReflection(); + } if (tableDebugMode === tableDebugModes.mirror) { renderer.setRenderTarget(null); renderer.clear(); diff --git a/public/js/webgl-book-scene-module.js b/public/js/webgl-book-scene-module.js index 5a4e7ed..27c391f 100644 --- a/public/js/webgl-book-scene-module.js +++ b/public/js/webgl-book-scene-module.js @@ -1,596 +1,493 @@ /** * WebGL Book Scene Module - * Creates the canvas-first UI shell and a page-turn-ready book scene. + * Hosts the procedural WebGL book lab scene inside the app shell. */ import { BaseModule } from './base-module.js'; +const DEFAULT_BOOK_PAGE_COUNT = 300; +const DEFAULT_BOOK_PROGRESS = 0.5; + class WebGLBookSceneModule extends BaseModule { constructor() { super('webgl-book-scene', 'WebGL Book Scene'); - this.dependencies = []; - this.THREE = null; - this.GLTFLoader = null; - this.renderer = null; - this.scene = null; - this.camera = null; - this.clock = null; - this.mixer = null; - this.openingAction = null; - this.bookGroup = null; - this.bookModel = null; - this.pageTextureApplied = false; - this.bookModelPath = '/assets/webgl/book/old_magical_book_metalrough.glb'; - this.leftPageTexture = null; - this.rightPageTexture = null; - this.leftTextureCanvas = null; - this.rightTextureCanvas = null; - this.lastTextureUpdate = 0; - this.tableTopY = -0.09; - this.openHoldTime = 4; - this.openAnimationDone = false; + this.dependencies = ['persistence-manager', 'localization']; + this.persistenceManager = null; + this.localization = null; + this.mode = '2d'; + this.is3dSupported = false; + this.labImportPromise = null; + this.textureRefreshTimer = null; + this.textureRefreshAnimationId = null; + this.lastAnimatedTextureRefresh = 0; + this.preferenceWriteGuard = false; + this.projectedHoverTarget = null; + this.projectedEventClient = null; + this.originalBookInlineStyle = null; + this.originalPageInlineStyles = new Map(); this.bindMethods([ 'ensureShell', 'initializeScene', - 'loadBookModel', - 'placeBookForOpenPose', - 'applyDynamicPageTextures', - 'remapRightPageUv', - 'createPageTexture', - 'drawPageTexture', + 'detectWebGLSupport', + 'createLabHost', + 'installPreferenceBridge', + 'installTextureEventBridge', + 'applyMode', 'adoptPageContent', + 'moveBookOffscreen', + 'restoreBookPlacement', 'refreshModalOverview', - 'updateSceneSize', - 'animate', - 'triggerPageTurn' + 'triggerTextureRefresh', + 'startAnimatedTextureRefresh', + 'stopAnimatedTextureRefresh', + 'handleProcessState', + 'updateLocalizedText', + 'handlePreferenceUpdated' ]); } async initialize() { - try { - this.reportProgress(10, 'Creating WebGL shell'); - this.ensureShell(); - this.reportProgress(30, 'Loading Three.js'); - this.THREE = await import('https://esm.sh/three@0.165.0'); - const gltfModule = await import('https://esm.sh/three@0.165.0/examples/jsm/loaders/GLTFLoader.js'); - this.GLTFLoader = gltfModule.GLTFLoader; - this.reportProgress(55, 'Building book scene'); - await this.initializeScene(); - this.addEventListener(window, 'resize', this.updateSceneSize); - this.addEventListener(document, 'story:turn-start', this.triggerPageTurn); - this.addEventListener(document, 'story:turn-complete', this.triggerPageTurn); - this.addEventListener(document, 'game:config', () => this.refreshModalOverview()); - this.reportProgress(100, 'WebGL book scene ready'); - return true; - } catch (error) { - console.warn('WebGLBookScene: Falling back to DOM-only shell:', error); - this.ensureShell(); - this.reportProgress(100, 'WebGL fallback shell ready'); + this.persistenceManager = this.getModule('persistence-manager'); + this.localization = this.getModule('localization'); + + this.reportProgress(15, 'Checking WebGL support'); + this.is3dSupported = this.detectWebGLSupport(); + this.initializeScenePreferences(); + this.mode = this.resolveInitialMode(); + this.applyMode(); + + this.addEventListener(document, 'preference-updated', this.handlePreferenceUpdated); + this.addEventListener(document, 'localization:languageChanged', this.updateLocalizedText); + this.addEventListener(document, 'story:turn-start', this.triggerTextureRefresh); + this.addEventListener(document, 'story:turn-complete', this.triggerTextureRefresh); + this.addEventListener(document, 'story:history-updated', this.triggerTextureRefresh); + this.addEventListener(document, 'story:process-state', this.handleProcessState); + this.addEventListener(document, 'input', this.triggerTextureRefresh, true); + this.addEventListener(document, 'change', this.triggerTextureRefresh, true); + + if (this.mode !== '3d') { + this.reportProgress(100, '2D book UI selected'); return true; } + + this.reportProgress(35, 'Creating WebGL host'); + this.ensureShell(); + this.installPreferenceBridge(); + + this.reportProgress(100, 'WebGL book host ready'); + return true; + } + + initializeScenePreferences() { + if (!this.persistenceManager) return; + const scenePrefs = this.persistenceManager.getPreference('webgl', 'bookPageCount', null); + if (!Number.isFinite(Number(scenePrefs))) { + this.persistenceManager.updatePreference('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT); + } + const progress = this.persistenceManager.getPreference('webgl', 'bookProgress', null); + if (!Number.isFinite(Number(progress))) { + this.persistenceManager.updatePreference('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS); + } + } + + resolveInitialMode() { + const storedMode = this.persistenceManager?.getPreference?.('webgl', 'mode', null); + if (storedMode === '2d' || storedMode === '3d') { + return storedMode === '3d' && !this.is3dSupported ? '2d' : storedMode; + } + const defaultMode = this.is3dSupported ? '3d' : '2d'; + this.persistenceManager?.updatePreference?.('webgl', 'mode', defaultMode); + return defaultMode; + } + + detectWebGLSupport() { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + if (!gl) return false; + + const vertexShader = gl.createShader(gl.VERTEX_SHADER); + const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); + const program = gl.createProgram(); + if (!vertexShader || !fragmentShader || !program) return false; + + gl.shaderSource(vertexShader, 'attribute vec2 p; void main(){ gl_Position = vec4(p, 0.0, 1.0); }'); + gl.shaderSource(fragmentShader, 'precision mediump float; void main(){ gl_FragColor = vec4(1.0); }'); + gl.compileShader(vertexShader); + gl.compileShader(fragmentShader); + const shadersCompile = gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS) && + gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS); + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + const linked = gl.getProgramParameter(program, gl.LINK_STATUS); + gl.deleteProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + return Boolean(shadersCompile && linked); + } + + applyMode() { + document.body.dataset.webglUiMode = this.mode; + document.body.classList.toggle('webgl-mode', this.mode === '3d'); + const app = document.getElementById('webgl_app'); + if (app) app.hidden = this.mode !== '3d'; + if (this.mode !== '3d') { + this.restoreBookPlacement(); + } } ensureShell() { + if (this.mode !== '3d') { + this.applyMode(); + return; + } document.body.classList.add('webgl-mode'); + this.createLabHost(); + this.updateLocalizedText(); + this.refreshModalOverview(); + } + createLabHost() { let app = document.getElementById('webgl_app'); if (!app) { app = document.createElement('div'); app.id = 'webgl_app'; document.body.prepend(app); } + app.hidden = false; - let canvas = document.getElementById('webgl_canvas'); + let canvas = document.getElementById('scene'); if (!canvas) { canvas = document.createElement('canvas'); - canvas.id = 'webgl_canvas'; - canvas.setAttribute('aria-label', '3D book scene'); + canvas.id = 'scene'; + canvas.setAttribute('aria-label', this.t('webgl.sceneLabel')); + app.appendChild(canvas); + } else if (canvas.parentElement !== app) { app.appendChild(canvas); } - let topMenu = document.getElementById('top_menu'); - if (!topMenu) { - topMenu = document.createElement('nav'); - topMenu.id = 'top_menu'; - topMenu.setAttribute('aria-label', 'Top menu'); - topMenu.innerHTML = ` -
- - `; - app.appendChild(topMenu); - topMenu.addEventListener('click', (event) => { - const button = event.target?.closest?.('[data-modal-target]'); - if (!button) return; - const targetId = button.dataset.modalTarget; - const existing = document.getElementById(targetId); - if (targetId === 'options-modal') { - document.getElementById('options')?.click(); - window.setTimeout(() => this.refreshModalOverview(), 100); - return; - } - if (targetId === 'credits_modal') { - document.getElementById('credits_button')?.click(); - } - if (existing) { - existing.classList.add('visible'); - existing.style.display = 'block'; - existing.setAttribute('aria-hidden', 'false'); - } - this.refreshModalOverview(); - }); - } + this.moveBookOffscreen(); - let modalOverview = document.getElementById('modal_overview'); - if (!modalOverview) { - modalOverview = document.createElement('aside'); - modalOverview.id = 'modal_overview'; - modalOverview.setAttribute('aria-label', 'Modal overview'); - modalOverview.innerHTML = '