diff --git a/package-lock.json b/package-lock.json index a3c5457..8f2547b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "eslint": "^9.23.0", "jest": "^29.7.0", "nodemon": "^3.1.9", + "playwright": "^1.60.0", "ts-jest": "^29.3.1", "ts-node": "^10.9.2", "typescript": "^5.8.2" @@ -6266,6 +6267,53 @@ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 1a82941..a447753 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "eslint": "^9.23.0", "jest": "^29.7.0", "nodemon": "^3.1.9", + "playwright": "^1.60.0", "ts-jest": "^29.3.1", "ts-node": "^10.9.2", "typescript": "^5.8.2" diff --git a/public/assets/webgl/README.md b/public/assets/webgl/README.md new file mode 100644 index 0000000..9917b76 --- /dev/null +++ b/public/assets/webgl/README.md @@ -0,0 +1,37 @@ +WebGL Scene Assets +================== + +- `wood_table_diff_1k.jpg` + - Source: Poly Haven, "Wood Table" + - URL: https://polyhaven.com/a/wood_table + - Author: Dimitrios Savva + - License: CC0 + +- `book/open-book-poly-pizza.glb` + - Source: Poly Pizza, "open book" + - URL: https://poly.pizza/m/4WPcl72i1_S + - Author: Justin Randall + - License: Creative Commons Attribution 3.0 + - Notes: Real authored open-book GLB used as the visible book model. + +- `book/open-book-poly-pizza-preview.jpg` + - Source: Poly Pizza, "open book" preview image + - URL: https://poly.pizza/m/4WPcl72i1_S + - Author: Justin Randall + - License: Creative Commons Attribution 3.0 + +Candidate model found during follow-up search: + +- "Old Magical Book" by Akiko.Tomiyoshi on Sketchfab + - URL: https://sketchfab.com/3d-models/old-magical-book-326cf7653c7c4ec19d2672f5a7a33578 + - License: Creative Commons Attribution + - Notes: The model description says it has page-flipping animation using bone and lattice modifiers. + - Blocker: Sketchfab's download API requires authenticated credentials, so it was not pulled into the repository automatically. + +Candidate model checked and rejected for automated import: + +- "Rigged book" by Jissse on Blend Swap + - URL: https://blendswap.com/blend/26504 + - License: CC0 + - Notes: The page lists a Blender rig with controllable pages. + - Blocker: The file download requires signing in; the unauthenticated response is a download page, not model bytes. diff --git a/public/assets/webgl/book/old_magical_book.glb b/public/assets/webgl/book/old_magical_book.glb new file mode 100644 index 0000000..0f48ec8 Binary files /dev/null and b/public/assets/webgl/book/old_magical_book.glb differ diff --git a/public/assets/webgl/book/old_magical_book_metalrough.glb b/public/assets/webgl/book/old_magical_book_metalrough.glb new file mode 100644 index 0000000..48bc8af Binary files /dev/null and b/public/assets/webgl/book/old_magical_book_metalrough.glb differ diff --git a/public/assets/webgl/book/open-book-poly-pizza-preview.jpg b/public/assets/webgl/book/open-book-poly-pizza-preview.jpg new file mode 100644 index 0000000..6c6daab Binary files /dev/null and b/public/assets/webgl/book/open-book-poly-pizza-preview.jpg differ diff --git a/public/assets/webgl/book/open-book-poly-pizza.glb b/public/assets/webgl/book/open-book-poly-pizza.glb new file mode 100644 index 0000000..b433190 Binary files /dev/null and b/public/assets/webgl/book/open-book-poly-pizza.glb differ diff --git a/public/assets/webgl/room_reflection_candlelit_study.png b/public/assets/webgl/room_reflection_candlelit_study.png new file mode 100644 index 0000000..85fe628 Binary files /dev/null and b/public/assets/webgl/room_reflection_candlelit_study.png differ diff --git a/public/assets/webgl/room_reflection_candlelit_study_equirect.png b/public/assets/webgl/room_reflection_candlelit_study_equirect.png new file mode 100644 index 0000000..582bd63 Binary files /dev/null and b/public/assets/webgl/room_reflection_candlelit_study_equirect.png differ diff --git a/public/assets/webgl/room_reflection_candlelit_study_equirect_4k.png b/public/assets/webgl/room_reflection_candlelit_study_equirect_4k.png new file mode 100644 index 0000000..1363e32 Binary files /dev/null and b/public/assets/webgl/room_reflection_candlelit_study_equirect_4k.png differ diff --git a/public/assets/webgl/wood_table_diff_1k.jpg b/public/assets/webgl/wood_table_diff_1k.jpg new file mode 100644 index 0000000..34cb99e Binary files /dev/null and b/public/assets/webgl/wood_table_diff_1k.jpg differ diff --git a/public/css/style.css b/public/css/style.css index 9dc4184..2c1465d 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1926,3 +1926,195 @@ body:not([data-game-running="true"]) #start_prompt { .openai-setting { display: none; /* Hidden by default, shown when the relevant provider is selected */ } + +body.webgl-mode { + background: #090705; + align-items: stretch; + justify-content: stretch; +} + +#webgl_app { + position: fixed; + inset: 0; + overflow: hidden; + background: #090705; +} + +#webgl_canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; +} + +#top_menu { + position: fixed; + z-index: 50; + top: 0; + left: 0; + right: 0; + height: 38px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 18px; + box-sizing: border-box; + color: rgba(246, 231, 201, 0.94); + background: linear-gradient(180deg, rgba(13, 10, 7, 0.88), rgba(13, 10, 7, 0.46)); + border-bottom: 1px solid rgba(246, 231, 201, 0.18); + font-size: 16px; + line-height: 1; +} + +#top_menu_title { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; +} + +#top_menu_controls { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +#top_menu_controls button, +.modal-overview-row { + font-family: 'EB Garamond', serif; + font-size: 14px; + line-height: 1; + color: rgba(246, 231, 201, 0.92); + background: rgba(44, 28, 17, 0.72); + border: 1px solid rgba(246, 231, 201, 0.24); + border-radius: 4px; + padding: 6px 10px; + cursor: pointer; +} + +#top_menu_controls button:hover, +.modal-overview-row:hover { + background: rgba(87, 55, 31, 0.78); +} + +#modal_overview { + position: fixed; + z-index: 45; + top: 52px; + right: 14px; + width: 164px; + color: rgba(246, 231, 201, 0.9); + background: rgba(12, 9, 7, 0.58); + border: 1px solid rgba(246, 231, 201, 0.18); + border-radius: 6px; + padding: 8px; + box-sizing: border-box; + backdrop-filter: blur(10px); +} + +.modal-overview-title { + font-size: 13px; + line-height: 1; + margin: 0 0 8px; + color: rgba(246, 231, 201, 0.68); +} + +#modal_overview_list { + display: grid; + gap: 6px; +} + +.modal-overview-row { + display: flex; + justify-content: space-between; + width: 100%; + text-align: left; + padding: 6px 8px; +} + +.modal-overview-row span:last-child { + 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; +} + +@media (max-width: 780px) { + #modal_overview { + display: none; + } + + #top_menu { + height: auto; + min-height: 42px; + gap: 8px; + padding: 6px 10px; + } + + #top_menu_controls button { + padding: 6px 7px; + } + + body.webgl-mode #book { + inset: 46px 0 0; + } + + 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 7fc83e6..54a0a10 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -24,7 +24,7 @@ const ModuleState = { ERROR: 'ERROR' }; -const MODULE_CACHE_BUSTER = '20260516-scroll-window'; +const MODULE_CACHE_BUSTER = '20260603-webgl-right-page-text'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** @@ -113,6 +113,7 @@ const ModuleLoader = (function() { { id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 }, { id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 }, { id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module + { id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 }, { id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 }, { id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS @@ -883,12 +884,18 @@ const ModuleLoader = (function() { return; } - await waitForProgressIndicatorsToExit(); + await Promise.race([ + waitForProgressIndicatorsToExit(), + new Promise(resolve => setTimeout(resolve, 700)) + ]); // Set opacity to 0 to trigger the fade-out transition loadingOverlay.style.opacity = '0'; - await waitForTransition(loadingOverlay, 'opacity'); + await Promise.race([ + waitForTransition(loadingOverlay, 'opacity'), + new Promise(resolve => setTimeout(resolve, 700)) + ]); console.log('Module Loader: Removing overlay from DOM'); diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index a3cf927..8622dfd 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -11,7 +11,7 @@ class UIDisplayHandlerModule extends BaseModule { super('ui-display-handler', 'UI Display Handler'); // Module dependencies - this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser']; + this.dependencies = ['layout-renderer', 'webgl-book-scene', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser']; // DOM elements this.container = null; @@ -170,6 +170,7 @@ class UIDisplayHandlerModule extends BaseModule { // Get references to required modules using parent's getModule method this.layoutRenderer = this.getModule('layout-renderer'); + this.webglBookScene = this.getModule('webgl-book-scene'); this.playbackCoordinator = this.getModule('playback-coordinator'); this.gameConfig = this.getModule('game-config'); this.localization = this.getModule('localization'); @@ -355,6 +356,8 @@ class UIDisplayHandlerModule extends BaseModule { * Initialize the UI containers */ initializeContainers() { + this.webglBookScene?.ensureShell?.(); + // Check if the book container already exists let bookContainer = document.getElementById('book'); if (!bookContainer) { @@ -526,7 +529,10 @@ class UIDisplayHandlerModule extends BaseModule { this.createNotificationDialog(); console.log('UIDisplayHandler: All containers initialized'); + this.webglBookScene?.adoptPageContent?.(); + this.webglBookScene?.refreshModalOverview?.(); this.applyGameConfig(this.gameConfig?.getConfig?.()); + this.webglBookScene?.adoptPageContent?.(); this.applyTranslations(); this.measureStoryLineHeight(); this.setStoryOffset(0); diff --git a/public/js/ui-display-handler-module.legacy.js b/public/js/ui-display-handler-module.legacy.js new file mode 100644 index 0000000..a3cf927 --- /dev/null +++ b/public/js/ui-display-handler-module.legacy.js @@ -0,0 +1,2551 @@ +/** + * UI Display Handler Module + * Manages the display of text and UI elements + */ +import { BaseModule } from './base-module.js'; + +const PAGE_LINE_COUNT = 25; + +class UIDisplayHandlerModule extends BaseModule { + constructor() { + super('ui-display-handler', 'UI Display Handler'); + + // Module dependencies + this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser']; + + // DOM elements + this.container = null; + this.pageLeft = null; + this.pageRight = null; + this.paragraphContainer = null; + this.renderedItems = []; + this.resizeTimer = null; + this.storyResizeObserver = null; + this.lastStoryMetrics = null; + this.visibleBlockLimit = 41; + this.historyBufferBlocks = 20; + this.pageLineCount = PAGE_LINE_COUNT; + this.historyWindowStartId = 1; + this.historyWindowEndId = 0; + this.draggingStoryScrollbar = false; + this.storyTopLine = 0; + this.storyOffsetPx = 0; + this.windowOriginLine = 0; + this.storyScrollAnimation = null; + this.scrollAnimationFrameId = null; + this.scrollAnimationPromise = null; + this.scrollAnimationResolve = null; + this.scrollTargetLine = null; + this.scrollRequestId = 0; + this.renderWindowToken = 0; + this.displayGeneration = 0; + this.wheelLineAccumulator = 0; + this.viewportLineCount = 1; + this.lineHeightPx = 24; + this.activeCenterBlockId = null; + this.maxTraversalBlocks = 190; + this.scrollbarPreviewLine = null; + this.storyScrollbarReleaseHandler = null; + this.lastManualScrollAt = 0; + this.layoutFlowLine = 0; + this.layoutExclusions = []; + this.notificationQueue = []; + this.notificationActive = false; + this.pendingTerminalNotifications = []; + this.latestInputMode = 'text'; + this.markdownRendererPromise = null; + + // Resources to preload + this.cssPath = '/css/style.css'; + this.imagesToPreload = [ + '/images/book_detailed.png', + '/images/mat.png' + ]; + + // Bind methods using parent's bindMethods utility + this.bindMethods([ + 'initializeContainers', + 'applyGameConfig', + 'applyTranslations', + 'renderSentence', + 'renderStoryBlock', + 'prepareRenderableBlock', + 'prepareTextRenderable', + 'prepareImageRenderable', + 'createImageExclusion', + 'addImageExclusion', + 'rebuildLayoutExclusions', + 'getActiveExclusions', + 'buildLineGeometry', + 'makeRenderedWordsVisible', + 'markBlockRendered', + 'restoreFromHistory', + 'renderHistoryWindow', + 'renderIncrementalWindow', + 'setWindowOriginLine', + 'removeRenderedBlocksOutside', + 'removeRenderedElement', + 'dedupeRenderedWindow', + 'reflowTextBlocksForActiveExclusions', + 'blockIntersectsExclusions', + 'getFlowLineFromItems', + 'insertStoredElement', + 'handleHistoryWheel', + 'handleManualScrollStart', + 'getActiveLineForTopLine', + 'getTopLineForActiveLine', + 'getRenderedBlockForLine', + 'getWindowBoundsForTraversal', + 'renderWindowForBounds', + 'disableAutoplayForManualScroll', + 'updateStoryScrollbar', + 'handleStoryScrollbarPointer', + 'renderImageBlock', + 'revealImageBlock', + 'resolveImageUrl', + 'calculateImageMetrics', + 'measureStoryLineHeight', + 'measureBlockLines', + 'recordRenderedMetrics', + 'setVirtualPadding', + 'setStoryOffset', + 'scrollUp', + 'scrollDown', + 'scrollTo', + 'getCurrentScrollLine', + 'getLiveEndLine', + 'ensureScrollRangeForTarget', + 'animateToTopLine', + 'getMaxStoryTopLine', + 'ensureLiveTailWindow', + 'readFirstFiniteNumber', + 'waitForSkippablePause', + 'focusTurn', + 'handleStoryScroll', + 'rerenderStory', + 'clear', + 'scheduleRerender', + 'isDisplayGenerationCurrent', + 'measureText', + 'loadCSS', + 'showChoices', + 'preloadImages', + 'createCreditsDialog', + 'openCreditsDialog', + 'closeCreditsDialog', + 'loadCreditsText', + 'getMarkdownRenderer', + 'renderMarkdown', + 'populateCreativeCredits', + 'creditLink', + 'createNotificationDialog', + 'handleStoryTag', + 'getTagMessage', + 'dispatchDeferredTagsForBlock', + 'showNotification', + 'displayNextNotification', + 'queueTerminalNotification', + 'flushTerminalNotifications', + 'closeNotification', + 'renderInlineMarkup' + ]); + + console.log('UIDisplayHandler: Constructor initialized'); + } + + t(key, params = {}) { + return this.localization?.translate?.(key, params) || key; + } + + async initialize() { + try { + this.reportProgress(10, "Initializing UI Display Handler"); + + // Load CSS and preload images + this.reportProgress(20, "Loading CSS and preloading images"); + await this.loadCSS(this.cssPath); + await this.preloadImages(this.imagesToPreload); + + this.reportProgress(30, "Getting module dependencies"); + + // Get references to required modules using parent's getModule method + this.layoutRenderer = this.getModule('layout-renderer'); + this.playbackCoordinator = this.getModule('playback-coordinator'); + this.gameConfig = this.getModule('game-config'); + this.localization = this.getModule('localization'); + this.storyHistory = this.getModule('story-history'); + this.persistenceManager = this.getModule('persistence-manager'); + + this.reportProgress(50, "Initializing display containers"); + + // Initialize container elements + this.initializeContainers(); + + this.reportProgress(70, "Setting up typography"); + + this.reportProgress(90, "Setting up event listeners"); + this.addEventListener(document, 'book:resized', () => { + this.scheduleRerender(); + }); + this.addEventListener(document, 'game:config', (event) => { + this.applyGameConfig(event.detail); + }); + this.addEventListener(document, 'localization:languageChanged', () => { + this.applyTranslations(); + }); + this.addEventListener(document, 'story:scroll-to-turn', (event) => { + this.focusTurn(event.detail?.turnId); + }); + this.addEventListener(document, 'story:history-updated', (event) => { + this.updateStoryScrollbar(event.detail || {}); + }); + this.addEventListener(document, 'story:tag', (event) => { + this.handleStoryTag(event.detail); + }); + this.addEventListener(document, 'story:turn-start', () => { + this.latestInputMode = 'text'; + }); + this.addEventListener(document, 'story:input-mode', (event) => { + this.latestInputMode = event.detail || 'text'; + }); + this.addEventListener(document, 'wheel', this.handleHistoryWheel, { passive: false }); + this.addEventListener(document, 'keydown', (event) => { + const tagName = String(event.target?.tagName || '').toLowerCase(); + if (['input', 'textarea', 'select'].includes(tagName) || event.altKey || event.ctrlKey || event.metaKey) { + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + this.handleManualScrollStart('arrow-up'); + this.scrollUp(1); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + this.handleManualScrollStart('arrow-down'); + this.scrollDown(1); + } else if (event.key === 'PageUp') { + event.preventDefault(); + this.handleManualScrollStart('page-up'); + this.scrollUp(24); + } else if (event.key === 'PageDown') { + event.preventDefault(); + this.handleManualScrollStart('page-down'); + this.scrollDown(24); + } else if (event.key === 'Home') { + event.preventDefault(); + this.handleManualScrollStart('home'); + this.scrollTo(0, { mode: 'home' }); + } else if (event.key === 'End') { + event.preventDefault(); + this.handleManualScrollStart('end'); + this.scrollTo(this.getLiveEndLine(), { mode: 'end' }); + } + }); + this.addEventListener(document, 'story:process-state', (event) => { + const state = event.detail?.state || 'ready'; + const remark = document.getElementById('remark_text'); + if (remark) { + remark.textContent = state === 'paused' + ? this.t('title.continueHint') + : this.t('title.fastForwardHint'); + } + if (state === 'ready' && this.latestInputMode === 'end') { + this.flushTerminalNotifications(); + } + }); + + if (window.ResizeObserver && this.paragraphContainer) { + this.storyResizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + + const computedStyle = window.getComputedStyle(this.paragraphContainer); + const metrics = { + width: Math.round(entry.contentRect.width), + fontSize: computedStyle.fontSize, + lineHeight: computedStyle.lineHeight + }; + + if (!this.lastStoryMetrics) { + this.lastStoryMetrics = metrics; + return; + } + + const changed = metrics.width !== this.lastStoryMetrics.width || + metrics.fontSize !== this.lastStoryMetrics.fontSize || + metrics.lineHeight !== this.lastStoryMetrics.lineHeight; + + this.lastStoryMetrics = metrics; + if (changed) { + this.scheduleRerender(); + } + }); + this.storyResizeObserver.observe(this.paragraphContainer); + } + + this.reportProgress(100, "UI Display Handler ready"); + return true; + } catch (error) { + console.error("Error initializing UI Display Handler:", error); + return false; + } + } + + scheduleRerender() { + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => this.rerenderStory(), 80); + } + + + /** + * Load CSS file asynchronously and wait for it to be applied + * @param {string} cssPath - Path to CSS file + * @returns {Promise} + */ + loadCSS(cssPath) { + return new Promise((resolve, reject) => { + // Create link element + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = cssPath; + + // Set up load and error handlers + link.onload = () => { + console.log(`UIDisplayHandler: CSS ${cssPath} loaded successfully`); + resolve(); + }; + + link.onerror = (error) => { + console.error(`UIDisplayHandler: Failed to load CSS ${cssPath}:`, error); + reject(error); + }; + + // Add to document head + document.head.appendChild(link); + }); + } + + /** + * Preload images to ensure they're in the cache + * @param {Array} imagePaths - Array of image paths to preload + * @returns {Promise} + */ + preloadImages(imagePaths) { + if (!imagePaths || imagePaths.length === 0) { + return Promise.resolve(); + } + + const promises = imagePaths.map(path => { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(); + img.onerror = () => { + console.warn(`UIDisplayHandler: Failed to preload image ${path}`); + resolve(); // Resolve anyway to not block loading + }; + img.src = path; + }); + }); + + return Promise.all(promises); + } + + /** + * Initialize the UI containers + */ + initializeContainers() { + // Check if the book container already exists + let bookContainer = document.getElementById('book'); + if (!bookContainer) { + console.log('UIDisplayHandler: Book container not found, creating it'); + bookContainer = document.createElement('div'); + bookContainer.id = 'book'; + document.body.appendChild(bookContainer); + } + + // Create or find page_left + this.pageLeft = document.getElementById('page_left'); + if (!this.pageLeft) { + console.log('UIDisplayHandler: Left page not found, creating it'); + this.pageLeft = document.createElement('div'); + this.pageLeft.id = 'page_left'; + + // Create header content + const header = document.createElement('div'); + header.className = 'header'; + header.innerHTML = ` + +

