Implement WebGL book spread flip groundwork

This commit is contained in:
2026-06-08 09:03:35 +02:00
parent c86a304364
commit 86b6fa0419
8 changed files with 652 additions and 27 deletions
+375 -15
View File
@@ -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);
+142 -4
View File
@@ -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')) {
+1 -1
View File
@@ -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);
+7
View File
@@ -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()
};
+9 -2
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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() {
+13 -1
View File
@@ -15,8 +15,12 @@ const bookPaginationPath = path.join(__dirname, '..', 'public', 'js', 'book-pagi
const bookPaginationSource = fs.readFileSync(bookPaginationPath, 'utf8');
const sentenceQueuePath = path.join(__dirname, '..', 'public', 'js', 'sentence-queue-module.js');
const sentenceQueueSource = fs.readFileSync(sentenceQueuePath, 'utf8');
const storyHistoryPath = path.join(__dirname, '..', 'public', 'js', 'story-history-module.js');
const storyHistorySource = fs.readFileSync(storyHistoryPath, 'utf8');
const webglScenePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-scene-module.js');
const webglSceneSource = fs.readFileSync(webglScenePath, 'utf8');
const markupParserPath = path.join(__dirname, '..', 'public', 'js', 'markup-parser-module.js');
const markupParserSource = fs.readFileSync(markupParserPath, 'utf8');
const loaderPath = path.join(__dirname, '..', 'public', 'js', 'loader.js');
const loaderSource = fs.readFileSync(loaderPath, 'utf8');
const pageFormatPath = path.join(__dirname, '..', 'public', 'js', 'book-page-format-module.js');
@@ -139,7 +143,15 @@ const checks = [
['drop-cap reservation uses both ink bounds and font advance width', /const advanceWidth = metrics\.width \|\| 0/.test(bookPaginationSource) && /Math\.max\(inkRight, advanceWidth, lineHeightPx \* 1\.08\)/.test(bookPaginationSource)],
['webgl scene avoids duplicate initial texture publish', !/this\.triggerTextureRefresh\(\)/.test(methodBody(webglSceneSource, 'initializeScene'))],
['webgl scene does not republish 3D page textures from DOM refresh events', !/addEventListener\(document, 'story:turn-start', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:turn-complete', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:history-updated', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'input', this\.triggerTextureRefresh/.test(webglSceneSource) && !/addEventListener\(document, 'change', this\.triggerTextureRefresh/.test(webglSceneSource)],
['webgl scene adoptPageContent does not republish 3D page textures', !/triggerTextureRefresh/.test(methodBody(webglSceneSource, 'adoptPageContent'))]
['webgl scene adoptPageContent does not republish 3D page textures', !/triggerTextureRefresh/.test(methodBody(webglSceneSource, 'adoptPageContent'))],
['webgl book starts at progress zero', /const DEFAULT_BOOK_PROGRESS = 0;/.test(webglSceneSource) && /appInitialState\.progress \?\? '0'/.test(source)],
['pagination opens with blank left and title right spread', /this\.createBlankPage\(0, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.createTitlePage\(1\)/.test(bookPaginationSource) && /this\.createBlankPage\(2, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource)],
['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)],
['texture renderer draws title page and page numbers from page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.currentSpread\?\.pageMeta/.test(textureRendererSource)],
['webgl flip borrows resident page texture and blanks right stack before forward animation', /prepareStaticPageForFlip/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.rightPage\.map = blankTexture/.test(source) && /webgl-book:page-flip-near-end/.test(source)],
['webgl right-page completion arms a flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /isRightBodyPageComplete/.test(source) && /isChoiceAwaitingPlayer/.test(source) && /pendingRightPageFlip/.test(source)],
['markup and 3d pagination accept full-page images', /'full'/.test(markupParserSource) && /size === 'full'/.test(bookPaginationSource)],
['story history can persist 3d pagination decisions', /persistPaginationMetrics/.test(bookPaginationSource) && /collectPaginationMetrics/.test(bookPaginationSource) && /pageStart/.test(storyHistorySource) && /pagination: metrics\.pagination/.test(storyHistorySource)]
];
const failures = checks.filter(([, passed]) => !passed).map(([name]) => name);