From efd1e6cfff00e230ac723f3bf1ec765ab33aa136 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Mon, 8 Jun 2026 10:25:54 +0200 Subject: [PATCH] Implement WebGL page reserve navigation --- dist/config/game-config.d.ts | 2 + dist/config/game-config.js.map | 2 +- public/css/style.css | 65 +++++ public/js/book-pagination-module.js | 31 ++- public/js/game-loop-module.js | 20 +- public/js/markup-parser-module.js | 30 ++- public/js/options-ui-module.js | 72 ++++-- public/js/persistence-manager-module.js | 3 +- public/js/webgl-book-lab.js | 315 ++++++++++++++++++++++-- public/js/webgl-book-scene-module.js | 73 +++++- public/locales/de_DE.json | 4 + public/locales/en_US.json | 4 + src/config/game-config.ts | 2 + 13 files changed, 571 insertions(+), 52 deletions(-) diff --git a/dist/config/game-config.d.ts b/dist/config/game-config.d.ts index 78396d7..6e16bac 100644 --- a/dist/config/game-config.d.ts +++ b/dist/config/game-config.d.ts @@ -6,6 +6,8 @@ export interface GameMetadata { version?: string; copyright?: string; language?: string; + bookPageCount?: number; + pageReserve?: number; } export interface GamePaths { mainGameFile: string; diff --git a/dist/config/game-config.js.map b/dist/config/game-config.js.map index 41632f0..9989f72 100644 --- a/dist/config/game-config.js.map +++ b/dist/config/game-config.js.map @@ -1 +1 @@ -{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/public/css/style.css b/public/css/style.css index 0328ec9..9704a8e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -2068,6 +2068,71 @@ body.webgl-mode .choice-list kbd { opacity: 0.38; } +#webgl_book_navigation { + position: fixed; + z-index: 52; + left: 50%; + bottom: 18px; + transform: translateX(-50%); + width: min(820px, calc(100vw - 32px)); + display: grid; + grid-template-columns: 34px 34px minmax(180px, 1fr) 34px 34px; + align-items: center; + gap: 10px; + padding: 10px 12px; + box-sizing: border-box; + color: rgba(246, 231, 201, 0.94); + background: rgba(12, 9, 7, 0.62); + border: 1px solid rgba(246, 231, 201, 0.24); + border-radius: 6px; + backdrop-filter: blur(10px); +} + +.webgl-book-nav-button { + width: 34px; + height: 30px; + display: grid; + place-items: center; + color: rgba(246, 231, 201, 0.94); + background: rgba(44, 28, 17, 0.74); + border: 1px solid rgba(246, 231, 201, 0.26); + border-radius: 4px; + cursor: pointer; +} + +.webgl-book-nav-button:disabled { + opacity: 0.36; + cursor: var(--default-cursor, default); +} + +.webgl-book-nav-slider-wrap { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(72px, auto); + align-items: center; + gap: 10px; + min-width: 0; +} + +#webgl_book_nav_position { + width: 100%; + accent-color: #f0cd8e; + background: + linear-gradient(90deg, + rgba(240, 205, 142, 0.48) 0%, + rgba(240, 205, 142, 0.48) calc(var(--book-nav-written, 0) * 100%), + rgba(246, 231, 201, 0.18) calc(var(--book-nav-written, 0) * 100%), + rgba(246, 231, 201, 0.18) calc(var(--book-nav-reserve-start, 1) * 100%), + rgba(80, 40, 34, 0.58) calc(var(--book-nav-reserve-start, 1) * 100%), + rgba(80, 40, 34, 0.58) 100%); +} + +.webgl-book-nav-page-label { + min-width: 72px; + text-align: right; + font-size: 13px; + line-height: 1; +} + #modal_overview { position: fixed; z-index: 45; diff --git a/public/js/book-pagination-module.js b/public/js/book-pagination-module.js index 7fed2da..f61820a 100644 --- a/public/js/book-pagination-module.js +++ b/public/js/book-pagination-module.js @@ -18,6 +18,7 @@ class BookPaginationModule extends BaseModule { this.refreshToken = 0; this.latestBlockId = 0; this.latestRenderedBlockId = 0; + this.appliedPageReserveBlocks = new Set(); this.bindMethods([ 'initialize', @@ -26,6 +27,7 @@ class BookPaginationModule extends BaseModule { 'buildSpreads', 'buildPages', 'buildSpreadsFromPages', + 'applyPageReserveDirective', 'createBlankPage', 'createTitlePage', 'ensurePage', @@ -76,7 +78,12 @@ class BookPaginationModule extends BaseModule { }); this.addEventListener(document, 'webgl-book:page-flip-near-end', (event) => { const direction = Math.sign(Number(event.detail?.direction || 0)); - if (direction !== 0) this.setCurrentSpread(this.currentSpreadIndex + direction); + const targetSpread = Number(event.detail?.targetSpread); + if (Number.isFinite(targetSpread)) { + this.setCurrentSpread(targetSpread); + } else if (direction !== 0) { + this.setCurrentSpread(this.currentSpreadIndex + direction); + } }); this.reportProgress(100, 'Book pagination ready'); return true; @@ -102,6 +109,7 @@ class BookPaginationModule extends BaseModule { this.latestBlockId = 0; this.latestRenderedBlockId = 0; this.currentSpreadIndex = 0; + this.appliedPageReserveBlocks.clear(); this.publish(); return; } @@ -196,6 +204,7 @@ class BookPaginationModule extends BaseModule { source.forEach((block) => { const type = block?.kind || block?.type || 'paragraph'; + this.applyPageReserveDirective(block); if (type === 'image') { ({ pageIndex, pageLine, contentPageNumber } = this.layoutImageBlock( pages, @@ -295,6 +304,24 @@ class BookPaginationModule extends BaseModule { return spreads.filter(Boolean); } + applyPageReserveDirective(block = {}) { + const directive = block?.metadata?.pageReserve || block?.pageReserve || null; + const blockId = Number(block?.blockId || block?.metadata?.blockId || 0); + const gameId = block?.gameId || block?.metadata?.gameId || this.storyHistory?.currentGameId || 'default'; + const key = `${gameId}:${blockId}`; + if (!directive || blockId <= 0 || this.appliedPageReserveBlocks.has(key)) return; + const value = Number(directive.value); + if (!Number.isFinite(value)) return; + this.appliedPageReserveBlocks.add(key); + document.dispatchEvent(new CustomEvent('webgl-book:page-reserve-directive', { + detail: { + blockId, + value, + unit: directive.unit === 'percent' ? 'percent' : 'pages' + } + })); + } + createBlankPage(index = 0, options = {}) { return { index, @@ -841,11 +868,13 @@ class BookPaginationModule extends BaseModule { } publish() { + const writtenPageLimit = Math.max(0, (Math.max(0, this.spreads.length - 1) * 2) - 1); document.dispatchEvent(new CustomEvent('book-pagination:spread-updated', { detail: { spread: this.getCurrentSpread(), spreadIndex: this.currentSpreadIndex, spreadCount: this.spreads.length, + writtenPageLimit, latestBlockId: this.latestBlockId, latestRenderedBlockId: this.latestRenderedBlockId } diff --git a/public/js/game-loop-module.js b/public/js/game-loop-module.js index 7ebaca7..b6d93d9 100644 --- a/public/js/game-loop-module.js +++ b/public/js/game-loop-module.js @@ -54,7 +54,9 @@ class GameLoopModule extends BaseModule { 'requestStartGame', 'requestSaveGame', 'requestLoadGame', - 'resetClientPlaybackAndDisplay' + 'resetClientPlaybackAndDisplay', + 'getWebGLBookState', + 'applyWebGLBookState' ]); } @@ -322,6 +324,7 @@ class GameLoopModule extends BaseModule { if (typeof storyHistory.saveSlot === 'function') { await storyHistory.saveSlot(this.autoSaveSlot, { inkState: null, + webglBookState: this.getWebGLBookState(), choices: [], inputMode: 'none', running: false @@ -347,6 +350,7 @@ class GameLoopModule extends BaseModule { if (!isCurrentOperation()) return; await storyHistory.saveSlot(this.autoSaveSlot, { inkState: response.savedState, + webglBookState: this.getWebGLBookState(), choices: [], inputMode: 'none', running: true @@ -372,6 +376,7 @@ class GameLoopModule extends BaseModule { latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0, renderedLineCount: storyHistory.renderedLineCount || 0, musicState: audioManager?.getMusicState?.() || null, + webglBookState: this.getWebGLBookState(), choices: this.currentChoices, inputMode: this.currentInputMode, running: this.gameState.started && !this.gameState.ended @@ -453,6 +458,7 @@ class GameLoopModule extends BaseModule { browserSave.renderedLineCount || 0 ); } + this.applyWebGLBookState(browserSave.webglBookState); const uiController = this.getModule('ui-controller'); if (browserSave && uiController?.displayHandler?.restoreFromHistory) { await uiController.displayHandler.restoreFromHistory(browserSave); @@ -516,6 +522,17 @@ class GameLoopModule extends BaseModule { } } + getWebGLBookState() { + return window.WebGLBookPreferenceBridge?.getBookState?.() + || window.BookLabDebug?.getBookState?.() + || null; + } + + applyWebGLBookState(state = null) { + if (!state || typeof state !== 'object') return; + window.WebGLBookPreferenceBridge?.applyBookState?.(state); + } + hasUnrenderedHistory(browserSave) { return Boolean(browserSave) && Number(browserSave.latestBlockId || 0) > Number(browserSave.latestRenderedBlockId || 0); @@ -565,6 +582,7 @@ class GameLoopModule extends BaseModule { latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0, renderedLineCount: storyHistory.renderedLineCount || 0, musicState: audioManager?.getMusicState?.() || null, + webglBookState: this.getWebGLBookState(), choices: this.currentChoices, inputMode: this.currentInputMode, running: this.gameState.started && !this.gameState.ended diff --git a/public/js/markup-parser-module.js b/public/js/markup-parser-module.js index 7f2359b..83f6fdd 100644 --- a/public/js/markup-parser-module.js +++ b/public/js/markup-parser-module.js @@ -24,6 +24,7 @@ class MarkupParserModule extends BaseModule { 'parseImageOptions', 'parseSfxOptions', 'parseMusicOptions', + 'parsePageReserveDirective', 'markdownToHtml', 'markdownToPlainText', 'smartypants', @@ -178,11 +179,14 @@ class MarkupParserModule extends BaseModule { } parseParagraph(rawText) { - const inline = this.parseInline(this.normalizeParagraph(rawText)); + const normalized = this.normalizeParagraph(rawText); + const reserveDirective = this.parsePageReserveDirective(normalized); + const inline = this.parseInline(reserveDirective.text); return { text: this.markdownToPlainText(inline.text), layoutText: this.markdownToHtml(inline.text), - cueMarkers: inline.cueMarkers + cueMarkers: inline.cueMarkers, + pageReserve: reserveDirective.directive }; } @@ -193,12 +197,34 @@ class MarkupParserModule extends BaseModule { layoutText: paragraph.layoutText, cueMarkers: paragraph.cueMarkers, role, + metadata: { + ...(paragraph.pageReserve ? { pageReserve: paragraph.pageReserve } : {}) + }, isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first', dropCap: role === 'chapter-first', addTopSpace: role === 'textblock-first' }; } + parsePageReserveDirective(text) { + const source = String(text || ''); + const match = source.match(/#pagereserve\[\s*([0-9]+(?:\.[0-9]+)?)\s*(%)?\s*\]/i); + if (!match) { + return { text: source, directive: null }; + } + const value = Number(match[1]); + const directive = Number.isFinite(value) + ? { + value, + unit: match[2] === '%' ? 'percent' : 'pages' + } + : null; + return { + text: source.replace(match[0], '').replace(/\s{2,}/g, ' ').trim(), + directive + }; + } + parseInline(text) { return { text: String(text || '').replace(/\s{2,}/g, ' ').trim(), diff --git a/public/js/options-ui-module.js b/public/js/options-ui-module.js index 29d0f5e..79fe371 100644 --- a/public/js/options-ui-module.js +++ b/public/js/options-ui-module.js @@ -50,6 +50,9 @@ class OptionsUIModule extends BaseModule { 'setupApiUrlFields', 'setupInitialState', 'dispatchApiChangeEvent', + 'getMetadataNumber', + 'hasFixedBookPageCount', + 'hasFixedPageReserve', 'getPreference', 'updatePreference', 'updateUIText', @@ -92,6 +95,25 @@ class OptionsUIModule extends BaseModule { detail: { provider, [valueType]: value } })); } + + getMetadataNumber(keys = []) { + const gameConfig = this.getModule('game-config'); + const metadata = gameConfig?.getMetadata?.() || {}; + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(metadata, key)) continue; + const value = Number(metadata[key]); + if (Number.isFinite(value)) return value; + } + return null; + } + + hasFixedBookPageCount() { + return Number.isFinite(this.getMetadataNumber(['bookPageCount', 'defaultBookPageCount', 'webglBookPageCount'])); + } + + hasFixedPageReserve() { + return Number.isFinite(this.getMetadataNumber(['pageReserve', 'defaultPageReserve', 'webglPageReserve'])); + } /** * Gets a preference from the persistence manager @@ -302,32 +324,36 @@ class OptionsUIModule extends BaseModule { 'data-pref-transform': 'integer:40,500' }, null, bookSizeContainer); this.elements.webglBookSize.addEventListener('input', () => this.updateWebGLDisplays()); - webglSection.appendChild(bookSizeContainer); + if (!this.hasFixedBookPageCount()) { + webglSection.appendChild(bookSizeContainer); + } - const bookProgressContainer = document.createElement('div'); - bookProgressContainer.className = 'option-item'; + const pageReserveContainer = document.createElement('div'); + pageReserveContainer.className = 'option-item'; - const bookProgressLabel = document.createElement('label'); - bookProgressLabel.textContent = this.t('options.bookProgress') + ':'; - bookProgressContainer.appendChild(bookProgressLabel); + const pageReserveLabel = document.createElement('label'); + pageReserveLabel.textContent = this.t('options.pageReserve') + ':'; + pageReserveContainer.appendChild(pageReserveLabel); - const bookProgressValue = document.createElement('span'); - bookProgressValue.className = 'slider-value'; - bookProgressValue.textContent = '50%'; - this.elements.webglBookProgressValue = bookProgressValue; - bookProgressContainer.appendChild(bookProgressValue); + const pageReserveValue = document.createElement('span'); + pageReserveValue.className = 'slider-value'; + pageReserveValue.textContent = '50'; + this.elements.webglPageReserveValue = pageReserveValue; + pageReserveContainer.appendChild(pageReserveValue); - this.elements.webglBookProgress = createUIElement('input', { + this.elements.webglPageReserve = createUIElement('input', { type: 'range', min: 0, - max: 100, + max: 500, step: 1, value: 50, - 'data-pref-bind': 'webgl.bookProgress', - 'data-pref-transform': 'range:0,1' - }, null, bookProgressContainer); - this.elements.webglBookProgress.addEventListener('input', () => this.updateWebGLDisplays()); - webglSection.appendChild(bookProgressContainer); + 'data-pref-bind': 'webgl.pageReserve', + 'data-pref-transform': 'integer:0,500' + }, null, pageReserveContainer); + this.elements.webglPageReserve.addEventListener('input', () => this.updateWebGLDisplays()); + if (!this.hasFixedPageReserve()) { + webglSection.appendChild(pageReserveContainer); + } body.appendChild(webglSection); @@ -1246,8 +1272,14 @@ class OptionsUIModule extends BaseModule { if (this.elements.webglBookSize && this.elements.webglBookSizeValue) { this.elements.webglBookSizeValue.textContent = String(this.elements.webglBookSize.value); } - if (this.elements.webglBookProgress && this.elements.webglBookProgressValue) { - this.elements.webglBookProgressValue.textContent = `${this.elements.webglBookProgress.value}%`; + if (this.elements.webglPageReserve && this.elements.webglPageReserveValue) { + const bookSize = Number(this.elements.webglBookSize?.value || this.getPreference('webgl', 'bookPageCount', 300)); + const maxReserve = Number.isFinite(bookSize) ? Math.max(0, Math.floor(bookSize)) : 500; + this.elements.webglPageReserve.max = String(maxReserve); + if (Number(this.elements.webglPageReserve.value) > maxReserve) { + this.elements.webglPageReserve.value = String(maxReserve); + } + this.elements.webglPageReserveValue.textContent = String(this.elements.webglPageReserve.value); } } } diff --git a/public/js/persistence-manager-module.js b/public/js/persistence-manager-module.js index 47ed0e1..324d010 100644 --- a/public/js/persistence-manager-module.js +++ b/public/js/persistence-manager-module.js @@ -71,7 +71,8 @@ class PersistenceManagerModule extends BaseModule { webgl: { mode: null, bookPageCount: 300, - bookProgress: 0.5 + bookProgress: 0, + pageReserve: 50 } }; diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index a0a183c..db9eff1 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -187,7 +187,8 @@ const book = new THREE.Group(); scene.add(book); const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0'), 0, 1); let readingProgress = Number.isFinite(initialReadingProgress) ? initialReadingProgress : 0; -let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? appInitialState.pageCount ?? '240'); +let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? appInitialState.pageCount ?? '300'); +let pageReserve = clampPageReserve(appInitialState.pageReserve ?? 50, bookPageCount); let currentProceduralBookModel = null; const progressInput = document.getElementById('progress_control'); const progressValue = document.getElementById('progress_value'); @@ -197,6 +198,12 @@ const fastBackwardButton = document.getElementById('fast_backward'); const backwardButton = document.getElementById('flip_backward'); const forwardButton = document.getElementById('flip_forward'); const fastForwardButton = document.getElementById('fast_forward'); +let bottomNavigation = null; +let bookPaginationState = { + spreadIndex: 0, + spreadCount: 1, + writtenPageLimit: 0 +}; const normalFlipDuration = 900; const fastFlipDuration = 520; const fastFlipCount = 10; @@ -399,6 +406,14 @@ const materials = { envMapIntensity: 0 }) }; +materials.flipPageBackSurface = materials.flipPageSurface.clone(); +materials.flipPageBackSurface.map = getBlankPageTexture(); +materials.flipPageBackSurface.side = THREE.DoubleSide; +materials.flipPageEdge = materials.pageSurface.clone(); +materials.flipPageEdge.map = paperTextures.edge; +materials.flipPageEdge.normalMap = paperTextures.normal; +materials.flipPageEdge.roughnessMap = paperTextures.roughness; +materials.flipPageEdge.side = THREE.DoubleSide; materials.leftPage.userData.bookPageReveal = { side: 'left' }; @@ -421,6 +436,8 @@ configureBookShadowReceiver(materials.pageBlock, 0.18); configureBookShadowReceiver(materials.pageEdge, 0.16); configureBookShadowReceiver(materials.pageSurface, 0.11); configureBookShadowReceiver(materials.flipPageSurface, 0.11); +configureBookShadowReceiver(materials.flipPageBackSurface, 0.11); +configureBookShadowReceiver(materials.flipPageEdge, 0.09); configureBookShadowReceiver(materials.leftPage, 0.08); configureBookShadowReceiver(materials.rightPage, 0.08); configureBookShadowReceiver(materials.spineCloth, 0.48); @@ -498,6 +515,23 @@ window.BookLabDebug = { setBookPageCount(value); return bookPageCount; }, + setPageReserve(value) { + setPageReserve(value); + return pageReserve; + }, + getBookState() { + return { + pageCount: bookPageCount, + pageReserve, + progress: readingProgress, + pagePosition: getCurrentPagePosition(), + spreadIndex: bookPaginationState.spreadIndex, + writtenPageLimit: bookPaginationState.writtenPageLimit + }; + }, + navigateToPagePosition(value) { + return navigateToPagePosition(value); + }, redrawPageTextures() { window.BookTextureRenderer?.publishSpread?.(); return true; @@ -533,6 +567,31 @@ document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => { document.addEventListener('webgl-book:reveal-committed', (event) => { handleRevealCommittedForPageFlip(event.detail || {}); }); +document.addEventListener('book-pagination:spread-updated', (event) => { + const detail = event.detail || {}; + const previousPageCount = bookPageCount; + bookPaginationState = { + spreadIndex: Math.max(0, Number(detail.spreadIndex || 0)), + spreadCount: Math.max(1, Number(detail.spreadCount || 1)), + writtenPageLimit: Math.max(0, Number(detail.writtenPageLimit || 0)) + }; + growBookIfWritableLimitReached(); + if (bookPageCount !== previousPageCount) { + buildBook(); + notifyBookPageCountChanged(); + window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount); + } + syncBookControls(); +}); +document.addEventListener('webgl-book:page-reserve-directive', (event) => { + const detail = event.detail || {}; + const value = Number(detail.value); + if (!Number.isFinite(value)) return; + const nextReserve = detail.unit === 'percent' + ? Math.round(bookPageCount * (value / 100)) + : Math.round(value); + setPageReserve(nextReserve); +}); document.addEventListener('ui:command', (event) => { if (event.detail?.type === 'continue' && pendingRightPageFlip) { pendingRightPageFlip = false; @@ -1640,16 +1699,68 @@ function setReadingProgress(value) { window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress); } +function clampPageReserve(value, pageCount = bookPageCount) { + const parsed = Math.round(Number(value)); + if (!Number.isFinite(parsed)) return 50; + return THREE.MathUtils.clamp(parsed, 0, Math.max(0, Math.floor(Number(pageCount) || 0))); +} + +function pageToSpreadIndex(pagePosition) { + const page = Math.max(0, Math.round(Number(pagePosition || 0))); + return page <= 0 ? 0 : Math.ceil(page / 2); +} + +function spreadIndexToPagePosition(spreadIndex) { + const spread = Math.max(0, Math.round(Number(spreadIndex || 0))); + return spread <= 0 ? 0 : Math.max(1, spread * 2 - 1); +} + +function getWritablePageLimit() { + return Math.max(0, bookPageCount - pageReserve); +} + +function getCurrentPagePosition() { + return spreadIndexToPagePosition(bookPaginationState.spreadIndex); +} + +function syncReadingProgressToCurrentPage() { + const nextProgress = THREE.MathUtils.clamp(getCurrentPagePosition() / Math.max(1, bookPageCount), 0, 1); + if (Math.abs(nextProgress - readingProgress) < 0.0001) return; + readingProgress = nextProgress; + buildBook(); + notifyBookPageCountChanged(); + window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress); +} + +function growBookIfWritableLimitReached() { + const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0); + while (writtenLimit >= getWritablePageLimit() && bookPageCount < PROCEDURAL_BOOK.PAGE_COUNT_MAX) { + bookPageCount = snapProceduralPageCount(bookPageCount + PROCEDURAL_BOOK.PAGE_COUNT_STEP); + } +} + function setBookPageCount(value) { const nextPageCount = snapProceduralPageCount(value); if (!Number.isFinite(nextPageCount)) return; - bookPageCount = nextPageCount; + bookPageCount = Math.max(nextPageCount, bookPageCount); + pageReserve = clampPageReserve(pageReserve, bookPageCount); + growBookIfWritableLimitReached(); buildBook(); notifyBookPageCountChanged(); syncBookControls(); window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount); } +function setPageReserve(value) { + pageReserve = clampPageReserve(value, bookPageCount); + growBookIfWritableLimitReached(); + buildBook(); + notifyBookPageCountChanged(); + syncBookControls(); + window.WebGLBookPreferenceBridge?.updatePageReserve?.(pageReserve); + window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount); +} + function notifyBookPageCountChanged() { document.dispatchEvent(new CustomEvent('webgl-book:page-count-changed', { detail: { @@ -1664,15 +1775,18 @@ function stepReadingProgress(pageDelta) { } function installBookControls() { - if (!progressInput || !pageCountInput) return; - progressInput.value = readingProgress.toFixed(3); - pageCountInput.min = String(PROCEDURAL_BOOK.PAGE_COUNT_MIN); - pageCountInput.max = String(PROCEDURAL_BOOK.PAGE_COUNT_MAX); - pageCountInput.step = String(PROCEDURAL_BOOK.PAGE_COUNT_STEP); - pageCountInput.value = String(bookPageCount); - - progressInput.addEventListener('input', () => setReadingProgress(progressInput.value)); - pageCountInput.addEventListener('input', () => setBookPageCount(pageCountInput.value)); + ensureBottomNavigation(); + if (progressInput) { + progressInput.value = readingProgress.toFixed(3); + progressInput.addEventListener('input', () => setReadingProgress(progressInput.value)); + } + if (pageCountInput) { + pageCountInput.min = String(PROCEDURAL_BOOK.PAGE_COUNT_MIN); + pageCountInput.max = String(PROCEDURAL_BOOK.PAGE_COUNT_MAX); + pageCountInput.step = String(PROCEDURAL_BOOK.PAGE_COUNT_STEP); + pageCountInput.value = String(bookPageCount); + pageCountInput.addEventListener('input', () => setBookPageCount(pageCountInput.value)); + } backwardButton?.addEventListener('click', () => startPageFlip(-1)); forwardButton?.addEventListener('click', () => startPageFlip(1)); fastBackwardButton?.addEventListener('click', () => startFastPageFlip(-1)); @@ -1680,6 +1794,93 @@ function installBookControls() { syncBookControls(); } +function ensureBottomNavigation() { + if (bottomNavigation) return bottomNavigation; + const root = document.createElement('nav'); + root.id = 'webgl_book_navigation'; + root.setAttribute('aria-label', appInitialState.t?.('webgl.bookControls') || 'Book controls'); + + const makeButton = (id, label, icon) => { + const button = document.createElement('button'); + button.id = id; + button.type = 'button'; + button.className = 'webgl-book-nav-button'; + button.setAttribute('aria-label', label); + button.title = label; + button.textContent = icon; + root.appendChild(button); + return button; + }; + + const startButton = makeButton('webgl_book_nav_start', appInitialState.t?.('webgl.returnToBeginning') || 'Return to beginning', '⏮'); + const backButton = makeButton('webgl_book_nav_back', appInitialState.t?.('webgl.backward') || 'Backward', '◀'); + const sliderWrap = document.createElement('div'); + sliderWrap.className = 'webgl-book-nav-slider-wrap'; + const pageLabel = document.createElement('output'); + pageLabel.id = 'webgl_book_nav_page_label'; + pageLabel.className = 'webgl-book-nav-page-label'; + pageLabel.textContent = '0'; + const slider = document.createElement('input'); + slider.id = 'webgl_book_nav_position'; + slider.type = 'range'; + slider.min = '0'; + slider.step = '1'; + slider.value = '0'; + sliderWrap.appendChild(slider); + sliderWrap.appendChild(pageLabel); + root.appendChild(sliderWrap); + const forwardButton = makeButton('webgl_book_nav_forward', appInitialState.t?.('webgl.forward') || 'Forward', '▶'); + const endButton = makeButton('webgl_book_nav_end', appInitialState.t?.('webgl.goToEnd') || 'Go to end', '⏭'); + + startButton.addEventListener('click', () => navigateToPagePosition(0)); + backButton.addEventListener('click', () => navigateByPageDelta(-1)); + forwardButton.addEventListener('click', () => navigateByPageDelta(1)); + endButton.addEventListener('click', () => navigateToPagePosition(bookPaginationState.writtenPageLimit)); + slider.addEventListener('input', () => { + const requested = Number(slider.value); + const clamped = Math.min(requested, Math.max(0, bookPaginationState.writtenPageLimit || 0), getWritablePageLimit()); + if (requested !== clamped) slider.value = String(clamped); + pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${clamped}`; + }); + slider.addEventListener('change', () => navigateToPagePosition(Number(slider.value))); + + document.body.appendChild(root); + bottomNavigation = { + root, + startButton, + backButton, + slider, + pageLabel, + forwardButton, + endButton + }; + return bottomNavigation; +} + +function navigateByPageDelta(delta) { + const current = getCurrentPagePosition(); + const next = Math.max(0, current + Math.sign(Number(delta || 0))); + return navigateToPagePosition(next); +} + +function navigateToPagePosition(pagePosition) { + const writableLimit = getWritablePageLimit(); + const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0); + const targetPage = THREE.MathUtils.clamp(Math.round(Number(pagePosition || 0)), 0, Math.min(writableLimit, writtenLimit)); + const currentPage = getCurrentPagePosition(); + if (targetPage === currentPage) { + syncBookControls(); + return false; + } + const targetSpread = pageToSpreadIndex(targetPage); + const currentSpread = bookPaginationState.spreadIndex; + const spreadDelta = targetSpread - currentSpread; + if (Math.abs(spreadDelta) === 1) { + return startPageFlip(Math.sign(spreadDelta), { targetSpread }); + } + return startFastPageFlip(Math.sign(spreadDelta), { targetSpread, skippedSpreads: Math.abs(spreadDelta) }); +} + function syncBookControls() { const busy = activeFlips.length > 0; if (progressInput) progressInput.value = readingProgress.toFixed(3); @@ -1690,6 +1891,28 @@ function syncBookControls() { if (fastBackwardButton) fastBackwardButton.disabled = busy || !canPageFlip(-1); if (forwardButton) forwardButton.disabled = busy || !canPageFlip(1); if (fastForwardButton) fastForwardButton.disabled = busy || !canPageFlip(1); + syncBottomNavigation(); +} + +function syncBottomNavigation() { + if (!bottomNavigation) return; + const currentPage = getCurrentPagePosition(); + const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0); + const writableLimit = getWritablePageLimit(); + const navigableLimit = Math.min(writtenLimit, writableLimit); + const reservedStart = Math.max(0, writableLimit); + bottomNavigation.slider.max = String(Math.max(0, bookPageCount)); + bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit)); + bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${Math.min(currentPage, navigableLimit)}`; + bottomNavigation.root.style.setProperty('--book-nav-position', `${bookPageCount > 0 ? currentPage / bookPageCount : 0}`); + bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? writtenLimit / bookPageCount : 0}`); + bottomNavigation.root.style.setProperty('--book-nav-reserve-start', `${bookPageCount > 0 ? reservedStart / bookPageCount : 1}`); + bottomNavigation.root.dataset.bookSize = String(bookPageCount); + bottomNavigation.root.dataset.pageReserve = String(pageReserve); + bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentPage <= 0; + bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentPage <= 0; + bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit; + bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit; } function handlePageCanvases(event) { @@ -2137,12 +2360,13 @@ function textureHitPageSide(hit) { return null; } -function startPageFlip(direction) { +function startPageFlip(direction, options = {}) { if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false; pendingRightPageFlip = false; delete document.documentElement.dataset.webglPendingPageFlip; const flip = createPageFlip(direction, performance.now(), normalFlipDuration); if (!flip) return false; + flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null; prepareStaticPageForFlip(flip); activeFlips.push(flip); syncBookControls(); @@ -2150,20 +2374,23 @@ function startPageFlip(direction) { return true; } -function startFastPageFlip(direction) { +function startFastPageFlip(direction, options = {}) { if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false; const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration); if (!firstFlip) return false; prepareStaticPageForFlip(firstFlip); const startTime = firstFlip.startTime; const interval = fastFlipDuration / fastFlipOverlap; - for (let index = 0; index < fastFlipCount; index += 1) { + const skippedSpreads = Math.max(2, Number(options.skippedSpreads || fastFlipCount)); + const visibleFlipCount = THREE.MathUtils.clamp(Math.round(skippedSpreads), 2, 5); + for (let index = 0; index < visibleFlipCount; index += 1) { activeFlips.push({ ...firstFlip, mesh: null, startTime: startTime + index * interval, pageOffset: index * 0.002, - commitBundleOnFinish: index === fastFlipCount - 1, + targetSpread: Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null, + commitBundleOnFinish: index === visibleFlipCount - 1, countAsPending: false }); } @@ -2195,12 +2422,19 @@ function createPageFlip(direction, startTime, duration) { function prepareStaticPageForFlip(flip) { if (!flip) return; const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage; + const oppositeMaterial = flip.sourcePageSide === 'left' ? materials.rightPage : materials.leftPage; const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture); + const backTexture = oppositeMaterial?.map || getBlankPageTexture(); materials.flipPageSurface.map = sourceTexture; + materials.flipPageBackSurface.map = backTexture; materials.flipPageSurface.normalMap = materials.pageSurface.normalMap; + materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap; + materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap; materials.flipPageSurface.needsUpdate = true; + materials.flipPageBackSurface.needsUpdate = true; flip.sourceTexture = sourceTexture; + flip.backTexture = backTexture; if (flip.direction > 0) { const blankTexture = getBlankPageTexture(); if (blankTexture && materials.rightPage.map !== blankTexture) { @@ -2208,13 +2442,22 @@ function prepareStaticPageForFlip(flip) { materials.rightPage.map = blankTexture; materials.rightPage.needsUpdate = true; } + } else if (flip.direction < 0) { + const blankTexture = getBlankPageTexture(); + if (blankTexture && materials.leftPage.map !== blankTexture) { + clearPageReveal('left', 'page-flip-start'); + materials.leftPage.map = blankTexture; + materials.leftPage.needsUpdate = true; + } } } function canPageFlip(direction) { if (!currentProceduralBookModel) return false; - if (direction > 0) return readingProgress < 1; - return readingProgress > 0; + const currentPage = getCurrentPagePosition(); + const maxNavigablePage = Math.min(Math.max(0, bookPaginationState.writtenPageLimit || 0), getWritablePageLimit()); + if (direction > 0) return currentPage < maxNavigablePage; + return currentPage > 0; } function handleRevealCommittedForPageFlip(detail = {}) { @@ -2268,10 +2511,14 @@ function updateActiveFlips(now) { setActivePageGeometry(flip, surface); if (!flip.spreadAdvanced && t >= 0.82) { flip.spreadAdvanced = true; + const targetSpread = Number.isFinite(Number(flip.targetSpread)) + ? Math.max(0, Math.round(Number(flip.targetSpread))) + : null; document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', { detail: { direction: flip.direction, - sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left') + sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left'), + targetSpread } })); } @@ -2395,7 +2642,11 @@ function lineYAtX(points, x) { function setActivePageGeometry(flip, surface) { const geometry = createFlippingPageGeometry(surface); if (!flip.mesh) { - flip.mesh = new THREE.Mesh(geometry, materials.flipPageSurface); + flip.mesh = new THREE.Mesh(geometry, [ + materials.flipPageSurface, + materials.flipPageBackSurface, + materials.flipPageEdge + ]); flip.mesh.castShadow = false; flip.mesh.receiveShadow = false; flip.mesh.userData.bookPart = 'flippingPage'; @@ -2411,9 +2662,12 @@ function createFlippingPageGeometry(surface) { const positions = []; const uvs = []; const indices = []; + const topIndices = []; + const bottomIndices = []; + const wallIndices = []; const topGrid = []; const bottomGrid = []; - const pageThickness = 0.006; + const pageThickness = Math.max(0.0008, Number(PROCEDURAL_BOOK.SHEET_THICKNESS_MODEL || 0.001)); const widthSegments = surface.length - 1; const depthSegments = surface[0].length - 1; const push = (point, yOffset, u, v) => { @@ -2445,8 +2699,8 @@ function createFlippingPageGeometry(surface) { const bottomB = bottomGrid[index + 1][zIndex]; const bottomC = bottomGrid[index][zIndex + 1]; const bottomD = bottomGrid[index + 1][zIndex + 1]; - indices.push(a, c, b, b, c, d); - indices.push(bottomA, bottomB, bottomC, bottomB, bottomD, bottomC); + topIndices.push(a, c, b, b, c, d); + bottomIndices.push(bottomA, bottomB, bottomC, bottomB, bottomD, bottomC); } } for (let index = 0; index < widthSegments; index += 1) { @@ -2458,15 +2712,21 @@ function createFlippingPageGeometry(surface) { addWall(topGrid[widthSegments][zIndex], topGrid[widthSegments][zIndex + 1], bottomGrid[widthSegments][zIndex], bottomGrid[widthSegments][zIndex + 1]); } + indices.push(...topIndices, ...bottomIndices, ...wallIndices); + 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.clearGroups(); + geometry.addGroup(0, topIndices.length, 0); + geometry.addGroup(topIndices.length, bottomIndices.length, 1); + geometry.addGroup(topIndices.length + bottomIndices.length, wallIndices.length, 2); geometry.computeVertexNormals(); return geometry; function addWall(topA, topB, bottomA, bottomB) { - indices.push(topA, bottomA, topB, topB, bottomA, bottomB); + wallIndices.push(topA, bottomA, topB, topB, bottomA, bottomB); } } @@ -2479,8 +2739,15 @@ function finishActiveFlip(flip) { sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left') } })); + if (activeFlips.length === 0 && Number.isFinite(Number(flip.targetSpread))) { + syncReadingProgressToCurrentPage(); + } if (flip.commitBundleOnFinish) { - shiftReadingProgressByBundle(flip.direction); + if (Number.isFinite(Number(flip.targetSpread))) { + syncBookControls(); + } else { + shiftReadingProgressByBundle(flip.direction); + } return; } if (!flip.countAsPending) { diff --git a/public/js/webgl-book-scene-module.js b/public/js/webgl-book-scene-module.js index ca88a28..68843c3 100644 --- a/public/js/webgl-book-scene-module.js +++ b/public/js/webgl-book-scene-module.js @@ -6,13 +6,15 @@ import { BaseModule } from './base-module.js'; const DEFAULT_BOOK_PAGE_COUNT = 300; const DEFAULT_BOOK_PROGRESS = 0; +const DEFAULT_PAGE_RESERVE = 50; class WebGLBookSceneModule extends BaseModule { constructor() { super('webgl-book-scene', 'WebGL Book Scene'); - this.dependencies = ['persistence-manager', 'localization', 'book-texture-renderer']; + this.dependencies = ['persistence-manager', 'localization', 'game-config', 'book-texture-renderer']; this.persistenceManager = null; this.localization = null; + this.gameConfig = null; this.mode = '2d'; this.is3dSupported = false; this.labImportPromise = null; @@ -29,6 +31,9 @@ class WebGLBookSceneModule extends BaseModule { 'ensureShell', 'initializeScene', 'detectWebGLSupport', + 'getMetadataNumber', + 'getFixedBookPageCount', + 'getFixedPageReserve', 'createLabHost', 'installPreferenceBridge', 'installTextureEventBridge', @@ -49,6 +54,7 @@ class WebGLBookSceneModule extends BaseModule { async initialize() { this.persistenceManager = this.getModule('persistence-manager'); this.localization = this.getModule('localization'); + this.gameConfig = this.getModule('game-config'); this.reportProgress(15, 'Checking WebGL support'); this.is3dSupported = this.detectWebGLSupport(); @@ -76,14 +82,42 @@ class WebGLBookSceneModule extends BaseModule { initializeScenePreferences() { if (!this.persistenceManager) return; + const fixedPageCount = this.getFixedBookPageCount(); + const fixedPageReserve = this.getFixedPageReserve(); const scenePrefs = this.persistenceManager.getPreference('webgl', 'bookPageCount', null); - if (!Number.isFinite(Number(scenePrefs))) { + if (Number.isFinite(fixedPageCount)) { + this.persistenceManager.updatePreference('webgl', 'bookPageCount', fixedPageCount); + } else if (!Number.isFinite(Number(scenePrefs))) { this.persistenceManager.updatePreference('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT); } const progress = this.persistenceManager.getPreference('webgl', 'bookProgress', null); if (!Number.isFinite(Number(progress))) { this.persistenceManager.updatePreference('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS); } + const pageReserve = this.persistenceManager.getPreference('webgl', 'pageReserve', null); + if (Number.isFinite(fixedPageReserve)) { + this.persistenceManager.updatePreference('webgl', 'pageReserve', fixedPageReserve); + } else if (!Number.isFinite(Number(pageReserve))) { + this.persistenceManager.updatePreference('webgl', 'pageReserve', DEFAULT_PAGE_RESERVE); + } + } + + getMetadataNumber(keys = []) { + const metadata = this.gameConfig?.getMetadata?.() || {}; + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(metadata, key)) continue; + const value = Number(metadata[key]); + if (Number.isFinite(value)) return value; + } + return null; + } + + getFixedBookPageCount() { + return this.getMetadataNumber(['bookPageCount', 'defaultBookPageCount', 'webglBookPageCount']); + } + + getFixedPageReserve() { + return this.getMetadataNumber(['pageReserve', 'defaultPageReserve', 'webglPageReserve']); } resolveInitialMode() { @@ -166,10 +200,15 @@ class WebGLBookSceneModule extends BaseModule { const pageCount = this.persistenceManager?.getPreference?.('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT) ?? DEFAULT_BOOK_PAGE_COUNT; const progress = this.persistenceManager?.getPreference?.('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS) ?? DEFAULT_BOOK_PROGRESS; + const pageReserve = this.persistenceManager?.getPreference?.('webgl', 'pageReserve', DEFAULT_PAGE_RESERVE) ?? DEFAULT_PAGE_RESERVE; window.WebGLBookInitialState = { appMode: true, pageCount, progress, + pageReserve, + fixedPageCount: this.getFixedBookPageCount(), + fixedPageReserve: this.getFixedPageReserve(), + t: (key, params = {}) => this.t(key, params), reportProgress: (percent, message) => { this.reportProgress(percent, message); } @@ -283,6 +322,34 @@ class WebGLBookSceneModule extends BaseModule { this.preferenceWriteGuard = true; this.persistenceManager?.updatePreference?.('webgl', 'bookPageCount', value); this.preferenceWriteGuard = false; + }, + updatePageReserve: (value) => { + if (this.preferenceWriteGuard) return; + this.preferenceWriteGuard = true; + this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', value); + this.preferenceWriteGuard = false; + }, + getBookState: () => window.BookLabDebug?.getBookState?.() || { + pageCount: this.persistenceManager?.getPreference?.('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT) ?? DEFAULT_BOOK_PAGE_COUNT, + pageReserve: this.persistenceManager?.getPreference?.('webgl', 'pageReserve', DEFAULT_PAGE_RESERVE) ?? DEFAULT_PAGE_RESERVE, + progress: this.persistenceManager?.getPreference?.('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS) ?? DEFAULT_BOOK_PROGRESS + }, + applyBookState: (state = {}) => { + const pageCount = Number(state.pageCount); + const pageReserve = Number(state.pageReserve); + const progress = Number(state.progress); + if (Number.isFinite(pageCount)) { + this.persistenceManager?.updatePreference?.('webgl', 'bookPageCount', pageCount); + window.BookLabDebug?.setBookPageCount?.(pageCount); + } + if (Number.isFinite(pageReserve)) { + this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', pageReserve); + window.BookLabDebug?.setPageReserve?.(pageReserve); + } + if (Number.isFinite(progress)) { + this.persistenceManager?.updatePreference?.('webgl', 'bookProgress', progress); + window.BookLabDebug?.setReadingProgress?.(progress); + } } }; } @@ -456,6 +523,8 @@ class WebGLBookSceneModule extends BaseModule { window.BookLabDebug?.setReadingProgress?.(value); } else if (key === 'bookPageCount' && !this.preferenceWriteGuard) { window.BookLabDebug?.setBookPageCount?.(value); + } else if (key === 'pageReserve' && !this.preferenceWriteGuard) { + window.BookLabDebug?.setPageReserve?.(value); } } diff --git a/public/locales/de_DE.json b/public/locales/de_DE.json index 8f23ef2..7833440 100644 --- a/public/locales/de_DE.json +++ b/public/locales/de_DE.json @@ -35,6 +35,7 @@ "options.displayMode2d": "2D", "options.bookSize": "Buchgröße", "options.bookProgress": "Seitenstapel", + "options.pageReserve": "Seitenreserve", "options.volume": "Lautstärke", "options.masterVolume": "Gesamtlautstärke", "options.speechVolume": "Sprachlautstärke", @@ -66,6 +67,9 @@ "webgl.status2d": "2D-Szene", "webgl.bookSize": "Seiten", "webgl.pageStackProgress": "Fortschritt", + "webgl.page": "Seite", + "webgl.returnToBeginning": "Zum Anfang", + "webgl.goToEnd": "Zum Ende", "webgl.fastBackward": "Schnell zurück", "webgl.backward": "Zurück", "webgl.forward": "Vorwärts", diff --git a/public/locales/en_US.json b/public/locales/en_US.json index 1f1807d..8b75847 100644 --- a/public/locales/en_US.json +++ b/public/locales/en_US.json @@ -35,6 +35,7 @@ "options.displayMode2d": "2D", "options.bookSize": "Book Size", "options.bookProgress": "Page Stack", + "options.pageReserve": "Page Reserve", "options.volume": "Volume", "options.masterVolume": "Master Volume", "options.speechVolume": "Speech Volume", @@ -66,6 +67,9 @@ "webgl.status2d": "2D scene", "webgl.bookSize": "Pages", "webgl.pageStackProgress": "Progress", + "webgl.page": "Page", + "webgl.returnToBeginning": "Return to beginning", + "webgl.goToEnd": "Go to end", "webgl.fastBackward": "Fast backward", "webgl.backward": "Backward", "webgl.forward": "Forward", diff --git a/src/config/game-config.ts b/src/config/game-config.ts index 8b4311e..29ea883 100644 --- a/src/config/game-config.ts +++ b/src/config/game-config.ts @@ -10,6 +10,8 @@ export interface GameMetadata { version?: string; copyright?: string; language?: string; + bookPageCount?: number; + pageReserve?: number; } export interface GamePaths {