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 = ` -
AI Interactive Fiction
-
- - - -
- `; - 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 = ''; - app.appendChild(modalOverview); - } + const pageCount = this.persistenceManager?.getPreference?.('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT) ?? DEFAULT_BOOK_PAGE_COUNT; + const progress = this.persistenceManager?.getPreference?.('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS) ?? DEFAULT_BOOK_PROGRESS; + window.WebGLBookInitialState = { + appMode: true, + pageCount, + progress + }; + } - let book = document.getElementById('book'); - if (!book) { - book = document.createElement('div'); - book.id = 'book'; - app.appendChild(book); - } else if (book.parentElement !== app) { - app.appendChild(book); + moveBookOffscreen() { + const book = document.getElementById('book'); + if (!book) return; + if (this.originalBookInlineStyle === null) { + this.originalBookInlineStyle = book.getAttribute('style') || ''; } + book.style.position = 'fixed'; + book.style.left = 'calc(100vw + 50%)'; + book.style.top = '50%'; + book.style.width = 'var(--book-width)'; + book.style.height = 'var(--book-height)'; + book.style.transform = 'translate(-50%, -50%) scale(var(--book-scale))'; + book.style.transformOrigin = 'center center'; + book.style.opacity = '1'; + book.style.visibility = 'visible'; + this.removePagePerspectiveTransforms(); + } - if (!book.dataset.webglOverlayBound) { - book.dataset.webglOverlayBound = 'true'; - book.addEventListener('input', () => this.drawPageTexture(), true); - book.addEventListener('change', () => this.drawPageTexture(), true); + restoreBookPlacement() { + const book = document.getElementById('book'); + if (book && this.originalBookInlineStyle !== null) { + if (this.originalBookInlineStyle) { + book.setAttribute('style', this.originalBookInlineStyle); + } else { + book.removeAttribute('style'); + } + this.originalBookInlineStyle = null; } + this.restorePagePerspectiveTransforms(); + } - this.refreshModalOverview(); + removePagePerspectiveTransforms() { + ['page_left', 'page_right'].forEach((id) => { + const page = document.getElementById(id); + if (!page) return; + if (!this.originalPageInlineStyles.has(id)) { + this.originalPageInlineStyles.set(id, page.getAttribute('style') || ''); + } + page.style.transform = 'none'; + }); + } + + restorePagePerspectiveTransforms() { + this.originalPageInlineStyles.forEach((style, id) => { + const page = document.getElementById(id); + if (!page) return; + if (style) { + page.setAttribute('style', style); + } else { + page.removeAttribute('style'); + } + }); + this.originalPageInlineStyles.clear(); + } + + installPreferenceBridge() { + window.WebGLBookPreferenceBridge = { + updateProgress: (value) => { + if (this.preferenceWriteGuard) return; + this.preferenceWriteGuard = true; + this.persistenceManager?.updatePreference?.('webgl', 'bookProgress', value); + this.preferenceWriteGuard = false; + }, + updatePageCount: (value) => { + if (this.preferenceWriteGuard) return; + this.preferenceWriteGuard = true; + this.persistenceManager?.updatePreference?.('webgl', 'bookPageCount', value); + this.preferenceWriteGuard = false; + } + }; } async initializeScene() { - const THREE = this.THREE; - const canvas = document.getElementById('webgl_canvas'); - if (!THREE || !canvas) return; - - this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); - this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); - this.renderer.shadowMap.enabled = true; - this.renderer.outputColorSpace = THREE.SRGBColorSpace; - - this.scene = new THREE.Scene(); - this.scene.background = new THREE.Color(0x0b0907); - this.clock = new THREE.Clock(); - - this.camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100); - this.camera.position.set(0, 3.1, 4.25); - this.camera.lookAt(0, 0.12, 0); - - const keyLight = new THREE.DirectionalLight(0xffead2, 2.15); - keyLight.position.set(-2.8, 5.1, 3.4); - keyLight.castShadow = true; - keyLight.shadow.bias = -0.00015; - keyLight.shadow.normalBias = 0.025; - keyLight.shadow.mapSize.set(2048, 2048); - this.scene.add(keyLight); - this.scene.add(new THREE.AmbientLight(0xa98963, 1.85)); - - const textureLoader = new THREE.TextureLoader(); - const tableTexture = await textureLoader.loadAsync('/assets/webgl/wood_table_diff_1k.jpg').catch(() => null); - if (tableTexture) { - tableTexture.colorSpace = THREE.SRGBColorSpace; - tableTexture.wrapS = THREE.RepeatWrapping; - tableTexture.wrapT = THREE.RepeatWrapping; - tableTexture.repeat.set(3, 2); - } - - const tableMaterial = tableTexture - ? new THREE.MeshStandardMaterial({ map: tableTexture, roughness: 0.62, metalness: 0 }) - : new THREE.MeshStandardMaterial({ color: 0x5a2f19, roughness: 0.76 }); - const table = new THREE.Mesh(new THREE.BoxGeometry(10.5, 0.34, 7.2), tableMaterial); - table.position.y = -0.26; - table.receiveShadow = true; - this.scene.add(table); - - await this.loadBookModel(); - this.updateSceneSize(); - this.animate(); + if (this.labImportPromise) return this.labImportPromise; + const cacheBuster = window.MODULE_CACHE_BUSTER || Date.now(); + this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(cacheBuster)}`); + await this.labImportPromise; + this.installTextureEventBridge(); + this.triggerTextureRefresh(); + return this.labImportPromise; } - async loadBookModel() { - const THREE = this.THREE; - if (!THREE || !this.scene || !this.GLTFLoader) return; - - this.bookGroup = new THREE.Group(); - this.bookGroup.position.set(0, 0, 0); - this.bookGroup.rotation.set(0, 0, 0); - this.scene.add(this.bookGroup); - - const loader = new this.GLTFLoader(); - const gltf = await loader.loadAsync(this.bookModelPath); - const model = gltf.scene; - this.bookModel = model; - model.traverse((object) => { - if (!object.isMesh) return; - object.castShadow = false; - object.receiveShadow = true; - if (object.material?.map) { - object.material.map.colorSpace = THREE.SRGBColorSpace; - } - }); - - const bounds = new THREE.Box3().setFromObject(model); - const size = bounds.getSize(new THREE.Vector3()); - const center = bounds.getCenter(new THREE.Vector3()); - model.position.sub(center); - - const tableFootprint = Math.max(size.x, size.z, 1); - const scale = 3.35 / tableFootprint; - model.scale.setScalar(scale); - model.rotation.y = 0; - this.bookGroup.add(model); - - if (gltf.animations?.length) { - this.mixer = new THREE.AnimationMixer(model); - const action = this.mixer.clipAction(gltf.animations[0]); - this.openingAction = action; - action.reset(); - action.setLoop(THREE.LoopOnce, 1); - action.clampWhenFinished = true; - action.timeScale = 1; - action.play(); - this.placeBookForOpenPose(action); - action.reset(); - action.setLoop(THREE.LoopOnce, 1); - action.clampWhenFinished = true; - action.timeScale = 1; - action.play(); - } else { - model.updateMatrixWorld(true); - const placedBounds = new THREE.Box3().setFromObject(model); - model.position.y += this.tableTopY - placedBounds.min.y + 0.012; - } - - this.applyDynamicPageTextures(model); - } - - placeBookForOpenPose(action) { - const THREE = this.THREE; - if (!THREE || !this.bookModel || !this.mixer || !action) return; - this.mixer.setTime(this.openHoldTime); - this.bookModel.updateMatrixWorld(true); - const bounds = new THREE.Box3().setFromObject(this.bookModel); - const center = bounds.getCenter(new THREE.Vector3()); - this.bookModel.position.x -= center.x; - this.bookModel.position.y += this.tableTopY - bounds.min.y + 0.012; - this.bookModel.position.z -= center.z; - this.bookModel.updateMatrixWorld(true); - } - - applyDynamicPageTextures(model) { - const THREE = this.THREE; - if (!THREE || !model) return; - - this.leftTextureCanvas = this.createPageTexture(); - this.rightTextureCanvas = this.createPageTexture(); - this.leftPageTexture = new THREE.CanvasTexture(this.leftTextureCanvas); - this.rightPageTexture = new THREE.CanvasTexture(this.rightTextureCanvas); - this.leftPageTexture.colorSpace = THREE.SRGBColorSpace; - this.rightPageTexture.colorSpace = THREE.SRGBColorSpace; - - model.traverse((object) => { - if (object.isMesh) { - delete object.userData.dynamicPageTexture; - } - }); - - const namedTargets = [ - { name: 'page1_005Shape', side: 'left' }, - { name: 'page1_004Shape', side: 'left' }, - { name: 'page1_002Shape', side: 'left' }, - { name: 'page1_001Shape', side: 'left' }, - { name: 'page1Shape', side: 'left' }, - { name: 'CubeShape', side: 'right' } - ]; - let appliedNamedTargets = 0; - namedTargets.forEach((target) => { - const object = model.getObjectByName(target.name); - if (!object?.isMesh) return; - const texture = target.side === 'left' ? this.leftPageTexture : this.rightPageTexture; - if (target.name === 'CubeShape') { - this.remapRightPageUv(object); - } - const material = new THREE.MeshStandardMaterial({ - map: texture, - color: 0xffffff, - roughness: 0.96, - metalness: 0, - side: THREE.DoubleSide + installTextureEventBridge() { + const canvas = document.getElementById('scene'); + if (!canvas || canvas.dataset.webglTextureEventsBound) return; + canvas.dataset.webglTextureEventsBound = 'true'; + ['click', 'dblclick', 'pointermove', 'mousemove', 'pointerdown', 'pointerup'].forEach((type) => { + canvas.addEventListener(type, (event) => { + if (event.button === 2) return; + const target = this.projectCanvasEventTarget(event); + if (!target && (type === 'pointermove' || type === 'mousemove')) { + this.updateProjectedHover(null, event); + return; + } + if (!target) return; + if (type === 'pointermove' || type === 'mousemove') { + this.updateProjectedHover(target, event); + } + if (type === 'click' && this.isNativeClickTarget(target)) { + target.click(); + return; + } + const replay = this.createProjectedEvent(type, event); + target.dispatchEvent(replay); }); - object.material = material; - object.userData.dynamicPageTexture = target.side; - appliedNamedTargets += 1; }); - - this.pageTextureApplied = appliedNamedTargets > 0; - this.drawPageTexture(); } - remapRightPageUv(object) { - const THREE = this.THREE; - const geometry = object?.geometry; - const position = geometry?.attributes?.position; - const uv = geometry?.attributes?.uv; - if (!THREE || !object?.isMesh || !position || !uv) return; - - object.updateMatrixWorld(true); - const morphPositions = geometry.morphAttributes?.position || []; - const influences = object.morphTargetInfluences || []; - const selected = [30, 31, 32, 33, 34, 35]; - const local = new THREE.Vector3(); - const world = new THREE.Vector3(); - const points = new Map(); - - selected.forEach((index) => { - local.fromBufferAttribute(position, index); - morphPositions.forEach((morph, morphIndex) => { - const influence = influences[morphIndex] || 0; - if (!influence) return; - local.x += morph.getX(index) * influence; - local.y += morph.getY(index) * influence; - local.z += morph.getZ(index) * influence; - }); - points.set(index, world.copy(local).applyMatrix4(object.matrixWorld).clone()); - }); - - const bounds = Array.from(points.values()).reduce((acc, point) => ({ - minX: Math.min(acc.minX, point.x), - maxX: Math.max(acc.maxX, point.x), - minZ: Math.min(acc.minZ, point.z), - maxZ: Math.max(acc.maxZ, point.z) - }), { minX: Infinity, maxX: -Infinity, minZ: Infinity, maxZ: -Infinity }); - const width = Math.max(0.001, bounds.maxX - bounds.minX); - const depth = Math.max(0.001, bounds.maxZ - bounds.minZ); - - points.forEach((point, index) => { - const u = (point.x - bounds.minX) / width; - const v = 1 - ((point.z - bounds.minZ) / depth); - uv.setXY(index, Math.min(1, Math.max(0, u)), Math.min(1, Math.max(0, v))); - }); - uv.needsUpdate = true; - } - - createPageTexture() { - const canvas = document.createElement('canvas'); - canvas.width = 1024; - canvas.height = 1304; - return canvas; - } - - drawPageTexture() { - this.paintDomPage(this.leftTextureCanvas, document.getElementById('page_left'), 'left'); - this.paintDomPage(this.rightTextureCanvas, document.getElementById('page_right'), 'right'); - if (this.leftPageTexture) this.leftPageTexture.needsUpdate = true; - if (this.rightPageTexture) this.rightPageTexture.needsUpdate = true; - } - - paintDomPage(canvas, source, side) { - if (!canvas) return; - const ctx = canvas.getContext('2d'); - ctx.fillStyle = '#f4dfad'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0); - gradient.addColorStop(0, 'rgba(83, 49, 23, 0.16)'); - gradient.addColorStop(0.15, 'rgba(255, 255, 255, 0)'); - gradient.addColorStop(0.88, 'rgba(255, 255, 255, 0)'); - gradient.addColorStop(1, 'rgba(83, 49, 23, 0.13)'); - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = 'rgba(120, 84, 44, 0.12)'; - for (let y = 64; y < canvas.height; y += 52) { - ctx.fillRect(96, y, canvas.width - 192, 2); - } - ctx.fillStyle = '#160d08'; - ctx.font = '48px "EB Garamond", Georgia, serif'; - ctx.textBaseline = 'top'; - - const blocks = this.collectPageBlocks(source, side); - if (blocks.length === 0) { - blocks.push({ - text: side === 'left' ? 'Menu and commands' : 'Story text', - role: 'body', - align: 'left' - }); - } - - ctx.fillStyle = '#120b07'; - let y = side === 'left' ? 150 : 120; - if (side === 'right' && blocks.length <= 2) { - y = 360; - } - blocks.forEach((block) => { - const fontSize = this.getTextureFontSize(block.role); - ctx.font = `${block.italic ? 'italic ' : ''}${fontSize}px "EB Garamond", Georgia, serif`; - ctx.textAlign = block.align || 'left'; - const x = ctx.textAlign === 'center' ? canvas.width / 2 : 118; - const maxWidth = ctx.textAlign === 'center' ? canvas.width - 180 : canvas.width - 236; - if (block.role === 'separator') { - y += 18; - } - y = this.wrapCanvasText(ctx, block.text, x, y, maxWidth, fontSize * 1.28) + this.getTextureBlockGap(block.role); - }); - - } - - collectPageBlocks(source, side) { - if (!source) return []; - if (side === 'left') { - const controlLabels = Array.from(source.querySelectorAll('#controls a, #controls span')) - .map((element) => element.textContent?.trim() || element.getAttribute('aria-label') || element.id || '') - .filter(Boolean) - .join(' | '); - return [ - { text: source.querySelector('#game_author')?.textContent?.trim(), role: 'byline', align: 'center' }, - { text: source.querySelector('#game_title')?.textContent?.trim(), role: 'title', align: 'center' }, - { text: source.querySelector('#game_subtitle')?.textContent?.trim(), role: 'subtitle', align: 'center' }, - { text: source.querySelector('.separator')?.textContent?.trim(), role: 'separator', align: 'center' }, - { text: controlLabels, role: 'controls', align: 'center' }, - ...Array.from(source.querySelectorAll('#command_history > *, #choices .choice-button')).map((element) => ({ - text: element.textContent?.trim(), - role: 'choice', - align: 'left' - })), - { text: source.querySelector('#player_input')?.value || source.querySelector('#player_input')?.textContent?.trim(), role: 'input', align: 'left' }, - { text: source.querySelector('#remark_text')?.textContent?.trim(), role: 'remark', align: 'center', italic: true }, - { text: source.querySelector('#game_legal')?.textContent?.trim(), role: 'legal', align: 'center' } - ].filter((block) => block.text); - } - - const storyBlocks = source.querySelector('#paragraphs')?.children?.length - ? Array.from(source.querySelector('#paragraphs').children) - : Array.from(source.querySelectorAll('#story > *, .history-item, .story-block, p')); - const blocks = storyBlocks - .map((element) => ({ - text: element.textContent?.replace(/\s+/g, ' ').trim(), - role: element.matches?.('h1,h2,h3') ? 'subtitle' : 'story', - align: 'left' - })) - .filter((block) => block.text); - if (blocks.length) return blocks; - - const sourceText = source.textContent?.replace(/\s+/g, ' ').trim(); - return sourceText ? [{ text: sourceText, role: 'story-focus', align: 'center' }] : []; - } - - getTextureFontSize(role) { - const sizes = { - byline: 42, - title: 76, - subtitle: 46, - separator: 42, - controls: 30, - choice: 34, - input: 34, - remark: 30, - legal: 26, - 'story-focus': 68, - story: 38, - body: 38 + createProjectedEvent(type, event) { + const eventOptions = { + bubbles: true, + cancelable: true, + clientX: this.projectedEventClient?.x ?? event.clientX, + clientY: this.projectedEventClient?.y ?? event.clientY, + button: event.button, + buttons: event.buttons }; - return sizes[role] || sizes.body; + if (type.startsWith('pointer') && typeof PointerEvent === 'function') { + return new PointerEvent(type, { + ...eventOptions, + pointerId: event.pointerId, + pointerType: event.pointerType, + isPrimary: event.isPrimary + }); + } + return new MouseEvent(type, eventOptions); } - getTextureBlockGap(role) { - const gaps = { - title: 28, - subtitle: 32, - separator: 22, - controls: 38, - choice: 18, - 'story-focus': 28, - story: 24, - legal: 10 - }; - return gaps[role] || 16; + isNativeClickTarget(target) { + return !!target?.matches?.('a, button, input, textarea, select, summary, label, [role="button"], [tabindex]'); } - wrapCanvasText(ctx, text, x, y, maxWidth, lineHeight) { - const words = String(text || '').split(/\s+/); - let line = ''; - words.forEach((word) => { - const testLine = line ? `${line} ${word}` : word; - if (ctx.measureText(testLine).width > maxWidth && line) { - ctx.fillText(line, x, y); - line = word; - y += lineHeight; - } else { - line = testLine; - } + updateProjectedHover(target, event) { + if (target === this.projectedHoverTarget) return; + if (this.projectedHoverTarget) { + this.projectedHoverTarget.dispatchEvent(new MouseEvent('mouseleave', { + bubbles: false, + cancelable: true, + clientX: this.projectedEventClient?.x ?? event.clientX, + clientY: this.projectedEventClient?.y ?? event.clientY + })); + } + this.projectedHoverTarget = target; + if (target) { + ['mouseover', 'mouseenter'].forEach((type) => { + target.dispatchEvent(new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: this.projectedEventClient?.x ?? event.clientX, + clientY: this.projectedEventClient?.y ?? event.clientY, + button: event.button, + buttons: event.buttons + })); + }); + } + } + + projectCanvasEventTarget(event) { + const projection = window.BookLabDebug?.projectPointerToPage?.(event.clientX, event.clientY); + if (!projection) { + document.documentElement.dataset.webglLastProjection = JSON.stringify({ + hit: false, + eventType: event.type, + clientX: event.clientX, + clientY: event.clientY + }); + return null; + } + const pageId = projection.pageId; + const page = document.getElementById(pageId); + if (!page) { + document.documentElement.dataset.webglLastProjection = JSON.stringify({ + hit: true, + pageId, + missingPage: true + }); + return null; + } + const pageRect = page.getBoundingClientRect(); + const pageX = pageRect.left + projection.x * pageRect.width; + const pageY = pageRect.top + projection.y * pageRect.height; + this.projectedEventClient = { x: pageX, y: pageY }; + const target = this.findProjectedPageTarget(page, pageX, pageY); + document.documentElement.dataset.webglLastProjection = JSON.stringify({ + hit: true, + eventType: event.type, + pageId, + x: Number(projection.x.toFixed(4)), + y: Number(projection.y.toFixed(4)), + uv: projection.uv + ? { + x: Number(projection.uv.x.toFixed(4)), + y: Number(projection.uv.y.toFixed(4)) + } + : null, + targetId: target.id || '', + targetTag: target.tagName || '', + targetClass: target.className || '', + targetText: (target.textContent || '').trim().slice(0, 120) }); - if (line) ctx.fillText(line, x, y); - return y + lineHeight; + return page.contains(target) ? target : page; + } + + findProjectedPageTarget(page, pageX, pageY) { + let target = page; + const candidates = page.querySelectorAll('*'); + candidates.forEach((element) => { + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden') return; + const opacity = Number.parseFloat(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0.005) return; + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + if (pageX < rect.left || pageX > rect.right || pageY < rect.top || pageY > rect.bottom) return; + target = element; + }); + return target.closest?.('a, button, input, textarea, select, [role="button"], .story-glossary-word') || target; + } + + handlePreferenceUpdated(event) { + const { category, key, value } = event.detail || {}; + if (category !== 'webgl') return; + if (key === 'mode') { + const nextMode = value === '3d' && this.is3dSupported ? '3d' : '2d'; + if (nextMode === this.mode) return; + this.mode = nextMode; + this.applyMode(); + if (this.mode === '3d') { + this.ensureShell(); + this.installPreferenceBridge(); + this.initializeScene(); + } + } else if (key === 'bookProgress' && !this.preferenceWriteGuard) { + window.BookLabDebug?.setReadingProgress?.(value); + } else if (key === 'bookPageCount' && !this.preferenceWriteGuard) { + window.BookLabDebug?.setBookPageCount?.(value); + } } adoptPageContent() { + if (this.mode === '3d') { + this.createLabHost(); + this.installPreferenceBridge(); + this.initializeScene() + .then(() => this.triggerTextureRefresh()) + .catch((error) => { + console.error('WebGLBookScene: Failed to initialize procedural scene', error); + }); + } const title = document.getElementById('game_title')?.textContent?.trim(); - const topTitle = document.getElementById('top_menu_title'); - if (title && topTitle) topTitle.textContent = title; - this.refreshModalOverview(); - this.drawPageTexture(); + const label = document.getElementById('lab_title'); + if (title && label) label.textContent = title; + this.triggerTextureRefresh(); } refreshModalOverview() { - const list = document.getElementById('modal_overview_list'); - if (!list) return; - const modals = [ - { id: 'options-modal', label: 'Options' }, - { id: 'credits_modal', label: 'Credits' }, - { id: 'story_popup_modal', label: 'Notice' } - ]; - list.innerHTML = ''; - modals.forEach((modal) => { - const element = document.getElementById(modal.id); - const computedDisplay = element ? window.getComputedStyle(element).display : 'none'; - const isOpen = Boolean(element && ( - element.classList.contains('visible') || - element.style.display === 'block' || - element.style.display === 'flex' || - computedDisplay !== 'none' - )); - const row = document.createElement('button'); - row.type = 'button'; - row.className = 'modal-overview-row'; - row.dataset.modalTarget = modal.id; - row.innerHTML = `${modal.label}${isOpen ? 'open' : 'closed'}`; - row.addEventListener('click', () => { - document.querySelector(`#top_menu [data-modal-target="${modal.id}"]`)?.click(); - }); - list.appendChild(row); - }); + this.updateLocalizedText(); } - updateSceneSize() { - if (!this.renderer || !this.camera) return; - const width = Math.max(1, window.innerWidth); - const height = Math.max(1, window.innerHeight); - this.renderer.setSize(width, height, false); - this.camera.aspect = width / height; - this.camera.updateProjectionMatrix(); + triggerTextureRefresh() { + clearTimeout(this.textureRefreshTimer); + this.textureRefreshTimer = setTimeout(() => { + document.dispatchEvent(new CustomEvent('webgl-book:redraw-pages')); + window.BookLabDebug?.redrawPageTextures?.(); + }, 60); } - triggerPageTurn() { - this.drawPageTexture(); - } - - animate(now = performance.now()) { - if (!this.renderer || !this.scene || !this.camera) return; - requestAnimationFrame(this.animate); - - if (now - this.lastTextureUpdate > 700) { - this.lastTextureUpdate = now; - this.drawPageTexture(); - this.refreshModalOverview(); + handleProcessState(event) { + const state = event.detail?.state || 'ready'; + if (state === 'ready' || state === 'paused' || this.mode !== '3d') { + this.stopAnimatedTextureRefresh(); + this.triggerTextureRefresh(); + return; } + this.startAnimatedTextureRefresh(); + } - if (this.mixer && this.clock) { - this.mixer.update(this.clock.getDelta()); - if (this.openingAction && this.openingAction.time >= this.openHoldTime && !this.openAnimationDone) { - this.openAnimationDone = true; - this.openingAction.paused = true; - this.openingAction.timeScale = 0; - this.openingAction.enabled = true; - this.openingAction.setEffectiveWeight(1); + startAnimatedTextureRefresh() { + if (this.textureRefreshAnimationId) return; + const tick = (now) => { + if (this.mode !== '3d') { + this.textureRefreshAnimationId = null; + return; } - } else { - if (!this.pageTextureApplied) { - this.applyDynamicPageTextures(this.bookModel); + if (now - this.lastAnimatedTextureRefresh > 100) { + this.lastAnimatedTextureRefresh = now; + document.dispatchEvent(new CustomEvent('webgl-book:redraw-pages')); + window.BookLabDebug?.redrawPageTextures?.(); } - } + this.textureRefreshAnimationId = window.requestAnimationFrame(tick); + }; + this.textureRefreshAnimationId = window.requestAnimationFrame(tick); + } - this.renderer.render(this.scene, this.camera); + stopAnimatedTextureRefresh() { + if (!this.textureRefreshAnimationId) return; + window.cancelAnimationFrame(this.textureRefreshAnimationId); + this.textureRefreshAnimationId = null; + } + + updateLocalizedText() { + const setText = (id, key) => { + const element = document.getElementById(id); + if (element) element.textContent = this.t(key); + }; + setText('lab_title', 'webgl.title'); + setText('lab_status', this.mode === '3d' ? 'webgl.status3d' : 'webgl.status2d'); + } + + t(key, params = {}) { + return this.localization?.translate?.(key, params) || key; } } diff --git a/public/locales/de_DE.json b/public/locales/de_DE.json index d4db005..8f23ef2 100644 --- a/public/locales/de_DE.json +++ b/public/locales/de_DE.json @@ -29,6 +29,12 @@ "options.voice": "Stimme", "options.speed": "Tempo", "options.audio": "Audio", + "options.bookDisplay": "Buchanzeige", + "options.displayMode": "Anzeigemodus", + "options.displayMode3d": "3D", + "options.displayMode2d": "2D", + "options.bookSize": "Buchgröße", + "options.bookProgress": "Seitenstapel", "options.volume": "Lautstärke", "options.masterVolume": "Gesamtlautstärke", "options.speechVolume": "Sprachlautstärke", @@ -53,6 +59,17 @@ "options.apiUrl": "API-URL", "options.model": "Modell", "options.requestTimeoutMs": "Anfrage-Timeout (ms)", + "webgl.title": "Prozedurales Buch", + "webgl.sceneLabel": "3D-Buchszene", + "webgl.bookControls": "Buchsteuerung", + "webgl.status3d": "3D-Szene", + "webgl.status2d": "2D-Szene", + "webgl.bookSize": "Seiten", + "webgl.pageStackProgress": "Fortschritt", + "webgl.fastBackward": "Schnell zurück", + "webgl.backward": "Zurück", + "webgl.forward": "Vorwärts", + "webgl.fastForward": "Schnell vorwärts", "credits.button": "Credits", "credits.buttonTitle": "Mitwirkende und Lizenzen anzeigen", "credits.title": "Mitwirkende und Lizenzen", diff --git a/public/locales/en_US.json b/public/locales/en_US.json index 8fc7ffd..1f1807d 100644 --- a/public/locales/en_US.json +++ b/public/locales/en_US.json @@ -29,6 +29,12 @@ "options.voice": "Voice", "options.speed": "Speed", "options.audio": "Audio", + "options.bookDisplay": "Book Display", + "options.displayMode": "Display Mode", + "options.displayMode3d": "3D", + "options.displayMode2d": "2D", + "options.bookSize": "Book Size", + "options.bookProgress": "Page Stack", "options.volume": "Volume", "options.masterVolume": "Master Volume", "options.speechVolume": "Speech Volume", @@ -53,6 +59,17 @@ "options.apiUrl": "API URL", "options.model": "Model", "options.requestTimeoutMs": "Request timeout (ms)", + "webgl.title": "Procedural Book", + "webgl.sceneLabel": "3D book scene", + "webgl.bookControls": "Book controls", + "webgl.status3d": "3D scene", + "webgl.status2d": "2D scene", + "webgl.bookSize": "Pages", + "webgl.pageStackProgress": "Progress", + "webgl.fastBackward": "Fast backward", + "webgl.backward": "Backward", + "webgl.forward": "Forward", + "webgl.fastForward": "Fast forward", "credits.button": "credits", "credits.buttonTitle": "Show credits and third-party licenses", "credits.title": "Credits and Licenses",