diff --git a/public/js/book-page-format-module.js b/public/js/book-page-format-module.js index 5b968cd..e48de44 100644 --- a/public/js/book-page-format-module.js +++ b/public/js/book-page-format-module.js @@ -18,7 +18,7 @@ class BookPageFormatModule extends BaseModule { topIn: 0.46, bottomIn: 0.58, innerIn: 0.62, - outerIn: 0.72 + outerIn: 0.86 }), typography: Object.freeze({ fontFamily: '"EB Garamond", "EB Garamond 12", serif', diff --git a/public/js/book-pagination-module.js b/public/js/book-pagination-module.js index f567c9d..6681d30 100644 --- a/public/js/book-pagination-module.js +++ b/public/js/book-pagination-module.js @@ -15,6 +15,8 @@ class BookPaginationModule extends BaseModule { this.spreads = []; this.currentSpreadIndex = 0; this.refreshToken = 0; + this.latestBlockId = 0; + this.latestRenderedBlockId = 0; this.bindMethods([ 'initialize', @@ -24,6 +26,7 @@ class BookPaginationModule extends BaseModule { 'getDropCapText', 'extractDropCapText', 'extractLines', + 'countLineWords', 'getLineGeometry', 'getSpread', 'getCurrentSpread', @@ -57,12 +60,19 @@ class BookPaginationModule extends BaseModule { ); if (!gameId || latestBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') { this.spreads = []; + this.latestBlockId = 0; + this.latestRenderedBlockId = 0; this.publish(); return; } const blocks = await this.storyHistory.getBlocksRange(gameId, 1, latestBlockId); if (token !== this.refreshToken) return; + this.latestBlockId = latestBlockId; + this.latestRenderedBlockId = Math.max( + 0, + Number(detail.latestRenderedBlockId || this.storyHistory?.latestRenderedBlockId || 0) + ); this.spreads = this.buildSpreads(blocks); this.currentSpreadIndex = Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1))); this.publish(); @@ -84,7 +94,7 @@ class BookPaginationModule extends BaseModule { layout.lines.forEach((line, layoutLineIndex) => { const geometry = this.getLineGeometry(cursorLine); - const lineWordCount = line.nodes.filter(node => node?.type === 'box' && node.value).length; + const lineWordCount = this.countLineWords(line); if (!spreads[geometry.spreadIndex]) { spreads[geometry.spreadIndex] = { index: geometry.spreadIndex, left: [], right: [] }; } @@ -100,7 +110,8 @@ class BookPaginationModule extends BaseModule { lineHeightPx: layout.lineHeightPx, fontStyle: layout.fontStyle, blockWordStart: blockWordCursor, - dropCapText: layoutLineIndex === 0 ? layout.dropCapText : '' + dropCapText: layoutLineIndex === 0 ? layout.dropCapText : '', + smallCaps: Boolean(layout.dropCap && layoutLineIndex === 0) }); blockWordCursor += lineWordCount; cursorLine += 1; @@ -125,7 +136,7 @@ class BookPaginationModule extends BaseModule { const bottomSpaceLines = role === 'chapter-heading' || role === 'section-heading' ? 1 : 0; const lineHeightPx = Math.max(1, Number(this.metrics.typographyLineHeightPx || 1)); const fontPx = Math.max(1, Number(this.metrics.bodyFontSizePx || lineHeightPx / 1.5)); - const dropCapWidth = dropCap ? lineHeightPx * 1.58 : 0; + const dropCapWidth = dropCap ? lineHeightPx * 1.72 : 0; const indent = (isHeading || block.isFirstParagraphInChapter || block.metadata?.isFirstParagraphInChapter || block.addTopSpace) ? 0 : lineHeightPx * 1.5; @@ -195,12 +206,32 @@ class BookPaginationModule extends BaseModule { offset, ratio: breaks[index].ratio || 0, isFinal: index === breaks.length - 1, + hyphenated: Boolean(lineNodes.at(-1)?.type === 'penalty' && lineNodes.at(-1)?.penalty === 100), align: options.align || 'justify' }); } return lines; } + countLineWords(line = {}) { + const nodes = Array.isArray(line.nodes) ? line.nodes : []; + let count = 0; + let previousWasGlue = true; + nodes.forEach((node) => { + if (!node) return; + if (node.type === 'glue') { + previousWasGlue = true; + return; + } + if (node.type === 'penalty') return; + if (node.type === 'box' && node.value) { + if (previousWasGlue) count += 1; + previousWasGlue = false; + } + }); + return count; + } + getLineGeometry(globalLine) { const linesPerPage = Math.max(1, Math.floor(this.metrics.content.height / this.metrics.typographyLineHeightPx || 1)); const spreadLineCount = linesPerPage * 2; @@ -233,7 +264,9 @@ class BookPaginationModule extends BaseModule { detail: { spread: this.getCurrentSpread(), spreadIndex: this.currentSpreadIndex, - spreadCount: this.spreads.length + spreadCount: this.spreads.length, + latestBlockId: this.latestBlockId, + latestRenderedBlockId: this.latestRenderedBlockId } })); } diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index edce3b6..8c46eb9 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -26,6 +26,7 @@ class BookTextureRendererModule extends BaseModule { }; this.currentSpread = null; this.activeAnimations = new Map(); + this.revealedBlockIds = new Set(); this.animationFrameId = null; this.lastAnimationFrameAt = 0; this.targetFrameDurationMs = 1000 / 30; @@ -39,9 +40,13 @@ class BookTextureRendererModule extends BaseModule { 'drawPageLines', 'drawLine', 'drawWord', + 'buildLineSegments', 'startRevealAnimation', 'fastForwardAnimations', 'stopAnimations', + 'getBlockSides', + 'getAnimatedSides', + 'markPendingReveal', 'requestAnimationFrame', 'tickAnimations', 'publishSpread', @@ -60,6 +65,9 @@ class BookTextureRendererModule extends BaseModule { this.drawEmptySpread(); this.addEventListener(document, 'webgl-book:scene-ready', this.handleSceneReady); this.addEventListener(document, 'book-pagination:spread-updated', (event) => { + const latestBlockId = event.detail?.latestBlockId; + const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0)); + if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) this.markPendingReveal(latestBlockId); this.drawSpread(event.detail?.spread || this.pagination?.getCurrentSpread?.()); }); this.addEventListener(document, 'book-texture:reveal-block', (event) => { @@ -92,13 +100,15 @@ class BookTextureRendererModule extends BaseModule { this.publishSpread(); } - drawSpread(spread = null) { + drawSpread(spread = null, sides = null) { this.currentSpread = spread || { left: [], right: [] }; - this.drawPageBase('left'); - this.drawPageBase('right'); - this.drawPageLines('left', this.currentSpread?.left || []); - this.drawPageLines('right', this.currentSpread?.right || []); - this.publishSpread(); + const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; + sidesToDraw.forEach((side) => { + if (!this.canvases[side]) return; + this.drawPageBase(side); + this.drawPageLines(side, this.currentSpread?.[side] || []); + }); + this.publishSpread(sidesToDraw); } drawPageBase(side) { @@ -158,51 +168,96 @@ class BookTextureRendererModule extends BaseModule { let x = metrics.content.x + centerOffset; let wordIndex = 0; - ctx.font = `${fontStyle}${fontPx}px ${metrics.typography.fontFamily}`; + ctx.font = `${fontStyle}${lineRecord.smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`; if (lineRecord.dropCapText) { ctx.save(); - ctx.font = `${Math.round(lineHeightPx * 2.08)}px "EB Garamond Initials", ${metrics.typography.fontFamily}`; - ctx.textBaseline = 'top'; - ctx.fillText( - String(lineRecord.dropCapText), - metrics.content.x, - metrics.content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) - (lineHeightPx * 0.08) - ); - ctx.restore(); - ctx.font = `${fontStyle}${fontPx}px ${metrics.typography.fontFamily}`; + const alpha = this.getWordAlpha(lineRecord, 0); + if (alpha <= 0) { + ctx.restore(); + } else { + ctx.globalAlpha *= alpha; + ctx.font = `${Math.round(lineHeightPx * 2.14)}px "EB Garamond Initials", ${metrics.typography.fontFamily}`; + ctx.textBaseline = 'top'; + ctx.fillText( + String(lineRecord.dropCapText), + metrics.content.x, + metrics.content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) - (lineHeightPx * 0.05) + ); + ctx.restore(); + } + ctx.font = `${fontStyle}${lineRecord.smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`; } + this.buildLineSegments(ctx, nodes, line, ratio).forEach((segment) => { + this.drawWord(ctx, segment.value, x + segment.x, baseY, lineRecord, segment.wordIndex); + }); + } + + buildLineSegments(ctx, nodes = [], line = {}, ratio = 0) { + const segments = []; + let x = 0; + let currentSegment = null; + let previousWasGlue = true; + nodes.forEach((node, index) => { if (!node) return; if (node.type === 'box' && node.value) { - const nextNode = nodes[index + 1]; - const value = `${node.value}${nextNode?.type === 'penalty' && nextNode.penalty === 100 ? '-' : ''}`; - this.drawWord(ctx, value, x, baseY, lineRecord, wordIndex); - x += Number(node.width || ctx.measureText(value).width || 0); - wordIndex += 1; + const value = String(node.value); + const width = Number(node.width || ctx.measureText(value).width || 0); + if (currentSegment && !previousWasGlue) { + currentSegment.value += value; + currentSegment.width += width; + } else { + currentSegment = { + value, + x, + width, + wordIndex: segments.length + }; + segments.push(currentSegment); + } + x += width; + previousWasGlue = false; } else if (node.type === 'glue' && node.width !== 0) { let width = Number(node.width || 0); if (ratio > 0) width += Number(node.stretch || 0) * ratio; if (ratio < 0) width += Number(node.shrink || 0) * ratio; x += width; + previousWasGlue = true; + currentSegment = null; + } else if (node.type === 'penalty' && node.penalty === 100) { + const isLineEndHyphen = Boolean(line.hyphenated && index === nodes.length - 1 && currentSegment); + if (isLineEndHyphen) { + const hyphenWidth = Number(node.width || ctx.measureText('-').width || 0); + currentSegment.value += '-'; + currentSegment.width += hyphenWidth; + x += hyphenWidth; + } + previousWasGlue = false; } }); + + return segments; } drawWord(ctx, value, x, baseY, lineRecord, localWordIndex) { + const alpha = this.getWordAlpha(lineRecord, localWordIndex); + if (alpha <= 0) return; + const previousAlpha = ctx.globalAlpha; + ctx.globalAlpha = previousAlpha * alpha; + ctx.fillText(value, x, baseY); + ctx.globalAlpha = previousAlpha; + } + + getWordAlpha(lineRecord, localWordIndex) { const animation = this.activeAnimations.get(String(lineRecord.blockId ?? '')); if (!animation) { - ctx.globalAlpha = 1; - ctx.fillText(value, x, baseY); - return; + return 1; } const globalWordIndex = Number(lineRecord.blockWordStart || 0) + localWordIndex; const timing = animation.wordTimings[globalWordIndex]; if (!timing) { - ctx.globalAlpha = animation.completed ? 1 : 0; - ctx.fillText(value, x, baseY); - ctx.globalAlpha = 1; - return; + return animation.completed ? 1 : 0; } const elapsed = animation.completed @@ -210,12 +265,7 @@ class BookTextureRendererModule extends BaseModule { : performance.now() - animation.startedAt; const duration = Math.max(1, Number(timing.duration || 1)); const progress = Math.max(0, Math.min(1, (elapsed - Number(timing.delay || 0)) / duration)); - if (progress <= 0) return; - - const previousAlpha = ctx.globalAlpha; - ctx.globalAlpha = previousAlpha * progress; - ctx.fillText(value, x, baseY); - ctx.globalAlpha = previousAlpha; + return progress; } startRevealAnimation(detail = {}) { @@ -227,6 +277,7 @@ class BookTextureRendererModule extends BaseModule { startedAt: performance.now(), completed: false }); + this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId)); this.requestAnimationFrame(); } @@ -235,11 +286,12 @@ class BookTextureRendererModule extends BaseModule { this.activeAnimations.forEach((animation) => { if (!animation.completed) { animation.completed = true; + this.revealedBlockIds.add(String(animation.blockId ?? '')); changed = true; } }); if (changed) { - this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); + this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getAnimatedSides(true)); } } @@ -252,6 +304,39 @@ class BookTextureRendererModule extends BaseModule { this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); } + getBlockSides(blockId) { + const id = String(blockId ?? ''); + const spread = this.currentSpread || this.pagination?.getCurrentSpread?.() || { left: [], right: [] }; + return ['left', 'right'].filter((side) => { + const lines = Array.isArray(spread?.[side]) ? spread[side] : []; + return lines.some(line => String(line?.blockId ?? '') === id); + }); + } + + getAnimatedSides(includeCompleted = false) { + const spread = this.currentSpread || this.pagination?.getCurrentSpread?.() || { left: [], right: [] }; + const activeBlockIds = new Set(); + this.activeAnimations.forEach((animation, blockId) => { + if (includeCompleted || !animation.completed) activeBlockIds.add(String(blockId)); + }); + const sides = ['left', 'right'].filter((side) => { + const lines = Array.isArray(spread?.[side]) ? spread[side] : []; + return lines.some(line => activeBlockIds.has(String(line?.blockId ?? ''))); + }); + return sides.length ? sides : ['left', 'right']; + } + + markPendingReveal(blockId) { + const id = String(blockId ?? ''); + if (!id || this.activeAnimations.has(id) || this.revealedBlockIds.has(id)) return; + this.activeAnimations.set(id, { + blockId, + wordTimings: [], + startedAt: performance.now(), + completed: false + }); + } + requestAnimationFrame() { if (this.animationFrameId) return; this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs); @@ -269,26 +354,30 @@ class BookTextureRendererModule extends BaseModule { const currentNow = performance.now(); this.activeAnimations.forEach((animation) => { if (animation.completed) return; + if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return; const lastTiming = animation.wordTimings.at(-1); const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0); if (currentNow - animation.startedAt >= total + 50) { animation.completed = true; + this.revealedBlockIds.add(String(animation.blockId ?? '')); } else { hasActive = true; } }); - this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); + this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getAnimatedSides(true)); if (hasActive) this.requestAnimationFrame(); } - publishSpread() { + publishSpread(sides = null) { + const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; + const detail = { + metrics: this.metrics, + hitMaps: this.hitMaps + }; + if (sidesToPublish.includes('left')) detail.left = this.canvases.left; + if (sidesToPublish.includes('right')) detail.right = this.canvases.right; document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { - detail: { - left: this.canvases.left, - right: this.canvases.right, - metrics: this.metrics, - hitMaps: this.hitMaps - } + detail })); } diff --git a/public/js/loader.js b/public/js/loader.js index 68ec8ea..39f0a2d 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -24,7 +24,7 @@ const ModuleState = { ERROR: 'ERROR' }; -const MODULE_CACHE_BUSTER = '20260606-webgl-texture-dropcap-animation'; +const MODULE_CACHE_BUSTER = '20260606-webgl-texture-refresh-fix'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** diff --git a/public/js/procedural-book-model.js b/public/js/procedural-book-model.js index 3016066..577b273 100644 --- a/public/js/procedural-book-model.js +++ b/public/js/procedural-book-model.js @@ -9,7 +9,7 @@ export const PROCEDURAL_BOOK = { PAGE_WIDTH: 2.24 * 2 / 3, COVER_DEPTH: 2.30, OPEN_SEAM_GAP: 0.003, - PAGE_TEXTURE_FORE_EDGE_INSET_RATIO: 0.075, + PAGE_TEXTURE_FORE_EDGE_INSET_RATIO: 0.12, PROFILE: { tableY: 0, coverThickness: 0.03, diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index afa8547..bf1af60 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -4,7 +4,7 @@ import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js'; import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js'; import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js'; -import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260606-webgl-texture-dropcap-animation'; +import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260606-webgl-texture-refresh-fix'; const canvas = document.getElementById('scene'); canvas.style.cursor = 'grab'; @@ -25,7 +25,7 @@ const appInitialState = window.WebGLBookInitialState || {}; const tableDebugName = urlParams.get('tableDebug') || 'none'; const tableDebugMode = tableDebugModes[tableDebugName] ?? tableDebugModes.none; const isAppIntegrationMode = appInitialState.appMode === true; -const appRenderPixelRatio = isAppIntegrationMode ? 0.5 : Math.min(window.devicePixelRatio || 1, 2); +const appRenderPixelRatio = isAppIntegrationMode ? 1 : Math.min(window.devicePixelRatio || 1, 2); const labStatus = document.getElementById('lab_status'); if (labStatus && tableDebugMode !== tableDebugModes.none) { labStatus.textContent = tableDebugName === 'ao' ? 'scene debug: SSAO' : `table debug: ${tableDebugName}`; @@ -40,7 +40,7 @@ renderer.shadowMap.type = THREE.VSMShadowMap; const generatedTextureCanvases = {}; const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy(); -const reflectionPixelRatio = isAppIntegrationMode ? 0.28 : Math.min(window.devicePixelRatio || 1, 2); +const reflectionPixelRatio = isAppIntegrationMode ? 0.5 : Math.min(window.devicePixelRatio || 1, 2); const pageTextureWidth = isAppIntegrationMode ? 1280 : 3200; const reflectionTargetSize = new THREE.Vector2(); const pageRaycaster = new THREE.Raycaster(); @@ -65,8 +65,8 @@ let tableDustTexture = null; let tableGreaseTexture = null; const tableTopY = -0.02; const bookTableContactClearance = 0.002; -const tableReflectionBaseWidth = isAppIntegrationMode ? 480 : 4096; -const tableReflectionBaseHeight = isAppIntegrationMode ? 270 : 2304; +const tableReflectionBaseWidth = isAppIntegrationMode ? 640 : 4096; +const tableReflectionBaseHeight = isAppIntegrationMode ? 360 : 2304; const tableReflectionTarget = new THREE.WebGLRenderTarget(tableReflectionBaseWidth, tableReflectionBaseHeight, { colorSpace: THREE.SRGBColorSpace, depthBuffer: true, @@ -90,7 +90,7 @@ const reflectionUp = new THREE.Vector3(); const candleShadowSources = []; const candleWorldPosition = new THREE.Vector3(); const flameWorldPosition = new THREE.Vector3(); -const bookShadowMapSize = isAppIntegrationMode ? 128 : 1536; +const bookShadowMapSize = isAppIntegrationMode ? 256 : 1536; const bookShadowTargets = Array.from({ length: 3 }, () => { const target = new THREE.WebGLRenderTarget(bookShadowMapSize, bookShadowMapSize, { colorSpace: THREE.NoColorSpace, @@ -179,9 +179,9 @@ const rightTexture = new THREE.CanvasTexture(rightCanvas); [leftTexture, rightTexture].forEach((texture) => { texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = maxTextureAnisotropy; - texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; - texture.generateMipmaps = true; + texture.generateMipmaps = false; }); const leatherTextures = createLeatherTextures(); const spineClothTextures = createSpineClothTextures(); diff --git a/public/js/webgl-book-scene-module.js b/public/js/webgl-book-scene-module.js index 3c9407e..64081e9 100644 --- a/public/js/webgl-book-scene-module.js +++ b/public/js/webgl-book-scene-module.js @@ -484,28 +484,12 @@ class WebGLBookSceneModule extends BaseModule { handleProcessState(event) { const state = event.detail?.state || 'ready'; - if (state === 'ready' || state === 'paused' || this.mode !== '3d') { - this.stopAnimatedTextureRefresh(); - this.triggerTextureRefresh(); - return; - } - this.startAnimatedTextureRefresh(); + this.stopAnimatedTextureRefresh(); + if (state === 'ready' || state === 'paused' || this.mode !== '3d') this.triggerTextureRefresh(); } startAnimatedTextureRefresh() { - if (this.textureRefreshAnimationId) return; - const tick = (now) => { - if (this.mode !== '3d') { - this.textureRefreshAnimationId = null; - return; - } - if (now - this.lastAnimatedTextureRefresh > 100) { - this.lastAnimatedTextureRefresh = now; - window.BookLabDebug?.redrawPageTextures?.(); - } - this.textureRefreshAnimationId = window.requestAnimationFrame(tick); - }; - this.textureRefreshAnimationId = window.requestAnimationFrame(tick); + this.stopAnimatedTextureRefresh(); } stopAnimatedTextureRefresh() {