+

+
+ `; + this.pageLeft.appendChild(header); + + // Create controls + const controls = document.createElement('div'); + controls.id = 'controls'; + controls.className = 'buttons'; + controls.innerHTML = ` + + + + + + + + `; + this.pageLeft.appendChild(controls); + + // Create choices container + const choicesContainer = document.createElement('div'); + choicesContainer.id = 'choices'; + choicesContainer.className = 'container'; + + const controlSeparator = document.createElement('div'); + controlSeparator.id = 'left_control_separator'; + choicesContainer.appendChild(controlSeparator); + + // Create command history container + const commandHistory = document.createElement('div'); + commandHistory.id = 'command_history'; + choicesContainer.appendChild(commandHistory); + + // Create command input container + const commandInput = document.createElement('div'); + commandInput.id = 'command_input'; + commandInput.innerHTML = ` +
+ + +
+ `; + choicesContainer.appendChild(commandInput); + + this.pageLeft.appendChild(choicesContainer); + + // Create remark + const remark = document.createElement('div'); + remark.id = 'remark'; + remark.innerHTML = ` +
+ + `; + this.pageLeft.appendChild(remark); + + bookContainer.appendChild(this.pageLeft); + } + + const choicesPanel = document.getElementById('choices'); + if (choicesPanel && !document.getElementById('left_control_separator')) { + const controlSeparator = document.createElement('div'); + controlSeparator.id = 'left_control_separator'; + choicesPanel.insertBefore(controlSeparator, choicesPanel.firstChild); + } + + // Create or find page_right + this.pageRight = document.getElementById('page_right'); + if (!this.pageRight) { + console.log('UIDisplayHandler: Right page not found, creating it'); + this.pageRight = document.createElement('div'); + this.pageRight.id = 'page_right'; + bookContainer.appendChild(this.pageRight); + } + if (!document.getElementById('story_scrollbar')) { + const storyScrollbar = document.createElement('div'); + storyScrollbar.id = 'story_scrollbar'; + storyScrollbar.innerHTML = '
'; + this.pageRight.appendChild(storyScrollbar); + } + const storyScrollbar = document.getElementById('story_scrollbar'); + if (storyScrollbar && !storyScrollbar.dataset.historyScrollBound) { + storyScrollbar.dataset.historyScrollBound = 'true'; + ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick'].forEach((type) => { + storyScrollbar.addEventListener(type, (event) => { + event.preventDefault(); + event.stopPropagation(); + if (type === 'pointerdown') { + this.handleStoryScrollbarPointer(event); + } else if ((type === 'pointerup' || type === 'mouseup') && typeof this.storyScrollbarReleaseHandler === 'function') { + this.storyScrollbarReleaseHandler(event); + } + if (typeof event.stopImmediatePropagation === 'function') { + event.stopImmediatePropagation(); + } + }, true); + }); + storyScrollbar.addEventListener('wheel', (event) => { + event.preventDefault(); + event.stopPropagation(); + this.handleHistoryWheel(event); + }, { passive: false }); + } + + // Create or find story container + this.container = document.getElementById('story'); + if (!this.container) { + console.log('UIDisplayHandler: Story container not found, creating it'); + this.container = document.createElement('div'); + this.container.id = 'story'; + this.container.className = 'container'; + this.pageRight.appendChild(this.container); + } + + if (!document.getElementById('start_prompt')) { + const startPrompt = document.createElement('div'); + startPrompt.id = 'start_prompt'; + this.pageRight.appendChild(startPrompt); + } + + // Create paragraph container inside story container + this.paragraphContainer = document.getElementById('paragraphs'); + if (!this.paragraphContainer) { + console.log('UIDisplayHandler: Paragraphs container not found, creating it'); + this.paragraphContainer = document.createElement('div'); + this.paragraphContainer.id = 'paragraphs'; + this.container.appendChild(this.paragraphContainer); + } + + // Create ruler for text measurements + let ruler = document.getElementById('ruler'); + if (!ruler) { + ruler = document.createElement('div'); + ruler.id = 'ruler'; + document.body.appendChild(ruler); + } + + // Create lighting effect + let lighting = document.getElementById('lighting'); + if (!lighting) { + lighting = document.createElement('div'); + lighting.id = 'lighting'; + document.body.appendChild(lighting); + } + + this.createCreditsDialog(); + this.createNotificationDialog(); + + console.log('UIDisplayHandler: All containers initialized'); + this.applyGameConfig(this.gameConfig?.getConfig?.()); + this.applyTranslations(); + this.measureStoryLineHeight(); + this.setStoryOffset(0); + } + + applyGameConfig(config) { + const metadata = config?.metadata || this.gameConfig?.getMetadata?.() || {}; + const titleElement = document.getElementById('game_title'); + const authorElement = document.getElementById('game_author'); + const subtitleElement = document.getElementById('game_subtitle'); + const legalElement = document.getElementById('game_legal'); + document.getElementById('game_version')?.remove(); + document.getElementById('game_copyright')?.remove(); + + if (titleElement) titleElement.textContent = metadata.title || ''; + if (authorElement) authorElement.textContent = metadata.author ? this.t('title.byAuthor', { author: metadata.author }) : ''; + if (subtitleElement) subtitleElement.textContent = metadata.subtitle || ''; + if (legalElement) { + const items = [ + metadata.version ? this.t('title.version', { version: metadata.version }) : '', + metadata.copyright || '' + ].filter(Boolean); + legalElement.innerHTML = ''; + const legalText = document.createElement('span'); + legalText.id = 'game_legal_text'; + legalText.textContent = items.join(' | '); + legalElement.appendChild(legalText); + + const creditsButton = document.createElement('button'); + creditsButton.id = 'credits_button'; + creditsButton.type = 'button'; + creditsButton.textContent = this.t('credits.button'); + creditsButton.title = this.t('credits.buttonTitle'); + creditsButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + this.openCreditsDialog(); + }); + + if (items.length > 0) { + legalElement.appendChild(document.createTextNode(' | ')); + } + legalElement.appendChild(creditsButton); + } + } + + applyTranslations() { + this.localization = this.getModule('localization') || this.localization; + + const setText = (id, key) => { + const element = document.getElementById(id); + if (element) element.textContent = this.t(key); + }; + const setTitle = (id, key) => { + const element = document.getElementById(id); + if (element) element.setAttribute('title', this.t(key)); + }; + + setText('speech', 'topbar.speech'); + setText('autoplay', 'topbar.autoplay'); + setText('speed_label', 'topbar.speed'); + setText('rewind', 'topbar.newGame'); + setText('save', 'topbar.save'); + setText('reload', 'topbar.load'); + setText('options', 'topbar.options'); + setText('remark_text', 'title.fastForwardHint'); + setText('start_prompt', 'title.startPrompt'); + setText('credits_dialog_title', 'credits.title'); + setText('credits_close_footer', 'credits.close'); + setText('story_popup_ok', 'popup.ok'); + setTitle('speech', 'topbar.speechTitle'); + setTitle('autoplay', 'topbar.autoplayTitle'); + setTitle('rewind', 'topbar.newGameTitle'); + setTitle('save', 'topbar.saveTitle'); + setTitle('reload', 'topbar.loadTitle'); + setTitle('options', 'topbar.optionsTitle'); + + const input = document.getElementById('player_input'); + if (input) input.setAttribute('placeholder', this.t('input.placeholder')); + this.applyGameConfig(this.gameConfig?.getConfig?.()); + } + + createCreditsDialog() { + if (document.getElementById('credits_modal')) { + return; + } + + const modal = document.createElement('div'); + modal.id = 'credits_modal'; + modal.className = 'credits-modal'; + modal.setAttribute('aria-hidden', 'true'); + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + modal.addEventListener('click', (event) => { + if (event.target === modal) { + this.closeCreditsDialog(); + } + }); + + [document.getElementById('credits_close'), document.getElementById('credits_close_footer')] + .filter(Boolean) + .forEach(button => button.addEventListener('click', () => this.closeCreditsDialog())); + } + + async openCreditsDialog() { + const modal = document.getElementById('credits_modal'); + const content = document.getElementById('credits_content'); + if (!modal || !content) { + return; + } + + modal.classList.add('visible'); + modal.setAttribute('aria-hidden', 'false'); + + if (!content.dataset.loaded) { + content.textContent = this.t('credits.loading'); + content.innerHTML = await this.renderMarkdown(await this.loadCreditsText()); + content.dataset.loaded = 'true'; + } + this.populateCreativeCredits(); + } + + closeCreditsDialog() { + const modal = document.getElementById('credits_modal'); + if (!modal) { + return; + } + modal.classList.remove('visible'); + modal.setAttribute('aria-hidden', 'true'); + } + + async loadCreditsText() { + try { + const response = await fetch('/THIRD_PARTY_NOTICES.md', { cache: 'no-cache' }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return await response.text(); + } catch (error) { + console.warn('UIDisplayHandler: Failed to load credits notices', error); + return this.t('credits.loadFailed'); + } + } + + async getMarkdownRenderer() { + if (!this.markdownRendererPromise) { + this.markdownRendererPromise = import('/js/vendor/marked.esm.js') + .then(module => module.marked || module.default || module); + } + return this.markdownRendererPromise; + } + + async renderMarkdown(markdown) { + try { + const renderer = await this.getMarkdownRenderer(); + if (typeof renderer.parse === 'function') { + return renderer.parse(String(markdown || ''), { async: false }); + } + } catch (error) { + console.warn('UIDisplayHandler: Failed to render Markdown notices', error); + } + return `
${String(markdown || '').replace(/&/g, '&').replace(//g, '>')}
`; + } + + populateCreativeCredits() { + const container = document.getElementById('credits_creative'); + if (!container || container.dataset.loaded) return; + + const sections = [ + { + title: 'Production', + rows: [ + ['Produced by', ['Bad Tools Studio']], + ['Story', ['Georg Tomitsch']], + ['Writing', ['Georg Tomitsch', 'ChatGPT']], + ['UI visual design', ['Georg Tomitsch']], + ['Typography', ['EB Garamond 12 by Georg Duffner and Octavio Pardo', 'EB Garamond Initials by Georg Duffner']], + ['Art direction', ['Georg Tomitsch']], + ['Music', ['Georg Tomitsch', 'Suno']], + ['Images', ['OpenAI GPT-image-2']] + ] + }, + { + title: 'Technology', + rows: [ + ['Runtime server programming', ['Georg Tomitsch', 'OpenAI Codex']], + ['Client and UI programming', ['Georg Tomitsch', 'OpenAI Codex', 'Claude Code']], + ['Game engine', ['Ink by Inkle', 'inkjs by Yannick Lohse']] + ] + } + ]; + + container.innerHTML = sections.map(section => ` +
+

${section.title}

+ ${section.rows.map(([label, names]) => ` +
+
${label}
+
${names.map(name => this.creditLink(name)).join(', ')}
+
+ `).join('')} +
+ `).join(''); + container.dataset.loaded = 'true'; + } + + creditLink(name) { + const links = { + 'Bad Tools Studio': '', + 'OpenAI Codex': 'https://openai.com/codex/', + 'OpenAI GPT-image-2': 'https://openai.com/', + 'ChatGPT': 'https://chatgpt.com/', + 'Claude Code': 'https://www.anthropic.com/claude-code', + 'Ink by Inkle': 'https://www.inklestudios.com/ink/', + 'inkjs by Yannick Lohse': 'https://www.npmjs.com/package/inkjs', + 'EB Garamond 12 by Georg Duffner and Octavio Pardo': 'https://github.com/octaviopardo/EBGaramond12', + 'EB Garamond Initials by Georg Duffner': 'https://github.com/georgd/EB-Garamond', + 'Suno': 'https://suno.com/' + }; + const escaped = String(name || '').replace(/&/g, '&').replace(//g, '>'); + const url = links[name]; + return url ? `${escaped}` : escaped; + } + + createNotificationDialog() { + if (document.getElementById('story_popup_modal')) { + return; + } + + const modal = document.createElement('div'); + modal.id = 'story_popup_modal'; + modal.className = 'story-popup-modal'; + modal.setAttribute('aria-hidden', 'true'); + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + modal.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (event.target === modal) { + this.closeNotification(); + } + }); + modal.addEventListener('pointerdown', (event) => { + event.stopPropagation(); + }); + + [document.getElementById('story_popup_ok'), document.getElementById('story_popup_close')] + .filter(Boolean) + .forEach(button => button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + this.closeNotification(); + })); + } + + handleStoryTag(tag) { + const key = String(tag?.key || '').toLowerCase(); + if (!['score', 'error', 'achievement', 'alert'].includes(key)) { + return; + } + + const message = this.getTagMessage(tag); + if (key === 'score') { + this.showNotification( + 'ending', + this.t('popup.endingTitle'), + message || this.t('popup.defaultEnding') + ); + } else if (key === 'error') { + this.showNotification( + 'error', + this.t('popup.errorTitle'), + message || this.t('popup.defaultError') + ); + } else if (key === 'achievement') { + this.showNotification( + 'achievement', + this.t('popup.achievementTitle'), + message || this.t('popup.defaultAchievement') + ); + } else if (key === 'alert') { + this.showNotification( + 'alert', + this.t('popup.alertTitle'), + message || this.t('popup.defaultAlert') + ); + } + } + + getTagMessage(tag) { + return [tag?.value, tag?.param] + .map((part) => String(part || '').trim()) + .filter(Boolean) + .join('\n'); + } + + dispatchDeferredTagsForBlock(block) { + const directTags = Array.isArray(block?.deferredTags) ? block.deferredTags : []; + const metadataTags = Array.isArray(block?.metadata?.deferredTags) ? block.metadata.deferredTags : []; + const tags = [...directTags, ...metadataTags]; + if (tags.length === 0) return; + + tags.forEach((tag) => { + if (!tag?.key) return; + document.dispatchEvent(new CustomEvent('story:tag', { + detail: { + ...tag, + blockId: block.blockId ?? null, + turnId: block.turnId ?? null + } + })); + }); + block.deferredTags = []; + if (block.metadata) { + block.metadata.deferredTags = []; + } + } + + showNotification(kind, title, message) { + this.notificationQueue.push({ kind, title, message }); + this.displayNextNotification(); + } + + queueTerminalNotification(kind, title, message) { + this.pendingTerminalNotifications.push({ kind, title, message }); + if (this.latestInputMode === 'end') { + this.flushTerminalNotifications(); + } + } + + flushTerminalNotifications() { + if (this.pendingTerminalNotifications.length === 0) { + return; + } + this.pendingTerminalNotifications.splice(0).forEach((notification) => { + this.showNotification(notification.kind, notification.title, notification.message); + }); + } + + displayNextNotification() { + if (this.notificationActive || this.notificationQueue.length === 0) { + return; + } + + const next = this.notificationQueue.shift(); + const modal = document.getElementById('story_popup_modal'); + const title = document.getElementById('story_popup_title'); + const message = document.getElementById('story_popup_message'); + const okButton = document.getElementById('story_popup_ok'); + if (!modal || !title || !message) { + return; + } + + modal.dataset.kind = next.kind; + title.innerHTML = this.renderInlineMarkup(next.title); + message.innerHTML = this.renderInlineMarkup(next.message); + if (okButton) { + okButton.textContent = this.t('popup.ok'); + setTimeout(() => okButton.focus(), 0); + } + this.notificationActive = true; + modal.classList.add('visible'); + modal.setAttribute('aria-hidden', 'false'); + } + + closeNotification() { + const modal = document.getElementById('story_popup_modal'); + if (!modal) { + this.notificationActive = false; + return; + } + modal.classList.remove('visible'); + modal.setAttribute('aria-hidden', 'true'); + this.notificationActive = false; + setTimeout(() => this.displayNextNotification(), 0); + } + + renderInlineMarkup(text) { + const markupParser = this.getModule('markup-parser'); + if (markupParser && typeof markupParser.markdownToHtml === 'function') { + return markupParser.markdownToHtml(String(text || '')); + } + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>'); + } + + /** + * Measure text width using canvas + * @param {string} text - Text to measure + * @returns {number} - Width of the text + */ + measureText(text) { + // Use ParagraphLayout's measureText function instead of implementing our own + if (this.paragraphLayout && typeof this.paragraphLayout.measureText === 'function') { + return this.paragraphLayout.measureText(text); + } + + // Fallback measuring if paragraph layout is not available + if (!this.canvas) { + this.canvas = document.createElement('canvas'); + this.context = this.canvas.getContext('2d'); + this.context.font = `${this.config.typography.fontSize} ${this.config.typography.fontFamily}`; + } + + return this.context.measureText(text).width; + } + + + async renderSentence(sentence) { + if (!sentence) { + console.error('UIDisplayHandler: Invalid sentence object'); + return null; + } + + const generation = this.displayGeneration; + const sentenceGameId = sentence.gameId || null; + const isCurrent = () => this.isDisplayGenerationCurrent(generation, sentenceGameId); + + try { + await this.ensureLiveTailWindow(); + if (!isCurrent()) return null; + await this.scrollTo(this.getLiveEndLine(), { mode: 'enter-live-tail', smooth: false }); + if (!isCurrent()) return null; + this.rebuildLayoutExclusions(this.renderedItems); + this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems); + const element = await this.renderStoryBlock(sentence, { + animate: true, + playback: true, + placement: 'append', + token: this.renderWindowToken, + generation + }); + if (!element) return null; + if (!isCurrent()) { + element.remove(); + return null; + } + sentence.element = element; + await this.scrollTo(this.getLiveEndLine(), { mode: 'append-live' }); + if (!isCurrent()) { + element.remove(); + return null; + } + + if (sentence.kind === 'image') { + this.revealImageBlock(element); + } else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') { + await this.playbackCoordinator.play(sentence); + } else if (sentence.kind === 'music') { + console.log('UIDisplayHandler: Music block started', sentence.metadata || {}); + } + if (!isCurrent()) return null; + + this.dispatchDeferredTagsForBlock(sentence); + + if (sentence.onComplete) { + sentence.onComplete(); + } + + return element; + + } catch (error) { + console.error('UIDisplayHandler: Error rendering sentence:', error); + throw error; + } + } + + async rerenderStory() { + if (!this.paragraphContainer || this.renderedItems.length === 0) return; + console.log('UIDisplayHandler: Re-typesetting story after page resize'); + const generation = this.displayGeneration; + const activeLine = this.getCurrentScrollLine(); + await this.renderHistoryWindow([...this.renderedItems], { generation }); + if (!this.isDisplayGenerationCurrent(generation)) return; + await this.scrollTo(activeLine, { mode: 'rerender-preserve', smooth: false }); + } + + isDisplayGenerationCurrent(generation = this.displayGeneration, gameId = null) { + if (generation !== this.displayGeneration) return false; + if (gameId && this.storyHistory?.currentGameId && this.storyHistory.currentGameId !== gameId) { + return false; + } + return true; + } + + async restoreFromHistory(saveRecord = {}) { + if (!this.paragraphContainer || !this.storyHistory || !saveRecord?.gameId) return; + const generation = this.displayGeneration; + const latestRenderedBlockId = Math.max(0, Number(saveRecord.latestRenderedBlockId || 0)); + if (!this.storyHistory.renderedLineCount) { + await this.storyHistory.getRenderedLineCount(saveRecord.gameId, latestRenderedBlockId); + if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return; + } + const targetLine = Math.max(0, latestRenderedBlockId > 0 ? this.storyHistory.renderedLineCount - 1 : 0); + if (latestRenderedBlockId > 0) { + const targetBlock = await this.storyHistory.findBlockForLine(saveRecord.gameId, targetLine, latestRenderedBlockId); + if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return; + const targetBlockId = Math.max(1, Number(targetBlock?.blockId || latestRenderedBlockId)); + await this.renderWindowForBounds({ + start: Math.max(1, targetBlockId - this.historyBufferBlocks), + end: Math.min(latestRenderedBlockId, targetBlockId + this.historyBufferBlocks), + targetBlockId, + windowOriginLine: this.getTopLineForActiveLine(targetLine), + gameId: saveRecord.gameId, + generation + }); + } else { + await this.renderHistoryWindow([], { windowOriginLine: 0, generation }); + } + if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return; + await this.scrollTo(targetLine, { + mode: 'restore-bottom', + smooth: false + }); + if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return; + this.updateStoryScrollbar({ latestBlockId: saveRecord.latestBlockId || latestRenderedBlockId || 1 }); + } + + insertStoredElement(element, placement = 'append', targetContainer = this.paragraphContainer) { + if (!targetContainer || !element) return; + if (placement === 'prepend') { + targetContainer.insertBefore(element, targetContainer.firstChild); + } else { + targetContainer.appendChild(element); + } + } + + async renderStoryBlock(item, options = {}) { + const { + animate = false, + playback = false, + placement = 'append', + targetContainer = this.paragraphContainer, + renderedItemsTarget = this.renderedItems, + token = null, + recordMetrics = true, + generation = this.displayGeneration + } = options; + if (!item || !this.paragraphContainer) return null; + const renderable = await this.prepareRenderableBlock(item); + if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) return null; + if (token != null && token !== this.renderWindowToken) return null; + if (!renderable) return null; + + const type = renderable.type; + let element = null; + if (type === 'image') { + element = this.renderImageBlock(renderable, animate, placement, targetContainer); + } else if (type === 'paragraph' || type === 'heading') { + element = this.layoutRenderer.renderParagraph(renderable.layout, { id: renderable.id }); + this.insertStoredElement(element, placement, targetContainer); + if (!animate) { + this.makeRenderedWordsVisible(element); + element.dataset.playbackComplete = 'true'; + } + if (playback) { + const sentenceQueue = this.getModule('sentence-queue'); + const words = sentenceQueue?.extractWords?.(renderable.layout.nodes) || []; + item.animation = sentenceQueue?.calculateAnimationTiming?.(words, item.tts?.duration || 0, renderable.metadata.cueMarkers || []) + || { wordTimings: [], cueTimings: [], totalDuration: 0 }; + item.element = element; + } + } else { + element = document.createElement('div'); + element.style.display = 'none'; + this.insertStoredElement(element, placement, targetContainer); + if (playback) { + document.dispatchEvent(new CustomEvent('story:media-block', { + detail: { + id: item.id, + type, + ...(item.metadata || {}) + } + })); + } + } + + if (item.turnId != null) { + element.dataset.turnId = String(item.turnId); + element.classList.add('story-turn-block'); + } + if (item.blockId != null) { + element.dataset.storyBlockId = String(item.blockId); + this.markBlockRendered(item.blockId); + } + element.dataset.lineStart = String(renderable.lineStart); + element.dataset.lineCount = String(renderable.lineCount); + element.dataset.heightLines = String(renderable.lineCount); + + const renderedItem = { + ...item, + type, + lineStart: renderable.lineStart, + lineCount: renderable.lineCount, + metadata: { + ...(item.metadata || {}), + ...renderable.metadata, + lineStart: renderable.lineStart, + lineCount: renderable.lineCount + } + }; + if (placement === 'prepend') { + renderedItemsTarget.unshift(renderedItem); + } else { + renderedItemsTarget.push(renderedItem); + } + + if (recordMetrics && item.blockId != null) { + const updated = await this.recordRenderedMetrics(item.blockId, element, renderable.lineCount, renderable.lineStart); + if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) { + element?.remove(); + return null; + } + if (token != null && token !== this.renderWindowToken) { + element?.remove(); + return null; + } + if (updated) { + renderedItem.lineStart = updated.lineStart; + renderedItem.lineCount = updated.lineCount; + renderedItem.metadata.lineStart = updated.lineStart; + renderedItem.metadata.lineCount = updated.lineCount; + } + } + this.historyWindowStartId = renderedItemsTarget.find(entry => entry.blockId != null)?.blockId || this.historyWindowStartId; + this.historyWindowEndId = [...renderedItemsTarget].reverse().find(entry => entry.blockId != null)?.blockId || this.historyWindowEndId; + this.setVirtualPadding(); + this.updateStoryScrollbar(); + return element; + } + + async renderHistoryWindow(blocks = [], options = {}) { + if (!this.paragraphContainer) return; + const generation = options.generation ?? this.displayGeneration; + if (!this.isDisplayGenerationCurrent(generation, options.gameId || blocks[0]?.gameId || null)) return; + const token = ++this.renderWindowToken; + const orderedBlocks = Array.isArray(blocks) + ? [...blocks].sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0)) + : []; + const lineStarts = orderedBlocks + .map(block => Number(block?.lineStart ?? block?.metadata?.lineStart)) + .filter(Number.isFinite); + const nextOrigin = lineStarts.length ? Math.max(0, Math.min(...lineStarts)) : 0; + const fragment = document.createDocumentFragment(); + const nextRenderedItems = []; + + this.windowOriginLine = nextOrigin; + this.layoutFlowLine = nextOrigin; + this.rebuildLayoutExclusions(orderedBlocks); + + for (const item of orderedBlocks) { + await this.renderStoryBlock(item, { + animate: false, + playback: false, + placement: 'append', + targetContainer: fragment, + renderedItemsTarget: nextRenderedItems, + token, + generation + }); + if (token !== this.renderWindowToken) return; + if (!this.isDisplayGenerationCurrent(generation, item.gameId || options.gameId || null)) return; + } + + if (token !== this.renderWindowToken) return; + if (!this.isDisplayGenerationCurrent(generation, options.gameId || orderedBlocks[0]?.gameId || null)) return; + this.paragraphContainer.replaceChildren(fragment); + this.renderedItems = nextRenderedItems; + this.historyWindowStartId = orderedBlocks[0]?.blockId || 1; + this.historyWindowEndId = orderedBlocks.at(-1)?.blockId || 0; + this.setVirtualPadding(); + this.setStoryOffset(-((this.storyTopLine - (this.windowOriginLine || 0)) * this.lineHeightPx)); + this.updateStoryScrollbar(); + } + + setWindowOriginLine(originLine = 0) { + const nextOrigin = Math.max(0, Math.round(Number(originLine || 0))); + this.windowOriginLine = nextOrigin; + const lineHeight = this.measureStoryLineHeight(); + this.paragraphContainer?.querySelectorAll?.('[data-story-block-id][data-line-start]')?.forEach(element => { + const lineStart = Number(element.dataset.lineStart); + if (Number.isFinite(lineStart)) { + element.style.top = `${(lineStart - nextOrigin) * lineHeight}px`; + } + }); + this.setStoryOffset(-((this.storyTopLine - nextOrigin) * lineHeight)); + this.setVirtualPadding(); + } + + removeRenderedElement(item) { + if (!item?.blockId || !this.paragraphContainer) return; + const element = this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(item.blockId))}"]`); + element?.remove(); + } + + removeRenderedBlocksOutside(startBlockId, endBlockId) { + const start = Math.max(1, Number(startBlockId || 1)); + const end = Math.max(start, Number(endBlockId || start)); + this.renderedItems = this.renderedItems.filter(item => { + const blockId = Number(item?.blockId || 0); + const keep = blockId >= start && blockId <= end; + if (!keep) { + this.removeRenderedElement(item); + } + return keep; + }); + this.historyWindowStartId = this.renderedItems.find(entry => entry.blockId != null)?.blockId || 1; + this.historyWindowEndId = [...this.renderedItems].reverse().find(entry => entry.blockId != null)?.blockId || 0; + } + + dedupeRenderedWindow() { + const seenItems = new Set(); + this.renderedItems = this.renderedItems + .sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0)) + .filter(item => { + const blockId = Number(item?.blockId || 0); + if (!blockId) return true; + if (seenItems.has(blockId)) return false; + seenItems.add(blockId); + return true; + }); + + const seenElements = new Set(); + this.paragraphContainer?.querySelectorAll?.('[data-story-block-id]')?.forEach(element => { + const blockId = element.dataset.storyBlockId; + if (!blockId) return; + if (seenElements.has(blockId)) { + element.remove(); + } else { + seenElements.add(blockId); + } + }); + + if (this.paragraphContainer) { + const ordered = document.createDocumentFragment(); + this.renderedItems.forEach(item => { + if (item?.blockId == null) return; + const element = this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(item.blockId))}"]`); + if (element) ordered.appendChild(element); + }); + this.paragraphContainer.appendChild(ordered); + } + } + + blockIntersectsExclusions(item = {}) { + if (!this.layoutExclusions.length) return false; + const type = item.kind || item.type || 'paragraph'; + if (type !== 'paragraph' && type !== 'heading') return false; + const start = Number(item.lineStart ?? item.metadata?.lineStart); + const count = Math.max(0, Number(item.lineCount ?? item.metadata?.lineCount ?? 0)); + if (!Number.isFinite(start) || count <= 0) return false; + const end = start + count; + return this.layoutExclusions.some(exclusion => start < exclusion.endLine && end > exclusion.startLine); + } + + getFlowLineFromItems(items = this.renderedItems) { + const source = Array.isArray(items) ? items : []; + return source.reduce((max, item) => { + const type = String(item?.kind || item?.type || '').toLowerCase(); + const size = String(item?.metadata?.imageLayout?.size || item?.metadata?.size || item?.size || '').toLowerCase(); + if (type === 'image' && size === 'portrait') { + return max; + } + const start = Number(item?.lineStart ?? item?.metadata?.lineStart); + const count = Math.max(0, Number(item?.lineCount ?? item?.metadata?.lineCount ?? 0)); + return Number.isFinite(start) && count > 0 ? Math.max(max, start + count) : max; + }, 0); + } + + async reflowTextBlocksForActiveExclusions(token = this.renderWindowToken) { + if (!this.layoutExclusions.length || !this.paragraphContainer) return; + const generation = this.displayGeneration; + const candidates = this.renderedItems.filter(item => this.blockIntersectsExclusions(item)); + for (const item of candidates) { + if (token !== this.renderWindowToken) return; + if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) return; + const oldElement = this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(item.blockId))}"]`); + if (!oldElement) continue; + const previousFlowLine = this.layoutFlowLine; + const previousExclusions = [...this.layoutExclusions]; + const renderable = await this.prepareTextRenderable(item, (item.kind || item.type) === 'heading' ? 'heading' : 'paragraph'); + this.layoutFlowLine = previousFlowLine; + this.layoutExclusions = previousExclusions; + if (token !== this.renderWindowToken) return; + if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) return; + renderable.layout.lineCount = Math.max(1, Number(item.lineCount ?? item.metadata?.lineCount ?? renderable.lineCount)); + renderable.lineCount = renderable.layout.lineCount; + const replacement = this.layoutRenderer.renderParagraph(renderable.layout, { id: renderable.id }); + replacement.dataset.lineStart = String(renderable.lineStart); + replacement.dataset.lineCount = String(renderable.lineCount); + replacement.dataset.heightLines = String(renderable.lineCount); + if (item.turnId != null) { + replacement.dataset.turnId = String(item.turnId); + replacement.classList.add('story-turn-block'); + } + if (item.blockId != null) { + replacement.dataset.storyBlockId = String(item.blockId); + } + this.makeRenderedWordsVisible(replacement); + oldElement.replaceWith(replacement); + } + } + + async renderIncrementalWindow(bounds = {}, requestId = null) { + if (!this.storyHistory || !this.paragraphContainer) return; + const generation = bounds.generation ?? this.displayGeneration; + const gameId = bounds.gameId || this.storyHistory.currentGameId; + if (!this.isDisplayGenerationCurrent(generation, gameId)) return; + if (!this.renderedItems.length) { + await this.renderWindowForBounds(bounds, requestId); + return; + } + const token = ++this.renderWindowToken; + + const start = Math.max(1, Number(bounds.start || 1)); + const end = Math.max(start, Number(bounds.end || start)); + const keptItems = this.renderedItems.filter(item => { + const blockId = Number(item?.blockId || 0); + return blockId >= start && blockId <= end; + }); + const existingIds = new Set(keptItems.map(item => Number(item?.blockId || 0)).filter(Boolean)); + const missingBeforeEnd = Math.min(end, (this.historyWindowStartId || start) - 1); + const missingAfterStart = Math.max(start, (this.historyWindowEndId || 0) + 1); + const missingBeforeBlocks = start <= missingBeforeEnd + ? await this.storyHistory.getBlocksRange(gameId, start, missingBeforeEnd) + : []; + if (requestId != null && requestId !== this.scrollRequestId) return; + if (token !== this.renderWindowToken) return; + if (!this.isDisplayGenerationCurrent(generation, gameId)) return; + const missingAfterBlocks = missingAfterStart <= end + ? await this.storyHistory.getBlocksRange(gameId, missingAfterStart, end) + : []; + if (requestId != null && requestId !== this.scrollRequestId) return; + if (token !== this.renderWindowToken) return; + if (!this.isDisplayGenerationCurrent(generation, gameId)) return; + + const uniqueBeforeBlocks = missingBeforeBlocks.filter(block => { + const id = Number(block?.blockId || 0); + if (!id || existingIds.has(id)) return false; + existingIds.add(id); + return true; + }); + const uniqueAfterBlocks = missingAfterBlocks.filter(block => { + const id = Number(block?.blockId || 0); + if (!id || existingIds.has(id)) return false; + existingIds.add(id); + return true; + }); + const stagedItems = [...uniqueBeforeBlocks, ...keptItems, ...uniqueAfterBlocks] + .sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0)); + const originCandidates = stagedItems + .map(item => Number(item?.lineStart ?? item?.metadata?.lineStart)) + .filter(Number.isFinite); + const finalOrigin = originCandidates.length ? Math.min(...originCandidates) : 0; + const previousOrigin = this.windowOriginLine; + const previousFlowLine = this.layoutFlowLine; + const previousExclusions = [...this.layoutExclusions]; + this.windowOriginLine = finalOrigin; + this.layoutFlowLine = finalOrigin; + this.rebuildLayoutExclusions(stagedItems); + + const beforeFragment = document.createDocumentFragment(); + const beforeItems = []; + for (const block of uniqueBeforeBlocks) { + await this.renderStoryBlock(block, { + animate: false, + playback: false, + placement: 'append', + targetContainer: beforeFragment, + renderedItemsTarget: beforeItems, + token, + generation, + recordMetrics: false + }); + if (token !== this.renderWindowToken || (requestId != null && requestId !== this.scrollRequestId)) { + if (token === this.renderWindowToken) { + this.windowOriginLine = previousOrigin; + this.layoutFlowLine = previousFlowLine; + this.layoutExclusions = previousExclusions; + this.setWindowOriginLine(previousOrigin); + } + return; + } + } + + const afterFragment = document.createDocumentFragment(); + const afterItems = []; + for (const block of uniqueAfterBlocks) { + await this.renderStoryBlock(block, { + animate: false, + playback: false, + placement: 'append', + targetContainer: afterFragment, + renderedItemsTarget: afterItems, + token, + generation, + recordMetrics: false + }); + if (token !== this.renderWindowToken || (requestId != null && requestId !== this.scrollRequestId)) { + if (token === this.renderWindowToken) { + this.windowOriginLine = previousOrigin; + this.layoutFlowLine = previousFlowLine; + this.layoutExclusions = previousExclusions; + this.setWindowOriginLine(previousOrigin); + } + return; + } + } + + this.setWindowOriginLine(finalOrigin); + this.paragraphContainer.insertBefore(beforeFragment, this.paragraphContainer.firstChild); + this.paragraphContainer.appendChild(afterFragment); + this.renderedItems = [...beforeItems, ...keptItems, ...afterItems] + .sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0)); + this.removeRenderedBlocksOutside(start, end); + this.dedupeRenderedWindow(); + this.setWindowOriginLine(finalOrigin); + this.rebuildLayoutExclusions(this.renderedItems); + await this.reflowTextBlocksForActiveExclusions(token); + if (token !== this.renderWindowToken) return; + if (!this.isDisplayGenerationCurrent(generation, gameId)) return; + this.setVirtualPadding(); + this.updateStoryScrollbar(); + } + + handleHistoryWheel(event) { + if (!event.target?.closest?.('#page_right') || !this.pageRight) return; + if (event.target?.closest?.('.story-choices')) return; + event.preventDefault(); + event.stopPropagation(); + this.handleManualScrollStart('wheel'); + let lineDelta = 0; + const rawDelta = Number(event.deltaY || 0); + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) { + lineDelta = rawDelta; + } else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + lineDelta = rawDelta * (this.viewportLineCount || this.pageLineCount || 25); + } else { + lineDelta = rawDelta / Math.max(8, this.lineHeightPx || 24); + } + this.wheelLineAccumulator += lineDelta; + const wholeLines = Math.trunc(this.wheelLineAccumulator); + this.wheelLineAccumulator -= wholeLines; + if (wholeLines < 0) { + this.scrollUp(Math.abs(wholeLines), { mode: 'wheel' }); + } else if (wholeLines > 0) { + this.scrollDown(wholeLines, { mode: 'wheel' }); + } + } + + handleManualScrollStart(source = 'manual-scroll') { + this.lastManualScrollAt = performance.now(); + this.disableAutoplayForManualScroll(); + if (this.playbackCoordinator && this.playbackCoordinator.isPlaying && typeof this.playbackCoordinator.fastForward === 'function') { + this.playbackCoordinator.fastForward(); + } + document.dispatchEvent(new CustomEvent('story:manual-scroll', { + detail: { source } + })); + } + + async prepareRenderableBlock(item) { + const type = item.kind || item.type || 'paragraph'; + if (type === 'music' || type === 'sfx') { + return { + type, + id: item.id || `${type}-${item.blockId || Date.now()}`, + lineStart: this.layoutFlowLine, + lineCount: 0, + metadata: { ...(item.metadata || {}) } + }; + } + if (type === 'image') { + return this.prepareImageRenderable(item); + } + return this.prepareTextRenderable(item, type === 'heading' ? 'heading' : 'paragraph'); + } + + async prepareTextRenderable(item, type = 'paragraph') { + const sentenceQueue = this.getModule('sentence-queue'); + if (!sentenceQueue || typeof sentenceQueue.prepareLayout !== 'function') { + throw new Error('UIDisplayHandler: sentence-queue layout calculator unavailable.'); + } + if (document.fonts && document.fonts.ready) { + await document.fonts.ready; + } + + const metadata = { + ...(item.metadata || {}), + type, + role: item.role || item.metadata?.role || (type === 'heading' ? 'chapter-heading' : 'body'), + layoutText: item.layoutText || item.metadata?.layoutText || item.text, + isFirstParagraphInChapter: Boolean(item.isFirstParagraphInChapter ?? item.metadata?.isFirstParagraphInChapter), + dropCap: Boolean(item.dropCap ?? item.metadata?.dropCap), + addTopSpace: Boolean(item.addTopSpace ?? item.metadata?.addTopSpace), + paragraphIndex: item.paragraphIndex ?? item.metadata?.paragraphIndex, + cueMarkers: item.cueMarkers || item.metadata?.cueMarkers || [], + glossaryEntries: item.glossaryEntries || item.metadata?.glossaryEntries || [], + turnId: item.turnId ?? item.metadata?.turnId, + blockId: item.blockId ?? item.metadata?.blockId, + gameId: item.gameId ?? item.metadata?.gameId + }; + if (metadata.dropCap && typeof sentenceQueue.measureDropCapReservation === 'function') { + const dropCapText = typeof sentenceQueue.getDropCapText === 'function' + ? sentenceQueue.getDropCapText(metadata.layoutText || item.text || '') + : String(metadata.layoutText || item.text || '').trim().charAt(0); + metadata.dropCapWidth = await sentenceQueue.measureDropCapReservation( + this.container || this.paragraphContainer || document.getElementById('story'), + dropCapText, + this.measureStoryLineHeight() + ); + } + + const role = metadata.role; + const isHeading = type === 'heading' || role === 'chapter-heading' || role === 'section-heading'; + if (isHeading) { + this.layoutFlowLine = Math.max(this.layoutFlowLine, this.getExclusionEndLine()); + this.layoutExclusions = this.layoutExclusions.filter(exclusion => exclusion.endLine > this.layoutFlowLine); + } + + const topSpace = role === 'chapter-heading' ? 2 : role === 'section-heading' ? 1 : metadata.addTopSpace ? 1 : 0; + const bottomSpace = role === 'chapter-heading' ? 1 : role === 'section-heading' ? 1 : 0; + const lineStart = Number.isFinite(Number(item.lineStart)) ? Number(item.lineStart) : this.layoutFlowLine; + const contentStartLine = lineStart + topSpace; + const geometry = this.buildLineGeometry(metadata, contentStartLine); + const layout = await sentenceQueue.prepareLayout(item.text || '', { + ...metadata, + measures: geometry.measures, + lineOffsets: geometry.lineOffsets, + imageWrap: geometry.imageWrap + }); + const contentLines = Math.max(1, (layout.breaks?.length || 2) - 1); + const lineCount = Math.max(1, topSpace + contentLines + bottomSpace); + layout.lineStart = lineStart; + layout.lineCount = lineCount; + layout.contentTopLines = topSpace; + layout.measures = geometry.measures; + layout.lineOffsets = geometry.lineOffsets; + layout.pageWidth = geometry.pageWidth; + layout.windowOriginLine = this.windowOriginLine || 0; + + this.layoutFlowLine = Math.max(this.layoutFlowLine, contentStartLine + contentLines + bottomSpace); + this.layoutExclusions = this.layoutExclusions.filter(exclusion => exclusion.endLine > this.layoutFlowLine); + + return { + type, + id: item.id || `${type}-${item.blockId || Date.now()}`, + lineStart, + lineCount, + layout, + metadata + }; + } + + prepareImageRenderable(item) { + const metadata = { ...(item.metadata || {}), ...item }; + const metrics = this.calculateImageMetrics(metadata); + const lineStart = Number.isFinite(Number(item.lineStart)) ? Number(item.lineStart) : this.layoutFlowLine; + const lineCount = Math.max(1, Math.round(metrics.lineCount || 1)); + const renderMetadata = { + ...metadata, + imageLayout: { ...metrics, lineStart, lineCount }, + lineStart, + lineCount, + windowOriginLine: this.windowOriginLine || 0 + }; + + if ((metrics.size || metadata.size) === 'portrait') { + this.addImageExclusion({ ...item, metadata: renderMetadata, lineStart, lineCount }); + } else { + this.layoutFlowLine = Math.max(this.layoutFlowLine, this.getExclusionEndLine()); + this.layoutExclusions = []; + this.layoutFlowLine = Math.max(this.layoutFlowLine, lineStart + lineCount); + } + + return { + type: 'image', + id: item.id || `image-${item.blockId || Date.now()}`, + lineStart, + lineCount, + metadata: renderMetadata + }; + } + + createImageExclusion(item = {}) { + const type = item.kind || item.type; + const metadata = { ...(item.metadata || {}), ...item }; + const layout = metadata.imageLayout || {}; + const size = String(layout.size || metadata.size || '').toLowerCase(); + if (type !== 'image' || size !== 'portrait') return null; + + const lineStart = Number(metadata.lineStart ?? layout.lineStart ?? item.lineStart); + const lineCount = Math.max(1, Number(metadata.lineCount ?? layout.lineCount ?? item.lineCount ?? 1)); + const width = Number(layout.width ?? metadata.width ?? 0); + const gap = Number(layout.gap ?? metadata.gap ?? this.measureStoryLineHeight()); + if (!Number.isFinite(lineStart) || !Number.isFinite(width) || width <= 0) return null; + + return { + blockId: item.blockId ?? metadata.blockId, + startLine: Math.max(0, lineStart), + endLine: Math.max(0, lineStart) + lineCount, + width: width + Math.max(0, gap), + side: layout.floatSide || metadata.floatSide || 'right' + }; + } + + addImageExclusion(item = {}) { + const exclusion = this.createImageExclusion(item); + if (!exclusion) return; + const key = String(exclusion.blockId ?? `${exclusion.startLine}:${exclusion.endLine}:${exclusion.side}`); + const existingIndex = this.layoutExclusions.findIndex(entry => { + const entryKey = String(entry.blockId ?? `${entry.startLine}:${entry.endLine}:${entry.side}`); + return entryKey === key; + }); + if (existingIndex >= 0) { + this.layoutExclusions[existingIndex] = exclusion; + } else { + this.layoutExclusions.push(exclusion); + } + } + + rebuildLayoutExclusions(items = this.renderedItems) { + this.layoutExclusions = []; + const source = Array.isArray(items) ? items : []; + source.forEach(item => this.addImageExclusion(item)); + } + + getExclusionEndLine() { + return this.layoutExclusions.reduce((max, exclusion) => Math.max(max, exclusion.endLine || 0), this.layoutFlowLine || 0); + } + + getActiveExclusions(line) { + return this.layoutExclusions.filter(exclusion => line >= exclusion.startLine && line < exclusion.endLine); + } + + buildLineGeometry(metadata = {}, contentStartLine = 0) { + const pageWidth = this.container?.clientWidth || this.paragraphContainer?.clientWidth || 600; + const lineHeight = this.measureStoryLineHeight(); + const isHeading = metadata.type === 'heading' || metadata.role === 'chapter-heading' || metadata.role === 'section-heading'; + const dropCapLines = metadata.dropCap ? 2 : 0; + const dropCapWidth = metadata.dropCap + ? (Number.isFinite(Number(metadata.dropCapWidth)) && Number(metadata.dropCapWidth) > 0 + ? Number(metadata.dropCapWidth) + : lineHeight * 1.34) + : 0; + const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5; + const maxConsideredLines = Math.max(80, this.pageLineCount * 4); + const measures = []; + const lineOffsets = []; + + for (let index = 0; index < maxConsideredLines; index += 1) { + const line = contentStartLine + index; + const active = isHeading ? [] : this.getActiveExclusions(line); + const leftExclusion = active.filter(exclusion => exclusion.side !== 'right') + .reduce((sum, exclusion) => sum + Number(exclusion.width || 0), 0); + const rightExclusion = active.filter(exclusion => exclusion.side === 'right') + .reduce((sum, exclusion) => sum + Number(exclusion.width || 0), 0); + const available = Math.max(120, pageWidth - leftExclusion - rightExclusion); + const firstLineInset = metadata.dropCap && index < dropCapLines + ? dropCapWidth + : index === 0 + ? indentWidth + : 0; + measures.push(Math.max(120, available - firstLineInset)); + lineOffsets.push(isHeading ? 0 : leftExclusion + firstLineInset); + } + + return { + pageWidth, + measures, + lineOffsets, + imageWrap: this.layoutExclusions.length > 0 ? [...this.layoutExclusions] : null + }; + } + + makeRenderedWordsVisible(element) { + element?.querySelectorAll?.('.word')?.forEach(word => { + word.style.transition = 'none'; + word.style.animation = 'none'; + word.style.visibility = 'visible'; + word.style.opacity = '1'; + word.style.transform = 'translateY(0)'; + word.style.clipPath = 'none'; + }); + } + + markBlockRendered(blockId) { + if (this.storyHistory && typeof this.storyHistory.markRendered === 'function') { + const latestRenderedBlockId = this.storyHistory.markRendered(blockId); + document.dispatchEvent(new CustomEvent('story:history-updated', { + detail: { + gameId: this.storyHistory.currentGameId || null, + latestBlockId: this.getLatestHistoryBlockId(), + latestRenderedBlockId + } + })); + } + } + + getLatestHistoryBlockId() { + return Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0)); + } + + updateStoryScrollbar(detail = {}) { + const track = document.getElementById('story_scrollbar'); + const thumb = document.getElementById('story_scrollbar_thumb'); + if (!thumb) return; + this.measureStoryLineHeight(); + const totalLines = Math.max(1, Number(detail.renderedLineCount || this.storyHistory?.renderedLineCount || 0)); + const viewportLines = Math.max(1, this.viewportLineCount || 1); + const visibleLines = Math.min(viewportLines, totalLines); + const maxTopLine = Math.max(0, totalLines - visibleLines); + const previewTop = this.scrollbarPreviewLine == null + ? null + : this.getTopLineForActiveLine(this.scrollbarPreviewLine); + const currentTop = Math.max(0, Math.min(maxTopLine, previewTop ?? (this.storyTopLine || 0))); + const heightPercent = Math.max(8, Math.min(100, (visibleLines / totalLines) * 100)); + const topPercent = maxTopLine <= 0 ? 0 : (currentTop / maxTopLine) * (100 - heightPercent); + if (track) { + track.dataset.totalLines = String(totalLines); + track.dataset.viewportLines = String(viewportLines); + track.dataset.topLine = String(currentTop); + track.hidden = totalLines <= viewportLines; + } + thumb.style.height = `${heightPercent}%`; + thumb.style.top = `${topPercent}%`; + } + + handleStoryScrollbarPointer(event) { + event.preventDefault(); + event.stopPropagation(); + this.handleManualScrollStart('scrollbar'); + const track = event.currentTarget; + if (!track) return; + const thumb = document.getElementById('story_scrollbar_thumb'); + const pointerId = event.pointerId; + if (typeof track.setPointerCapture === 'function' && pointerId != null) { + try { + track.setPointerCapture(pointerId); + } catch (error) { + console.warn('UIDisplayHandler: Story scrollbar pointer capture unavailable', error); + } + } + this.draggingStoryScrollbar = true; + track.dataset.dragging = 'true'; + const trackRect = track.getBoundingClientRect(); + const thumbRect = thumb?.getBoundingClientRect?.(); + const grabOffset = thumb && thumb.contains(event.target) + ? Math.max(0, event.clientY - (thumbRect?.top || trackRect.top)) + : Math.max(0, (thumbRect?.height || 0) / 2); + const previewToPointer = (pointerEvent) => { + const rect = track.getBoundingClientRect(); + const thumbHeight = thumb?.getBoundingClientRect?.().height || 0; + const travel = Math.max(1, rect.height - thumbHeight); + const thumbTop = Math.max(0, Math.min(travel, pointerEvent.clientY - rect.top - grabOffset)); + const ratio = Math.max(0, Math.min(1, thumbTop / travel)); + const maxTopLine = this.getMaxStoryTopLine(); + const targetTopLine = Math.round(maxTopLine * ratio); + this.scrollbarPreviewLine = this.getActiveLineForTopLine(targetTopLine); + if (thumb) { + thumb.style.transition = 'none'; + const totalLines = Math.max(1, Number(this.storyHistory?.renderedLineCount || 0)); + const viewportLines = Math.max(1, this.viewportLineCount || 1); + const heightPercent = Math.max(8, Math.min(100, (Math.min(viewportLines, totalLines) / totalLines) * 100)); + thumb.style.height = `${heightPercent}%`; + thumb.style.top = `${ratio * (100 - heightPercent)}%`; + } + }; + + previewToPointer(event); + const onMove = (moveEvent) => previewToPointer(moveEvent); + const cleanup = () => { + this.storyScrollbarReleaseHandler = null; + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onRelease); + document.removeEventListener('pointercancel', onRelease); + document.removeEventListener('mouseup', onRelease); + window.removeEventListener('blur', onRelease); + if (typeof track.releasePointerCapture === 'function' && pointerId != null) { + try { + track.releasePointerCapture(pointerId); + } catch (error) { + // The browser may already have released capture; cleanup can continue. + } + } + this.draggingStoryScrollbar = false; + delete track.dataset.dragging; + if (thumb) { + thumb.style.transition = ''; + } + }; + const onRelease = async (releaseEvent) => { + releaseEvent?.preventDefault?.(); + releaseEvent?.stopPropagation?.(); + cleanup(); + const targetLine = this.scrollbarPreviewLine; + this.scrollbarPreviewLine = null; + if (Number.isFinite(Number(targetLine))) { + await this.scrollTo(targetLine, { mode: 'scrollbar-release' }); + } else { + this.updateStoryScrollbar(); + } + }; + this.storyScrollbarReleaseHandler = onRelease; + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onRelease); + document.addEventListener('pointercancel', onRelease); + document.addEventListener('mouseup', onRelease); + window.addEventListener('blur', onRelease); + } + + disableAutoplayForManualScroll() { + if (!this.persistenceManager || typeof this.persistenceManager.updatePreference !== 'function') { + console.error('UIDisplayHandler: Cannot disable autoplay; persistence-manager dependency is unavailable.'); + return; + } + this.persistenceManager.updatePreference('app', 'autoplay', false); + document.dispatchEvent(new CustomEvent('app:autoplay:change', { + detail: { enabled: false, autoplay: false, source: 'manual-story-scroll' } + })); + } + + measureStoryLineHeight() { + const pageHeight = this.pageRight?.clientHeight || 0; + const lineHeight = pageHeight > 0 + ? pageHeight / this.pageLineCount + : this.lineHeightPx || 24; + this.lineHeightPx = lineHeight; + this.viewportLineCount = this.pageLineCount; + document.documentElement.style.setProperty('--page-line-count', String(this.pageLineCount)); + document.documentElement.style.setProperty('--story-line-height', `${lineHeight}px`); + document.documentElement.style.setProperty('--story-font-size', `${lineHeight / 1.45}px`); + return this.lineHeightPx; + } + + measureBlockLines(element, fallbackLineCount = 1) { + const lineHeight = this.measureStoryLineHeight(); + const declaredLines = Number(element?.dataset?.heightLines); + if (Number.isFinite(declaredLines) && declaredLines >= 0) { + const lineCount = Math.max(0, Math.round(declaredLines)); + return { lineCount, heightPx: lineCount * lineHeight, lineHeightPx: lineHeight }; + } + throw new Error(`UIDisplayHandler: Rendered story block ${element?.id || '(unknown)'} has no data-height-lines declaration.`); + } + + async recordRenderedMetrics(blockId, element, fallbackLineCount = 1, lineStart = null) { + if (!this.storyHistory || typeof this.storyHistory.updateBlockMetrics !== 'function' || blockId == null) return null; + const metrics = this.measureBlockLines(element, fallbackLineCount); + if (Number.isFinite(Number(lineStart))) { + metrics.lineStart = Math.max(0, Number(lineStart)); + } + const updated = await this.storyHistory.updateBlockMetrics(blockId, metrics); + if (updated && element) { + element.dataset.lineStart = String(updated.lineStart || 0); + element.dataset.lineCount = String(updated.lineCount || metrics.lineCount); + } + this.setVirtualPadding(); + this.updateStoryScrollbar(); + return updated; + } + + setVirtualPadding() { + if (!this.paragraphContainer) return; + const renderedEndLine = this.renderedItems.reduce((max, item) => { + const start = Number(item?.lineStart ?? item?.metadata?.lineStart); + const count = Math.max(0, Number(item?.lineCount ?? item?.metadata?.lineCount ?? 0)); + return Number.isFinite(start) ? Math.max(max, start + count) : max; + }, this.windowOriginLine || 0); + const totalLines = Math.max(0, renderedEndLine - Math.max(0, this.windowOriginLine || 0)); + const lineHeight = this.measureStoryLineHeight(); + this.paragraphContainer.style.paddingTop = '0'; + this.paragraphContainer.style.paddingBottom = '0'; + this.paragraphContainer.style.height = `${totalLines * lineHeight}px`; + } + + setStoryOffset(offsetPx) { + this.storyOffsetPx = Number(offsetPx) || 0; + if (this.container) { + this.container.style.transform = `translateY(${this.storyOffsetPx}px)`; + } + this.handleStoryScroll(); + this.updateStoryScrollbar(); + } + + getMaxStoryTopLine() { + this.measureStoryLineHeight(); + const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0)); + return Math.max(0, totalLines - Math.max(1, this.viewportLineCount || 1)); + } + + getActiveLineForTopLine(topLine = this.storyTopLine) { + const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0)); + if (totalLines <= 0) return 0; + const requested = Math.round(Number(topLine || 0)) + Math.max(1, this.viewportLineCount || 1) - 1; + return Math.max(0, Math.min(totalLines - 1, requested)); + } + + getTopLineForActiveLine(activeLine = 0) { + const maxTopLine = this.getMaxStoryTopLine(); + const requested = Math.round(Number(activeLine || 0)) - Math.max(1, this.viewportLineCount || 1) + 1; + return Math.max(0, Math.min(maxTopLine, requested)); + } + + getRenderedBlockForLine(line = 0) { + const target = Math.max(0, Number(line || 0)); + return this.renderedItems.find(item => { + const start = Number(item.lineStart ?? item.metadata?.lineStart); + const count = Math.max(0, Number(item.lineCount ?? item.metadata?.lineCount ?? 0)); + return Number.isFinite(start) && count > 0 && target >= start && target < start + count; + }) || null; + } + + getCurrentScrollLine() { + if (Number.isFinite(Number(this.scrollTargetLine))) { + return Math.max(0, Number(this.scrollTargetLine)); + } + return this.getActiveLineForTopLine(this.storyTopLine); + } + + getLiveEndLine() { + return Math.max(0, Number(this.storyHistory?.renderedLineCount || 0) - 1); + } + + scrollUp(numberOfLines = 1, options = {}) { + const lines = Math.max(0, Math.round(Number(numberOfLines || 0))); + return this.scrollTo(this.getCurrentScrollLine() - lines, { ...options, direction: -1 }); + } + + scrollDown(numberOfLines = 1, options = {}) { + const lines = Math.max(0, Math.round(Number(numberOfLines || 0))); + return this.scrollTo(this.getCurrentScrollLine() + lines, { ...options, direction: 1 }); + } + + async scrollTo(lineNumber = 0, options = {}) { + this.measureStoryLineHeight(); + const totalLines = Math.max(0, Number(this.storyHistory?.renderedLineCount || 0)); + const targetLine = Math.max(0, Math.min(Math.max(0, totalLines - 1), Math.round(Number(lineNumber || 0)))); + const previousLine = this.getCurrentScrollLine(); + const requestId = ++this.scrollRequestId; + this.scrollTargetLine = targetLine; + + if (this.storyHistory && totalLines > 0) { + await this.ensureScrollRangeForTarget(previousLine, targetLine, { ...options, requestId }); + if (requestId !== this.scrollRequestId) return; + } + + const targetTopLine = this.getTopLineForActiveLine(targetLine); + await this.animateToTopLine(targetTopLine, options.smooth !== false, options); + this.scrollTargetLine = this.getActiveLineForTopLine(this.storyTopLine); + } + + async ensureScrollRangeForTarget(previousLine = 0, targetLine = 0, options = {}) { + if (!this.storyHistory || !this.paragraphContainer) return; + const latest = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0)); + if (latest <= 0) return; + + const bounds = await this.getWindowBoundsForTraversal(previousLine, targetLine, latest); + if (!bounds) return; + if (options.mode === 'append-live' && this.getRenderedBlockForLine(targetLine)) { + this.activeCenterBlockId = bounds.targetBlockId; + this.updateStoryScrollbar(); + return; + } + + const currentWindowCoversBounds = bounds.start >= this.historyWindowStartId && bounds.end <= this.historyWindowEndId; + const exactWindowAlreadyLoaded = bounds.start === this.historyWindowStartId && bounds.end === this.historyWindowEndId; + + if (bounds.teleport) { + this.paragraphContainer.classList.add('story-history-fading'); + await new Promise(resolve => setTimeout(resolve, 220)); + await this.renderWindowForBounds(bounds, options.requestId); + this.paragraphContainer.classList.remove('story-history-fading'); + } else if (!currentWindowCoversBounds || !exactWindowAlreadyLoaded) { + await this.renderIncrementalWindow(bounds, options.requestId); + } + + this.activeCenterBlockId = bounds.targetBlockId; + this.updateStoryScrollbar(); + } + + animateToTopLine(targetLine, smooth = true, options = {}) { + this.measureStoryLineHeight(); + const maxTopLine = this.getMaxStoryTopLine(); + const target = Math.round(Math.max(0, Math.min(maxTopLine, Number(targetLine || 0)))); + if (!smooth) { + if (this.scrollAnimationFrameId != null) { + cancelAnimationFrame(this.scrollAnimationFrameId); + this.scrollAnimationFrameId = null; + } + if (this.scrollAnimationResolve) { + this.scrollAnimationResolve(); + this.scrollAnimationResolve = null; + this.scrollAnimationPromise = null; + } + this.storyTopLine = target; + this.setStoryOffset(-((target - (this.windowOriginLine || 0)) * this.lineHeightPx)); + return Promise.resolve(); + } + + const now = performance.now(); + const distance = Math.abs(target - (this.storyTopLine || 0)); + this.storyScrollAnimation = { + startTopLine: this.storyTopLine || 0, + targetTopLine: target, + startedAt: now, + duration: Math.max(180, Math.min(700, 160 + (distance * 35))) + }; + + if (!this.scrollAnimationPromise) { + this.scrollAnimationPromise = new Promise(resolve => { + this.scrollAnimationResolve = resolve; + }); + } + + if (this.scrollAnimationFrameId == null) { + const step = (now) => { + const animation = this.storyScrollAnimation; + if (!animation) { + this.scrollAnimationFrameId = null; + const resolve = this.scrollAnimationResolve; + this.scrollAnimationResolve = null; + this.scrollAnimationPromise = null; + resolve?.(); + return; + } + + const progress = Math.min(1, Math.max(0, (now - animation.startedAt) / Math.max(1, animation.duration))); + const eased = progress < 0.5 + ? 4 * progress * progress * progress + : 1 - Math.pow(-2 * progress + 2, 3) / 2; + this.storyTopLine = animation.startTopLine + ((animation.targetTopLine - animation.startTopLine) * eased); + this.setStoryOffset(-((this.storyTopLine - (this.windowOriginLine || 0)) * this.lineHeightPx)); + if (progress >= 1 || Math.abs(animation.targetTopLine - this.storyTopLine) < 0.02) { + this.storyTopLine = animation.targetTopLine; + this.setStoryOffset(-((this.storyTopLine - (this.windowOriginLine || 0)) * this.lineHeightPx)); + this.storyScrollAnimation = null; + this.scrollAnimationFrameId = null; + const resolve = this.scrollAnimationResolve; + this.scrollAnimationResolve = null; + this.scrollAnimationPromise = null; + resolve?.(); + return; + } + this.scrollAnimationFrameId = requestAnimationFrame(step); + }; + this.scrollAnimationFrameId = requestAnimationFrame(step); + } + + return this.scrollAnimationPromise; + } + + async ensureLiveTailWindow() { + if (!this.storyHistory || !this.paragraphContainer) return; + const generation = this.displayGeneration; + const gameId = this.storyHistory.currentGameId; + const latestRendered = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0)); + if (latestRendered > 0) { + const start = Math.max(1, latestRendered - (this.visibleBlockLimit - 2)); + const end = latestRendered; + if (this.historyWindowStartId !== start || this.historyWindowEndId !== end) { + const liveEndLine = Math.max(0, Number(this.storyHistory.renderedLineCount || 0) - 1); + await this.renderWindowForBounds({ + start, + end, + targetBlockId: latestRendered, + windowOriginLine: this.getTopLineForActiveLine(liveEndLine), + gameId, + generation + }); + if (!this.isDisplayGenerationCurrent(generation, gameId)) return; + } + } else if (this.renderedItems.length) { + this.paragraphContainer.innerHTML = ''; + this.renderedItems = []; + this.historyWindowStartId = 1; + this.historyWindowEndId = 0; + this.windowOriginLine = 0; + } + this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems); + this.activeCenterBlockId = latestRendered || null; + } + + async getWindowBoundsForTraversal(previousLine = 0, targetLine = 0, latest = null) { + const latestRendered = Math.max(0, Number(latest ?? this.storyHistory?.latestRenderedBlockId ?? 0)); + if (!this.storyHistory || latestRendered <= 0) return null; + const targetBlock = this.getRenderedBlockForLine(targetLine) || await this.storyHistory.findBlockForLine( + this.storyHistory.currentGameId, + targetLine, + latestRendered + ); + if (!targetBlock?.blockId) return null; + + const previousBlock = this.getRenderedBlockForLine(previousLine) || await this.storyHistory.findBlockForLine( + this.storyHistory.currentGameId, + previousLine, + latestRendered + ) || targetBlock; + + const targetBlockId = Math.max(1, Number(targetBlock.blockId)); + const previousBlockId = Math.max(1, Number(previousBlock.blockId || targetBlockId)); + const rangeStart = Math.min(previousBlockId, targetBlockId); + const rangeEnd = Math.max(previousBlockId, targetBlockId); + const expandedStart = Math.max(1, rangeStart - this.historyBufferBlocks); + const expandedEnd = Math.min(latestRendered, rangeEnd + this.historyBufferBlocks); + const expandedCount = expandedEnd - expandedStart + 1; + + if (expandedCount > this.maxTraversalBlocks) { + return { + start: Math.max(1, targetBlockId - this.historyBufferBlocks), + end: Math.min(latestRendered, targetBlockId + this.historyBufferBlocks), + targetBlockId, + windowOriginLine: this.getTopLineForActiveLine(targetLine), + teleport: true + }; + } + + return { + start: expandedStart, + end: expandedEnd, + targetBlockId, + windowOriginLine: Math.min( + this.getTopLineForActiveLine(previousLine), + this.getTopLineForActiveLine(targetLine) + ), + teleport: false + }; + } + + async renderWindowForBounds(bounds = {}, requestId = null) { + if (!this.storyHistory) return; + const generation = bounds.generation ?? this.displayGeneration; + const gameId = bounds.gameId || this.storyHistory.currentGameId; + if (!this.isDisplayGenerationCurrent(generation, gameId)) return; + const start = Math.max(1, Number(bounds.start || 1)); + const end = Math.max(start, Number(bounds.end || start)); + const blocks = await this.storyHistory.getBlocksRange(gameId, start, end); + if (requestId != null && requestId !== this.scrollRequestId) return; + if (!this.isDisplayGenerationCurrent(generation, gameId)) return; + await this.renderHistoryWindow(blocks, { + windowOriginLine: bounds.windowOriginLine, + gameId, + generation + }); + } + + focusTurn(turnId) { + if (!this.pageRight || turnId == null) return; + const escapedTurnId = CSS.escape(String(turnId)); + const scrollToLiveTarget = () => { + const target = this.paragraphContainer?.querySelector(`[data-turn-id="${escapedTurnId}"]`); + if (!target) return false; + const targetLine = Number(target.dataset.lineStart); + if (Number.isFinite(targetLine)) { + this.scrollTo(targetLine, { mode: 'jump-to-turn' }); + } + return true; + }; + + if (scrollToLiveTarget()) return; + this.storyHistory?.getFirstBlockForTurn?.(this.storyHistory.currentGameId, turnId).then((block) => { + const targetLine = Number(block?.lineStart); + if (Number.isFinite(targetLine)) { + this.scrollTo(targetLine, { mode: 'jump-to-turn' }); + } + }); + } + + handleStoryScroll() { + if (!this.pageRight || !this.paragraphContainer) return; + + const blocks = Array.from(this.paragraphContainer.querySelectorAll('[data-turn-id]')); + if (blocks.length === 0) return; + + const viewportMiddle = (this.storyTopLine * this.measureStoryLineHeight()) + (this.pageRight.clientHeight / 2); + let best = null; + let bestDistance = Infinity; + + blocks.forEach((block) => { + const lineStart = Number(block.dataset.lineStart); + const lineCount = Number(block.dataset.lineCount); + if (!Number.isFinite(lineStart) || !Number.isFinite(lineCount)) { + return; + } + const blockMiddle = (lineStart + (lineCount / 2)) * this.lineHeightPx; + const distance = Math.abs(blockMiddle - viewportMiddle); + if (distance < bestDistance) { + bestDistance = distance; + best = block; + } + }); + + if (best?.dataset?.turnId && this.activeTurnId !== best.dataset.turnId) { + this.activeTurnId = best.dataset.turnId; + document.dispatchEvent(new CustomEvent('story:visible-turn', { + detail: { turnId: Number(best.dataset.turnId) } + })); + } + } + + readFirstFiniteNumber(...values) { + for (const value of values) { + const number = Number(value); + if (Number.isFinite(number)) { + return Math.max(0, number); + } + } + + return 0; + } + + waitForSkippablePause(seconds, kind = 'media') { + const duration = Math.max(0, Number(seconds) || 0) * 1000; + if (duration <= 0) return Promise.resolve(false); + + document.documentElement.dataset.skippablePause = 'true'; + document.dispatchEvent(new CustomEvent('story:process-state', { + detail: { state: 'playing-ready', reason: `${kind}-pause-start`, duration } + })); + + return new Promise(resolve => { + let finished = false; + let timeoutId = null; + + const finish = (skipped) => { + if (finished) return; + finished = true; + clearTimeout(timeoutId); + document.removeEventListener('ui:command', onCommand); + delete document.documentElement.dataset.skippablePause; + document.dispatchEvent(new CustomEvent('story:process-state', { + detail: { state: 'playing-ready', reason: `${kind}-pause-${skipped ? 'skipped' : 'complete'}` } + })); + resolve(skipped); + }; + + const onCommand = (event) => { + if (event.detail?.type === 'continue') { + finish(true); + } + }; + + document.addEventListener('ui:command', onCommand); + timeoutId = setTimeout(() => finish(false), duration); + }); + } + + renderImageBlock(renderableOrMetadata = {}, animate = true, placement = 'append', targetContainer = this.paragraphContainer) { + if (!this.paragraphContainer) return null; + + const metadata = renderableOrMetadata.metadata || renderableOrMetadata; + const metrics = metadata.imageLayout || this.calculateImageMetrics(metadata.size); + const lineStart = Math.max(0, Number(renderableOrMetadata.lineStart ?? metadata.lineStart ?? metrics.lineStart ?? 0)); + const lineCount = Math.max(1, Number(renderableOrMetadata.lineCount ?? metadata.lineCount ?? metrics.lineCount ?? 1)); + const windowOriginLine = Math.max(0, Number(renderableOrMetadata.windowOriginLine ?? metadata.windowOriginLine ?? this.windowOriginLine ?? 0)); + const lineHeight = this.measureStoryLineHeight(); + const pageWidth = this.container?.clientWidth || this.paragraphContainer?.clientWidth || metrics.pageWidth || 600; + const figure = document.createElement('figure'); + if (metadata.id) { + figure.id = metadata.id; + } + figure.className = [ + 'story-image-block', + `story-image-${metrics.size || 'landscape'}`, + animate ? 'story-image-pending' : 'story-image-visible' + ].filter(Boolean).join(' '); + figure.style.position = 'absolute'; + figure.style.top = `${(lineStart - windowOriginLine) * lineHeight}px`; + figure.style.height = `${lineCount * lineHeight}px`; + figure.style.width = `${metrics.width}px`; + figure.style.margin = '0'; + figure.style.padding = '0'; + const side = metrics.floatSide || metadata.floatSide || 'right'; + if ((metrics.size || metadata.size) === 'portrait' && side === 'right') { + figure.style.left = `${Math.max(0, pageWidth - metrics.width)}px`; + } else if ((metrics.size || metadata.size) === 'portrait') { + figure.style.left = '0px'; + } else { + figure.style.left = `${Math.max(0, (pageWidth - metrics.width) / 2)}px`; + } + figure.dataset.heightLines = String(lineCount); + figure.dataset.lineStart = String(lineStart); + figure.dataset.lineCount = String(lineCount); + figure.dataset.animationMs = '2000'; + if (metadata.turnId != null) { + figure.dataset.turnId = String(metadata.turnId); + figure.classList.add('story-turn-block'); + } + + const img = document.createElement('img'); + img.src = this.resolveImageUrl(metadata); + img.alt = metadata.alt || ''; + img.decoding = 'async'; + img.loading = 'eager'; + img.style.position = 'absolute'; + img.style.left = '0'; + img.style.top = `${metrics.verticalMargin || 0}px`; + img.style.width = `${metrics.width}px`; + img.style.height = `${metrics.height}px`; + figure.appendChild(img); + + this.insertStoredElement(figure, placement, targetContainer); + + if (animate && metadata.revealImmediately !== false) { + window.requestAnimationFrame(() => this.revealImageBlock(figure)); + } else { + if (!animate) { + figure.classList.remove('story-image-pending'); + figure.classList.add('story-image-visible'); + } + } + + return figure; + } + + revealImageBlock(figure) { + if (!figure) return; + figure.classList.remove('story-image-pending'); + figure.classList.add('story-image-visible'); + } + + resolveImageUrl(metadata = {}) { + const explicit = String(metadata.url || '').trim(); + if (explicit) return explicit; + + const filename = String(metadata.filename || '').trim(); + if (!filename) return ''; + if (/^(https?:|data:|blob:|\/)/i.test(filename)) return filename; + return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`; + } + + calculateImageMetrics(metadataOrSize = 'landscape') { + const storyElement = document.getElementById('story'); + const pageWidth = storyElement?.clientWidth || 600; + const lineHeight = this.measureStoryLineHeight(); + + const metadata = typeof metadataOrSize === 'object' && metadataOrSize !== null ? metadataOrSize : { size: metadataOrSize }; + const normalizedSize = String(metadata.size || 'landscape').toLowerCase() === 'widescreen' + ? 'landscape' + : String(metadata.size || 'landscape').toLowerCase(); + const aspect = normalizedSize === 'portrait' ? (9 / 16) : normalizedSize === 'square' ? 1 : (16 / 9); + const isPortrait = normalizedSize === 'portrait'; + const imageGap = lineHeight; + const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth; + const maxImageWidth = isPortrait + ? Math.max(lineHeight * 4, maxOuterWidth - imageGap) + : maxOuterWidth; + const naturalHeight = maxImageWidth / aspect; + const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight)); + const verticalMargin = lineHeight / 2; + const lineCount = imageLineCount + 1; + const height = Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2)); + const width = Math.min(maxImageWidth, height * aspect); + + return { + size: normalizedSize, + aspect, + width, + height, + gap: imageGap, + lineCount, + imageLineCount, + lineHeight, + verticalMargin, + floatSide: metadata.floatSide || 'right', + pageWidth + }; + } + + clear() { + this.displayGeneration += 1; + this.renderWindowToken += 1; + this.scrollRequestId += 1; + if (this.scrollAnimationFrameId != null) { + cancelAnimationFrame(this.scrollAnimationFrameId); + this.scrollAnimationFrameId = null; + } + if (this.scrollAnimationResolve) { + this.scrollAnimationResolve(); + this.scrollAnimationResolve = null; + this.scrollAnimationPromise = null; + } + this.storyScrollAnimation = null; + + if (document.documentElement.dataset.skippablePause === 'true') { + document.dispatchEvent(new CustomEvent('ui:command', { + detail: { moduleId: this.id, type: 'continue', source: 'display-clear' } + })); + delete document.documentElement.dataset.skippablePause; + } + + if (this.container) { + this.container.innerHTML = ''; + this.paragraphContainer = document.createElement('div'); + this.paragraphContainer.id = 'paragraphs'; + this.container.appendChild(this.paragraphContainer); + } + this.renderedItems = []; + this.notificationQueue = []; + this.pendingTerminalNotifications = []; + this.notificationActive = false; + document.getElementById('story_popup_modal')?.classList.remove('visible'); + document.getElementById('story_popup_modal')?.setAttribute('aria-hidden', 'true'); + this.historyWindowStartId = 1; + this.historyWindowEndId = 0; + this.storyTopLine = 0; + this.scrollTargetLine = null; + this.wheelLineAccumulator = 0; + this.draggingStoryScrollbar = false; + this.scrollbarPreviewLine = null; + this.activeCenterBlockId = null; + this.layoutFlowLine = 0; + this.layoutExclusions = []; + this.setVirtualPadding(); + this.setStoryOffset(0); + this.updateStoryScrollbar({ latestBlockId: this.getLatestHistoryBlockId() }); + } + + /** + * Show choices in the UI + * @param {Array} choices - Array of choice objects + * @param {Function} callback - Callback function for choice selection + * @returns {Promise} - Promise resolving to the choices container + */ + showChoices(choices, callback) { + if (!choices || choices.length === 0) { + return Promise.resolve(null); + } + + return new Promise((resolve) => { + // Find or create choices container + let choicesContainer = document.getElementById('choices'); + if (!choicesContainer) { + // UI Input Handler should create this, but if it doesn't exist yet, create it + choicesContainer = document.createElement('div'); + choicesContainer.id = 'choices'; + choicesContainer.className = 'container'; + this.pageLeft.appendChild(choicesContainer); + } + + // Create a dedicated container for this set of choices + const choicesGroup = document.createElement('div'); + choicesGroup.className = 'choices-group'; + choicesContainer.appendChild(choicesGroup); + + // Create each choice button + choices.forEach((choice, index) => { + const choiceButton = document.createElement('button'); + choiceButton.className = 'choice-button'; + choiceButton.textContent = choice.text; + + // Add index as data attribute + choiceButton.dataset.index = index; + + // Add event listener + choiceButton.addEventListener('click', () => { + // Disable all buttons in this group + Array.from(choicesGroup.querySelectorAll('button')).forEach(btn => { + btn.disabled = true; + btn.classList.add('selected'); + }); + + // Highlight the selected button + choiceButton.classList.add('selected'); + + // Call the callback + if (typeof callback === 'function') { + callback(index, choice); + } + }); + + choicesGroup.appendChild(choiceButton); + }); + + window.requestAnimationFrame(() => { + choicesGroup.classList.add('visible'); + resolve(choicesGroup); + }); + }); + } +} + +// Create the singleton instance +const uiDisplayHandler = new UIDisplayHandlerModule(); + +// Export the module +export { uiDisplayHandler as UIDisplayHandler }; + +// Register with the module registry +if (window.moduleRegistry) { + window.moduleRegistry.register(uiDisplayHandler); +} + +// Keep a reference in window for loader system +window.UIDisplayHandler = uiDisplayHandler; diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js new file mode 100644 index 0000000..8176518 --- /dev/null +++ b/public/js/webgl-book-lab.js @@ -0,0 +1,1174 @@ +import * as THREE from 'https://esm.sh/three@0.165.0'; + +const canvas = document.getElementById('scene'); +canvas.style.cursor = 'grab'; +const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); +renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); +renderer.outputColorSpace = THREE.SRGBColorSpace; +renderer.toneMapping = THREE.ACESFilmicToneMapping; +renderer.toneMappingExposure = 1.12; +renderer.shadowMap.enabled = true; +renderer.shadowMap.type = THREE.VSMShadowMap; + +const generatedTextureCanvases = {}; + +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0x080604); +scene.fog = new THREE.FogExp2(0x080604, 0.035); +let candleBounceLight = null; +let tableMesh = null; +let tableShader = null; +let tableRoomReflectionTexture = createRoomReflectionTexture(); +let tableDustTexture = null; +const tableTopY = -0.02; +const tableReflectionTarget = new THREE.WebGLRenderTarget(1024, 576, { + colorSpace: THREE.SRGBColorSpace, + depthBuffer: true, + stencilBuffer: false +}); +tableReflectionTarget.texture.colorSpace = THREE.SRGBColorSpace; +tableReflectionTarget.texture.minFilter = THREE.LinearFilter; +tableReflectionTarget.texture.magFilter = THREE.LinearFilter; +const tableReflectionCamera = new THREE.PerspectiveCamera(); +const tableReflectionMatrix = new THREE.Matrix4(); +const tableReflectionBiasMatrix = new THREE.Matrix4().set( + 0.5, 0, 0, 0.5, + 0, 0.5, 0, 0.5, + 0, 0, 0.5, 0.5, + 0, 0, 0, 1 +); +const reflectionForward = new THREE.Vector3(); +const reflectionTarget = new THREE.Vector3(); +const reflectionUp = new THREE.Vector3(); +const candleShadowSources = []; +const candleWorldPosition = new THREE.Vector3(); +const flameWorldPosition = new THREE.Vector3(); + +const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 80); +const cameraRig = { + target: new THREE.Vector3(0, 0.16, -0.04), + yaw: 0, + pitch: 1.06, + radius: 5.54, + minPitch: 0.28, + maxPitch: 1.34, + minRadius: 2.4, + maxRadius: 9.0, + dragging: false, + pointerX: 0, + pointerY: 0, + keys: new Set() +}; +updateCameraRig(0); + +const clock = new THREE.Clock(); +const book = new THREE.Group(); +scene.add(book); + +const paperColor = new THREE.Color(0xf3dfad); +const inkColor = '#1a1009'; + +const leftCanvas = createPageCanvas('left'); +const rightCanvas = createPageCanvas('right'); +const leftTexture = new THREE.CanvasTexture(leftCanvas); +const rightTexture = new THREE.CanvasTexture(rightCanvas); +[leftTexture, rightTexture].forEach((texture) => { + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = renderer.capabilities.getMaxAnisotropy(); + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.generateMipmaps = true; +}); + +const materials = { + leather: new THREE.MeshStandardMaterial({ + color: 0x25130b, + roughness: 0.58, + metalness: 0.02, + envMapIntensity: 0.18 + }), + coverEdge: new THREE.MeshStandardMaterial({ + color: 0x6f4b25, + roughness: 0.52, + metalness: 0.04, + envMapIntensity: 0.22 + }), + pageBlock: new THREE.MeshStandardMaterial({ + color: 0xe3c98f, + roughness: 0.82, + metalness: 0, + envMapIntensity: 0.08 + }), + pageEdge: new THREE.MeshStandardMaterial({ + color: 0xc69f64, + roughness: 0.92, + metalness: 0, + envMapIntensity: 0.08 + }), + leftPage: new THREE.MeshStandardMaterial({ + color: 0xffffff, + map: leftTexture, + roughness: 0.74, + metalness: 0, + emissive: 0x2d1e12, + emissiveIntensity: 0.18, + side: THREE.DoubleSide + }), + rightPage: new THREE.MeshStandardMaterial({ + color: 0xffffff, + map: rightTexture, + roughness: 0.74, + metalness: 0, + emissive: 0x2d1e12, + emissiveIntensity: 0.18, + side: THREE.DoubleSide + }) +}; + +buildTable(); +buildLighting(); +buildBook(); +loadAiRoomReflection(); +window.BookLabDebug = { + textures: generatedTextureCanvases, + exportTexture(name) { + return generatedTextureCanvases[name]?.toDataURL('image/png') || null; + } +}; + +window.addEventListener('resize', resize); +installCameraControls(); +resize(); +animate(); + +function buildTable() { + const tableTexture = new THREE.TextureLoader().load('/assets/webgl/wood_table_diff_1k.jpg'); + tableTexture.colorSpace = THREE.SRGBColorSpace; + tableTexture.wrapS = THREE.RepeatWrapping; + tableTexture.wrapT = THREE.RepeatWrapping; + tableTexture.repeat.set(2.15, 1.45); + tableTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); + const tableNormal = createTableNormalTexture(); + tableNormal.wrapS = THREE.RepeatWrapping; + tableNormal.wrapT = THREE.RepeatWrapping; + tableNormal.repeat.set(2.15, 1.45); + tableNormal.anisotropy = renderer.capabilities.getMaxAnisotropy(); + tableDustTexture = createTableDustTexture(); + tableDustTexture.wrapS = THREE.ClampToEdgeWrapping; + tableDustTexture.wrapT = THREE.ClampToEdgeWrapping; + tableDustTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); + + const tableMaterial = new THREE.MeshPhysicalMaterial({ + color: 0x8a4c22, + map: tableTexture, + normalMap: tableNormal, + normalScale: new THREE.Vector2(0.07, 0.07), + roughness: 0.31, + metalness: 0, + clearcoat: 0.54, + clearcoatRoughness: 0.42, + reflectivity: 0.34, + envMapIntensity: 0 + }); + configureTableShader(tableMaterial); + tableMesh = new THREE.Mesh(new THREE.BoxGeometry(9.8, 0.28, 6.6, 1, 1, 1), tableMaterial); + tableMesh.position.y = -0.16; + tableMesh.receiveShadow = true; + scene.add(tableMesh); +} + +function buildLighting() { + scene.add(new THREE.AmbientLight(0x120b06, 0.22)); + + candleBounceLight = new THREE.HemisphereLight(0x4a2a14, 0x080403, 0.3); + scene.add(candleBounceLight); + + addCandle(-2.38, 0.0, -0.55, 2.35, 0.62); + addCandle(2.2, 0.0, -1.34, 1.85, 0.38); + addCandle(2.36, 0.0, 0.62, 1.5, 0.48); +} + +function addCandle(x, y, z, intensity, height) { + const candle = new THREE.Group(); + candle.position.set(x, y, z); + + const waxMaterial = createWaxMaterial(height); + const wax = new THREE.Mesh( + new THREE.CylinderGeometry(0.12, 0.12, height, 32), + waxMaterial + ); + wax.position.y = height / 2 - 0.05; + wax.castShadow = false; + wax.receiveShadow = false; + candle.add(wax); + + const waxGlow = new THREE.Mesh( + new THREE.CylinderGeometry(0.126, 0.126, height * 0.98, 32), + new THREE.MeshBasicMaterial({ + color: 0xffc579, + transparent: true, + opacity: 0.045, + depthWrite: false + }) + ); + waxGlow.position.copy(wax.position); + waxGlow.castShadow = false; + waxGlow.receiveShadow = false; + candle.add(waxGlow); + + const wickTopY = height + 0.075; + const wick = new THREE.Mesh( + new THREE.CylinderGeometry(0.012, 0.009, 0.16, 10), + new THREE.MeshStandardMaterial({ + color: 0x1a0f08, + roughness: 0.92, + metalness: 0, + emissive: 0x2a1206, + emissiveIntensity: 0.24 + }) + ); + wick.position.y = height + 0.015; + wick.rotation.x = 0.16; + candle.add(wick); + + const flame = createFlame(); + flame.position.y = wickTopY + 0.055; + candle.add(flame); + + const baseLightIntensity = intensity * 7.4; + const light = new THREE.PointLight(0xff9f45, baseLightIntensity, 4.35, 1.86); + light.position.copy(flame.position); + light.castShadow = false; + candle.add(light); + + candle.userData = { + light, + flame, + wax, + waxMaterial, + waxGlow, + bodyRadius: 0.12, + bodyHeight: height, + baseIntensity: baseLightIntensity, + seed: Math.random() * 100 + }; + candleShadowSources.push(candle); + scene.add(candle); +} + +function createFlame() { + const flame = new THREE.Group(); + const outer = new THREE.Mesh( + createFlameGeometry(0.07, 0.2, 28, 18), + createFlameMaterial({ + base: new THREE.Color(0x342100), + middle: new THREE.Color(0xff9a20), + tip: new THREE.Color(0xffd271), + opacity: 0.58, + noiseScale: 16, + displacement: 0.015 + }) + ); + const core = new THREE.Mesh( + createFlameGeometry(0.034, 0.145, 24, 14), + createFlameMaterial({ + base: new THREE.Color(0x203258), + middle: new THREE.Color(0xfff2a8), + tip: new THREE.Color(0xffb54a), + opacity: 0.82, + noiseScale: 21, + displacement: 0.008 + }) + ); + outer.renderOrder = 4; + core.renderOrder = 5; + flame.add(outer, core); + return flame; +} + +function createFlameGeometry(radius, height, radialSegments, heightSegments) { + const positions = []; + const normals = []; + const uvs = []; + const indices = []; + + for (let y = 0; y <= heightSegments; y += 1) { + const v = y / heightSegments; + const taper = Math.sin(Math.PI * Math.pow(v, 0.72)); + const point = Math.pow(v, 3.2); + const ringRadius = radius * taper * (1 - point * 0.82) * (0.72 + v * 0.5); + const yPos = (v - 0.18) * height; + for (let x = 0; x <= radialSegments; x += 1) { + const u = x / radialSegments; + const angle = u * Math.PI * 2; + const lean = v * v * 0.018; + positions.push( + Math.cos(angle) * ringRadius + lean, + yPos, + Math.sin(angle) * ringRadius + ); + normals.push(Math.cos(angle), 0.35, Math.sin(angle)); + uvs.push(u, v); + } + } + + for (let y = 0; y < heightSegments; y += 1) { + for (let x = 0; x < radialSegments; x += 1) { + const a = y * (radialSegments + 1) + x; + const b = a + 1; + const c = a + radialSegments + 1; + const d = c + 1; + indices.push(a, c, b, b, c, d); + } + } + + const geometry = new THREE.BufferGeometry(); + geometry.setIndex(indices); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); + geometry.computeVertexNormals(); + return geometry; +} + +function createFlameMaterial({ base, middle, tip, opacity, noiseScale, displacement }) { + return new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: THREE.AdditiveBlending, + uniforms: { + time: { value: 0 }, + baseColor: { value: base }, + middleColor: { value: middle }, + tipColor: { value: tip }, + flameOpacity: { value: opacity }, + noiseScale: { value: noiseScale }, + displacement: { value: displacement } + }, + vertexShader: ` + uniform float time; + uniform float noiseScale; + uniform float displacement; + varying vec2 vUv; + varying float vHeight; + + float wave(vec3 p) { + return sin(p.x * noiseScale + time * 7.1) * 0.5 + + sin((p.z + p.y) * noiseScale * 0.73 - time * 5.4) * 0.5; + } + + void main() { + vUv = uv; + vHeight = uv.y; + vec3 transformed = position; + float flutter = wave(position) * displacement * smoothstep(0.08, 1.0, uv.y); + transformed.x += flutter; + transformed.z += sin(time * 4.9 + position.y * 23.0) * displacement * 0.35 * uv.y; + gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0); + } + `, + fragmentShader: ` + uniform float time; + uniform vec3 baseColor; + uniform vec3 middleColor; + uniform vec3 tipColor; + uniform float flameOpacity; + varying vec2 vUv; + varying float vHeight; + + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + void main() { + float alphaShape = smoothstep(0.0, 0.18, vHeight) * smoothstep(1.0, 0.58, vHeight); + float flicker = 0.88 + sin(time * 9.0 + hash(vUv) * 6.2831) * 0.08; + vec3 lower = mix(baseColor, middleColor, smoothstep(0.08, 0.5, vHeight)); + vec3 color = mix(lower, tipColor, smoothstep(0.54, 1.0, vHeight)); + gl_FragColor = vec4(color * flicker, alphaShape * flameOpacity); + } + ` + }); +} + +function createWaxMaterial(height) { + const material = new THREE.MeshPhysicalMaterial({ + color: 0xffdfaa, + roughness: 0.52, + metalness: 0, + transmission: 0.46, + thickness: 0.42, + attenuationColor: 0xffb76a, + attenuationDistance: 0.62, + ior: 1.42, + emissive: 0xffb56a, + emissiveIntensity: 0.055, + envMapIntensity: 0 + }); + + material.customProgramCacheKey = () => `book-lab-wax-flame-aware-sss-${height.toFixed(3)}`; + material.onBeforeCompile = (shader) => { + material.userData.shader = shader; + shader.uniforms.waxHeight = { value: height }; + shader.uniforms.waxFlameWorldPosition = { value: new THREE.Vector3(0, height + 0.12, 0) }; + shader.uniforms.waxBodyWorldPosition = { value: new THREE.Vector3() }; + shader.uniforms.waxLightPower = { value: 1 }; + shader.vertexShader = shader.vertexShader + .replace( + '#include ', + '#include \nvarying float vWaxLocalY;\nvarying vec3 vWaxWorldPosition;\nvarying vec3 vWaxWorldNormal;' + ) + .replace( + '#include ', + '#include \nvWaxLocalY = position.y;' + ) + .replace( + '#include ', + '#include \nvWaxWorldNormal = normalize(mat3(modelMatrix) * objectNormal);' + ) + .replace( + '#include ', + 'vWaxWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;\n#include ' + ); + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform float waxHeight; + uniform vec3 waxFlameWorldPosition; + uniform vec3 waxBodyWorldPosition; + uniform float waxLightPower; + varying float vWaxLocalY; + varying vec3 vWaxWorldPosition; + varying vec3 vWaxWorldNormal;` + ) + .replace( + '#include ', + `float waxTop = smoothstep(waxHeight * 0.08, waxHeight * 0.5, vWaxLocalY); + float waxRim = pow(1.0 - abs(dot(normalize(normal), normalize(geometryViewDir))), 2.2); + float waxCore = smoothstep(-waxHeight * 0.45, waxHeight * 0.3, vWaxLocalY); + float waxFlameCup = smoothstep(waxHeight * 0.28, waxHeight * 0.52, vWaxLocalY); + vec3 waxToFlame = waxFlameWorldPosition - vWaxWorldPosition; + float waxFlameDistance = length(waxToFlame); + vec3 waxLightDir = normalize(waxToFlame); + vec3 waxWorldNormal = normalize(vWaxWorldNormal); + float waxNearFlame = 1.0 - smoothstep(0.06, 0.58, waxFlameDistance); + float waxUpperBody = smoothstep(waxBodyWorldPosition.y + waxHeight * 0.38, waxBodyWorldPosition.y + waxHeight * 0.92, vWaxWorldPosition.y); + float waxForwardScatter = pow(max(dot(waxWorldNormal, waxLightDir), 0.0), 0.62); + float waxBackScatter = pow(max(dot(-waxWorldNormal, waxLightDir), 0.0), 1.5); + float waxSideGlow = pow(max(1.0 - abs(dot(waxWorldNormal, waxLightDir)), 0.0), 1.15); + float waxSubsurface = (waxNearFlame * (0.56 + waxForwardScatter * 0.42) + waxBackScatter * 0.25 + waxSideGlow * 0.18) * waxUpperBody * waxLightPower; + float waxCavityGlow = waxFlameCup * waxNearFlame * waxLightPower * 0.75; + vec3 waxScatter = vec3(1.0, 0.48, 0.19) * (waxTop * 0.11 + waxRim * waxCore * 0.09 + waxFlameCup * 0.08 + waxSubsurface * 0.46 + waxCavityGlow * 0.36); + outgoingLight += waxScatter; + #include ` + ); + }; + + return material; +} + +function configureTableShader(material) { + material.customProgramCacheKey = () => 'book-lab-table-planar-environment-reflection-v2'; + material.onBeforeCompile = (shader) => { + tableShader = shader; + shader.uniforms.roomReflectionMap = { value: tableRoomReflectionTexture }; + shader.uniforms.sceneReflectionMap = { value: tableReflectionTarget.texture }; + shader.uniforms.sceneReflectionMatrix = { value: tableReflectionMatrix }; + shader.uniforms.tableDustMap = { value: tableDustTexture }; + shader.uniforms.candleBodyPositions = { + value: [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()] + }; + shader.uniforms.candleFlamePositions = { + value: [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()] + }; + shader.uniforms.candleBodyData = { + value: [new THREE.Vector2(), new THREE.Vector2(), new THREE.Vector2()] + }; + + shader.vertexShader = shader.vertexShader + .replace( + '#include ', + '#include \nuniform mat4 sceneReflectionMatrix;\nvarying vec3 vTableWorldPosition;\nvarying vec4 vSceneReflectionCoord;' + ) + .replace( + '#include ', + 'vec4 tableWorldPosition = modelMatrix * vec4(transformed, 1.0);\nvTableWorldPosition = tableWorldPosition.xyz;\nvSceneReflectionCoord = sceneReflectionMatrix * tableWorldPosition;\n#include ' + ); + + shader.fragmentShader = shader.fragmentShader + .replace( + '#include ', + `#include + uniform sampler2D roomReflectionMap; + uniform sampler2D sceneReflectionMap; + uniform sampler2D tableDustMap; + uniform mat4 sceneReflectionMatrix; + uniform vec3 candleBodyPositions[3]; + uniform vec3 candleFlamePositions[3]; + uniform vec2 candleBodyData[3]; + varying vec3 vTableWorldPosition; + varying vec4 vSceneReflectionCoord; + + vec3 rotateRoomReflection(vec3 dir) { + const float yaw = 0.42; + float s = sin(yaw); + float c = cos(yaw); + return normalize(vec3(c * dir.x - s * dir.z, dir.y, s * dir.x + c * dir.z)); + } + + vec3 sampleRoomReflection(vec3 dir) { + dir = rotateRoomReflection(normalize(dir)); + float u = 0.5 + atan(dir.z, dir.x) / 6.28318530718; + float v = 0.5 + asin(clamp(dir.y, -1.0, 1.0)) / 3.14159265359; + return texture2D(roomReflectionMap, vec2(u, v)).rgb; + } + + vec3 sampleRoughRoomReflection(vec3 dir) { + vec3 tangent = normalize(cross(vec3(0.0, 1.0, 0.0), dir)); + if (length(tangent) < 0.01) tangent = vec3(1.0, 0.0, 0.0); + vec3 bitangent = normalize(cross(dir, tangent)); + return sampleRoomReflection(dir) * 0.42 + + sampleRoomReflection(dir + tangent * 0.035) * 0.18 + + sampleRoomReflection(dir - tangent * 0.035) * 0.18 + + sampleRoomReflection(dir + bitangent * 0.026) * 0.11 + + sampleRoomReflection(dir - bitangent * 0.026) * 0.11; + } + + float candleBodyOcclusion(vec3 point, vec3 flame, vec3 body, vec2 bodyData) { + vec3 ray = point - flame; + float rayLen = max(length(ray), 0.0001); + vec3 dir = ray / rayLen; + float t = clamp(dot(body - flame, dir), 0.0, rayLen); + vec3 closest = flame + dir * t; + float lateral = length((body - closest).xz); + float bodyTop = body.y + bodyData.y; + float vertical = smoothstep(body.y - 0.05, body.y + 0.14, closest.y) * + (1.0 - smoothstep(bodyTop - 0.12, bodyTop + 0.08, closest.y)); + float sourceDistance = length(flame - body); + float penumbra = bodyData.x * (2.2 + sourceDistance * 1.65 + rayLen * 0.34); + float umbra = 1.0 - smoothstep(bodyData.x * 0.45, bodyData.x * 1.16, lateral); + float softEdge = 1.0 - smoothstep(bodyData.x * 1.05, penumbra, lateral); + float travelFade = 1.0 - smoothstep(1.65, 4.2, rayLen); + float waxTransmission = 0.38 + 0.28 * smoothstep(bodyTop - 0.32, bodyTop + 0.05, closest.y); + return clamp((umbra * 0.34 + softEdge * 0.2) * vertical * travelFade * waxTransmission, 0.0, 0.38); + } + + float candleContactOcclusion(vec3 point, vec3 body, vec2 bodyData) { + vec2 delta = point.xz - body.xz; + float base = 1.0 - smoothstep(bodyData.x * 0.72, bodyData.x * 2.55, length(delta)); + return base * 0.18; + } + + float candleOcclusionField(vec3 point) { + float contact = 0.0; + float projectedShadow = 0.0; + for (int bodyIndex = 0; bodyIndex < 3; bodyIndex++) { + contact = max(contact, candleContactOcclusion(point, candleBodyPositions[bodyIndex], candleBodyData[bodyIndex])); + for (int flameIndex = 0; flameIndex < 3; flameIndex++) { + projectedShadow = max(projectedShadow, candleBodyOcclusion(point, candleFlamePositions[flameIndex], candleBodyPositions[bodyIndex], candleBodyData[bodyIndex])); + } + } + return clamp(contact + projectedShadow, 0.0, 0.46); + }` + ) + .replace( + '#include ', + `vec3 viewDirWorld = normalize(cameraPosition - vTableWorldPosition); + vec3 tableNormalWorld = normalize(vec3(normal.x * 0.18, 1.0, normal.z * 0.18)); + vec3 reflectedDir = reflect(-viewDirWorld, tableNormalWorld); + vec3 roomReflection = sampleRoughRoomReflection(reflectedDir); + roomReflection = pow(max(roomReflection, vec3(0.0)), vec3(0.78)); + roomReflection *= vec3(0.88, 0.7, 0.5); + vec2 sceneReflectionUv = vSceneReflectionCoord.xy / max(vSceneReflectionCoord.w, 0.0001); + float sceneReflectionInBounds = step(0.0, sceneReflectionUv.x) * step(0.0, sceneReflectionUv.y) * + step(sceneReflectionUv.x, 1.0) * step(sceneReflectionUv.y, 1.0); + float sceneReflectionEdge = smoothstep(0.0, 0.08, sceneReflectionUv.x) * + smoothstep(0.0, 0.08, sceneReflectionUv.y) * + smoothstep(0.0, 0.08, 1.0 - sceneReflectionUv.x) * + smoothstep(0.0, 0.08, 1.0 - sceneReflectionUv.y); + vec2 roughReflectionUv = sceneReflectionUv + normal.xz * 0.012; + vec3 sceneReflection = texture2D(sceneReflectionMap, roughReflectionUv).rgb; + sceneReflection = pow(max(sceneReflection, vec3(0.0)), vec3(0.88)) * sceneReflectionInBounds * sceneReflectionEdge; + vec2 tableDustUv = clamp(vec2(vTableWorldPosition.x / 9.8 + 0.5, 0.5 - vTableWorldPosition.z / 6.6), vec2(0.0), vec2(1.0)); + float dust = texture2D(tableDustMap, tableDustUv).r; + float reflectionCleanliness = 1.0 - dust * 0.52; + vec3 combinedReflection = (roomReflection * 0.22 + sceneReflection * 0.32) * reflectionCleanliness; + float fresnel = pow(1.0 - max(dot(viewDirWorld, tableNormalWorld), 0.0), 1.85); + float tableReflectionMask = smoothstep(-0.095, -0.025, vTableWorldPosition.y); + vec3 reflectedSurface = combinedReflection * (0.56 + fresnel * 0.44); + vec3 reflectionLift = max(reflectedSurface - outgoingLight * 0.18, vec3(0.0)); + float contactAo = 1.0 - smoothstep(0.0, 0.9, length(vTableWorldPosition.xz * vec2(0.34, 0.58))) * 0.16; + float dustDulling = dust * tableReflectionMask; + float candleOcclusion = candleOcclusionField(vTableWorldPosition) * tableReflectionMask; + outgoingLight *= mix(1.0, contactAo, tableReflectionMask * 0.55); + outgoingLight += tableReflectionMask * reflectionLift * (0.12 + fresnel * 0.2) * reflectionCleanliness; + outgoingLight += tableReflectionMask * combinedReflection * 0.012; + outgoingLight = mix(outgoingLight, outgoingLight * vec3(0.78, 0.74, 0.66) + vec3(0.035, 0.028, 0.02), dustDulling * 0.18); + outgoingLight *= mix(vec3(1.0), vec3(0.62, 0.52, 0.42), candleOcclusion); + #include ` + ); + }; +} + +function buildBook() { + book.position.set(0, 0.03, 0); + book.rotation.y = 0; + + const coverW = 1.76; + const coverH = 2.42; + const coverT = 0.09; + const pageW = 1.56; + const pageH = 2.22; + const stackT = 0.22; + + const spine = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.24, coverH), materials.leather); + spine.position.set(0, -0.015, 0); + spine.castShadow = true; + spine.receiveShadow = true; + book.add(spine); + + const leftCover = makeBox(coverW, coverT, coverH, materials.leather); + leftCover.position.set(-coverW / 2 - 0.08, -0.055, 0); + leftCover.rotation.z = 0.035; + book.add(leftCover); + + const rightCover = makeBox(coverW, coverT, coverH, materials.leather); + rightCover.position.set(coverW / 2 + 0.08, -0.055, 0); + rightCover.rotation.z = -0.035; + book.add(rightCover); + + const leftStack = makeBox(pageW, stackT, pageH, materials.pageBlock); + leftStack.position.set(-pageW / 2 - 0.075, 0.045, 0); + leftStack.rotation.z = 0.018; + book.add(leftStack); + + const rightStack = makeBox(pageW, stackT, pageH, materials.pageBlock); + rightStack.position.set(pageW / 2 + 0.075, 0.045, 0); + rightStack.rotation.z = -0.018; + book.add(rightStack); + + addPageEdgeLines(leftStack.position.x, -1, pageW, pageH, stackT); + addPageEdgeLines(rightStack.position.x, 1, pageW, pageH, stackT); + + const leftPage = new THREE.Mesh(createPageGeometry(-1, pageW, pageH), materials.leftPage); + leftPage.position.y = 0.2; + leftPage.castShadow = true; + leftPage.receiveShadow = false; + book.add(leftPage); + + const rightPage = new THREE.Mesh(createPageGeometry(1, pageW, pageH), materials.rightPage); + rightPage.position.y = 0.2; + rightPage.castShadow = true; + rightPage.receiveShadow = false; + book.add(rightPage); + + const gutterShadow = new THREE.Mesh( + new THREE.BoxGeometry(0.045, 0.028, pageH * 0.96), + new THREE.MeshStandardMaterial({ color: 0x5f3d20, roughness: 0.98 }) + ); + gutterShadow.position.set(0, 0.205, 0); + book.add(gutterShadow); +} + +function makeBox(width, height, depth, material) { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth), material); + mesh.castShadow = true; + mesh.receiveShadow = true; + return mesh; +} + +function addPageEdgeLines(centerX, side, pageW, pageH, stackT) { + const lineMaterial = new THREE.LineBasicMaterial({ color: 0x8e6840, transparent: true, opacity: 0.34 }); + const edgeX = centerX + side * pageW * 0.5; + for (let i = 0; i < 22; i += 1) { + const y = -0.06 + (i / 21) * stackT; + const zInset = 0.04 + (i % 3) * 0.006; + const points = [ + new THREE.Vector3(edgeX, y, -pageH / 2 + zInset), + new THREE.Vector3(edgeX + side * 0.012, y + 0.002, pageH / 2 - zInset) + ]; + book.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), lineMaterial)); + } +} + +function createPageGeometry(side, width, height) { + const columns = 36; + const rows = 42; + const positions = []; + const uvs = []; + const indices = []; + for (let y = 0; y <= rows; y += 1) { + const v = y / rows; + const z = (v - 0.5) * height; + for (let x = 0; x <= columns; x += 1) { + const u = x / columns; + const outward = u * width; + const pageX = side * (0.055 + outward); + const gutterLift = 0.055 * Math.pow(1 - u, 2.1); + const edgeFall = -0.012 * Math.pow(u, 1.7); + const centerSag = -0.014 * Math.sin(Math.PI * v) * Math.sin(Math.PI * u); + const ripple = 0.004 * Math.sin(v * Math.PI * 4 + side * 0.7) * (1 - Math.abs(u - 0.5)); + positions.push(pageX, gutterLift + edgeFall + centerSag, z + ripple); + uvs.push(side < 0 ? 1 - u : u, 1 - v); + } + } + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < columns; x += 1) { + const a = y * (columns + 1) + x; + const b = a + 1; + const c = a + columns + 1; + const d = c + 1; + indices.push(a, c, b, b, c, d); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setIndex(indices); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); + geometry.computeVertexNormals(); + return geometry; +} + +function createPageCanvas(side) { + const canvas = document.createElement('canvas'); + canvas.width = 1800; + canvas.height = 2500; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#f5dfab'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const grain = ctx.createImageData(canvas.width, canvas.height); + for (let i = 0; i < grain.data.length; i += 4) { + const n = 234 + Math.floor(Math.random() * 24); + grain.data[i] = n; + grain.data[i + 1] = Math.min(255, n - 8); + grain.data[i + 2] = Math.max(0, n - 45); + grain.data[i + 3] = 18; + } + ctx.putImageData(grain, 0, 0); + + const shade = ctx.createLinearGradient(0, 0, canvas.width, 0); + shade.addColorStop(0, 'rgba(93, 55, 24, 0.18)'); + shade.addColorStop(side === 'left' ? 0.85 : 0.15, 'rgba(255, 255, 255, 0)'); + shade.addColorStop(1, 'rgba(85, 49, 21, 0.12)'); + ctx.fillStyle = shade; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = 'rgba(92, 63, 31, 0.18)'; + ctx.lineWidth = 2; + for (let y = 290; y < canvas.height - 190; y += 88) { + ctx.beginPath(); + ctx.moveTo(170, y); + ctx.lineTo(canvas.width - 160, y + Math.sin(y * 0.02) * 4); + ctx.stroke(); + } + + ctx.fillStyle = inkColor; + ctx.textBaseline = 'top'; + if (side === 'left') { + drawCentered(ctx, 'Georg Tomitsch', 255, 44); + drawCentered(ctx, 'Eibenreith', 330, 92); + drawCentered(ctx, 'Ein Kaiserpunk Abenteuer', 455, 54); + drawCentered(ctx, 'speech | autoplay | speed | new game | save | load | options', 610, 34); + drawCentered(ctx, 'click on page or press spacebar to fast forward text animation', 720, 34); + } else { + drawParagraph(ctx, 'Click on new game or load to start the game', 210, 310, canvas.width - 420, 74, 1.35); + } + return canvas; +} + +function createRoomReflectionTexture() { + const canvas = document.createElement('canvas'); + generatedTextureCanvases.roomReflection = canvas; + canvas.width = 2048; + canvas.height = 1024; + const ctx = canvas.getContext('2d'); + + const wall = ctx.createLinearGradient(0, 0, 0, canvas.height); + wall.addColorStop(0, '#050302'); + wall.addColorStop(0.36, '#140906'); + wall.addColorStop(0.72, '#2b150b'); + wall.addColorStop(1, '#060302'); + ctx.fillStyle = wall; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.globalAlpha = 0.28; + for (let i = 0; i < 7; i += 1) { + const x = 170 + i * 285; + const glow = ctx.createRadialGradient(x, 520, 0, x, 520, 300); + glow.addColorStop(0, 'rgba(255, 157, 64, 0.55)'); + glow.addColorStop(0.2, 'rgba(144, 68, 27, 0.28)'); + glow.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.fillStyle = glow; + ctx.fillRect(x - 330, 190, 660, 660); + } + + ctx.globalAlpha = 0.16; + ctx.fillStyle = '#c99655'; + for (let i = 0; i < 10; i += 1) { + ctx.fillRect(120 + i * 190, 230, 52, 390); + } + ctx.globalAlpha = 1; + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.mapping = THREE.EquirectangularReflectionMapping; + texture.needsUpdate = true; + return texture; +} + +function loadAiRoomReflection() { + new THREE.TextureLoader().load('/assets/webgl/room_reflection_candlelit_study_equirect_4k.png', (texture) => { + texture.colorSpace = THREE.SRGBColorSpace; + texture.mapping = THREE.EquirectangularReflectionMapping; + texture.anisotropy = renderer.capabilities.getMaxAnisotropy(); + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.generateMipmaps = true; + texture.needsUpdate = true; + tableRoomReflectionTexture = texture; + if (tableShader) { + tableShader.uniforms.roomReflectionMap.value = texture; + } + + const image = texture.image; + if (!image) return; + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth || image.width; + canvas.height = image.naturalHeight || image.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + generatedTextureCanvases.aiRoomReflection = canvas; + tintAmbientFromCanvas(canvas); + }, undefined, () => { + tintAmbientFromCanvas(generatedTextureCanvases.roomReflection); + }); +} + +function tintAmbientFromCanvas(canvas) { + if (!canvas || !candleBounceLight) return; + const ctx = canvas.getContext('2d'); + const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + let r = 0; + let g = 0; + let b = 0; + let count = 0; + for (let i = 0; i < data.length; i += 256) { + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + count += 1; + } + const color = new THREE.Color(r / count / 255, g / count / 255, b / count / 255); + color.offsetHSL(0, 0.08, 0.04); + candleBounceLight.color.copy(color); + candleBounceLight.intensity = 0.28; +} + +function createTableNormalTexture() { + const canvas = document.createElement('canvas'); + generatedTextureCanvases.tableNormal = canvas; + canvas.width = 2048; + canvas.height = 2048; + const ctx = canvas.getContext('2d'); + const image = ctx.createImageData(canvas.width, canvas.height); + for (let y = 0; y < canvas.height; y += 1) { + for (let x = 0; x < canvas.width; x += 1) { + const i = (y * canvas.width + x) * 4; + const grain = Math.sin(x * 0.028) * 8 + Math.sin((x + y) * 0.011) * 5 + Math.sin(x * 0.11 + y * 0.007) * 2; + const pore = Math.sin(y * 0.12 + Math.sin(x * 0.016) * 2.1) * 3 + (Math.random() - 0.5) * 6; + image.data[i] = 128 + grain; + image.data[i + 1] = 128 + pore; + image.data[i + 2] = 255; + image.data[i + 3] = 255; + } + } + ctx.putImageData(image, 0, 0); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.NoColorSpace; + texture.needsUpdate = true; + return texture; +} + +function createTableDustTexture() { + const canvas = document.createElement('canvas'); + generatedTextureCanvases.tableDust = canvas; + canvas.width = 2048; + canvas.height = 2048; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'rgb(10, 10, 10)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const image = ctx.getImageData(0, 0, canvas.width, canvas.height); + for (let y = 0; y < canvas.height; y += 1) { + for (let x = 0; x < canvas.width; x += 1) { + const i = (y * canvas.width + x) * 4; + const nx = x / canvas.width; + const ny = y / canvas.height; + const edgeDust = Math.max(0, 1 - Math.min(nx, ny, 1 - nx, 1 - ny) * 9); + const grain = Math.random() < 0.018 ? 110 + Math.random() * 85 : Math.random() * 18; + const sweep = Math.max(0, Math.sin(nx * 32 + Math.sin(ny * 15) * 2.5) - 0.84) * 55; + const pageDust = Math.exp(-Math.pow((nx - 0.5) * 2.2, 2) - Math.pow((ny - 0.5) * 1.4, 2)) * 16; + const value = Math.min(255, 10 + edgeDust * 36 + grain + sweep + pageDust); + image.data[i] = value; + image.data[i + 1] = value; + image.data[i + 2] = value; + image.data[i + 3] = 255; + } + } + ctx.putImageData(image, 0, 0); + + const smudge = (x, y, rx, ry, alpha) => { + const gradient = ctx.createRadialGradient(x, y, 0, x, y, Math.max(rx, ry)); + gradient.addColorStop(0, `rgba(220, 220, 220, ${alpha})`); + gradient.addColorStop(0.42, `rgba(150, 150, 150, ${alpha * 0.32})`); + gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.save(); + ctx.translate(x, y); + ctx.scale(rx / Math.max(rx, ry), ry / Math.max(rx, ry)); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(0, 0, Math.max(rx, ry), 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + }; + + smudge(canvas.width * 0.17, canvas.height * 0.38, 170, 70, 0.12); + smudge(canvas.width * 0.83, canvas.height * 0.24, 120, 58, 0.1); + smudge(canvas.width * 0.73, canvas.height * 0.76, 155, 64, 0.09); + smudge(canvas.width * 0.5, canvas.height * 0.52, 260, 110, 0.055); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.NoColorSpace; + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.generateMipmaps = true; + texture.needsUpdate = true; + return texture; +} + +function drawCentered(ctx, text, y, size) { + ctx.font = `${size}px Georgia, "Times New Roman", serif`; + ctx.textAlign = 'center'; + ctx.fillText(text, ctx.canvas.width / 2, y); +} + +function drawParagraph(ctx, text, x, y, width, size, lineHeight) { + ctx.font = `${size}px Georgia, "Times New Roman", serif`; + ctx.textAlign = 'left'; + const words = text.split(/\s+/); + let line = ''; + words.forEach((word) => { + const test = line ? `${line} ${word}` : word; + if (ctx.measureText(test).width > width && line) { + ctx.fillText(line, x, y); + line = word; + y += size * lineHeight; + } else { + line = test; + } + }); + if (line) ctx.fillText(line, x, y); +} + +function resize() { + const width = Math.max(1, window.innerWidth); + const height = Math.max(1, window.innerHeight); + renderer.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + const pixelRatio = renderer.getPixelRatio(); + tableReflectionTarget.setSize( + Math.max(320, Math.min(1280, Math.floor(width * pixelRatio * 0.75))), + Math.max(180, Math.min(720, Math.floor(height * pixelRatio * 0.75))) + ); +} + +function installCameraControls() { + canvas.addEventListener('pointerdown', (event) => { + cameraRig.dragging = true; + canvas.style.cursor = 'grabbing'; + cameraRig.pointerX = event.clientX; + cameraRig.pointerY = event.clientY; + canvas.setPointerCapture(event.pointerId); + }); + + canvas.addEventListener('pointermove', (event) => { + if (!cameraRig.dragging) return; + const dx = event.clientX - cameraRig.pointerX; + const dy = event.clientY - cameraRig.pointerY; + cameraRig.pointerX = event.clientX; + cameraRig.pointerY = event.clientY; + cameraRig.yaw -= dx * 0.006; + cameraRig.pitch = THREE.MathUtils.clamp( + cameraRig.pitch + dy * 0.004, + cameraRig.minPitch, + cameraRig.maxPitch + ); + updateCameraRig(0); + }); + + canvas.addEventListener('pointerup', (event) => { + cameraRig.dragging = false; + canvas.style.cursor = 'grab'; + canvas.releasePointerCapture(event.pointerId); + }); + + canvas.addEventListener('pointercancel', () => { + cameraRig.dragging = false; + canvas.style.cursor = 'grab'; + }); + + canvas.addEventListener('wheel', (event) => { + event.preventDefault(); + const zoom = Math.exp(event.deltaY * 0.001); + cameraRig.radius = THREE.MathUtils.clamp( + cameraRig.radius * zoom, + cameraRig.minRadius, + cameraRig.maxRadius + ); + updateCameraRig(0); + }, { passive: false }); + + window.addEventListener('keydown', (event) => { + if (['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) { + cameraRig.keys.add(event.code); + event.preventDefault(); + } + }); + + window.addEventListener('keyup', (event) => { + cameraRig.keys.delete(event.code); + }); +} + +function updateCameraRig(deltaSeconds) { + if (deltaSeconds > 0 && cameraRig.keys.size) { + const forward = new THREE.Vector3(); + camera.getWorldDirection(forward); + forward.y = 0; + forward.normalize(); + const right = new THREE.Vector3().crossVectors(forward, camera.up).normalize(); + const move = new THREE.Vector3(); + if (cameraRig.keys.has('KeyW')) move.add(forward); + if (cameraRig.keys.has('KeyS')) move.sub(forward); + if (cameraRig.keys.has('KeyD')) move.add(right); + if (cameraRig.keys.has('KeyA')) move.sub(right); + if (move.lengthSq() > 0) { + move.normalize().multiplyScalar(deltaSeconds * cameraRig.radius * 0.72); + cameraRig.target.add(move); + cameraRig.target.x = THREE.MathUtils.clamp(cameraRig.target.x, -2.6, 2.6); + cameraRig.target.z = THREE.MathUtils.clamp(cameraRig.target.z, -1.9, 1.9); + } + } + + const horizontalRadius = Math.sin(cameraRig.pitch) * cameraRig.radius; + camera.position.set( + cameraRig.target.x + Math.sin(cameraRig.yaw) * horizontalRadius, + cameraRig.target.y + Math.cos(cameraRig.pitch) * cameraRig.radius, + cameraRig.target.z + Math.cos(cameraRig.yaw) * horizontalRadius + ); + camera.lookAt(cameraRig.target); +} + +function updateCandleShadowUniforms() { + if (!tableShader) return; + candleShadowSources.forEach((candle, index) => { + if (index >= 3) return; + candle.getWorldPosition(candleWorldPosition); + candle.userData.flame.getWorldPosition(flameWorldPosition); + tableShader.uniforms.candleBodyPositions.value[index].set( + candleWorldPosition.x, + candleWorldPosition.y - 0.05, + candleWorldPosition.z + ); + tableShader.uniforms.candleFlamePositions.value[index].copy(flameWorldPosition); + tableShader.uniforms.candleBodyData.value[index].set( + candle.userData.bodyRadius, + candle.userData.bodyHeight + ); + }); +} + +function updateTableReflection() { + if (!tableMesh || !tableShader) return; + + tableReflectionCamera.copy(camera); + tableReflectionCamera.position.set( + camera.position.x, + tableTopY - (camera.position.y - tableTopY), + camera.position.z + ); + + camera.getWorldDirection(reflectionForward); + reflectionTarget.copy(camera.position).add(reflectionForward); + reflectionTarget.y = tableTopY - (reflectionTarget.y - tableTopY); + reflectionUp.set(0, 1, 0).applyQuaternion(camera.quaternion); + reflectionUp.y *= -1; + tableReflectionCamera.up.copy(reflectionUp); + tableReflectionCamera.lookAt(reflectionTarget); + tableReflectionCamera.updateProjectionMatrix(); + tableReflectionCamera.updateMatrixWorld(); + tableReflectionCamera.matrixWorldInverse.copy(tableReflectionCamera.matrixWorld).invert(); + tableReflectionMatrix + .copy(tableReflectionBiasMatrix) + .multiply(tableReflectionCamera.projectionMatrix) + .multiply(tableReflectionCamera.matrixWorldInverse); + + const previousRenderTarget = renderer.getRenderTarget(); + const previousXrEnabled = renderer.xr.enabled; + const previousShadowAutoUpdate = renderer.shadowMap.autoUpdate; + const previousToneMappingExposure = renderer.toneMappingExposure; + + tableMesh.visible = false; + renderer.xr.enabled = false; + renderer.shadowMap.autoUpdate = false; + renderer.toneMappingExposure = 0.92; + renderer.setRenderTarget(tableReflectionTarget); + renderer.clear(); + renderer.render(scene, tableReflectionCamera); + renderer.setRenderTarget(previousRenderTarget); + renderer.toneMappingExposure = previousToneMappingExposure; + renderer.shadowMap.autoUpdate = previousShadowAutoUpdate; + renderer.xr.enabled = previousXrEnabled; + tableMesh.visible = true; +} + +function animate() { + requestAnimationFrame(animate); + const delta = clock.getDelta(); + const t = clock.elapsedTime; + updateCameraRig(delta); + scene.traverse((object) => { + if (!object.userData?.light) return; + const swayX = Math.sin(t * 5.7 + object.userData.seed) * 0.012; + const swayZ = Math.cos(t * 4.9 + object.userData.seed * 0.7) * 0.01; + const pulse = 0.9 + Math.sin(t * 7.3 + object.userData.seed) * 0.09 + Math.sin(t * 13.1) * 0.045; + object.userData.light.intensity = object.userData.baseIntensity * pulse * (object.position.x < 0 ? 1.08 : 0.92); + object.userData.flame.scale.y = 1.65 + Math.sin(t * 9.2 + object.userData.seed) * 0.18; + object.userData.flame.position.x = swayX * 0.75; + object.userData.flame.position.z = swayZ * 0.75; + object.userData.flame.traverse((child) => { + if (child.material?.uniforms?.time) child.material.uniforms.time.value = t + object.userData.seed; + }); + object.userData.light.position.copy(object.userData.flame.position); + object.userData.waxGlow.material.opacity = 0.07 + Math.max(0, pulse - 0.9) * 0.08; + const waxShader = object.userData.waxMaterial.userData.shader; + if (waxShader) { + object.getWorldPosition(candleWorldPosition); + object.userData.flame.getWorldPosition(flameWorldPosition); + waxShader.uniforms.waxFlameWorldPosition.value.copy(flameWorldPosition); + waxShader.uniforms.waxBodyWorldPosition.value.set( + candleWorldPosition.x, + candleWorldPosition.y - 0.05, + candleWorldPosition.z + ); + waxShader.uniforms.waxLightPower.value = THREE.MathUtils.clamp(pulse * object.userData.baseIntensity * 0.42, 0.35, 1.6); + } + }); + updateCandleShadowUniforms(); + updateTableReflection(); + renderer.render(scene, camera); +} diff --git a/public/js/webgl-book-scene-module.js b/public/js/webgl-book-scene-module.js new file mode 100644 index 0000000..5a4e7ed --- /dev/null +++ b/public/js/webgl-book-scene-module.js @@ -0,0 +1,605 @@ +/** + * WebGL Book Scene Module + * Creates the canvas-first UI shell and a page-turn-ready book scene. + */ +import { BaseModule } from './base-module.js'; + +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.bindMethods([ + 'ensureShell', + 'initializeScene', + 'loadBookModel', + 'placeBookForOpenPose', + 'applyDynamicPageTextures', + 'remapRightPageUv', + 'createPageTexture', + 'drawPageTexture', + 'adoptPageContent', + 'refreshModalOverview', + 'updateSceneSize', + 'animate', + 'triggerPageTurn' + ]); + } + + 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'); + return true; + } + } + + ensureShell() { + document.body.classList.add('webgl-mode'); + + let app = document.getElementById('webgl_app'); + if (!app) { + app = document.createElement('div'); + app.id = 'webgl_app'; + document.body.prepend(app); + } + + let canvas = document.getElementById('webgl_canvas'); + if (!canvas) { + canvas = document.createElement('canvas'); + canvas.id = 'webgl_canvas'; + canvas.setAttribute('aria-label', '3D book scene'); + 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(); + }); + } + + 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); + } + + 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); + } + + if (!book.dataset.webglOverlayBound) { + book.dataset.webglOverlayBound = 'true'; + book.addEventListener('input', () => this.drawPageTexture(), true); + book.addEventListener('change', () => this.drawPageTexture(), true); + } + + this.refreshModalOverview(); + } + + 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(); + } + + 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 + }); + 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 + }; + return sizes[role] || sizes.body; + } + + 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; + } + + 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; + } + }); + if (line) ctx.fillText(line, x, y); + return y + lineHeight; + } + + adoptPageContent() { + 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(); + } + + 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); + }); + } + + 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(); + } + + 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(); + } + + 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); + } + } else { + if (!this.pageTextureApplied) { + this.applyDynamicPageTextures(this.bookModel); + } + } + + this.renderer.render(this.scene, this.camera); + } +} + +const WebGLBookScene = new WebGLBookSceneModule(); + +export { WebGLBookScene }; + +if (window.moduleRegistry) { + window.moduleRegistry.register(WebGLBookScene); +} + +window.WebGLBookScene = WebGLBookScene; diff --git a/public/webgl-book-lab.html b/public/webgl-book-lab.html new file mode 100644 index 0000000..f861084 --- /dev/null +++ b/public/webgl-book-lab.html @@ -0,0 +1,60 @@ + + + + + + WebGL Book Lab + + + + +
+
Procedural Book Lab
+
standalone scene
+
+ + +