Files
ai.interactive.fiction/public/js/book-texture-renderer-module.js
T

305 lines
11 KiB
JavaScript

/**
* Book Texture Renderer Module
* Draws the virtual book pages directly into texture-space canvases.
*/
import { BaseModule } from './base-module.js';
class BookTextureRendererModule extends BaseModule {
constructor() {
super('book-texture-renderer', 'Book Texture Renderer');
this.dependencies = ['book-page-format', 'book-pagination', 'localization'];
this.pageFormat = null;
this.pagination = null;
this.localization = null;
this.metrics = null;
this.canvases = {
left: null,
right: null
};
this.contexts = {
left: null,
right: null
};
this.hitMaps = {
left: [],
right: []
};
this.currentSpread = null;
this.activeAnimations = new Map();
this.animationFrameId = null;
this.lastAnimationFrameAt = 0;
this.targetFrameDurationMs = 1000 / 30;
this.bindMethods([
'initialize',
'createPageCanvases',
'drawEmptySpread',
'drawSpread',
'drawPageBase',
'drawPageLines',
'drawLine',
'drawWord',
'startRevealAnimation',
'fastForwardAnimations',
'stopAnimations',
'requestAnimationFrame',
'tickAnimations',
'publishSpread',
'getPageCanvas',
'getHitMap',
'handleSceneReady'
]);
}
async initialize() {
this.pageFormat = this.getModule('book-page-format');
this.pagination = this.getModule('book-pagination');
this.localization = this.getModule('localization');
this.reportProgress(20, 'Preparing page texture canvases');
this.createPageCanvases();
this.drawEmptySpread();
this.addEventListener(document, 'webgl-book:scene-ready', this.handleSceneReady);
this.addEventListener(document, 'book-pagination:spread-updated', (event) => {
this.drawSpread(event.detail?.spread || this.pagination?.getCurrentSpread?.());
});
this.addEventListener(document, 'book-texture:reveal-block', (event) => {
this.startRevealAnimation(event.detail || {});
});
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
this.addEventListener(document, 'ui:command', (event) => {
if (event.detail?.type === 'continue') this.fastForwardAnimations();
});
this.addEventListener(document, 'story:manual-scroll', this.fastForwardAnimations);
this.addEventListener(document, 'story:history-restoring', this.stopAnimations);
this.reportProgress(100, 'Book texture renderer ready');
return true;
}
createPageCanvases(textureWidth = 1280) {
this.metrics = this.pageFormat.getTextureMetrics(textureWidth);
['left', 'right'].forEach((side) => {
const canvas = document.createElement('canvas');
canvas.width = this.metrics.width;
canvas.height = this.metrics.height;
this.canvases[side] = canvas;
this.contexts[side] = canvas.getContext('2d');
});
}
drawEmptySpread() {
this.drawPageBase('left');
this.drawPageBase('right');
this.publishSpread();
}
drawSpread(spread = 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();
}
drawPageBase(side) {
const canvas = this.canvases[side];
const ctx = this.contexts[side];
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff7dc';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const shade = ctx.createLinearGradient(0, 0, canvas.width, 0);
if (side === 'left') {
shade.addColorStop(0, 'rgba(255, 255, 255, 0.10)');
shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(82, 42, 14, 0.16)');
} else {
shade.addColorStop(0, 'rgba(82, 42, 14, 0.16)');
shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(255, 255, 255, 0.10)');
}
ctx.fillStyle = shade;
ctx.fillRect(0, 0, canvas.width, canvas.height);
this.hitMaps[side] = [];
}
drawPageLines(side, lines = []) {
const ctx = this.contexts[side];
if (!ctx || !this.metrics || !Array.isArray(lines)) return;
ctx.save();
ctx.fillStyle = 'rgba(31, 19, 10, 0.86)';
ctx.textBaseline = 'alphabetic';
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
lines.forEach(line => this.drawLine(ctx, line));
ctx.restore();
}
drawLine(ctx, lineRecord = {}) {
const metrics = this.metrics;
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30));
const fontStyle = lineRecord.fontStyle === 'italic' ? 'italic ' : '';
const line = lineRecord.line || {};
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
const baseY = metrics.content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx;
const ratio = line.isFinal || line.align === 'center' ? 0 : Number(line.ratio || 0);
const naturalWidth = nodes.reduce((sum, node) => {
if (node.type === 'box' || node.type === 'glue') return sum + Number(node.width || 0);
return sum;
}, 0);
const centerOffset = line.align === 'center'
? Math.max(0, (metrics.content.width - naturalWidth) / 2)
: Number(line.offset || 0);
let x = metrics.content.x + centerOffset;
let wordIndex = 0;
ctx.font = `${fontStyle}${fontPx}px ${metrics.typography.fontFamily}`;
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;
} 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;
}
});
}
drawWord(ctx, value, x, baseY, lineRecord, localWordIndex) {
const animation = this.activeAnimations.get(String(lineRecord.blockId ?? ''));
if (!animation) {
ctx.globalAlpha = 1;
ctx.fillText(value, x, baseY);
return;
}
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;
}
const elapsed = animation.completed
? Number.POSITIVE_INFINITY
: 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;
}
startRevealAnimation(detail = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
this.activeAnimations.set(String(blockId), {
blockId,
wordTimings: detail.wordTimings,
startedAt: performance.now(),
completed: false
});
this.requestAnimationFrame();
}
fastForwardAnimations() {
let changed = false;
this.activeAnimations.forEach((animation) => {
if (!animation.completed) {
animation.completed = true;
changed = true;
}
});
if (changed) {
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
}
}
stopAnimations() {
this.activeAnimations.clear();
if (this.animationFrameId) {
clearTimeout(this.animationFrameId);
this.animationFrameId = null;
}
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
}
requestAnimationFrame() {
if (this.animationFrameId) return;
this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs);
}
tickAnimations(now) {
this.animationFrameId = null;
if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) {
this.requestAnimationFrame();
return;
}
this.lastAnimationFrameAt = now;
let hasActive = false;
const currentNow = performance.now();
this.activeAnimations.forEach((animation) => {
if (animation.completed) 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;
} else {
hasActive = true;
}
});
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
if (hasActive) this.requestAnimationFrame();
}
publishSpread() {
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
detail: {
left: this.canvases.left,
right: this.canvases.right,
metrics: this.metrics,
hitMaps: this.hitMaps
}
}));
}
getPageCanvas(side) {
return this.canvases[side] || null;
}
getHitMap(side) {
return this.hitMaps[side] || [];
}
handleSceneReady() {
this.publishSpread();
}
}
const bookTextureRenderer = new BookTextureRendererModule();
export { bookTextureRenderer as BookTextureRenderer };
if (window.moduleRegistry) {
window.moduleRegistry.register(bookTextureRenderer);
}
window.BookTextureRenderer = bookTextureRenderer;