/** * WebGL Book Scene Module * 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 = ['persistence-manager', 'localization', 'book-texture-renderer']; 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', 'detectWebGLSupport', 'createLabHost', 'installPreferenceBridge', 'installTextureEventBridge', 'applyMode', 'adoptPageContent', 'moveBookToControlOverlay', 'restoreBookPlacement', 'refreshModalOverview', 'triggerTextureRefresh', 'startAnimatedTextureRefresh', 'stopAnimatedTextureRefresh', 'handleProcessState', 'updateLocalizedText', 'handlePreferenceUpdated' ]); } async initialize() { 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(45, 'Loading WebGL scene modules'); await this.initializeScene(); 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('scene'); if (!canvas) { canvas = document.createElement('canvas'); canvas.id = 'scene'; canvas.setAttribute('aria-label', this.t('webgl.sceneLabel')); app.appendChild(canvas); } else if (canvas.parentElement !== app) { app.appendChild(canvas); } this.moveBookToControlOverlay(); 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, reportProgress: (percent, message) => { this.reportProgress(percent, message); } }; } moveBookToControlOverlay() { const book = document.getElementById('book'); if (!book) return; if (this.originalBookInlineStyle === null) { this.originalBookInlineStyle = book.getAttribute('style') || ''; } book.style.position = 'fixed'; book.style.left = '1rem'; book.style.top = '1rem'; book.style.width = 'min(31rem, calc(100vw - 2rem))'; book.style.height = 'min(27rem, calc(100vh - 2rem))'; book.style.background = 'rgba(18, 11, 8, 0.62)'; book.style.border = '1px solid rgba(240, 205, 142, 0.28)'; book.style.boxShadow = '0 1.2rem 3rem rgba(0, 0, 0, 0.42)'; book.style.backdropFilter = 'blur(5px)'; book.style.transform = 'none'; book.style.transformOrigin = 'top left'; book.style.opacity = '1'; book.style.visibility = 'visible'; book.style.zIndex = '40'; book.style.pointerEvents = 'none'; this.removePagePerspectiveTransforms(); this.positionOverlayPages(); } 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(); } 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'; }); } positionOverlayPages() { const pageLeft = document.getElementById('page_left'); if (pageLeft) { Object.assign(pageLeft.style, { position: 'absolute', inset: '0', width: 'auto', height: 'auto', padding: '1rem', overflow: 'auto', opacity: '1', mixBlendMode: 'normal', clipPath: 'none', pointerEvents: 'auto' }); } const pageRight = document.getElementById('page_right'); if (pageRight) { Object.assign(pageRight.style, { position: 'fixed', left: 'calc(100vw + 2rem)', top: '0', width: 'var(--book-right-page-width)', height: 'var(--book-page-height)', opacity: '1', visibility: 'visible', pointerEvents: '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() { 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.reportProgress(94, 'Uploading initial book page textures'); window.BookTextureRenderer?.publishSpread?.(); await new Promise(resolve => requestAnimationFrame(resolve)); this.reportProgress(96, 'Binding WebGL page controls'); this.installTextureEventBridge(); this.triggerTextureRefresh(); return this.labImportPromise; } 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); }); }); } 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 }; 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); } isNativeClickTarget(target) { return !!target?.matches?.('a, button, input, textarea, select, summary, label, [role="button"], [tabindex]'); } 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) }); 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(); if (this.labImportPromise) this.triggerTextureRefresh(); } const title = document.getElementById('game_title')?.textContent?.trim(); const label = document.getElementById('lab_title'); if (title && label) label.textContent = title; this.triggerTextureRefresh(); } refreshModalOverview() { this.updateLocalizedText(); } triggerTextureRefresh() { clearTimeout(this.textureRefreshTimer); this.textureRefreshTimer = setTimeout(() => { window.BookLabDebug?.redrawPageTextures?.(); }, 60); } handleProcessState(event) { const state = event.detail?.state || 'ready'; this.stopAnimatedTextureRefresh(); if (state === 'ready' || state === 'paused' || this.mode !== '3d') this.triggerTextureRefresh(); } startAnimatedTextureRefresh() { this.stopAnimatedTextureRefresh(); } 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; } } const WebGLBookScene = new WebGLBookSceneModule(); export { WebGLBookScene }; if (window.moduleRegistry) { window.moduleRegistry.register(WebGLBookScene); } window.WebGLBookScene = WebGLBookScene;