Implement WebGL page reserve navigation
This commit is contained in:
Vendored
+2
@@ -6,6 +6,8 @@ export interface GameMetadata {
|
||||
version?: string;
|
||||
copyright?: string;
|
||||
language?: string;
|
||||
bookPageCount?: number;
|
||||
pageReserve?: number;
|
||||
}
|
||||
export interface GamePaths {
|
||||
mainGameFile: string;
|
||||
|
||||
Vendored
+1
-1
@@ -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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"}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,8 @@ class PersistenceManagerModule extends BaseModule {
|
||||
webgl: {
|
||||
mode: null,
|
||||
bookPageCount: 300,
|
||||
bookProgress: 0.5
|
||||
bookProgress: 0,
|
||||
pageReserve: 50
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+291
-24
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface GameMetadata {
|
||||
version?: string;
|
||||
copyright?: string;
|
||||
language?: string;
|
||||
bookPageCount?: number;
|
||||
pageReserve?: number;
|
||||
}
|
||||
|
||||
export interface GamePaths {
|
||||
|
||||
Reference in New Issue
Block a user