Files
ai.interactive.fiction/public/js/webgl-book-scene-module.js
T

501 lines
19 KiB
JavaScript

/**
* WebGL Book Scene Module
* Hosts the procedural WebGL book lab scene inside the app shell.
*/
import { BaseModule } from './base-module.js';
const DEFAULT_BOOK_PAGE_COUNT = 300;
const DEFAULT_BOOK_PROGRESS = 0.5;
class WebGLBookSceneModule extends BaseModule {
constructor() {
super('webgl-book-scene', 'WebGL Book Scene');
this.dependencies = ['persistence-manager', 'localization'];
this.persistenceManager = null;
this.localization = null;
this.mode = '2d';
this.is3dSupported = false;
this.labImportPromise = null;
this.textureRefreshTimer = null;
this.textureRefreshAnimationId = null;
this.lastAnimatedTextureRefresh = 0;
this.preferenceWriteGuard = false;
this.projectedHoverTarget = null;
this.projectedEventClient = null;
this.originalBookInlineStyle = null;
this.originalPageInlineStyles = new Map();
this.bindMethods([
'ensureShell',
'initializeScene',
'detectWebGLSupport',
'createLabHost',
'installPreferenceBridge',
'installTextureEventBridge',
'applyMode',
'adoptPageContent',
'moveBookOffscreen',
'restoreBookPlacement',
'refreshModalOverview',
'triggerTextureRefresh',
'startAnimatedTextureRefresh',
'stopAnimatedTextureRefresh',
'handleProcessState',
'updateLocalizedText',
'handlePreferenceUpdated'
]);
}
async initialize() {
this.persistenceManager = this.getModule('persistence-manager');
this.localization = this.getModule('localization');
this.reportProgress(15, 'Checking WebGL support');
this.is3dSupported = this.detectWebGLSupport();
this.initializeScenePreferences();
this.mode = this.resolveInitialMode();
this.applyMode();
this.addEventListener(document, 'preference-updated', this.handlePreferenceUpdated);
this.addEventListener(document, 'localization:languageChanged', this.updateLocalizedText);
this.addEventListener(document, 'story:turn-start', this.triggerTextureRefresh);
this.addEventListener(document, 'story:turn-complete', this.triggerTextureRefresh);
this.addEventListener(document, 'story:history-updated', this.triggerTextureRefresh);
this.addEventListener(document, 'story:process-state', this.handleProcessState);
this.addEventListener(document, 'input', this.triggerTextureRefresh, true);
this.addEventListener(document, 'change', this.triggerTextureRefresh, true);
if (this.mode !== '3d') {
this.reportProgress(100, '2D book UI selected');
return true;
}
this.reportProgress(35, 'Creating WebGL host');
this.ensureShell();
this.installPreferenceBridge();
this.reportProgress(100, 'WebGL book host ready');
return true;
}
initializeScenePreferences() {
if (!this.persistenceManager) return;
const scenePrefs = this.persistenceManager.getPreference('webgl', 'bookPageCount', null);
if (!Number.isFinite(Number(scenePrefs))) {
this.persistenceManager.updatePreference('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT);
}
const progress = this.persistenceManager.getPreference('webgl', 'bookProgress', null);
if (!Number.isFinite(Number(progress))) {
this.persistenceManager.updatePreference('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS);
}
}
resolveInitialMode() {
const storedMode = this.persistenceManager?.getPreference?.('webgl', 'mode', null);
if (storedMode === '2d' || storedMode === '3d') {
return storedMode === '3d' && !this.is3dSupported ? '2d' : storedMode;
}
const defaultMode = this.is3dSupported ? '3d' : '2d';
this.persistenceManager?.updatePreference?.('webgl', 'mode', defaultMode);
return defaultMode;
}
detectWebGLSupport() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (!gl) return false;
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
const program = gl.createProgram();
if (!vertexShader || !fragmentShader || !program) return false;
gl.shaderSource(vertexShader, 'attribute vec2 p; void main(){ gl_Position = vec4(p, 0.0, 1.0); }');
gl.shaderSource(fragmentShader, 'precision mediump float; void main(){ gl_FragColor = vec4(1.0); }');
gl.compileShader(vertexShader);
gl.compileShader(fragmentShader);
const shadersCompile = gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS) &&
gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS);
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
const linked = gl.getProgramParameter(program, gl.LINK_STATUS);
gl.deleteProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return Boolean(shadersCompile && linked);
}
applyMode() {
document.body.dataset.webglUiMode = this.mode;
document.body.classList.toggle('webgl-mode', this.mode === '3d');
const app = document.getElementById('webgl_app');
if (app) app.hidden = this.mode !== '3d';
if (this.mode !== '3d') {
this.restoreBookPlacement();
}
}
ensureShell() {
if (this.mode !== '3d') {
this.applyMode();
return;
}
document.body.classList.add('webgl-mode');
this.createLabHost();
this.updateLocalizedText();
this.refreshModalOverview();
}
createLabHost() {
let app = document.getElementById('webgl_app');
if (!app) {
app = document.createElement('div');
app.id = 'webgl_app';
document.body.prepend(app);
}
app.hidden = false;
let canvas = document.getElementById('scene');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'scene';
canvas.setAttribute('aria-label', this.t('webgl.sceneLabel'));
app.appendChild(canvas);
} else if (canvas.parentElement !== app) {
app.appendChild(canvas);
}
this.moveBookOffscreen();
const pageCount = this.persistenceManager?.getPreference?.('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT) ?? DEFAULT_BOOK_PAGE_COUNT;
const progress = this.persistenceManager?.getPreference?.('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS) ?? DEFAULT_BOOK_PROGRESS;
window.WebGLBookInitialState = {
appMode: true,
pageCount,
progress
};
}
moveBookOffscreen() {
const book = document.getElementById('book');
if (!book) return;
if (this.originalBookInlineStyle === null) {
this.originalBookInlineStyle = book.getAttribute('style') || '';
}
book.style.position = 'fixed';
book.style.left = 'calc(100vw + 50%)';
book.style.top = '50%';
book.style.width = 'var(--book-width)';
book.style.height = 'var(--book-height)';
book.style.transform = 'translate(-50%, -50%) scale(var(--book-scale))';
book.style.transformOrigin = 'center center';
book.style.opacity = '1';
book.style.visibility = 'visible';
this.removePagePerspectiveTransforms();
}
restoreBookPlacement() {
const book = document.getElementById('book');
if (book && this.originalBookInlineStyle !== null) {
if (this.originalBookInlineStyle) {
book.setAttribute('style', this.originalBookInlineStyle);
} else {
book.removeAttribute('style');
}
this.originalBookInlineStyle = null;
}
this.restorePagePerspectiveTransforms();
}
removePagePerspectiveTransforms() {
['page_left', 'page_right'].forEach((id) => {
const page = document.getElementById(id);
if (!page) return;
if (!this.originalPageInlineStyles.has(id)) {
this.originalPageInlineStyles.set(id, page.getAttribute('style') || '');
}
page.style.transform = 'none';
});
}
restorePagePerspectiveTransforms() {
this.originalPageInlineStyles.forEach((style, id) => {
const page = document.getElementById(id);
if (!page) return;
if (style) {
page.setAttribute('style', style);
} else {
page.removeAttribute('style');
}
});
this.originalPageInlineStyles.clear();
}
installPreferenceBridge() {
window.WebGLBookPreferenceBridge = {
updateProgress: (value) => {
if (this.preferenceWriteGuard) return;
this.preferenceWriteGuard = true;
this.persistenceManager?.updatePreference?.('webgl', 'bookProgress', value);
this.preferenceWriteGuard = false;
},
updatePageCount: (value) => {
if (this.preferenceWriteGuard) return;
this.preferenceWriteGuard = true;
this.persistenceManager?.updatePreference?.('webgl', 'bookPageCount', value);
this.preferenceWriteGuard = false;
}
};
}
async initializeScene() {
if (this.labImportPromise) return this.labImportPromise;
const cacheBuster = window.MODULE_CACHE_BUSTER || Date.now();
this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(cacheBuster)}`);
await this.labImportPromise;
this.installTextureEventBridge();
this.triggerTextureRefresh();
return this.labImportPromise;
}
installTextureEventBridge() {
const canvas = document.getElementById('scene');
if (!canvas || canvas.dataset.webglTextureEventsBound) return;
canvas.dataset.webglTextureEventsBound = 'true';
['click', 'dblclick', 'pointermove', 'mousemove', 'pointerdown', 'pointerup'].forEach((type) => {
canvas.addEventListener(type, (event) => {
if (event.button === 2) return;
const target = this.projectCanvasEventTarget(event);
if (!target && (type === 'pointermove' || type === 'mousemove')) {
this.updateProjectedHover(null, event);
return;
}
if (!target) return;
if (type === 'pointermove' || type === 'mousemove') {
this.updateProjectedHover(target, event);
}
if (type === 'click' && this.isNativeClickTarget(target)) {
target.click();
return;
}
const replay = this.createProjectedEvent(type, event);
target.dispatchEvent(replay);
});
});
}
createProjectedEvent(type, event) {
const eventOptions = {
bubbles: true,
cancelable: true,
clientX: this.projectedEventClient?.x ?? event.clientX,
clientY: this.projectedEventClient?.y ?? event.clientY,
button: event.button,
buttons: event.buttons
};
if (type.startsWith('pointer') && typeof PointerEvent === 'function') {
return new PointerEvent(type, {
...eventOptions,
pointerId: event.pointerId,
pointerType: event.pointerType,
isPrimary: event.isPrimary
});
}
return new MouseEvent(type, eventOptions);
}
isNativeClickTarget(target) {
return !!target?.matches?.('a, button, input, textarea, select, summary, label, [role="button"], [tabindex]');
}
updateProjectedHover(target, event) {
if (target === this.projectedHoverTarget) return;
if (this.projectedHoverTarget) {
this.projectedHoverTarget.dispatchEvent(new MouseEvent('mouseleave', {
bubbles: false,
cancelable: true,
clientX: this.projectedEventClient?.x ?? event.clientX,
clientY: this.projectedEventClient?.y ?? event.clientY
}));
}
this.projectedHoverTarget = target;
if (target) {
['mouseover', 'mouseenter'].forEach((type) => {
target.dispatchEvent(new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX: this.projectedEventClient?.x ?? event.clientX,
clientY: this.projectedEventClient?.y ?? event.clientY,
button: event.button,
buttons: event.buttons
}));
});
}
}
projectCanvasEventTarget(event) {
const projection = window.BookLabDebug?.projectPointerToPage?.(event.clientX, event.clientY);
if (!projection) {
document.documentElement.dataset.webglLastProjection = JSON.stringify({
hit: false,
eventType: event.type,
clientX: event.clientX,
clientY: event.clientY
});
return null;
}
const pageId = projection.pageId;
const page = document.getElementById(pageId);
if (!page) {
document.documentElement.dataset.webglLastProjection = JSON.stringify({
hit: true,
pageId,
missingPage: true
});
return null;
}
const pageRect = page.getBoundingClientRect();
const pageX = pageRect.left + projection.x * pageRect.width;
const pageY = pageRect.top + projection.y * pageRect.height;
this.projectedEventClient = { x: pageX, y: pageY };
const target = this.findProjectedPageTarget(page, pageX, pageY);
document.documentElement.dataset.webglLastProjection = JSON.stringify({
hit: true,
eventType: event.type,
pageId,
x: Number(projection.x.toFixed(4)),
y: Number(projection.y.toFixed(4)),
uv: projection.uv
? {
x: Number(projection.uv.x.toFixed(4)),
y: Number(projection.uv.y.toFixed(4))
}
: null,
targetId: target.id || '',
targetTag: target.tagName || '',
targetClass: target.className || '',
targetText: (target.textContent || '').trim().slice(0, 120)
});
return page.contains(target) ? target : page;
}
findProjectedPageTarget(page, pageX, pageY) {
let target = page;
const candidates = page.querySelectorAll('*');
candidates.forEach((element) => {
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden') return;
const opacity = Number.parseFloat(style.opacity);
if (Number.isFinite(opacity) && opacity <= 0.005) return;
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
if (pageX < rect.left || pageX > rect.right || pageY < rect.top || pageY > rect.bottom) return;
target = element;
});
return target.closest?.('a, button, input, textarea, select, [role="button"], .story-glossary-word') || target;
}
handlePreferenceUpdated(event) {
const { category, key, value } = event.detail || {};
if (category !== 'webgl') return;
if (key === 'mode') {
const nextMode = value === '3d' && this.is3dSupported ? '3d' : '2d';
if (nextMode === this.mode) return;
this.mode = nextMode;
this.applyMode();
if (this.mode === '3d') {
this.ensureShell();
this.installPreferenceBridge();
this.initializeScene();
}
} else if (key === 'bookProgress' && !this.preferenceWriteGuard) {
window.BookLabDebug?.setReadingProgress?.(value);
} else if (key === 'bookPageCount' && !this.preferenceWriteGuard) {
window.BookLabDebug?.setBookPageCount?.(value);
}
}
adoptPageContent() {
if (this.mode === '3d') {
this.createLabHost();
this.installPreferenceBridge();
this.initializeScene()
.then(() => this.triggerTextureRefresh())
.catch((error) => {
console.error('WebGLBookScene: Failed to initialize procedural scene', error);
});
}
const title = document.getElementById('game_title')?.textContent?.trim();
const label = document.getElementById('lab_title');
if (title && label) label.textContent = title;
this.triggerTextureRefresh();
}
refreshModalOverview() {
this.updateLocalizedText();
}
triggerTextureRefresh() {
clearTimeout(this.textureRefreshTimer);
this.textureRefreshTimer = setTimeout(() => {
window.BookLabDebug?.redrawPageTextures?.();
}, 60);
}
handleProcessState(event) {
const state = event.detail?.state || 'ready';
if (state === 'ready' || state === 'paused' || this.mode !== '3d') {
this.stopAnimatedTextureRefresh();
this.triggerTextureRefresh();
return;
}
this.startAnimatedTextureRefresh();
}
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);
}
stopAnimatedTextureRefresh() {
if (!this.textureRefreshAnimationId) return;
window.cancelAnimationFrame(this.textureRefreshAnimationId);
this.textureRefreshAnimationId = null;
}
updateLocalizedText() {
const setText = (id, key) => {
const element = document.getElementById(id);
if (element) element.textContent = this.t(key);
};
setText('lab_title', 'webgl.title');
setText('lab_status', this.mode === '3d' ? 'webgl.status3d' : 'webgl.status2d');
}
t(key, params = {}) {
return this.localization?.translate?.(key, params) || key;
}
}
const WebGLBookScene = new WebGLBookSceneModule();
export { WebGLBookScene };
if (window.moduleRegistry) {
window.moduleRegistry.register(WebGLBookScene);
}
window.WebGLBookScene = WebGLBookScene;