Implement WebGL book spread flip groundwork
This commit is contained in:
@@ -13,6 +13,7 @@ class BookPaginationModule extends BaseModule {
|
||||
this.storyHistory = null;
|
||||
this.metrics = null;
|
||||
this.spreads = [];
|
||||
this.pages = [];
|
||||
this.currentSpreadIndex = 0;
|
||||
this.refreshToken = 0;
|
||||
this.latestBlockId = 0;
|
||||
@@ -23,6 +24,20 @@ class BookPaginationModule extends BaseModule {
|
||||
'refreshFromHistory',
|
||||
'preparePendingBlock',
|
||||
'buildSpreads',
|
||||
'buildPages',
|
||||
'buildSpreadsFromPages',
|
||||
'createBlankPage',
|
||||
'createTitlePage',
|
||||
'ensurePage',
|
||||
'nextContentPageNumber',
|
||||
'advancePage',
|
||||
'advanceToNextRightPage',
|
||||
'shouldAdvanceBeforeTextLine',
|
||||
'getLinesPerPage',
|
||||
'layoutImageBlock',
|
||||
'createImageRecord',
|
||||
'persistPaginationMetrics',
|
||||
'collectPaginationMetrics',
|
||||
'layoutTextBlock',
|
||||
'getDropCapText',
|
||||
'extractDropCapText',
|
||||
@@ -59,6 +74,10 @@ class BookPaginationModule extends BaseModule {
|
||||
this.addEventListener(document, 'book-pagination:set-spread', (event) => {
|
||||
this.setCurrentSpread(event.detail?.spreadIndex);
|
||||
});
|
||||
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);
|
||||
});
|
||||
this.reportProgress(100, 'Book pagination ready');
|
||||
return true;
|
||||
}
|
||||
@@ -78,9 +97,11 @@ class BookPaginationModule extends BaseModule {
|
||||
Number(detail.latestRenderedBlockId || detail.latestBlockId || this.storyHistory?.latestRenderedBlockId || (this.storyHistory?.nextBlockId || 1) - 1)
|
||||
);
|
||||
if (!gameId || latestBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') {
|
||||
this.spreads = [];
|
||||
this.pages = this.buildPages([]);
|
||||
this.spreads = this.buildSpreadsFromPages(this.pages);
|
||||
this.latestBlockId = 0;
|
||||
this.latestRenderedBlockId = 0;
|
||||
this.currentSpreadIndex = 0;
|
||||
this.publish();
|
||||
return;
|
||||
}
|
||||
@@ -92,7 +113,9 @@ class BookPaginationModule extends BaseModule {
|
||||
0,
|
||||
Number(detail.latestRenderedBlockId || this.storyHistory?.latestRenderedBlockId || 0)
|
||||
);
|
||||
this.spreads = this.buildSpreads(blocks);
|
||||
this.pages = this.buildPages(blocks);
|
||||
this.spreads = this.buildSpreadsFromPages(this.pages);
|
||||
this.persistPaginationMetrics(this.pages);
|
||||
this.currentSpreadIndex = Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
|
||||
this.publish();
|
||||
}
|
||||
@@ -126,7 +149,8 @@ class BookPaginationModule extends BaseModule {
|
||||
gameId
|
||||
}
|
||||
};
|
||||
const preparedSpreads = this.buildSpreads([...historyBlocks, normalizedBlock]);
|
||||
const preparedPages = this.buildPages([...historyBlocks, normalizedBlock]);
|
||||
const preparedSpreads = this.buildSpreadsFromPages(preparedPages);
|
||||
const targetSpread = preparedSpreads.find(spread => ['left', 'right'].some(side => {
|
||||
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
||||
return lines.some(line => Number(line?.blockId || 0) === pendingBlockId);
|
||||
@@ -134,6 +158,7 @@ class BookPaginationModule extends BaseModule {
|
||||
if (options.activate !== false) {
|
||||
this.latestBlockId = pendingBlockId;
|
||||
this.latestRenderedBlockId = latestRenderedBlockId;
|
||||
this.pages = preparedPages;
|
||||
this.spreads = preparedSpreads;
|
||||
this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex));
|
||||
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
|
||||
@@ -153,33 +178,74 @@ class BookPaginationModule extends BaseModule {
|
||||
}
|
||||
|
||||
buildSpreads(blocks = []) {
|
||||
const spreads = [];
|
||||
let cursorLine = 0;
|
||||
this.pages = this.buildPages(blocks);
|
||||
return this.buildSpreadsFromPages(this.pages);
|
||||
}
|
||||
|
||||
buildPages(blocks = []) {
|
||||
const pages = [
|
||||
this.createBlankPage(0, { section: 'frontmatter' }),
|
||||
this.createTitlePage(1),
|
||||
this.createBlankPage(2, { section: 'frontmatter' })
|
||||
];
|
||||
let pageIndex = 3;
|
||||
let pageLine = 0;
|
||||
let contentPageNumber = 1;
|
||||
const source = Array.isArray(blocks) ? blocks : [];
|
||||
const linesPerPage = this.getLinesPerPage();
|
||||
|
||||
source.forEach((block) => {
|
||||
const type = block?.kind || block?.type || 'paragraph';
|
||||
if (type === 'image') {
|
||||
({ pageIndex, pageLine, contentPageNumber } = this.layoutImageBlock(
|
||||
pages,
|
||||
block,
|
||||
pageIndex,
|
||||
pageLine,
|
||||
contentPageNumber,
|
||||
linesPerPage
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (!['paragraph', 'heading'].includes(type)) return;
|
||||
|
||||
const layout = this.layoutTextBlock(block, type);
|
||||
if (!layout?.lines?.length) return;
|
||||
let blockWordCursor = 0;
|
||||
cursorLine += layout.topSpaceLines;
|
||||
const isHeading = type === 'heading' || layout.role === 'chapter-heading' || layout.role === 'section-heading';
|
||||
if (isHeading) {
|
||||
({ pageIndex, pageLine, contentPageNumber } = this.advanceToNextRightPage(pages, pageIndex, pageLine, contentPageNumber));
|
||||
} else if (pageLine + layout.topSpaceLines >= linesPerPage) {
|
||||
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
|
||||
}
|
||||
pageLine += layout.topSpaceLines;
|
||||
|
||||
layout.lines.forEach((line, layoutLineIndex) => {
|
||||
const geometry = this.getLineGeometry(cursorLine);
|
||||
const lineWordCount = this.countLineWords(line);
|
||||
if (!spreads[geometry.spreadIndex]) {
|
||||
spreads[geometry.spreadIndex] = { index: geometry.spreadIndex, left: [], right: [] };
|
||||
if (this.shouldAdvanceBeforeTextLine({
|
||||
line,
|
||||
layout,
|
||||
layoutLineIndex,
|
||||
pageIndex,
|
||||
pageLine,
|
||||
linesPerPage
|
||||
})) {
|
||||
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
|
||||
}
|
||||
spreads[geometry.spreadIndex][geometry.side].push({
|
||||
const page = this.ensurePage(pages, pageIndex, {
|
||||
pageNumber: contentPageNumber,
|
||||
section: 'body'
|
||||
});
|
||||
const lineWordCount = this.countLineWords(line);
|
||||
page.lines.push({
|
||||
blockId: block.blockId ?? null,
|
||||
turnId: block.turnId ?? block.metadata?.turnId ?? null,
|
||||
role: layout.role,
|
||||
text: block.text || '',
|
||||
line,
|
||||
lineIndex: cursorLine,
|
||||
pageLine: geometry.pageLine,
|
||||
lineIndex: page.index * linesPerPage + pageLine,
|
||||
pageIndex: page.index,
|
||||
pageNumber: page.pageNumber,
|
||||
pageLine,
|
||||
fontPx: layout.fontPx,
|
||||
lineHeightPx: layout.lineHeightPx,
|
||||
fontStyle: layout.fontStyle,
|
||||
@@ -188,14 +254,308 @@ class BookPaginationModule extends BaseModule {
|
||||
smallCaps: Boolean(layout.dropCap && layoutLineIndex === 0)
|
||||
});
|
||||
blockWordCursor += lineWordCount;
|
||||
cursorLine += 1;
|
||||
pageLine += 1;
|
||||
});
|
||||
cursorLine += layout.bottomSpaceLines;
|
||||
pageLine += layout.bottomSpaceLines;
|
||||
if (pageLine >= linesPerPage) {
|
||||
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
|
||||
}
|
||||
});
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
buildSpreadsFromPages(pages = []) {
|
||||
const spreads = [];
|
||||
const linesPerPage = this.getLinesPerPage();
|
||||
pages.forEach((page, pageIndex) => {
|
||||
const spreadIndex = Math.floor(pageIndex / 2);
|
||||
const side = pageIndex % 2 === 0 ? 'left' : 'right';
|
||||
if (!spreads[spreadIndex]) {
|
||||
spreads[spreadIndex] = {
|
||||
index: spreadIndex,
|
||||
left: [],
|
||||
right: [],
|
||||
pageMeta: {
|
||||
left: null,
|
||||
right: null
|
||||
}
|
||||
};
|
||||
}
|
||||
spreads[spreadIndex][side] = Array.isArray(page?.lines) ? page.lines : [];
|
||||
spreads[spreadIndex].pageMeta[side] = {
|
||||
kind: page?.kind || 'content',
|
||||
pageIndex,
|
||||
pageNumber: page?.pageNumber ?? null,
|
||||
section: page?.section || 'body',
|
||||
omitPageNumber: page?.omitPageNumber === true,
|
||||
linesPerPage
|
||||
};
|
||||
});
|
||||
return spreads.filter(Boolean);
|
||||
}
|
||||
|
||||
createBlankPage(index = 0, options = {}) {
|
||||
return {
|
||||
index,
|
||||
kind: 'blank',
|
||||
section: options.section || 'body',
|
||||
pageNumber: options.pageNumber ?? null,
|
||||
omitPageNumber: true,
|
||||
lines: []
|
||||
};
|
||||
}
|
||||
|
||||
createTitlePage(index = 1) {
|
||||
return {
|
||||
index,
|
||||
kind: 'title',
|
||||
section: 'frontmatter',
|
||||
pageNumber: null,
|
||||
omitPageNumber: true,
|
||||
lines: []
|
||||
};
|
||||
}
|
||||
|
||||
ensurePage(pages, index, options = {}) {
|
||||
if (!pages[index]) {
|
||||
pages[index] = {
|
||||
index,
|
||||
kind: options.kind || 'content',
|
||||
section: options.section || 'body',
|
||||
pageNumber: options.pageNumber ?? null,
|
||||
omitPageNumber: options.omitPageNumber === true,
|
||||
lines: []
|
||||
};
|
||||
} else if (options.pageNumber != null && pages[index].pageNumber == null) {
|
||||
pages[index].pageNumber = options.pageNumber;
|
||||
pages[index].kind = pages[index].kind === 'blank' ? 'content' : pages[index].kind;
|
||||
pages[index].section = options.section || pages[index].section;
|
||||
}
|
||||
return pages[index];
|
||||
}
|
||||
|
||||
nextContentPageNumber(pages = []) {
|
||||
return pages.reduce((max, page) => Math.max(max, Number(page?.pageNumber || 0)), 0) + 1;
|
||||
}
|
||||
|
||||
advancePage(pages, pageIndex, contentPageNumber) {
|
||||
const nextIndex = pageIndex + 1;
|
||||
const pageNumber = Math.max(Number(contentPageNumber || 1), this.nextContentPageNumber(pages));
|
||||
this.ensurePage(pages, nextIndex, {
|
||||
kind: 'content',
|
||||
section: 'body',
|
||||
pageNumber,
|
||||
omitPageNumber: false
|
||||
});
|
||||
return {
|
||||
pageIndex: nextIndex,
|
||||
pageLine: 0,
|
||||
contentPageNumber: pageNumber + 1
|
||||
};
|
||||
}
|
||||
|
||||
advanceToNextRightPage(pages, pageIndex, pageLine, contentPageNumber) {
|
||||
let nextIndex = pageIndex;
|
||||
let nextLine = pageLine;
|
||||
let nextPageNumber = contentPageNumber;
|
||||
if (nextLine > 0) {
|
||||
const advanced = this.advancePage(pages, nextIndex, nextPageNumber);
|
||||
nextIndex = advanced.pageIndex;
|
||||
nextLine = advanced.pageLine;
|
||||
nextPageNumber = advanced.contentPageNumber;
|
||||
}
|
||||
if (nextIndex % 2 === 0) {
|
||||
const blankNumber = Math.max(Number(nextPageNumber || 1), this.nextContentPageNumber(pages));
|
||||
this.ensurePage(pages, nextIndex, {
|
||||
kind: 'blank',
|
||||
section: 'body',
|
||||
pageNumber: blankNumber,
|
||||
omitPageNumber: true
|
||||
});
|
||||
nextIndex += 1;
|
||||
nextPageNumber = blankNumber + 1;
|
||||
}
|
||||
this.ensurePage(pages, nextIndex, {
|
||||
kind: 'content',
|
||||
section: 'body',
|
||||
pageNumber: Math.max(Number(nextPageNumber || 1), this.nextContentPageNumber(pages)),
|
||||
omitPageNumber: false
|
||||
});
|
||||
return {
|
||||
pageIndex: nextIndex,
|
||||
pageLine: 0,
|
||||
contentPageNumber: this.nextContentPageNumber(pages)
|
||||
};
|
||||
}
|
||||
|
||||
shouldAdvanceBeforeTextLine({ line, layout, layoutLineIndex, pageIndex, pageLine, linesPerPage }) {
|
||||
if (pageLine >= linesPerPage) return true;
|
||||
const remainingPageLines = linesPerPage - pageLine;
|
||||
const remainingBlockLines = Math.max(0, layout.lines.length - layoutLineIndex);
|
||||
if (remainingPageLines === 1 && remainingBlockLines > 1) return true;
|
||||
if (remainingBlockLines === 1 && pageLine === 0 && layout.lines.length > 1) return true;
|
||||
if (pageIndex % 2 === 1 && pageLine === linesPerPage - 1 && line?.hyphenated) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
layoutImageBlock(pages, block, pageIndex, pageLine, contentPageNumber, linesPerPage) {
|
||||
const metrics = this.metrics;
|
||||
const content = metrics.contentBySide?.right || metrics.content || {};
|
||||
const metadata = { ...(block?.metadata || {}), ...block };
|
||||
const requestedSize = String(metadata.size || metadata.imageLayout?.size || 'landscape').toLowerCase();
|
||||
const size = requestedSize === 'widescreen' ? 'landscape' : requestedSize;
|
||||
const lineHeightPx = Math.max(1, Number(metrics.typographyLineHeightPx || 1));
|
||||
const textAreaWidth = Math.max(1, Number(content.width || metrics.content?.width || 1));
|
||||
const textAreaHeight = Math.max(1, Number(content.height || linesPerPage * lineHeightPx));
|
||||
let imageLineCount = Math.max(1, Math.ceil(linesPerPage * 0.5));
|
||||
let rect = null;
|
||||
|
||||
if (size === 'full') {
|
||||
if (pageLine > 0) {
|
||||
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
|
||||
}
|
||||
rect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: metrics.width,
|
||||
height: metrics.height
|
||||
};
|
||||
imageLineCount = linesPerPage;
|
||||
} else if (size === 'portrait') {
|
||||
const aspect = 9 / 16;
|
||||
const imageWidth = textAreaWidth * 0.5;
|
||||
const imageHeight = imageWidth / aspect;
|
||||
imageLineCount = Math.max(1, Math.ceil(imageHeight / lineHeightPx));
|
||||
if (pageLine + imageLineCount > linesPerPage) {
|
||||
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
|
||||
}
|
||||
rect = {
|
||||
x: textAreaWidth - imageWidth,
|
||||
y: pageLine * lineHeightPx,
|
||||
width: imageWidth,
|
||||
height: Math.min(textAreaHeight - pageLine * lineHeightPx, imageHeight)
|
||||
};
|
||||
} else {
|
||||
const bottomHalfStart = Math.ceil(linesPerPage * 0.5);
|
||||
if (pageLine > bottomHalfStart) {
|
||||
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
|
||||
} else {
|
||||
pageLine = Math.max(pageLine, bottomHalfStart);
|
||||
}
|
||||
imageLineCount = linesPerPage - pageLine;
|
||||
rect = {
|
||||
x: 0,
|
||||
y: pageLine * lineHeightPx,
|
||||
width: textAreaWidth,
|
||||
height: Math.max(lineHeightPx, imageLineCount * lineHeightPx)
|
||||
};
|
||||
}
|
||||
|
||||
const page = this.ensurePage(pages, pageIndex, {
|
||||
pageNumber: contentPageNumber,
|
||||
section: 'body'
|
||||
});
|
||||
page.lines.push(this.createImageRecord(block, page, pageLine, imageLineCount, rect, size));
|
||||
pageLine += imageLineCount;
|
||||
if (pageLine >= linesPerPage) {
|
||||
({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber));
|
||||
}
|
||||
return { pageIndex, pageLine, contentPageNumber };
|
||||
}
|
||||
|
||||
persistPaginationMetrics(pages = []) {
|
||||
if (!this.storyHistory || typeof this.storyHistory.updateBlockMetrics !== 'function') return;
|
||||
const metricsByBlock = this.collectPaginationMetrics(pages);
|
||||
metricsByBlock.forEach((metrics, blockId) => {
|
||||
this.storyHistory.updateBlockMetrics(blockId, metrics).catch(error => {
|
||||
console.warn('BookPagination: Failed to persist pagination metrics', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
collectPaginationMetrics(pages = []) {
|
||||
const byBlock = new Map();
|
||||
pages.forEach((page) => {
|
||||
const lines = Array.isArray(page?.lines) ? page.lines : [];
|
||||
lines.forEach((line) => {
|
||||
const blockId = Number(line?.blockId || 0);
|
||||
if (blockId <= 0) return;
|
||||
const pageLineStart = Math.max(0, Number(line.pageLine || 0));
|
||||
const pageLineEnd = pageLineStart + Math.max(1, Number(line.lineCount || 1)) - 1;
|
||||
const lineStart = Math.max(0, Number(line.lineIndex || 0));
|
||||
const lineCount = Math.max(1, Number(line.lineCount || 1));
|
||||
const spreadIndex = Math.floor(Number(page.index || 0) / 2);
|
||||
const current = byBlock.get(blockId);
|
||||
if (!current) {
|
||||
byBlock.set(blockId, {
|
||||
lineStart,
|
||||
lineCount,
|
||||
pageStart: Number(page.index || 0),
|
||||
pageEnd: Number(page.index || 0),
|
||||
pageLineStart,
|
||||
pageLineEnd,
|
||||
spreadStart: spreadIndex,
|
||||
spreadEnd: spreadIndex,
|
||||
pagination: {
|
||||
pages: [{
|
||||
pageIndex: Number(page.index || 0),
|
||||
pageNumber: page.pageNumber ?? null,
|
||||
firstLine: pageLineStart,
|
||||
lastLine: pageLineEnd
|
||||
}]
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
current.lineStart = Math.min(current.lineStart, lineStart);
|
||||
current.lineCount = Math.max(current.lineStart + current.lineCount, lineStart + lineCount) - current.lineStart;
|
||||
current.pageStart = Math.min(current.pageStart, Number(page.index || 0));
|
||||
current.pageEnd = Math.max(current.pageEnd, Number(page.index || 0));
|
||||
current.pageLineStart = Math.min(current.pageLineStart, pageLineStart);
|
||||
current.pageLineEnd = Math.max(current.pageLineEnd, pageLineEnd);
|
||||
current.spreadStart = Math.min(current.spreadStart, spreadIndex);
|
||||
current.spreadEnd = Math.max(current.spreadEnd, spreadIndex);
|
||||
current.pagination.pages.push({
|
||||
pageIndex: Number(page.index || 0),
|
||||
pageNumber: page.pageNumber ?? null,
|
||||
firstLine: pageLineStart,
|
||||
lastLine: pageLineEnd
|
||||
});
|
||||
});
|
||||
});
|
||||
return byBlock;
|
||||
}
|
||||
|
||||
createImageRecord(block, page, pageLine, lineCount, rect, size) {
|
||||
return {
|
||||
type: 'image',
|
||||
kind: 'image',
|
||||
blockId: block.blockId ?? null,
|
||||
turnId: block.turnId ?? block.metadata?.turnId ?? null,
|
||||
pageIndex: page.index,
|
||||
pageNumber: page.pageNumber,
|
||||
pageLine,
|
||||
lineIndex: page.index * this.getLinesPerPage() + pageLine,
|
||||
lineCount,
|
||||
metadata: {
|
||||
...(block.metadata || {}),
|
||||
...block,
|
||||
imageLayout: {
|
||||
...(block.metadata?.imageLayout || {}),
|
||||
size,
|
||||
textureRect: rect,
|
||||
lineStart: pageLine,
|
||||
lineCount
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getLinesPerPage() {
|
||||
return Math.max(1, Math.floor(this.metrics.content.height / this.metrics.typographyLineHeightPx || 1));
|
||||
}
|
||||
|
||||
layoutTextBlock(block = {}, type = 'paragraph') {
|
||||
const sourceText = String(block.layoutText || block.text || '').trim();
|
||||
const dropCap = Boolean(block.dropCap || block.metadata?.dropCap);
|
||||
|
||||
@@ -39,6 +39,7 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.lastAnimationFrameAt = 0;
|
||||
this.targetFrameDurationMs = 1000 / 30;
|
||||
this.pipelineTimings = [];
|
||||
this.imageCache = new Map();
|
||||
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
@@ -50,7 +51,14 @@ class BookTextureRendererModule extends BaseModule {
|
||||
'getDrawSignature',
|
||||
'cloneCanvas',
|
||||
'drawPageBase',
|
||||
'drawPageMeta',
|
||||
'drawTitlePage',
|
||||
'drawPageNumber',
|
||||
'drawPageLines',
|
||||
'drawImageRecord',
|
||||
'resolveImageSource',
|
||||
'getCachedImage',
|
||||
'drawImageFitted',
|
||||
'drawLine',
|
||||
'drawWord',
|
||||
'recordRevealRect',
|
||||
@@ -194,7 +202,9 @@ class BookTextureRendererModule extends BaseModule {
|
||||
if (!this.canvases[side]) return;
|
||||
this.drawPageBase(side);
|
||||
if (hasReveal) this.revealBaseCanvases[side] = this.cloneCanvas(this.canvases[side]);
|
||||
this.drawPageMeta(side, 'before-lines');
|
||||
this.drawPageLines(side, this.currentSpread?.[side] || []);
|
||||
this.drawPageMeta(side, 'after-lines');
|
||||
});
|
||||
const published = this.publishSpread(sidesToDraw, options);
|
||||
this.markPipelineTiming('drawSpread:end', {
|
||||
@@ -214,8 +224,9 @@ class BookTextureRendererModule extends BaseModule {
|
||||
const source = spread || {};
|
||||
return sides.map(side => {
|
||||
const lines = Array.isArray(source[side]) ? source[side] : [];
|
||||
const ids = lines.map(line => `${line.blockId ?? ''}:${line.lineIndex ?? ''}:${line.pageLine ?? ''}:${line.line?.nodes?.length || 0}`).join(',');
|
||||
return `${side}[${ids}]`;
|
||||
const meta = source.pageMeta?.[side] || {};
|
||||
const ids = lines.map(line => `${line.type || 'line'}:${line.blockId ?? ''}:${line.lineIndex ?? ''}:${line.pageLine ?? ''}:${line.lineCount ?? ''}:${line.line?.nodes?.length || 0}`).join(',');
|
||||
return `${side}:${meta.kind || ''}:${meta.pageIndex ?? ''}:${meta.pageNumber ?? ''}:${meta.omitPageNumber === true}[${ids}]`;
|
||||
}).join('|');
|
||||
}
|
||||
|
||||
@@ -254,6 +265,69 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.hitMaps[side] = [];
|
||||
}
|
||||
|
||||
drawPageMeta(side, phase = 'after-lines') {
|
||||
const meta = this.currentSpread?.pageMeta?.[side] || null;
|
||||
if (!meta) return;
|
||||
if (phase === 'before-lines' && meta.kind === 'title') this.drawTitlePage(side);
|
||||
if (phase === 'after-lines') this.drawPageNumber(side, meta);
|
||||
}
|
||||
|
||||
drawTitlePage(side) {
|
||||
const ctx = this.contexts[side];
|
||||
if (!ctx || !this.metrics) return;
|
||||
const content = this.getPageContent(side);
|
||||
const titleText = document.getElementById('game_title')?.textContent?.trim() || '';
|
||||
const authorText = document.getElementById('game_author')?.textContent?.trim() || '';
|
||||
const subtitleText = document.getElementById('game_subtitle')?.textContent?.trim() || '';
|
||||
const ornamentText = document.querySelector('#start_prompt .separator, #start_prompt .ornament, #start_prompt [class*="separator"]')?.textContent?.trim() || '';
|
||||
const legalText = document.getElementById('game_legal_text')?.textContent?.trim() || '';
|
||||
const centerX = content.x + content.width * 0.5;
|
||||
const font = this.metrics.typography.fontFamily;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(31, 19, 10, 0.9)';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
||||
if (authorText) {
|
||||
ctx.font = `italic ${Math.round(this.metrics.bodyFontSizePx * 0.86)}px ${font}`;
|
||||
ctx.fillText(authorText, centerX, content.y + content.height * 0.18);
|
||||
}
|
||||
if (titleText) {
|
||||
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 1.55)}px ${font}`;
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'small-caps';
|
||||
ctx.fillText(titleText, centerX, content.y + content.height * 0.28);
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
||||
}
|
||||
if (subtitleText) {
|
||||
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.94)}px ${font}`;
|
||||
ctx.fillText(subtitleText, centerX, content.y + content.height * 0.39);
|
||||
}
|
||||
if (ornamentText) {
|
||||
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 1.3)}px ${font}`;
|
||||
ctx.fillText(ornamentText, centerX, content.y + content.height * 0.52);
|
||||
}
|
||||
if (legalText) {
|
||||
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.62)}px ${font}`;
|
||||
ctx.fillText(legalText, centerX, content.y + content.height * 0.96);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawPageNumber(side, meta = {}) {
|
||||
if (meta.omitPageNumber || meta.pageNumber == null) return;
|
||||
const ctx = this.contexts[side];
|
||||
if (!ctx || !this.metrics) return;
|
||||
const content = this.getPageContent(side);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(31, 19, 10, 0.74)';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.68)}px ${this.metrics.typography.fontFamily}`;
|
||||
ctx.fillText(String(meta.pageNumber), content.x + content.width * 0.5, content.y + content.height + this.metrics.margin.bottom * 0.48);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawPageLines(side, lines = []) {
|
||||
const ctx = this.contexts[side];
|
||||
if (!ctx || !this.metrics || !Array.isArray(lines)) return;
|
||||
@@ -263,10 +337,73 @@ class BookTextureRendererModule extends BaseModule {
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
||||
lines.forEach(line => this.drawLine(ctx, line, side));
|
||||
lines.forEach(line => {
|
||||
if (line?.type === 'image' || line?.kind === 'image') this.drawImageRecord(ctx, line, side);
|
||||
else this.drawLine(ctx, line, side);
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
drawImageRecord(ctx, lineRecord = {}, side = 'left') {
|
||||
const content = this.getPageContent(side);
|
||||
const layout = lineRecord.metadata?.imageLayout || {};
|
||||
const rect = layout.textureRect || {};
|
||||
const x = content.x + Number(rect.x || 0);
|
||||
const y = content.y + Number(rect.y || 0);
|
||||
const width = Math.max(1, Number(rect.width || content.width));
|
||||
const height = Math.max(1, Number(rect.height || this.metrics.typographyLineHeightPx));
|
||||
const src = this.resolveImageSource(lineRecord.metadata || {});
|
||||
|
||||
ctx.save();
|
||||
if (src) {
|
||||
const image = this.getCachedImage(src);
|
||||
if (image?.complete && image.naturalWidth > 0) {
|
||||
this.drawImageFitted(ctx, image, x, y, width, height);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
resolveImageSource(metadata = {}) {
|
||||
const explicit = String(metadata.url || metadata.src || '').trim();
|
||||
if (explicit) return explicit;
|
||||
const filename = String(metadata.filename || '').trim();
|
||||
if (!filename) return '';
|
||||
if (/^(https?:|data:|blob:|\/)/i.test(filename)) return filename;
|
||||
return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`;
|
||||
}
|
||||
|
||||
getCachedImage(src) {
|
||||
if (!src) return null;
|
||||
if (this.imageCache.has(src)) return this.imageCache.get(src);
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.onload = () => this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
||||
image.onerror = () => this.markPipelineTiming('image:load-error', { src });
|
||||
image.src = src;
|
||||
this.imageCache.set(src, image);
|
||||
return image;
|
||||
}
|
||||
|
||||
drawImageFitted(ctx, image, x, y, width, height) {
|
||||
const sourceWidth = image.naturalWidth || image.width || 1;
|
||||
const sourceHeight = image.naturalHeight || image.height || 1;
|
||||
const sourceAspect = sourceWidth / sourceHeight;
|
||||
const targetAspect = width / height;
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sw = sourceWidth;
|
||||
let sh = sourceHeight;
|
||||
if (sourceAspect > targetAspect) {
|
||||
sw = sourceHeight * targetAspect;
|
||||
sx = (sourceWidth - sw) * 0.5;
|
||||
} else if (sourceAspect < targetAspect) {
|
||||
sh = sourceWidth / targetAspect;
|
||||
sy = (sourceHeight - sh) * 0.5;
|
||||
}
|
||||
ctx.drawImage(image, sx, sy, sw, sh, x, y, width, height);
|
||||
}
|
||||
|
||||
drawLine(ctx, lineRecord = {}, side = 'left') {
|
||||
const metrics = this.metrics;
|
||||
const content = this.getPageContent(side);
|
||||
@@ -708,7 +845,8 @@ class BookTextureRendererModule extends BaseModule {
|
||||
const detail = {
|
||||
metrics: this.metrics,
|
||||
hitMaps: this.hitMaps,
|
||||
sides: sidesToPublish
|
||||
sides: sidesToPublish,
|
||||
pageMeta: this.currentSpread?.pageMeta || {}
|
||||
};
|
||||
if (options.preloadOnly) detail.preloadOnly = true;
|
||||
if (sidesToPublish.includes('left')) {
|
||||
|
||||
@@ -89,7 +89,7 @@ class MarkupParserModule extends BaseModule {
|
||||
const lower = token.toLowerCase();
|
||||
const [key, value] = lower.split('=');
|
||||
|
||||
if (['landscape', 'widescreen', 'portrait', 'square'].includes(lower)) {
|
||||
if (['landscape', 'widescreen', 'portrait', 'square', 'full'].includes(lower)) {
|
||||
options.size = lower === 'widescreen' ? 'landscape' : lower;
|
||||
} else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro', 'pause', 'wait', 'hold'].includes(key)) {
|
||||
const seconds = Number(value);
|
||||
|
||||
@@ -157,6 +157,13 @@ class StoryHistoryModule extends BaseModule {
|
||||
...record,
|
||||
lineStart,
|
||||
lineCount,
|
||||
...(Number.isFinite(Number(metrics.pageStart)) ? { pageStart: Math.max(0, Number(metrics.pageStart)) } : {}),
|
||||
...(Number.isFinite(Number(metrics.pageEnd)) ? { pageEnd: Math.max(0, Number(metrics.pageEnd)) } : {}),
|
||||
...(Number.isFinite(Number(metrics.pageLineStart)) ? { pageLineStart: Math.max(0, Number(metrics.pageLineStart)) } : {}),
|
||||
...(Number.isFinite(Number(metrics.pageLineEnd)) ? { pageLineEnd: Math.max(0, Number(metrics.pageLineEnd)) } : {}),
|
||||
...(Number.isFinite(Number(metrics.spreadStart)) ? { spreadStart: Math.max(0, Number(metrics.spreadStart)) } : {}),
|
||||
...(Number.isFinite(Number(metrics.spreadEnd)) ? { spreadEnd: Math.max(0, Number(metrics.spreadEnd)) } : {}),
|
||||
...(metrics.pagination ? { pagination: metrics.pagination } : {}),
|
||||
metricsUpdatedAt: Date.now()
|
||||
};
|
||||
|
||||
|
||||
@@ -2453,8 +2453,15 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
const normalizedSize = String(metadata.size || 'landscape').toLowerCase() === 'widescreen'
|
||||
? 'landscape'
|
||||
: String(metadata.size || 'landscape').toLowerCase();
|
||||
const aspect = normalizedSize === 'portrait' ? (9 / 16) : normalizedSize === 'square' ? 1 : (16 / 9);
|
||||
const aspect = normalizedSize === 'portrait'
|
||||
? (9 / 16)
|
||||
: normalizedSize === 'square'
|
||||
? 1
|
||||
: normalizedSize === 'full'
|
||||
? (4.25 / 6.875)
|
||||
: (16 / 9);
|
||||
const isPortrait = normalizedSize === 'portrait';
|
||||
const isFullPage = normalizedSize === 'full';
|
||||
const imageGap = lineHeight;
|
||||
const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth;
|
||||
const maxImageWidth = isPortrait
|
||||
@@ -2463,7 +2470,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
const naturalHeight = maxImageWidth / aspect;
|
||||
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
|
||||
const verticalMargin = lineHeight / 2;
|
||||
const lineCount = imageLineCount + 1;
|
||||
const lineCount = isFullPage ? this.pageLineCount : imageLineCount + 1;
|
||||
const height = Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2));
|
||||
const width = Math.min(maxImageWidth, height * aspect);
|
||||
|
||||
|
||||
+104
-3
@@ -185,8 +185,8 @@ function markPageTextureTiming(name, detail = {}) {
|
||||
|
||||
const book = new THREE.Group();
|
||||
scene.add(book);
|
||||
const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0.28'), 0, 1);
|
||||
let readingProgress = Number.isFinite(initialReadingProgress) ? initialReadingProgress : 0.28;
|
||||
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 currentProceduralBookModel = null;
|
||||
const progressInput = document.getElementById('progress_control');
|
||||
@@ -235,10 +235,22 @@ function createPageCanvasTexture(sourceCanvas) {
|
||||
return texture;
|
||||
}
|
||||
|
||||
function getBlankPageTexture() {
|
||||
if (blankPageTexture) return blankPageTexture;
|
||||
blankPageTexture = createPageCanvasTexture(createPageCanvas('blank'));
|
||||
return blankPageTexture;
|
||||
}
|
||||
|
||||
const preparedPageTextures = {
|
||||
left: new Map(),
|
||||
right: new Map()
|
||||
};
|
||||
let blankPageTexture = null;
|
||||
let currentPageMeta = {
|
||||
left: null,
|
||||
right: null
|
||||
};
|
||||
let pendingRightPageFlip = false;
|
||||
const pageRevealState = {
|
||||
left: null,
|
||||
right: null
|
||||
@@ -518,6 +530,15 @@ document.addEventListener('webgl-book:page-reveal-start', (event) => {
|
||||
document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
|
||||
fastForwardPageReveals(event.detail?.blockIds || []);
|
||||
});
|
||||
document.addEventListener('webgl-book:reveal-committed', (event) => {
|
||||
handleRevealCommittedForPageFlip(event.detail || {});
|
||||
});
|
||||
document.addEventListener('ui:command', (event) => {
|
||||
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
|
||||
pendingRightPageFlip = false;
|
||||
startPageFlip(1);
|
||||
}
|
||||
});
|
||||
installBookControls();
|
||||
installCameraControls();
|
||||
resize();
|
||||
@@ -1673,11 +1694,18 @@ function syncBookControls() {
|
||||
|
||||
function handlePageCanvases(event) {
|
||||
const detail = event.detail || {};
|
||||
if (detail.pageMeta) {
|
||||
currentPageMeta = {
|
||||
left: detail.pageMeta.left || currentPageMeta.left || null,
|
||||
right: detail.pageMeta.right || currentPageMeta.right || null
|
||||
};
|
||||
}
|
||||
markPageTextureTiming('handlePageCanvases:start', {
|
||||
hasLeft: Boolean(detail.left),
|
||||
hasRight: Boolean(detail.right),
|
||||
revealSides: Object.keys(detail.reveal || {}),
|
||||
preloadOnly: Boolean(detail.preloadOnly)
|
||||
preloadOnly: Boolean(detail.preloadOnly),
|
||||
pageMeta: currentPageMeta
|
||||
});
|
||||
if (detail.preloadOnly) {
|
||||
if (detail.left) preloadPageTexture('left', detail.left, detail.reveal?.left);
|
||||
@@ -2111,8 +2139,11 @@ function textureHitPageSide(hit) {
|
||||
|
||||
function startPageFlip(direction) {
|
||||
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;
|
||||
prepareStaticPageForFlip(flip);
|
||||
activeFlips.push(flip);
|
||||
syncBookControls();
|
||||
updateActiveFlips(flip.startTime);
|
||||
@@ -2123,6 +2154,7 @@ function startFastPageFlip(direction) {
|
||||
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) {
|
||||
@@ -2142,11 +2174,13 @@ function startFastPageFlip(direction) {
|
||||
|
||||
function createPageFlip(direction, startTime, duration) {
|
||||
const sourceSide = direction > 0 ? 1 : -1;
|
||||
const sourcePageSide = direction > 0 ? 'right' : 'left';
|
||||
const sourceLine = topVisibleLine(sourceSide);
|
||||
const destinationLine = topVisibleLine(-sourceSide);
|
||||
if (!sourceLine || !destinationLine) return null;
|
||||
return {
|
||||
direction,
|
||||
sourcePageSide,
|
||||
sourceLine,
|
||||
destinationLine,
|
||||
startTime,
|
||||
@@ -2158,12 +2192,64 @@ function createPageFlip(direction, startTime, duration) {
|
||||
};
|
||||
}
|
||||
|
||||
function prepareStaticPageForFlip(flip) {
|
||||
if (!flip) return;
|
||||
const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage;
|
||||
const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture);
|
||||
materials.flipPageSurface.map = sourceTexture;
|
||||
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
|
||||
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
||||
materials.flipPageSurface.needsUpdate = true;
|
||||
flip.sourceTexture = sourceTexture;
|
||||
if (flip.direction > 0) {
|
||||
const blankTexture = getBlankPageTexture();
|
||||
if (blankTexture && materials.rightPage.map !== blankTexture) {
|
||||
clearPageReveal('right', 'page-flip-start');
|
||||
materials.rightPage.map = blankTexture;
|
||||
materials.rightPage.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canPageFlip(direction) {
|
||||
if (!currentProceduralBookModel) return false;
|
||||
if (direction > 0) return readingProgress < 1;
|
||||
return readingProgress > 0;
|
||||
}
|
||||
|
||||
function handleRevealCommittedForPageFlip(detail = {}) {
|
||||
if (detail.side !== 'right' || !isRightBodyPageComplete()) return;
|
||||
if (activeFlips.length > 0 || pendingRightPageFlip) return;
|
||||
if (isChoiceAwaitingPlayer()) return;
|
||||
if (isTtsPlaybackActive()) {
|
||||
startPageFlip(1);
|
||||
return;
|
||||
}
|
||||
pendingRightPageFlip = true;
|
||||
document.documentElement.dataset.webglPendingPageFlip = 'right';
|
||||
}
|
||||
|
||||
function isRightBodyPageComplete() {
|
||||
const meta = currentPageMeta?.right || null;
|
||||
if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false;
|
||||
const rendererDebug = window.BookTextureRenderer?.currentSpread || null;
|
||||
const rightLines = Array.isArray(rendererDebug?.right) ? rendererDebug.right : [];
|
||||
const maxLine = rightLines.reduce((max, line) => Math.max(max, Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1))), 0);
|
||||
const expectedLines = Math.max(1, Number(meta.linesPerPage || 25));
|
||||
return maxLine >= expectedLines;
|
||||
}
|
||||
|
||||
function isChoiceAwaitingPlayer() {
|
||||
return document.documentElement.dataset.choiceAwaiting === 'true'
|
||||
|| document.body?.dataset?.choiceAwaiting === 'true'
|
||||
|| Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice'));
|
||||
}
|
||||
|
||||
function isTtsPlaybackActive() {
|
||||
const coordinator = window.moduleRegistry?.getModule?.('playback-coordinator') || window.PlaybackCoordinator || null;
|
||||
return Boolean(coordinator?.isPlaying || coordinator?.state === 'playing' || document.documentElement.dataset.ttsPlaying === 'true');
|
||||
}
|
||||
|
||||
function topVisibleLine(side) {
|
||||
const sideLines = currentProceduralBookModel.lines
|
||||
.filter((line) => line.side === side)
|
||||
@@ -2180,6 +2266,15 @@ function updateActiveFlips(now) {
|
||||
const t = THREE.MathUtils.clamp(elapsed, 0, 1);
|
||||
const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset);
|
||||
setActivePageGeometry(flip, surface);
|
||||
if (!flip.spreadAdvanced && t >= 0.82) {
|
||||
flip.spreadAdvanced = true;
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', {
|
||||
detail: {
|
||||
direction: flip.direction,
|
||||
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left')
|
||||
}
|
||||
}));
|
||||
}
|
||||
if (t >= 1) completed.push(flip);
|
||||
});
|
||||
completed.forEach((flip) => finishActiveFlip(flip));
|
||||
@@ -2378,6 +2473,12 @@ function createFlippingPageGeometry(surface) {
|
||||
function finishActiveFlip(flip) {
|
||||
removeFlipMesh(flip);
|
||||
activeFlips = activeFlips.filter((active) => active !== flip);
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', {
|
||||
detail: {
|
||||
direction: flip.direction,
|
||||
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left')
|
||||
}
|
||||
}));
|
||||
if (flip.commitBundleOnFinish) {
|
||||
shiftReadingProgressByBundle(flip.direction);
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
const DEFAULT_BOOK_PAGE_COUNT = 300;
|
||||
const DEFAULT_BOOK_PROGRESS = 0.5;
|
||||
const DEFAULT_BOOK_PROGRESS = 0;
|
||||
|
||||
class WebGLBookSceneModule extends BaseModule {
|
||||
constructor() {
|
||||
|
||||
Reference in New Issue
Block a user