Implement WebGL page reserve navigation

This commit is contained in:
2026-06-08 10:25:54 +02:00
parent 3e28d7db23
commit efd1e6cfff
13 changed files with 571 additions and 52 deletions
+2
View File
@@ -6,6 +6,8 @@ export interface GameMetadata {
version?: string;
copyright?: string;
language?: string;
bookPageCount?: number;
pageReserve?: number;
}
export interface GamePaths {
mainGameFile: string;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":"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"}
+65
View File
@@ -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;
+30 -1
View File
@@ -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
}
+19 -1
View File
@@ -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
+28 -2
View File
@@ -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(),
+52 -20
View File
@@ -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);
}
}
}
+2 -1
View File
@@ -71,7 +71,8 @@ class PersistenceManagerModule extends BaseModule {
webgl: {
mode: null,
bookPageCount: 300,
bookProgress: 0.5
bookProgress: 0,
pageReserve: 50
}
};
+291 -24
View File
@@ -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) {
+71 -2
View File
@@ -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);
}
}
+4
View File
@@ -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",
+4
View File
@@ -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",
+2
View File
@@ -10,6 +10,8 @@ export interface GameMetadata {
version?: string;
copyright?: string;
language?: string;
bookPageCount?: number;
pageReserve?: number;
}
export interface GamePaths {