522 lines
20 KiB
JavaScript
522 lines
20 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', 'book-texture-renderer'];
|
|
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',
|
|
'moveBookToControlOverlay',
|
|
'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);
|
|
|
|
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(45, 'Loading WebGL scene modules');
|
|
await this.initializeScene();
|
|
|
|
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.moveBookToControlOverlay();
|
|
|
|
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,
|
|
reportProgress: (percent, message) => {
|
|
this.reportProgress(percent, message);
|
|
}
|
|
};
|
|
}
|
|
|
|
moveBookToControlOverlay() {
|
|
const book = document.getElementById('book');
|
|
if (!book) return;
|
|
if (this.originalBookInlineStyle === null) {
|
|
this.originalBookInlineStyle = book.getAttribute('style') || '';
|
|
}
|
|
book.style.position = 'fixed';
|
|
book.style.left = '1rem';
|
|
book.style.top = '1rem';
|
|
book.style.width = 'min(31rem, calc(100vw - 2rem))';
|
|
book.style.height = 'min(27rem, calc(100vh - 2rem))';
|
|
book.style.background = 'rgba(18, 11, 8, 0.62)';
|
|
book.style.border = '1px solid rgba(240, 205, 142, 0.28)';
|
|
book.style.boxShadow = '0 1.2rem 3rem rgba(0, 0, 0, 0.42)';
|
|
book.style.backdropFilter = 'blur(5px)';
|
|
book.style.transform = 'none';
|
|
book.style.transformOrigin = 'top left';
|
|
book.style.opacity = '1';
|
|
book.style.visibility = 'visible';
|
|
book.style.zIndex = '40';
|
|
book.style.pointerEvents = 'none';
|
|
this.removePagePerspectiveTransforms();
|
|
this.positionOverlayPages();
|
|
}
|
|
|
|
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';
|
|
});
|
|
}
|
|
|
|
positionOverlayPages() {
|
|
const pageLeft = document.getElementById('page_left');
|
|
if (pageLeft) {
|
|
Object.assign(pageLeft.style, {
|
|
position: 'absolute',
|
|
inset: '0',
|
|
width: 'auto',
|
|
height: 'auto',
|
|
padding: '1rem',
|
|
overflow: 'auto',
|
|
opacity: '1',
|
|
mixBlendMode: 'normal',
|
|
clipPath: 'none',
|
|
pointerEvents: 'auto'
|
|
});
|
|
}
|
|
|
|
const pageRight = document.getElementById('page_right');
|
|
if (pageRight) {
|
|
Object.assign(pageRight.style, {
|
|
position: 'fixed',
|
|
left: 'calc(100vw + 2rem)',
|
|
top: '0',
|
|
width: 'var(--book-right-page-width)',
|
|
height: 'var(--book-page-height)',
|
|
opacity: '1',
|
|
visibility: 'visible',
|
|
pointerEvents: '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.reportProgress(94, 'Uploading initial book page textures');
|
|
window.BookTextureRenderer?.publishSpread?.();
|
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
this.reportProgress(96, 'Binding WebGL page controls');
|
|
this.installTextureEventBridge();
|
|
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;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
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();
|
|
}
|
|
const title = document.getElementById('game_title')?.textContent?.trim();
|
|
const label = document.getElementById('lab_title');
|
|
if (title && label) label.textContent = title;
|
|
}
|
|
|
|
refreshModalOverview() {
|
|
this.updateLocalizedText();
|
|
}
|
|
|
|
triggerTextureRefresh() {
|
|
clearTimeout(this.textureRefreshTimer);
|
|
this.textureRefreshTimer = setTimeout(() => {
|
|
window.BookLabDebug?.redrawPageTextures?.();
|
|
}, 60);
|
|
}
|
|
|
|
handleProcessState(event) {
|
|
const state = event.detail?.state || 'ready';
|
|
this.stopAnimatedTextureRefresh();
|
|
if (state === 'ready' || state === 'paused' || this.mode !== '3d') this.triggerTextureRefresh();
|
|
}
|
|
|
|
startAnimatedTextureRefresh() {
|
|
this.stopAnimatedTextureRefresh();
|
|
}
|
|
|
|
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;
|