From 86b6fa041901312b91b690c8c094614159934b22 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Mon, 8 Jun 2026 09:03:35 +0200 Subject: [PATCH] Implement WebGL book spread flip groundwork --- public/js/book-pagination-module.js | 390 +++++++++++++++++++++- public/js/book-texture-renderer-module.js | 146 +++++++- public/js/markup-parser-module.js | 2 +- public/js/story-history-module.js | 7 + public/js/ui-display-handler-module.js | 11 +- public/js/webgl-book-lab.js | 107 +++++- public/js/webgl-book-scene-module.js | 2 +- scripts/check-webgl-book-lab.js | 14 +- 8 files changed, 652 insertions(+), 27 deletions(-) diff --git a/public/js/book-pagination-module.js b/public/js/book-pagination-module.js index 1989418..7fed2da 100644 --- a/public/js/book-pagination-module.js +++ b/public/js/book-pagination-module.js @@ -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); diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 56499f4..c26db7e 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -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')) { diff --git a/public/js/markup-parser-module.js b/public/js/markup-parser-module.js index 2a55812..7f2359b 100644 --- a/public/js/markup-parser-module.js +++ b/public/js/markup-parser-module.js @@ -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); diff --git a/public/js/story-history-module.js b/public/js/story-history-module.js index 0ebc0e4..3bcffc0 100644 --- a/public/js/story-history-module.js +++ b/public/js/story-history-module.js @@ -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() }; diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index 1415b1e..ecd4a9d 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -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); diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 73f1e8c..a0a183c 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -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; diff --git a/public/js/webgl-book-scene-module.js b/public/js/webgl-book-scene-module.js index d494879..ca88a28 100644 --- a/public/js/webgl-book-scene-module.js +++ b/public/js/webgl-book-scene-module.js @@ -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() { diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index c2d1be1..c699083 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -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);